main
js 549 lines 17.4 KB
Raw
1 let React;
2 let startTransition;
3 let ReactNoop;
4 let resolveSuspenseyThing;
5 let getSuspenseyThingStatus;
6 let Suspense;
7 let Activity;
8 let SuspenseList;
9 let useMemo;
10 let Scheduler;
11 let act;
12 let assertLog;
13 let waitForPaint;
14
15 describe('ReactSuspenseyCommitPhase', () => {
16 beforeEach(() => {
17 jest.resetModules();
18
19 React = require('react');
20 ReactNoop = require('react-noop-renderer');
21 Scheduler = require('scheduler');
22 Suspense = React.Suspense;
23 if (gate(flags => flags.enableSuspenseList)) {
24 SuspenseList = React.unstable_SuspenseList;
25 }
26 Activity = React.Activity;
27 useMemo = React.useMemo;
28 startTransition = React.startTransition;
29 resolveSuspenseyThing = ReactNoop.resolveSuspenseyThing;
30 getSuspenseyThingStatus = ReactNoop.getSuspenseyThingStatus;
31
32 const InternalTestUtils = require('internal-test-utils');
33 act = InternalTestUtils.act;
34 assertLog = InternalTestUtils.assertLog;
35 waitForPaint = InternalTestUtils.waitForPaint;
36 });
37
38 function Text({text}) {
39 Scheduler.log(text);
40 return text;
41 }
42
43 function SuspenseyImage({src}) {
44 return (
45 <suspensey-thing
46 src={src}
47 timeout={100}
48 onLoadStart={() => Scheduler.log(`Image requested [${src}]`)}
49 />
50 );
51 }
52
53 // @gate enableSuspenseyImages
54 it('suspend commit during initial mount', async () => {
55 const root = ReactNoop.createRoot();
56 await act(async () => {
57 startTransition(() => {
58 root.render(
59 <Suspense fallback={<Text text="Loading..." />}>
60 <SuspenseyImage src="A" />
61 </Suspense>,
62 );
63 });
64 });
65 assertLog(['Image requested [A]', 'Loading...']);
66 expect(getSuspenseyThingStatus('A')).toBe('pending');
67 expect(root).toMatchRenderedOutput('Loading...');
68
69 // This should synchronously commit
70 resolveSuspenseyThing('A');
71 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
72 });
73
74 // @gate enableSuspenseyImages
75 it('suspend commit during update', async () => {
76 const root = ReactNoop.createRoot();
77 await act(() => resolveSuspenseyThing('A'));
78 await act(async () => {
79 startTransition(() => {
80 root.render(
81 <Suspense fallback={<Text text="Loading..." />}>
82 <SuspenseyImage src="A" />
83 </Suspense>,
84 );
85 });
86 });
87 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
88
89 // Update to a new image src. The transition should suspend because
90 // the src hasn't loaded yet, and the image is in an already-visible tree.
91 await act(async () => {
92 startTransition(() => {
93 root.render(
94 <Suspense fallback={<Text text="Loading..." />}>
95 <SuspenseyImage src="B" />
96 </Suspense>,
97 );
98 });
99 });
100 assertLog(['Image requested [B]']);
101 expect(getSuspenseyThingStatus('B')).toBe('pending');
102 // Should remain on previous screen
103 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
104
105 // This should synchronously commit
106 resolveSuspenseyThing('B');
107 expect(root).toMatchRenderedOutput(<suspensey-thing src="B" />);
108 });
109
110 // @gate enableSuspenseyImages
111 it('suspend commit during initial mount at the root', async () => {
112 const root = ReactNoop.createRoot();
113 await act(async () => {
114 startTransition(() => {
115 root.render(<SuspenseyImage src="A" />);
116 });
117 });
118 assertLog(['Image requested [A]']);
119 expect(getSuspenseyThingStatus('A')).toBe('pending');
120 expect(root).toMatchRenderedOutput(null);
121
122 resolveSuspenseyThing('A');
123 expect(getSuspenseyThingStatus('A')).toBe('fulfilled');
124 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
125 });
126
127 // @gate enableSuspenseyImages
128 it('suspend commit during update at the root', async () => {
129 const root = ReactNoop.createRoot();
130 await act(() => resolveSuspenseyThing('A'));
131 expect(getSuspenseyThingStatus('A')).toBe('fulfilled');
132 expect(root).toMatchRenderedOutput(null);
133 await act(async () => {
134 startTransition(() => {
135 root.render(<SuspenseyImage src="A" />);
136 });
137 });
138 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
139
140 await act(async () => {
141 startTransition(() => {
142 root.render(<SuspenseyImage src="B" />);
143 });
144 });
145 assertLog(['Image requested [B]']);
146 expect(getSuspenseyThingStatus('B')).toBe('pending');
147 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
148
149 resolveSuspenseyThing('B');
150 expect(getSuspenseyThingStatus('B')).toBe('fulfilled');
151 expect(root).toMatchRenderedOutput(<suspensey-thing src="B" />);
152 });
153
154 // @gate enableSuspenseyImages
155 it('suspend commit during urgent initial mount', async () => {
156 const root = ReactNoop.createRoot();
157 await act(async () => {
158 root.render(
159 <Suspense fallback={<Text text="Loading..." />}>
160 <SuspenseyImage src="A" />
161 </Suspense>,
162 );
163 });
164 assertLog(['Image requested [A]', 'Loading...']);
165 expect(getSuspenseyThingStatus('A')).toBe('pending');
166 expect(root).toMatchRenderedOutput('Loading...');
167
168 resolveSuspenseyThing('A');
169 expect(getSuspenseyThingStatus('A')).toBe('fulfilled');
170 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
171 });
172
173 // @gate enableSuspenseyImages
174 it('suspend commit during urgent update', async () => {
175 const root = ReactNoop.createRoot();
176 await act(() => resolveSuspenseyThing('A'));
177 expect(getSuspenseyThingStatus('A')).toBe('fulfilled');
178 await act(async () => {
179 root.render(
180 <Suspense fallback={<Text text="Loading..." />}>
181 <SuspenseyImage src="A" />
182 </Suspense>,
183 );
184 });
185 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
186
187 resolveSuspenseyThing('A');
188 expect(getSuspenseyThingStatus('A')).toBe('fulfilled');
189 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
190
191 await act(async () => {
192 root.render(
193 <Suspense fallback={<Text text="Loading..." />}>
194 <SuspenseyImage src="B" />
195 </Suspense>,
196 );
197 });
198 assertLog(['Image requested [B]', 'Loading...']);
199 expect(getSuspenseyThingStatus('B')).toBe('pending');
200 expect(root).toMatchRenderedOutput(
201 <>
202 <suspensey-thing src="A" hidden={true} />
203 {'Loading...'}
204 </>,
205 );
206
207 resolveSuspenseyThing('B');
208 expect(getSuspenseyThingStatus('B')).toBe('fulfilled');
209 expect(root).toMatchRenderedOutput(<suspensey-thing src="B" />);
210 });
211
212 // @gate enableSuspenseyImages
213 it('suspends commit during urgent initial mount at the root', async () => {
214 const root = ReactNoop.createRoot();
215 await act(async () => {
216 root.render(<SuspenseyImage src="A" />);
217 });
218 assertLog(['Image requested [A]']);
219 expect(getSuspenseyThingStatus('A')).toBe('pending');
220 expect(root).toMatchRenderedOutput(null);
221
222 resolveSuspenseyThing('A');
223 expect(getSuspenseyThingStatus('A')).toBe('fulfilled');
224 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
225 });
226
227 // @gate enableSuspenseyImages
228 it('suspends commit during urgent update at the root', async () => {
229 const root = ReactNoop.createRoot();
230 await act(() => resolveSuspenseyThing('A'));
231 expect(getSuspenseyThingStatus('A')).toBe('fulfilled');
232 expect(root).toMatchRenderedOutput(null);
233 await act(async () => {
234 root.render(<SuspenseyImage src="A" />);
235 });
236 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
237
238 await act(async () => {
239 root.render(<SuspenseyImage src="B" />);
240 });
241 assertLog(['Image requested [B]']);
242 expect(getSuspenseyThingStatus('B')).toBe('pending');
243 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
244
245 resolveSuspenseyThing('B');
246 expect(getSuspenseyThingStatus('B')).toBe('fulfilled');
247 expect(root).toMatchRenderedOutput(<suspensey-thing src="B" />);
248 });
249
250 // @gate enableSuspenseyImages
251 it('does suspend commit during urgent initial mount at the root when sync rendering', async () => {
252 const root = ReactNoop.createRoot();
253 await act(async () => {
254 ReactNoop.flushSync(() => {
255 root.render(<SuspenseyImage src="A" />);
256 });
257 });
258 assertLog(['Image requested [A]']);
259 expect(getSuspenseyThingStatus('A')).toBe('pending');
260 // Suspend the initial mount
261 expect(root).toMatchRenderedOutput(null);
262
263 resolveSuspenseyThing('A');
264 expect(getSuspenseyThingStatus('A')).toBe('fulfilled');
265 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
266 });
267
268 // @gate enableSuspenseyImages
269 it('does suspend commit during urgent update at the root when sync rendering', async () => {
270 const root = ReactNoop.createRoot();
271 await act(() => resolveSuspenseyThing('A'));
272 expect(getSuspenseyThingStatus('A')).toBe('fulfilled');
273 expect(root).toMatchRenderedOutput(null);
274 await act(async () => {
275 ReactNoop.flushSync(() => {
276 root.render(<SuspenseyImage src="A" />);
277 });
278 });
279 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
280
281 await act(async () => {
282 ReactNoop.flushSync(() => {
283 root.render(<SuspenseyImage src="B" />);
284 });
285 });
286 assertLog(['Image requested [B]']);
287 expect(getSuspenseyThingStatus('B')).toBe('pending');
288 // Suspend and remain on previous screen
289 expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
290
291 resolveSuspenseyThing('B');
292 expect(getSuspenseyThingStatus('B')).toBe('fulfilled');
293 expect(root).toMatchRenderedOutput(<suspensey-thing src="B" />);
294 });
295
296 // @gate enableSuspenseyImages
297 it('an urgent update interrupts a suspended commit', async () => {
298 const root = ReactNoop.createRoot();
299
300 // Mount an image. This transition will suspend because it's not inside a
301 // Suspense boundary.
302 await act(() => {
303 startTransition(() => {
304 root.render(<SuspenseyImage src="A" />);
305 });
306 });
307 assertLog(['Image requested [A]']);
308 // Nothing showing yet.
309 expect(root).toMatchRenderedOutput(null);
310
311 // If there's an update, it should interrupt the suspended commit.
312 await act(() => {
313 root.render(<Text text="Something else" />);
314 });
315 assertLog(['Something else']);
316 expect(root).toMatchRenderedOutput('Something else');
317 });
318
319 // @gate enableSuspenseyImages
320 it('a transition update interrupts a suspended commit', async () => {
321 const root = ReactNoop.createRoot();
322
323 // Mount an image. This transition will suspend because it's not inside a
324 // Suspense boundary.
325 await act(() => {
326 startTransition(() => {
327 root.render(<SuspenseyImage src="A" />);
328 });
329 });
330 assertLog(['Image requested [A]']);
331 // Nothing showing yet.
332 expect(root).toMatchRenderedOutput(null);
333
334 // If there's an update, it should interrupt the suspended commit.
335 await act(() => {
336 startTransition(() => {
337 root.render(<Text text="Something else" />);
338 });
339 });
340 assertLog(['Something else']);
341 expect(root).toMatchRenderedOutput('Something else');
342 });
343
344 // @gate enableSuspenseList && enableSuspenseyImages
345 it('demonstrate current behavior when used with SuspenseList (not ideal)', async () => {
346 function App() {
347 return (
348 <SuspenseList revealOrder="forwards" tail="visible">
349 <Suspense fallback={<Text text="Loading A" />}>
350 <SuspenseyImage src="A" />
351 </Suspense>
352 <Suspense fallback={<Text text="Loading B" />}>
353 <SuspenseyImage src="B" />
354 </Suspense>
355 <Suspense fallback={<Text text="Loading C" />}>
356 <SuspenseyImage src="C" />
357 </Suspense>
358 </SuspenseList>
359 );
360 }
361
362 const root = ReactNoop.createRoot();
363 await act(() => {
364 startTransition(() => {
365 root.render(<App />);
366 });
367 });
368 assertLog([
369 'Image requested [A]',
370 'Loading A',
371 'Loading B',
372 'Loading C',
373 'Image requested [B]',
374 'Image requested [C]',
375 ]);
376 expect(root).toMatchRenderedOutput('Loading ALoading BLoading C');
377
378 // TODO: Notice that none of these items appear until they've all loaded.
379 // That's not ideal; we should commit each row as it becomes ready to
380 // commit. That means we need to prepare both the fallback and the primary
381 // tree during the render phase. Related to Activity, too.
382 resolveSuspenseyThing('A');
383 expect(root).toMatchRenderedOutput('Loading ALoading BLoading C');
384 resolveSuspenseyThing('B');
385 expect(root).toMatchRenderedOutput('Loading ALoading BLoading C');
386 resolveSuspenseyThing('C');
387 expect(root).toMatchRenderedOutput(
388 <>
389 <suspensey-thing src="A" />
390 <suspensey-thing src="B" />
391 <suspensey-thing src="C" />
392 </>,
393 );
394 });
395
396 // @gate enableSuspenseyImages
397 it('avoid triggering a fallback if resource loads immediately', async () => {
398 const root = ReactNoop.createRoot();
399 await act(async () => {
400 startTransition(() => {
401 // Intentionally rendering <suspensey-thing>s in a variety of tree
402 // positions to test that the work loop resumes correctly in each case.
403 root.render(
404 <Suspense fallback={<Text text="Loading..." />}>
405 <suspensey-thing
406 src="A"
407 onLoadStart={() => Scheduler.log('Request [A]')}>
408 <suspensey-thing
409 src="B"
410 onLoadStart={() => Scheduler.log('Request [B]')}
411 />
412 </suspensey-thing>
413 <suspensey-thing
414 src="C"
415 onLoadStart={() => Scheduler.log('Request [C]')}
416 />
417 </Suspense>,
418 );
419 });
420 // React will yield right after the resource suspends.
421 // TODO: The child is preloaded first because we preload in the complete
422 // phase. Ideally it should be in the begin phase, but we currently don't
423 // create the instance until complete. However, it's unclear if we even
424 // need the instance for preloading. So we should probably move this to
425 // the begin phase.
426 await waitForPaint(['Request [B]']);
427 // Resolve in an immediate task. This could happen if the resource is
428 // already loaded into the cache.
429 resolveSuspenseyThing('B');
430 await waitForPaint(['Request [A]']);
431 resolveSuspenseyThing('A');
432 await waitForPaint(['Request [C]']);
433 resolveSuspenseyThing('C');
434 });
435 expect(root).toMatchRenderedOutput(
436 <>
437 <suspensey-thing src="A">
438 <suspensey-thing src="B" />
439 </suspensey-thing>
440 <suspensey-thing src="C" />
441 </>,
442 );
443 });
444
445 // @gate enableSuspenseyImages
446 it("host instances don't suspend during prerendering, but do suspend when they are revealed", async () => {
447 function More() {
448 Scheduler.log('More');
449 return <SuspenseyImage src="More" />;
450 }
451
452 function Details({showMore}) {
453 Scheduler.log('Details');
454 const more = useMemo(() => <More />, []);
455 return (
456 <>
457 <div>Main Content</div>
458 <Activity mode={showMore ? 'visible' : 'hidden'}>{more}</Activity>
459 </>
460 );
461 }
462
463 const root = ReactNoop.createRoot();
464 await act(async () => {
465 root.render(<Details showMore={false} />);
466 // First render the outer component, without the hidden content
467 await waitForPaint(['Details']);
468 expect(root).toMatchRenderedOutput(<div>Main Content</div>);
469 });
470 // Then prerender the hidden content.
471 assertLog(['More', 'Image requested [More]']);
472 // The prerender should commit even though the image is still loading,
473 // because it's hidden.
474 expect(root).toMatchRenderedOutput(
475 <>
476 <div>Main Content</div>
477 <suspensey-thing hidden={true} src="More" />
478 </>,
479 );
480
481 // Reveal the prerendered content. This update should suspend, because the
482 // image that is being revealed still hasn't loaded.
483 await act(() => {
484 startTransition(() => {
485 root.render(<Details showMore={true} />);
486 });
487 });
488 // The More component should not render again, because it was memoized,
489 // and it already prerendered.
490 assertLog(['Details']);
491 expect(root).toMatchRenderedOutput(
492 <>
493 <div>Main Content</div>
494 <suspensey-thing hidden={true} src="More" />
495 </>,
496 );
497
498 // Now resolve the image. The transition should complete.
499 resolveSuspenseyThing('More');
500 expect(root).toMatchRenderedOutput(
501 <>
502 <div>Main Content</div>
503 <suspensey-thing src="More" />
504 </>,
505 );
506 });
507
508 // FIXME: Should pass with `enableYieldingBeforePassive`
509 // @gate !enableYieldingBeforePassive && enableSuspenseyImages
510 it('runs passive effects after suspended commit resolves', async () => {
511 function Effect() {
512 React.useEffect(() => {
513 Scheduler.log('flush effect');
514 });
515 return <Text text="render effect" />;
516 }
517
518 const root = ReactNoop.createRoot();
519
520 await act(() => {
521 root.render(
522 <Suspense fallback={<Text text="Loading..." />}>
523 <Effect />
524 <SuspenseyImage src="A" />
525 </Suspense>,
526 );
527 });
528
529 assertLog([
530 'render effect',
531 'Image requested [A]',
532 'Loading...',
533 'render effect',
534 ]);
535 expect(root).toMatchRenderedOutput('Loading...');
536
537 await act(() => {
538 resolveSuspenseyThing('A');
539 });
540
541 assertLog(['flush effect']);
542 expect(root).toMatchRenderedOutput(
543 <>
544 {'render effect'}
545 <suspensey-thing src="A" />
546 </>,
547 );
548 });
549 });