From 29ef05d30ee45f94f59e68b89994c0effa953876 Mon Sep 17 00:00:00 2001 From: Michael Hart Date: Fri, 20 Feb 2026 15:47:44 +1100 Subject: [PATCH] [Flight] Pack deferred children into continuation rows Resolve large rendered children arrays into continuation tasks instead of deferring each remaining child one-by-one, while preserving toJSON warnings and deduped references across packed rows. Fixes #35125. --- .../src/__tests__/ReactFlight-test.js | 47 +- .../src/__tests__/ReactFlightDOMEdge-test.js | 632 +++++++++++++++++- .../react-server/src/ReactFlightServer.js | 252 +++++-- 3 files changed, 857 insertions(+), 74 deletions(-) diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index e25b8c87a9..1bc97e7855 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -657,6 +657,38 @@ describe('ReactFlight', () => { expect(readValue).toEqual(date); }); + it('should warn in DEV if an object with toJSON is passed as a top-level value', async () => { + const obj = { + toJSON() { + return 123; + }, + }; + + const transport = ReactNoopFlightServer.render(obj); + assertConsoleErrorDev([ + 'Only plain objects can be passed to Client Components from Server Components. ' + + 'Objects with toJSON methods are not supported. ' + + 'Convert it manually to a simple value before passing it to props.\n' + + ' {: {toJSON: ...}}\n' + + ' ^^^^^^^^^^^^^', + ]); + + let readValue; + await act(async () => { + readValue = await ReactNoopFlightClient.read(transport); + }); + + expect(readValue).toBe(123); + assertConsoleErrorDev([ + 'Only plain objects can be passed to Client Components from Server Components. ' + + 'Objects with toJSON methods are not supported. ' + + 'Convert it manually to a simple value before passing it to props.\n' + + ' {: {toJSON: ...}}\n' + + ' ^^^^^^^^^^^^^\n' + + ' at ()', + ]); + }); + it('can transport Error objects as values', async () => { class CustomError extends Error { constructor(message) { @@ -670,7 +702,10 @@ describe('ReactFlight', () => { is error: ${prop instanceof Error} name: ${prop.name} message: ${prop.message} - stack: ${normalizeCodeLocInfo(prop.stack).split('\n').slice(0, 2).join('\n')} + stack: ${normalizeCodeLocInfo(prop.stack) + .split('\n') + .slice(0, 2) + .join('\n')} environmentName: ${prop.environmentName} `; } @@ -716,7 +751,10 @@ describe('ReactFlight', () => { is error: ${error instanceof Error} name: ${error.name} message: ${error.message} - stack: ${normalizeCodeLocInfo(error.stack).split('\n').slice(0, 2).join('\n')} + stack: ${normalizeCodeLocInfo(error.stack) + .split('\n') + .slice(0, 2) + .join('\n')} environmentName: ${error.environmentName} cause: ${'cause' in error ? renderError(error.cause) : 'no cause'}`; } @@ -782,7 +820,10 @@ describe('ReactFlight', () => { is error: true name: ${error.name} message: ${error.message} - stack: ${normalizeCodeLocInfo(error.stack).split('\n').slice(0, 2).join('\n')} + stack: ${normalizeCodeLocInfo(error.stack) + .split('\n') + .slice(0, 2) + .join('\n')} environmentName: ${error.environmentName} cause: ${'cause' in error ? renderError(error.cause) : 'no cause'}`; } diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index 266471e588..f1e6cb946b 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -43,6 +43,38 @@ function normalizeSerializedContent(str) { return str.replaceAll(__REACT_ROOT_PATH_TEST__, '**'); } +function getSerializedModelRows(serializedContent) { + return Object.fromEntries( + serializedContent + .trim() + .split('\n') + .map(line => { + const colonIndex = line.indexOf(':'); + if (colonIndex < 0 || colonIndex === line.length - 1) { + return null; + } + const tag = line[colonIndex + 1]; + if (tag === 'D' || tag === 'T' || tag === 'N') { + return null; + } + return [ + line.slice(0, colonIndex), + JSON.parse(line.slice(colonIndex + 1)), + ]; + }) + .filter(Boolean), + ); +} + +function createFromStream(stream) { + return ReactServerDOMClient.createFromReadableStream(stream, { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); +} + describe('ReactFlightDOMEdge', () => { beforeEach(() => { // Mock performance.now for timing tests @@ -782,9 +814,9 @@ describe('ReactFlightDOMEdge', () => { }, ); - // We should have resolved enough to be able to get the array even though some - // of the items inside are still lazy. - expect(result.length).toBe(20); + // We should have some of the elements, but not all of them yet + expect(result.length).toBeGreaterThan(4); + expect(result.length).toBeLessThan(20); // Unblock the rest drip(Infinity); @@ -803,6 +835,583 @@ describe('ReactFlightDOMEdge', () => { expect(html).toBe(html2); }); + it('packs trailing children into continuation rows', async () => { + const largeText = 'x'.repeat(1000); + const elements = [ +

