From bfeb469c1c0edaa2765498d8522ff7d5210a2361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Tue, 24 Mar 2026 23:12:20 +0900 Subject: [PATCH] [Flight] Fix isValidElement returning false for elements with a debug channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `debugChannel` with a readable stream is provided to `createFromNodeStream`, positions 4 (owner) and 5 (stack) in RSC element tuples reference pending debug chunks. The previous code tracked these as blocking deps, which caused elements to be wrapped in `REACT_LAZY_TYPE` via `createLazyChunkWrapper` — breaking `isValidElement()`, `cloneElement()`, and hydration checks. Track debug-only deps (positions 4/5 with an active debug channel) separately in a new `debugDeps` counter on `InitializationHandler`. Only lazy-wrap when there are real blocking deps beyond debug-only ones. After all debug deps resolve, call `initializeElement()` again so `_debugStack` gets normalized from the raw serialized string into an `Error` object, which is required by `captureOwnerStack` / `formatOwnerStack`. Fixes https://github.com/facebook/react/issues/36097 --- .../react-client/src/ReactFlightClient.js | 58 ++++++++++++++----- .../src/__tests__/ReactFlightDOMNode-test.js | 49 ++++++++++++++++ 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index fa89cf69ad67..89769fc64de0 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -931,6 +931,7 @@ type InitializationHandler = { value: any, reason: any, deps: number, + debugDeps: number, errored: boolean, }; let initializingHandler: null | InitializationHandler = null; @@ -1412,7 +1413,7 @@ function createElement( } return createLazyChunkWrapper(erroredChunk, validated); } - if (handler.deps > 0) { + if (handler.deps > (__DEV__ ? handler.debugDeps : 0)) { // We have blocked references inside this Element but we can turn this into // a Lazy node referencing this Element to let everything around it proceed. const blockedChunk: BlockedChunk> = @@ -1427,6 +1428,9 @@ function createElement( } return lazyNode; } + if (__DEV__ && handler.debugDeps > 0) { + handler.value = element; + } } if (__DEV__) { initializeElement(response, element, null); @@ -1669,6 +1673,19 @@ function fulfillReference( if (handler.deps === 0) { const chunk = handler.chunk; if (chunk === null || chunk.status !== BLOCKED) { + if (__DEV__ && chunk === null && handler.debugDeps > 0) { + // All debug-only deps (owner/stack from debug channel) have resolved. + // Re-initialize the element so _debugStack gets normalized into an Error + // object now that the raw stack string is available. + const elementValue = handler.value; + if ( + typeof elementValue === 'object' && + elementValue !== null && + elementValue.$$typeof === REACT_ELEMENT_TYPE + ) { + initializeElement(response, elementValue, null); + } + } return; } const resolveListeners = chunk.value; @@ -1745,23 +1762,28 @@ function waitForReference( path: Array, isAwaitingDebugInfo: boolean, // DEV-only ): T { - if ( - __DEV__ && - (response._debugChannel === undefined || - !response._debugChannel.hasReadable) - ) { + let isDebugOnlyDep = false; + if (__DEV__) { if ( referencedChunk.status === PENDING && parentObject[0] === REACT_ELEMENT_TYPE && (key === '4' || key === '5') ) { - // If the parent object is an unparsed React element tuple, and this is a reference - // to the owner or debug stack. Then we expect the chunk to have been emitted earlier - // in the stream. It might be blocked on other things but chunk should no longer be pending. - // If it's still pending that suggests that it was referencing an object in the debug - // channel, but no debug channel was wired up so it's missing. In this case we can just - // drop the debug info instead of halting the whole stream. - return (null: any); + // The parent object is an unparsed React element tuple and this is a reference + // to the owner (pos 4) or debug stack (pos 5) — dev-only metadata. + if ( + response._debugChannel === undefined || + !response._debugChannel.hasReadable + ) { + // No debug channel is wired up so this data will never arrive. + // Drop the reference instead of halting the stream. + return (null: any); + } + // A debug channel with a readable is active: the data will arrive, but + // owner/stack must never block element construction because that would + // wrap the element in a lazy chunk ($$typeof: REACT_LAZY_TYPE) and break + // isValidElement(), cloneElement(), and hydration. + isDebugOnlyDep = true; } } @@ -1769,6 +1791,9 @@ function waitForReference( if (initializingHandler) { handler = initializingHandler; handler.deps++; + if (__DEV__ && isDebugOnlyDep) { + handler.debugDeps++; + } } else { handler = initializingHandler = { parent: null, @@ -1776,6 +1801,7 @@ function waitForReference( value: null, reason: null, deps: 1, + debugDeps: __DEV__ && isDebugOnlyDep ? 1 : 0, errored: false, }; } @@ -1865,6 +1891,7 @@ function loadServerReference, T>( value: null, reason: null, deps: 1, + debugDeps: 0, errored: false, }; } @@ -2108,6 +2135,7 @@ function getOutlinedModel( value: null, reason: null, deps: 1, + debugDeps: 0, errored: false, }; } @@ -2127,6 +2155,7 @@ function getOutlinedModel( value: null, reason: referencedChunk.reason, deps: 0, + debugDeps: 0, errored: true, }; } @@ -2205,6 +2234,7 @@ function getOutlinedModel( value: null, reason: null, deps: 1, + debugDeps: 0, errored: false, }; } @@ -2224,6 +2254,7 @@ function getOutlinedModel( value: null, reason: chunk.reason, deps: 0, + debugDeps: 0, errored: true, }; } @@ -2377,6 +2408,7 @@ function parseModelString( value: null, reason: null, deps: 0, + debugDeps: 0, errored: false, }; } diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index 0ac1f84cd396..080a180a8199 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -1909,4 +1909,53 @@ describe('ReactFlightDOMNode', () => { globalThis.eval = previousEval; } }); + + it('isValidElement returns true for elements deserialized with a delayed debugChannel', async () => { + function ServerApp() { + return ReactServer.createElement('div', null, 'hello'); + } + + const {delayedStream, resolveDelayedStream} = createDelayedStream(); + + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream( + ReactServer.createElement(ServerApp, null), + webpackMap, + { + debugChannel: new Stream.Writable({ + write(chunk, encoding, callback) { + delayedStream.write(chunk, encoding); + callback(); + }, + final() { + delayedStream.end(); + }, + }), + }, + ), + ); + + const readable = new Stream.PassThrough(streamOptions); + rscStream.pipe(readable); + + const serverConsumerManifest = { + moduleMap: {}, + moduleLoading: webpackModuleLoading, + }; + + const response = ReactServerDOMClient.createFromNodeStream( + readable, + serverConsumerManifest, + {debugChannel: delayedStream}, + ); + + setTimeout(resolveDelayedStream); + + let deserialized; + await serverAct(async () => { + deserialized = await response; + }); + + expect(React.isValidElement(deserialized)).toBe(true); + }); });