@samitouri / QOS-React-1 / commits / 055705ca01

[Flight] Abort the cache signal when debug objects are retained (#37342)

A debug channel with a readable side lets the client fetch debug objects lazily. For example, React serializes each component's props into the debug model and defers the part of an object tree that exceeds the model's object limit. A deferred object stays retained, and its debug chunk stays pending, until the client asks for it or the channel closes. The render can therefore complete while such an object is still outstanding. When that happens, `flushCompletedChunks` closes the main stream, sets the request status to `CLOSED`, and returns before it reaches the block that releases the render's resources. Once the client closes the debug channel and the retained objects drop, a later flush does reach that block, but the cache controller is only aborted while the status is below `ABORTING`, and `CLOSED` is above it. The signal is therefore never aborted at all rather than merely released late, so anything that waits on `cacheSignal()` to clean up resources waits for the lifetime of the process. This change moves the cache controller abort above the debug stream bookkeeping. An empty pending chunk count already means the render is complete, and debug chunks carry development-only instrumentation rather than the render's output, so the cache signal can abort at that point no matter what the debug stream is still doing. The path that writes debug chunks on the main stream can now reach this code on several flushes, which is safe because aborting an aborted controller does nothing a second time. The taint queue cleanup stays where it is. A tainted typed array, `DataView` or blob is checked against the taint registry as its chunk is written, and such a write can happen long after the render completes, either because the client queried a deferred debug object or because a blob's stream resolved late. Moving it up would let those writes through unchecked, and it would fix nothing, because unlike the abort it never sat behind the status guard.

Hendrik Liebau committed Aug 22, 2026 at 18:05 UTC 055705ca01766d2a4379261b05e7990a849bdedc
2 files changed +98 -6
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+79
@@ -3082,6 +3082,85 @@ describe('ReactFlightDOMBrowser', () => {
3082 }
3083 });
3084
3085 + it('should abort the cache signal when a render completes while debug objects are still retained', async () => {
3086 + // A debug channel with a readable side lets the client fetch debug objects
3087 + // lazily. React serializes each component's props into the debug model, and
3088 + // it defers the part of an object tree that exceeds the model's object
3089 + // limit. A deferred object stays retained, and its debug chunk stays
3090 + // pending, until the client asks for it or the channel closes. The render
3091 + // below finishes while one such object is outstanding.
3092 + function createDeepJSX(n) {
3093 + if (n <= 0) {
3094 + return null;
3095 + }
3096 + return <div>{createDeepJSX(n - 1)}</div>;
3097 + }
3098 +
3099 + let cacheSignal;
3100 +
3101 + function ServerComponent() {
3102 + cacheSignal = ReactServer.cacheSignal();
3103 + return <div>not using props</div>;
3104 + }
3105 +
3106 + let debugChannelReadableController;
3107 + const debugChunks = [];
3108 +
3109 + const debugChannelReadable = new ReadableStream({
3110 + start(controller) {
3111 + debugChannelReadableController = controller;
3112 + },
3113 + });
3114 +
3115 + const stream = await serverAct(() =>
3116 + ReactServerDOMServer.renderToReadableStream(
3117 + // These children nest deeper than the debug model's object limit.
3118 + <ServerComponent>{createDeepJSX(20)}</ServerComponent>,
3119 + webpackMap,
3120 + {
3121 + debugChannel: {
3122 + readable: debugChannelReadable,
3123 + writable: new WritableStream({
3124 + write(chunk) {
3125 + debugChunks.push(chunk);
3126 + },
3127 + }),
3128 + },
3129 + },
3130 + ),
3131 + );
3132 +
3133 + const reader = stream.getReader();
3134 + while (true) {
3135 + const {done} = await reader.read();
3136 + if (done) {
3137 + break;
3138 + }
3139 + }
3140 + await serverAct(() => {});
3141 +
3142 + if (__DEV__) {
3143 + // Fail loudly if the setup stops deferring anything, for example because
3144 + // the object limit changed. Without a retained object this test passes
3145 + // for the wrong reason.
3146 + const debugOutput = debugChunks
3147 + .map(chunk => new TextDecoder().decode(chunk))
3148 + .join('');
3149 + expect(debugOutput).toContain('$Y');
3150 + }
3151 +
3152 + expect(cacheSignal.aborted).toBe(true);
3153 +
3154 + // Closing the debug channel drops the retained objects. The signal must
3155 + // already be aborted at that point, and must stay aborted.
3156 + await serverAct(() => {
3157 + debugChannelReadableController.close();
3158 + });
3159 + await serverAct(() => {});
3160 +
3161 + expect(cacheSignal.aborted).toBe(true);
3162 + });
3163 +
3164 it('should resolve a cycle between debug info and the value it produces when using a debug channel', async () => {
3165 // Same as `should resolve a cycle between debug info and the value it produces`, but using a debug channel.
3166
packages/react-server/src/ReactFlightServer.js
+19 -6
@@ -6488,6 +6488,25 @@ function flushCompletedChunks(request: Request): void {
6488 flushBuffered(destination);
6489 }
6490 if (request.pendingChunks === 0) {
6491 + // There are no pending chunks left, so the render is complete and its cache
6492 + // signal is aborted here. Debug chunks can still be pending, but they carry
6493 + // development-only instrumentation rather than the render's output.
6494 + //
6495 + // This runs before the stream bookkeeping below, because that bookkeeping
6496 + // can close the main stream and set the status to CLOSED while debug chunks
6497 + // are outstanding. The abort only happens below ABORTING, so a later flush
6498 + // would skip it. Repeated flushes are safe, because aborting an aborted
6499 + // controller does nothing a second time.
6500 + //
6501 + // The taint queue stays untouched here. Debug chunks are checked against
6502 + // the taint registry as they are written, and a deferred debug object can
6503 + // be written long after this point.
6504 + if (request.status < ABORTING) {
6505 + const abortReason = new Error(
6506 + 'This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources.',
6507 + );
6508 + request.cacheController.abort(abortReason);
6509 + }
6510 if (__DEV__) {
6511 const debugDestination = request.debugDestination;
6512 if (request.pendingDebugChunks === 0) {
@@ -6518,12 +6537,6 @@ function flushCompletedChunks(request: Request): void {
6537 if (enableTaint) {
6538 cleanupTaintQueue(request);
6539 }
6521 - if (request.status < ABORTING) {
6522 - const abortReason = new Error(
6523 - 'This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources.',
6524 - );
6525 - request.cacheController.abort(abortReason);
6526 - }
6540 if (request.destination !== null) {
6541 request.status = CLOSED;
6542 close(request.destination);