main
js 544 lines 15.9 KB
Raw
1 let React;
2 let ReactNoop;
3 let Scheduler;
4 let act;
5 let assertLog;
6 let waitFor;
7 let waitForPaint;
8 let waitForAll;
9 let textCache;
10 let startTransition;
11 let Suspense;
12 let Activity;
13
14 describe('ReactSiblingPrerendering', () => {
15 beforeEach(() => {
16 jest.resetModules();
17
18 React = require('react');
19 ReactNoop = require('react-noop-renderer');
20 Scheduler = require('scheduler');
21 act = require('internal-test-utils').act;
22 assertLog = require('internal-test-utils').assertLog;
23 waitFor = require('internal-test-utils').waitFor;
24 waitForPaint = require('internal-test-utils').waitForPaint;
25 waitForAll = require('internal-test-utils').waitForAll;
26 startTransition = React.startTransition;
27 Suspense = React.Suspense;
28 Activity = React.Activity;
29
30 textCache = new Map();
31 });
32
33 function resolveText(text) {
34 const record = textCache.get(text);
35 if (record === undefined) {
36 const newRecord = {
37 status: 'resolved',
38 value: text,
39 };
40 textCache.set(text, newRecord);
41 } else if (record.status === 'pending') {
42 const thenable = record.value;
43 record.status = 'resolved';
44 record.value = text;
45 thenable.pings.forEach(t => t());
46 }
47 }
48
49 function readText(text) {
50 const record = textCache.get(text);
51 if (record !== undefined) {
52 switch (record.status) {
53 case 'pending':
54 Scheduler.log(`Suspend! [${text}]`);
55 throw record.value;
56 case 'rejected':
57 throw record.value;
58 case 'resolved':
59 return record.value;
60 }
61 } else {
62 Scheduler.log(`Suspend! [${text}]`);
63 const thenable = {
64 pings: [],
65 then(resolve) {
66 if (newRecord.status === 'pending') {
67 thenable.pings.push(resolve);
68 } else {
69 Promise.resolve().then(() => resolve(newRecord.value));
70 }
71 },
72 };
73
74 const newRecord = {
75 status: 'pending',
76 value: thenable,
77 };
78 textCache.set(text, newRecord);
79
80 throw thenable;
81 }
82 }
83
84 // function getText(text) {
85 // const record = textCache.get(text);
86 // if (record === undefined) {
87 // const thenable = {
88 // pings: [],
89 // then(resolve) {
90 // if (newRecord.status === 'pending') {
91 // thenable.pings.push(resolve);
92 // } else {
93 // Promise.resolve().then(() => resolve(newRecord.value));
94 // }
95 // },
96 // };
97 // const newRecord = {
98 // status: 'pending',
99 // value: thenable,
100 // };
101 // textCache.set(text, newRecord);
102 // return thenable;
103 // } else {
104 // switch (record.status) {
105 // case 'pending':
106 // return record.value;
107 // case 'rejected':
108 // return Promise.reject(record.value);
109 // case 'resolved':
110 // return Promise.resolve(record.value);
111 // }
112 // }
113 // }
114
115 function Text({text}) {
116 Scheduler.log(text);
117 return text;
118 }
119
120 function AsyncText({text}) {
121 readText(text);
122 Scheduler.log(text);
123 return text;
124 }
125
126 it("don't prerender siblings when something errors", async () => {
127 class ErrorBoundary extends React.Component {
128 state = {error: null};
129 static getDerivedStateFromError(error) {
130 return {error};
131 }
132 render() {
133 if (this.state.error) {
134 return <Text text={this.state.error.message} />;
135 }
136 return this.props.children;
137 }
138 }
139
140 function Oops() {
141 throw new Error('Oops!');
142 }
143
144 function App() {
145 return (
146 <>
147 <div>
148 <ErrorBoundary>
149 <Oops />
150 <AsyncText text="A" />
151 </ErrorBoundary>
152 </div>
153 <div>
154 <AsyncText text="B" />
155 <AsyncText text="C" />
156 </div>
157 </>
158 );
159 }
160
161 const root = ReactNoop.createRoot();
162 await act(() => startTransition(() => root.render(<App />)));
163 assertLog([
164 'Oops!',
165
166 // A is skipped because we don't prerender siblings when
167 // something errors.
168
169 'Suspend! [B]',
170
171 // After B suspends, we're still able to prerender C without starting
172 // over because there's no fallback, so the root is blocked from
173 // committing anyway.
174 'Suspend! [C]',
175 ]);
176 });
177
178 it("don't skip siblings when rendering inside a hidden tree", async () => {
179 function App() {
180 return (
181 <>
182 <Text text="A" />
183 <Activity mode="hidden">
184 <Suspense fallback={<Text text="Loading..." />}>
185 <AsyncText text="B" />
186 <AsyncText text="C" />
187 </Suspense>
188 </Activity>
189 </>
190 );
191 }
192
193 const root = ReactNoop.createRoot();
194 await act(async () => {
195 startTransition(async () => root.render(<App />));
196
197 // The first render includes only the visible part of the tree. The
198 // hidden content is deferred until later.
199 await waitForPaint(['A']);
200 expect(root).toMatchRenderedOutput('A');
201
202 if (gate(flags => flags.enableYieldingBeforePassive)) {
203 // Passive effects.
204 await waitForPaint([]);
205 }
206 // The second render is a prerender of the hidden content.
207 await waitForPaint([
208 'Suspend! [B]',
209 // If B and C were visible, C would not have been attempted
210 // during this pass, because it would prevented the fallback
211 // from showing.
212 'Suspend! [C]',
213 'Loading...',
214 ]);
215 expect(root).toMatchRenderedOutput('A');
216 });
217 });
218
219 it('start prerendering retries right after the fallback commits', async () => {
220 function App() {
221 return (
222 <Suspense fallback={<Text text="Loading..." />}>
223 <AsyncText text="A" />
224 <AsyncText text="B" />
225 </Suspense>
226 );
227 }
228
229 const root = ReactNoop.createRoot();
230 await act(async () => {
231 startTransition(() => root.render(<App />));
232
233 // On the first attempt, A suspends. Unwind and show a fallback, without
234 // attempting B.
235 await waitForPaint(['Suspend! [A]', 'Loading...']);
236 expect(root).toMatchRenderedOutput('Loading...');
237
238 // Immediately after the fallback commits, retry the boundary again. This
239 // time we include B, since we're not blocking the fallback from showing.
240 if (gate(flags => flags.enableYieldingBeforePassive)) {
241 // Passive effects.
242 await waitForPaint([]);
243 }
244 await waitForPaint(['Suspend! [A]', 'Suspend! [B]']);
245 });
246 expect(root).toMatchRenderedOutput('Loading...');
247 });
248
249 it('switch back to normal rendering mode if a ping occurs during prerendering', async () => {
250 function App() {
251 return (
252 <div>
253 <Suspense fallback={<Text text="Loading outer..." />}>
254 <div>
255 <Text text="A" />
256 <AsyncText text="B" />
257 </div>
258 <div>
259 <Suspense fallback={<Text text="Loading inner..." />}>
260 <AsyncText text="C" />
261 <AsyncText text="D" />
262 </Suspense>
263 </div>
264 </Suspense>
265 </div>
266 );
267 }
268
269 const root = ReactNoop.createRoot();
270 await act(async () => {
271 startTransition(() => root.render(<App />));
272
273 // On the first attempt, B suspends. Unwind and show a fallback, without
274 // attempting the siblings.
275 await waitForPaint(['A', 'Suspend! [B]', 'Loading outer...']);
276 expect(root).toMatchRenderedOutput(<div>Loading outer...</div>);
277
278 // Now that the fallback is visible, we can prerender the siblings. Start
279 // prerendering, then yield to simulate an interleaved event.
280 await waitFor(['A']);
281
282 // To avoid the Suspense throttling mechanism, let's pretend there's been
283 // more than a Just Noticeable Difference since we rendered the
284 // outer fallback.
285 Scheduler.unstable_advanceTime(500);
286
287 // During the render phase, but before we get to B again, resolve its
288 // promise. We should re-enter normal rendering mode, but we also
289 // shouldn't unwind and lose our work-in-progress.
290 await resolveText('B');
291 await waitForPaint([
292 'B',
293 'Suspend! [C]',
294
295 // If we were still in prerendering mode, then we would have attempted
296 // to render D here. But since we received new data, we will skip the
297 // remaining siblings to unblock the inner fallback.
298 'Loading inner...',
299 ]);
300
301 expect(root).toMatchRenderedOutput(
302 <div>
303 <div>AB</div>
304 <div>Loading inner...</div>
305 </div>,
306 );
307 });
308
309 // Now that the inner fallback is showing, we can prerender the rest of
310 // the tree.
311 assertLog([
312 // NOTE: C renders twice instead of once because when B resolved, it
313 // was treated like a retry update, not just a ping. So first it
314 // regular renders, then it prerenders. TODO: We should be able to
315 // optimize this by detecting inside the retry listener that the
316 // outer boundary is no longer suspended, and therefore doesn't need
317 // to be updated.
318 'Suspend! [C]',
319
320 // Now we're in prerender mode, so D is incuded in this attempt.
321 'Suspend! [C]',
322 'Suspend! [D]',
323 ]);
324 expect(root).toMatchRenderedOutput(
325 <div>
326 <div>AB</div>
327 <div>Loading inner...</div>
328 </div>,
329 );
330 });
331
332 it("don't throw out completed work in order to prerender", async () => {
333 function App() {
334 return (
335 <div>
336 <Suspense fallback={<Text text="Loading outer..." />}>
337 <div>
338 <AsyncText text="A" />
339 </div>
340 <div>
341 <Suspense fallback={<Text text="Loading inner..." />}>
342 <AsyncText text="B" />
343 <AsyncText text="C" />
344 </Suspense>
345 </div>
346 </Suspense>
347 </div>
348 );
349 }
350
351 const root = ReactNoop.createRoot();
352 await act(async () => {
353 startTransition(() => root.render(<App />));
354
355 await waitForPaint(['Suspend! [A]', 'Loading outer...']);
356 expect(root).toMatchRenderedOutput(<div>Loading outer...</div>);
357
358 // Before the prerendering of the inner boundary starts, the data for A
359 // resolves, so we try rendering that again.
360 await resolveText('A');
361 // This produces a new tree that we can show. However, the commit phase
362 // is throttled because it's been less than a Just Noticeable Difference
363 // since the outer fallback was committed.
364 //
365 // In the meantime, we could choose to start prerendering C, but instead
366 // we wait for a JND to elapse and the commit to finish — it's not
367 // worth discarding the work we've already done.
368 await waitForAll([
369 'A',
370 'Suspend! [B]',
371
372 // C is skipped because we're no longer in prerendering mode; there's
373 // a new fallback we can show.
374 'Loading inner...',
375 ]);
376 expect(root).toMatchRenderedOutput(<div>Loading outer...</div>);
377
378 // Fire the timer to commit the outer fallback.
379 jest.runAllTimers();
380 expect(root).toMatchRenderedOutput(
381 <div>
382 <div>A</div>
383 <div>Loading inner...</div>
384 </div>,
385 );
386 });
387 // Once the inner fallback is committed, we can start prerendering C.
388 assertLog(['Suspend! [B]', 'Suspend! [C]']);
389 });
390
391 it(
392 "don't skip siblings during the retry if there was a ping since the " +
393 'first attempt',
394 async () => {
395 function App() {
396 return (
397 <>
398 <div>
399 <Suspense fallback={<Text text="Loading outer..." />}>
400 <div>
401 <AsyncText text="A" />
402 </div>
403 <div>
404 <Suspense fallback={<Text text="Loading inner..." />}>
405 <AsyncText text="B" />
406 <AsyncText text="C" />
407 </Suspense>
408 </div>
409 </Suspense>
410 </div>
411 <div>
412 <Text text="D" />
413 </div>
414 </>
415 );
416 }
417
418 const root = ReactNoop.createRoot();
419 await act(async () => {
420 startTransition(() => root.render(<App />));
421
422 // On the first attempt, A suspends. Unwind and show a fallback, without
423 // attempting B or C.
424 await waitFor([
425 'Suspend! [A]',
426 'Loading outer...',
427
428 // Yield to simulate an interleaved event
429 ]);
430
431 // Ping the promise for A before the render phase has finished, as might
432 // happen in an interleaved network event
433 await resolveText('A');
434
435 // Now continue rendering the rest of the tree.
436 await waitForPaint(['D']);
437 expect(root).toMatchRenderedOutput(
438 <>
439 <div>Loading outer...</div>
440 <div>D</div>
441 </>,
442 );
443
444 if (gate(flags => flags.enableYieldingBeforePassive)) {
445 // Passive effects.
446 await waitForPaint([]);
447 }
448 // Immediately after the fallback commits, retry the boundary again.
449 // Because the promise for A resolved, this is a normal render, _not_
450 // a prerender. So when we proceed to B, and B suspends, we unwind again
451 // without attempting C. The practical benefit of this is that we don't
452 // block the inner Suspense fallback from appearing.
453 await waitForPaint(['A', 'Suspend! [B]', 'Loading inner...']);
454 // (Since this is a retry, the commit phase is throttled by a timer.)
455 jest.runAllTimers();
456 // The inner fallback is now visible.
457 expect(root).toMatchRenderedOutput(
458 <>
459 <div>
460 <div>A</div>
461 <div>Loading inner...</div>
462 </div>
463 <div>D</div>
464 </>,
465 );
466
467 if (gate(flags => flags.enableYieldingBeforePassive)) {
468 // Passive effects.
469 await waitForPaint([]);
470 }
471 // Now we can proceed to prerendering C.
472 await waitForPaint(['Suspend! [B]', 'Suspend! [C]']);
473 });
474 assertLog([]);
475 },
476 );
477
478 it(
479 'when a synchronous update suspends outside a boundary, the resulting' +
480 'prerender is concurrent',
481 async () => {
482 function App() {
483 return (
484 <>
485 <Text text="A" />
486 <Text text="B" />
487 <AsyncText text="Async" />
488 <Text text="C" />
489 <Text text="D" />
490 </>
491 );
492 }
493
494 const root = ReactNoop.createRoot();
495 // Mount the root synchronously
496 ReactNoop.flushSync(() => root.render(<App />));
497
498 // Synchronously render everything until we suspend in the shell
499 assertLog(['A', 'B', 'Suspend! [Async]']);
500
501 // The rest of the siblings begin to prerender concurrently. Notice
502 // that we don't unwind here; we pick up where we left off above.
503 await waitFor(['C']);
504 await waitFor(['D']);
505
506 assertLog([]);
507 expect(root).toMatchRenderedOutput(null);
508
509 await resolveText('Async');
510 assertLog(['A', 'B', 'Async', 'C', 'D']);
511 expect(root).toMatchRenderedOutput('ABAsyncCD');
512 },
513 );
514
515 it('restart a suspended sync render if something suspends while prerendering the siblings', async () => {
516 function App() {
517 return (
518 <>
519 <Text text="A" />
520 <Text text="B" />
521 <AsyncText text="Async" />
522 <Text text="C" />
523 <Text text="D" />
524 </>
525 );
526 }
527
528 const root = ReactNoop.createRoot();
529 // Mount the root synchronously
530 ReactNoop.flushSync(() => root.render(<App />));
531
532 // Synchronously render everything until we suspend in the shell
533 assertLog(['A', 'B', 'Suspend! [Async]']);
534
535 // The rest of the siblings begin to prerender concurrently
536 await waitFor(['C']);
537
538 // While we're prerendering, Async resolves. We should unwind and
539 // start over, rather than continue prerendering D.
540 await resolveText('Async');
541 assertLog(['A', 'B', 'Async', 'C', 'D']);
542 expect(root).toMatchRenderedOutput('ABAsyncCD');
543 });
544 });