[Fizz] Start initial work immediately (#31079)
In a recent update we make Flight start working immediately rather than waitin for a new task. This commit updates fizz to have similar mechanics. We start the render in the currently running task but we do so in a microtask to avoid reentrancy. This aligns Fizz with Flight. ref: https://github.com/facebook/react/pull/30961
Josh Story committed
Sep 26, 2024 at 13:51 UTC
67fee58b1f72754cc77488c40c44e786572ef954
3 files changed
+160
-153
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+2
-2
@@ -8119,7 +8119,7 @@ describe('ReactDOMFizzServer', () => {
8119
8120
prerendering = false;
8121
8122
- const resumed = await ReactDOMFizzServer.resumeToPipeableStream(
8122
+ const resumed = ReactDOMFizzServer.resumeToPipeableStream(
8123
<App />,
8124
JSON.parse(JSON.stringify(prerendered.postponed)),
8125
{
@@ -8187,7 +8187,7 @@ describe('ReactDOMFizzServer', () => {
8187
function onPostpone(reason) {
8188
postpones.push(reason);
8189
}
8190
- const result = await renderToPipeableStream(<App />, {
8190
+ const result = renderToPipeableStream(<App />, {
8191
onError,
8192
onShellError,
8193
onPostpone,
packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
+124
-113
@@ -14,6 +14,7 @@ let Stream;
14
let React;
15
let ReactDOMFizzServer;
16
let Suspense;
17
+let act;
18
19
describe('ReactDOMFizzServerNode', () => {
20
beforeEach(() => {
@@ -22,6 +23,7 @@ describe('ReactDOMFizzServerNode', () => {
23
ReactDOMFizzServer = require('react-dom/server');
24
Stream = require('stream');
25
Suspense = React.Suspense;
26
+ act = require('internal-test-utils').act;
27
});
28
29
function getTestWritable() {
@@ -54,54 +56,59 @@ describe('ReactDOMFizzServerNode', () => {
56
throw theInfinitePromise;
57
}
58
57
- it('should call renderToPipeableStream', () => {
59
+ it('should call renderToPipeableStream', async () => {
60
const {writable, output} = getTestWritable();
59
- const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
60
- <div>hello world</div>,
61
- );
62
- pipe(writable);
63
- jest.runAllTimers();
61
+ await act(() => {
62
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
63
+ <div>hello world</div>,
64
+ );
65
+ pipe(writable);
66
+ });
67
expect(output.result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
68
});
69
67
- it('should emit DOCTYPE at the root of the document', () => {
70
+ it('should emit DOCTYPE at the root of the document', async () => {
71
const {writable, output} = getTestWritable();
69
- const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
70
- <html>
71
- <body>hello world</body>
72
- </html>,
73
- );
74
- pipe(writable);
75
- jest.runAllTimers();
72
+ await act(() => {
73
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
74
+ <html>
75
+ <body>hello world</body>
76
+ </html>,
77
+ );
78
+ pipe(writable);
79
+ });
80
// with Float, we emit empty heads if they are elided when rendering <html>
81
expect(output.result).toMatchInlineSnapshot(
82
`"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
83
);
84
});
85
82
- it('should emit bootstrap script src at the end', () => {
86
+ it('should emit bootstrap script src at the end', async () => {
87
const {writable, output} = getTestWritable();
84
- const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
85
- <div>hello world</div>,
86
- {
87
- bootstrapScriptContent: 'INIT();',
88
- bootstrapScripts: ['init.js'],
89
- bootstrapModules: ['init.mjs'],
90
- },
91
- );
92
- pipe(writable);
93
- jest.runAllTimers();
88
+ await act(() => {
89
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
90
+ <div>hello world</div>,
91
+ {
92
+ bootstrapScriptContent: 'INIT();',
93
+ bootstrapScripts: ['init.js'],
94
+ bootstrapModules: ['init.mjs'],
95
+ },
96
+ );
97
+ pipe(writable);
98
+ });
99
expect(output.result).toMatchInlineSnapshot(
100
`"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script>INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
101
);
102
});
103
99
- it('should start writing after pipe', () => {
104
+ it('should start writing after pipe', async () => {
105
const {writable, output} = getTestWritable();
101
- const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
102
- <div>hello world</div>,
103
- );
104
- jest.runAllTimers();
106
+ let pipe;
107
+ await act(() => {
108
+ pipe = ReactDOMFizzServer.renderToPipeableStream(
109
+ <div>hello world</div>,
110
+ ).pipe;
111
+ });
112
// First we write our header.
113
output.result +=
114
'<!doctype html><html><head><title>test</title><head><body>';
@@ -281,24 +288,26 @@ describe('ReactDOMFizzServerNode', () => {
288
let isCompleteCalls = 0;
289
const errors = [];
290
const {writable, output, completed} = getTestWritable();
284
- const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
285
- <div>
286
- <Suspense fallback={<div>Loading</div>}>
287
- <InfiniteSuspend />
288
- </Suspense>
289
- </div>,
290
- {
291
- onError(x) {
292
- errors.push(x.message);
293
- },
294
- onAllReady() {
295
- isCompleteCalls++;
291
+ let abort;
292
+ await act(() => {
293
+ const pipeable = ReactDOMFizzServer.renderToPipeableStream(
294
+ <div>
295
+ <Suspense fallback={<div>Loading</div>}>
296
+ <InfiniteSuspend />
297
+ </Suspense>
298
+ </div>,
299
+ {
300
+ onError(x) {
301
+ errors.push(x.message);
302
+ },
303
+ onAllReady() {
304
+ isCompleteCalls++;
305
+ },
306
},
297
- },
298
- );
299
- pipe(writable);
300
-
301
- jest.runAllTimers();
307
+ );
308
+ pipeable.pipe(writable);
309
+ abort = pipeable.abort;
310
+ });
311
312
expect(output.result).toContain('Loading');
313
expect(isCompleteCalls).toBe(0);
@@ -360,26 +369,28 @@ describe('ReactDOMFizzServerNode', () => {
369
let isCompleteCalls = 0;
370
const errors = [];
371
const {writable, output, completed} = getTestWritable();
363
- const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
364
- <div>
365
- <Suspense fallback="Loading">
366
- <Suspense fallback={<InfiniteSuspend />}>
367
- <InfiniteSuspend />
372
+ let abort;
373
+ await act(() => {
374
+ const pipeable = ReactDOMFizzServer.renderToPipeableStream(
375
+ <div>
376
+ <Suspense fallback="Loading">
377
+ <Suspense fallback={<InfiniteSuspend />}>
378
+ <InfiniteSuspend />
379
+ </Suspense>
380
</Suspense>
369
- </Suspense>
370
- </div>,
371
- {
372
- onError(x) {
373
- errors.push(x.message);
374
- },
375
- onAllReady() {
376
- isCompleteCalls++;
381
+ </div>,
382
+ {
383
+ onError(x) {
384
+ errors.push(x.message);
385
+ },
386
+ onAllReady() {
387
+ isCompleteCalls++;
388
+ },
389
},
378
- },
379
- );
380
- pipe(writable);
381
-
382
- jest.runAllTimers();
390
+ );
391
+ pipeable.pipe(writable);
392
+ abort = pipeable.abort;
393
+ });
394
395
expect(output.result).toContain('Loading');
396
expect(isCompleteCalls).toBe(0);
@@ -428,15 +439,15 @@ describe('ReactDOMFizzServerNode', () => {
439
440
const client = new DelayClient();
441
const {writable, output, completed} = getTestWritable();
431
- ReactDOMFizzServer.renderToPipeableStream(
432
- <DelayContext.Provider value={client}>
433
- <Suspense fallback="loading">
434
- <Component />
435
- </Suspense>
436
- </DelayContext.Provider>,
437
- ).pipe(writable);
438
-
439
- jest.runAllTimers();
442
+ await act(() => {
443
+ ReactDOMFizzServer.renderToPipeableStream(
444
+ <DelayContext.Provider value={client}>
445
+ <Suspense fallback="loading">
446
+ <Component />
447
+ </Suspense>
448
+ </DelayContext.Provider>,
449
+ ).pipe(writable);
450
+ });
451
452
expect(output.error).toBe(undefined);
453
expect(output.result).toContain('loading');
@@ -481,29 +492,28 @@ describe('ReactDOMFizzServerNode', () => {
492
output: output0,
493
completed: completed0,
494
} = getTestWritable();
484
- ReactDOMFizzServer.renderToPipeableStream(
485
- <DelayContext.Provider value={client0}>
486
- <Suspense fallback="loading">
487
- <Component />
488
- </Suspense>
489
- </DelayContext.Provider>,
490
- ).pipe(writable0);
491
-
495
const client1 = new DelayClient();
496
const {
497
writable: writable1,
498
output: output1,
499
completed: completed1,
500
} = getTestWritable();
498
- ReactDOMFizzServer.renderToPipeableStream(
499
- <DelayContext.Provider value={client1}>
500
- <Suspense fallback="loading">
501
- <Component />
502
- </Suspense>
503
- </DelayContext.Provider>,
504
- ).pipe(writable1);
505
-
506
- jest.runAllTimers();
501
+ await act(() => {
502
+ ReactDOMFizzServer.renderToPipeableStream(
503
+ <DelayContext.Provider value={client0}>
504
+ <Suspense fallback="loading">
505
+ <Component />
506
+ </Suspense>
507
+ </DelayContext.Provider>,
508
+ ).pipe(writable0);
509
+ ReactDOMFizzServer.renderToPipeableStream(
510
+ <DelayContext.Provider value={client1}>
511
+ <Suspense fallback="loading">
512
+ <Component />
513
+ </Suspense>
514
+ </DelayContext.Provider>,
515
+ ).pipe(writable1);
516
+ });
517
518
expect(output0.error).toBe(undefined);
519
expect(output0.result).toContain('loading');
@@ -552,22 +562,22 @@ describe('ReactDOMFizzServerNode', () => {
562
563
const client = new DelayClient();
564
const {writable, output, completed} = getTestWritable();
555
- ReactDOMFizzServer.renderToPipeableStream(
556
- <>
557
- <DelayContext.Provider value={client}>
558
- <Suspense fallback="loading">
559
- <Component />
560
- </Suspense>
561
- </DelayContext.Provider>
562
- <DelayContext.Provider value={client}>
563
- <Suspense fallback="loading">
564
- <Component />
565
- </Suspense>
566
- </DelayContext.Provider>
567
- </>,
568
- ).pipe(writable);
569
-
570
- jest.runAllTimers();
565
+ await act(() => {
566
+ ReactDOMFizzServer.renderToPipeableStream(
567
+ <>
568
+ <DelayContext.Provider value={client}>
569
+ <Suspense fallback="loading">
570
+ <Component />
571
+ </Suspense>
572
+ </DelayContext.Provider>
573
+ <DelayContext.Provider value={client}>
574
+ <Suspense fallback="loading">
575
+ <Component />
576
+ </Suspense>
577
+ </DelayContext.Provider>
578
+ </>,
579
+ ).pipe(writable);
580
+ });
581
582
expect(output.error).toBe(undefined);
583
expect(output.result).toContain('loading');
@@ -630,13 +640,14 @@ describe('ReactDOMFizzServerNode', () => {
640
expect(isComplete).toBe(true);
641
});
642
633
- it('should encode multibyte characters correctly without nulls (#24985)', () => {
643
+ it('should encode multibyte characters correctly without nulls (#24985)', async () => {
644
const {writable, output} = getTestWritable();
635
- const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
636
- <div>{Array(700).fill('ののの')}</div>,
637
- );
638
- pipe(writable);
639
- jest.runAllTimers();
645
+ await act(() => {
646
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
647
+ <div>{Array(700).fill('ののの')}</div>,
648
+ );
649
+ pipe(writable);
650
+ });
651
expect(output.result.indexOf('\u0000')).toBe(-1);
652
expect(output.result).toEqual(
653
'<div>' + Array(700).fill('ののの').join('<!-- -->') + '</div>',
packages/react-server/src/ReactFizzServer.js
+34
-38
@@ -316,10 +316,11 @@ type Segment = {
316
textEmbedded: boolean,
317
};
318
319
-const OPEN = 0;
320
-const ABORTING = 1;
321
-const CLOSING = 2;
322
-const CLOSED = 3;
319
+const OPENING = 10;
320
+const OPEN = 11;
321
+const ABORTING = 12;
322
+const CLOSING = 13;
323
+const CLOSED = 14;
324
325
export opaque type Request = {
326
destination: null | Destination,
@@ -328,7 +329,7 @@ export opaque type Request = {
329
+renderState: RenderState,
330
+rootFormatContext: FormatContext,
331
+progressiveChunkSize: number,
331
- status: 0 | 1 | 2 | 3,
332
+ status: 10 | 11 | 12 | 13 | 14,
333
fatalError: mixed,
334
nextSegmentId: number,
335
allPendingTasks: number, // when it reaches zero, we can close the connection.
@@ -424,7 +425,7 @@ function RequestInstance(
425
progressiveChunkSize === undefined
426
? DEFAULT_PROGRESSIVE_CHUNK_SIZE
427
: progressiveChunkSize;
427
- this.status = OPEN;
428
+ this.status = OPENING;
429
this.fatalError = null;
430
this.nextSegmentId = 0;
431
this.allPendingTasks = 0;
@@ -688,7 +689,7 @@ function pingTask(request: Request, task: Task): void {
689
pingedTasks.push(task);
690
if (request.pingedTasks.length === 1) {
691
request.flushScheduled = request.destination !== null;
691
- if (request.trackedPostpones !== null) {
692
+ if (request.trackedPostpones !== null || request.status === OPENING) {
693
scheduleMicrotask(() => performWork(request));
694
} else {
695
scheduleWork(() => performWork(request));
@@ -4977,43 +4978,38 @@ function flushCompletedQueues(
4978
4979
export function startWork(request: Request): void {
4980
request.flushScheduled = request.destination !== null;
4980
- if (request.trackedPostpones !== null) {
4981
- // When prerendering we use microtasks for pinging work
4982
- if (supportsRequestStorage) {
4983
- scheduleMicrotask(() =>
4984
- requestStorage.run(request, performWork, request),
4985
- );
4986
- } else {
4987
- scheduleMicrotask(() => performWork(request));
4988
- }
4981
+ // When prerendering we use microtasks for pinging work
4982
+ if (supportsRequestStorage) {
4983
+ scheduleMicrotask(() => requestStorage.run(request, performWork, request));
4984
} else {
4990
- // When rendering/resuming we use regular tasks and we also emit early preloads
4991
- if (supportsRequestStorage) {
4992
- scheduleWork(() => requestStorage.run(request, performWork, request));
4993
- } else {
4994
- scheduleWork(() => performWork(request));
4985
+ scheduleMicrotask(() => performWork(request));
4986
+ }
4987
+ scheduleWork(() => {
4988
+ if (request.status === OPENING) {
4989
+ request.status = OPEN;
4990
}
4996
- // this is either a regular render or a resume. For regular render we want
4997
- // to call emitEarlyPreloads after the first performWork because we want
4998
- // are responding to a live request and need to balance sending something early
4999
- // (i.e. don't want for the shell to finish) but we need something to send.
5000
- // The only implementation of this is for DOM at the moment and during resumes nothing
5001
- // actually emits but the code paths here are the same.
5002
- // During a prerender we don't want to be too aggressive in emitting early preloads
5003
- // because we aren't responding to a live request and we can wait for the prerender to
5004
- // postpone before we emit anything.
5005
- if (supportsRequestStorage) {
5006
- scheduleWork(() =>
4991
+
4992
+ if (request.trackedPostpones === null) {
4993
+ // this is either a regular render or a resume. For regular render we want
4994
+ // to call emitEarlyPreloads after the first performWork because we want
4995
+ // are responding to a live request and need to balance sending something early
4996
+ // (i.e. don't want for the shell to finish) but we need something to send.
4997
+ // The only implementation of this is for DOM at the moment and during resumes nothing
4998
+ // actually emits but the code paths here are the same.
4999
+ // During a prerender we don't want to be too aggressive in emitting early preloads
5000
+ // because we aren't responding to a live request and we can wait for the prerender to
5001
+ // postpone before we emit anything.
5002
+ if (supportsRequestStorage) {
5003
requestStorage.run(
5004
request,
5005
enqueueEarlyPreloadsAfterInitialWork,
5006
request,
5011
- ),
5012
- );
5013
- } else {
5014
- scheduleWork(() => enqueueEarlyPreloadsAfterInitialWork(request));
5007
+ );
5008
+ } else {
5009
+ enqueueEarlyPreloadsAfterInitialWork(request);
5010
+ }
5011
}
5016
- }
5012
+ });
5013
}
5014
5015
function enqueueEarlyPreloadsAfterInitialWork(request: Request) {
@@ -5095,7 +5091,7 @@ export function stopFlowing(request: Request): void {
5091
5092
// This is called to early terminate a request. It puts all pending boundaries in client rendered state.
5093
export function abort(request: Request, reason: mixed): void {
5098
- if (request.status === OPEN) {
5094
+ if (request.status === OPEN || request.status === OPENING) {
5095
request.status = ABORTING;
5096
}
5097