From b07aa7d643ec9028e452612c3ff2c17a6cee6bb7 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 9 Feb 2026 16:17:53 +0100 Subject: [PATCH 1/3] [Flight] Fix `encodeReply` for JSX with temporary references (#35730) `encodeReply` throws "React Element cannot be passed to Server Functions from the Client without a temporary reference set" when a React element is the root value of a `serializeModel` call (either passed directly or resolved from a promise), even when a temporary reference set is provided. The cause is that `resolveToJSON` hits the `REACT_ELEMENT_TYPE` switch case before reaching the `existingReference`/`modelRoot` check that regular objects benefit from. The synthetic JSON root created by `JSON.stringify` is never tracked in `writtenObjects`, so `parentReference` is `undefined` and the code falls through to the throw. This adds a `modelRoot` check in the `REACT_ELEMENT_TYPE` case, following the same pattern used for promises and plain objects. The added `JSX as root model` test also uncovered a pre-existing crash in the Flight Client: when the JSX element round-trips back, it arrives as a frozen object (client-created elements are frozen in DEV), and `Object.defineProperty` for `_debugInfo` fails because frozen objects are non-configurable. The same crash can occur with JSX exported as a client reference. For now, we're adding `!Object.isFrozen()` guards in `moveDebugInfoFromChunkToInnerValue` and `addAsyncInfo` to prevent the crash, which means debug info is silently dropped for frozen elements. The proper fix would likely be to clone the element so each rendering context gets its own mutable copy with correct debug info. closes #34984 closes #35690 --- .../react-client/src/ReactFlightClient.js | 11 ++- .../src/ReactFlightReplyClient.js | 8 +++ .../src/__tests__/ReactFlight-test.js | 57 ++++++++++++++++ .../src/__tests__/ReactFlightDOMReply-test.js | 68 +++++++++++++++++++ 4 files changed, 142 insertions(+), 2 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 12c9528b8de6..80673006eadb 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -552,7 +552,7 @@ function moveDebugInfoFromChunkToInnerValue( resolvedValue._debugInfo, debugInfo, ); - } else { + } else if (!Object.isFrozen(resolvedValue)) { Object.defineProperty((resolvedValue: any), '_debugInfo', { configurable: false, enumerable: false, @@ -560,6 +560,11 @@ function moveDebugInfoFromChunkToInnerValue( value: debugInfo, }); } + // TODO: If the resolved value is a frozen element (e.g. a client-created + // element from a temporary reference, or a JSX element exported as a client + // reference), server debug info is currently dropped because the element + // can't be mutated. We should probably clone the element so each rendering + // context gets its own mutable copy with the correct debug info. } } @@ -2900,7 +2905,9 @@ function addAsyncInfo(chunk: SomeChunk, asyncInfo: ReactAsyncInfo): void { if (isArray(value._debugInfo)) { // $FlowFixMe[method-unbinding] value._debugInfo.push(asyncInfo); - } else { + } else if (!Object.isFrozen(value)) { + // TODO: Debug info is dropped for frozen elements. See the TODO in + // moveDebugInfoFromChunkToInnerValue. Object.defineProperty((value: any), '_debugInfo', { configurable: false, enumerable: false, diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index 0661f7824650..56f60d3623c9 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -429,6 +429,14 @@ export function processReply( return serializeTemporaryReferenceMarker(); } } + // This element is the root of a serializeModel call (e.g. JSX + // passed directly to encodeReply, or a promise that resolved to + // JSX). It was already registered as a temporary reference by + // serializeModel so we just need to emit the marker. + if (temporaryReferences !== undefined && modelRoot === value) { + modelRoot = null; + return serializeTemporaryReferenceMarker(); + } throw new Error( 'React Element cannot be passed to Server Functions from the Client without a ' + 'temporary reference set. Pass a TemporaryReferenceSet to the options.' + diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 0c482f72cdc5..ff84c8834abb 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -3941,4 +3941,61 @@ describe('ReactFlight', () => { const model = await ReactNoopFlightClient.read(transport); expect(model.element.key).toBe(React.optimisticKey); }); + + it('can use a JSX element exported as a client reference in multiple server components', async () => { + const ClientReference = clientReference(React.createElement('span')); + + function Foo() { + return ClientReference; + } + + function Bar() { + return ClientReference; + } + + function App() { + return ReactServer.createElement( + 'div', + null, + ReactServer.createElement(Foo), + ReactServer.createElement(Bar), + ); + } + + const transport = ReactNoopFlightServer.render( + ReactServer.createElement(App), + ); + + await act(async () => { + const result = await ReactNoopFlightClient.read(transport); + ReactNoop.render(result); + + if (__DEV__) { + // TODO: Debug info is dropped for frozen elements (client-created JSX + // exported as a client reference in this case). Ideally we'd clone the + // element so that each context gets its own mutable copy with correct + // debug info. When fixed, foo should have Foo's debug info and bar should + // have Bar's debug info. + const [foo, bar] = result.props.children; + expect(getDebugInfo(foo)).toBe(null); + expect(getDebugInfo(bar)).toBe(null); + } + }); + + // TODO: With cloning, each context would get its own element copy, so this + // key warning should go away. + assertConsoleErrorDev([ + 'Each child in a list should have a unique "key" prop.\n\n' + + 'Check the top-level render call using
. ' + + 'See https://react.dev/link/warning-keys for more information.\n' + + ' in span (at **)', + ]); + + expect(ReactNoop).toMatchRenderedOutput( +
+ + +
, + ); + }); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js index 409718973be9..77ae692e9800 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js @@ -394,6 +394,74 @@ describe('ReactFlightDOMReply', () => { expect(response.children).toBe(children); }); + it('can pass JSX as root model through a round trip using temporary references', async () => { + const jsx =
; + + const temporaryReferences = + ReactServerDOMClient.createTemporaryReferenceSet(); + const body = await ReactServerDOMClient.encodeReply(jsx, { + temporaryReferences, + }); + + const temporaryReferencesServer = + ReactServerDOMServer.createTemporaryReferenceSet(); + const serverPayload = await ReactServerDOMServer.decodeReply( + body, + webpackServerMap, + {temporaryReferences: temporaryReferencesServer}, + ); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(serverPayload, null, { + temporaryReferences: temporaryReferencesServer, + }), + ); + const response = await ReactServerDOMClient.createFromReadableStream( + stream, + { + temporaryReferences, + }, + ); + + // This should be the same reference that we already saw. + await expect(response).toBe(jsx); + }); + + it('can pass a promise that resolves to JSX through a round trip using temporary references', async () => { + const jsx =
; + const promise = Promise.resolve(jsx); + + const temporaryReferences = + ReactServerDOMClient.createTemporaryReferenceSet(); + const body = await ReactServerDOMClient.encodeReply( + {promise}, + { + temporaryReferences, + }, + ); + + const temporaryReferencesServer = + ReactServerDOMServer.createTemporaryReferenceSet(); + const serverPayload = await ReactServerDOMServer.decodeReply( + body, + webpackServerMap, + {temporaryReferences: temporaryReferencesServer}, + ); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(serverPayload, null, { + temporaryReferences: temporaryReferencesServer, + }), + ); + const response = await ReactServerDOMClient.createFromReadableStream( + stream, + { + temporaryReferences, + }, + ); + + // This should resolve to the same reference that we already saw. + await expect(response.promise).resolves.toBe(jsx); + }); + it('can return the same object using temporary references', async () => { const obj = { this: {is: 'a large object'}, From 272441a9ade6bf84de11ba73039eb4c80668fa6a Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 9 Feb 2026 19:19:32 +0100 Subject: [PATCH 2/3] [Flight] Add `unstable_allowPartialStream` option to Flight Client (#35731) When using a partial prerender stream, i.e. a prerender that is intentionally aborted before all I/O has resolved, consumers of `createFromReadableStream` would need to keep the stream unclosed to prevent React Flight from erroring on unresolved chunks. However, some browsers (e.g. Chrome, Firefox) keep unclosed ReadableStreams with pending reads as native GC roots, retaining the entire Flight response. With this PR we're adding an `unstable_allowPartialStream` option, that allows consumers to close the stream normally. The Flight Client's `close()` function then transitions pending chunks to halted instead of erroring them. Halted chunks keep Suspense fallbacks showing (i.e. they never resolve), and their `.then()` is a no-op so no new listeners accumulate. Inner stream chunks (ReadableStream/AsyncIterable) are closed gracefully, and `getChunk()` returns halted chunks for new IDs that are accessed after closing the response. Blocked chunks are left alone because they may be waiting on client-side async operations like module loading, or on forward references to chunks that appeared later in the stream, both of which resolve independently of closing. --- .../react-client/src/ReactFlightClient.js | 65 ++++++- .../react-markup/src/ReactMarkupServer.js | 1 + .../src/ReactNoopFlightClient.js | 1 + .../src/client/ReactFlightDOMClientBrowser.js | 4 + .../src/client/ReactFlightDOMClientNode.js | 4 + .../src/client/ReactFlightDOMClientBrowser.js | 4 + .../src/client/ReactFlightDOMClientEdge.js | 4 + .../src/client/ReactFlightDOMClientNode.js | 4 + .../src/client/ReactFlightDOMClientBrowser.js | 4 + .../src/client/ReactFlightDOMClientEdge.js | 4 + .../src/client/ReactFlightDOMClientNode.js | 4 + .../src/client/ReactFlightDOMClientEdge.js | 4 + .../src/client/ReactFlightDOMClientNode.js | 4 + .../__tests__/ReactFlightDOMBrowser-test.js | 169 +++++++++++++++++- .../src/client/ReactFlightDOMClientBrowser.js | 4 + .../src/client/ReactFlightDOMClientEdge.js | 4 + .../src/client/ReactFlightDOMClientNode.js | 4 + 17 files changed, 278 insertions(+), 10 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 80673006eadb..20aa8ce8f9a3 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -359,6 +359,7 @@ type Response = { _stringDecoder: StringDecoder, _closed: boolean, _closedReason: mixed, + _allowPartialStream: boolean, _tempRefs: void | TemporaryReferenceSet, // the set temporary references can be resolved from _timeOrigin: number, // Profiling-only _pendingInitialRender: null | TimeoutID, // Profiling-only, @@ -1456,9 +1457,19 @@ function getChunk(response: Response, id: number): SomeChunk { let chunk = chunks.get(id); if (!chunk) { if (response._closed) { - // We have already errored the response and we're not going to get - // anything more streaming in so this will immediately error. - chunk = createErrorChunk(response, response._closedReason); + if (response._allowPartialStream) { + // For partial streams, chunks accessed after close should be HALTED + // (never resolve). + chunk = createPendingChunk(response); + const haltedChunk: HaltedChunk = (chunk: any); + haltedChunk.status = HALTED; + haltedChunk.value = null; + haltedChunk.reason = null; + } else { + // We have already errored the response and we're not going to get + // anything more streaming in so this will immediately error. + chunk = createErrorChunk(response, response._closedReason); + } } else { chunk = createPendingChunk(response); } @@ -2655,6 +2666,7 @@ function ResponseInstance( encodeFormAction: void | EncodeFormActionCallback, nonce: void | string, temporaryReferences: void | TemporaryReferenceSet, + allowPartialStream: boolean, findSourceMapURL: void | FindSourceMapURLCallback, // DEV-only replayConsole: boolean, // DEV-only environmentName: void | string, // DEV-only @@ -2674,6 +2686,7 @@ function ResponseInstance( this._fromJSON = (null: any); this._closed = false; this._closedReason = null; + this._allowPartialStream = allowPartialStream; this._tempRefs = temporaryReferences; if (enableProfilerTimer && enableComponentPerformanceTrack) { this._timeOrigin = 0; @@ -2767,6 +2780,7 @@ export function createResponse( encodeFormAction: void | EncodeFormActionCallback, nonce: void | string, temporaryReferences: void | TemporaryReferenceSet, + allowPartialStream: boolean, findSourceMapURL: void | FindSourceMapURLCallback, // DEV-only replayConsole: boolean, // DEV-only environmentName: void | string, // DEV-only @@ -2792,6 +2806,7 @@ export function createResponse( encodeFormAction, nonce, temporaryReferences, + allowPartialStream, findSourceMapURL, replayConsole, environmentName, @@ -5243,11 +5258,45 @@ function createFromJSONCallback(response: Response) { } export function close(weakResponse: WeakResponse): void { - // In case there are any remaining unresolved chunks, they won't - // be resolved now. So we need to issue an error to those. - // Ideally we should be able to early bail out if we kept a - // ref count of pending chunks. - reportGlobalError(weakResponse, new Error('Connection closed.')); + // In case there are any remaining unresolved chunks, they won't be resolved + // now. So we either error or halt them depending on whether partial streams + // are allowed. + // TODO: Ideally we should be able to bail out early if we kept a ref count of + // pending chunks. + if (hasGCedResponse(weakResponse)) { + return; + } + const response = unwrapWeakResponse(weakResponse); + if (response._allowPartialStream) { + // For partial streams, we halt pending chunks instead of erroring them. + response._closed = true; + response._chunks.forEach(chunk => { + if (chunk.status === PENDING) { + // Clear listeners to release closures and transition to HALTED. + // Future .then() calls on HALTED chunks are no-ops. + releasePendingChunk(response, chunk); + const haltedChunk: HaltedChunk = (chunk: any); + haltedChunk.status = HALTED; + haltedChunk.value = null; + haltedChunk.reason = null; + } else if (chunk.status === INITIALIZED && chunk.reason !== null) { + // Stream chunk - close gracefully instead of erroring. + chunk.reason.close('"$undefined"'); + } + }); + if (__DEV__) { + const debugChannel = response._debugChannel; + if (debugChannel !== undefined) { + closeDebugChannel(debugChannel); + response._debugChannel = undefined; + if (debugChannelRegistry !== null) { + debugChannelRegistry.unregister(response); + } + } + } + } else { + reportGlobalError(weakResponse, new Error('Connection closed.')); + } } function getCurrentOwnerInDEV(): null | ReactComponentInfo { diff --git a/packages/react-markup/src/ReactMarkupServer.js b/packages/react-markup/src/ReactMarkupServer.js index 95a5ce51c3e1..43e258bf13ef 100644 --- a/packages/react-markup/src/ReactMarkupServer.js +++ b/packages/react-markup/src/ReactMarkupServer.js @@ -89,6 +89,7 @@ export function experimental_renderToHTML( noServerCallOrFormAction, undefined, undefined, + false, undefined, false, undefined, diff --git a/packages/react-noop-renderer/src/ReactNoopFlightClient.js b/packages/react-noop-renderer/src/ReactNoopFlightClient.js index 45edbd6f0030..a5c43bd65259 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightClient.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightClient.js @@ -71,6 +71,7 @@ function read(source: Source, options: ReadOptions): Thenable { undefined, undefined, undefined, + false, options !== undefined ? options.findSourceMapURL : undefined, true, undefined, diff --git a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js index ee2475287d03..1c07d4369b85 100644 --- a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js @@ -49,6 +49,7 @@ export type Options = { callServer?: CallServerCallback, debugChannel?: {writable?: WritableStream, readable?: ReadableStream, ...}, temporaryReferences?: TemporaryReferenceSet, + unstable_allowPartialStream?: boolean, findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, @@ -98,6 +99,9 @@ function createResponseFromOptions(options: void | Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + options && options.unstable_allowPartialStream + ? options.unstable_allowPartialStream + : false, __DEV__ && options && options.findSourceMapURL ? options.findSourceMapURL : undefined, diff --git a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js index ae11dc29bfff..e9692997dd7c 100644 --- a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js @@ -54,6 +54,7 @@ type EncodeFormActionCallback = ( export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, + unstable_allowPartialStream?: boolean, findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, @@ -104,6 +105,9 @@ function createFromNodeStream( options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, undefined, // TODO: If encodeReply is supported, this should support temporaryReferences + options && options.unstable_allowPartialStream + ? options.unstable_allowPartialStream + : false, __DEV__ && options && options.findSourceMapURL ? options.findSourceMapURL : undefined, diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js index b304d442046d..a034a460f809 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js @@ -124,6 +124,9 @@ function createResponseFromOptions(options: void | Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + options && options.unstable_allowPartialStream + ? options.unstable_allowPartialStream + : false, __DEV__ ? findSourceMapURL : undefined, __DEV__ ? (options ? options.replayConsoleLogs !== false : true) : false, // defaults to true __DEV__ && options && options.environmentName @@ -207,6 +210,7 @@ function startReadingFromStream( export type Options = { debugChannel?: {writable?: WritableStream, readable?: ReadableStream, ...}, temporaryReferences?: TemporaryReferenceSet, + unstable_allowPartialStream?: boolean, replayConsoleLogs?: boolean, environmentName?: string, startTime?: number, diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js index f58f8534348e..57afee2e914c 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js @@ -77,6 +77,7 @@ export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, temporaryReferences?: TemporaryReferenceSet, + unstable_allowPartialStream?: boolean, replayConsoleLogs?: boolean, environmentName?: string, startTime?: number, @@ -104,6 +105,9 @@ function createResponseFromOptions(options?: Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + options && options.unstable_allowPartialStream + ? options.unstable_allowPartialStream + : false, __DEV__ ? findSourceMapURL : undefined, __DEV__ && options ? options.replayConsoleLogs === true : false, // defaults to false __DEV__ && options && options.environmentName diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js index e8716bdc6bb9..941ca67dcfa1 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js @@ -50,6 +50,7 @@ type EncodeFormActionCallback = ( export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, + unstable_allowPartialStream?: boolean, replayConsoleLogs?: boolean, environmentName?: string, startTime?: number, @@ -97,6 +98,9 @@ export function createFromNodeStream( options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, undefined, // TODO: If encodeReply is supported, this should support temporaryReferences + options && options.unstable_allowPartialStream + ? options.unstable_allowPartialStream + : false, __DEV__ ? findSourceMapURL : undefined, __DEV__ && options ? options.replayConsoleLogs === true : false, // defaults to false __DEV__ && options && options.environmentName diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js index 0bf615001982..c38d5fd05133 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js @@ -48,6 +48,7 @@ export type Options = { callServer?: CallServerCallback, debugChannel?: {writable?: WritableStream, readable?: ReadableStream, ...}, temporaryReferences?: TemporaryReferenceSet, + unstable_allowPartialStream?: boolean, findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, @@ -97,6 +98,9 @@ function createResponseFromOptions(options: void | Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + options && options.unstable_allowPartialStream + ? options.unstable_allowPartialStream + : false, __DEV__ && options && options.findSourceMapURL ? options.findSourceMapURL : undefined, diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js index c6dd4ee94ad1..6b781f897fc1 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientEdge.js @@ -76,6 +76,7 @@ export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, temporaryReferences?: TemporaryReferenceSet, + unstable_allowPartialStream?: boolean, findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, @@ -104,6 +105,9 @@ function createResponseFromOptions(options: Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + options && options.unstable_allowPartialStream + ? options.unstable_allowPartialStream + : false, __DEV__ && options && options.findSourceMapURL ? options.findSourceMapURL : undefined, diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js index 8863b1bf1c66..7aff35e85da5 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientNode.js @@ -57,6 +57,7 @@ type EncodeFormActionCallback = ( export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, + unstable_allowPartialStream?: boolean, findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, @@ -106,6 +107,9 @@ function createFromNodeStream( options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, undefined, // TODO: If encodeReply is supported, this should support temporaryReferences + options && options.unstable_allowPartialStream + ? options.unstable_allowPartialStream + : false, __DEV__ && options && options.findSourceMapURL ? options.findSourceMapURL : undefined, diff --git a/packages/react-server-dom-unbundled/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-unbundled/src/client/ReactFlightDOMClientEdge.js index 2cf668f679d3..8bcdcdbfe0d2 100644 --- a/packages/react-server-dom-unbundled/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-unbundled/src/client/ReactFlightDOMClientEdge.js @@ -76,6 +76,7 @@ export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, temporaryReferences?: TemporaryReferenceSet, + unstable_allowPartialStream?: boolean, findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, @@ -104,6 +105,9 @@ function createResponseFromOptions(options: Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + options && options.unstable_allowPartialStream + ? options.unstable_allowPartialStream + : false, __DEV__ && options && options.findSourceMapURL ? options.findSourceMapURL : undefined, diff --git a/packages/react-server-dom-unbundled/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-unbundled/src/client/ReactFlightDOMClientNode.js index 8863b1bf1c66..7aff35e85da5 100644 --- a/packages/react-server-dom-unbundled/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-unbundled/src/client/ReactFlightDOMClientNode.js @@ -57,6 +57,7 @@ type EncodeFormActionCallback = ( export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, + unstable_allowPartialStream?: boolean, findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, @@ -106,6 +107,9 @@ function createFromNodeStream( options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, undefined, // TODO: If encodeReply is supported, this should support temporaryReferences + options && options.unstable_allowPartialStream + ? options.unstable_allowPartialStream + : false, __DEV__ && options && options.findSourceMapURL ? options.findSourceMapURL : undefined, diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index 1399effbc1ec..d7ec51780a19 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -2504,6 +2504,171 @@ describe('ReactFlightDOMBrowser', () => { expect(container.innerHTML).toBe(''); }); + it('renders Suspense fallback for unresolved promises with unstable_allowPartialStream', async () => { + let resolveGreeting; + const greetingPromise = new Promise(resolve => { + resolveGreeting = resolve; + }); + + function App() { + return ( + + + + ); + } + + async function Greeting() { + const greeting = await greetingPromise; + return greeting; + } + + const controller = new AbortController(); + const {pendingResult} = await serverAct(async () => { + return { + pendingResult: ReactServerDOMStaticServer.prerender( + , + webpackMap, + { + signal: controller.signal, + }, + ), + }; + }); + + controller.abort(); + resolveGreeting('Hello, World!'); + const {prelude} = await serverAct(() => pendingResult); + + function ClientRoot({response}) { + return use(response); + } + + const response = ReactServerDOMClient.createFromReadableStream( + passThrough(prelude), + { + unstable_allowPartialStream: true, + }, + ); + const container = document.createElement('div'); + const errors = []; + const root = ReactDOMClient.createRoot(container, { + onUncaughtError(err) { + errors.push(err); + }, + }); + + await act(() => { + root.render(); + }); + + // With `unstable_allowPartialStream`, we should see the fallback instead of a + // 'Connection closed.' error + expect(errors).toEqual([]); + expect(container.innerHTML).toBe('loading...'); + }); + + it('renders client components that are blocked on chunks with unstable_allowPartialStream', async () => { + let resolveClientComponentChunk; + + const ClientComponent = clientExports( + function ClientComponent({children}) { + return
{children}
; + }, + '42', + '/test.js', + new Promise(resolve => (resolveClientComponentChunk = resolve)), + ); + + function App() { + return Hello, World!; + } + + const controller = new AbortController(); + const {pendingResult} = await serverAct(async () => { + return { + pendingResult: ReactServerDOMStaticServer.prerender( + , + webpackMap, + { + signal: controller.signal, + }, + ), + }; + }); + + controller.abort(); + const {prelude} = await serverAct(() => pendingResult); + + function ClientRoot({response}) { + return use(response); + } + + const response = ReactServerDOMClient.createFromReadableStream( + passThrough(prelude), + { + unstable_allowPartialStream: true, + }, + ); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + + await act(() => { + root.render(); + }); + + expect(container.innerHTML).toBe(''); + + await act(() => { + resolveClientComponentChunk(); + }); + + expect(container.innerHTML).toBe('
Hello, World!
'); + }); + + it('closes inner ReadableStreams gracefully with unstable_allowPartialStream', async () => { + let streamController; + const innerStream = new ReadableStream({ + start(c) { + streamController = c; + }, + }); + + const abortController = new AbortController(); + const {pendingResult} = await serverAct(async () => { + streamController.enqueue({hello: 'world'}); + return { + pendingResult: ReactServerDOMStaticServer.prerender( + {stream: innerStream}, + webpackMap, + { + signal: abortController.signal, + }, + ), + }; + }); + + abortController.abort(); + const {prelude} = await serverAct(() => pendingResult); + + const response = await ReactServerDOMClient.createFromReadableStream( + passThrough(prelude), + { + unstable_allowPartialStream: true, + }, + ); + + // The inner stream should be readable up to what was enqueued. + const reader = response.stream.getReader(); + const {value, done} = await reader.read(); + expect(value).toEqual({hello: 'world'}); + expect(done).toBe(false); + + // The next read should signal the stream is done (closed, not errored). + const final = await reader.read(); + expect(final.done).toBe(true); + }); + it('can dedupe references inside promises', async () => { const foo = {}; const bar = { @@ -2902,9 +3067,9 @@ describe('ReactFlightDOMBrowser', () => { [ "Object.", "/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js", - 2824, + 2989, 19, - 2808, + 2973, 89, ], ], diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js index 0bf615001982..c38d5fd05133 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js @@ -48,6 +48,7 @@ export type Options = { callServer?: CallServerCallback, debugChannel?: {writable?: WritableStream, readable?: ReadableStream, ...}, temporaryReferences?: TemporaryReferenceSet, + unstable_allowPartialStream?: boolean, findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, @@ -97,6 +98,9 @@ function createResponseFromOptions(options: void | Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + options && options.unstable_allowPartialStream + ? options.unstable_allowPartialStream + : false, __DEV__ && options && options.findSourceMapURL ? options.findSourceMapURL : undefined, diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js index 2cf668f679d3..8bcdcdbfe0d2 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js @@ -76,6 +76,7 @@ export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, temporaryReferences?: TemporaryReferenceSet, + unstable_allowPartialStream?: boolean, findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, @@ -104,6 +105,9 @@ function createResponseFromOptions(options: Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + options && options.unstable_allowPartialStream + ? options.unstable_allowPartialStream + : false, __DEV__ && options && options.findSourceMapURL ? options.findSourceMapURL : undefined, diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js index 8863b1bf1c66..7aff35e85da5 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientNode.js @@ -57,6 +57,7 @@ type EncodeFormActionCallback =
( export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, + unstable_allowPartialStream?: boolean, findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, @@ -106,6 +107,9 @@ function createFromNodeStream( options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, undefined, // TODO: If encodeReply is supported, this should support temporaryReferences + options && options.unstable_allowPartialStream + ? options.unstable_allowPartialStream + : false, __DEV__ && options && options.findSourceMapURL ? options.findSourceMapURL : undefined, From eab523e2a99583703b13536670dfdd8a3b1e26e0 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Mon, 9 Feb 2026 20:36:56 +0100 Subject: [PATCH 3/3] [Fiber] Avoid duplicate debug info for array children (#35733) --- .../src/__tests__/ReactFlight-test.js | 61 ++++++++++++++++++- .../src/__tests__/store-test.js | 34 +++++++++++ .../react-reconciler/src/ReactChildFiber.js | 13 ++-- 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index ff84c8834abb..45a8c74ee28e 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -2820,7 +2820,8 @@ describe('ReactFlight', () => { ] : undefined, ); - expect(getDebugInfo(thirdPartyChildren[2])).toEqual( + const fragment = thirdPartyChildren[2]; + expect(getDebugInfo(fragment)).toEqual( __DEV__ ? [ {time: gate(flags => flags.enableAsyncDebugInfo) ? 54 : 22}, @@ -2835,6 +2836,9 @@ describe('ReactFlight', () => { ] : undefined, ); + expect(getDebugInfo(fragment.props.children[0])).toEqual( + __DEV__ ? null : undefined, + ); ReactNoop.render(result); }); @@ -2847,6 +2851,61 @@ describe('ReactFlight', () => { ); }); + it('preserves debug info for keyed Fragment', async () => { + function App() { + return ReactServer.createElement( + ReactServer.Fragment, + {key: 'app'}, + ReactServer.createElement('h1', null, 'App'), + ReactServer.createElement('div', null, 'Child'), + ); + } + + const transport = ReactNoopFlightServer.render( + ReactServer.createElement( + ReactServer.Fragment, + null, + ReactServer.createElement('link', {key: 'styles'}), + ReactServer.createElement(App, null), + ), + ); + + await act(async () => { + const root = await ReactNoopFlightClient.read(transport); + + const fragment = root[1]; + expect(getDebugInfo(fragment)).toEqual( + __DEV__ + ? [ + {time: 12}, + { + name: 'App', + env: 'Server', + key: null, + stack: ' in Object. (at **)', + props: {}, + }, + {time: 13}, + ] + : undefined, + ); + // Making sure debug info doesn't get added multiple times on Fragment children + expect(getDebugInfo(fragment[0])).toEqual(__DEV__ ? null : undefined); + const fragmentChild = fragment[0].props.children[0]; + expect(getDebugInfo(fragmentChild)).toEqual(__DEV__ ? null : undefined); + + ReactNoop.render(root); + }); + + expect(ReactNoop).toMatchRenderedOutput( + <> + +

App

+
Child
+ , + ); + }); + // @gate enableAsyncIterableChildren && enableComponentPerformanceTrack it('preserves debug info for server-to-server pass through of async iterables', async () => { let resolve; diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 8c9beb185443..5e39a0fe68f9 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -2827,6 +2827,40 @@ describe('Store', () => { `); }); + // @reactVersion >= 19.0 + it('does not duplicate Server Component parents in keyed Fragments', async () => { + // TODO: Use an actual Flight renderer. + // See ReactFlight-test for the produced JSX from Flight. + function ClientComponent() { + return null; + } + // This used to be a keyed Fragment on the Server. + const children = []; + children._debugInfo = [ + {time: 12}, + { + name: 'App', + env: 'Server', + key: null, + stack: ' in Object. (at **)', + props: {}, + }, + {time: 13}, + ]; + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await actAsync(() => { + root.render([children]); + }); + + expect(store).toMatchInlineSnapshot(` + [root] + ▾ [Server] + + `); + }); + // @reactVersion >= 17.0 it('can reconcile Suspense in fallback positions', async () => { let resolveFallback; diff --git a/packages/react-reconciler/src/ReactChildFiber.js b/packages/react-reconciler/src/ReactChildFiber.js index 2a726447263c..42f1b70918d3 100644 --- a/packages/react-reconciler/src/ReactChildFiber.js +++ b/packages/react-reconciler/src/ReactChildFiber.js @@ -789,6 +789,7 @@ function createChildReconciler( // We treat the parent as the owner for stack purposes. created._debugOwner = returnFiber; created._debugTask = returnFiber._debugTask; + // Make sure to not push again when handling the Fragment child. const prevDebugInfo = pushDebugInfo(newChild._debugInfo); created._debugInfo = currentDebugInfo; currentDebugInfo = prevDebugInfo; @@ -1915,26 +1916,26 @@ function createChildReconciler( } if (isArray(newChild)) { - const prevDebugInfo = pushDebugInfo(newChild._debugInfo); + // We created a Fragment for this child with the debug info. + // No need to push again. const firstChild = reconcileChildrenArray( returnFiber, currentFirstChild, newChild, lanes, ); - currentDebugInfo = prevDebugInfo; return firstChild; } if (getIteratorFn(newChild)) { - const prevDebugInfo = pushDebugInfo(newChild._debugInfo); + // We created a Fragment for this child with the debug info. + // No need to push again. const firstChild = reconcileChildrenIteratable( returnFiber, currentFirstChild, newChild, lanes, ); - currentDebugInfo = prevDebugInfo; return firstChild; } @@ -1942,14 +1943,14 @@ function createChildReconciler( enableAsyncIterableChildren && typeof newChild[ASYNC_ITERATOR] === 'function' ) { - const prevDebugInfo = pushDebugInfo(newChild._debugInfo); + // We created a Fragment for this child with the debug info. + // No need to push again. const firstChild = reconcileChildrenAsyncIteratable( returnFiber, currentFirstChild, newChild, lanes, ); - currentDebugInfo = prevDebugInfo; return firstChild; }