From e2fd5dc6ad973dd3f220056404d0ae0a8707998d Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 2 Dec 2025 20:11:07 -0500 Subject: [PATCH] Patch FlightReplyServer with fixes from ReactFlightClient FlightReplyServer are for client->server and ReactFlightClient is for server->client. They're not 100% symmetrical. We did a number of refactors to ReactFlightClient in PRs like #29823 and #33664 to change the structure of the resolution. This PR brings those changes to synchronize the two approaches. Which addresses deep resolution of cycles and deferred error handling. This also fixes a critical security vulnerability. --- .../src/server/ReactFlightDOMServerNode.js | 35 +- .../ReactFlightClientConfigBundlerParcel.js | 7 +- .../src/server/ReactFlightDOMServerNode.js | 35 +- ...ReactFlightClientConfigBundlerTurbopack.js | 7 +- .../src/server/ReactFlightDOMServerNode.js | 35 +- .../ReactFlightClientConfigBundlerNode.js | 7 +- .../ReactFlightClientConfigBundlerWebpack.js | 7 +- .../src/server/ReactFlightDOMServerNode.js | 35 +- .../src/ReactFlightReplyServer.js | 822 +++++++++++++----- 9 files changed, 712 insertions(+), 278 deletions(-) diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index c45b784d24e..b81e177d642 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -344,16 +344,23 @@ function decodeReplyFromBusboy( // we queue any fields we receive until the previous file is done. queuedFields.push(name, value); } else { - resolveField(response, name, value); + try { + resolveField(response, name, value); + } catch (error) { + busboyStream.destroy(error); + } } }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { - throw new Error( - "React doesn't accept base64 encoded file uploads because we don't expect " + - "form data passed from a browser to ever encode data that way. If that's " + - 'the wrong assumption, we can easily fix it.', + busboyStream.destroy( + new Error( + "React doesn't accept base64 encoded file uploads because we don't expect " + + "form data passed from a browser to ever encode data that way. If that's " + + 'the wrong assumption, we can easily fix it.', + ), ); + return; } pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); @@ -361,14 +368,18 @@ function decodeReplyFromBusboy( resolveFileChunk(response, file, chunk); }); value.on('end', () => { - resolveFileComplete(response, name, file); - pendingFiles--; - if (pendingFiles === 0) { - // Release any queued fields - for (let i = 0; i < queuedFields.length; i += 2) { - resolveField(response, queuedFields[i], queuedFields[i + 1]); + try { + resolveFileComplete(response, name, file); + pendingFiles--; + if (pendingFiles === 0) { + // Release any queued fields + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + queuedFields.length = 0; } - queuedFields.length = 0; + } catch (error) { + busboyStream.destroy(error); } }); }); diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js b/packages/react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js index 6c652c93c22..67b0abc9cc9 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js @@ -19,6 +19,8 @@ import { } from '../shared/ReactFlightImportMetadata'; import {prepareDestinationWithChunks} from 'react-client/src/ReactFlightClientConfig'; +import hasOwnProperty from 'shared/hasOwnProperty'; + export type ServerManifest = { [string]: Array, }; @@ -78,7 +80,10 @@ export function preloadModule( export function requireModule(metadata: ClientReference): T { const moduleExports = parcelRequire(metadata[ID]); - return moduleExports[metadata[NAME]]; + if (hasOwnProperty.call(moduleExports, metadata[NAME])) { + return moduleExports[metadata[NAME]]; + } + return (undefined: any); } export function getModuleDebugInfo( diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js index bd828d31780..294e99e502a 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js @@ -572,16 +572,23 @@ export function decodeReplyFromBusboy( // we queue any fields we receive until the previous file is done. queuedFields.push(name, value); } else { - resolveField(response, name, value); + try { + resolveField(response, name, value); + } catch (error) { + busboyStream.destroy(error); + } } }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { - throw new Error( - "React doesn't accept base64 encoded file uploads because we don't expect " + - "form data passed from a browser to ever encode data that way. If that's " + - 'the wrong assumption, we can easily fix it.', + busboyStream.destroy( + new Error( + "React doesn't accept base64 encoded file uploads because we don't expect " + + "form data passed from a browser to ever encode data that way. If that's " + + 'the wrong assumption, we can easily fix it.', + ), ); + return; } pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); @@ -589,14 +596,18 @@ export function decodeReplyFromBusboy( resolveFileChunk(response, file, chunk); }); value.on('end', () => { - resolveFileComplete(response, name, file); - pendingFiles--; - if (pendingFiles === 0) { - // Release any queued fields - for (let i = 0; i < queuedFields.length; i += 2) { - resolveField(response, queuedFields[i], queuedFields[i + 1]); + try { + resolveFileComplete(response, name, file); + pendingFiles--; + if (pendingFiles === 0) { + // Release any queued fields + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + queuedFields.length = 0; } - queuedFields.length = 0; + } catch (error) { + busboyStream.destroy(error); } }); }); diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js b/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js index f061fa98165..2c26859280a 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightClientConfigBundlerTurbopack.js @@ -34,6 +34,8 @@ import { addChunkDebugInfo, } from 'react-client/src/ReactFlightClientConfig'; +import hasOwnProperty from 'shared/hasOwnProperty'; + export type ServerConsumerModuleMap = null | { [clientId: string]: { [clientExportName: string]: ClientReferenceManifestEntry, @@ -245,7 +247,10 @@ export function requireModule(metadata: ClientReference): T { // default property of this if it was an ESM interop module. return moduleExports.__esModule ? moduleExports.default : moduleExports; } - return moduleExports[metadata[NAME]]; + if (hasOwnProperty.call(moduleExports, metadata[NAME])) { + return moduleExports[metadata[NAME]]; + } + return (undefined: any); } export function getModuleDebugInfo( diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index 44bb6209ada..2f4301d1120 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -564,16 +564,23 @@ function decodeReplyFromBusboy( // we queue any fields we receive until the previous file is done. queuedFields.push(name, value); } else { - resolveField(response, name, value); + try { + resolveField(response, name, value); + } catch (error) { + busboyStream.destroy(error); + } } }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { - throw new Error( - "React doesn't accept base64 encoded file uploads because we don't expect " + - "form data passed from a browser to ever encode data that way. If that's " + - 'the wrong assumption, we can easily fix it.', + busboyStream.destroy( + new Error( + "React doesn't accept base64 encoded file uploads because we don't expect " + + "form data passed from a browser to ever encode data that way. If that's " + + 'the wrong assumption, we can easily fix it.', + ), ); + return; } pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); @@ -581,14 +588,18 @@ function decodeReplyFromBusboy( resolveFileChunk(response, file, chunk); }); value.on('end', () => { - resolveFileComplete(response, name, file); - pendingFiles--; - if (pendingFiles === 0) { - // Release any queued fields - for (let i = 0; i < queuedFields.length; i += 2) { - resolveField(response, queuedFields[i], queuedFields[i + 1]); + try { + resolveFileComplete(response, name, file); + pendingFiles--; + if (pendingFiles === 0) { + // Release any queued fields + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + queuedFields.length = 0; } - queuedFields.length = 0; + } catch (error) { + busboyStream.destroy(error); } }); }); diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode.js b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode.js index de38569e52a..63049b2ca40 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode.js @@ -24,6 +24,8 @@ import { } from '../shared/ReactFlightImportMetadata'; import {prepareDestinationWithChunks} from 'react-client/src/ReactFlightClientConfig'; +import hasOwnProperty from 'shared/hasOwnProperty'; + export type ServerConsumerModuleMap = { [clientId: string]: { [clientExportName: string]: ClientReference, @@ -158,7 +160,10 @@ export function requireModule(metadata: ClientReference): T { // default property of this if it was an ESM interop module. return moduleExports.default; } - return moduleExports[metadata.name]; + if (hasOwnProperty.call(moduleExports, metadata.name)) { + return moduleExports[metadata.name]; + } + return (undefined: any); } export function getModuleDebugInfo(metadata: ClientReference): null { diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js index 550e10eb008..23825c4dcf7 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack.js @@ -34,6 +34,8 @@ import { addChunkDebugInfo, } from 'react-client/src/ReactFlightClientConfig'; +import hasOwnProperty from 'shared/hasOwnProperty'; + export type ServerConsumerModuleMap = null | { [clientId: string]: { [clientExportName: string]: ClientReferenceManifestEntry, @@ -253,7 +255,10 @@ export function requireModule(metadata: ClientReference): T { // default property of this if it was an ESM interop module. return moduleExports.__esModule ? moduleExports.default : moduleExports; } - return moduleExports[metadata[NAME]]; + if (hasOwnProperty.call(moduleExports, metadata[NAME])) { + return moduleExports[metadata[NAME]]; + } + return (undefined: any); } export function getModuleDebugInfo( diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index 10162fe33df..5e73d8eb3a5 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -564,16 +564,23 @@ function decodeReplyFromBusboy( // we queue any fields we receive until the previous file is done. queuedFields.push(name, value); } else { - resolveField(response, name, value); + try { + resolveField(response, name, value); + } catch (error) { + busboyStream.destroy(error); + } } }); busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { if (encoding.toLowerCase() === 'base64') { - throw new Error( - "React doesn't accept base64 encoded file uploads because we don't expect " + - "form data passed from a browser to ever encode data that way. If that's " + - 'the wrong assumption, we can easily fix it.', + busboyStream.destroy( + new Error( + "React doesn't accept base64 encoded file uploads because we don't expect " + + "form data passed from a browser to ever encode data that way. If that's " + + 'the wrong assumption, we can easily fix it.', + ), ); + return; } pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); @@ -581,14 +588,18 @@ function decodeReplyFromBusboy( resolveFileChunk(response, file, chunk); }); value.on('end', () => { - resolveFileComplete(response, name, file); - pendingFiles--; - if (pendingFiles === 0) { - // Release any queued fields - for (let i = 0; i < queuedFields.length; i += 2) { - resolveField(response, queuedFields[i], queuedFields[i + 1]); + try { + resolveFileComplete(response, name, file); + pendingFiles--; + if (pendingFiles === 0) { + // Release any queued fields + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + queuedFields.length = 0; } - queuedFields.length = 0; + } catch (error) { + busboyStream.destroy(error); } }); }); diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index 424e26d36da..39734dae7ca 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -50,44 +50,35 @@ export type JSONValue = const PENDING = 'pending'; const BLOCKED = 'blocked'; -const CYCLIC = 'cyclic'; const RESOLVED_MODEL = 'resolved_model'; const INITIALIZED = 'fulfilled'; const ERRORED = 'rejected'; +type RESPONSE_SYMBOL_TYPE = 'RESPONSE_SYMBOL'; // Fake symbol type. +const RESPONSE_SYMBOL: RESPONSE_SYMBOL_TYPE = (Symbol(): any); + type PendingChunk = { status: 'pending', - value: null | Array<(T) => mixed>, - reason: null | Array<(mixed) => mixed>, - _response: Response, + value: null | Array mixed)>, + reason: null | Array mixed)>, then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type BlockedChunk = { status: 'blocked', - value: null | Array<(T) => mixed>, - reason: null | Array<(mixed) => mixed>, - _response: Response, - then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, -}; -type CyclicChunk = { - status: 'cyclic', - value: null | Array<(T) => mixed>, - reason: null | Array<(mixed) => mixed>, - _response: Response, + value: null | Array mixed)>, + reason: null | Array mixed)>, then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type ResolvedModelChunk = { status: 'resolved_model', value: string, - reason: number, - _response: Response, + reason: {id: number, [RESPONSE_SYMBOL_TYPE]: Response}, then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type InitializedChunk = { status: 'fulfilled', value: T, reason: null, - _response: Response, then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type InitializedStreamChunk< @@ -96,38 +87,34 @@ type InitializedStreamChunk< status: 'fulfilled', value: T, reason: FlightStreamController, - _response: Response, then(resolve: (ReadableStream) => mixed, reject?: (mixed) => mixed): void, }; type ErroredChunk = { status: 'rejected', value: null, reason: mixed, - _response: Response, then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type SomeChunk = | PendingChunk | BlockedChunk - | CyclicChunk | ResolvedModelChunk | InitializedChunk | ErroredChunk; // $FlowFixMe[missing-this-annot] -function Chunk(status: any, value: any, reason: any, response: Response) { +function ReactPromise(status: any, value: any, reason: any) { this.status = status; this.value = value; this.reason = reason; - this._response = response; } // We subclass Promise.prototype so that we get other methods like .catch -Chunk.prototype = (Object.create(Promise.prototype): any); +ReactPromise.prototype = (Object.create(Promise.prototype): any); // TODO: This doesn't return a new Promise chain unlike the real .then -Chunk.prototype.then = function ( +ReactPromise.prototype.then = function ( this: SomeChunk, resolve: (value: T) => mixed, - reject: (reason: mixed) => mixed, + reject: ?(reason: mixed) => mixed, ) { const chunk: SomeChunk = this; // If we have resolved content, we try to initialize it first which @@ -140,26 +127,31 @@ Chunk.prototype.then = function ( // The status might have changed after initialization. switch (chunk.status) { case INITIALIZED: - resolve(chunk.value); + if (typeof resolve === 'function') { + resolve(chunk.value); + } break; case PENDING: case BLOCKED: - case CYCLIC: - if (resolve) { + if (typeof resolve === 'function') { if (chunk.value === null) { - chunk.value = ([]: Array<(T) => mixed>); + chunk.value = ([]: Array mixed)>); } chunk.value.push(resolve); } - if (reject) { + if (typeof reject === 'function') { if (chunk.reason === null) { - chunk.reason = ([]: Array<(mixed) => mixed>); + chunk.reason = ([]: Array< + InitializationReference | (mixed => mixed), + >); } chunk.reason.push(reject); } break; default: - reject(chunk.reason); + if (typeof reject === 'function') { + reject(chunk.reason); + } break; } }; @@ -181,28 +173,114 @@ export function getRoot(response: Response): Thenable { function createPendingChunk(response: Response): PendingChunk { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new Chunk(PENDING, null, null, response); + return new ReactPromise(PENDING, null, null); } -function wakeChunk(listeners: Array<(T) => mixed>, value: T): void { +function wakeChunk( + response: Response, + listeners: Array mixed)>, + value: T, +): void { for (let i = 0; i < listeners.length; i++) { const listener = listeners[i]; - listener(value); + if (typeof listener === 'function') { + listener(value); + } else { + fulfillReference(response, listener, value); + } } } +function rejectChunk( + response: Response, + listeners: Array mixed)>, + error: mixed, +): void { + for (let i = 0; i < listeners.length; i++) { + const listener = listeners[i]; + if (typeof listener === 'function') { + listener(error); + } else { + rejectReference(response, listener.handler, error); + } + } +} + +function resolveBlockedCycle( + resolvedChunk: SomeChunk, + reference: InitializationReference, +): null | InitializationHandler { + const referencedChunk = reference.handler.chunk; + if (referencedChunk === null) { + return null; + } + if (referencedChunk === resolvedChunk) { + // We found the cycle. We can resolve the blocked cycle now. + return reference.handler; + } + const resolveListeners = referencedChunk.value; + if (resolveListeners !== null) { + for (let i = 0; i < resolveListeners.length; i++) { + const listener = resolveListeners[i]; + if (typeof listener !== 'function') { + const foundHandler = resolveBlockedCycle(resolvedChunk, listener); + if (foundHandler !== null) { + return foundHandler; + } + } + } + } + return null; +} + function wakeChunkIfInitialized( + response: Response, chunk: SomeChunk, - resolveListeners: Array<(T) => mixed>, - rejectListeners: null | Array<(mixed) => mixed>, + resolveListeners: Array mixed)>, + rejectListeners: null | Array mixed)>, ): void { switch (chunk.status) { case INITIALIZED: - wakeChunk(resolveListeners, chunk.value); + wakeChunk(response, resolveListeners, chunk.value); break; - case PENDING: case BLOCKED: - case CYCLIC: + // It is possible that we're blocked on our own chunk if it's a cycle. + // Before adding back the listeners to the chunk, let's check if it would + // result in a cycle. + for (let i = 0; i < resolveListeners.length; i++) { + const listener = resolveListeners[i]; + if (typeof listener !== 'function') { + const reference: InitializationReference = listener; + const cyclicHandler = resolveBlockedCycle(chunk, reference); + if (cyclicHandler !== null) { + // This reference points back to this chunk. We can resolve the cycle by + // using the value from that handler. + fulfillReference(response, reference, cyclicHandler.value); + resolveListeners.splice(i, 1); + i--; + if (rejectListeners !== null) { + const rejectionIdx = rejectListeners.indexOf(reference); + if (rejectionIdx !== -1) { + rejectListeners.splice(rejectionIdx, 1); + } + } + // The status might have changed after fulfilling the reference. + switch ((chunk: SomeChunk).status) { + case INITIALIZED: + const initializedChunk: InitializedChunk = (chunk: any); + wakeChunk(response, resolveListeners, initializedChunk.value); + return; + case ERRORED: + if (rejectListeners !== null) { + rejectChunk(response, rejectListeners, chunk.reason); + } + return; + } + } + } + } + // Fallthrough + case PENDING: if (chunk.value) { for (let i = 0; i < resolveListeners.length; i++) { chunk.value.push(resolveListeners[i]); @@ -223,13 +301,17 @@ function wakeChunkIfInitialized( break; case ERRORED: if (rejectListeners) { - wakeChunk(rejectListeners, chunk.reason); + wakeChunk(response, rejectListeners, chunk.reason); } break; } } -function triggerErrorOnChunk(chunk: SomeChunk, error: mixed): void { +function triggerErrorOnChunk( + response: Response, + chunk: SomeChunk, + error: mixed, +): void { if (chunk.status !== PENDING && chunk.status !== BLOCKED) { // If we get more data to an already resolved ID, we assume that it's // a stream chunk since any other row shouldn't have more than one entry. @@ -244,7 +326,7 @@ function triggerErrorOnChunk(chunk: SomeChunk, error: mixed): void { erroredChunk.status = ERRORED; erroredChunk.reason = error; if (listeners !== null) { - wakeChunk(listeners, error); + rejectChunk(response, listeners, error); } } @@ -254,7 +336,10 @@ function createResolvedModelChunk( id: number, ): ResolvedModelChunk { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new Chunk(RESOLVED_MODEL, value, id, response); + return new ReactPromise(RESOLVED_MODEL, value, { + id, + [RESPONSE_SYMBOL]: response, + }); } function createErroredChunk( @@ -262,10 +347,11 @@ function createErroredChunk( reason: mixed, ): ErroredChunk { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new Chunk(ERRORED, null, reason, response); + return new ReactPromise(ERRORED, null, reason); } function resolveModelChunk( + response: Response, chunk: SomeChunk, value: string, id: number, @@ -287,14 +373,14 @@ function resolveModelChunk( const resolvedChunk: ResolvedModelChunk = (chunk: any); resolvedChunk.status = RESOLVED_MODEL; resolvedChunk.value = value; - resolvedChunk.reason = id; + resolvedChunk.reason = {id, [RESPONSE_SYMBOL]: response}; if (resolveListeners !== null) { // This is unfortunate that we're reading this eagerly if // we already have listeners attached since they might no // longer be rendered or might not be the highest pri. initializeModelChunk(resolvedChunk); // The status might have changed after initialization. - wakeChunkIfInitialized(chunk, resolveListeners, rejectListeners); + wakeChunkIfInitialized(response, chunk, resolveListeners, rejectListeners); } } @@ -308,7 +394,7 @@ function createInitializedStreamChunk< // We use the reason field to stash the controller since we already have that // field. It's a bit of a hack but efficient. // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new Chunk(INITIALIZED, value, controller, response); + return new ReactPromise(INITIALIZED, value, controller); } function createResolvedIteratorResultChunk( @@ -320,10 +406,14 @@ function createResolvedIteratorResultChunk( const iteratorResultJSON = (done ? '{"done":true,"value":' : '{"done":false,"value":') + value + '}'; // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new Chunk(RESOLVED_MODEL, iteratorResultJSON, -1, response); + return new ReactPromise(RESOLVED_MODEL, iteratorResultJSON, { + id: -1, + [RESPONSE_SYMBOL]: response, + }); } function resolveIteratorResultChunk( + response: Response, chunk: SomeChunk>, value: string, done: boolean, @@ -331,55 +421,112 @@ function resolveIteratorResultChunk( // To reuse code as much code as possible we add the wrapper element as part of the JSON. const iteratorResultJSON = (done ? '{"done":true,"value":' : '{"done":false,"value":') + value + '}'; - resolveModelChunk(chunk, iteratorResultJSON, -1); -} - -function bindArgs(fn: any, args: any) { - return fn.bind.apply(fn, [null].concat(args)); + resolveModelChunk(response, chunk, iteratorResultJSON, -1); } -function loadServerReference( +function loadServerReference, T>( response: Response, - id: ServerReferenceId, - bound: null | Thenable>, - parentChunk: SomeChunk, + metaData: { + id: any, + bound: null | Thenable>, + }, parentObject: Object, key: string, -): T { +): (...A) => Promise { + const id: ServerReferenceId = metaData.id; + if (typeof id !== 'string') { + return (null: any); + } const serverReference: ServerReference = resolveServerReference<$FlowFixMe>(response._bundlerConfig, id); // We expect most servers to not really need this because you'd just have all // the relevant modules already loaded but it allows for lazy loading of code // if needed. - const preloadPromise = preloadModule(serverReference); - let promise: Promise; - if (bound) { - promise = Promise.all([(bound: any), preloadPromise]).then( - ([args]: Array) => bindArgs(requireModule(serverReference), args), - ); - } else { - if (preloadPromise) { - promise = Promise.resolve(preloadPromise).then(() => - requireModule(serverReference), - ); + const bound = metaData.bound; + let promise: null | Thenable = preloadModule(serverReference); + if (!promise) { + if (bound instanceof ReactPromise) { + promise = Promise.resolve(bound); } else { - // Synchronously available - return requireModule(serverReference); + const resolvedValue = (requireModule(serverReference): any); + return resolvedValue; } + } else if (bound instanceof ReactPromise) { + promise = Promise.all([promise, bound]); } - promise.then( - createModelResolver( - parentChunk, - parentObject, - key, - false, - response, - createModel, - [], - ), - createModelReject(parentChunk), - ); - // We need a placeholder value that will be replaced later. + + let handler: InitializationHandler; + if (initializingHandler) { + handler = initializingHandler; + handler.deps++; + } else { + handler = initializingHandler = { + chunk: null, + value: null, + reason: null, + deps: 1, + errored: false, + }; + } + + function fulfill(): void { + let resolvedValue = (requireModule(serverReference): any); + + if (metaData.bound) { + // This promise is coming from us and should have initilialized by now. + const promiseValue = (metaData.bound: any).value; + const boundArgs: Array = Array.isArray(promiseValue) + ? promiseValue.slice(0) + : []; + boundArgs.unshift(null); // this + resolvedValue = resolvedValue.bind.apply(resolvedValue, boundArgs); + } + + parentObject[key] = resolvedValue; + + // If this is the root object for a model reference, where `handler.value` + // is a stale `null`, the resolved value can be used directly. + if (key === '' && handler.value === null) { + handler.value = resolvedValue; + } + + handler.deps--; + + if (handler.deps === 0) { + const chunk = handler.chunk; + if (chunk === null || chunk.status !== BLOCKED) { + return; + } + const resolveListeners = chunk.value; + const initializedChunk: InitializedChunk = (chunk: any); + initializedChunk.status = INITIALIZED; + initializedChunk.value = handler.value; + if (resolveListeners !== null) { + wakeChunk(response, resolveListeners, handler.value); + } + } + } + + function reject(error: mixed): void { + if (handler.errored) { + // We've already errored. We could instead build up an AggregateError + // but if there are multiple errors we just take the first one like + // Promise.all. + return; + } + handler.errored = true; + handler.value = null; + handler.reason = error; + const chunk = handler.chunk; + if (chunk === null || chunk.status !== BLOCKED) { + return; + } + triggerErrorOnChunk(response, chunk, error); + } + + promise.then(fulfill, reject); + + // Return a place holder value for now. return (null: any); } @@ -427,7 +574,7 @@ function reviveModel( value[key], childRef, ); - if (newValue !== undefined) { + if (newValue !== undefined || key === '__proto__') { // $FlowFixMe[cannot-write] value[key] = newValue; } else { @@ -441,24 +588,42 @@ function reviveModel( return value; } -let initializingChunk: ResolvedModelChunk = (null: any); -let initializingChunkBlockedModel: null | {deps: number, value: any} = null; +type InitializationReference = { + handler: InitializationHandler, + parentObject: Object, + key: string, + map: ( + response: Response, + model: any, + parentObject: Object, + key: string, + ) => any, + path: Array, +}; +type InitializationHandler = { + chunk: null | BlockedChunk, + value: any, + reason: any, + deps: number, + errored: boolean, +}; +let initializingHandler: null | InitializationHandler = null; + function initializeModelChunk(chunk: ResolvedModelChunk): void { - const prevChunk = initializingChunk; - const prevBlocked = initializingChunkBlockedModel; - initializingChunk = chunk; - initializingChunkBlockedModel = null; + const prevHandler = initializingHandler; + initializingHandler = null; - const rootReference = - chunk.reason === -1 ? undefined : chunk.reason.toString(16); + const {[RESPONSE_SYMBOL]: response, id} = chunk.reason; + + const rootReference = id === -1 ? undefined : id.toString(16); const resolvedModel = chunk.value; - // We go to the CYCLIC state until we've fully resolved this. + // We go to the BLOCKED state until we've fully resolved this. // We do this before parsing in case we try to initialize the same chunk // while parsing the model. Such as in a cyclic reference. - const cyclicChunk: CyclicChunk = (chunk: any); - cyclicChunk.status = CYCLIC; + const cyclicChunk: BlockedChunk = (chunk: any); + cyclicChunk.status = BLOCKED; cyclicChunk.value = null; cyclicChunk.reason = null; @@ -466,37 +631,50 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { const rawModel = JSON.parse(resolvedModel); const value: T = reviveModel( - chunk._response, + response, {'': rawModel}, '', rawModel, rootReference, ); - if ( - initializingChunkBlockedModel !== null && - initializingChunkBlockedModel.deps > 0 - ) { - initializingChunkBlockedModel.value = value; - // We discovered new dependencies on modules that are not yet resolved. - // We have to go the BLOCKED state until they're resolved. - const blockedChunk: BlockedChunk = (chunk: any); - blockedChunk.status = BLOCKED; - } else { - const resolveListeners = cyclicChunk.value; - const initializedChunk: InitializedChunk = (chunk: any); - initializedChunk.status = INITIALIZED; - initializedChunk.value = value; - if (resolveListeners !== null) { - wakeChunk(resolveListeners, value); + + // Invoke any listeners added while resolving this model. I.e. cyclic + // references. This may or may not fully resolve the model depending on + // if they were blocked. + const resolveListeners = cyclicChunk.value; + if (resolveListeners !== null) { + cyclicChunk.value = null; + cyclicChunk.reason = null; + for (let i = 0; i < resolveListeners.length; i++) { + const listener = resolveListeners[i]; + if (typeof listener === 'function') { + listener(value); + } else { + fulfillReference(response, listener, value); + } } } + if (initializingHandler !== null) { + if (initializingHandler.errored) { + throw initializingHandler.reason; + } + if (initializingHandler.deps > 0) { + // We discovered new dependencies on modules that are not yet resolved. + // We have to keep the BLOCKED state until they're resolved. + initializingHandler.value = value; + initializingHandler.chunk = cyclicChunk; + return; + } + } + const initializedChunk: InitializedChunk = (chunk: any); + initializedChunk.status = INITIALIZED; + initializedChunk.value = value; } catch (error) { const erroredChunk: ErroredChunk = (chunk: any); erroredChunk.status = ERRORED; erroredChunk.reason = error; } finally { - initializingChunk = prevChunk; - initializingChunkBlockedModel = prevBlocked; + initializingHandler = prevHandler; } } @@ -510,7 +688,7 @@ export function reportGlobalError(response: Response, error: Error): void { // trigger an error but if it wasn't then we need to // because we won't be getting any new data to resolve it. if (chunk.status === PENDING) { - triggerErrorOnChunk(chunk, error); + triggerErrorOnChunk(response, chunk, error); } }); } @@ -523,9 +701,8 @@ function getChunk(response: Response, id: number): SomeChunk { const key = prefix + id; // Check if we have this field in the backing store already. const backingEntry = response._formData.get(key); - if (backingEntry != null) { - // We assume that this is a string entry for now. - chunk = createResolvedModelChunk(response, (backingEntry: any), id); + if (typeof backingEntry === 'string') { + chunk = createResolvedModelChunk(response, backingEntry, id); } else 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. @@ -539,57 +716,152 @@ function getChunk(response: Response, id: number): SomeChunk { return chunk; } -function createModelResolver( - chunk: SomeChunk, +function fulfillReference( + response: Response, + reference: InitializationReference, + value: any, +): void { + const {handler, parentObject, key, map, path} = reference; + + for (let i = 1; i < path.length; i++) { + // The server doesn't have any lazy references but we unwrap Chunks here in the same way as the client. + while (value instanceof ReactPromise) { + const referencedChunk: SomeChunk = value; + switch (referencedChunk.status) { + case RESOLVED_MODEL: + initializeModelChunk(referencedChunk); + break; + } + switch (referencedChunk.status) { + case INITIALIZED: { + value = referencedChunk.value; + continue; + } + case BLOCKED: + case PENDING: { + // If we're not yet initialized we need to skip what we've already drilled + // through and then wait for the next value to become available. + path.splice(0, i - 1); + // Add "listener" to our new chunk dependency. + if (referencedChunk.value === null) { + referencedChunk.value = [reference]; + } else { + referencedChunk.value.push(reference); + } + if (referencedChunk.reason === null) { + referencedChunk.reason = [reference]; + } else { + referencedChunk.reason.push(reference); + } + return; + } + default: { + rejectReference(response, reference.handler, referencedChunk.reason); + return; + } + } + } + const name = path[i]; + if (typeof value === 'object' && hasOwnProperty.call(value, name)) { + value = value[name]; + } + } + + const mappedValue = map(response, value, parentObject, key); + parentObject[key] = mappedValue; + + // If this is the root object for a model reference, where `handler.value` + // is a stale `null`, the resolved value can be used directly. + if (key === '' && handler.value === null) { + handler.value = mappedValue; + } + + // There are no Elements or Debug Info to transfer here. + + handler.deps--; + + if (handler.deps === 0) { + const chunk = handler.chunk; + if (chunk === null || chunk.status !== BLOCKED) { + return; + } + const resolveListeners = chunk.value; + const initializedChunk: InitializedChunk = (chunk: any); + initializedChunk.status = INITIALIZED; + initializedChunk.value = handler.value; + initializedChunk.reason = handler.reason; // Used by streaming chunks + if (resolveListeners !== null) { + wakeChunk(response, resolveListeners, handler.value); + } + } +} + +function rejectReference( + response: Response, + handler: InitializationHandler, + error: mixed, +): void { + if (handler.errored) { + // We've already errored. We could instead build up an AggregateError + // but if there are multiple errors we just take the first one like + // Promise.all. + return; + } + handler.errored = true; + handler.value = null; + handler.reason = error; + const chunk = handler.chunk; + if (chunk === null || chunk.status !== BLOCKED) { + return; + } + // There's no debug info to forward in this direction. + triggerErrorOnChunk(response, chunk, error); +} + +function waitForReference( + referencedChunk: PendingChunk | BlockedChunk, parentObject: Object, key: string, - cyclic: boolean, response: Response, - map: (response: Response, model: any) => T, + map: (response: Response, model: any, parentObject: Object, key: string) => T, path: Array, -): (value: any) => void { - let blocked; - if (initializingChunkBlockedModel) { - blocked = initializingChunkBlockedModel; - if (!cyclic) { - blocked.deps++; - } +): T { + let handler: InitializationHandler; + if (initializingHandler) { + handler = initializingHandler; + handler.deps++; } else { - blocked = initializingChunkBlockedModel = { - deps: (cyclic ? 0 : 1) as number, - value: (null: any), + handler = initializingHandler = { + chunk: null, + value: null, + reason: null, + deps: 1, + errored: false, }; } - return value => { - for (let i = 1; i < path.length; i++) { - value = value[path[i]]; - } - parentObject[key] = map(response, value); - // If this is the root object for a model reference, where `blocked.value` - // is a stale `null`, the resolved value can be used directly. - if (key === '' && blocked.value === null) { - blocked.value = parentObject[key]; - } - - blocked.deps--; - if (blocked.deps === 0) { - if (chunk.status !== BLOCKED) { - return; - } - const resolveListeners = chunk.value; - const initializedChunk: InitializedChunk = (chunk: any); - initializedChunk.status = INITIALIZED; - initializedChunk.value = blocked.value; - if (resolveListeners !== null) { - wakeChunk(resolveListeners, blocked.value); - } - } + const reference: InitializationReference = { + handler, + parentObject, + key, + map, + path, }; -} -function createModelReject(chunk: SomeChunk): (error: mixed) => void { - return (error: mixed) => triggerErrorOnChunk(chunk, error); + // Add "listener". + if (referencedChunk.value === null) { + referencedChunk.value = [reference]; + } else { + referencedChunk.value.push(reference); + } + if (referencedChunk.reason === null) { + referencedChunk.reason = [reference]; + } else { + referencedChunk.reason.push(reference); + } + + // Return a place holder value for now. + return (null: any); } function getOutlinedModel( @@ -597,7 +869,7 @@ function getOutlinedModel( reference: string, parentObject: Object, key: string, - map: (response: Response, model: any) => T, + map: (response: Response, model: any, parentObject: Object, key: string) => T, ): T { const path = reference.split(':'); const id = parseInt(path[0], 16); @@ -612,28 +884,79 @@ function getOutlinedModel( case INITIALIZED: let value = chunk.value; for (let i = 1; i < path.length; i++) { - value = value[path[i]]; + // The server doesn't have any lazy references but we unwrap Chunks here in the same way as the client. + while (value instanceof ReactPromise) { + const referencedChunk: SomeChunk = value; + switch (referencedChunk.status) { + case RESOLVED_MODEL: + initializeModelChunk(referencedChunk); + break; + } + switch (referencedChunk.status) { + case INITIALIZED: { + value = referencedChunk.value; + break; + } + case BLOCKED: + case PENDING: { + return waitForReference( + referencedChunk, + parentObject, + key, + response, + map, + path.slice(i - 1), + ); + } + default: { + // This is an error. Instead of erroring directly, we're going to encode this on + // an initialization handler so that we can catch it at the nearest Element. + if (initializingHandler) { + initializingHandler.errored = true; + initializingHandler.value = null; + initializingHandler.reason = referencedChunk.reason; + } else { + initializingHandler = { + chunk: null, + value: null, + reason: referencedChunk.reason, + deps: 0, + errored: true, + }; + } + return (null: any); + } + } + } + const name = path[i]; + if (typeof value === 'object' && hasOwnProperty.call(value, name)) { + value = value[name]; + } } - return map(response, value); + const chunkValue = map(response, value, parentObject, key); + // There's no Element nor Debug Info in the ReplyServer so we don't have to check those here. + return chunkValue; case PENDING: case BLOCKED: - case CYCLIC: - const parentChunk = initializingChunk; - chunk.then( - createModelResolver( - parentChunk, - parentObject, - key, - chunk.status === CYCLIC, - response, - map, - path, - ), - createModelReject(parentChunk), - ); - return (null: any); + return waitForReference(chunk, parentObject, key, response, map, path); default: - throw chunk.reason; + // This is an error. Instead of erroring directly, we're going to encode this on + // an initialization handler. + if (initializingHandler) { + initializingHandler.errored = true; + initializingHandler.value = null; + initializingHandler.reason = chunk.reason; + } else { + initializingHandler = { + chunk: null, + value: null, + reason: chunk.reason, + deps: 0, + errored: true, + }; + } + // Placeholder + return (null: any); } } @@ -657,7 +980,7 @@ function createModel(response: Response, model: any): any { return model; } -function parseTypedArray( +function parseTypedArray( response: Response, reference: string, constructor: any, @@ -670,30 +993,78 @@ function parseTypedArray( const key = prefix + id; // We should have this backingEntry in the store already because we emitted // it before referencing it. It should be a Blob. + // TODO: Use getOutlinedModel to allow us to emit the Blob later. We should be able to do that now. const backingEntry: Blob = (response._formData.get(key): any); - const promise = - constructor === ArrayBuffer - ? backingEntry.arrayBuffer() - : backingEntry.arrayBuffer().then(function (buffer) { - return new constructor(buffer); - }); + const promise: Promise = backingEntry.arrayBuffer(); // Since loading the buffer is an async operation we'll be blocking the parent // chunk. - const parentChunk = initializingChunk; - promise.then( - createModelResolver( - parentChunk, - parentObject, - parentKey, - false, - response, - createModel, - [], - ), - createModelReject(parentChunk), - ); + + let handler: InitializationHandler; + if (initializingHandler) { + handler = initializingHandler; + handler.deps++; + } else { + handler = initializingHandler = { + chunk: null, + value: null, + reason: null, + deps: 1, + errored: false, + }; + } + + function fulfill(buffer: ArrayBuffer): void { + const resolvedValue: T = + constructor === ArrayBuffer + ? (buffer: any) + : (new constructor(buffer): any); + + parentObject[parentKey] = resolvedValue; + + // If this is the root object for a model reference, where `handler.value` + // is a stale `null`, the resolved value can be used directly. + if (parentKey === '' && handler.value === null) { + handler.value = resolvedValue; + } + + handler.deps--; + + if (handler.deps === 0) { + const chunk = handler.chunk; + if (chunk === null || chunk.status !== BLOCKED) { + return; + } + const resolveListeners = chunk.value; + const initializedChunk: InitializedChunk = (chunk: any); + initializedChunk.status = INITIALIZED; + initializedChunk.value = handler.value; + if (resolveListeners !== null) { + wakeChunk(response, resolveListeners, handler.value); + } + } + } + + function reject(error: mixed): void { + if (handler.errored) { + // We've already errored. We could instead build up an AggregateError + // but if there are multiple errors we just take the first one like + // Promise.all. + return; + } + handler.errored = true; + handler.value = null; + handler.reason = error; + const chunk = handler.chunk; + if (chunk === null || chunk.status !== BLOCKED) { + return; + } + triggerErrorOnChunk(response, chunk, error); + } + + promise.then(fulfill, reject); + return null; } @@ -711,12 +1082,13 @@ function resolveStream>( const key = prefix + id; const existingEntries = response._formData.getAll(key); for (let i = 0; i < existingEntries.length; i++) { - // We assume that this is a string entry for now. - const value: string = (existingEntries[i]: any); - if (value[0] === 'C') { - controller.close(value === 'C' ? '"$undefined"' : value.slice(1)); - } else { - controller.enqueueModel(value); + const value = existingEntries[i]; + if (typeof value === 'string') { + if (value[0] === 'C') { + controller.close(value === 'C' ? '"$undefined"' : value.slice(1)); + } else { + controller.enqueueModel(value); + } } } } @@ -774,7 +1146,7 @@ function parseReadableStream( // to synchronous emitting. previousBlockedChunk = null; } - resolveModelChunk(chunk, json, -1); + resolveModelChunk(response, chunk, json, -1); }); } }, @@ -844,7 +1216,12 @@ function parseAsyncIterable( false, ); } else { - resolveIteratorResultChunk(buffer[nextWriteIndex], value, false); + resolveIteratorResultChunk( + response, + buffer[nextWriteIndex], + value, + false, + ); } nextWriteIndex++; }, @@ -857,12 +1234,18 @@ function parseAsyncIterable( true, ); } else { - resolveIteratorResultChunk(buffer[nextWriteIndex], value, true); + resolveIteratorResultChunk( + response, + buffer[nextWriteIndex], + value, + true, + ); } nextWriteIndex++; while (nextWriteIndex < buffer.length) { // In generators, any extra reads from the iterator have the value undefined. resolveIteratorResultChunk( + response, buffer[nextWriteIndex++], '"$undefined"', true, @@ -876,7 +1259,7 @@ function parseAsyncIterable( createPendingChunk>(response); } while (nextWriteIndex < buffer.length) { - triggerErrorOnChunk(buffer[nextWriteIndex++], error); + triggerErrorOnChunk(response, buffer[nextWriteIndex++], error); } }, }; @@ -892,11 +1275,10 @@ function parseAsyncIterable( if (nextReadIndex === buffer.length) { if (closed) { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new Chunk( + return new ReactPromise( INITIALIZED, {done: true, value: undefined}, null, - response, ); } buffer[nextReadIndex] = @@ -935,19 +1317,7 @@ function parseModelString( case 'F': { // Server Reference const ref = value.slice(2); - // TODO: Just encode this in the reference inline instead of as a model. - const metaData: { - id: ServerReferenceId, - bound: null | Thenable>, - } = getOutlinedModel(response, ref, obj, key, createModel); - return loadServerReference( - response, - metaData.id, - metaData.bound, - initializingChunk, - obj, - key, - ); + return getOutlinedModel(response, ref, obj, key, loadServerReference); } case 'T': { // Temporary Reference @@ -1121,7 +1491,7 @@ export function resolveField( const chunk = chunks.get(id); if (chunk) { // We were waiting on this key so now we can resolve it. - resolveModelChunk(chunk, value, id); + resolveModelChunk(response, chunk, value, id); } } }