diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index e25b8c87a9bd..1bc97e7855a7 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 266471e58881..891aa3aab9fa 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,645 @@ 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('does not leak packed child continuations into client component array props', async () => { + const ClientComp = clientExports(function ClientComp({items}) { + const flatItems = + items.length === 500 && items.every(item => !Array.isArray(item)); + + return ( +
+ {items[0]} + {items[items.length - 1]} +
+ ); + }); + + function ServerComp() { + const flatItems = Array.from({length: 500}, (_, i) => ( +
{'Item ' + i}
+ )); + const nestedItems = Array.from({length: 500}, (_, i) => [ +
{'Group ' + i}
, + ]); + return ( + <> + + + + ); + } + + const clientMetadata = webpackMap[ClientComp.$$id]; + const translationMap = { + [clientMetadata.id]: { + '*': clientMetadata, + }, + }; + + const stream = await serverAct(() => + passThrough( + ReactServerDOMServer.renderToReadableStream(, webpackMap), + ), + ); + const response = ReactServerDOMClient.createFromReadableStream(stream, { + serverConsumerManifest: { + moduleMap: translationMap, + moduleLoading: webpackModuleLoading, + }, + }); + + function ClientRoot() { + return use(response); + } + + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); + + expect(await readResult(ssrStream)).toBe( + '
Item 0
Item 499
Group 0
Group 499
', + ); + }); + 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 +1488,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 4c50f6a7d20a..5b4cad3e5643 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -526,8 +526,9 @@ type Task = { id: number, status: 0 | 1 | 3 | 4 | 5, model: ReactClientValue, + continuationSource: null | Array, + continuationIndex: number, 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 @@ -2712,10 +2713,11 @@ function createTask( debugOwner: null | ReactComponentInfo, // DEV-only debugStack: null | Error, // DEV-only debugTask: null | ConsoleTask, // DEV-only + registerModelReference: boolean = true, ): Task { request.pendingChunks++; const id = request.nextChunkId++; - if (typeof model === 'object' && model !== null) { + if (registerModelReference && typeof model === 'object' && model !== null) { // If we're about to write this into a new task we can assign it an ID early so that // any other references can refer to the value we're about to write. if (keyPath !== null || implicitSlot) { @@ -2729,59 +2731,12 @@ function createTask( id, status: PENDING, model, + continuationSource: null, + continuationIndex: 0, keyPath, 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 +3415,256 @@ function renderModel( } } +function createArrayContinuationTask( + request: Request, + task: Task, + continuationSource: Array, + continuationIndex: number, +): Task { + // Continuation tasks resume the same source array from a later index. + // This task resumes an existing array instead of outlining a new one. + const newTask = createTask( + request, + continuationSource, + 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, + false, + ); + newTask.continuationSource = continuationSource; + newTask.continuationIndex = continuationIndex; + 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, + startIndex: number, + mayBeChildrenArray: boolean, +): Array { + const totalLength = sourceArray.length - startIndex; + const resolvedArray = new Array(totalLength); + // Continuation tasks are only created for rendered children arrays. + let isChildrenArray = startIndex > 0; + let didCheckChildrenArray = startIndex > 0 || !mayBeChildrenArray; + for (let i = startIndex; i < sourceArray.length; i++) { + const targetIndex = i - startIndex; + const item = sourceArray[i]; + const resolvedItem = resolveModelNode( + request, + task, + sourceArray, + '' + i, + item, + ); + resolvedArray[targetIndex] = resolvedItem; + + 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 continuationTask = createArrayContinuationTask( + request, + task, + ((sourceArray: any): Array), + i + 1, + ); + pingTask(request, continuationTask); + + resolvedArray.length = targetIndex + 2; + resolvedArray[targetIndex + 1] = serializeLazyID(continuationTask.id); + break; + } + } + return resolvedArray; +} + +function getReferenceInParent( + request: Request, + task: Task, + parent: + | {+[key: string | number]: ReactClientValue} + | $ReadOnlyArray, + parentPropertyName: string, +): void | string { + let parentReference; + let propertyName = parentPropertyName; + if ( + task.continuationSource !== null && + parent === task.continuationSource && + isArray(parent) + ) { + parentReference = serializeByValueID(task.id); + propertyName = (+parentPropertyName - task.continuationIndex).toString(10); + } else { + parentReference = request.writtenObjects.get(parent); + if (parentReference === undefined) { + return undefined; + } + } + + if (isArray(parent) && parent[0] === REACT_ELEMENT_TYPE) { + switch (propertyName) { + case '1': + propertyName = 'type'; + break; + case '2': + propertyName = 'key'; + break; + case '3': + propertyName = 'props'; + break; + case '4': + propertyName = '_owner'; + break; + } + } + + return parentReference + ':' + propertyName; +} + +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), + 0, + 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, @@ -3520,11 +3725,16 @@ function renderModelDestructive( } } else if (parentPropertyName.indexOf(':') === -1) { // TODO: If the property name contains a colon, we don't dedupe. Escape instead. - const parentReference = writtenObjects.get(parent); - if (parentReference !== undefined) { + const reference = getReferenceInParent( + request, + task, + parent, + parentPropertyName, + ); + if (reference !== undefined) { // If the parent has a reference, we can refer to this object indirectly // through the property name inside that parent. - elementReference = parentReference + ':' + parentPropertyName; + elementReference = reference; writtenObjects.set(value, elementReference); } } @@ -3745,31 +3955,16 @@ function renderModelDestructive( } } else if (parentPropertyName.indexOf(':') === -1) { // TODO: If the property name contains a colon, we don't dedupe. Escape instead. - const parentReference = writtenObjects.get(parent); - if (parentReference !== undefined) { + const reference = getReferenceInParent( + request, + task, + parent, + parentPropertyName, + ); + if (reference !== undefined) { // If the parent has a reference, we can refer to this object indirectly // through the property name inside that parent. - let propertyName = parentPropertyName; - if (isArray(parent) && parent[0] === REACT_ELEMENT_TYPE) { - // For elements, we've converted it to an array but we'll have converted - // it back to an element before we read the references so the property - // needs to be aliased. - switch (parentPropertyName) { - case '1': - propertyName = 'type'; - break; - case '2': - propertyName = 'key'; - break; - case '3': - propertyName = 'props'; - break; - case '4': - propertyName = '_owner'; - break; - } - } - writtenObjects.set(value, parentReference + ':' + propertyName); + writtenObjects.set(value, reference); } } @@ -5712,8 +5907,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 +5955,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__) { @@ -5767,15 +5963,21 @@ function retryTask(request: Request, task: Task): void { canEmitDebugInfo = true; } + const isContinuationRow = + task.continuationSource !== null && + task.model === task.continuationSource; + // We call the destructive form that mutates this task. That way if something // suspends again, we can reuse the same task instead of spawning a new one. - const resolvedModel = renderModelDestructive( - request, - task, - emptyRoot, - '', - task.model, - ); + const resolvedModel = isContinuationRow + ? resolveModelArray( + request, + task, + ((task.continuationSource: any): Array), + task.continuationIndex, + true, + ) + : renderModelDestructive(request, task, emptyRoot, '', task.model); if (__DEV__) { // We're now past rendering this task and future renders will spawn new tasks for their @@ -5816,10 +6018,17 @@ function retryTask(request: Request, task: Task): void { // Object might contain unresolved values like additional elements. // This is simulating what the JSON loop would do if this was part of it. - emitChunk(request, task, resolvedModel); + if (isContinuationRow && isArray(resolvedModel)) { + // This continuation row is already resolved. + // $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do + const json: string = stringify(resolvedModel); + emitModelChunk(request, task.id, json); + } else { + 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);