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 826386f791833..aefcf5f6ee809 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -1600,6 +1600,29 @@ 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 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, @@ -2393,6 +2416,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 '$'; @@ -2442,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) { @@ -2500,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; @@ -2811,6 +2844,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 +3926,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. + 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. + 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 {