@samitouri / QOS-React-2 / commits / 77ed3f5452

[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.

Hendrik Liebau committed Aug 22, 2026 at 17:15 UTC 77ed3f545226ac9a834db7490a616b650c2a87a8
37 files changed +607 -584
packages/react-client/src/ReactFlightReplyClient.js
+67 -4
@@ -39,6 +39,12 @@ import getPrototypeOf from 'shared/getPrototypeOf';
39
40 const ObjectPrototype = Object.prototype;
41
42 +// Passed to replyLifetimeController.abort(). Nothing reads the reason, but a
43 +// call to abort() without one constructs an AbortError DOMException. Capturing
44 +// the stack trace dominates that cost, and the cost grows with the depth of the
45 +// stack.
46 +const REPLY_ENDED = 'The reply ended.';
47 +
48 import {
49 usedWithSSR,
50 checkEvalAvailabilityOnceDev,
@@ -184,14 +190,58 @@ export function processReply(
190 root: ReactServerValue,
191 formFieldPrefix: string,
192 temporaryReferences: void | TemporaryReferenceSet,
187 - resolve: (string | FormData) => void,
188 - reject: (error: mixed) => void,
189 -): (reason: mixed) => void {
193 + onResolve: (string | FormData) => void,
194 + onReject: (error: mixed) => void,
195 + signal: void | AbortSignal,
196 +): void {
197 let nextPartId = 1;
198 let pendingParts = 0;
199 let formData: null | FormData = null;
200 const writtenObjects: WeakMap<Reference, string> = new WeakMap();
201 let modelRoot: null | ReactServerValue = root;
202 + let settled = false;
203 + // Bounds the abort listener that attachAbortSignal attaches to the caller's
204 + // signal. Null until a signal is attached, so a reply that gets no signal
205 + // never creates a controller.
206 + let replyLifetimeController: null | AbortController = null;
207 +
208 + // Ending the lifetime makes the runtime remove the caller's abort listener.
209 + // Without that, the listener keeps everything this reply serialized reachable
210 + // for as long as the caller's signal lives, and a composite signal from
211 + // AbortSignal.any() is itself retained by the runtime while it has any abort
212 + // listener attached.
213 + function endReplyLifetime(): void {
214 + if (replyLifetimeController !== null) {
215 + replyLifetimeController.abort(REPLY_ENDED);
216 + }
217 + }
218 +
219 + function resolve(value: string | FormData): void {
220 + settled = true;
221 + endReplyLifetime();
222 + onResolve(value);
223 + }
224 +
225 + function reject(error: mixed): void {
226 + settled = true;
227 + endReplyLifetime();
228 + onReject(error);
229 + }
230 +
231 + function attachAbortSignal(abortSignal: AbortSignal): void {
232 + if (abortSignal.aborted) {
233 + abort(abortSignal.reason);
234 + return;
235 + }
236 + replyLifetimeController = new AbortController();
237 + abortSignal.addEventListener(
238 + 'abort',
239 + () => {
240 + abort(abortSignal.reason);
241 + },
242 + {signal: replyLifetimeController.signal},
243 + );
244 + }
245
246 if (__DEV__) {
247 // We use eval to create fake function stacks which includes Component stacks.
@@ -894,6 +944,9 @@ export function processReply(
944 }
945
946 function abort(reason: mixed): void {
947 + // Nothing can make the reply pending again from here, so the caller's
948 + // signal has no further effect on it.
949 + endReplyLifetime();
950 if (pendingParts > 0) {
951 pendingParts = 0; // Don't resolve again later.
952 // Resolve with what we have so far, which may have holes at this point.
@@ -920,7 +973,17 @@ export function processReply(
973 }
974 }
975
923 - return abort;
976 + // Wired up after serializing: abort() reads `json` and resolves with the
977 + // parts that finished, so it must not be reachable before then. A reply that
978 + // already settled gets no listener, since aborting it would be a no-op and
979 + // the lifetime that removes the listener has already ended.
980 + //
981 + // TODO: Skip serializing when the signal is already aborted, the way the
982 + // server entry points abort before rendering starts. Needs a decision on what
983 + // to resolve with, since abort() resolves with the parts that finished.
984 + if (signal !== undefined && !settled) {
985 + attachAbortSignal(signal);
986 + }
987 }
988
989 const boundCache: WeakMap<
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+158
@@ -1441,4 +1441,162 @@ describe('ReactDOMFizzStaticBrowser', () => {
1441
1442 expect(getVisibleChildren(container)).toEqual(<div>Hi</div>);
1443 });
1444 +
1445 + describe('abort signal lifetime', () => {
1446 + // Collects the lifetime signal that React bounds each abort listener with.
1447 + // React passes that signal to addEventListener instead of calling
1448 + // removeEventListener, so the runtime performs the removal and nothing here
1449 + // observes it directly. An aborted lifetime is what shows the listener is
1450 + // gone.
1451 + function trackAbortListenerLifetimes(signal) {
1452 + const lifetimes = [];
1453 + const add = signal.addEventListener.bind(signal);
1454 + signal.addEventListener = (type, listener, options) => {
1455 + if (type === 'abort') {
1456 + lifetimes.push(options.signal);
1457 + }
1458 + return add(type, listener, options);
1459 + };
1460 + return lifetimes;
1461 + }
1462 +
1463 + it('detaches the listener when a prerender completes', async () => {
1464 + const controller = new AbortController();
1465 + const lifetimes = trackAbortListenerLifetimes(controller.signal);
1466 +
1467 + const result = await serverAct(() =>
1468 + ReactDOMFizzStatic.prerender(<div>hello world</div>, {
1469 + signal: controller.signal,
1470 + }),
1471 + );
1472 + expect(lifetimes).toHaveLength(1);
1473 + expect(lifetimes[0].aborted).toBe(false);
1474 +
1475 + await readContent(result.prelude);
1476 + expect(lifetimes[0].aborted).toBe(true);
1477 + });
1478 +
1479 + it('detaches the listener when a render completes', async () => {
1480 + const controller = new AbortController();
1481 + const lifetimes = trackAbortListenerLifetimes(controller.signal);
1482 +
1483 + const stream = await serverAct(() =>
1484 + ReactDOMFizzServer.renderToReadableStream(<div>hello world</div>, {
1485 + signal: controller.signal,
1486 + }),
1487 + );
1488 + expect(lifetimes).toHaveLength(1);
1489 + expect(lifetimes[0].aborted).toBe(false);
1490 +
1491 + await readContent(stream);
1492 + expect(lifetimes[0].aborted).toBe(true);
1493 + });
1494 +
1495 + it('detaches the listener when the signal aborts mid-render', async () => {
1496 + let hasLoaded = false;
1497 + let resolve;
1498 + const promise = new Promise(r => (resolve = r));
1499 + function Wait() {
1500 + if (!hasLoaded) {
1501 + throw promise;
1502 + }
1503 + return 'Done';
1504 + }
1505 +
1506 + const controller = new AbortController();
1507 + const lifetimes = trackAbortListenerLifetimes(controller.signal);
1508 +
1509 + const resultPromise = ReactDOMFizzStatic.prerender(
1510 + <div>
1511 + <Suspense fallback="Loading">
1512 + <Wait />
1513 + </Suspense>
1514 + </div>,
1515 + {signal: controller.signal, onError() {}},
1516 + );
1517 + await jest.runAllTimers();
1518 + expect(lifetimes).toHaveLength(1);
1519 + expect(lifetimes[0].aborted).toBe(false);
1520 +
1521 + controller.abort();
1522 + hasLoaded = true;
1523 + resolve();
1524 + await serverAct(() => resultPromise);
1525 +
1526 + expect(lifetimes[0].aborted).toBe(true);
1527 + });
1528 +
1529 + it('detaches the listener when the stream is cancelled', async () => {
1530 + let hasLoaded = false;
1531 + let resolve;
1532 + const promise = new Promise(r => (resolve = r));
1533 + function Wait() {
1534 + if (!hasLoaded) {
1535 + throw promise;
1536 + }
1537 + return 'Done';
1538 + }
1539 +
1540 + const controller = new AbortController();
1541 + const lifetimes = trackAbortListenerLifetimes(controller.signal);
1542 +
1543 + const stream = await serverAct(() =>
1544 + ReactDOMFizzServer.renderToReadableStream(
1545 + <div>
1546 + <Suspense fallback="Loading">
1547 + <Wait />
1548 + </Suspense>
1549 + </div>,
1550 + {signal: controller.signal, onError() {}},
1551 + ),
1552 + );
1553 + expect(lifetimes).toHaveLength(1);
1554 + expect(lifetimes[0].aborted).toBe(false);
1555 +
1556 + await serverAct(() => stream.cancel());
1557 + hasLoaded = true;
1558 + resolve();
1559 +
1560 + expect(lifetimes[0].aborted).toBe(true);
1561 + });
1562 +
1563 + it('detaches the listener when the shell errors', async () => {
1564 + // A shell error rejects before the caller ever receives a stream, so
1565 + // nothing consumes the request and it never closes. The listener has to
1566 + // come off at the fatal error itself.
1567 + function Boom() {
1568 + throw new Error('boom');
1569 + }
1570 +
1571 + const controller = new AbortController();
1572 + const lifetimes = trackAbortListenerLifetimes(controller.signal);
1573 +
1574 + await expect(
1575 + serverAct(() =>
1576 + ReactDOMFizzServer.renderToReadableStream(<Boom />, {
1577 + signal: controller.signal,
1578 + onError() {},
1579 + }),
1580 + ),
1581 + ).rejects.toThrow('boom');
1582 +
1583 + expect(lifetimes).toHaveLength(1);
1584 + expect(lifetimes[0].aborted).toBe(true);
1585 + });
1586 +
1587 + it('attaches no listener when the signal is already aborted', async () => {
1588 + const controller = new AbortController();
1589 + controller.abort();
1590 + const lifetimes = trackAbortListenerLifetimes(controller.signal);
1591 +
1592 + await serverAct(() =>
1593 + ReactDOMFizzStatic.prerender(<div>hello world</div>, {
1594 + signal: controller.signal,
1595 + onError() {},
1596 + }),
1597 + );
1598 +
1599 + expect(lifetimes).toHaveLength(0);
1600 + });
1601 + });
1602 });
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+3 -20
@@ -24,6 +24,7 @@ import {
24 startFlowing,
25 stopFlowing,
26 abort,
27 + attachAbortSignal,
28 } from 'react-server/src/ReactFizzServer';
29
30 import {
@@ -151,16 +152,7 @@ function renderToReadableStream(
152 options ? options.formState : undefined,
153 );
154 if (options && options.signal) {
154 - const signal = options.signal;
155 - if (signal.aborted) {
156 - abort(request, (signal as any).reason);
157 - } else {
158 - const listener = () => {
159 - abort(request, (signal as any).reason);
160 - signal.removeEventListener('abort', listener);
161 - };
162 - signal.addEventListener('abort', listener);
163 - }
155 + attachAbortSignal(request, options.signal);
156 }
157 startWork(request);
158 });
@@ -221,16 +213,7 @@ function resume(
213 onFatalError,
214 );
215 if (options && options.signal) {
224 - const signal = options.signal;
225 - if (signal.aborted) {
226 - abort(request, (signal as any).reason);
227 - } else {
228 - const listener = () => {
229 - abort(request, (signal as any).reason);
230 - signal.removeEventListener('abort', listener);
231 - };
232 - signal.addEventListener('abort', listener);
233 - }
216 + attachAbortSignal(request, options.signal);
217 }
218 startWork(request);
219 });
packages/react-dom/src/server/ReactDOMFizzServerBun.js
+2 -10
@@ -23,6 +23,7 @@ import {
23 startFlowing,
24 stopFlowing,
25 abort,
26 + attachAbortSignal,
27 } from 'react-server/src/ReactFizzServer';
28
29 import {
@@ -140,16 +141,7 @@ function renderToReadableStream(
141 options ? options.formState : undefined,
142 );
143 if (options && options.signal) {
143 - const signal = options.signal;
144 - if (signal.aborted) {
145 - abort(request, (signal as any).reason);
146 - } else {
147 - const listener = () => {
148 - abort(request, (signal as any).reason);
149 - signal.removeEventListener('abort', listener);
150 - };
151 - signal.addEventListener('abort', listener);
152 - }
144 + attachAbortSignal(request, options.signal);
145 }
146 startWork(request);
147 });
packages/react-dom/src/server/ReactDOMFizzServerEdge.js
+3 -20
@@ -24,6 +24,7 @@ import {
24 startFlowing,
25 stopFlowing,
26 abort,
27 + attachAbortSignal,
28 } from 'react-server/src/ReactFizzServer';
29
30 import {
@@ -151,16 +152,7 @@ function renderToReadableStream(
152 options ? options.formState : undefined,
153 );
154 if (options && options.signal) {
154 - const signal = options.signal;
155 - if (signal.aborted) {
156 - abort(request, (signal as any).reason);
157 - } else {
158 - const listener = () => {
159 - abort(request, (signal as any).reason);
160 - signal.removeEventListener('abort', listener);
161 - };
162 - signal.addEventListener('abort', listener);
163 - }
155 + attachAbortSignal(request, options.signal);
156 }
157 startWork(request);
158 });
@@ -221,16 +213,7 @@ function resume(
213 onFatalError,
214 );
215 if (options && options.signal) {
224 - const signal = options.signal;
225 - if (signal.aborted) {
226 - abort(request, (signal as any).reason);
227 - } else {
228 - const listener = () => {
229 - abort(request, (signal as any).reason);
230 - signal.removeEventListener('abort', listener);
231 - };
232 - signal.addEventListener('abort', listener);
233 - }
216 + attachAbortSignal(request, options.signal);
217 }
218 startWork(request);
219 });
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+3 -20
@@ -30,6 +30,7 @@ import {
30 startFlowing,
31 stopFlowing,
32 abort,
33 + attachAbortSignal,
34 prepareForStartFlowingIfBeforeAllReady,
35 } from 'react-server/src/ReactFizzServer';
36
@@ -289,16 +290,7 @@ function renderToReadableStream(
290 options ? options.formState : undefined,
291 );
292 if (options && options.signal) {
292 - const signal = options.signal;
293 - if (signal.aborted) {
294 - abort(request, (signal as any).reason);
295 - } else {
296 - const listener = () => {
297 - abort(request, (signal as any).reason);
298 - signal.removeEventListener('abort', listener);
299 - };
300 - signal.addEventListener('abort', listener);
301 - }
293 + attachAbortSignal(request, options.signal);
294 }
295 startWork(request);
296 });
@@ -427,16 +419,7 @@ function resume(
419 onFatalError,
420 );
421 if (options && options.signal) {
430 - const signal = options.signal;
431 - if (signal.aborted) {
432 - abort(request, (signal as any).reason);
433 - } else {
434 - const listener = () => {
435 - abort(request, (signal as any).reason);
436 - signal.removeEventListener('abort', listener);
437 - };
438 - signal.addEventListener('abort', listener);
439 - }
422 + attachAbortSignal(request, options.signal);
423 }
424 startWork(request);
425 });
packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js
+3 -20
@@ -24,6 +24,7 @@ import {
24 startFlowing,
25 stopFlowing,
26 abort,
27 + attachAbortSignal,
28 getPostponedState,
29 } from 'react-server/src/ReactFizzServer';
30
@@ -132,16 +133,7 @@ function prerender(
133 onFatalError,
134 );
135 if (options && options.signal) {
135 - const signal = options.signal;
136 - if (signal.aborted) {
137 - abort(request, (signal as any).reason);
138 - } else {
139 - const listener = () => {
140 - abort(request, (signal as any).reason);
141 - signal.removeEventListener('abort', listener);
142 - };
143 - signal.addEventListener('abort', listener);
144 - }
136 + attachAbortSignal(request, options.signal);
137 }
138 startWork(request);
139 });
@@ -199,16 +191,7 @@ function resumeAndPrerender(
191 onFatalError,
192 );
193 if (options && options.signal) {
202 - const signal = options.signal;
203 - if (signal.aborted) {
204 - abort(request, (signal as any).reason);
205 - } else {
206 - const listener = () => {
207 - abort(request, (signal as any).reason);
208 - signal.removeEventListener('abort', listener);
209 - };
210 - signal.addEventListener('abort', listener);
211 - }
194 + attachAbortSignal(request, options.signal);
195 }
196 startWork(request);
197 });
packages/react-dom/src/server/ReactDOMFizzStaticEdge.js
+3 -20
@@ -24,6 +24,7 @@ import {
24 startFlowing,
25 stopFlowing,
26 abort,
27 + attachAbortSignal,
28 getPostponedState,
29 } from 'react-server/src/ReactFizzServer';
30
@@ -131,16 +132,7 @@ function prerender(
132 onFatalError,
133 );
134 if (options && options.signal) {
134 - const signal = options.signal;
135 - if (signal.aborted) {
136 - abort(request, (signal as any).reason);
137 - } else {
138 - const listener = () => {
139 - abort(request, (signal as any).reason);
140 - signal.removeEventListener('abort', listener);
141 - };
142 - signal.addEventListener('abort', listener);
143 - }
135 + attachAbortSignal(request, options.signal);
136 }
137 startWork(request);
138 });
@@ -197,16 +189,7 @@ function resumeAndPrerender(
189 onFatalError,
190 );
191 if (options && options.signal) {
200 - const signal = options.signal;
201 - if (signal.aborted) {
202 - abort(request, (signal as any).reason);
203 - } else {
204 - const listener = () => {
205 - abort(request, (signal as any).reason);
206 - signal.removeEventListener('abort', listener);
207 - };
208 - signal.addEventListener('abort', listener);
209 - }
192 + attachAbortSignal(request, options.signal);
193 }
194 startWork(request);
195 });
packages/react-dom/src/server/ReactDOMFizzStaticNode.js
+5 -40
@@ -26,6 +26,7 @@ import {
26 startFlowing,
27 stopFlowing,
28 abort,
29 + attachAbortSignal,
30 getPostponedState,
31 } from 'react-server/src/ReactFizzServer';
32
@@ -163,16 +164,7 @@ function prerenderToNodeStream(
164 onFatalError,
165 );
166 if (options && options.signal) {
166 - const signal = options.signal;
167 - if (signal.aborted) {
168 - abort(request, (signal as any).reason);
169 - } else {
170 - const listener = () => {
171 - abort(request, (signal as any).reason);
172 - signal.removeEventListener('abort', listener);
173 - };
174 - signal.addEventListener('abort', listener);
175 - }
167 + attachAbortSignal(request, options.signal);
168 }
169 startWork(request);
170 });
@@ -254,16 +246,7 @@ function prerender(
246 onFatalError,
247 );
248 if (options && options.signal) {
257 - const signal = options.signal;
258 - if (signal.aborted) {
259 - abort(request, (signal as any).reason);
260 - } else {
261 - const listener = () => {
262 - abort(request, (signal as any).reason);
263 - signal.removeEventListener('abort', listener);
264 - };
265 - signal.addEventListener('abort', listener);
266 - }
249 + attachAbortSignal(request, options.signal);
250 }
251 startWork(request);
252 });
@@ -310,16 +293,7 @@ function resumeAndPrerenderToNodeStream(
293 onFatalError,
294 );
295 if (options && options.signal) {
313 - const signal = options.signal;
314 - if (signal.aborted) {
315 - abort(request, (signal as any).reason);
316 - } else {
317 - const listener = () => {
318 - abort(request, (signal as any).reason);
319 - signal.removeEventListener('abort', listener);
320 - };
321 - signal.addEventListener('abort', listener);
322 - }
296 + attachAbortSignal(request, options.signal);
297 }
298 startWork(request);
299 });
@@ -377,16 +351,7 @@ function resumeAndPrerender(
351 onFatalError,
352 );
353 if (options && options.signal) {
380 - const signal = options.signal;
381 - if (signal.aborted) {
382 - abort(request, (signal as any).reason);
383 - } else {
384 - const listener = () => {
385 - abort(request, (signal as any).reason);
386 - signal.removeEventListener('abort', listener);
387 - };
388 - signal.addEventListener('abort', listener);
389 - }
354 + attachAbortSignal(request, options.signal);
355 }
356 startWork(request);
357 });
packages/react-flight-server-fb/src/client/ReactFlightDOMClientBrowser.js
+2 -13
@@ -264,7 +264,7 @@ function encodeReply(
264 string | URLSearchParams | FormData,
265 > /* We don't use URLSearchParams yet but maybe */ {
266 return new Promise((resolve, reject) => {
267 - const abort = processReply(
267 + processReply(
268 value,
269 '',
270 options && options.temporaryReferences
@@ -272,19 +272,8 @@ function encodeReply(
272 : undefined,
273 resolve,
274 reject,
275 + options ? options.signal : undefined,
276 );
276 - if (options && options.signal) {
277 - const signal = options.signal;
278 - if (signal.aborted) {
279 - abort((signal as any).reason);
280 - } else {
281 - const listener = () => {
282 - abort((signal as any).reason);
283 - signal.removeEventListener('abort', listener);
284 - };
285 - signal.addEventListener('abort', listener);
286 - }
287 - }
277 });
278 }
279
packages/react-markup/src/ReactMarkupClient.js
+2 -11
@@ -16,7 +16,7 @@ import {
16 createRequest as createFizzRequest,
17 startWork as startFizzWork,
18 startFlowing as startFizzFlowing,
19 - abort as abortFizz,
19 + attachAbortSignal as attachFizzAbortSignal,
20 } from 'react-server/src/ReactFizzServer';
21
22 import {
@@ -88,16 +88,7 @@ export function experimental_renderToHTML(
88 undefined,
89 );
90 if (options && options.signal) {
91 - const signal = options.signal;
92 - if (signal.aborted) {
93 - abortFizz(fizzRequest, (signal as any).reason);
94 - } else {
95 - const listener = () => {
96 - abortFizz(fizzRequest, (signal as any).reason);
97 - signal.removeEventListener('abort', listener);
98 - };
99 - signal.addEventListener('abort', listener);
100 - }
91 + attachFizzAbortSignal(fizzRequest, options.signal);
92 }
93 startFizzWork(fizzRequest);
94 startFizzFlowing(fizzRequest, fizzDestination);
packages/react-markup/src/ReactMarkupServer.js
+6 -13
@@ -21,6 +21,7 @@ import {
21 startWork as startFlightWork,
22 startFlowing as startFlightFlowing,
23 abort as abortFlight,
24 + attachAbortSignal as attachFlightAbortSignal,
25 } from 'react-server/src/ReactFlightServer';
26
27 import {
@@ -36,6 +37,7 @@ import {
37 startWork as startFizzWork,
38 startFlowing as startFizzFlowing,
39 abort as abortFizz,
40 + attachAbortSignal as attachFizzAbortSignal,
41 } from 'react-server/src/ReactFizzServer';
42
43 import {
@@ -217,19 +219,10 @@ export function experimental_renderToHTML(
219 undefined,
220 undefined,
221 );
220 - if (options && options.signal) {
221 - const signal = options.signal;
222 - if (signal.aborted) {
223 - abortFlight(flightRequest, (signal as any).reason);
224 - abortFizz(fizzRequest, (signal as any).reason);
225 - } else {
226 - const listener = () => {
227 - abortFlight(flightRequest, (signal as any).reason);
228 - abortFizz(fizzRequest, (signal as any).reason);
229 - signal.removeEventListener('abort', listener);
230 - };
231 - signal.addEventListener('abort', listener);
232 - }
222 + const signal = options ? options.signal : undefined;
223 + if (signal) {
224 + attachFlightAbortSignal(flightRequest, signal);
225 + attachFizzAbortSignal(fizzRequest, signal);
226 }
227 startFlightWork(flightRequest);
228 startFlightFlowing(flightRequest, flightDestination);
packages/react-noop-renderer/src/ReactNoopFlightServer.js
+1 -9
@@ -96,15 +96,7 @@ function render(model: ReactClientValue, options?: Options): Destination {
96 );
97 const signal = options ? options.signal : undefined;
98 if (signal) {
99 - if (signal.aborted) {
100 - ReactNoopFlightServer.abort(request, (signal as any).reason);
101 - } else {
102 - const listener = () => {
103 - ReactNoopFlightServer.abort(request, (signal as any).reason);
104 - signal.removeEventListener('abort', listener);
105 - };
106 - signal.addEventListener('abort', listener);
107 - }
99 + ReactNoopFlightServer.attachAbortSignal(request, signal);
100 }
101 if (__DEV__ && options && options.debugChannel !== undefined) {
102 options.debugChannel.onMessage = message => {
packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js
+2 -13
@@ -265,7 +265,7 @@ function encodeReply(
265 string | URLSearchParams | FormData,
266 > /* We don't use URLSearchParams yet but maybe */ {
267 return new Promise((resolve, reject) => {
268 - const abort = processReply(
268 + processReply(
269 value,
270 '',
271 options && options.temporaryReferences
@@ -273,19 +273,8 @@ function encodeReply(
273 : undefined,
274 resolve,
275 reject,
276 + options ? options.signal : undefined,
277 );
277 - if (options && options.signal) {
278 - const signal = options.signal;
279 - if (signal.aborted) {
280 - abort((signal as any).reason);
281 - } else {
282 - const listener = () => {
283 - abort((signal as any).reason);
284 - signal.removeEventListener('abort', listener);
285 - };
286 - signal.addEventListener('abort', listener);
287 - }
288 - }
278 });
279 }
280
packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js
+2 -12
@@ -30,6 +30,7 @@ import {
30 startFlowingDebug,
31 stopFlowing,
32 abort,
33 + attachAbortSignal,
34 resolveDebugMessage,
35 closeDebugChannel,
36 } from 'react-server/src/ReactFlightServer';
@@ -313,18 +314,7 @@ function prerenderToNodeStream(
314 false,
315 );
316 if (options && options.signal) {
316 - const signal = options.signal;
317 - if (signal.aborted) {
318 - const reason = (signal as any).reason;
319 - abort(request, reason);
320 - } else {
321 - const listener = () => {
322 - const reason = (signal as any).reason;
323 - abort(request, reason);
324 - signal.removeEventListener('abort', listener);
325 - };
326 - signal.addEventListener('abort', listener);
327 - }
317 + attachAbortSignal(request, options.signal);
318 }
319 startWork(request);
320 });
packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js
+2 -13
@@ -299,7 +299,7 @@ export function encodeReply(
299 string | URLSearchParams | FormData,
300 > /* We don't use URLSearchParams yet but maybe */ {
301 return new Promise((resolve, reject) => {
302 - const abort = processReply(
302 + processReply(
303 value,
304 '', // formFieldPrefix
305 options && options.temporaryReferences
@@ -307,19 +307,8 @@ export function encodeReply(
307 : undefined,
308 resolve,
309 reject,
310 + options ? options.signal : undefined,
311 );
311 - if (options && options.signal) {
312 - const signal = options.signal;
313 - if (signal.aborted) {
314 - abort((signal as any).reason);
315 - } else {
316 - const listener = () => {
317 - abort((signal as any).reason);
318 - signal.removeEventListener('abort', listener);
319 - };
320 - signal.addEventListener('abort', listener);
321 - }
322 - }
312 });
313 }
314
packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js
+2 -13
@@ -230,7 +230,7 @@ export function encodeReply(
230 string | URLSearchParams | FormData,
231 > /* We don't use URLSearchParams yet but maybe */ {
232 return new Promise((resolve, reject) => {
233 - const abort = processReply(
233 + processReply(
234 value,
235 '',
236 options && options.temporaryReferences
@@ -238,18 +238,7 @@ export function encodeReply(
238 : undefined,
239 resolve,
240 reject,
241 + options ? options.signal : undefined,
242 );
242 - if (options && options.signal) {
243 - const signal = options.signal;
244 - if (signal.aborted) {
245 - abort((signal as any).reason);
246 - } else {
247 - const listener = () => {
248 - abort((signal as any).reason);
249 - signal.removeEventListener('abort', listener);
250 - };
251 - signal.addEventListener('abort', listener);
252 - }
253 - }
243 });
244 }
packages/react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js
+3 -22
@@ -29,6 +29,7 @@ import {
29 startFlowingDebug,
30 stopFlowing,
31 abort,
32 + attachAbortSignal,
33 resolveDebugMessage,
34 closeDebugChannel,
35 } from 'react-server/src/ReactFlightServer';
@@ -136,16 +137,7 @@ export function renderToReadableStream(
137 debugChannelReadable !== undefined,
138 );
139 if (options && options.signal) {
139 - const signal = options.signal;
140 - if (signal.aborted) {
141 - abort(request, (signal as any).reason);
142 - } else {
143 - const listener = () => {
144 - abort(request, (signal as any).reason);
145 - signal.removeEventListener('abort', listener);
146 - };
147 - signal.addEventListener('abort', listener);
148 - }
140 + attachAbortSignal(request, options.signal);
141 }
142 if (debugChannelWritable !== undefined) {
143 const debugStream = new ReadableStream(
@@ -227,18 +219,7 @@ export function prerender(
219 false,
220 );
221 if (options && options.signal) {
230 - const signal = options.signal;
231 - if (signal.aborted) {
232 - const reason = (signal as any).reason;
233 - abort(request, reason);
234 - } else {
235 - const listener = () => {
236 - const reason = (signal as any).reason;
237 - abort(request, reason);
238 - signal.removeEventListener('abort', listener);
239 - };
240 - signal.addEventListener('abort', listener);
241 - }
222 + attachAbortSignal(request, options.signal);
223 }
224 startWork(request);
225 });
packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js
+3 -22
@@ -32,6 +32,7 @@ import {
32 startFlowingDebug,
33 stopFlowing,
34 abort,
35 + attachAbortSignal,
36 resolveDebugMessage,
37 closeDebugChannel,
38 } from 'react-server/src/ReactFlightServer';
@@ -142,16 +143,7 @@ export function renderToReadableStream(
143 debugChannelReadable !== undefined,
144 );
145 if (options && options.signal) {
145 - const signal = options.signal;
146 - if (signal.aborted) {
147 - abort(request, (signal as any).reason);
148 - } else {
149 - const listener = () => {
150 - abort(request, (signal as any).reason);
151 - signal.removeEventListener('abort', listener);
152 - };
153 - signal.addEventListener('abort', listener);
154 - }
146 + attachAbortSignal(request, options.signal);
147 }
148 if (debugChannelWritable !== undefined) {
149 const debugStream = new ReadableStream(
@@ -233,18 +225,7 @@ export function prerender(
225 false,
226 );
227 if (options && options.signal) {
236 - const signal = options.signal;
237 - if (signal.aborted) {
238 - const reason = (signal as any).reason;
239 - abort(request, reason);
240 - } else {
241 - const listener = () => {
242 - const reason = (signal as any).reason;
243 - abort(request, reason);
244 - signal.removeEventListener('abort', listener);
245 - };
246 - signal.addEventListener('abort', listener);
247 - }
228 + attachAbortSignal(request, options.signal);
229 }
230 startWork(request);
231 });
packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js
+4 -34
@@ -35,6 +35,7 @@ import {
35 startFlowingDebug,
36 stopFlowing,
37 abort,
38 + attachAbortSignal,
39 resolveDebugMessage,
40 closeDebugChannel,
41 } from 'react-server/src/ReactFlightServer';
@@ -362,16 +363,7 @@ export function renderToReadableStream(
363 debugChannelReadable !== undefined,
364 );
365 if (options && options.signal) {
365 - const signal = options.signal;
366 - if (signal.aborted) {
367 - abort(request, (signal as any).reason);
368 - } else {
369 - const listener = () => {
370 - abort(request, (signal as any).reason);
371 - signal.removeEventListener('abort', listener);
372 - };
373 - signal.addEventListener('abort', listener);
374 - }
366 + attachAbortSignal(request, options.signal);
367 }
368 if (debugChannelWritable !== undefined) {
369 let debugWritable: Writable;
@@ -478,18 +470,7 @@ export function prerenderToNodeStream(
470 false,
471 );
472 if (options && options.signal) {
481 - const signal = options.signal;
482 - if (signal.aborted) {
483 - const reason = (signal as any).reason;
484 - abort(request, reason);
485 - } else {
486 - const listener = () => {
487 - const reason = (signal as any).reason;
488 - abort(request, reason);
489 - signal.removeEventListener('abort', listener);
490 - };
491 - signal.addEventListener('abort', listener);
492 - }
473 + attachAbortSignal(request, options.signal);
474 }
475 startWork(request);
476 });
@@ -542,18 +523,7 @@ export function prerender(
523 false,
524 );
525 if (options && options.signal) {
545 - const signal = options.signal;
546 - if (signal.aborted) {
547 - const reason = (signal as any).reason;
548 - abort(request, reason);
549 - } else {
550 - const listener = () => {
551 - const reason = (signal as any).reason;
552 - abort(request, reason);
553 - signal.removeEventListener('abort', listener);
554 - };
555 - signal.addEventListener('abort', listener);
556 - }
526 + attachAbortSignal(request, options.signal);
527 }
528 startWork(request);
529 });
packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js
+2 -13
@@ -265,7 +265,7 @@ function encodeReply(
265 string | URLSearchParams | FormData,
266 > /* We don't use URLSearchParams yet but maybe */ {
267 return new Promise((resolve, reject) => {
268 - const abort = processReply(
268 + processReply(
269 value,
270 '',
271 options && options.temporaryReferences
@@ -273,19 +273,8 @@ function encodeReply(
273 : undefined,
274 resolve,
275 reject,
276 + options ? options.signal : undefined,
277 );
277 - if (options && options.signal) {
278 - const signal = options.signal;
279 - if (signal.aborted) {
280 - abort((signal as any).reason);
281 - } else {
282 - const listener = () => {
283 - abort((signal as any).reason);
284 - signal.removeEventListener('abort', listener);
285 - };
286 - signal.addEventListener('abort', listener);
287 - }
288 - }
278 });
279 }
280
packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js
+2 -13
@@ -232,7 +232,7 @@ function encodeReply(
232 string | URLSearchParams | FormData,
233 > /* We don't use URLSearchParams yet but maybe */ {
234 return new Promise((resolve, reject) => {
235 - const abort = processReply(
235 + processReply(
236 value,
237 '',
238 options && options.temporaryReferences
@@ -240,19 +240,8 @@ function encodeReply(
240 : undefined,
241 resolve,
242 reject,
243 + options ? options.signal : undefined,
244 );
244 - if (options && options.signal) {
245 - const signal = options.signal;
246 - if (signal.aborted) {
247 - abort((signal as any).reason);
248 - } else {
249 - const listener = () => {
250 - abort((signal as any).reason);
251 - signal.removeEventListener('abort', listener);
252 - };
253 - signal.addEventListener('abort', listener);
254 - }
255 - }
245 });
246 }
247
packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js
+3 -22
@@ -23,6 +23,7 @@ import {
23 startFlowingDebug,
24 stopFlowing,
25 abort,
26 + attachAbortSignal,
27 resolveDebugMessage,
28 closeDebugChannel,
29 } from 'react-server/src/ReactFlightServer';
@@ -133,16 +134,7 @@ function renderToReadableStream(
134 debugChannelReadable !== undefined,
135 );
136 if (options && options.signal) {
136 - const signal = options.signal;
137 - if (signal.aborted) {
138 - abort(request, (signal as any).reason);
139 - } else {
140 - const listener = () => {
141 - abort(request, (signal as any).reason);
142 - signal.removeEventListener('abort', listener);
143 - };
144 - signal.addEventListener('abort', listener);
145 - }
137 + attachAbortSignal(request, options.signal);
138 }
139 if (debugChannelWritable !== undefined) {
140 const debugStream = new ReadableStream(
@@ -225,18 +217,7 @@ function prerender(
217 false,
218 );
219 if (options && options.signal) {
228 - const signal = options.signal;
229 - if (signal.aborted) {
230 - const reason = (signal as any).reason;
231 - abort(request, reason);
232 - } else {
233 - const listener = () => {
234 - const reason = (signal as any).reason;
235 - abort(request, reason);
236 - signal.removeEventListener('abort', listener);
237 - };
238 - signal.addEventListener('abort', listener);
239 - }
220 + attachAbortSignal(request, options.signal);
221 }
222 startWork(request);
223 });
packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js
+3 -22
@@ -26,6 +26,7 @@ import {
26 startFlowingDebug,
27 stopFlowing,
28 abort,
29 + attachAbortSignal,
30 resolveDebugMessage,
31 closeDebugChannel,
32 } from 'react-server/src/ReactFlightServer';
@@ -139,16 +140,7 @@ function renderToReadableStream(
140 debugChannelReadable !== undefined,
141 );
142 if (options && options.signal) {
142 - const signal = options.signal;
143 - if (signal.aborted) {
144 - abort(request, (signal as any).reason);
145 - } else {
146 - const listener = () => {
147 - abort(request, (signal as any).reason);
148 - signal.removeEventListener('abort', listener);
149 - };
150 - signal.addEventListener('abort', listener);
151 - }
143 + attachAbortSignal(request, options.signal);
144 }
145 if (debugChannelWritable !== undefined) {
146 const debugStream = new ReadableStream(
@@ -231,18 +223,7 @@ function prerender(
223 false,
224 );
225 if (options && options.signal) {
234 - const signal = options.signal;
235 - if (signal.aborted) {
236 - const reason = (signal as any).reason;
237 - abort(request, reason);
238 - } else {
239 - const listener = () => {
240 - const reason = (signal as any).reason;
241 - abort(request, reason);
242 - signal.removeEventListener('abort', listener);
243 - };
244 - signal.addEventListener('abort', listener);
245 - }
226 + attachAbortSignal(request, options.signal);
227 }
228 startWork(request);
229 });
packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js
+4 -34
@@ -33,6 +33,7 @@ import {
33 startFlowingDebug,
34 stopFlowing,
35 abort,
36 + attachAbortSignal,
37 resolveDebugMessage,
38 closeDebugChannel,
39 } from 'react-server/src/ReactFlightServer';
@@ -357,16 +358,7 @@ function renderToReadableStream(
358 debugChannelReadable !== undefined,
359 );
360 if (options && options.signal) {
360 - const signal = options.signal;
361 - if (signal.aborted) {
362 - abort(request, (signal as any).reason);
363 - } else {
364 - const listener = () => {
365 - abort(request, (signal as any).reason);
366 - signal.removeEventListener('abort', listener);
367 - };
368 - signal.addEventListener('abort', listener);
369 - }
361 + attachAbortSignal(request, options.signal);
362 }
363 if (debugChannelWritable !== undefined) {
364 let debugWritable: Writable;
@@ -474,18 +466,7 @@ function prerenderToNodeStream(
466 false,
467 );
468 if (options && options.signal) {
477 - const signal = options.signal;
478 - if (signal.aborted) {
479 - const reason = (signal as any).reason;
480 - abort(request, reason);
481 - } else {
482 - const listener = () => {
483 - const reason = (signal as any).reason;
484 - abort(request, reason);
485 - signal.removeEventListener('abort', listener);
486 - };
487 - signal.addEventListener('abort', listener);
488 - }
469 + attachAbortSignal(request, options.signal);
470 }
471 startWork(request);
472 });
@@ -539,18 +520,7 @@ function prerender(
520 false,
521 );
522 if (options && options.signal) {
542 - const signal = options.signal;
543 - if (signal.aborted) {
544 - const reason = (signal as any).reason;
545 - abort(request, reason);
546 - } else {
547 - const listener = () => {
548 - const reason = (signal as any).reason;
549 - abort(request, reason);
550 - signal.removeEventListener('abort', listener);
551 - };
552 - signal.addEventListener('abort', listener);
553 - }
523 + attachAbortSignal(request, options.signal);
524 }
525 startWork(request);
526 });
packages/react-server-dom-unbundled/src/client/ReactFlightDOMClientEdge.js
+2 -13
@@ -232,7 +232,7 @@ function encodeReply(
232 string | URLSearchParams | FormData,
233 > /* We don't use URLSearchParams yet but maybe */ {
234 return new Promise((resolve, reject) => {
235 - const abort = processReply(
235 + processReply(
236 value,
237 '',
238 options && options.temporaryReferences
@@ -240,19 +240,8 @@ function encodeReply(
240 : undefined,
241 resolve,
242 reject,
243 + options ? options.signal : undefined,
244 );
244 - if (options && options.signal) {
245 - const signal = options.signal;
246 - if (signal.aborted) {
247 - abort((signal as any).reason);
248 - } else {
249 - const listener = () => {
250 - abort((signal as any).reason);
251 - signal.removeEventListener('abort', listener);
252 - };
253 - signal.addEventListener('abort', listener);
254 - }
255 - }
245 });
246 }
247
packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js
+4 -34
@@ -33,6 +33,7 @@ import {
33 startFlowingDebug,
34 stopFlowing,
35 abort,
36 + attachAbortSignal,
37 resolveDebugMessage,
38 closeDebugChannel,
39 } from 'react-server/src/ReactFlightServer';
@@ -357,16 +358,7 @@ function renderToReadableStream(
358 debugChannelReadable !== undefined,
359 );
360 if (options && options.signal) {
360 - const signal = options.signal;
361 - if (signal.aborted) {
362 - abort(request, (signal as any).reason);
363 - } else {
364 - const listener = () => {
365 - abort(request, (signal as any).reason);
366 - signal.removeEventListener('abort', listener);
367 - };
368 - signal.addEventListener('abort', listener);
369 - }
361 + attachAbortSignal(request, options.signal);
362 }
363 if (debugChannelWritable !== undefined) {
364 let debugWritable: Writable;
@@ -474,18 +466,7 @@ function prerenderToNodeStream(
466 false,
467 );
468 if (options && options.signal) {
477 - const signal = options.signal;
478 - if (signal.aborted) {
479 - const reason = (signal as any).reason;
480 - abort(request, reason);
481 - } else {
482 - const listener = () => {
483 - const reason = (signal as any).reason;
484 - abort(request, reason);
485 - signal.removeEventListener('abort', listener);
486 - };
487 - signal.addEventListener('abort', listener);
488 - }
469 + attachAbortSignal(request, options.signal);
470 }
471 startWork(request);
472 });
@@ -539,18 +520,7 @@ function prerender(
520 false,
521 );
522 if (options && options.signal) {
542 - const signal = options.signal;
543 - if (signal.aborted) {
544 - const reason = (signal as any).reason;
545 - abort(request, reason);
546 - } else {
547 - const listener = () => {
548 - const reason = (signal as any).reason;
549 - abort(request, reason);
550 - signal.removeEventListener('abort', listener);
551 - };
552 - signal.addEventListener('abort', listener);
553 - }
523 + attachAbortSignal(request, options.signal);
524 }
525 startWork(request);
526 });
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+146
@@ -3359,6 +3359,152 @@ describe('ReactFlightDOMBrowser', () => {
3359 );
3360 });
3361
3362 + describe('abort signal lifetime', () => {
3363 + // Collects the lifetime signal that React bounds each abort listener with.
3364 + // React passes that signal to addEventListener instead of calling
3365 + // removeEventListener, so the runtime performs the removal and nothing here
3366 + // observes it directly. An aborted lifetime is what shows the listener is
3367 + // gone. ReactFlightDOMNode-test asserts the removal itself, which needs a
3368 + // Node API that jsdom does not have.
3369 + function trackAbortListenerLifetimes(signal) {
3370 + const lifetimes = [];
3371 + const add = signal.addEventListener.bind(signal);
3372 + signal.addEventListener = (type, listener, options) => {
3373 + if (type === 'abort') {
3374 + lifetimes.push(options.signal);
3375 + }
3376 + return add(type, listener, options);
3377 + };
3378 + return lifetimes;
3379 + }
3380 +
3381 + async function drain(stream) {
3382 + const reader = stream.getReader();
3383 + while (true) {
3384 + const {done} = await reader.read();
3385 + if (done) {
3386 + return;
3387 + }
3388 + }
3389 + }
3390 +
3391 + function App() {
3392 + return <div>hello world</div>;
3393 + }
3394 +
3395 + it('detaches the listener when a prerender completes', async () => {
3396 + const controller = new AbortController();
3397 + const lifetimes = trackAbortListenerLifetimes(controller.signal);
3398 +
3399 + const {prelude} = await serverAct(() =>
3400 + ReactServerDOMStaticServer.prerender(<App />, webpackMap, {
3401 + signal: controller.signal,
3402 + }),
3403 + );
3404 + expect(lifetimes).toHaveLength(1);
3405 + expect(lifetimes[0].aborted).toBe(false);
3406 +
3407 + await serverAct(() => drain(prelude));
3408 + expect(lifetimes[0].aborted).toBe(true);
3409 + });
3410 +
3411 + it('detaches the listener when a render completes', async () => {
3412 + const controller = new AbortController();
3413 + const lifetimes = trackAbortListenerLifetimes(controller.signal);
3414 +
3415 + const stream = await serverAct(() =>
3416 + ReactServerDOMServer.renderToReadableStream(<App />, webpackMap, {
3417 + signal: controller.signal,
3418 + }),
3419 + );
3420 + expect(lifetimes).toHaveLength(1);
3421 + expect(lifetimes[0].aborted).toBe(false);
3422 +
3423 + await serverAct(() => drain(stream));
3424 + expect(lifetimes[0].aborted).toBe(true);
3425 + });
3426 +
3427 + it('detaches the listener when the signal aborts mid-render', async () => {
3428 + let resolveGreeting;
3429 + const greetingPromise = new Promise(resolve => {
3430 + resolveGreeting = resolve;
3431 + });
3432 +
3433 + async function Greeting() {
3434 + await greetingPromise;
3435 + return 'hello world';
3436 + }
3437 +
3438 + const controller = new AbortController();
3439 + const lifetimes = trackAbortListenerLifetimes(controller.signal);
3440 +
3441 + const {pendingResult} = await serverAct(async () => {
3442 + return {
3443 + pendingResult: ReactServerDOMStaticServer.prerender(
3444 + <Greeting />,
3445 + webpackMap,
3446 + {signal: controller.signal, onError() {}},
3447 + ),
3448 + };
3449 + });
3450 + expect(lifetimes).toHaveLength(1);
3451 + expect(lifetimes[0].aborted).toBe(false);
3452 +
3453 + controller.abort('boom');
3454 + resolveGreeting();
3455 + await serverAct(() => pendingResult);
3456 +
3457 + expect(lifetimes[0].aborted).toBe(true);
3458 + });
3459 +
3460 + it('detaches the listener when the stream is cancelled', async () => {
3461 + let resolveGreeting;
3462 + const greetingPromise = new Promise(resolve => {
3463 + resolveGreeting = resolve;
3464 + });
3465 +
3466 + async function Greeting() {
3467 + await greetingPromise;
3468 + return 'hello world';
3469 + }
3470 +
3471 + const controller = new AbortController();
3472 + const lifetimes = trackAbortListenerLifetimes(controller.signal);
3473 +
3474 + const stream = await serverAct(() =>
3475 + ReactServerDOMServer.renderToReadableStream(<Greeting />, webpackMap, {
3476 + signal: controller.signal,
3477 + onError() {},
3478 + }),
3479 + );
3480 + expect(lifetimes).toHaveLength(1);
3481 + expect(lifetimes[0].aborted).toBe(false);
3482 +
3483 + await serverAct(() => stream.cancel('boom'));
3484 + resolveGreeting();
3485 +
3486 + expect(lifetimes[0].aborted).toBe(true);
3487 + });
3488 +
3489 + it('attaches no listener when the signal is already aborted', async () => {
3490 + const controller = new AbortController();
3491 + controller.abort('boom');
3492 + const lifetimes = trackAbortListenerLifetimes(controller.signal);
3493 +
3494 + await serverAct(() =>
3495 + ReactServerDOMStaticServer.prerender(<App />, webpackMap, {
3496 + signal: controller.signal,
3497 + onError() {},
3498 + }),
3499 + );
3500 +
3501 + expect(lifetimes).toHaveLength(0);
3502 + });
3503 +
3504 + // The composite-signal case lives in ReactFlightDOMNode-test, because
3505 + // jsdom's AbortSignal has no AbortSignal.any.
3506 + });
3507 +
3508 describe('with console.createTask', () => {
3509 // Stands in for what a browser console does with fake tasks: whatever runs
3510 // inside a task is shown under that task's name in the async stack. This is
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js
+39
@@ -2446,4 +2446,43 @@ describe('ReactFlightDOMNode', () => {
2446 expect(Buffer.isBuffer(result.font)).toBe(false);
2447 expect(result.font).toEqual({type: 'Buffer', data: [1, 2, 3, 4]});
2448 });
2449 +
2450 + it('detaches the abort listener from a composite signal once the prerender completes', async () => {
2451 + // A composite signal from AbortSignal.any() is retained by the runtime for
2452 + // as long as it has an abort listener attached, so a listener left behind
2453 + // by a completed render keeps that render reachable for the lifetime of the
2454 + // source signals.
2455 + //
2456 + // React bounds its listener with a lifetime signal, so the runtime removes
2457 + // the listener rather than React calling removeEventListener. This test
2458 + // observes the registration itself through a Node API, which is the only
2459 + // way to see that removal. The suites that run under jsdom assert on the
2460 + // lifetime signal instead.
2461 + const {getEventListeners} = require('node:events');
2462 +
2463 + const outer = new AbortController();
2464 + const timeout = new AbortController();
2465 + const composite = AbortSignal.any([outer.signal, timeout.signal]);
2466 +
2467 + function App() {
2468 + return <div>hello world</div>;
2469 + }
2470 +
2471 + const {prelude} = await serverAct(() =>
2472 + ReactServerDOMStaticServer.prerenderToNodeStream(<App />, webpackMap, {
2473 + signal: composite,
2474 + }),
2475 + );
2476 + expect(getEventListeners(composite, 'abort')).toHaveLength(1);
2477 +
2478 + await serverAct(
2479 + () =>
2480 + new Promise(resolve => {
2481 + prelude.resume();
2482 + prelude.on('end', resolve);
2483 + }),
2484 + );
2485 +
2486 + expect(getEventListeners(composite, 'abort')).toHaveLength(0);
2487 + });
2488 });
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js
+45
@@ -755,4 +755,49 @@ describe('ReactFlightDOMReply', () => {
755 }
756 expect(error.message).toContain('Referenced Blob is not a Blob.');
757 });
758 +
759 + it('detaches the abort listener once the reply is encoded', async () => {
760 + const controller = new AbortController();
761 + const signal = controller.signal;
762 +
763 + // Collects the lifetime signal that React bounds each abort listener with.
764 + // React passes that signal to addEventListener instead of calling
765 + // removeEventListener, so the runtime performs the removal and nothing here
766 + // observes it directly. An aborted lifetime is what shows the listener is
767 + // gone.
768 + const lifetimes = [];
769 + const add = signal.addEventListener.bind(signal);
770 + signal.addEventListener = (type, listener, options) => {
771 + if (type === 'abort') {
772 + lifetimes.push(options.signal);
773 + }
774 + return add(type, listener, options);
775 + };
776 +
777 + let resolvePart;
778 + const part = new Promise(r => (resolvePart = r));
779 + const bodyPromise = ReactServerDOMClient.encodeReply(
780 + {part, hello: 'world'},
781 + {signal},
782 + );
783 + expect(lifetimes).toHaveLength(1);
784 + expect(lifetimes[0].aborted).toBe(false);
785 +
786 + resolvePart('done');
787 + await bodyPromise;
788 +
789 + expect(lifetimes[0].aborted).toBe(true);
790 + });
791 +
792 + it('resolves with the partial result when the signal is already aborted', async () => {
793 + const controller = new AbortController();
794 + controller.abort();
795 + const neverResolves = new Promise(() => {});
796 + const body = await ReactServerDOMClient.encodeReply(
797 + {promise: neverResolves, hello: 'world'},
798 + {signal: controller.signal},
799 + );
800 + const result = await ReactServerDOMServer.decodeReply(body);
801 + expect(result.hello).toBe('world');
802 + });
803 });
packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js
+2 -13
@@ -265,7 +265,7 @@ function encodeReply(
265 string | URLSearchParams | FormData,
266 > /* We don't use URLSearchParams yet but maybe */ {
267 return new Promise((resolve, reject) => {
268 - const abort = processReply(
268 + processReply(
269 value,
270 '',
271 options && options.temporaryReferences
@@ -273,19 +273,8 @@ function encodeReply(
273 : undefined,
274 resolve,
275 reject,
276 + options ? options.signal : undefined,
277 );
277 - if (options && options.signal) {
278 - const signal = options.signal;
279 - if (signal.aborted) {
280 - abort((signal as any).reason);
281 - } else {
282 - const listener = () => {
283 - abort((signal as any).reason);
284 - signal.removeEventListener('abort', listener);
285 - };
286 - signal.addEventListener('abort', listener);
287 - }
288 - }
278 });
279 }
280
packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js
+2 -13
@@ -232,7 +232,7 @@ function encodeReply(
232 string | URLSearchParams | FormData,
233 > /* We don't use URLSearchParams yet but maybe */ {
234 return new Promise((resolve, reject) => {
235 - const abort = processReply(
235 + processReply(
236 value,
237 '',
238 options && options.temporaryReferences
@@ -240,19 +240,8 @@ function encodeReply(
240 : undefined,
241 resolve,
242 reject,
243 + options ? options.signal : undefined,
244 );
244 - if (options && options.signal) {
245 - const signal = options.signal;
246 - if (signal.aborted) {
247 - abort((signal as any).reason);
248 - } else {
249 - const listener = () => {
250 - abort((signal as any).reason);
251 - signal.removeEventListener('abort', listener);
252 - };
253 - signal.addEventListener('abort', listener);
254 - }
255 - }
245 });
246 }
247
packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js
+3 -22
@@ -23,6 +23,7 @@ import {
23 startFlowingDebug,
24 stopFlowing,
25 abort,
26 + attachAbortSignal,
27 resolveDebugMessage,
28 closeDebugChannel,
29 } from 'react-server/src/ReactFlightServer';
@@ -133,16 +134,7 @@ function renderToReadableStream(
134 debugChannelReadable !== undefined,
135 );
136 if (options && options.signal) {
136 - const signal = options.signal;
137 - if (signal.aborted) {
138 - abort(request, (signal as any).reason);
139 - } else {
140 - const listener = () => {
141 - abort(request, (signal as any).reason);
142 - signal.removeEventListener('abort', listener);
143 - };
144 - signal.addEventListener('abort', listener);
145 - }
137 + attachAbortSignal(request, options.signal);
138 }
139 if (debugChannelWritable !== undefined) {
140 const debugStream = new ReadableStream(
@@ -225,18 +217,7 @@ function prerender(
217 false,
218 );
219 if (options && options.signal) {
228 - const signal = options.signal;
229 - if (signal.aborted) {
230 - const reason = (signal as any).reason;
231 - abort(request, reason);
232 - } else {
233 - const listener = () => {
234 - const reason = (signal as any).reason;
235 - abort(request, reason);
236 - signal.removeEventListener('abort', listener);
237 - };
238 - signal.addEventListener('abort', listener);
239 - }
220 + attachAbortSignal(request, options.signal);
221 }
222 startWork(request);
223 });
packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js
+3 -22
@@ -26,6 +26,7 @@ import {
26 startFlowingDebug,
27 stopFlowing,
28 abort,
29 + attachAbortSignal,
30 resolveDebugMessage,
31 closeDebugChannel,
32 } from 'react-server/src/ReactFlightServer';
@@ -139,16 +140,7 @@ function renderToReadableStream(
140 debugChannelReadable !== undefined,
141 );
142 if (options && options.signal) {
142 - const signal = options.signal;
143 - if (signal.aborted) {
144 - abort(request, (signal as any).reason);
145 - } else {
146 - const listener = () => {
147 - abort(request, (signal as any).reason);
148 - signal.removeEventListener('abort', listener);
149 - };
150 - signal.addEventListener('abort', listener);
151 - }
143 + attachAbortSignal(request, options.signal);
144 }
145 if (debugChannelWritable !== undefined) {
146 const debugStream = new ReadableStream(
@@ -231,18 +223,7 @@ function prerender(
223 false,
224 );
225 if (options && options.signal) {
234 - const signal = options.signal;
235 - if (signal.aborted) {
236 - const reason = (signal as any).reason;
237 - abort(request, reason);
238 - } else {
239 - const listener = () => {
240 - const reason = (signal as any).reason;
241 - abort(request, reason);
242 - signal.removeEventListener('abort', listener);
243 - };
244 - signal.addEventListener('abort', listener);
245 - }
226 + attachAbortSignal(request, options.signal);
227 }
228 startWork(request);
229 });
packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js
+4 -34
@@ -33,6 +33,7 @@ import {
33 startFlowingDebug,
34 stopFlowing,
35 abort,
36 + attachAbortSignal,
37 resolveDebugMessage,
38 closeDebugChannel,
39 } from 'react-server/src/ReactFlightServer';
@@ -357,16 +358,7 @@ function renderToReadableStream(
358 debugChannelReadable !== undefined,
359 );
360 if (options && options.signal) {
360 - const signal = options.signal;
361 - if (signal.aborted) {
362 - abort(request, (signal as any).reason);
363 - } else {
364 - const listener = () => {
365 - abort(request, (signal as any).reason);
366 - signal.removeEventListener('abort', listener);
367 - };
368 - signal.addEventListener('abort', listener);
369 - }
361 + attachAbortSignal(request, options.signal);
362 }
363 if (debugChannelWritable !== undefined) {
364 let debugWritable: Writable;
@@ -474,18 +466,7 @@ function prerenderToNodeStream(
466 false,
467 );
468 if (options && options.signal) {
477 - const signal = options.signal;
478 - if (signal.aborted) {
479 - const reason = (signal as any).reason;
480 - abort(request, reason);
481 - } else {
482 - const listener = () => {
483 - const reason = (signal as any).reason;
484 - abort(request, reason);
485 - signal.removeEventListener('abort', listener);
486 - };
487 - signal.addEventListener('abort', listener);
488 - }
469 + attachAbortSignal(request, options.signal);
470 }
471 startWork(request);
472 });
@@ -539,18 +520,7 @@ function prerender(
520 false,
521 );
522 if (options && options.signal) {
542 - const signal = options.signal;
543 - if (signal.aborted) {
544 - const reason = (signal as any).reason;
545 - abort(request, reason);
546 - } else {
547 - const listener = () => {
548 - const reason = (signal as any).reason;
549 - abort(request, reason);
550 - signal.removeEventListener('abort', listener);
551 - };
552 - signal.addEventListener('abort', listener);
553 - }
523 + attachAbortSignal(request, options.signal);
524 }
525 startWork(request);
526 });
packages/react-server/src/ReactFizzServer.js
+39
@@ -376,6 +376,12 @@ const CLOSING = 12;
376 const CLOSED = 13;
377 const STALLED_DEV = 14;
378
379 +// Passed to renderLifetimeController.abort(). Nothing reads the reason, but a
380 +// call to abort() without one constructs an AbortError DOMException. Capturing
381 +// the stack trace dominates that cost, and the cost grows with the depth of the
382 +// stack.
383 +const RENDER_ENDED = 'The render ended.';
384 +
385 export opaque type Request = {
386 destination: null | Destination,
387 flushScheduled: boolean,
@@ -427,6 +433,9 @@ export opaque type Request = {
433 // emit a different response to the stream instead.
434 onShellError: (error: mixed) => void,
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,
439 // Form state that was the result of an MPA submission, if it was provided.
440 formState: null | ReactFormState<any, any>,
441 // DEV-only, warning dedupe
@@ -580,6 +589,7 @@ function RequestInstance(
589 this.onShellReady = onShellReady === undefined ? noop : onShellReady;
590 this.onShellError = onShellError === undefined ? noop : onShellError;
591 this.onFatalError = onFatalError === undefined ? noop : onFatalError;
592 + this.renderLifetimeController = new AbortController();
593 this.formState = formState === undefined ? null : formState;
594 if (__DEV__) {
595 this.didWarnForKey = null;
@@ -1445,6 +1455,7 @@ function fatalError(
1455 }
1456 onFatalError(error);
1457 }
1458 + request.renderLifetimeController.abort(RENDER_ENDED);
1459 if (request.destination !== null) {
1460 request.status = CLOSED;
1461 closeWithError(request.destination, error);
@@ -6341,6 +6352,7 @@ function flushCompletedQueues(
6352 }
6353 }
6354 // We're done.
6355 + request.renderLifetimeController.abort(RENDER_ENDED);
6356 request.status = CLOSED;
6357 close(destination);
6358 // We need to stop flowing now because we do not want any async contexts which might call
@@ -6504,6 +6516,32 @@ function finishAbort(request: Request, abortableTasks: Set<Task>): void {
6516 }
6517 }
6518
6519 +// Aborts the request when the caller's signal aborts. The render lifetime
6520 +// bounds the listener, so the runtime removes the listener as soon as the
6521 +// render ends. From that point on abort() returns early, so the listener has
6522 +// nothing left to do.
6523 +//
6524 +// The listener has to be removed, because it would otherwise keep the whole
6525 +// Request reachable for as long as the caller's signal lives. A composite
6526 +// signal from AbortSignal.any() is itself retained by the runtime while it has
6527 +// any abort listener attached.
6528 +//
6529 +// A request whose stream is neither consumed nor cancelled never ends, so its
6530 +// listener stays attached for as long as the caller's signal lives.
6531 +export function attachAbortSignal(request: Request, signal: AbortSignal): void {
6532 + if (signal.aborted) {
6533 + abort(request, signal.reason);
6534 + return;
6535 + }
6536 + signal.addEventListener(
6537 + 'abort',
6538 + () => {
6539 + abort(request, signal.reason);
6540 + },
6541 + {signal: request.renderLifetimeController.signal},
6542 + );
6543 +}
6544 +
6545 // This is called to early terminate a request. It puts all pending boundaries in client rendered state.
6546 export function abort(request: Request, reason: mixed): void {
6547 if (
@@ -6514,6 +6552,7 @@ export function abort(request: Request, reason: mixed): void {
6552 // can be aborted. in practice this makes abort callable at most once per render.
6553 return;
6554 }
6555 + request.renderLifetimeController.abort(RENDER_ENDED);
6556 const isRecoverableReason =
6557 typeof reason === 'object' &&
6558 reason !== null &&
packages/react-server/src/ReactFlightServer.js
+28
@@ -6659,6 +6659,34 @@ function finishAbort(
6659 }
6660 }
6661
6662 +// Aborts the request when the caller's signal aborts. The cache controller's
6663 +// signal bounds the listener's lifetime, so the runtime removes the listener as
6664 +// soon as that signal aborts. The cache controller aborts at every point that
6665 +// ends the render: a fatal error, the completion of the flush loop, and abort()
6666 +// itself. From any of those points on, abort() returns early, so the listener
6667 +// has nothing left to do.
6668 +//
6669 +// The listener has to be removed, because it would otherwise keep the whole
6670 +// Request reachable for as long as the caller's signal lives. A composite
6671 +// signal from AbortSignal.any() is itself retained by the runtime while it has
6672 +// any abort listener attached.
6673 +//
6674 +// A request whose stream is neither consumed nor cancelled never ends, so its
6675 +// listener stays attached for as long as the caller's signal lives.
6676 +export function attachAbortSignal(request: Request, signal: AbortSignal): void {
6677 + if (signal.aborted) {
6678 + abort(request, signal.reason);
6679 + return;
6680 + }
6681 + signal.addEventListener(
6682 + 'abort',
6683 + () => {
6684 + abort(request, signal.reason);
6685 + },
6686 + {signal: request.cacheController.signal},
6687 + );
6688 +}
6689 +
6690 export function abort(request: Request, reason: mixed): void {
6691 // We define any status below OPEN as OPEN equivalent
6692 if (request.status > OPEN) {