+ w +

, +

+ x +

, +

+ y +

, +

+ z +

, + ]; + + // Elements 0 and 1 each exceed MAX_ROW_SIZE alone (4×1000 chars), so + // each triggers its own continuation split. Element 3 is small enough to + // share a row with element 2 (~1000 chars each), verifying that a continuation can + // pack multiple elements when they fit. + + // Without array continuations: + // Row 0: [elem0, $L1, $L2, $L3] — all three remaining elements deferred individually + // Row 1: [elem1] (the resolved lazy for $L1) + // Row 2: [elem2] (the resolved lazy for $L2) + // Row 3: [elem3] (the resolved lazy for $L3) + + // With array continuations: + // Row 0: [elem0, $L1] — split after elem0 (assertion 1: continuation on first row) + // Row 1: [elem1, $L2] — split again after elem1 (assertion 2: continuation itself splits) + // Row 2: [elem2, elem3] — both small elements fit together (assertion 3: multiple elements per continuation row) + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(elements), + ); + const serializedContent = normalizeSerializedContent( + await readResult(stream), + ); + const modelRows = Object.values(getSerializedModelRows(serializedContent)); + + // There should be two rows that have lazy refs + expect( + modelRows.filter( + row => + Array.isArray(row) && + row.some(item => typeof item === 'string' && /^\$L/.test(item)), + ).length, + ).toBe(2); + + // The last continuation row (for elements 2 and 3) should contain both + // elements without a further lazy ref, confirming they share a single row. + const lastContinuationRow = modelRows.find( + row => + Array.isArray(row) && + !row.some(item => typeof item === 'string' && /^\$L/.test(item)) && + row.length === 2 && + Array.isArray(row[0]) && + row[0].some(prop => prop?.children === 'y') && + Array.isArray(row[1]) && + row[1].some(prop => prop?.children === 'z'), + ); + expect(lastContinuationRow).toBeDefined(); + + const result = await createFromStream( + passThrough( + await serverAct(() => + ReactServerDOMServer.renderToReadableStream(elements), + ), + ), + ); + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(result), + ); + const html = await readResult(ssrStream); + const ssrStream2 = await serverAct(() => + ReactDOMServer.renderToReadableStream(elements), + ); + const html2 = await readResult(ssrStream2); + expect(html).toBe(html2); + }); + + it('does not truncate a packed children array reused by another prop', async () => { + const items = Array.from({length: 100}, (_, i) => ( +

{'text '.repeat(50) + i}

