From 5edd7068931c8465ab0005213c79d21948c97e5a Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 25 Apr 2025 16:56:33 -0400 Subject: [PATCH 1/3] Count the size of the currently serialized row Including any outlined chunks that would go before this row since they also block it. The size doesn't include every single byte written like every bracket and number. It's just the big picture scale like string lengths including keys. --- .../react-server/src/ReactFlightServer.js | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 826386f791833..7877ff0db1e12 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -1600,6 +1600,11 @@ function renderClientElement( // The chunk ID we're currently rendering that we can assign debug data to. let debugID: null | number = null; +// Approximate string length of the currently serializing row. +// Used to power outlining heuristics. +let serializedSize = 0; +const MAX_ROW_SIZE = 3200; + function outlineTask(request: Request, task: Task): ReactJSONValue { const newTask = createTask( request, @@ -2393,6 +2398,8 @@ function renderModelDestructive( // Set the currently rendering model task.model = value; + serializedSize += parentPropertyName.length; + // Special Symbol, that's very common. if (value === REACT_ELEMENT_TYPE) { return '$'; @@ -2811,6 +2818,7 @@ function renderModelDestructive( throwTaintViolation(tainted.message); } } + serializedSize += value.length; // TODO: Maybe too clever. If we support URL there's no similar trick. if (value[value.length - 1] === 'Z') { // Possibly a Date, whose toJSON automatically calls toISOString @@ -3892,9 +3900,18 @@ function emitChunk( return; } // For anything else we need to try to serialize it using JSON. - // $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do - const json: string = stringify(value, task.toJSON); - emitModelChunk(request, task.id, json); + // We stash the outer parent size so we can restore it when we exit. + // We don't reset the serialized size counter from reentry because that indicates that we + // are outlining a model and we actually want to include that size into the parent since + // it will still block the parent row. It only restores to zero at the top of the stack. + const parentSerializedSize = 0; + try { + // $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do + const json: string = stringify(value, task.toJSON); + emitModelChunk(request, task.id, json); + } finally { + serializedSize = parentSerializedSize; + } } function erroredTask(request: Request, task: Task, error: mixed): void { From e7d31afaea51d3d1adcb3c6252183f7afa807e19 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 25 Apr 2025 18:21:03 -0400 Subject: [PATCH 2/3] If we have exceeded our allocated space for inlining objects defer any objects eligible to be a lazy reference (other lazy references and elements). This allows these to stream in on the client. We ping these instead of retrying them, which places them after the currently running task in the stream. --- .../src/__tests__/ReactFlightDOMEdge-test.js | 116 ++++++++++++++++++ .../react-server/src/ReactFlightServer.js | 26 ++++ 2 files changed, 142 insertions(+) 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 8998a471cb863..5194913d2cb32 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -160,6 +160,61 @@ describe('ReactFlightDOMEdge', () => { }); } + function dripStream(input) { + const reader = input.getReader(); + let nextDrop = 0; + let controller = null; + let streamDone = false; + const buffer = []; + function flush() { + if (controller === null || nextDrop === 0) { + return; + } + while (buffer.length > 0 && nextDrop > 0) { + const nextChunk = buffer[0]; + if (nextChunk.byteLength <= nextDrop) { + nextDrop -= nextChunk.byteLength; + controller.enqueue(nextChunk); + buffer.shift(); + if (streamDone && buffer.length === 0) { + controller.done(); + } + } else { + controller.enqueue(nextChunk.subarray(0, nextDrop)); + buffer[0] = nextChunk.subarray(nextDrop); + nextDrop = 0; + } + } + } + const output = new ReadableStream({ + start(c) { + controller = c; + async function pump() { + for (;;) { + const {value, done} = await reader.read(); + if (done) { + streamDone = true; + break; + } + buffer.push(value); + flush(); + } + } + pump(); + }, + pull() {}, + cancel(reason) { + reader.cancel(reason); + }, + }); + function drip(n) { + nextDrop += n; + flush(); + } + + return [output, drip]; + } + async function readResult(stream) { const reader = stream.getReader(); let result = ''; @@ -576,6 +631,67 @@ describe('ReactFlightDOMEdge', () => { expect(serializedContent.length).toBeLessThan(150 + expectedDebugInfoSize); }); + it('should break up large sync components by outlining into streamable elements', async () => { + const paragraphs = []; + for (let i = 0; i < 20; i++) { + const text = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris' + + 'porttitor tortor ac lectus faucibus, eget eleifend elit hendrerit.' + + 'Integer porttitor nisi in leo congue rutrum. Morbi sed ante posuere,' + + 'aliquam lorem ac, imperdiet orci. Duis malesuada gravida pharetra. Cras' + + 'facilisis arcu diam, id dictum lorem imperdiet a. Suspendisse aliquet' + + 'tempus tortor et ultricies. Aliquam libero velit, posuere tempus ante' + + 'sed, pellentesque tincidunt lorem. Nullam iaculis, eros a varius' + + 'aliquet, tortor felis tempor metus, nec cursus felis eros aliquam nulla.' + + 'Vivamus ut orci sed mauris congue lacinia. Cras eget blandit neque.' + + 'Pellentesque a massa in turpis ullamcorper volutpat vel at massa. Sed' + + 'ante est, auctor non diam non, vulputate ultrices metus. Maecenas dictum' + + 'fermentum quam id aliquam. Donec porta risus vitae pretium posuere.' + + 'Fusce facilisis eros in lacus tincidunt congue.' + + i; /* trick dedupe */ + paragraphs.push(

{text}

); + } + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(paragraphs), + ); + + const [stream2, drip] = dripStream(stream); + + // Allow some of the content through. + drip(5000); + + const result = await ReactServerDOMClient.createFromReadableStream( + stream2, + { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + + // 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); + + // Unblock the rest + drip(Infinity); + + // Use the SSR render to resolve any lazy elements + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(result), + ); + const html = await readResult(ssrStream); + + const ssrStream2 = await serverAct(() => + ReactDOMServer.renderToReadableStream(paragraphs), + ); + const html2 = await readResult(ssrStream2); + + expect(html).toBe(html2); + }); + it('should be able to serialize any kind of typed array', async () => { const buffer = new Uint8Array([ 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20, diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 7877ff0db1e12..b7e8a7b78344f 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -1605,6 +1605,24 @@ let debugID: null | number = null; let serializedSize = 0; const MAX_ROW_SIZE = 3200; +function deferTask(request: Request, task: Task): ReactJSONValue { + // Like outlineTask but instead the item is scheduled to be serialized + // after its parent in the stream. + const newTask = createTask( + request, + task.model, // the currently rendering element + task.keyPath, // unlike outlineModel this one carries along context + task.implicitSlot, + request.abortableTasks, + __DEV__ ? task.debugOwner : null, + __DEV__ ? task.debugStack : null, + __DEV__ ? task.debugTask : null, + ); + + pingTask(request, newTask); + return serializeLazyID(newTask.id); +} + function outlineTask(request: Request, task: Task): ReactJSONValue { const newTask = createTask( request, @@ -2449,6 +2467,10 @@ function renderModelDestructive( const element: ReactElement = (value: any); + if (serializedSize > MAX_ROW_SIZE) { + return deferTask(request, task); + } + if (__DEV__) { const debugInfo: ?ReactDebugInfo = (value: any)._debugInfo; if (debugInfo) { @@ -2507,6 +2529,10 @@ function renderModelDestructive( return newChild; } case REACT_LAZY_TYPE: { + if (serializedSize > MAX_ROW_SIZE) { + return deferTask(request, task); + } + // Reset the task's thenable state before continuing. If there was one, it was // from suspending the lazy before. task.thenableState = null; From 54104be413157fb8355c49e28153d0a73b4b32cf Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 29 Apr 2025 20:20:09 -0400 Subject: [PATCH 3/3] Fix parentSerializedSize --- packages/react-server/src/ReactFlightServer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index b7e8a7b78344f..aefcf5f6ee809 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -3927,10 +3927,10 @@ function emitChunk( } // For anything else we need to try to serialize it using JSON. // We stash the outer parent size so we can restore it when we exit. + const parentSerializedSize = serializedSize; // We don't reset the serialized size counter from reentry because that indicates that we // are outlining a model and we actually want to include that size into the parent since // it will still block the parent row. It only restores to zero at the top of the stack. - const parentSerializedSize = 0; try { // $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do const json: string = stringify(value, task.toJSON);