Pewarta Proxy

📋 Server Request & Proxy Logs

Loading active proxy settings...
Developed by Khairul Adha (adhexa.id)
Mengambil log server...

⚙️ Pengaturan Proxy Target

Skip to content

Commit 77ed3f5

Browse files
authored
[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.
1 parent eafeac0 commit 77ed3f5

37 files changed

Lines changed: 607 additions & 584 deletions

‎packages/react-client/src/ReactFlightReplyClient.js‎

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ import getPrototypeOf from 'shared/getPrototypeOf';
3939

4040
const ObjectPrototype = Object.prototype;
4141

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+
4248
import {
4349
usedWithSSR,
4450
checkEvalAvailabilityOnceDev,
@@ -184,14 +190,58 @@ export function processReply(
184190
root: ReactServerValue,
185191
formFieldPrefix: string,
186192
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 {
190197
let nextPartId = 1;
191198
let pendingParts = 0;
192199
let formData: null | FormData = null;
193200
const writtenObjects: WeakMap<Reference, string> = new WeakMap();
194201
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+
}
195245

196246
if (__DEV__) {
197247
// We use eval to create fake function stacks which includes Component stacks.
@@ -894,6 +944,9 @@ export function processReply(
894944
}
895945

896946
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();
897950
if (pendingParts > 0) {
898951
pendingParts = 0; // Don't resolve again later.
899952
// Resolve with what we have so far, which may have holes at this point.
@@ -920,7 +973,17 @@ export function processReply(
920973
}
921974
}
922975

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+
}
924987
}
925988

926989
const boundCache: WeakMap<

‎packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1441,4 +1441,162 @@ describe('ReactDOMFizzStaticBrowser', () => {
14411441

14421442
expect(getVisibleChildren(container)).toEqual(<div>Hi</div>);
14431443
});
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+
});
14441602
});

‎packages/react-dom/src/server/ReactDOMFizzServerBrowser.js‎

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
startFlowing,
2525
stopFlowing,
2626
abort,
27+
attachAbortSignal,
2728
} from 'react-server/src/ReactFizzServer';
2829

2930
import {
@@ -151,16 +152,7 @@ function renderToReadableStream(
151152
options ? options.formState : undefined,
152153
);
153154
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);
164156
}
165157
startWork(request);
166158
});
@@ -221,16 +213,7 @@ function resume(
221213
onFatalError,
222214
);
223215
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);
234217
}
235218
startWork(request);
236219
});

‎packages/react-dom/src/server/ReactDOMFizzServerBun.js‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
startFlowing,
2424
stopFlowing,
2525
abort,
26+
attachAbortSignal,
2627
} from 'react-server/src/ReactFizzServer';
2728

2829
import {
@@ -140,16 +141,7 @@ function renderToReadableStream(
140141
options ? options.formState : undefined,
141142
);
142143
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);
153145
}
154146
startWork(request);
155147
});

‎packages/react-dom/src/server/ReactDOMFizzServerEdge.js‎

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
startFlowing,
2525
stopFlowing,
2626
abort,
27+
attachAbortSignal,
2728
} from 'react-server/src/ReactFizzServer';
2829

2930
import {
@@ -151,16 +152,7 @@ function renderToReadableStream(
151152
options ? options.formState : undefined,
152153
);
153154
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);
164156
}
165157
startWork(request);
166158
});
@@ -221,16 +213,7 @@ function resume(
221213
onFatalError,
222214
);
223215
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);
234217
}
235218
startWork(request);
236219
});

0 commit comments

Comments
 (0)