@samitouri / QOS-React / commits / 71ecaf8990

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Sebastian "Sebbie" Silbermann committed Jul 16, 2026 at 14:50 UTC 71ecaf89904a352e751ee43b00253c9927fb7183
1 file changed +228
packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js
+228
@@ -3932,4 +3932,232 @@ describe('ReactFlightAsyncDebugInfo', () => {
3932 `);
3933 }
3934 });
3935 +
3936 + it('does not lose I/O debug info when intermediate promises are garbage collected', async () => {
3937 + // Get a handle on the garbage collector without running the test with --expose-gc.
3938 + const v8 = require('v8');
3939 + const vm = require('vm');
3940 + v8.setFlagsFromString('--expose-gc');
3941 + const gc = vm.runInNewContext('gc');
3942 + v8.setFlagsFromString('--no-expose-gc');
3943 +
3944 + // The global setImmediate is patched to setTimeout in this test which
3945 + // registers as new I/O in async_hooks. Use the real setImmediate for
3946 + // yielding to the event loop while waiting for GC so that it doesn't add
3947 + // I/O entries to the debug info of the component below.
3948 + const {setImmediate: realSetImmediate} = require('timers');
3949 + function tick() {
3950 + return new Promise(resolve => realSetImmediate(resolve));
3951 + }
3952 +
3953 + let ioPromiseRef = null;
3954 + let collectedIntermediatePromises = false;
3955 +
3956 + async function getData(text) {
3957 + const promise = delay(1);
3958 + ioPromiseRef = new WeakRef(promise);
3959 + await promise;
3960 + return text.toUpperCase();
3961 + }
3962 +
3963 + async function waitForGarbageCollection(weakRef) {
3964 + // deref() keeps the target alive until the end of the current task, so
3965 + // check it on a later tick than the gc() call.
3966 + let collected = false;
3967 + for (let i = 0; !collected && i < 100; i++) {
3968 + gc();
3969 + await tick();
3970 + collected = weakRef.deref() === undefined;
3971 + await tick();
3972 + }
3973 + // The destroy() hooks of collected promises fire asynchronously. Yield
3974 + // a few more times to ensure they have all run.
3975 + for (let i = 0; i < 5; i++) {
3976 + gc();
3977 + await tick();
3978 + }
3979 + return collected;
3980 + }
3981 +
3982 + async function Component() {
3983 + const result = await getData('hi');
3984 + // At this point the intermediate promises (the delay() promise and
3985 + // getData's own async function promise) are unreachable. The async
3986 + // debug info graph holds them only weakly through WeakRefs. Force them
3987 + // to be garbage collected, which fires their async_hooks destroy()
3988 + // hooks, before this component resolves, which is when React walks the
3989 + // async graph to emit the component's debug info.
3990 + collectedIntermediatePromises =
3991 + await waitForGarbageCollection(ioPromiseRef);
3992 + return result;
3993 + }
3994 +
3995 + const stream = ReactServerDOMServer.renderToPipeableStream(<Component />);
3996 +
3997 + const readable = new Stream.PassThrough(streamOptions);
3998 +
3999 + const result = ReactServerDOMClient.createFromNodeStream(readable, {
4000 + moduleMap: {},
4001 + moduleLoading: {},
4002 + });
4003 + stream.pipe(readable);
4004 +
4005 + expect(await result).toBe('HI');
4006 + // If this fails, the GC helper above no longer actually collects the
4007 + // promises and this test is not testing anything.
4008 + expect(collectedIntermediatePromises).toBe(true);
4009 +
4010 + await finishLoadingStream(readable);
4011 + if (
4012 + __DEV__ &&
4013 + gate(
4014 + flags =>
4015 + flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo,
4016 + )
4017 + ) {
4018 + const debugInfo = getDebugInfo(result);
4019 + // The I/O entry for delay() must survive the garbage collection of the
4020 + // intermediate promises. The graph nodes are intentionally held
4021 + // strongly (only the promises themselves are held weakly through
4022 + // WeakRefs) so that the originating I/O is still reachable when the
4023 + // debug info is emitted after the promises are gone.
4024 + expect(debugInfo).toContainEqual(
4025 + expect.objectContaining({
4026 + awaited: expect.objectContaining({name: 'delay'}),
4027 + }),
4028 + );
4029 + expect(debugInfo).toMatchInlineSnapshot(`
4030 + [
4031 + {
4032 + "time": 0,
4033 + },
4034 + {
4035 + "env": "Server",
4036 + "key": null,
4037 + "name": "Component",
4038 + "props": {},
4039 + "stack": [
4040 + [
4041 + "Object.<anonymous>",
4042 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
4043 + 3995,
4044 + 109,
4045 + 3936,
4046 + 87,
4047 + ],
4048 + ],
4049 + },
4050 + {
4051 + "time": 0,
4052 + },
4053 + {
4054 + "awaited": {
4055 + "end": 0,
4056 + "env": "Server",
4057 + "name": "delay",
4058 + "owner": {
4059 + "env": "Server",
4060 + "key": null,
4061 + "name": "Component",
4062 + "props": {},
4063 + "stack": [
4064 + [
4065 + "Object.<anonymous>",
4066 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
4067 + 3995,
4068 + 109,
4069 + 3936,
4070 + 87,
4071 + ],
4072 + ],
4073 + },
4074 + "stack": [
4075 + [
4076 + "delay",
4077 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
4078 + 87,
4079 + 12,
4080 + 86,
4081 + 3,
4082 + ],
4083 + [
4084 + "getData",
4085 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
4086 + 3957,
4087 + 21,
4088 + 3956,
4089 + 5,
4090 + ],
4091 + [
4092 + "Component",
4093 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
4094 + 3983,
4095 + 26,
4096 + 3982,
4097 + 5,
4098 + ],
4099 + ],
4100 + "start": 0,
4101 + "value": {
4102 + "value": undefined,
4103 + },
4104 + },
4105 + "env": "Server",
4106 + "owner": {
4107 + "env": "Server",
4108 + "key": null,
4109 + "name": "Component",
4110 + "props": {},
4111 + "stack": [
4112 + [
4113 + "Object.<anonymous>",
4114 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
4115 + 3995,
4116 + 109,
4117 + 3936,
4118 + 87,
4119 + ],
4120 + ],
4121 + },
4122 + "stack": [
4123 + [
4124 + "getData",
4125 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
4126 + 3959,
4127 + 7,
4128 + 3956,
4129 + 5,
4130 + ],
4131 + [
4132 + "Component",
4133 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
4134 + 3983,
4135 + 26,
4136 + 3982,
4137 + 5,
4138 + ],
4139 + ],
4140 + },
4141 + {
4142 + "time": 0,
4143 + },
4144 + {
4145 + "time": 0,
4146 + },
4147 + {
4148 + "awaited": {
4149 + "byteSize": 0,
4150 + "end": 0,
4151 + "name": "rsc stream",
4152 + "owner": null,
4153 + "start": 0,
4154 + "value": {
4155 + "value": "stream",
4156 + },
4157 + },
4158 + },
4159 + ]
4160 + `);
4161 + }
4162 + });
4163 });