+ )); + + const stream = await serverAct(() => + passThrough( + ReactServerDOMServer.renderToReadableStream( + React.createElement('div', {children: items, list: items}), + ), + ), + ); + const serializedContent = normalizeSerializedContent( + await readResult(stream), + ); + const rootRow = getSerializedModelRows(serializedContent)['0']; + + // This one intentionally stays at the row level because the regression is + // about reusing the packed children encoding rather than the final output. + expect(rootRow[3].children.length).toBeLessThan(100); + expect(rootRow[3].children[0][3].children).toBe('text '.repeat(50) + 0); + expect(rootRow[3].children[rootRow[3].children.length - 1]).toMatch(/^\$L/); + expect(rootRow[3].list).toBe('$0:props:children'); + }); + + it('preserves deduped shared props across packed child rows', async () => { + const shared = {value: 'deduped'}; + const items = Array.from({length: 100}, (_, i) => ( +
40 ? shared : null}> + {'x'.repeat(80)} +
+ )); + + const stream = await serverAct(() => + passThrough( + ReactServerDOMServer.renderToReadableStream(
{items}
), + ), + ); + const serializedContent = normalizeSerializedContent( + await readResult(stream), + ); + const modelRows = Object.values(getSerializedModelRows(serializedContent)); + let sharedObjectCount = 0; + let sharedReferenceCount = 0; + + function visit(value) { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + visit(value[i]); + } + } else if (value !== null && typeof value === 'object') { + if (value.value === 'deduped') { + sharedObjectCount++; + } + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + visit(value[key]); + } + } + } else if (typeof value === 'string' && value.endsWith(':props:extra')) { + sharedReferenceCount++; + } + } + + for (let i = 0; i < modelRows.length; i++) { + visit(modelRows[i]); + } + + expect(sharedObjectCount).toBe(1); + expect(sharedReferenceCount).toBeGreaterThan(0); + }); + + it('preserves deduped nested props across packed child rows', async () => { + const shared = {value: 'nested-deduped'}; + const items = Array.from({length: 100}, (_, i) => ( +
40 ? shared : null}}> + {'x'.repeat(80)} +
+ )); + + const stream = await serverAct(() => + passThrough( + ReactServerDOMServer.renderToReadableStream(
{items}
), + ), + ); + const serializedContent = normalizeSerializedContent( + await readResult(stream), + ); + const modelRows = Object.values(getSerializedModelRows(serializedContent)); + let sharedObjectCount = 0; + let sharedReferenceCount = 0; + + function visit(value) { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + visit(value[i]); + } + } else if (value !== null && typeof value === 'object') { + if (value.value === 'nested-deduped') { + sharedObjectCount++; + } + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + visit(value[key]); + } + } + } else if ( + typeof value === 'string' && + value.endsWith(':props:extra:nested') + ) { + sharedReferenceCount++; + } + } + + for (let i = 0; i < modelRows.length; i++) { + visit(modelRows[i]); + } + + expect(sharedObjectCount).toBe(1); + expect(sharedReferenceCount).toBeGreaterThan(0); + }); + + it('preserves promise identity across packed child rows', async () => { + const foo = {}; + const bar = { + foo, + }; + foo.bar = bar; + const promisedFoo = Promise.resolve(foo); + const promisedBar = Promise.resolve(bar); + + const items = Array.from({length: 100}, (_, i) => + i === 70 + ? promisedFoo + : i === 71 + ? promisedBar + : Promise.resolve('x'.repeat(80) + i), + ); + + const stream = await serverAct(() => + passThrough(ReactServerDOMServer.renderToReadableStream(items)), + ); + const result = await createFromStream(stream); + + const resolvedFoo = await result[70]; + const resolvedBar = await result[71]; + + expect(resolvedFoo.bar).toBe(resolvedBar); + expect(resolvedBar.foo).toBe(resolvedFoo); + }); + + it('preserves outlined promise identity across packed child rows', async () => { + const foo = {}; + const bar = new Set([foo]); + foo.bar = bar; + const promisedFoo = Promise.resolve(foo); + const promisedBar = Promise.resolve(bar); + + const items = Array.from({length: 100}, (_, i) => + i === 70 + ? promisedFoo + : i === 71 + ? promisedBar + : Promise.resolve('y'.repeat(80) + i), + ); + + const stream = await serverAct(() => + passThrough(ReactServerDOMServer.renderToReadableStream(items)), + ); + const result = await createFromStream(stream); + + const resolvedFoo = await result[70]; + const resolvedBar = await result[71]; + + expect(resolvedFoo.bar).toBe(resolvedBar); + expect(Array.from(resolvedBar)[0]).toBe(resolvedFoo); + }); + + it('preserves map value identity across packed child rows', async () => { + const shared = {id: 42}; + const map = new Map([[42, shared]]); + const items = Array.from({length: 100}, (_, i) => + i === 70 ? {shared, map} : Promise.resolve('z'.repeat(80) + i), + ); + + const stream = await serverAct(() => + passThrough(ReactServerDOMServer.renderToReadableStream(items)), + ); + const result = await createFromStream(stream); + + const target = result[70]; + expect(target.map.get(42)).toBe(target.shared); + }); + + it('preserves deduped client props across packed child rows', async () => { + const Client = clientExports(function Client({value}) { + return JSON.stringify(value); + }); + + const shared = [1, 2, 3]; + const items = Array.from({length: 100}, (_, i) => + i === 70 ? ( + + ) : ( + {'b'.repeat(80) + i} + ), + ); + + const stream = await serverAct(() => + passThrough( + ReactServerDOMServer.renderToReadableStream(items, webpackMap), + ), + ); + const serializedContent = normalizeSerializedContent( + await readResult(stream), + ); + const modelRows = serializedContent + .trim() + .split('\n') + .map(line => { + const colonIndex = line.indexOf(':'); + if (colonIndex < 0 || colonIndex === line.length - 1) { + return null; + } + const payload = line.slice(colonIndex + 1); + if (payload[0] !== '[' && payload[0] !== '{') { + return null; + } + return JSON.parse(payload); + }) + .filter(Boolean); + let sharedArrayCount = 0; + let sharedReferenceCount = 0; + + function visit(value) { + if (Array.isArray(value)) { + if ( + value.length === 3 && + value[0] === 1 && + value[1] === 2 && + value[2] === 3 + ) { + sharedArrayCount++; + } + for (let i = 0; i < value.length; i++) { + visit(value[i]); + } + } else if (value !== null && typeof value === 'object') { + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + visit(value[key]); + } + } + } else if ( + typeof value === 'string' && + value.endsWith(':props:value:0') + ) { + sharedReferenceCount++; + } + } + + for (let i = 0; i < modelRows.length; i++) { + visit(modelRows[i]); + } + + expect(sharedArrayCount).toBe(1); + expect(sharedReferenceCount).toBeGreaterThan(0); + }); + + it('preserves cross-boundary deduped element props across packed child rows', async () => { + function PassthroughServerComponent({children}) { + return children; + } + + const Client = clientExports(function Client({children, track}) { + return children; + }); + + const shared =
; + const items = Array.from({length: 100}, (_, i) => + i === 70 ? ( + + {shared} + + ) : ( + {'c'.repeat(80) + i} + ), + ); + + const stream = await serverAct(() => + passThrough( + ReactServerDOMServer.renderToReadableStream(items, webpackMap), + ), + ); + const serializedContent = normalizeSerializedContent( + await readResult(stream), + ); + const modelRows = serializedContent + .trim() + .split('\n') + .map(line => { + const colonIndex = line.indexOf(':'); + if (colonIndex < 0 || colonIndex === line.length - 1) { + return null; + } + const payload = line.slice(colonIndex + 1); + if (payload[0] !== '[' && payload[0] !== '{') { + return null; + } + return JSON.parse(payload); + }) + .filter(Boolean); + let sharedElementCount = 0; + let sharedReferenceCount = 0; + + function visit(value) { + if (Array.isArray(value)) { + if ( + value[0] === '$' && + value[1] === 'div' && + value[3] !== null && + typeof value[3] === 'object' && + value[3]['data-shared'] === 'yes' + ) { + sharedElementCount++; + } + for (let i = 0; i < value.length; i++) { + visit(value[i]); + } + } else if (value !== null && typeof value === 'object') { + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + visit(value[key]); + } + } + } else if (typeof value === 'string' && value.endsWith(':props:track')) { + sharedReferenceCount++; + } + } + + for (let i = 0; i < modelRows.length; i++) { + visit(modelRows[i]); + } + + expect(sharedElementCount).toBe(1); + expect(sharedReferenceCount).toBeGreaterThan(0); + }); + + it('resolves shared children arrays referenced from continuation rows', async () => { + // The same children array is reused by two parents while the outer children + // array is also split into continuation rows. Before the fix, the server could + // emit a property-path reference based on the original unsplit index, which + // the client could not resolve after packing. + const sharedItems0 = Array.from({length: 8}, (_, i) => ( + + {String(i)} + + )); + const sharedItems1 = Array.from({length: 8}, (_, i) => ( + + {String(i)} + + )); + + const element = ( +
+
{sharedItems0}
+ +
{sharedItems0}
+
{sharedItems1}
+ +
{sharedItems1}
+
+ ); + + const rscStream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(element), + ); + const result = await createFromStream(passThrough(rscStream)); + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(result), + ); + const rscHtml = await readResult(ssrStream); + + const directStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(element), + ); + const directHtml = await readResult(directStream); + + expect(rscHtml).toBe(directHtml); + }); + + it('does not pack nested child arrays reused by sibling props', async () => { + const items = Array.from({length: 100}, (_, i) => ( +

{'text '.repeat(50) + i}

+ )); + + const stream = await serverAct(() => + passThrough( + ReactServerDOMServer.renderToReadableStream( +
+ {items} + +
, + ), + ), + ); + const serializedContent = normalizeSerializedContent( + await readResult(stream), + ); + const rows = getSerializedModelRows(serializedContent); + const rootRow = rows['0']; + + expect(rootRow[3].children[0].length).toBe(100); + expect(rootRow[3].children[1]).toMatch(/^\$L/); + expect(rows[rootRow[3].children[1].slice(2)][3].list).toBe( + '$0:props:children:0', + ); + }); + + it('should not treat plain prop arrays as renderable children', async () => { + const groups = []; + for (let groupIndex = 0; groupIndex < 20; groupIndex++) { + const tuples = []; + for (let tupleIndex = 0; tupleIndex < 20; tupleIndex++) { + tuples.push([ + 'key-' + groupIndex + '-' + tupleIndex, + 'value-' + groupIndex + '-' + tupleIndex + '-' + 'x'.repeat(40), + ]); + } + groups.push( +
+

{'Group ' + groupIndex}

+ {tuples.length + ' items'} +
, + ); + } + + const stream = await serverAct(() => + passThrough( + ReactServerDOMServer.renderToReadableStream( +
{groups}
, + ), + ), + ); + + const serializedContent = normalizeSerializedContent( + await readResult(stream), + ); + const modelRows = Object.values(getSerializedModelRows(serializedContent)); + const dataItemArrays = []; + + function visit(value) { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + visit(value[i]); + } + } else if (value !== null && typeof value === 'object') { + if (Array.isArray(value['data-items'])) { + dataItemArrays.push(value['data-items']); + } + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + visit(value[key]); + } + } + } + } + + for (let i = 0; i < modelRows.length; i++) { + visit(modelRows[i]); + } + + expect(dataItemArrays.length).toBeGreaterThan(0); + for (let i = 0; i < dataItemArrays.length; i++) { + const tuples = dataItemArrays[i]; + expect( + tuples.some(item => typeof item === 'string' && /^\$L/.test(item)), + ).toBe(false); + for (let j = 0; j < tuples.length; j++) { + expect(Array.isArray(tuples[j])).toBe(true); + } + } + }); + it('regression: should not leak serialized size', async () => { const MAX_ROW_SIZE = 3200; // This test case is a bit convoluted and may no longer trigger the original bug. @@ -817,26 +1426,13 @@ describe('ReactFlightDOMEdge', () => { ReactServerDOMServer.renderToReadableStream(model), ); - const result = await ReactServerDOMClient.createFromReadableStream(stream, { - serverConsumerManifest: { - moduleMap: null, - moduleLoading: null, - }, - }); + const result = await createFromStream(stream); const stream2 = await serverAct(() => ReactServerDOMServer.renderToReadableStream(model), ); - const result2 = await ReactServerDOMClient.createFromReadableStream( - stream2, - { - serverConsumerManifest: { - moduleMap: null, - moduleLoading: null, - }, - }, - ); + const result2 = await createFromStream(stream2); expect(result2.syncText).toEqual(result.syncText); }); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 4c50f6a7d2..99c6e42286 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -527,7 +527,6 @@ type Task = { status: 0 | 1 | 3 | 4 | 5, model: ReactClientValue, ping: () => void, - toJSON: (key: string, value: ReactClientValue) => ReactJSONValue, keyPath: ReactKey, // parent server component keys implicitSlot: boolean, // true if the root server component of this sequence had a null key formatContext: FormatContext, // an approximate parent context from host components @@ -2733,55 +2732,6 @@ function createTask( implicitSlot, formatContext: formatContext, ping: () => pingTask(request, task), - toJSON: function ( - this: - | {+[key: string | number]: ReactClientValue} - | $ReadOnlyArray, - parentPropertyName: string, - value: ReactClientValue, - ): ReactJSONValue { - const parent = this; - // Make sure that `parent[parentPropertyName]` wasn't JSONified before `value` was passed to us - if (__DEV__) { - // $FlowFixMe[incompatible-use] - const originalValue = parent[parentPropertyName]; - if ( - typeof originalValue === 'object' && - originalValue !== value && - !(originalValue instanceof Date) - ) { - // Call with the server component as the currently rendering component - // for context. - callWithDebugContextInDEV(request, task, () => { - if (objectName(originalValue) !== 'Object') { - const jsxParentType = jsxChildrenParents.get(parent); - if (typeof jsxParentType === 'string') { - console.error( - '%s objects cannot be rendered as text children. Try formatting it using toString().%s', - objectName(originalValue), - describeObjectForErrorMessage(parent, parentPropertyName), - ); - } else { - console.error( - 'Only plain objects can be passed to Client Components from Server Components. ' + - '%s objects are not supported.%s', - objectName(originalValue), - describeObjectForErrorMessage(parent, parentPropertyName), - ); - } - } else { - console.error( - 'Only plain objects can be passed to Client Components from Server Components. ' + - 'Objects with toJSON methods are not supported. Convert it manually ' + - 'to a simple value before passing it to props.%s', - describeObjectForErrorMessage(parent, parentPropertyName), - ); - } - }); - } - } - return renderModel(request, task, parent, parentPropertyName, value); - }, thenableState: null, }: Omit< Task, @@ -3460,6 +3410,201 @@ function renderModel( } } +function createArrayContinuationTask( + request: Request, + task: Task, + model: Array, +): Task { + // Continuation tasks own a sliced tail, which keeps their bookkeeping and + // written-object references independent from the original array. + const newTask = createTask( + request, + model, + task.keyPath, + task.implicitSlot, + task.formatContext, + request.abortableTasks, + enableProfilerTimer && + (enableComponentPerformanceTrack || enableAsyncDebugInfo) + ? task.time + : 0, + __DEV__ ? task.debugOwner : null, + __DEV__ ? task.debugStack : null, + __DEV__ ? task.debugTask : null, + ); + return newTask; +} + +function looksLikeRenderableChildrenArray( + sourceArray: Array, +): boolean { + // Only arrays that look like rendered children should use the row-packing + // continuation path. Plain data arrays (Map entries, tuple props, etc.) stay + // on the normal serialization path. + if (sourceArray.length === 0) { + return false; + } + const firstItem = sourceArray[0]; + if (firstItem === '$' || firstItem === REACT_ELEMENT_TYPE) { + return false; + } + if (typeof firstItem === 'string') { + return firstItem[0] === '$'; + } + if (typeof firstItem === 'object' && firstItem !== null) { + if (isArray(firstItem)) { + return firstItem[0] === '$' || firstItem[0] === REACT_ELEMENT_TYPE; + } + return ( + firstItem.$$typeof === REACT_ELEMENT_TYPE || + firstItem.$$typeof === REACT_LAZY_TYPE || + typeof firstItem.then === 'function' + ); + } + return false; +} + +function resolveModelArray( + request: Request, + task: Task, + sourceArray: Array, + mayBeChildrenArray: boolean, +): Array { + const resolvedArray = new Array(sourceArray.length); + let isChildrenArray = false; + let didCheckChildrenArray = !mayBeChildrenArray; + for (let i = 0; i < sourceArray.length; i++) { + resolvedArray[i] = resolveModelNode( + request, + task, + sourceArray, + '' + i, + sourceArray[i], + ); + + if (serializedSize > MAX_ROW_SIZE && i + 1 < sourceArray.length) { + // Most arrays never cross the split threshold, so defer the children-array + // heuristic until we actually need to decide whether this array should be + // packed into continuation rows. + if (!didCheckChildrenArray) { + isChildrenArray = looksLikeRenderableChildrenArray(sourceArray); + didCheckChildrenArray = true; + } + if (!isChildrenArray) { + continue; + } + const remaining = sourceArray.slice(i + 1); + const continuationTask = createArrayContinuationTask( + request, + task, + ((remaining: any): Array), + ); + pingTask(request, continuationTask); + + resolvedArray[i + 1] = serializeLazyID(continuationTask.id); + resolvedArray.length = i + 2; + break; + } + } + return resolvedArray; +} + +function resolveModelNode( + request: Request, + task: Task, + parent: + | {+[key: string | number]: ReactClientValue} + | $ReadOnlyArray, + parentPropertyName: string, + value: ReactClientValue, +): ReactJSONValue { + // Mirror JSON.stringify replacer semantics so custom toJSON methods still run + // before we recurse, but do the traversal explicitly so we can row-pack arrays + // and avoid mutating frozen inputs in place. + let jsonValue: ReactClientValue = value; + if ( + value !== null && + typeof value === 'object' && + // $FlowFixMe[method-unbinding] + typeof value.toJSON === 'function' + ) { + // $FlowFixMe[incompatible-use] + jsonValue = value.toJSON(parentPropertyName); + } + + if (__DEV__) { + // $FlowFixMe[incompatible-use] + const originalValue = parent[parentPropertyName]; + if ( + typeof originalValue === 'object' && + originalValue !== jsonValue && + !(originalValue instanceof Date) + ) { + callWithDebugContextInDEV(request, task, () => { + if (objectName(originalValue) !== 'Object') { + const jsxParentType = jsxChildrenParents.get(parent); + if (typeof jsxParentType === 'string') { + console.error( + '%s objects cannot be rendered as text children. Try formatting it using toString().%s', + objectName(originalValue), + describeObjectForErrorMessage(parent, parentPropertyName), + ); + } else { + console.error( + 'Only plain objects can be passed to Client Components from Server Components. ' + + '%s objects are not supported.%s', + objectName(originalValue), + describeObjectForErrorMessage(parent, parentPropertyName), + ); + } + } else { + console.error( + 'Only plain objects can be passed to Client Components from Server Components. ' + + 'Objects with toJSON methods are not supported. Convert it manually ' + + 'to a simple value before passing it to props.%s', + describeObjectForErrorMessage(parent, parentPropertyName), + ); + } + }); + } + } + const rendered = renderModel( + request, + task, + parent, + parentPropertyName, + jsonValue, + ); + if (rendered === null || typeof rendered !== 'object') { + return rendered; + } + if (isArray(rendered)) { + // Arrays need special handling so we can split large renderable child lists + // into lazy continuation rows. + return resolveModelArray( + request, + task, + ((rendered: any): Array), + parentPropertyName === '' || parentPropertyName === 'children', + ); + } + const resolvedObject: {[key: string]: ReactJSONValue} = (Object.create( + null, + ): any); + for (const propertyName in rendered) { + if (hasOwnProperty.call(rendered, propertyName)) { + resolvedObject[propertyName] = resolveModelNode( + request, + task, + rendered, + propertyName, + rendered[propertyName], + ); + } + } + return resolvedObject; +} + function renderModelDestructive( request: Request, task: Task, @@ -5712,8 +5857,9 @@ function emitChunk( return; } // For anything else we need to try to serialize it using JSON. + const resolvedModel = resolveModelNode(request, task, {'': value}, '', value); // $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do - const json: string = stringify(value, task.toJSON); + const json: string = stringify(resolvedModel); emitModelChunk(request, task.id, json); } @@ -5759,7 +5905,7 @@ function retryTask(request: Request, task: Task): void { try { // Track the root so we know that we have to emit this object even though it // already has an ID. This is needed because we might see this object twice - // in the same toJSON if it is cyclic. + // in the same serialization pass if it is cyclic. modelRoot = task.model; if (__DEV__) { @@ -5819,7 +5965,7 @@ function retryTask(request: Request, task: Task): void { emitChunk(request, task, resolvedModel); } else { // If the value is a string, it means it's a terminal value and we already escaped it - // We don't need to escape it again so it's not passed the toJSON replacer. + // We don't need to escape it again. // $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do const json: string = stringify(resolvedModel); emitModelChunk(request, task.id, json);