[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
});