@samitouri / QOS-React-2 / commits / 3d050805e8

[Fizz] Construct the render lifetime controller only when it is needed (#37357)

This follows #37315, which added the render lifetime controller to bound the abort listener that `attachAbortSignal` attaches to a caller's signal. `RequestInstance` constructed one for every request, so a render that is given no signal allocated a controller, aborted it on completion, and nothing ever observed either. The controller is now created in `attachAbortSignal`, and the three places that end the lifetime go through `endRenderLifetime`, which does nothing when there is no controller. Callers that pass a signal are unaffected. Callers that do not no longer allocate one, and `signal` is optional in every browser, edge and static entry point, while `renderToPipeableStream` and `resumeToPipeableStream` accept no signal at all. They also no longer reach `AbortController` at all, which matters more than the allocation. Fizz had no runtime dependency on it before #37315, and an unconditional one reaches environments that provide the API through a polyfill. An incomplete polyfill can then fail a render that never asked for abort support. The new test asserts that no controller is constructed when no signal is passed. It fails with the eager construction restored, since nothing else in the suite would notice a regression to it.

Hendrik Liebau committed Aug 24, 2026 at 19:46 UTC 3d050805e802e6c340d2f0c0962dd5a1616a44a4
2 files changed +43 -6
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+26
@@ -1584,6 +1584,32 @@ describe('ReactDOMFizzStaticBrowser', () => {
1584 expect(lifetimes[0].aborted).toBe(true);
1585 });
1586
1587 + it('constructs no abort controller when no signal is passed', async () => {
1588 + // The render lifetime exists only to bound the caller's abort listener,
1589 + // so a render that is given no signal has nothing to bound and should not
1590 + // pay for a controller. It also means such a render never requires
1591 + // AbortController to exist or to work.
1592 + const RealAbortController = globalThis.AbortController;
1593 + let constructed = 0;
1594 + globalThis.AbortController = class extends RealAbortController {
1595 + constructor() {
1596 + super();
1597 + constructed++;
1598 + }
1599 + };
1600 +
1601 + try {
1602 + const stream = await serverAct(() =>
1603 + ReactDOMFizzServer.renderToReadableStream(<div>hello world</div>),
1604 + );
1605 + await readContent(stream);
1606 + } finally {
1607 + globalThis.AbortController = RealAbortController;
1608 + }
1609 +
1610 + expect(constructed).toBe(0);
1611 + });
1612 +
1613 it('attaches no listener when the signal is already aborted', async () => {
1614 const controller = new AbortController();
1615 controller.abort();
packages/react-server/src/ReactFizzServer.js
+17 -6
@@ -435,7 +435,9 @@ export opaque type Request = {
435 onFatalError: (error: mixed) => void,
436 // Aborted once the render ends, whether it completed, failed fatally or was
437 // aborted. Bounds the lifetime of anything that must not outlive the render.
438 - renderLifetimeController: AbortController,
438 + // Null until attachAbortSignal creates it, so a render that is given no
439 + // signal constructs no controller.
440 + renderLifetimeController: null | AbortController,
441 // Form state that was the result of an MPA submission, if it was provided.
442 formState: null | ReactFormState<any, any>,
443 // DEV-only, warning dedupe
@@ -589,7 +591,7 @@ function RequestInstance(
591 this.onShellReady = onShellReady === undefined ? noop : onShellReady;
592 this.onShellError = onShellError === undefined ? noop : onShellError;
593 this.onFatalError = onFatalError === undefined ? noop : onFatalError;
592 - this.renderLifetimeController = new AbortController();
594 + this.renderLifetimeController = null;
595 this.formState = formState === undefined ? null : formState;
596 if (__DEV__) {
597 this.didWarnForKey = null;
@@ -1455,7 +1457,7 @@ function fatalError(
1457 }
1458 onFatalError(error);
1459 }
1458 - request.renderLifetimeController.abort(RENDER_ENDED);
1460 + endRenderLifetime(request);
1461 if (request.destination !== null) {
1462 request.status = CLOSED;
1463 closeWithError(request.destination, error);
@@ -6352,7 +6354,7 @@ function flushCompletedQueues(
6354 }
6355 }
6356 // We're done.
6355 - request.renderLifetimeController.abort(RENDER_ENDED);
6357 + endRenderLifetime(request);
6358 request.status = CLOSED;
6359 close(destination);
6360 // We need to stop flowing now because we do not want any async contexts which might call
@@ -6516,6 +6518,13 @@ function finishAbort(request: Request, abortableTasks: Set<Task>): void {
6518 }
6519 }
6520
6521 +function endRenderLifetime(request: Request): void {
6522 + const renderLifetimeController = request.renderLifetimeController;
6523 + if (renderLifetimeController !== null) {
6524 + renderLifetimeController.abort(RENDER_ENDED);
6525 + }
6526 +}
6527 +
6528 // Aborts the request when the caller's signal aborts. The render lifetime
6529 // bounds the listener, so the runtime removes the listener as soon as the
6530 // render ends. From that point on abort() returns early, so the listener has
@@ -6533,12 +6542,14 @@ export function attachAbortSignal(request: Request, signal: AbortSignal): void {
6542 abort(request, signal.reason);
6543 return;
6544 }
6545 + const renderLifetimeController = new AbortController();
6546 + request.renderLifetimeController = renderLifetimeController;
6547 signal.addEventListener(
6548 'abort',
6549 () => {
6550 abort(request, signal.reason);
6551 },
6541 - {signal: request.renderLifetimeController.signal},
6552 + {signal: renderLifetimeController.signal},
6553 );
6554 }
6555
@@ -6552,7 +6563,7 @@ export function abort(request: Request, reason: mixed): void {
6563 // can be aborted. in practice this makes abort callable at most once per render.
6564 return;
6565 }
6555 - request.renderLifetimeController.abort(RENDER_ENDED);
6566 + endRenderLifetime(request);
6567 const isRecoverableReason =
6568 typeof reason === 'object' &&
6569 reason !== null &&