SIGN IN SIGN UP

[Flight/Fizz] Stop the caller's signal from retaining a finished render (#37315)

Every server entry point that accepts a `signal` attached an abort
listener to it and only ever removed that listener from inside the
listener itself. On the success path the signal never aborts, so the
listener stayed attached and its closure kept the whole `Request`, and
therefore the entire rendered output, reachable for as long as the
caller's signal lived.

This matters most for composite signals from `AbortSignal.any()` and for
timeout signals, because the runtime retains those for as long as they
carry a non-weak abort listener, and releases them only when the last
listener is removed or the signal aborts. A composite passed to
`prerender()` therefore became a garbage collection root holding a
finished render for the lifetime of the process. A plain
`AbortController` signal is never retained that way, but it still keeps
the render reachable for as long as the caller holds the controller.

Each listener is now bound to a lifetime signal passed to
`addEventListener`, so the runtime removes the listener as soon as that
signal aborts and nothing has to track a teardown function. Flight
reuses `request.cacheController`, which already aborts on a fatal error,
at the completion of the flush loop (depends on #37342), and in
`abort()`. Fizz has no equivalent, so it gains a
`renderLifetimeController` that aborts at those same three points.
`processReply` creates its controller only when a caller passes a
signal, so a reply without one allocates nothing. Since `abort()`
returns early once the request is past `OPEN`, removing the listener at
those points cannot change observable behavior. The fifty-two copies of
the listener block across the entry points collapse to a single
`attachAbortSignal` call each.

Binding the listener to the render also covers a cancelled stream, which
calls `abort()` without the request ever reaching a terminal status, so
a teardown driven by that status would have left the listener attached.
Fizz ends the lifetime in `fatalError` rather than at the `CLOSING` to
`CLOSED` transition, because a shell error rejects before the caller
receives a stream. Nothing then consumes the request, it never closes,
and a listener waiting for that transition would never come off.

The two new controllers are aborted with an explicit reason. A call to
`abort()` without one constructs an `AbortError` DOMException. Capturing
the stack trace dominates that cost, and the cost grows with the depth
of the stack, so every render and every reply would pay for an object
that no code reads.

`processReply` no longer returns its `abort` function, because that
return value existed only so each `encodeReply` implementation could
wire the signal up itself, and nothing uses it now that the wiring lives
inside. A reply whose model settles synchronously gets no listener,
since aborting it was already a no-op.

The tests assert on the lifetime signal, because the runtime's removal
does not go through `removeEventListener` and is therefore invisible to
a patched signal. `ReactFlightDOMNode-test` asserts the removal itself
with `getEventListeners` from `node:events`, which jsdom has no
equivalent for.

Two cases stay open. A request whose stream is neither consumed nor
cancelled never ends, and a reply with a part that never settles never
settles either, so both keep their listener.
H
Hendrik Liebau committed
77ed3f545226ac9a834db7490a616b650c2a87a8
Parent: eafeac0
Committed by GitHub <noreply@github.com> on 8/22/2026, 3:15:56 PM