From 550ae4b9105e8fa1ef71269b24e90cb60c7301e5 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Mon, 10 Aug 2026 19:34:13 +0200 Subject: [PATCH 1/7] [Flight] Restore standard React version placeholder --- packages/shared/ReactVersion.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/shared/ReactVersion.js b/packages/shared/ReactVersion.js index c7ea17f42b07..bd5fa23ca26b 100644 --- a/packages/shared/ReactVersion.js +++ b/packages/shared/ReactVersion.js @@ -1 +1,15 @@ -export default '19.3.0-canary-0d593e90-20260109'; +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// TODO: this is special because it gets imported during build. +// +// It exists as a placeholder so that DevTools can support work tag changes between releases. +// When we next publish a release, update the matching TODO in backend/renderer.js +// TODO: This module is used both by the release scripts and to expose a version +// at runtime. We should instead inject the version number as part of the build +// process, and use the ReactVersions.js module as the single source of truth. +export default '19.3.0'; From 7baa108b92be2c4f16bb55ffc416a1ef1825695f Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Tue, 17 Mar 2026 11:50:27 +0100 Subject: [PATCH 2/7] [Flight Reply] Early bailout if backing entry for Blob deserialization is not a Blob (#36055) Co-authored-by: Hendrik Liebau (cherry picked from commit 12ba7d81297abac61012a36f20d4a9d22b9210d9) --- .../src/__tests__/ReactFlightDOMReply-test.js | 13 +++++++++++++ packages/react-server/src/ReactFlightReplyServer.js | 5 ++++- scripts/error-codes/codes.json | 3 ++- 3 files changed, 19 insertions(+), 2 deletions(-) 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..9a16d179a635 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js @@ -676,4 +676,17 @@ describe('ReactFlightDOMReply', () => { // has closed but that's a bug in both ReactFlightReplyServer and ReactFlightClient. // It just halts in this case. }); + + it('cannot deserialize a Blob reference backed by a string', async () => { + const formData = new FormData(); + formData.set('1', '-'.repeat(50000)); + formData.set('0', JSON.stringify(['$B1'])); + let error; + try { + await ReactServerDOMServer.decodeReply(formData, webpackServerMap); + } catch (x) { + error = x; + } + expect(error.message).toContain('Referenced Blob is not a Blob.'); + }); }); diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index d3eff13ff465..4a3adc079ff4 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -1805,7 +1805,10 @@ function parseModelString( const blobKey = prefix + id; // We should have this backingEntry in the store already because we emitted // it before referencing it. It should be a Blob. - const backingEntry: Blob = (response._formData.get(blobKey): any); + const backingEntry = response._formData.get(blobKey); + if (!(backingEntry instanceof Blob)) { + throw new Error('Referenced Blob is not a Blob.'); + } return backingEntry; } case 'R': { diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 46bb2e6bccf0..d7ca5474b3de 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -566,5 +566,6 @@ "578": "Already initialized Iterator.", "579": "Invalid data for bytes stream.", "580": "Server Function has too many bound arguments. Received %s but the limit is %s.", - "581": "BigInt is too large. Received %s digits but the limit is %s." + "581": "BigInt is too large. Received %s digits but the limit is %s.", + "582": "Referenced Blob is not a Blob." } From 2d19a1429a1f85d5216e1909159eee7e600c9c7e Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 5 Mar 2026 12:41:21 +0100 Subject: [PATCH 3/7] [Flight] Avoid consuming cyclic models multiple times Co-authored-by: "Sebastian \"Sebbie\" Silbermann" (cherry picked from commit 672b24221f25c98adc67457468ab0b348342526f) --- packages/react-server/src/ReactFlightReplyServer.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index 4a3adc079ff4..46c45f68b033 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -1122,8 +1122,9 @@ function createMap( if ((model as any).$$consumed === true) { throw new Error('Already initialized Map.'); } - const map = new Map(model); + // This needs to come first to prevent the model from being consumed again in case of a cyclic reference. (model as any).$$consumed = true; + const map = new Map(model); return map; } @@ -1134,8 +1135,9 @@ function createSet(response: Response, model: Array): Set { if ((model as any).$$consumed === true) { throw new Error('Already initialized Set.'); } - const set = new Set(model); + // This needs to come first to prevent the model from being consumed again in case of a cyclic reference. (model as any).$$consumed = true; + const set = new Set(model); return set; } @@ -1146,9 +1148,10 @@ function extractIterator(response: Response, model: Array): Iterator { if ((model as any).$$consumed === true) { throw new Error('Already initialized Iterator.'); } + // This needs to come first to prevent the model from being consumed again in case of a cyclic reference. + (model as any).$$consumed = true; // $FlowFixMe[incompatible-use]: This uses raw Symbols because we're extracting from a native array. const iterator = model[Symbol.iterator](); - (model as any).$$consumed = true; return iterator; } From 4b905baa03928709f02d3438431de6babfa30e8b Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Mon, 20 Apr 2026 19:01:23 +0200 Subject: [PATCH 4/7] [FlightReply] Type hardening and performance improvements Co-authored-by: Hendrik Liebau (cherry picked from commit 795203e75e12d8968f6c6edf3cb53cb456d7b2cf) --- .eslintrc.js | 2 + flow-typed/environments/bom.js | 4 +- .../src/ReactFlightReplyClient.js | 4 +- .../src/ReactFlightReplyBackingFormData.js | 107 ++++++++++++++ .../src/ReactFlightReplyServer.js | 136 +++++++++++++----- scripts/error-codes/codes.json | 3 +- 6 files changed, 214 insertions(+), 42 deletions(-) create mode 100644 packages/react-server/src/ReactFlightReplyBackingFormData.js diff --git a/.eslintrc.js b/.eslintrc.js index e0c61a608a47..913eec625cd4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -589,6 +589,8 @@ module.exports = { CopyInspectedElementPath: 'readonly', DOMHighResTimeStamp: 'readonly', EventListener: 'readonly', + // Flow type + FormDataEntryValue: 'readonly', Iterable: 'readonly', AsyncIterable: 'readonly', $AsyncIterable: 'readonly', diff --git a/flow-typed/environments/bom.js b/flow-typed/environments/bom.js index 7c082ea5dae0..0e83e904cf76 100644 --- a/flow-typed/environments/bom.js +++ b/flow-typed/environments/bom.js @@ -682,11 +682,11 @@ declare class FormData { get(name: string): ?FormDataEntryValue; getAll(name: string): Array; - set(name: string, value: string): void; + set(name: string, value: FormDataEntryValue): void; set(name: string, value: Blob, filename?: string): void; set(name: string, value: File, filename?: string): void; - append(name: string, value: string): void; + append(name: string, value: FormDataEntryValue): void; append(name: string, value: Blob, filename?: string): void; append(name: string, value: File, filename?: string): void; diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index 0661f7824650..bc6c83909e3e 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -590,7 +590,9 @@ export function processReply( // Copy all the form fields with a prefix for this reference. // These must come first in the form order because we assume that all the // fields are available before this is referenced. - const prefix = formFieldPrefix + refId + '_'; + // We include a special marker so that the Server can detect FormData entries + // that are values in referenced FormData objects. + const prefix = formFieldPrefix + '_' + refId + '_'; // $FlowFixMe[prop-missing]: FormData has forEach. value.forEach((originalValue: string | File, originalKey: string) => { // $FlowFixMe[incompatible-call] diff --git a/packages/react-server/src/ReactFlightReplyBackingFormData.js b/packages/react-server/src/ReactFlightReplyBackingFormData.js new file mode 100644 index 000000000000..0fa250bafb2d --- /dev/null +++ b/packages/react-server/src/ReactFlightReplyBackingFormData.js @@ -0,0 +1,107 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Backing FormData is a wrapper around FormData that allows iterating over the + * keys while allowing to evict values from the FormData without affecting the iteration. + * Native FormData.keys() will skip keys if entries with Blob are deleted e.g. + * ```js + * const formData = new FormData(); + * formData.append('a', new Blob()); + * formData.append('b', 2); + * const keys = formData.keys(); + * keys.next().value; // 'a' + * formData.delete('a'); + * keys.next().value; // undefined, but we expect 'b' + * ``` + */ +export opaque type BackingFormData = { + data: FormData, + keyPointer: number, + // Lazily initialized array of keys. We only need this at the moment + // for referenced FormData. + keys: null | Array, +}; + +export function peekBackingEntry(backingStore: BackingFormData): string | void { + let keys = backingStore.keys; + if (keys === null) { + keys = backingStore.keys = Array.from(backingStore.data.keys()); + backingStore.keyPointer = 0; + } + + return keys[backingStore.keyPointer]; +} + +export function advanceBackingEntryIterator( + backingStore: BackingFormData, +): void { + backingStore.keyPointer++; +} + +export function consumeBackingEntry( + backingStore: BackingFormData, + key: string, +): void { + backingStore.data.delete(key); + backingStore.keyPointer++; +} + +export function appendBackingEntry( + backingStore: BackingFormData, + key: string, + value: FormDataEntryValue, +): void { + backingStore.data.append(key, value); + let keys = backingStore.keys; + if (keys === null) { + keys = backingStore.keys = Array.from(backingStore.data.keys()); + backingStore.keyPointer = 0; + } else { + keys.push(key); + } +} + +export function appendBackingFile( + backingStore: BackingFormData, + key: string, + value: Blob, + filename: string, +): void { + backingStore.data.append(key, value, filename); + let keys = backingStore.keys; + if (keys === null) { + keys = backingStore.keys = Array.from(backingStore.data.keys()); + backingStore.keyPointer = 0; + } else { + keys.push(key); + } +} + +export function getBackingEntry( + backingStore: BackingFormData, + key: string, +): ?FormDataEntryValue { + return backingStore.data.get(key); +} + +export function getAllBackingEntries( + backingStore: BackingFormData, + key: string, +): Array { + return backingStore.data.getAll(key); +} + +export function createBackingFormData(formData: FormData): BackingFormData { + return { + data: formData, + keyPointer: -1, + keys: null, + }; +} diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index 46c45f68b033..5d6e94be4ffa 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -18,6 +18,7 @@ import type { ClientReference as ServerReference, } from 'react-client/src/ReactFlightClientConfig'; +import type {BackingFormData} from './ReactFlightReplyBackingFormData'; import type {TemporaryReferenceSet} from './ReactFlightServerTemporaryReferences'; import { @@ -26,6 +27,16 @@ import { requireModule, } from 'react-client/src/ReactFlightClientConfig'; +import { + createBackingFormData, + advanceBackingEntryIterator, + appendBackingEntry, + appendBackingFile, + consumeBackingEntry, + getBackingEntry, + getAllBackingEntries, + peekBackingEntry, +} from './ReactFlightReplyBackingFormData'; import { createTemporaryReference, registerTemporaryReference, @@ -192,7 +203,7 @@ const ArrayPrototype = Array.prototype; export type Response = { _bundlerConfig: ServerManifest, _prefix: string, - _formData: FormData, + _formData: BackingFormData, _chunks: Map>, _closed: boolean, _closedReason: mixed, @@ -605,7 +616,13 @@ function reviveModel( if (value.length > 1) { childContext.fork = true; } - bumpArrayCount(childContext, value.length + 1, response); + bumpArrayCount( + childContext, + // Number of commas + square brackets + // value.length - 1 + 2 + value.length + 1, + response, + ); for (let i = 0; i < value.length; i++) { const childRef = reference !== undefined ? reference + ':' + i : undefined; @@ -690,7 +707,9 @@ type InitializationReference = { type InitializationHandler = { chunk: null | BlockedChunk, value: any, - reason: any, + // TODO: Split type to make it impossible to treat a thrown value as NestedArrayContext. + // thrown value if errored, otherwise array context + reason: mixed | NestedArrayContext, deps: number, errored: boolean, }; @@ -785,11 +804,16 @@ export function reportGlobalError(response: Response, error: Error): void { // because we won't be getting any new data to resolve it. if (chunk.status === PENDING) { triggerErrorOnChunk(response, chunk, error); - } else if (chunk.status === INITIALIZED && chunk.reason !== null) { - const maybeController = chunk.reason; - // $FlowFixMe - if (typeof maybeController.error === 'function') { - maybeController.error(error); + } else if (chunk.status === INITIALIZED) { + const initializedChunk: + | InitializedChunk + | InitializedStreamChunk = (chunk: any); + if (initializedChunk.reason !== null) { + const maybeController = initializedChunk.reason; + // $FlowFixMe[method-unbinding] Just doing a typeof check + if (typeof maybeController.error === 'function') { + maybeController.error(error); + } } } }); @@ -802,7 +826,7 @@ function getChunk(response: Response, id: number): SomeChunk { const prefix = response._prefix; const key = prefix + id; // Check if we have this field in the backing store already. - const backingEntry = response._formData.get(key); + const backingEntry = getBackingEntry(response._formData, key); if (typeof backingEntry === 'string') { chunk = createResolvedModelChunk(response, backingEntry, id); } else if (response._closed) { @@ -923,7 +947,9 @@ function resolveReference( const initializedChunk: InitializedChunk = (chunk: any); initializedChunk.status = INITIALIZED; initializedChunk.value = handler.value; - initializedChunk.reason = handler.reason; // Used by streaming chunks + initializedChunk.reason = + // $FlowFixMe[incompatible-type] Assuming handler.errored is false. + handler.reason; if (resolveListeners !== null) { wakeChunk(response, resolveListeners, handler.value, initializedChunk); } @@ -1010,17 +1036,30 @@ function getOutlinedModel( ): T { const path = reference.split(':'); const id = parseInt(path[0], 16); - const chunk = getChunk(response, id); + let chunk = getChunk(response, id); switch (chunk.status) { case RESOLVED_MODEL: initializeModelChunk(chunk); + // $FlowFixMe[incompatible-cast] We just initialized this chunk so it can't be a ResolvedModelChunk anymore. + chunk = (chunk: Exclude, ResolvedModelChunk>); break; } // The status might have changed after initialization. switch (chunk.status) { case INITIALIZED: let value = chunk.value; - let arrayRoot: null | NestedArrayContext = chunk.reason; + const arrayRootOrController: + | null + | NestedArrayContext + | FlightStreamController = chunk.reason; + if (arrayRootOrController !== null && 'error' in arrayRootOrController) { + throw new Error( + 'Expected an initialized chunk but got an initialized stream chunk instead. ' + + 'This payload may have been submitted by an older version of React.', + ); + } + let arrayRoot = arrayRootOrController; + let localLength: number = 0; const rootArrayContexts = response._rootArrayContexts; for (let i = 1; i < path.length; i++) { @@ -1035,7 +1074,11 @@ function getOutlinedModel( value = value[name]; if (isArray(value)) { localLength = 0; - arrayRoot = rootArrayContexts.get(value) || arrayRoot; + arrayRoot = + rootArrayContexts.get( + // $FlowFixMe[incompatible-cast] Our `isArray` typing can't narrow `mixed` + (value: $ReadOnlyArray), + ) || arrayRoot; } else { arrayRoot = null; if (typeof value === 'string') { @@ -1194,7 +1237,7 @@ function parseTypedArray( // We should have this backingEntry in the store already because we emitted // it before referencing it. It should be a Blob. - const backingEntry: Blob = (response._formData.get(key): any); + const backingEntry: Blob = (getBackingEntry(response._formData, key): any); const promise: Promise = backingEntry.arrayBuffer(); @@ -1294,7 +1337,7 @@ function resolveStream>( const prefix = response._prefix; const key = prefix + id; - const existingEntries = response._formData.getAll(key); + const existingEntries = getAllBackingEntries(response._formData, key); for (let i = 0; i < existingEntries.length; i++) { const value = existingEntries[i]; if (typeof value === 'string') { @@ -1598,28 +1641,41 @@ function parseModelString( case 'K': { // FormData const stringId = value.slice(2); - const formPrefix = response._prefix + stringId + '_'; + + const responsePrefix = response._prefix; + // Use the special marker from the Client to distinguish keys that should + // be consumed by referenced FormData. + const anyFormPrefix = responsePrefix + '_'; + const formPrefix = anyFormPrefix + stringId + '_'; + const data = new FormData(); const backingFormData = response._formData; - // We assume that the reference to FormData always comes after each - // entry that it references so we can assume they all exist in the - // backing store already. - // Clone the keys to workaround bugs in the delete-while-iterating - // algorithm of FormData. - const keys = Array.from(backingFormData.keys()); - for (let i = 0; i < keys.length; i++) { - const entryKey = keys[i]; - if (entryKey.startsWith(formPrefix)) { - const entries = backingFormData.getAll(entryKey); - const newKey = entryKey.slice(formPrefix.length); - for (let j = 0; j < entries.length; j++) { + // We're still transpiling for-of loops, so we have to use the iterator directly instead of a for-of loop. + while (true) { + const formDataKey = peekBackingEntry(backingFormData); + if (formDataKey === undefined) { + break; + } + if (formDataKey.startsWith(formPrefix)) { + const referencedFormDataValue = getAllBackingEntries( + backingFormData, + formDataKey, + ); + const referencedFormDataKey = formDataKey.slice(formPrefix.length); + for (let i = 0; i < referencedFormDataValue.length; i++) { // $FlowFixMe[incompatible-call] - data.append(newKey, entries[j]); + data.append(referencedFormDataKey, referencedFormDataValue[i]); } - // These entries have now all been consumed. Let's free it. - // This also ensures that we don't have any entries left if we - // see the same key twice. - backingFormData.delete(entryKey); + consumeBackingEntry(backingFormData, formDataKey); + } else if (formDataKey.startsWith(anyFormPrefix)) { + // The FormData values are continuous and before the FormData reference. + // If we see something that doesn't look like a value for a referenced + // FormData, we can assume we're past the values for this FormData + // reference and stop iterating. + break; + } else { + // Either an outlined value or something not owned by this Reply. + advanceBackingEntryIterator(backingFormData); } } return data; @@ -1808,7 +1864,10 @@ function parseModelString( const blobKey = prefix + id; // We should have this backingEntry in the store already because we emitted // it before referencing it. It should be a Blob. - const backingEntry = response._formData.get(blobKey); + const backingEntry = getBackingEntry( + response._formData, + blobKey, + ); if (!(backingEntry instanceof Blob)) { throw new Error('Referenced Blob is not a Blob.'); } @@ -1855,10 +1914,11 @@ export function createResponse( arraySizeLimit?: number = DEFAULT_MAX_ARRAY_NESTING, ): Response { const chunks: Map> = new Map(); + const response: Response = { _bundlerConfig: bundlerConfig, _prefix: formFieldPrefix, - _formData: backingFormData, + _formData: createBackingFormData(backingFormData), _chunks: chunks, _closed: false, _closedReason: null, @@ -1875,7 +1935,7 @@ export function resolveField( value: string, ): void { // Add this field to the backing store. - response._formData.append(key, value); + appendBackingEntry(response._formData, key, value); const prefix = response._prefix; if (key.startsWith(prefix)) { const chunks = response._chunks; @@ -1890,7 +1950,7 @@ export function resolveField( export function resolveFile(response: Response, key: string, file: File): void { // Add this field to the backing store. - response._formData.append(key, file); + appendBackingEntry(response._formData, key, file); } export opaque type FileHandle = { @@ -1930,7 +1990,7 @@ export function resolveFileComplete( // the append() form that takes the file name as the third argument, // to create a File object. const blob = new Blob(handle.chunks, {type: handle.mime}); - response._formData.append(key, blob, handle.filename); + appendBackingFile(response._formData, key, blob, handle.filename); } export function close(response: Response): void { diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index d7ca5474b3de..a58cc784bfd6 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -567,5 +567,6 @@ "579": "Invalid data for bytes stream.", "580": "Server Function has too many bound arguments. Received %s but the limit is %s.", "581": "BigInt is too large. Received %s digits but the limit is %s.", - "582": "Referenced Blob is not a Blob." + "582": "Referenced Blob is not a Blob.", + "599": "Expected an initialized chunk but got an initialized stream chunk instead. This payload may have been submitted by an older version of React." } From 5ec5d7ed1698902de7bcc171de3a215480121f7a Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 14 May 2026 22:03:52 +0200 Subject: [PATCH 5/7] [FlightReply] Don't drop FormData entries in `decodeReplyFromBusboy` (#36468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a regression from #36425 where referenced `FormData` entries can be dropped by `decodeReplyFromBusboy` when files are interleaved with text fields in the payload. `decodeReplyFromBusboy` queues text fields that arrive while a file is being streamed and flushes them after the last file's `'end'`, working around busboy emitting `'end'` deferred relative to subsequent `'field'` events. With multiple files interleaved with text, this loses the relative order of the affected text entries. The reorder was a long-standing but invisible issue — entries came back in the wrong order but were all present — until #36425 tightened how referenced FormData entries are collected from the backing store to rely on them being contiguous. With that assumption violated, referenced FormDatas can now come back with some entries dropped. The pattern is most easily surfaced through `useActionState` actions that return the submitted `FormData` as part of their state. This replaces the tail-flush with a linked list of pending files. Text fields that arrive while a file is in flight are queued on the tail file's `queuedFields`; fields that arrive when the list is empty resolve immediately. `flush()` walks from the head, resolving each completed file followed by its queued fields, and stops at the first file that hasn't ended yet. The backing FormData now matches the payload's order, restoring the contiguity assumption (and fixing the long-standing reorder as a side effect). The same change is applied to all five copies in `react-server-dom-{webpack,turbopack,parcel,esm,unbundled}`. Two new tests cover the multi-file interleave. fixes vercel/next.js#93822 (cherry picked from commit b91823e21434ac665450e67ddc6f816710255938) --- package.json | 1 + .../src/server/ReactFlightDOMServerNode.js | 112 ++++++++++--- .../src/server/ReactFlightDOMServerNode.js | 112 ++++++++++--- .../src/server/ReactFlightDOMServerNode.js | 112 ++++++++++--- .../src/server/ReactFlightDOMServerNode.js | 112 ++++++++++--- .../__tests__/ReactFlightDOMReplyNode-test.js | 149 ++++++++++++++++++ .../src/server/ReactFlightDOMServerNode.js | 112 ++++++++++--- yarn.lock | 12 ++ 8 files changed, 617 insertions(+), 105 deletions(-) create mode 100644 packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyNode-test.js diff --git a/package.json b/package.json index a1f5aa0eeff9..b37aa0bc0d39 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "art": "0.10.1", "babel-plugin-syntax-hermes-parser": "^0.32.0", "babel-plugin-syntax-trailing-function-commas": "^6.5.0", + "busboy": "^1.6.0", "chalk": "^3.0.0", "cli-table": "^0.3.1", "coffee-script": "^1.12.7", diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index 8e0799fb0209..fb575aeffc9e 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -62,6 +62,7 @@ import { } from 'react-client/src/ReactFlightClientStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -325,6 +326,17 @@ function prerenderToNodeStream( }); } +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, + next: null | PendingFile, +}; + function decodeReplyFromBusboy( busboyStream: Busboy, moduleBasePath: ServerManifest, @@ -340,14 +352,55 @@ function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); - let pendingFiles = 0; - const queuedFields: Array = []; + + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the list is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + let head: null | PendingFile = null; + let tail: null | PendingFile = null; + let bodyFinished = false; + let closed = false; + + function flush() { + while (head !== null) { + const current = head; + if (!current.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; + } + try { + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + } + } catch (error) { + busboyStream.destroy(error); + return; + } + head = current.next; + } + tail = null; + if (bodyFinished && !closed) { + closed = true; + close(response); + } + } + busboyStream.on('field', (name, value) => { - if (pendingFiles > 0) { - // Because the 'end' event fires two microtasks after the next 'field' - // we would resolve files and fields out of order. To handle this properly - // we queue any fields we receive until the previous file is done. - queuedFields.push(name, value); + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; + } + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -367,29 +420,46 @@ function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const pendingFile: PendingFile = { + name, + file, + complete: false, + queuedFields: null, + next: null, + }; + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; value.on('data', chunk => { - resolveFileChunk(response, file, chunk); - }); - value.on('end', () => { 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; - } + resolveFileChunk(response, file, chunk); } catch (error) { busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); + value.on('end', () => { + pendingFile.complete = true; + flush(); + }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + reportGlobalError( + response, + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js index c5903c41ed49..4ac7157365d4 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js @@ -75,6 +75,7 @@ import { import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -554,6 +555,17 @@ export function registerServerActions(manifest: ServerManifest) { serverManifest = manifest; } +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, + next: null | PendingFile, +}; + export function decodeReplyFromBusboy( busboyStream: Busboy, options?: { @@ -568,14 +580,55 @@ export function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); - let pendingFiles = 0; - const queuedFields: Array = []; + + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the list is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + let head: null | PendingFile = null; + let tail: null | PendingFile = null; + let bodyFinished = false; + let closed = false; + + function flush() { + while (head !== null) { + const current = head; + if (!current.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; + } + try { + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + } + } catch (error) { + busboyStream.destroy(error); + return; + } + head = current.next; + } + tail = null; + if (bodyFinished && !closed) { + closed = true; + close(response); + } + } + busboyStream.on('field', (name, value) => { - if (pendingFiles > 0) { - // Because the 'end' event fires two microtasks after the next 'field' - // we would resolve files and fields out of order. To handle this properly - // we queue any fields we receive until the previous file is done. - queuedFields.push(name, value); + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; + } + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -595,29 +648,46 @@ export function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const pendingFile: PendingFile = { + name, + file, + complete: false, + queuedFields: null, + next: null, + }; + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; value.on('data', chunk => { - resolveFileChunk(response, file, chunk); - }); - value.on('end', () => { 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; - } + resolveFileChunk(response, file, chunk); } catch (error) { busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); + value.on('end', () => { + pendingFile.complete = true; + flush(); + }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + reportGlobalError( + response, + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index 74d379f53a02..4adb47fa5f81 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -68,6 +68,7 @@ import { import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -545,6 +546,17 @@ function prerender( }); } +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, + next: null | PendingFile, +}; + function decodeReplyFromBusboy( busboyStream: Busboy, turbopackMap: ServerManifest, @@ -560,14 +572,55 @@ function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); - let pendingFiles = 0; - const queuedFields: Array = []; + + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the list is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + let head: null | PendingFile = null; + let tail: null | PendingFile = null; + let bodyFinished = false; + let closed = false; + + function flush() { + while (head !== null) { + const current = head; + if (!current.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; + } + try { + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + } + } catch (error) { + busboyStream.destroy(error); + return; + } + head = current.next; + } + tail = null; + if (bodyFinished && !closed) { + closed = true; + close(response); + } + } + busboyStream.on('field', (name, value) => { - if (pendingFiles > 0) { - // Because the 'end' event fires two microtasks after the next 'field' - // we would resolve files and fields out of order. To handle this properly - // we queue any fields we receive until the previous file is done. - queuedFields.push(name, value); + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; + } + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -587,29 +640,46 @@ function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const pendingFile: PendingFile = { + name, + file, + complete: false, + queuedFields: null, + next: null, + }; + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; value.on('data', chunk => { - resolveFileChunk(response, file, chunk); - }); - value.on('end', () => { 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; - } + resolveFileChunk(response, file, chunk); } catch (error) { busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); + value.on('end', () => { + pendingFile.complete = true; + flush(); + }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + reportGlobalError( + response, + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( diff --git a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js index 9a75c20395bc..d607e65c0d36 100644 --- a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js @@ -68,6 +68,7 @@ import { import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -545,6 +546,17 @@ function prerender( }); } +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, + next: null | PendingFile, +}; + function decodeReplyFromBusboy( busboyStream: Busboy, webpackMap: ServerManifest, @@ -560,14 +572,55 @@ function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); - let pendingFiles = 0; - const queuedFields: Array = []; + + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the list is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + let head: null | PendingFile = null; + let tail: null | PendingFile = null; + let bodyFinished = false; + let closed = false; + + function flush() { + while (head !== null) { + const current = head; + if (!current.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; + } + try { + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + } + } catch (error) { + busboyStream.destroy(error); + return; + } + head = current.next; + } + tail = null; + if (bodyFinished && !closed) { + closed = true; + close(response); + } + } + busboyStream.on('field', (name, value) => { - if (pendingFiles > 0) { - // Because the 'end' event fires two microtasks after the next 'field' - // we would resolve files and fields out of order. To handle this properly - // we queue any fields we receive until the previous file is done. - queuedFields.push(name, value); + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; + } + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -587,29 +640,46 @@ function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const pendingFile: PendingFile = { + name, + file, + complete: false, + queuedFields: null, + next: null, + }; + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; value.on('data', chunk => { - resolveFileChunk(response, file, chunk); - }); - value.on('end', () => { 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; - } + resolveFileChunk(response, file, chunk); } catch (error) { busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); + value.on('end', () => { + pendingFile.complete = true; + flush(); + }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + reportGlobalError( + response, + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyNode-test.js new file mode 100644 index 000000000000..0924bafbdcd0 --- /dev/null +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyNode-test.js @@ -0,0 +1,149 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment node + */ + +'use strict'; + +let webpackServerMap; +let busboy; +let ReactServerDOMServer; +let ReactServerDOMClient; + +describe('ReactFlightDOMReplyNode', () => { + beforeEach(() => { + jest.resetModules(); + // Simulate the condition resolution + jest.mock('react', () => require('react/react.react-server')); + jest.mock('react-server-dom-webpack/server', () => + require('react-server-dom-webpack/server.node'), + ); + const WebpackMock = require('./utils/WebpackMock'); + webpackServerMap = WebpackMock.webpackServerMap; + ReactServerDOMServer = require('react-server-dom-webpack/server.node'); + jest.resetModules(); + ReactServerDOMClient = require('react-server-dom-webpack/client.node'); + + busboy = require('busboy'); + }); + + // Writes the body to busboy as a multipart stream. Blob entries become + // `filename`-bearing parts so busboy emits them as 'file' events (with + // streamed data) rather than 'field' events. + async function pipeBodyToBusboy(bb, body, boundary) { + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const [name, value] of body) { + if (typeof value === 'string') { + bb.write( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${name}"\r\n` + + `\r\n` + + `${value}\r\n`, + ); + } else { + const filename = + typeof value.name === 'string' && value.name !== '' + ? value.name + : 'blob'; + const mimeType = + typeof value.type === 'string' && value.type !== '' + ? value.type + : 'application/octet-stream'; + const buffer = Buffer.from(await value.arrayBuffer()); + bb.write( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` + + `Content-Type: ${mimeType}\r\n` + + `\r\n`, + ); + bb.write(buffer); + bb.write('\r\n'); + } + } + bb.end(`--${boundary}--\r\n`); + } + + // FormData iterates entries in insertion order per spec, so a referenced + // FormData must round-trip with its entry order intact even when files + // and text fields are interleaved in the payload. + it('preserves entry order when referenced FormDatas interleave files and text', async () => { + const a = new FormData(); + a.append('text_a', 'value_a'); + a.append('file_a', new Blob(['content_a'], {type: 'text/plain'}), 'a.txt'); + const b = new FormData(); + b.append('text_b', 'value_b'); + b.append('file_b', new Blob(['content_b'], {type: 'text/plain'}), 'b.txt'); + + const body = await ReactServerDOMClient.encodeReply([a, b]); + const boundary = 'boundary'; + const bb = busboy({ + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + }); + const reply = ReactServerDOMServer.decodeReplyFromBusboy( + bb, + webpackServerMap, + ); + await pipeBodyToBusboy(bb, body, boundary); + + const result = await reply; + expect(result).toHaveLength(2); + const [decodedA, decodedB] = result; + + const aEntries = Array.from(decodedA.entries()); + expect(aEntries.map(([k]) => k)).toEqual(['text_a', 'file_a']); + expect(aEntries[0][1]).toBe('value_a'); + expect(aEntries[1][1]).toBeInstanceOf(File); + expect(aEntries[1][1].name).toBe('a.txt'); + + const bEntries = Array.from(decodedB.entries()); + expect(bEntries.map(([k]) => k)).toEqual(['text_b', 'file_b']); + expect(bEntries[0][1]).toBe('value_b'); + expect(bEntries[1][1]).toBeInstanceOf(File); + expect(bEntries[1][1].name).toBe('b.txt'); + }); + + // Every entry of a referenced FormData must be present in the decoded + // FormData regardless of where files appear in its iteration order. + it('does not drop entries when referenced FormDatas iterate files before text', async () => { + const a = new FormData(); + a.append('file_a', new Blob(['content_a'], {type: 'text/plain'}), 'a.txt'); + a.append('text_a', 'value_a'); + const b = new FormData(); + b.append('file_b', new Blob(['content_b'], {type: 'text/plain'}), 'b.txt'); + b.append('text_b', 'value_b'); + + const body = await ReactServerDOMClient.encodeReply([a, b]); + const boundary = 'boundary'; + const bb = busboy({ + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + }); + const reply = ReactServerDOMServer.decodeReplyFromBusboy( + bb, + webpackServerMap, + ); + await pipeBodyToBusboy(bb, body, boundary); + + const result = await reply; + expect(result).toHaveLength(2); + const [decodedA, decodedB] = result; + + const aKeys = Array.from(decodedA.keys()).sort(); + expect(aKeys).toEqual(['file_a', 'text_a']); + expect(decodedA.get('text_a')).toBe('value_a'); + expect(decodedA.get('file_a')).toBeInstanceOf(File); + + const bKeys = Array.from(decodedB.keys()).sort(); + expect(bKeys).toEqual(['file_b', 'text_b']); + expect(decodedB.get('text_b')).toBe('value_b'); + expect(decodedB.get('file_b')).toBeInstanceOf(File); + }); +}); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index 888d01391449..edaf2fa3f82f 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -68,6 +68,7 @@ import { import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -545,6 +546,17 @@ function prerender( }); } +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, + next: null | PendingFile, +}; + function decodeReplyFromBusboy( busboyStream: Busboy, webpackMap: ServerManifest, @@ -560,14 +572,55 @@ function decodeReplyFromBusboy( undefined, options ? options.arraySizeLimit : undefined, ); - let pendingFiles = 0; - const queuedFields: Array = []; + + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the list is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + let head: null | PendingFile = null; + let tail: null | PendingFile = null; + let bodyFinished = false; + let closed = false; + + function flush() { + while (head !== null) { + const current = head; + if (!current.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; + } + try { + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + } + } catch (error) { + busboyStream.destroy(error); + return; + } + head = current.next; + } + tail = null; + if (bodyFinished && !closed) { + closed = true; + close(response); + } + } + busboyStream.on('field', (name, value) => { - if (pendingFiles > 0) { - // Because the 'end' event fires two microtasks after the next 'field' - // we would resolve files and fields out of order. To handle this properly - // we queue any fields we receive until the previous file is done. - queuedFields.push(name, value); + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; + } + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -587,29 +640,46 @@ function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const pendingFile: PendingFile = { + name, + file, + complete: false, + queuedFields: null, + next: null, + }; + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; value.on('data', chunk => { - resolveFileChunk(response, file, chunk); - }); - value.on('end', () => { 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; - } + resolveFileChunk(response, file, chunk); } catch (error) { busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); + value.on('end', () => { + pendingFile.complete = true; + flush(); + }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + reportGlobalError( + response, + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( diff --git a/yarn.lock b/yarn.lock index c7f9e8501453..f80bae876bd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6092,6 +6092,13 @@ bunyan@1.8.15: mv "~2" safe-json-stringify "~1" +busboy@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" @@ -16093,6 +16100,11 @@ stream-shift@^1.0.0: resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" From 7e5850c06df951487010bd9dfe3474769cc8c38a Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Tue, 21 Jul 2026 17:21:31 +0200 Subject: [PATCH 6/7] [FlightReply] Performance improvements when decoding (#37087) This fixes security vulnerabilities in Server Functions. (cherry picked from commit 1dd4ecbdabf826f527fc9a58c05ea70375b7d170) --- .../src/server/ReactFlightDOMServerEdge.js | 5 +- .../src/server/ReactFlightDOMServerNode.js | 5 +- .../src/server/ReactFlightDOMServerEdge.js | 5 +- .../src/server/ReactFlightDOMServerNode.js | 5 +- .../src/server/ReactFlightDOMServerNode.js | 5 +- .../src/server/ReactFlightDOMServerEdge.js | 5 +- .../src/server/ReactFlightDOMServerNode.js | 5 +- .../src/ReactFlightActionServer.js | 89 ++++++++++--------- .../react-server/src/ReactFlightServer.js | 10 ++- scripts/error-codes/codes.json | 3 +- 10 files changed, 74 insertions(+), 63 deletions(-) diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js index 83150996ae69..dba7bcfc51cb 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js @@ -20,6 +20,7 @@ import { type ServerReferenceId, } from '../client/ReactFlightClientConfigBundlerParcel'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -306,8 +307,8 @@ export function decodeReplyFromAsyncIterable( reportGlobalError(response, reason); if (typeof (iterator: any).throw === 'function') { // The iterator protocol doesn't necessarily include this but a generator do. - // $FlowFixMe should be able to pass mixed - iterator.throw(reason).then(error, error); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js index 4ac7157365d4..8c2eab4c77b1 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js @@ -24,6 +24,7 @@ import type {Duplex} from 'stream'; import {Readable} from 'stream'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -762,8 +763,8 @@ export function decodeReplyFromAsyncIterable( reportGlobalError(response, reason); if (typeof (iterator: any).throw === 'function') { // The iterator protocol doesn't necessarily include this but a generator do. - // $FlowFixMe should be able to pass mixed - iterator.throw(reason).then(error, error); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js index f6a8fcc9abcd..6ced97ff73aa 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js @@ -15,6 +15,7 @@ import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigTurbopackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -306,8 +307,8 @@ function decodeReplyFromAsyncIterable( reportGlobalError(response, reason); if (typeof (iterator: any).throw === 'function') { // The iterator protocol doesn't necessarily include this but a generator do. - // $FlowFixMe should be able to pass mixed - iterator.throw(reason).then(error, error); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index 4adb47fa5f81..e6f56147f7ac 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -22,6 +22,7 @@ import type {Duplex} from 'stream'; import {Readable} from 'stream'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -756,8 +757,8 @@ function decodeReplyFromAsyncIterable( reportGlobalError(response, reason); if (typeof (iterator: any).throw === 'function') { // The iterator protocol doesn't necessarily include this but a generator do. - // $FlowFixMe should be able to pass mixed - iterator.throw(reason).then(error, error); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js index d607e65c0d36..ced4eb7d893f 100644 --- a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js @@ -22,6 +22,7 @@ import type {Duplex} from 'stream'; import {Readable} from 'stream'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -756,8 +757,8 @@ function decodeReplyFromAsyncIterable( reportGlobalError(response, reason); if (typeof (iterator: any).throw === 'function') { // The iterator protocol doesn't necessarily include this but a generator do. - // $FlowFixMe should be able to pass mixed - iterator.throw(reason).then(error, error); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js index 77067754bc59..323c1ee021ec 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js @@ -15,6 +15,7 @@ import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigWebpackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -306,8 +307,8 @@ function decodeReplyFromAsyncIterable( reportGlobalError(response, reason); if (typeof (iterator: any).throw === 'function') { // The iterator protocol doesn't necessarily include this but a generator do. - // $FlowFixMe should be able to pass mixed - iterator.throw(reason).then(error, error); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index edaf2fa3f82f..4004a1f7e65f 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -22,6 +22,7 @@ import type {Duplex} from 'stream'; import {Readable} from 'stream'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -756,8 +757,8 @@ function decodeReplyFromAsyncIterable( reportGlobalError(response, reason); if (typeof (iterator: any).throw === 'function') { // The iterator protocol doesn't necessarily include this but a generator do. - // $FlowFixMe should be able to pass mixed - iterator.throw(reason).then(error, error); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server/src/ReactFlightActionServer.js b/packages/react-server/src/ReactFlightActionServer.js index a3a47a9f9793..4f8f9c52ca53 100644 --- a/packages/react-server/src/ReactFlightActionServer.js +++ b/packages/react-server/src/ReactFlightActionServer.js @@ -112,53 +112,50 @@ export function decodeAction( // the implementation details of the action data. const formData = new FormData(); - let action: Promise<(formData: FormData) => T> | null = null; - const seenActions = new Set(); + let maybeActionKey: null | string = null; // $FlowFixMe[prop-missing] body.forEach((value: string | File, key: string) => { if (!key.startsWith('$ACTION_')) { // $FlowFixMe[incompatible-call] formData.append(key, value); - return; - } - // Later actions may override earlier actions if a button is used to - // override the default form action. However, we don't expect the same - // action ref field to be sent multiple times in legitimate form data. - if (key.startsWith('$ACTION_REF_')) { - if (seenActions.has(key)) { - return; - } - seenActions.add(key); - const formFieldPrefix = '$ACTION_' + key.slice(12) + ':'; - const metaData = decodeBoundActionMetaData( - body, - serverManifest, - formFieldPrefix, - ); - action = loadServerReference(serverManifest, metaData); - return; - } - // A simple action with no bound arguments may appear twice in the form data - // if a button specifies the same action as the default form action. We only - // load the first one, as they're guaranteed to be identical. - if (key.startsWith('$ACTION_ID_')) { - if (seenActions.has(key)) { - return; - } - seenActions.add(key); - const id = key.slice(11); - action = loadServerReference(serverManifest, { - id, - bound: null, - }); - return; + } else if (key.startsWith('$ACTION_REF_')) { + // Later actions may override earlier actions if a button is used to + // override the default form action. However, we don't expect the same + // action ref field to be sent multiple times in legitimate form data. + maybeActionKey = key; + } else if (key.startsWith('$ACTION_ID_')) { + // A simple action with no bound arguments may appear twice in the form data + // if a button specifies the same action as the default form action. + maybeActionKey = key; } }); - if (action === null) { + if (maybeActionKey === null) { return null; } + const actionKey = maybeActionKey; + + let action: Promise<(formData: FormData) => T> | null = null; + if (actionKey.startsWith('$ACTION_REF_')) { + const formFieldPrefix = + '$ACTION_' + actionKey.slice('$ACTION_REF_'.length) + ':'; + const metaData = decodeBoundActionMetaData( + body, + serverManifest, + formFieldPrefix, + ); + action = loadServerReference(serverManifest, metaData); + } else if (actionKey.startsWith('$ACTION_ID_')) { + const id = actionKey.slice('$ACTION_ID_'.length); + action = loadServerReference(serverManifest, { + id, + bound: null, + }); + } else { + throw new Error('Cannot handle action key. This is a bug in React.'); + } + // Return the action with the remaining FormData bound to the first argument. return action.then(fn => fn.bind(null, formData)); } @@ -175,24 +172,28 @@ export function decodeFormState( } // Search through the form data object to get the reference id and the number // of bound arguments. This repeats some of the work done in decodeAction. - let metaData = null; + let actionKey: null | string = null; // $FlowFixMe[prop-missing] body.forEach((value: string | File, key: string) => { if (key.startsWith('$ACTION_REF_')) { - const formFieldPrefix = '$ACTION_' + key.slice(12) + ':'; - metaData = decodeBoundActionMetaData( - body, - serverManifest, - formFieldPrefix, - ); + actionKey = key; } // We don't check for the simple $ACTION_ID_ case because form state actions // are always bound to the state argument. }); - if (metaData === null) { + if (actionKey === null) { // Should be unreachable. return Promise.resolve(null); } + + const formFieldPrefix = + '$ACTION_' + actionKey.slice('$ACTION_REF_'.length) + ':'; + const metaData = decodeBoundActionMetaData( + body, + serverManifest, + formFieldPrefix, + ); + const referenceId = metaData.id; return Promise.resolve(metaData.bound).then(bound => { if (bound === null) { diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index f31fa45f7a77..12c40e790d45 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -1355,8 +1355,8 @@ function serializeAsyncIterable( enqueueFlush(request); if (typeof (iterator: any).throw === 'function') { // The iterator protocol doesn't necessarily include this but a generator do. - // $FlowFixMe should be able to pass mixed - iterator.throw(reason).then(error, error); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } function abortIterable() { @@ -1376,9 +1376,11 @@ function serializeAsyncIterable( enqueueFlush(request); } if (typeof (iterator: any).throw === 'function') { + // TODO: Premature exits should call return() on the iterator if it exists + // to allow cleanup. See https://tc39.es/ecma262/multipage/control-abstraction-objects.html#table-async-iterator-optional // The iterator protocol doesn't necessarily include this but a generator do. - // $FlowFixMe should be able to pass mixed - iterator.throw(reason).then(error, error); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } request.cacheController.signal.addEventListener('abort', abortIterable); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index a58cc784bfd6..69d88551322a 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -568,5 +568,6 @@ "580": "Server Function has too many bound arguments. Received %s but the limit is %s.", "581": "BigInt is too large. Received %s digits but the limit is %s.", "582": "Referenced Blob is not a Blob.", - "599": "Expected an initialized chunk but got an initialized stream chunk instead. This payload may have been submitted by an older version of React." + "599": "Expected an initialized chunk but got an initialized stream chunk instead. This payload may have been submitted by an older version of React.", + "602": "Cannot handle action key. This is a bug in React." } From 3b9164083bb5544f62ff8ef1a36abdfe285b0038 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Mon, 10 Aug 2026 20:24:31 +0200 Subject: [PATCH 7/7] [Flight Reply] Align Rspack decoders with upstream changes Mirror the selected Reply changes into the Rspack-owned browser, edge, and Node adapters after the dependency-closed upstream backports. Forward caller-provided array size limits, preserve multipart field/file order, and settle failed async iterators without recursive error re-entry. Add public behavior coverage across every Rspack decoder, action-selection path, cyclic collection type, iterator settlement, and Busboy ordering direction. This is a tactical source-parity change for the existing proposal branch; it does not make that branch current with React main. --- .../ReactFlightDOMRspackReplyNode-test.js | 164 ++++++++++++ .../__tests__/ReactFlightRspackReply-test.js | 19 ++ .../ReactFlightRspackReplyBrowser-test.js | 27 ++ .../ReactFlightRspackReplyEdge-test.js | 20 ++ .../utils/RspackReplySecurityTests.js | 252 ++++++++++++++++++ .../src/__tests__/utils/RspackTestManifest.js | 71 +++++ .../src/server/ReactFlightDOMServerBrowser.js | 6 +- .../src/server/ReactFlightDOMServerEdge.js | 18 +- .../src/server/ReactFlightDOMServerNode.js | 137 ++++++++-- .../src/ReactFlightReplyServer.js | 5 +- 10 files changed, 684 insertions(+), 35 deletions(-) create mode 100644 packages/react-server-dom-rspack/src/__tests__/ReactFlightDOMRspackReplyNode-test.js create mode 100644 packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReply-test.js create mode 100644 packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReplyBrowser-test.js create mode 100644 packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReplyEdge-test.js create mode 100644 packages/react-server-dom-rspack/src/__tests__/utils/RspackReplySecurityTests.js create mode 100644 packages/react-server-dom-rspack/src/__tests__/utils/RspackTestManifest.js diff --git a/packages/react-server-dom-rspack/src/__tests__/ReactFlightDOMRspackReplyNode-test.js b/packages/react-server-dom-rspack/src/__tests__/ReactFlightDOMRspackReplyNode-test.js new file mode 100644 index 000000000000..9e4d71bdda0f --- /dev/null +++ b/packages/react-server-dom-rspack/src/__tests__/ReactFlightDOMRspackReplyNode-test.js @@ -0,0 +1,164 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment node + */ + +'use strict'; + +const {installRspackTestManifest} = require('./utils/RspackTestManifest'); + +let busboy; +let ReactServerDOMServer; +let ReactServerDOMClient; +let rspack; + +describe('ReactFlightDOMRspackReplyNode', () => { + beforeEach(() => { + jest.resetModules(); + jest.mock('react', () => require('react/react.react-server')); + rspack = installRspackTestManifest(); + ReactServerDOMServer = require('react-server-dom-rspack/server.node'); + + jest.resetModules(); + __unmockReact(); + ReactServerDOMClient = require('react-server-dom-rspack/client.browser'); + busboy = require('busboy'); + }); + + afterEach(() => { + rspack.restore(); + }); + + async function pipeBodyToBusboy(bb, body, boundary) { + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const [name, value] of body) { + if (typeof value === 'string') { + bb.write( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${name}"\r\n` + + `\r\n` + + `${value}\r\n`, + ); + } else { + const filename = + typeof value.name === 'string' && value.name !== '' + ? value.name + : 'blob'; + const mimeType = + typeof value.type === 'string' && value.type !== '' + ? value.type + : 'application/octet-stream'; + const buffer = Buffer.from(await value.arrayBuffer()); + bb.write( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` + + `Content-Type: ${mimeType}\r\n` + + `\r\n`, + ); + bb.write(buffer); + bb.write('\r\n'); + } + } + bb.end(`--${boundary}--\r\n`); + } + + function createBusboy(boundary) { + return busboy({ + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + }); + } + + it('preserves interleaved file and field order in referenced FormData', async () => { + const first = new FormData(); + first.append( + 'first-file', + new Blob(['first-content'], {type: 'text/plain'}), + 'first.txt', + ); + first.append('first-text', 'first-value'); + const second = new FormData(); + second.append( + 'second-file', + new Blob(['second-content'], {type: 'text/plain'}), + 'second.txt', + ); + second.append('second-text', 'second-value'); + + const body = await ReactServerDOMClient.encodeReply([first, second]); + const boundary = 'rspack-reply-boundary'; + const bb = createBusboy(boundary); + const reply = ReactServerDOMServer.decodeReplyFromBusboy(bb); + await pipeBodyToBusboy(bb, body, boundary); + + const [decodedFirst, decodedSecond] = await reply; + expect(Array.from(decodedFirst.keys())).toEqual([ + 'first-file', + 'first-text', + ]); + expect(decodedFirst.get('first-text')).toBe('first-value'); + expect(decodedFirst.get('first-file').name).toBe('first.txt'); + expect(Array.from(decodedSecond.keys())).toEqual([ + 'second-file', + 'second-text', + ]); + expect(decodedSecond.get('second-text')).toBe('second-value'); + expect(decodedSecond.get('second-file').name).toBe('second.txt'); + }); + + it('preserves text before file order across referenced FormDatas', async () => { + const first = new FormData(); + first.append('first-text', 'first-value'); + first.append( + 'first-file', + new Blob(['first-content'], {type: 'text/plain'}), + 'first.txt', + ); + const second = new FormData(); + second.append('second-text', 'second-value'); + second.append( + 'second-file', + new Blob(['second-content'], {type: 'text/plain'}), + 'second.txt', + ); + + const body = await ReactServerDOMClient.encodeReply([first, second]); + const boundary = 'rspack-text-file-boundary'; + const bb = createBusboy(boundary); + const reply = ReactServerDOMServer.decodeReplyFromBusboy(bb); + await pipeBodyToBusboy(bb, body, boundary); + + const [decodedFirst, decodedSecond] = await reply; + expect(Array.from(decodedFirst.keys())).toEqual([ + 'first-text', + 'first-file', + ]); + expect(decodedFirst.get('first-text')).toBe('first-value'); + expect(decodedFirst.get('first-file').name).toBe('first.txt'); + expect(Array.from(decodedSecond.keys())).toEqual([ + 'second-text', + 'second-file', + ]); + expect(decodedSecond.get('second-text')).toBe('second-value'); + expect(decodedSecond.get('second-file').name).toBe('second.txt'); + }); + + it('enforces the array size limit for Busboy replies', async () => { + const body = new FormData(); + body.set('0', JSON.stringify([[0, 1]])); + const boundary = 'rspack-array-limit-boundary'; + const bb = createBusboy(boundary); + const reply = ReactServerDOMServer.decodeReplyFromBusboy(bb, { + arraySizeLimit: 3, + }); + await pipeBodyToBusboy(bb, body, boundary); + + await expect(reply).rejects.toThrow('Maximum array nesting exceeded'); + }); +}); diff --git a/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReply-test.js b/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReply-test.js new file mode 100644 index 000000000000..d717670a3c8f --- /dev/null +++ b/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReply-test.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +const { + describeRspackReplySecurityTests, +} = require('./utils/RspackReplySecurityTests'); + +describeRspackReplySecurityTests( + () => require('react-server-dom-rspack/server.node'), + () => require('react-server-dom-rspack/client.browser'), +); diff --git a/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReplyBrowser-test.js b/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReplyBrowser-test.js new file mode 100644 index 000000000000..f80f823cbbc1 --- /dev/null +++ b/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReplyBrowser-test.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment + */ + +'use strict'; + +const { + describeRspackReplySecurityTests, +} = require('./utils/RspackReplySecurityTests'); +const { + patchMessageChannel, +} = require('../../../../scripts/jest/patchMessageChannel'); + +describeRspackReplySecurityTests( + () => { + patchMessageChannel(); + return require('react-server-dom-rspack/server.browser'); + }, + () => require('react-server-dom-rspack/client.browser'), + false, +); diff --git a/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReplyEdge-test.js b/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReplyEdge-test.js new file mode 100644 index 000000000000..aa20d474cfbf --- /dev/null +++ b/packages/react-server-dom-rspack/src/__tests__/ReactFlightRspackReplyEdge-test.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment + */ + +'use strict'; + +const { + describeRspackReplySecurityTests, +} = require('./utils/RspackReplySecurityTests'); + +describeRspackReplySecurityTests( + () => require('react-server-dom-rspack/server.edge'), + () => require('react-server-dom-rspack/client.edge'), +); diff --git a/packages/react-server-dom-rspack/src/__tests__/utils/RspackReplySecurityTests.js b/packages/react-server-dom-rspack/src/__tests__/utils/RspackReplySecurityTests.js new file mode 100644 index 000000000000..f2040c78f457 --- /dev/null +++ b/packages/react-server-dom-rspack/src/__tests__/utils/RspackReplySecurityTests.js @@ -0,0 +1,252 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const {installRspackTestManifest} = require('./RspackTestManifest'); + +function describeRspackReplySecurityTests( + loadServer, + loadClient, + supportsAsyncIterable = true, +) { + let ReactServerDOMServer; + let ReactServerDOMClient; + let rspack; + + describe('ReactFlightRspackReply security behavior', () => { + beforeEach(() => { + jest.resetModules(); + jest.mock('react', () => require('react/react.react-server')); + rspack = installRspackTestManifest(); + ReactServerDOMServer = loadServer(); + + jest.resetModules(); + __unmockReact(); + ReactServerDOMClient = loadClient(); + }); + + afterEach(() => { + rspack.restore(); + }); + + it('uses only the last action when a submitter overrides the form action', async () => { + const body = new FormData(); + body.append('message', 'hello'); + for (let i = 0; i < 7; i++) { + const actionId = 'overridden-' + i; + rspack.registerErroredServerAction( + actionId, + new Error('An overridden action must not be loaded.'), + ); + body.append('$ACTION_ID_' + actionId, ''); + } + rspack.registerServerAction('submitter', formData => { + return 'submitter:' + formData.get('message'); + }); + body.append('$ACTION_ID_submitter', ''); + + const action = await ReactServerDOMServer.decodeAction(body); + + expect(await action()).toBe('submitter:hello'); + expect(rspack.getModuleLoadCount()).toBe(1); + }); + + it('lazily decodes only the last bound action and form state metadata', async () => { + rspack.registerServerAction('submitter', (state, formData) => { + return `${state}:${formData.get('message')}`; + }); + + const body = new FormData(); + body.append('message', 'hello'); + body.append('$ACTION_invalid:0', '{'); + body.append('$ACTION_REF_invalid', ''); + + const metadata = await ReactServerDOMClient.encodeReply({ + id: 'submitter', + bound: Promise.resolve(['initial-state']), + }); + if (typeof metadata === 'string') { + body.append('$ACTION_selected:0', metadata); + } else { + metadata.forEach((value, key) => { + body.append('$ACTION_selected:' + key, value); + }); + } + body.append('$ACTION_REF_selected', ''); + body.append('$ACTION_KEY', 'state-key'); + + const action = await ReactServerDOMServer.decodeAction(body); + expect(await action()).toBe('initial-state:hello'); + expect(rspack.getModuleLoadCount()).toBe(1); + await expect( + ReactServerDOMServer.decodeFormState('result', body), + ).resolves.toEqual(['result', 'state-key', 'submitter', 0]); + }); + + it('returns null synchronously when a form has no action metadata', () => { + const body = new FormData(); + body.append('message', 'hello'); + + expect(ReactServerDOMServer.decodeAction(body)).toBe(null); + }); + + it('rejects a Blob reference whose backing entry is a string', async () => { + const body = new FormData(); + body.set('1', '-'.repeat(50000)); + body.set('0', JSON.stringify(['$B1'])); + + let error; + try { + await ReactServerDOMServer.decodeReply(body); + } catch (x) { + error = x; + } + + expect(error).toEqual( + expect.objectContaining({ + message: expect.stringContaining('Referenced Blob is not a Blob.'), + }), + ); + }); + + it('round trips cyclic objects and arrays without duplicating them', async () => { + const cyclicObject = {self: null}; + cyclicObject.self = cyclicObject; + const cyclicArray = [cyclicObject]; + cyclicArray.push(cyclicArray); + + const body = await ReactServerDOMClient.encodeReply({ + cyclicObject, + cyclicArray, + }); + const result = await ReactServerDOMServer.decodeReply(body); + + expect(result.cyclicObject.self).toBe(result.cyclicObject); + expect(result.cyclicArray[0]).toBe(result.cyclicObject); + expect(result.cyclicArray[1]).toBe(result.cyclicArray); + }); + + it('marks collection models consumed before hostile iteration', async () => { + const nativeIterator = Array.prototype[Symbol.iterator]; + const iteratorSpy = jest + .spyOn(Array.prototype, Symbol.iterator) + .mockImplementation(function () { + const isMapModel = + this.length === 1 && + Array.isArray(this[0]) && + this[0][0] === 'map-marker'; + const isSetOrIteratorModel = + this.length === 2 && + (this[0] === 'set-marker' || this[0] === 'iterator-marker'); + if ( + (isMapModel || isSetOrIteratorModel) && + !Object.prototype.hasOwnProperty.call(this, '$$consumed') + ) { + throw new Error( + 'Collection model was consumed before it was marked.', + ); + } + return nativeIterator.call(this); + }); + + try { + const cases = [ + ['$Q1', '[["map-marker","$Q1"]]', 'Already initialized Map.'], + ['$W1', '["set-marker","$W1"]', 'Already initialized Set.'], + ['$i1', '["iterator-marker","$i1"]', 'Already initialized Iterator.'], + ]; + for (let i = 0; i < cases.length; i++) { + const [root, model, expectedError] = cases[i]; + const body = new FormData(); + body.append('0', JSON.stringify(root)); + body.append('1', model); + await expect(ReactServerDOMServer.decodeReply(body)).rejects.toThrow( + expectedError, + ); + } + } finally { + iteratorSpy.mockRestore(); + } + }); + + it('does not materialize an own __proto__ property from a reply', async () => { + const body = new FormData(); + body.set('0', '{"safe":true,"__proto__":{"polluted":true}}'); + + const result = await ReactServerDOMServer.decodeReply(body); + + expect(result).toEqual({safe: true}); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect({}.polluted).toBe(undefined); + }); + + if (supportsAsyncIterable) { + it('throws once into a failed async iterator for either settlement', async () => { + await Promise.all( + [false, true].map(async throwRejects => { + const failure = new Error('input failed'); + let throwCount = 0; + const iterable = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.reject(failure); + }, + throw(reason) { + throwCount++; + if (throwCount > 1) { + return new Promise(() => {}); + } + return throwRejects + ? Promise.reject(reason) + : Promise.resolve(); + }, + }; + }, + }; + + await expect( + ReactServerDOMServer.decodeReplyFromAsyncIterable(iterable), + ).rejects.toThrow('input failed'); + await Promise.resolve(); + await Promise.resolve(); + + expect(throwCount).toBe(1); + }), + ); + }); + } + + it('enforces a caller-provided array size limit', async () => { + const body = new FormData(); + body.set('0', JSON.stringify([[0, 1]])); + + await expect( + ReactServerDOMServer.decodeReply(body, {arraySizeLimit: 3}), + ).rejects.toThrow('Maximum array nesting exceeded'); + }); + + if (supportsAsyncIterable) { + it('enforces the array size limit for async iterable replies', async () => { + const iterable = { + async *[Symbol.asyncIterator]() { + yield ['0', JSON.stringify([[0, 1]])]; + }, + }; + + await expect( + ReactServerDOMServer.decodeReplyFromAsyncIterable(iterable, { + arraySizeLimit: 3, + }), + ).rejects.toThrow('Maximum array nesting exceeded'); + }); + } + }); +} + +exports.describeRspackReplySecurityTests = describeRspackReplySecurityTests; diff --git a/packages/react-server-dom-rspack/src/__tests__/utils/RspackTestManifest.js b/packages/react-server-dom-rspack/src/__tests__/utils/RspackTestManifest.js new file mode 100644 index 000000000000..d4c43fbd7fcc --- /dev/null +++ b/packages/react-server-dom-rspack/src/__tests__/utils/RspackTestManifest.js @@ -0,0 +1,71 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +function installRspackTestManifest() { + const previousManifest = global.__rspack_rsc_manifest__; + const previousRequire = global.__webpack_require__; + const previousChunkLoad = global.__webpack_chunk_load__; + const modules = new Map(); + let moduleLoadCount = 0; + + global.__webpack_require__ = moduleId => { + moduleLoadCount++; + const moduleRecord = modules.get(moduleId); + if (moduleRecord.error !== undefined) { + throw moduleRecord.error; + } + return moduleRecord.exports; + }; + global.__webpack_chunk_load__ = () => Promise.resolve(); + global.__rspack_rsc_manifest__ = { + clientManifest: {}, + serverManifest: {}, + serverConsumerModuleMap: null, + moduleLoading: null, + entryJsFiles: [], + entryCssFiles: {}, + }; + + return { + getModuleLoadCount() { + return moduleLoadCount; + }, + + registerServerAction(actionId, action) { + const moduleId = 'server-action:' + actionId; + const exportName = 'action'; + modules.set(moduleId, { + exports: {[exportName]: action}, + }); + global.__rspack_rsc_manifest__.serverManifest[actionId] = { + id: moduleId, + chunks: [], + name: exportName, + }; + }, + + registerErroredServerAction(actionId, error) { + const moduleId = 'server-action:' + actionId; + modules.set(moduleId, {error}); + global.__rspack_rsc_manifest__.serverManifest[actionId] = { + id: moduleId, + chunks: [], + name: 'action', + }; + }, + + restore() { + global.__rspack_rsc_manifest__ = previousManifest; + global.__webpack_require__ = previousRequire; + global.__webpack_chunk_load__ = previousChunkLoad; + }, + }; +} + +exports.installRspackTestManifest = installRspackTestManifest; diff --git a/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerBrowser.js index 67a146117355..d32b859d2457 100644 --- a/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerBrowser.js @@ -249,7 +249,10 @@ function prerender( function decodeReply( body: string | FormData, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -261,6 +264,7 @@ function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); diff --git a/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerEdge.js index e0e08cfb3c2e..c1b813735aa8 100644 --- a/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerEdge.js @@ -24,6 +24,7 @@ import { } from './ReactFlightServerConfigRspackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -254,7 +255,10 @@ function prerender( function decodeReply( body: string | FormData, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -266,6 +270,7 @@ function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); @@ -274,7 +279,10 @@ function decodeReply( function decodeReplyFromAsyncIterable( iterable: AsyncIterable<[string, string | File]>, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const iterator: AsyncIterator<[string, string | File]> = iterable[ASYNC_ITERATOR](); @@ -283,6 +291,8 @@ function decodeReplyFromAsyncIterable( __rspack_rsc_manifest__.serverManifest, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); function progress( @@ -306,8 +316,8 @@ function decodeReplyFromAsyncIterable( reportGlobalError(response, reason); if (typeof (iterator: any).throw === 'function') { // The iterator protocol doesn't necessarily include this but a generator do. - // $FlowFixMe should be able to pass mixed - iterator.throw(reason).then(error, error); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerNode.js index f1a5c681998d..2deb239693fe 100644 --- a/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-rspack/src/server/ReactFlightDOMServerNode.js @@ -31,6 +31,7 @@ import type {Duplex} from 'stream'; import {Readable} from 'stream'; +import noop from 'shared/noop'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import { @@ -76,6 +77,7 @@ import { import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; +import type {FileHandle} from 'react-server/src/ReactFlightReplyServer'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -554,23 +556,80 @@ function prerender( }); } +type PendingFile = { + name: string, + file: FileHandle, + complete: boolean, + // Lazily allocated when a text field arrives after this file's 'file' + // event but before its (deferred) 'end' event. Stored as flat + // [name1, value1, name2, value2, ...] pairs. + queuedFields: null | Array, + next: null | PendingFile, +}; + function decodeReplyFromBusboy( busboyStream: Busboy, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const response = createResponse( __rspack_rsc_manifest__.serverManifest, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); - let pendingFiles = 0; - const queuedFields: Array = []; + + // Linked list of pending files in arrival (payload) order. Text fields that + // arrive while a file is in flight are queued on the tail file's + // `queuedFields` so they can be resolved together when that file completes. + // Fields that arrive while the list is empty bypass it and resolve + // immediately. This makes the backing FormData's insertion order match the + // payload's entry order. + let head: null | PendingFile = null; + let tail: null | PendingFile = null; + let bodyFinished = false; + let closed = false; + + function flush() { + while (head !== null) { + const current = head; + if (!current.complete) { + // This file is still streaming. Hold later files and fields until it + // completes so the backing FormData reflects payload order. + return; + } + try { + resolveFileComplete(response, current.name, current.file); + const queuedFields = current.queuedFields; + if (queuedFields !== null) { + for (let i = 0; i < queuedFields.length; i += 2) { + resolveField(response, queuedFields[i], queuedFields[i + 1]); + } + } + } catch (error) { + busboyStream.destroy(error); + return; + } + head = current.next; + } + tail = null; + if (bodyFinished && !closed) { + closed = true; + close(response); + } + } + busboyStream.on('field', (name, value) => { - if (pendingFiles > 0) { - // Because the 'end' event fires two microtasks after the next 'field' - // we would resolve files and fields out of order. To handle this properly - // we queue any fields we receive until the previous file is done. - queuedFields.push(name, value); + if (tail !== null) { + // A file is in flight; queue the field on the tail (most recent) pending + // file so it resolves after that file, preserving payload order. + if (tail.queuedFields === null) { + tail.queuedFields = []; + } + tail.queuedFields.push(name, value); } else { try { resolveField(response, name, value); @@ -590,29 +649,46 @@ function decodeReplyFromBusboy( ); return; } - pendingFiles++; const file = resolveFileInfo(response, name, filename, mimeType); + const pendingFile: PendingFile = { + name, + file, + complete: false, + queuedFields: null, + next: null, + }; + if (tail === null) { + head = pendingFile; + } else { + tail.next = pendingFile; + } + tail = pendingFile; value.on('data', chunk => { - resolveFileChunk(response, file, chunk); - }); - value.on('end', () => { 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; - } + resolveFileChunk(response, file, chunk); } catch (error) { busboyStream.destroy(error); } }); + value.on('error', error => { + busboyStream.destroy(error); + }); + value.on('end', () => { + pendingFile.complete = true; + flush(); + }); }); busboyStream.on('finish', () => { - close(response); + bodyFinished = true; + flush(); + if (!closed) { + // Invariant: busboy delays 'finish' until every file's 'end' event has + // fired, so the flush above should always close the response. + reportGlobalError( + response, + new Error('Reply finished with incomplete file part.'), + ); + } }); busboyStream.on('error', err => { reportGlobalError( @@ -626,7 +702,10 @@ function decodeReplyFromBusboy( function decodeReply( body: string | FormData, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -638,6 +717,7 @@ function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); @@ -646,7 +726,10 @@ function decodeReply( function decodeReplyFromAsyncIterable( iterable: AsyncIterable<[string, string | File]>, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const iterator: AsyncIterator<[string, string | File]> = iterable[ASYNC_ITERATOR](); @@ -655,6 +738,8 @@ function decodeReplyFromAsyncIterable( __rspack_rsc_manifest__.serverManifest, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); function progress( @@ -678,8 +763,8 @@ function decodeReplyFromAsyncIterable( reportGlobalError(response, reason); if (typeof (iterator: any).throw === 'function') { // The iterator protocol doesn't necessarily include this but a generator do. - // $FlowFixMe should be able to pass mixed - iterator.throw(reason).then(error, error); + // $FlowFixMe[prop-missing] should be able to pass mixed + iterator.throw(reason).then(noop, noop); } } diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index 5d6e94be4ffa..1c79dbdeaef3 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -1864,10 +1864,7 @@ function parseModelString( const blobKey = prefix + id; // We should have this backingEntry in the store already because we emitted // it before referencing it. It should be a Blob. - const backingEntry = getBackingEntry( - response._formData, - blobKey, - ); + const backingEntry = getBackingEntry(response._formData, blobKey); if (!(backingEntry instanceof Blob)) { throw new Error('Referenced Blob is not a Blob.'); }