diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 80673006ea..20aa8ce8f9 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 95a5ce51c3..43e258bf13 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 45edbd6f00..a5c43bd652 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 ee2475287d..1c07d4369b 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 ae11dc29bf..e9692997dd 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 b304d44204..a034a460f8 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 f58f853434..57afee2e91 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 e8716bdc6b..941ca67dcf 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 0bf6150019..c38d5fd051 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 c6dd4ee94a..6b781f897f 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 8863b1bf1c..7aff35e85d 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 2cf668f679..8bcdcdbfe0 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 8863b1bf1c..7aff35e85d 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 1399effbc1..d7ec51780a 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 0bf6150019..c38d5fd051 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 2cf668f679..8bcdcdbfe0 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 8863b1bf1c..7aff35e85d 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,