diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 12c9528b8de6..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, @@ -552,7 +553,7 @@ function moveDebugInfoFromChunkToInnerValue( resolvedValue._debugInfo, debugInfo, ); - } else { + } else if (!Object.isFrozen(resolvedValue)) { Object.defineProperty((resolvedValue: any), '_debugInfo', { configurable: false, enumerable: false, @@ -560,6 +561,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. } } @@ -1451,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); } @@ -2650,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 @@ -2669,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; @@ -2762,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 @@ -2787,6 +2806,7 @@ export function createResponse( encodeFormAction, nonce, temporaryReferences, + allowPartialStream, findSourceMapURL, replayConsole, environmentName, @@ -2900,7 +2920,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, @@ -5236,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-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..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; @@ -3941,4 +4000,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-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-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-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; } 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/__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'}, 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,