main
js 750 lines 20.8 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @emails react-core
8 * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
9 */
10
11 'use strict';
12 import {
13 insertNodesAndExecuteScripts,
14 getVisibleChildren,
15 } from '../test-utils/FizzTestUtils';
16
17 let JSDOM;
18 let React;
19 let Suspense;
20 let ViewTransition;
21 let ReactDOMClient;
22 let clientAct;
23 let ReactDOMFizzServer;
24 let Stream;
25 let document;
26 let writable;
27 let container;
28 let buffer = '';
29 let hasErrored = false;
30 let fatalError = undefined;
31
32 describe('ReactDOMFizzViewTransition', () => {
33 beforeEach(() => {
34 jest.resetModules();
35 JSDOM = require('jsdom').JSDOM;
36 React = require('react');
37 ReactDOMClient = require('react-dom/client');
38 clientAct = require('internal-test-utils').act;
39 ReactDOMFizzServer = require('react-dom/server');
40 Stream = require('stream');
41
42 Suspense = React.Suspense;
43 ViewTransition = React.ViewTransition;
44
45 // Test Environment
46 const jsdom = new JSDOM(
47 '<!DOCTYPE html><html><head></head><body><div id="container">',
48 {
49 runScripts: 'dangerously',
50 },
51 );
52 document = jsdom.window.document;
53 container = document.getElementById('container');
54 global.window = jsdom.window;
55 // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
56 global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
57 setTimeout(cb);
58
59 buffer = '';
60 hasErrored = false;
61
62 writable = new Stream.PassThrough();
63 writable.setEncoding('utf8');
64 writable.on('data', chunk => {
65 buffer += chunk;
66 });
67 writable.on('error', error => {
68 hasErrored = true;
69 fatalError = error;
70 });
71 });
72
73 afterEach(() => {
74 jest.restoreAllMocks();
75 });
76
77 async function serverAct(callback) {
78 await callback();
79 // Await one turn around the event loop.
80 // This assumes that we'll flush everything we have so far.
81 await new Promise(resolve => {
82 setImmediate(resolve);
83 });
84 if (hasErrored) {
85 throw fatalError;
86 }
87 // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
88 // We also want to execute any scripts that are embedded.
89 // We assume that we have now received a proper fragment of HTML.
90 const bufferedContent = buffer;
91 buffer = '';
92 const temp = document.createElement('body');
93 temp.innerHTML = bufferedContent;
94 await insertNodesAndExecuteScripts(temp, container, null);
95 jest.runAllTimers();
96 }
97
98 // @gate enableViewTransition
99 it('emits annotations for view transitions', async () => {
100 function App() {
101 return (
102 <div>
103 <ViewTransition>
104 <div />
105 </ViewTransition>
106 <ViewTransition name="foo" update="bar">
107 <div />
108 </ViewTransition>
109 <ViewTransition update={{something: 'a', default: 'baz'}}>
110 <div />
111 </ViewTransition>
112 <ViewTransition name="outer" update="bar" share="pair">
113 <ViewTransition>
114 <div />
115 </ViewTransition>
116 </ViewTransition>
117 </div>
118 );
119 }
120
121 await serverAct(async () => {
122 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
123 pipe(writable);
124 });
125
126 expect(getVisibleChildren(container)).toEqual(
127 <div>
128 <div vt-update="auto" />
129 <div vt-name="foo" vt-update="bar" vt-share="auto" />
130 <div vt-update="baz" />
131 <div vt-name="outer" vt-update="auto" vt-share="pair" />
132 </div>,
133 );
134
135 // Hydration should not yield any errors.
136 await clientAct(async () => {
137 ReactDOMClient.hydrateRoot(container, <App />);
138 });
139 });
140
141 // @gate enableViewTransition
142 it('emits enter/exit annotations for view transitions inside Suspense', async () => {
143 let resolve;
144 const promise = new Promise(r => (resolve = r));
145 function Suspend() {
146 return React.use(promise);
147 }
148 function App() {
149 const fallback = (
150 <ViewTransition>
151 <div>
152 <ViewTransition>
153 <span>Loading</span>
154 </ViewTransition>
155 </div>
156 </ViewTransition>
157 );
158 return (
159 <div>
160 <Suspense fallback={fallback}>
161 <ViewTransition>
162 <Suspend />
163 </ViewTransition>
164 </Suspense>
165 </div>
166 );
167 }
168
169 await serverAct(async () => {
170 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
171 pipe(writable);
172 });
173
174 expect(getVisibleChildren(container)).toEqual(
175 <div>
176 <div vt-update="auto" vt-exit="auto">
177 <span vt-update="auto">Loading</span>
178 </div>
179 </div>,
180 );
181
182 await serverAct(async () => {
183 await resolve(
184 <div>
185 <ViewTransition>
186 <span>Content</span>
187 </ViewTransition>
188 </div>,
189 );
190 });
191
192 expect(getVisibleChildren(container)).toEqual(
193 <div>
194 <div vt-update="auto" vt-enter="auto">
195 <span vt-update="auto">Content</span>
196 </div>
197 </div>,
198 );
199
200 // Hydration should not yield any errors.
201 await clientAct(async () => {
202 ReactDOMClient.hydrateRoot(container, <App />);
203 });
204 });
205
206 // @gate enableViewTransition
207 it('can emit both enter and exit on the same node', async () => {
208 let resolve;
209 const promise = new Promise(r => (resolve = r));
210 function Suspend() {
211 return React.use(promise);
212 }
213 function App() {
214 const fallback = (
215 <Suspense fallback={null}>
216 <ViewTransition enter="hello" exit="goodbye">
217 <div>
218 <ViewTransition>
219 <span>Loading</span>
220 </ViewTransition>
221 </div>
222 </ViewTransition>
223 </Suspense>
224 );
225 return (
226 <div>
227 <Suspense fallback={fallback}>
228 <ViewTransition enter="hi">
229 <Suspend />
230 </ViewTransition>
231 </Suspense>
232 </div>
233 );
234 }
235
236 await serverAct(async () => {
237 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
238 pipe(writable);
239 });
240
241 expect(getVisibleChildren(container)).toEqual(
242 <div>
243 <div vt-update="auto" vt-enter="hello" vt-exit="goodbye">
244 <span vt-update="auto">Loading</span>
245 </div>
246 </div>,
247 );
248
249 await serverAct(async () => {
250 await resolve(
251 <div>
252 <ViewTransition>
253 <span>Content</span>
254 </ViewTransition>
255 </div>,
256 );
257 });
258
259 expect(getVisibleChildren(container)).toEqual(
260 <div>
261 <div vt-update="auto" vt-enter="hi">
262 <span vt-update="auto">Content</span>
263 </div>
264 </div>,
265 );
266
267 // Hydration should not yield any errors.
268 await clientAct(async () => {
269 ReactDOMClient.hydrateRoot(container, <App />);
270 });
271 });
272
273 // @gate enableViewTransition
274 it('emits annotations for view transitions outside Suspense', async () => {
275 let resolve;
276 const promise = new Promise(r => (resolve = r));
277 function Suspend() {
278 return React.use(promise);
279 }
280 function App() {
281 const fallback = (
282 <div>
283 <ViewTransition>
284 <span>Loading</span>
285 </ViewTransition>
286 </div>
287 );
288 return (
289 <div>
290 <ViewTransition>
291 <Suspense fallback={fallback}>
292 <Suspend />
293 </Suspense>
294 </ViewTransition>
295 </div>
296 );
297 }
298
299 await serverAct(async () => {
300 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
301 pipe(writable);
302 });
303
304 expect(getVisibleChildren(container)).toEqual(
305 <div>
306 <div vt-name="_R_0_" vt-update="auto" vt-share="auto">
307 <span vt-update="auto">Loading</span>
308 </div>
309 </div>,
310 );
311
312 await serverAct(async () => {
313 await resolve(
314 <div>
315 <ViewTransition>
316 <span>Content</span>
317 </ViewTransition>
318 </div>,
319 );
320 });
321
322 expect(getVisibleChildren(container)).toEqual(
323 <div>
324 <div vt-name="_R_0_" vt-update="auto" vt-share="auto">
325 <span vt-update="auto">Content</span>
326 </div>
327 </div>,
328 );
329
330 // Hydration should not yield any errors.
331 await clientAct(async () => {
332 ReactDOMClient.hydrateRoot(container, <App />);
333 });
334 });
335
336 // @gate enableViewTransition && enableViewTransitionParentEnterExit
337 it('stops the parentExit relay when an intermediate class is "none"', async () => {
338 const promise = new Promise(() => {});
339 function Suspend() {
340 return React.use(promise);
341 }
342 function App() {
343 const fallback = (
344 <ViewTransition exit="page-exit">
345 <div id="A">
346 <ViewTransition parentExit="none">
347 <div id="B">
348 <ViewTransition parentExit="nested-exit">
349 <div id="C">Deep</div>
350 </ViewTransition>
351 </div>
352 </ViewTransition>
353 <ViewTransition parentExit="nested-exit">
354 <span id="D">Shallow</span>
355 </ViewTransition>
356 </div>
357 </ViewTransition>
358 );
359 return (
360 <div>
361 <Suspense fallback={fallback}>
362 <Suspend />
363 </Suspense>
364 </div>
365 );
366 }
367
368 await serverAct(async () => {
369 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
370 pipe(writable);
371 });
372
373 // The "none" on the wrapper stops the relay, so the deep child (C) never
374 // gets a vt-parent-exit annotation. The sibling (D), which is not behind a
375 // "none" boundary, still relays.
376 expect(getVisibleChildren(container)).toEqual(
377 <div>
378 <div id="A" vt-update="auto" vt-exit="page-exit">
379 <div id="B" vt-update="auto">
380 <div id="C" vt-update="auto">
381 Deep
382 </div>
383 </div>
384 <span id="D" vt-update="auto" vt-parent-exit="nested-exit">
385 Shallow
386 </span>
387 </div>
388 </div>,
389 );
390 });
391
392 // @gate enableViewTransition && enableViewTransitionParentEnterExit
393 it('stops the parentEnter relay when an intermediate class is "none"', async () => {
394 let resolve;
395 const promise = new Promise(r => (resolve = r));
396 function Suspend() {
397 return React.use(promise);
398 }
399 function App() {
400 return (
401 <div>
402 <Suspense fallback={<div>loading</div>}>
403 <ViewTransition enter="page-enter">
404 <Suspend />
405 </ViewTransition>
406 </Suspense>
407 </div>
408 );
409 }
410
411 await serverAct(async () => {
412 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
413 pipe(writable);
414 });
415 await serverAct(async () => {
416 await resolve(
417 <div id="A">
418 <ViewTransition parentEnter="none">
419 <div id="B">
420 <ViewTransition parentEnter="nested-enter">
421 <div id="C">Deep</div>
422 </ViewTransition>
423 </div>
424 </ViewTransition>
425 <ViewTransition parentEnter="nested-enter">
426 <span id="D">Shallow</span>
427 </ViewTransition>
428 </div>,
429 );
430 });
431
432 // The "none" on the wrapper stops the relay, so the deep child (C) never
433 // gets a vt-parent-enter annotation. The sibling (D) still relays.
434 expect(getVisibleChildren(container)).toEqual(
435 <div>
436 <div id="A" vt-update="auto" vt-enter="page-enter">
437 <div id="B" vt-update="auto">
438 <div id="C" vt-update="auto">
439 Deep
440 </div>
441 </div>
442 <span id="D" vt-update="auto" vt-parent-enter="nested-enter">
443 Shallow
444 </span>
445 </div>
446 </div>,
447 );
448
449 // Hydration should not yield any errors.
450 await clientAct(async () => {
451 ReactDOMClient.hydrateRoot(container, <App />);
452 });
453 });
454
455 // @gate enableViewTransition
456 it('breaks the parentExit relay through a ViewTransition without parentExit', async () => {
457 const promise = new Promise(() => {});
458 function Suspend() {
459 return React.use(promise);
460 }
461 function App() {
462 const fallback = (
463 <ViewTransition exit="page-exit">
464 <div id="A">
465 <ViewTransition>
466 <div id="B">
467 <ViewTransition parentExit="nested-exit">
468 <div id="C">Deep</div>
469 </ViewTransition>
470 </div>
471 </ViewTransition>
472 </div>
473 </ViewTransition>
474 );
475 return (
476 <div>
477 <Suspense fallback={fallback}>
478 <Suspend />
479 </Suspense>
480 </div>
481 );
482 }
483
484 await serverAct(async () => {
485 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
486 pipe(writable);
487 });
488
489 // A ViewTransition that doesn't opt in with parentExit breaks the relay,
490 // just like the client runtime, so C is not annotated.
491 expect(getVisibleChildren(container)).toEqual(
492 <div>
493 <div id="A" vt-update="auto" vt-exit="page-exit">
494 <div id="B" vt-update="auto">
495 <div id="C" vt-update="auto">
496 Deep
497 </div>
498 </div>
499 </div>
500 </div>,
501 );
502 });
503
504 // @gate enableViewTransition && enableViewTransitionParentEnterExit
505 it('relays the parentExit chain through an "auto" parentExit', async () => {
506 const promise = new Promise(() => {});
507 function Suspend() {
508 return React.use(promise);
509 }
510 function App() {
511 const fallback = (
512 <ViewTransition exit="page-exit">
513 <div id="A">
514 <ViewTransition parentExit="auto">
515 <div id="B">
516 <ViewTransition parentExit="nested-exit">
517 <div id="C">Deep</div>
518 </ViewTransition>
519 </div>
520 </ViewTransition>
521 </div>
522 </ViewTransition>
523 );
524 return (
525 <div>
526 <Suspense fallback={fallback}>
527 <Suspend />
528 </Suspense>
529 </div>
530 );
531 }
532
533 await serverAct(async () => {
534 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
535 pipe(writable);
536 });
537
538 // An "auto" parentExit emits no annotation of its own but still relays, so
539 // the deep child (C) is annotated.
540 expect(getVisibleChildren(container)).toEqual(
541 <div>
542 <div id="A" vt-update="auto" vt-exit="page-exit">
543 <div id="B" vt-update="auto">
544 <div id="C" vt-update="auto" vt-parent-exit="nested-exit">
545 Deep
546 </div>
547 </div>
548 </div>
549 </div>,
550 );
551 });
552
553 // @gate enableViewTransition && enableViewTransitionParentEnterExit
554 it('relays the parentExit chain through a handler-only ViewTransition', async () => {
555 const promise = new Promise(() => {});
556 function Suspend() {
557 return React.use(promise);
558 }
559 function App() {
560 const fallback = (
561 <ViewTransition exit="page-exit">
562 <div id="A">
563 <ViewTransition onParentExit={() => {}}>
564 <div id="B">
565 <ViewTransition parentExit="nested-exit">
566 <div id="C">Deep</div>
567 </ViewTransition>
568 </div>
569 </ViewTransition>
570 </div>
571 </ViewTransition>
572 );
573 return (
574 <div>
575 <Suspense fallback={fallback}>
576 <Suspend />
577 </Suspense>
578 </div>
579 );
580 }
581
582 await serverAct(async () => {
583 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
584 pipe(writable);
585 });
586
587 // A ViewTransition with only an onParentExit handler (no class) emits no
588 // annotation but still relays, just like the client runtime, so the deep
589 // child (C) is annotated.
590 expect(getVisibleChildren(container)).toEqual(
591 <div>
592 <div id="A" vt-update="auto" vt-exit="page-exit">
593 <div id="B" vt-update="auto">
594 <div id="C" vt-update="auto" vt-parent-exit="nested-exit">
595 Deep
596 </div>
597 </div>
598 </div>
599 </div>,
600 );
601 });
602
603 // @gate enableViewTransition && enableViewTransitionParentEnterExit
604 it('relays the parentEnter chain through a handler-only ViewTransition', async () => {
605 let resolve;
606 const promise = new Promise(r => (resolve = r));
607 function Suspend() {
608 return React.use(promise);
609 }
610 function App() {
611 return (
612 <div>
613 <Suspense fallback={<div>loading</div>}>
614 <ViewTransition enter="page-enter">
615 <Suspend />
616 </ViewTransition>
617 </Suspense>
618 </div>
619 );
620 }
621
622 await serverAct(async () => {
623 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
624 pipe(writable);
625 });
626 await serverAct(async () => {
627 await resolve(
628 <div id="A">
629 <ViewTransition onParentEnter={() => {}}>
630 <div id="B">
631 <ViewTransition parentEnter="nested-enter">
632 <div id="C">Deep</div>
633 </ViewTransition>
634 </div>
635 </ViewTransition>
636 </div>,
637 );
638 });
639
640 // A ViewTransition with only an onParentEnter handler still relays, so the
641 // deep child (C) is annotated.
642 expect(getVisibleChildren(container)).toEqual(
643 <div>
644 <div id="A" vt-update="auto" vt-enter="page-enter">
645 <div id="B" vt-update="auto">
646 <div id="C" vt-update="auto" vt-parent-enter="nested-enter">
647 Deep
648 </div>
649 </div>
650 </div>
651 </div>,
652 );
653 });
654
655 // @gate enableViewTransition && enableViewTransitionParentEnterExit
656 it('applies view-transition-name to nested parentEnter/parentExit on streaming reveal', async () => {
657 // Capture the view transition class applied to each element at the moment
658 // the reveal starts a view transition (the names are reverted once it
659 // begins, so we read them synchronously here).
660 const applied = new Map();
661 document.startViewTransition = function (arg) {
662 const update = typeof arg === 'function' ? arg : arg.update;
663 container.querySelectorAll('*').forEach(el => {
664 if (el.id && el.style && el.style.viewTransitionName) {
665 applied.set(el.id, el.style.viewTransitionClass);
666 }
667 });
668 if (update) {
669 update();
670 }
671 return {
672 ready: Promise.resolve(),
673 finished: Promise.resolve(),
674 skipTransition() {},
675 types: [],
676 };
677 };
678 if (!global.window.CSS) {
679 global.window.CSS = {escape: s => s};
680 }
681 Object.defineProperty(document, 'fonts', {
682 value: {status: 'loaded', ready: Promise.resolve()},
683 configurable: true,
684 });
685 // The reveal skips a boundary whose parent measures as empty, so give
686 // elements a non-zero rect.
687 global.window.Element.prototype.getBoundingClientRect = function () {
688 return {left: 0, top: 0, width: 100, height: 20, right: 100, bottom: 20};
689 };
690
691 let resolve;
692 const promise = new Promise(r => (resolve = r));
693 function Suspend() {
694 return React.use(promise);
695 }
696 function App() {
697 const fallback = (
698 <ViewTransition exit="page-exit">
699 <div id="fbA">
700 <ViewTransition parentExit="skeleton-exit">
701 <div id="fbC">
702 <ViewTransition parentExit="skeleton-exit-deep">
703 <div id="fbD">Loading</div>
704 </ViewTransition>
705 </div>
706 </ViewTransition>
707 </div>
708 </ViewTransition>
709 );
710 return (
711 <div>
712 <Suspense fallback={fallback}>
713 <ViewTransition enter="page-enter">
714 <Suspend />
715 </ViewTransition>
716 </Suspense>
717 </div>
718 );
719 }
720
721 await serverAct(async () => {
722 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
723 pipe(writable);
724 });
725 await serverAct(async () => {
726 await resolve(
727 <div id="A">
728 <ViewTransition parentEnter="nested-enter">
729 <div id="C">
730 <ViewTransition parentEnter="nested-enter-deep">
731 <div id="D">Deep</div>
732 </ViewTransition>
733 </div>
734 </ViewTransition>
735 </div>,
736 );
737 });
738
739 // Top-level enter/exit were already applied by the reveal. The nested parent
740 // relay is the new behavior, and it relays through multiple levels: the
741 // relay continues through C/fbC (non-none classes) down to D/fbD, matching
742 // the client runtime's commitParentEnter/ExitViewTransitions.
743 expect(applied.get('A')).toBe('page-enter');
744 expect(applied.get('C')).toBe('nested-enter');
745 expect(applied.get('D')).toBe('nested-enter-deep');
746 expect(applied.get('fbA')).toBe('page-exit');
747 expect(applied.get('fbC')).toBe('skeleton-exit');
748 expect(applied.get('fbD')).toBe('skeleton-exit-deep');
749 });
750 });