Skip to content

Commit 49ea8bf

Browse files
authored
[Flight] Defer Elements if the parent chunk is too large (facebook#33030)
Same principle as facebook#33029 but for Flight. We pretty aggressively create separate rows for things in Flight (every Server Component that's an async function create a microtask). However, sync Server Components and just plain Host Components are not. Plus we should ideally ideally inline more of the async ones in the same way Fizz does. This means that we can create rows that end up very large. Especially if all the data is already available. We can't show the parent content until the whole thing loads on the client. We don't really know where Suspense boundaries are for Flight but any Element is potentially a point that can be split. This heuristic counts roughly how much we've serialized to block the current chunk and once a limit is exceeded, we start deferring all Elements. That way they get outlined into future chunks that are later in the stream. Since they get replaced by Lazy references the parent can potentially get unblocked. This can help if you're trying to stream a very large document with a client nav for example.
1 parent 9a52ad9 commit 49ea8bf

File tree

2 files changed

+162
-3
lines changed

2 files changed

+162
-3
lines changed

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,61 @@ describe('ReactFlightDOMEdge', () => {
160160
});
161161
}
162162

163+
function dripStream(input) {
164+
const reader = input.getReader();
165+
let nextDrop = 0;
166+
let controller = null;
167+
let streamDone = false;
168+
const buffer = [];
169+
function flush() {
170+
if (controller === null || nextDrop === 0) {
171+
return;
172+
}
173+
while (buffer.length > 0 && nextDrop > 0) {
174+
const nextChunk = buffer[0];
175+
if (nextChunk.byteLength <= nextDrop) {
176+
nextDrop -= nextChunk.byteLength;
177+
controller.enqueue(nextChunk);
178+
buffer.shift();
179+
if (streamDone && buffer.length === 0) {
180+
controller.done();
181+
}
182+
} else {
183+
controller.enqueue(nextChunk.subarray(0, nextDrop));
184+
buffer[0] = nextChunk.subarray(nextDrop);
185+
nextDrop = 0;
186+
}
187+
}
188+
}
189+
const output = new ReadableStream({
190+
start(c) {
191+
controller = c;
192+
async function pump() {
193+
for (;;) {
194+
const {value, done} = await reader.read();
195+
if (done) {
196+
streamDone = true;
197+
break;
198+
}
199+
buffer.push(value);
200+
flush();
201+
}
202+
}
203+
pump();
204+
},
205+
pull() {},
206+
cancel(reason) {
207+
reader.cancel(reason);
208+
},
209+
});
210+
function drip(n) {
211+
nextDrop += n;
212+
flush();
213+
}
214+
215+
return [output, drip];
216+
}
217+
163218
async function readResult(stream) {
164219
const reader = stream.getReader();
165220
let result = '';
@@ -576,6 +631,67 @@ describe('ReactFlightDOMEdge', () => {
576631
expect(serializedContent.length).toBeLessThan(150 + expectedDebugInfoSize);
577632
});
578633

634+
it('should break up large sync components by outlining into streamable elements', async () => {
635+
const paragraphs = [];
636+
for (let i = 0; i < 20; i++) {
637+
const text =
638+
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris' +
639+
'porttitor tortor ac lectus faucibus, eget eleifend elit hendrerit.' +
640+
'Integer porttitor nisi in leo congue rutrum. Morbi sed ante posuere,' +
641+
'aliquam lorem ac, imperdiet orci. Duis malesuada gravida pharetra. Cras' +
642+
'facilisis arcu diam, id dictum lorem imperdiet a. Suspendisse aliquet' +
643+
'tempus tortor et ultricies. Aliquam libero velit, posuere tempus ante' +
644+
'sed, pellentesque tincidunt lorem. Nullam iaculis, eros a varius' +
645+
'aliquet, tortor felis tempor metus, nec cursus felis eros aliquam nulla.' +
646+
'Vivamus ut orci sed mauris congue lacinia. Cras eget blandit neque.' +
647+
'Pellentesque a massa in turpis ullamcorper volutpat vel at massa. Sed' +
648+
'ante est, auctor non diam non, vulputate ultrices metus. Maecenas dictum' +
649+
'fermentum quam id aliquam. Donec porta risus vitae pretium posuere.' +
650+
'Fusce facilisis eros in lacus tincidunt congue.' +
651+
i; /* trick dedupe */
652+
paragraphs.push(<p key={i}>{text}</p>);
653+
}
654+
655+
const stream = await serverAct(() =>
656+
ReactServerDOMServer.renderToReadableStream(paragraphs),
657+
);
658+
659+
const [stream2, drip] = dripStream(stream);
660+
661+
// Allow some of the content through.
662+
drip(5000);
663+
664+
const result = await ReactServerDOMClient.createFromReadableStream(
665+
stream2,
666+
{
667+
serverConsumerManifest: {
668+
moduleMap: null,
669+
moduleLoading: null,
670+
},
671+
},
672+
);
673+
674+
// We should have resolved enough to be able to get the array even though some
675+
// of the items inside are still lazy.
676+
expect(result.length).toBe(20);
677+
678+
// Unblock the rest
679+
drip(Infinity);
680+
681+
// Use the SSR render to resolve any lazy elements
682+
const ssrStream = await serverAct(() =>
683+
ReactDOMServer.renderToReadableStream(result),
684+
);
685+
const html = await readResult(ssrStream);
686+
687+
const ssrStream2 = await serverAct(() =>
688+
ReactDOMServer.renderToReadableStream(paragraphs),
689+
);
690+
const html2 = await readResult(ssrStream2);
691+
692+
expect(html).toBe(html2);
693+
});
694+
579695
it('should be able to serialize any kind of typed array', async () => {
580696
const buffer = new Uint8Array([
581697
123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,

packages/react-server/src/ReactFlightServer.js

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1600,6 +1600,29 @@ function renderClientElement(
16001600
// The chunk ID we're currently rendering that we can assign debug data to.
16011601
let debugID: null | number = null;
16021602

1603+
// Approximate string length of the currently serializing row.
1604+
// Used to power outlining heuristics.
1605+
let serializedSize = 0;
1606+
const MAX_ROW_SIZE = 3200;
1607+
1608+
function deferTask(request: Request, task: Task): ReactJSONValue {
1609+
// Like outlineTask but instead the item is scheduled to be serialized
1610+
// after its parent in the stream.
1611+
const newTask = createTask(
1612+
request,
1613+
task.model, // the currently rendering element
1614+
task.keyPath, // unlike outlineModel this one carries along context
1615+
task.implicitSlot,
1616+
request.abortableTasks,
1617+
__DEV__ ? task.debugOwner : null,
1618+
__DEV__ ? task.debugStack : null,
1619+
__DEV__ ? task.debugTask : null,
1620+
);
1621+
1622+
pingTask(request, newTask);
1623+
return serializeLazyID(newTask.id);
1624+
}
1625+
16031626
function outlineTask(request: Request, task: Task): ReactJSONValue {
16041627
const newTask = createTask(
16051628
request,
@@ -2393,6 +2416,8 @@ function renderModelDestructive(
23932416
// Set the currently rendering model
23942417
task.model = value;
23952418

2419+
serializedSize += parentPropertyName.length;
2420+
23962421
// Special Symbol, that's very common.
23972422
if (value === REACT_ELEMENT_TYPE) {
23982423
return '$';
@@ -2442,6 +2467,10 @@ function renderModelDestructive(
24422467

24432468
const element: ReactElement = (value: any);
24442469

2470+
if (serializedSize > MAX_ROW_SIZE) {
2471+
return deferTask(request, task);
2472+
}
2473+
24452474
if (__DEV__) {
24462475
const debugInfo: ?ReactDebugInfo = (value: any)._debugInfo;
24472476
if (debugInfo) {
@@ -2500,6 +2529,10 @@ function renderModelDestructive(
25002529
return newChild;
25012530
}
25022531
case REACT_LAZY_TYPE: {
2532+
if (serializedSize > MAX_ROW_SIZE) {
2533+
return deferTask(request, task);
2534+
}
2535+
25032536
// Reset the task's thenable state before continuing. If there was one, it was
25042537
// from suspending the lazy before.
25052538
task.thenableState = null;
@@ -2811,6 +2844,7 @@ function renderModelDestructive(
28112844
throwTaintViolation(tainted.message);
28122845
}
28132846
}
2847+
serializedSize += value.length;
28142848
// TODO: Maybe too clever. If we support URL there's no similar trick.
28152849
if (value[value.length - 1] === 'Z') {
28162850
// Possibly a Date, whose toJSON automatically calls toISOString
@@ -3892,9 +3926,18 @@ function emitChunk(
38923926
return;
38933927
}
38943928
// For anything else we need to try to serialize it using JSON.
3895-
// $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do
3896-
const json: string = stringify(value, task.toJSON);
3897-
emitModelChunk(request, task.id, json);
3929+
// We stash the outer parent size so we can restore it when we exit.
3930+
const parentSerializedSize = serializedSize;
3931+
// We don't reset the serialized size counter from reentry because that indicates that we
3932+
// are outlining a model and we actually want to include that size into the parent since
3933+
// it will still block the parent row. It only restores to zero at the top of the stack.
3934+
try {
3935+
// $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do
3936+
const json: string = stringify(value, task.toJSON);
3937+
emitModelChunk(request, task.id, json);
3938+
} finally {
3939+
serializedSize = parentSerializedSize;
3940+
}
38983941
}
38993942

39003943
function erroredTask(request: Request, task: Task, error: mixed): void {

0 commit comments

Comments
 (0)