[test] Add Flight regression test for async debug info surviving Promise GC (#37037)

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
Sebastian "Sebbie" Silbermann
2026-07-16 14:50:17 +02:00
committed by GitHub
co-authored by Hanzo Dev
parent c49275ba14
commit 7623c8a446
@@ -3932,4 +3932,232 @@ describe('ReactFlightAsyncDebugInfo', () => {
`);
}
});
it('does not lose I/O debug info when intermediate promises are garbage collected', async () => {
// Get a handle on the garbage collector without running the test with --expose-gc.
const v8 = require('v8');
const vm = require('vm');
v8.setFlagsFromString('--expose-gc');
const gc = vm.runInNewContext('gc');
v8.setFlagsFromString('--no-expose-gc');
// The global setImmediate is patched to setTimeout in this test which
// registers as new I/O in async_hooks. Use the real setImmediate for
// yielding to the event loop while waiting for GC so that it doesn't add
// I/O entries to the debug info of the component below.
const {setImmediate: realSetImmediate} = require('timers');
function tick() {
return new Promise(resolve => realSetImmediate(resolve));
}
let ioPromiseRef = null;
let collectedIntermediatePromises = false;
async function getData(text) {
const promise = delay(1);
ioPromiseRef = new WeakRef(promise);
await promise;
return text.toUpperCase();
}
async function waitForGarbageCollection(weakRef) {
// deref() keeps the target alive until the end of the current task, so
// check it on a later tick than the gc() call.
let collected = false;
for (let i = 0; !collected && i < 100; i++) {
gc();
await tick();
collected = weakRef.deref() === undefined;
await tick();
}
// The destroy() hooks of collected promises fire asynchronously. Yield
// a few more times to ensure they have all run.
for (let i = 0; i < 5; i++) {
gc();
await tick();
}
return collected;
}
async function Component() {
const result = await getData('hi');
// At this point the intermediate promises (the delay() promise and
// getData's own async function promise) are unreachable. The async
// debug info graph holds them only weakly through WeakRefs. Force them
// to be garbage collected, which fires their async_hooks destroy()
// hooks, before this component resolves, which is when React walks the
// async graph to emit the component's debug info.
collectedIntermediatePromises =
await waitForGarbageCollection(ioPromiseRef);
return result;
}
const stream = ReactServerDOMServer.renderToPipeableStream(<Component />);
const readable = new Stream.PassThrough(streamOptions);
const result = ReactServerDOMClient.createFromNodeStream(readable, {
moduleMap: {},
moduleLoading: {},
});
stream.pipe(readable);
expect(await result).toBe('HI');
// If this fails, the GC helper above no longer actually collects the
// promises and this test is not testing anything.
expect(collectedIntermediatePromises).toBe(true);
await finishLoadingStream(readable);
if (
__DEV__ &&
gate(
flags =>
flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo,
)
) {
const debugInfo = getDebugInfo(result);
// The I/O entry for delay() must survive the garbage collection of the
// intermediate promises. The graph nodes are intentionally held
// strongly (only the promises themselves are held weakly through
// WeakRefs) so that the originating I/O is still reachable when the
// debug info is emitted after the promises are gone.
expect(debugInfo).toContainEqual(
expect.objectContaining({
awaited: expect.objectContaining({name: 'delay'}),
}),
);
expect(debugInfo).toMatchInlineSnapshot(`
[
{
"time": 0,
},
{
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3995,
109,
3936,
87,
],
],
},
{
"time": 0,
},
{
"awaited": {
"end": 0,
"env": "Server",
"name": "delay",
"owner": {
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3995,
109,
3936,
87,
],
],
},
"stack": [
[
"delay",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
87,
12,
86,
3,
],
[
"getData",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3957,
21,
3956,
5,
],
[
"Component",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3983,
26,
3982,
5,
],
],
"start": 0,
"value": {
"value": undefined,
},
},
"env": "Server",
"owner": {
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3995,
109,
3936,
87,
],
],
},
"stack": [
[
"getData",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3959,
7,
3956,
5,
],
[
"Component",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
3983,
26,
3982,
5,
],
],
},
{
"time": 0,
},
{
"time": 0,
},
{
"awaited": {
"byteSize": 0,
"end": 0,
"name": "rsc stream",
"owner": null,
"start": 0,
"value": {
"value": "stream",
},
},
},
]
`);
}
});
});