@samitouri / QOS-React-1 / commits / 7cafeff340

[Flight] Close Debug Channel when All Lazy References Have Been GC:ed (#33718)

When we have a debug channel open that can ask for more objects. That doesn't close until all lazy objects have been explicitly asked for. If you GC an object before the lazy references inside of it before asking for or releasing the objects, then it'll never close. This ensures that if there are no more PendingChunk and no more ResolvedModelChunk then we can close the connection. There's two sources of retaining the Response object. On one side we have a handle to it from the stream coming from the server. On the other side we have a handle to it from ResolvedModelChunk to ask for more data when we lazily parse a model. This PR makes a weak handle from the stream to the Response. However, it keeps a strong reference alive whenever we're waiting on a pending chunk because then the stream might be the root if the only listeners are the callbacks passed to the promise and no references to the promise itself. The pending chunks count can end up being zero even if we might get more data because the references might be inside lazy chunks. In this case the lazy chunks keeps the Response alive. When the lazy chunk gets parsed it can find more chunks that then end up pending to keep the response strongly alive until they resolve.

Sebastian Markbåge committed Jul 7, 2025 at 11:28 UTC 7cafeff340f44fff840b332d3463533dc2d3734b
3 files changed +209 -52
fixtures/flight/src/index.js
+43 -12
@@ -16,18 +16,49 @@ function findSourceMapURL(fileName) {
16
17 let updateRoot;
18 async function callServer(id, args) {
19 - const response = fetch('/', {
20 - method: 'POST',
21 - headers: {
22 - Accept: 'text/x-component',
23 - 'rsc-action': id,
24 - },
25 - body: await encodeReply(args),
26 - });
27 - const {returnValue, root} = await createFromFetch(response, {
28 - callServer,
29 - findSourceMapURL,
30 - });
19 + let response;
20 + if (
21 + process.env.NODE_ENV === 'development' &&
22 + typeof WebSocketStream === 'function'
23 + ) {
24 + const requestId = crypto.randomUUID();
25 + const wss = new WebSocketStream(
26 + 'ws://localhost:3001/debug-channel?' + requestId
27 + );
28 + const debugChannel = await wss.opened;
29 + response = createFromFetch(
30 + fetch('/', {
31 + method: 'POST',
32 + headers: {
33 + Accept: 'text/x-component',
34 + 'rsc-action': id,
35 + 'rsc-request-id': requestId,
36 + },
37 + body: await encodeReply(args),
38 + }),
39 + {
40 + callServer,
41 + debugChannel,
42 + findSourceMapURL,
43 + }
44 + );
45 + } else {
46 + response = createFromFetch(
47 + fetch('/', {
48 + method: 'POST',
49 + headers: {
50 + Accept: 'text/x-component',
51 + 'rsc-action': id,
52 + },
53 + body: await encodeReply(args),
54 + }),
55 + {
56 + callServer,
57 + findSourceMapURL,
58 + }
59 + );
60 + }
61 + const {returnValue, root} = await response;
62 // Refresh the tree with the new RSC payload.
63 startTransition(() => {
64 updateRoot(root);
packages/react-client/src/ReactFlightClient.js
+153 -33
@@ -332,7 +332,7 @@ export type FindSourceMapURLCallback = (
332
333 export type DebugChannelCallback = (message: string) => void;
334
335 -export type Response = {
335 +type Response = {
336 _bundlerConfig: ServerConsumerModuleMap,
337 _serverReferenceConfig: null | ServerManifest,
338 _moduleLoading: ModuleLoading,
@@ -351,6 +351,8 @@ export type Response = {
351 _closedReason: mixed,
352 _tempRefs: void | TemporaryReferenceSet, // the set temporary references can be resolved from
353 _timeOrigin: number, // Profiling-only
354 + _pendingChunks: number, // DEV-only
355 + _weakResponse: WeakResponse, // DEV-only
356 _debugRootOwner?: null | ReactComponentInfo, // DEV-only
357 _debugRootStack?: null | Error, // DEV-only
358 _debugRootTask?: null | ConsoleTask, // DEV-only
@@ -360,6 +362,54 @@ export type Response = {
362 _rootEnvironmentName: string, // DEV-only, the requested environment name.
363 };
364
365 +// This indirection exists only to clean up DebugChannel when all Lazy References are GC:ed.
366 +// Therefore we only use the indirection in DEV.
367 +type WeakResponse = {
368 + weak: WeakRef<Response>,
369 + response: null | Response, // This is null when there are no pending chunks.
370 +};
371 +
372 +export type {WeakResponse as Response};
373 +
374 +function hasGCedResponse(weakResponse: WeakResponse): boolean {
375 + return __DEV__ && weakResponse.weak.deref() === undefined;
376 +}
377 +
378 +function unwrapWeakResponse(weakResponse: WeakResponse): Response {
379 + if (__DEV__) {
380 + const response = weakResponse.weak.deref();
381 + if (response === undefined) {
382 + // eslint-disable-next-line react-internal/prod-error-codes
383 + throw new Error(
384 + 'We did not expect to receive new data after GC:ing the response.',
385 + );
386 + }
387 + return response;
388 + } else {
389 + return (weakResponse: any); // In prod we just use the real Response directly.
390 + }
391 +}
392 +
393 +function getWeakResponse(response: Response): WeakResponse {
394 + if (__DEV__) {
395 + return response._weakResponse;
396 + } else {
397 + return (response: any); // In prod we just use the real Response directly.
398 + }
399 +}
400 +
401 +function cleanupDebugChannel(debugChannel: DebugChannelCallback): void {
402 + // When a Response gets GC:ed because nobody is referring to any of the objects that lazily
403 + // loads from the Response anymore, then we can close the debug channel.
404 + debugChannel('');
405 +}
406 +
407 +// If FinalizationRegistry doesn't exist, we cannot use the debugChannel.
408 +const debugChannelRegistry =
409 + __DEV__ && typeof FinalizationRegistry === 'function'
410 + ? new FinalizationRegistry(cleanupDebugChannel)
411 + : null;
412 +
413 function readChunk<T>(chunk: SomeChunk<T>): T {
414 // If we have resolved content, we try to initialize it first which
415 // might put us back into one of the other states.
@@ -385,16 +435,32 @@ function readChunk<T>(chunk: SomeChunk<T>): T {
435 }
436 }
437
388 -export function getRoot<T>(response: Response): Thenable<T> {
438 +export function getRoot<T>(weakResponse: WeakResponse): Thenable<T> {
439 + const response = unwrapWeakResponse(weakResponse);
440 const chunk = getChunk(response, 0);
441 return (chunk: any);
442 }
443
444 function createPendingChunk<T>(response: Response): PendingChunk<T> {
445 + if (__DEV__) {
446 + // Retain a strong reference to the Response while we wait for the result.
447 + response._pendingChunks++;
448 + response._weakResponse.response = response;
449 + }
450 // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
451 return new ReactPromise(PENDING, null, null);
452 }
453
454 +function releasePendingChunk(response: Response, chunk: SomeChunk<any>): void {
455 + if (__DEV__ && chunk.status === PENDING) {
456 + if (--response._pendingChunks === 0) {
457 + // We're no longer waiting for any more chunks. We can release the strong reference
458 + // to the response. We'll regain it if we ask for any more data later on.
459 + response._weakResponse.response = null;
460 + }
461 + }
462 +}
463 +
464 function createBlockedChunk<T>(response: Response): BlockedChunk<T> {
465 // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
466 return new ReactPromise(BLOCKED, null, null);
@@ -525,7 +591,11 @@ function wakeChunkIfInitialized<T>(
591 }
592 }
593
528 -function triggerErrorOnChunk<T>(chunk: SomeChunk<T>, error: mixed): void {
594 +function triggerErrorOnChunk<T>(
595 + response: Response,
596 + chunk: SomeChunk<T>,
597 + error: mixed,
598 +): void {
599 if (chunk.status !== PENDING && chunk.status !== BLOCKED) {
600 // If we get more data to an already resolved ID, we assume that it's
601 // a stream chunk since any other row shouldn't have more than one entry.
@@ -535,6 +605,7 @@ function triggerErrorOnChunk<T>(chunk: SomeChunk<T>, error: mixed): void {
605 controller.error(error);
606 return;
607 }
608 + releasePendingChunk(response, chunk);
609 const listeners = chunk.reason;
610 const erroredChunk: ErroredChunk<T> = (chunk: any);
611 erroredChunk.status = ERRORED;
@@ -635,6 +706,7 @@ function resolveModelChunk<T>(
706 controller.enqueueModel(value);
707 return;
708 }
709 + releasePendingChunk(response, chunk);
710 const resolveListeners = chunk.value;
711 const rejectListeners = chunk.reason;
712 const resolvedChunk: ResolvedModelChunk<T> = (chunk: any);
@@ -652,6 +724,7 @@ function resolveModelChunk<T>(
724 }
725
726 function resolveModuleChunk<T>(
727 + response: Response,
728 chunk: SomeChunk<T>,
729 value: ClientReference<T>,
730 ): void {
@@ -659,6 +732,7 @@ function resolveModuleChunk<T>(
732 // We already resolved. We didn't expect to see this.
733 return;
734 }
735 + releasePendingChunk(response, chunk);
736 const resolveListeners = chunk.value;
737 const rejectListeners = chunk.reason;
738 const resolvedChunk: ResolvedModuleChunk<T> = (chunk: any);
@@ -766,7 +840,15 @@ function initializeModuleChunk<T>(chunk: ResolvedModuleChunk<T>): void {
840
841 // Report that any missing chunks in the model is now going to throw this
842 // error upon read. Also notify any pending promises.
769 -export function reportGlobalError(response: Response, error: Error): void {
843 +export function reportGlobalError(
844 + weakResponse: WeakResponse,
845 + error: Error,
846 +): void {
847 + if (hasGCedResponse(weakResponse)) {
848 + // Ignore close signal if we are not awaiting any more pending chunks.
849 + return;
850 + }
851 + const response = unwrapWeakResponse(weakResponse);
852 response._closed = true;
853 response._closedReason = error;
854 response._chunks.forEach(chunk => {
@@ -774,7 +856,7 @@ export function reportGlobalError(response: Response, error: Error): void {
856 // trigger an error but if it wasn't then we need to
857 // because we won't be getting any new data to resolve it.
858 if (chunk.status === PENDING) {
777 - triggerErrorOnChunk(chunk, error);
859 + triggerErrorOnChunk(response, chunk, error);
860 }
861 });
862 if (__DEV__) {
@@ -1218,7 +1300,7 @@ function rejectReference(
1300 reference: InitializationReference,
1301 error: mixed,
1302 ): void {
1221 - const {handler} = reference;
1303 + const {handler, response} = reference;
1304
1305 if (handler.errored) {
1306 // We've already errored. We could instead build up an AggregateError
@@ -1263,7 +1345,7 @@ function rejectReference(
1345 }
1346 }
1347
1266 - triggerErrorOnChunk(chunk, error);
1348 + triggerErrorOnChunk(response, chunk, error);
1349 }
1350
1351 function waitForReference<T>(
@@ -1482,7 +1564,7 @@ function loadServerReference<A: Iterable<any>, T>(
1564 }
1565 }
1566
1485 - triggerErrorOnChunk(chunk, error);
1567 + triggerErrorOnChunk(response, chunk, error);
1568 }
1569
1570 promise.then(fulfill, reject);
@@ -2025,6 +2107,11 @@ function ResponseInstance(
2107 this._timeOrigin = 0;
2108 }
2109 if (__DEV__) {
2110 + this._pendingChunks = 0;
2111 + this._weakResponse = {
2112 + weak: new WeakRef(this),
2113 + response: this,
2114 + };
2115 // TODO: The Flight Client can be used in a Client Environment too and we should really support
2116 // getting the owner there as well, but currently the owner of ReactComponentInfo is typed as only
2117 // supporting other ReactComponentInfo as owners (and not Fiber or Fizz's ComponentStackNode).
@@ -2059,6 +2146,15 @@ function ResponseInstance(
2146 this._debugChannel = debugChannel;
2147 this._replayConsole = replayConsole;
2148 this._rootEnvironmentName = rootEnv;
2149 + if (debugChannel) {
2150 + if (debugChannelRegistry === null) {
2151 + // We can't safely clean things up later, so we immediately close the debug channel.
2152 + debugChannel('');
2153 + this._debugChannel = undefined;
2154 + } else {
2155 + debugChannelRegistry.register(this, debugChannel);
2156 + }
2157 + }
2158 }
2159 if (enableProfilerTimer && enableComponentPerformanceTrack) {
2160 // Since we don't know when recording of profiles will start and stop, we have to
@@ -2084,20 +2180,22 @@ export function createResponse(
2180 replayConsole: boolean, // DEV-only
2181 environmentName: void | string, // DEV-only
2182 debugChannel: void | DebugChannelCallback, // DEV-only
2087 -): Response {
2088 - // $FlowFixMe[invalid-constructor]: the shapes are exact here but Flow doesn't like constructors
2089 - return new ResponseInstance(
2090 - bundlerConfig,
2091 - serverReferenceConfig,
2092 - moduleLoading,
2093 - callServer,
2094 - encodeFormAction,
2095 - nonce,
2096 - temporaryReferences,
2097 - findSourceMapURL,
2098 - replayConsole,
2099 - environmentName,
2100 - debugChannel,
2183 +): WeakResponse {
2184 + return getWeakResponse(
2185 + // $FlowFixMe[invalid-constructor]: the shapes are exact here but Flow doesn't like constructors
2186 + new ResponseInstance(
2187 + bundlerConfig,
2188 + serverReferenceConfig,
2189 + moduleLoading,
2190 + callServer,
2191 + encodeFormAction,
2192 + nonce,
2193 + temporaryReferences,
2194 + findSourceMapURL,
2195 + replayConsole,
2196 + environmentName,
2197 + debugChannel,
2198 + ),
2199 );
2200 }
2201
@@ -2111,6 +2209,7 @@ function resolveDebugHalt(response: Response, id: number): void {
2209 if (chunk.status !== PENDING && chunk.status !== BLOCKED) {
2210 return;
2211 }
2212 + releasePendingChunk(response, chunk);
2213 const haltedChunk: HaltedChunk<any> = (chunk: any);
2214 haltedChunk.status = HALTED;
2215 haltedChunk.value = null;
@@ -2142,6 +2241,9 @@ function resolveText(response: Response, id: number, text: string): void {
2241 controller.enqueueValue(text);
2242 return;
2243 }
2244 + if (chunk) {
2245 + releasePendingChunk(response, chunk);
2246 + }
2247 chunks.set(id, createInitializedTextChunk(response, text));
2248 }
2249
@@ -2160,6 +2262,9 @@ function resolveBuffer(
2262 controller.enqueueValue(buffer);
2263 return;
2264 }
2265 + if (chunk) {
2266 + releasePendingChunk(response, chunk);
2267 + }
2268 chunks.set(id, createInitializedBufferChunk(response, buffer));
2269 }
2270
@@ -2197,14 +2302,15 @@ function resolveModule(
2302 blockedChunk = createBlockedChunk(response);
2303 chunks.set(id, blockedChunk);
2304 } else {
2305 + releasePendingChunk(response, chunk);
2306 // This can't actually happen because we don't have any forward
2307 // references to modules.
2308 blockedChunk = (chunk: any);
2309 blockedChunk.status = BLOCKED;
2310 }
2311 promise.then(
2206 - () => resolveModuleChunk(blockedChunk, clientReference),
2207 - error => triggerErrorOnChunk(blockedChunk, error),
2312 + () => resolveModuleChunk(response, blockedChunk, clientReference),
2313 + error => triggerErrorOnChunk(response, blockedChunk, error),
2314 );
2315 } else {
2316 if (!chunk) {
@@ -2212,7 +2318,7 @@ function resolveModule(
2318 } else {
2319 // This can't actually happen because we don't have any forward
2320 // references to modules.
2215 - resolveModuleChunk(chunk, clientReference);
2321 + resolveModuleChunk(response, chunk, clientReference);
2322 }
2323 }
2324 }
@@ -2233,6 +2339,7 @@ function resolveStream<T: ReadableStream | $AsyncIterable<any, any, void>>(
2339 // We already resolved. We didn't expect to see this.
2340 return;
2341 }
2342 + releasePendingChunk(response, chunk);
2343 const resolveListeners = chunk.value;
2344 const resolvedChunk: InitializedStreamChunk<T> = (chunk: any);
2345 resolvedChunk.status = INITIALIZED;
@@ -2432,7 +2539,7 @@ function startAsyncIterable<T>(
2539 createPendingChunk<IteratorResult<T, T>>(response);
2540 }
2541 while (nextWriteIndex < buffer.length) {
2435 - triggerErrorOnChunk(buffer[nextWriteIndex++], error);
2542 + triggerErrorOnChunk(response, buffer[nextWriteIndex++], error);
2543 }
2544 },
2545 };
@@ -2569,7 +2676,7 @@ function resolvePostponeProd(response: Response, id: number): void {
2676 if (!chunk) {
2677 chunks.set(id, createErrorChunk(response, postponeInstance));
2678 } else {
2572 - triggerErrorOnChunk(chunk, postponeInstance);
2679 + triggerErrorOnChunk(response, chunk, postponeInstance);
2680 }
2681 }
2682
@@ -2608,7 +2715,7 @@ function resolvePostponeDev(
2715 if (!chunk) {
2716 chunks.set(id, createErrorChunk(response, postponeInstance));
2717 } else {
2611 - triggerErrorOnChunk(chunk, postponeInstance);
2718 + triggerErrorOnChunk(response, chunk, postponeInstance);
2719 }
2720 }
2721
@@ -3690,7 +3797,7 @@ function processFullStringRow(
3797 if (!chunk) {
3798 chunks.set(id, createErrorChunk(response, errorWithDigest));
3799 } else {
3693 - triggerErrorOnChunk(chunk, errorWithDigest);
3800 + triggerErrorOnChunk(response, chunk, errorWithDigest);
3801 }
3802 return;
3803 }
@@ -3819,9 +3926,14 @@ function processFullStringRow(
3926 }
3927
3928 export function processBinaryChunk(
3822 - response: Response,
3929 + weakResponse: WeakResponse,
3930 chunk: Uint8Array,
3931 ): void {
3932 + if (hasGCedResponse(weakResponse)) {
3933 + // Ignore more chunks if we've already GC:ed all listeners.
3934 + return;
3935 + }
3936 + const response = unwrapWeakResponse(weakResponse);
3937 let i = 0;
3938 let rowState = response._rowState;
3939 let rowID = response._rowID;
@@ -3938,7 +4050,15 @@ export function processBinaryChunk(
4050 response._rowLength = rowLength;
4051 }
4052
3941 -export function processStringChunk(response: Response, chunk: string): void {
4053 +export function processStringChunk(
4054 + weakResponse: WeakResponse,
4055 + chunk: string,
4056 +): void {
4057 + if (hasGCedResponse(weakResponse)) {
4058 + // Ignore more chunks if we've already GC:ed all listeners.
4059 + return;
4060 + }
4061 + const response = unwrapWeakResponse(weakResponse);
4062 // This is a fork of processBinaryChunk that takes a string as input.
4063 // This can't be just any binary chunk coverted to a string. It needs to be
4064 // in the same offsets given from the Flight Server. E.g. if it's shifted by
@@ -4100,12 +4220,12 @@ function createFromJSONCallback(response: Response) {
4220 };
4221 }
4222
4103 -export function close(response: Response): void {
4223 +export function close(weakResponse: WeakResponse): void {
4224 // In case there are any remaining unresolved chunks, they won't
4225 // be resolved now. So we need to issue an error to those.
4226 // Ideally we should be able to early bail out if we kept a
4227 // ref count of pending chunks.
4108 - reportGlobalError(response, new Error('Connection closed.'));
4228 + reportGlobalError(weakResponse, new Error('Connection closed.'));
4229 }
4230
4231 function getCurrentOwnerInDEV(): null | ReactComponentInfo {
packages/react-client/src/__tests__/ReactFlight-test.js
+13 -7
@@ -92,21 +92,27 @@ function getDebugInfo(obj) {
92 return debugInfo;
93 }
94
95 -const heldValues = [];
96 -let finalizationCallback;
95 +const finalizationRegistries = [];
96 function FinalizationRegistryMock(callback) {
98 - finalizationCallback = callback;
97 + this._heldValues = [];
98 + this._callback = callback;
99 + finalizationRegistries.push(this);
100 }
101 FinalizationRegistryMock.prototype.register = function (target, heldValue) {
101 - heldValues.push(heldValue);
102 + this._heldValues.push(heldValue);
103 };
104 global.FinalizationRegistry = FinalizationRegistryMock;
105
106 function gc() {
106 - for (let i = 0; i < heldValues.length; i++) {
107 - finalizationCallback(heldValues[i]);
107 + for (let i = 0; i < finalizationRegistries.length; i++) {
108 + const registry = finalizationRegistries[i];
109 + const callback = registry._callback;
110 + const heldValues = registry._heldValues;
111 + for (let j = 0; j < heldValues.length; j++) {
112 + callback(heldValues[j]);
113 + }
114 + heldValues.length = 0;
115 }
109 - heldValues.length = 0;
116 }
117
118 let act;