@samitouri / QOS-React-2 / commits / 3ef31d196a

Implement Partial Hydration for Activity (#32863)

Stacked on #32862 and #32842. This means that Activity boundaries now act as boundaries which can have their effects mounted independently. Just like Suspense boundaries, we hydrate the outer content first and then start hydrating the content in an Offscreen lane. Flowing props or interacting with the content increases the priority just like Suspense boundaries. This skips emitting even the comments for `<Activity mode="hidden">` so we don't hydrate those. Instead those are deferred to a later client render. The implementation are just forked copies of the SuspenseComponent branches and then carefully going through each line and tweaking it. The main interesting bit is that, unlike Suspense, Activity boundaries don't have fallbacks so all those branches where you might commit a suspended tree disappears. Instead, if something suspends while hydration, we can just leave the dehydrated content in place. However, if something does suspend during client rendering then it should bubble up to the parent. Therefore, we have to be careful to only pushSuspenseHandler when hydrating. That's really the main difference. This just uses the existing basic Activity tests but I've started work on port all of the applicable Suspense tests in SelectiveHydration-test and PartialHydration-test to Activity versions.

Sebastian Markbåge committed Apr 22, 2025 at 21:00 UTC 3ef31d196a83e45d4c70b300a265a9c657c386b4
18 files changed +5381 -76
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+1 -12
@@ -3746,11 +3746,7 @@ describe('ReactDOMServerPartialHydration', () => {
3746 <span>
3747 Visible
3748 </span>
3749 - <!--&-->
3750 - <!--/&-->
3749 <!--$-->
3752 - <!--&-->
3753 - <!--/&-->
3750 <!--/$-->
3751 </div>
3752 `);
@@ -3766,6 +3762,7 @@ describe('ReactDOMServerPartialHydration', () => {
3762 // Passive effects.
3763 await waitForPaint([]);
3764 }
3765 +
3766 // Subsequently, the hidden child is prerendered on the client
3767 // along with hydrating the Suspense boundary outside the Activity.
3768 await waitForPaint(['HiddenChild']);
@@ -3774,11 +3771,7 @@ describe('ReactDOMServerPartialHydration', () => {
3771 <span>
3772 Visible
3773 </span>
3777 - <!--&-->
3778 - <!--/&-->
3774 <!--$-->
3780 - <!--&-->
3781 - <!--/&-->
3775 <!--/$-->
3776 <span
3777 style="display: none;"
@@ -3796,11 +3789,7 @@ describe('ReactDOMServerPartialHydration', () => {
3789 <span>
3790 Visible
3791 </span>
3799 - <!--&-->
3800 - <!--/&-->
3792 <!--$-->
3802 - <!--&-->
3803 - <!--/&-->
3793 <!--/$-->
3794 <span
3795 style="display: none;"
packages/react-dom/src/__tests__/ReactDOMServerPartialHydrationActivity-test.internal.js new
+3014
@@ -0,0 +1,3014 @@
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 +
13 +let Activity;
14 +let React = require('react');
15 +let ReactDOM;
16 +let ReactDOMClient;
17 +let ReactDOMServer;
18 +let ReactFeatureFlags;
19 +let Scheduler;
20 +let Suspense;
21 +let useSyncExternalStore;
22 +let act;
23 +let IdleEventPriority;
24 +let waitForAll;
25 +let waitFor;
26 +let assertLog;
27 +let assertConsoleErrorDev;
28 +
29 +function normalizeError(msg) {
30 + // Take the first sentence to make it easier to assert on.
31 + const idx = msg.indexOf('.');
32 + if (idx > -1) {
33 + return msg.slice(0, idx + 1);
34 + }
35 + return msg;
36 +}
37 +
38 +function dispatchMouseEvent(to, from) {
39 + if (!to) {
40 + to = null;
41 + }
42 + if (!from) {
43 + from = null;
44 + }
45 + if (from) {
46 + const mouseOutEvent = document.createEvent('MouseEvents');
47 + mouseOutEvent.initMouseEvent(
48 + 'mouseout',
49 + true,
50 + true,
51 + window,
52 + 0,
53 + 50,
54 + 50,
55 + 50,
56 + 50,
57 + false,
58 + false,
59 + false,
60 + false,
61 + 0,
62 + to,
63 + );
64 + from.dispatchEvent(mouseOutEvent);
65 + }
66 + if (to) {
67 + const mouseOverEvent = document.createEvent('MouseEvents');
68 + mouseOverEvent.initMouseEvent(
69 + 'mouseover',
70 + true,
71 + true,
72 + window,
73 + 0,
74 + 50,
75 + 50,
76 + 50,
77 + 50,
78 + false,
79 + false,
80 + false,
81 + false,
82 + 0,
83 + from,
84 + );
85 + to.dispatchEvent(mouseOverEvent);
86 + }
87 +}
88 +
89 +describe('ReactDOMServerPartialHydrationActivity', () => {
90 + beforeEach(() => {
91 + jest.resetModules();
92 +
93 + ReactFeatureFlags = require('shared/ReactFeatureFlags');
94 + ReactFeatureFlags.enableSuspenseCallback = true;
95 + ReactFeatureFlags.enableCreateEventHandleAPI = true;
96 +
97 + React = require('react');
98 + ReactDOM = require('react-dom');
99 + ReactDOMClient = require('react-dom/client');
100 + act = require('internal-test-utils').act;
101 + ReactDOMServer = require('react-dom/server');
102 + Scheduler = require('scheduler');
103 + Activity = React.unstable_Activity;
104 + Suspense = React.Suspense;
105 + useSyncExternalStore = React.useSyncExternalStore;
106 +
107 + const InternalTestUtils = require('internal-test-utils');
108 + waitForAll = InternalTestUtils.waitForAll;
109 + assertLog = InternalTestUtils.assertLog;
110 + waitFor = InternalTestUtils.waitFor;
111 + assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
112 +
113 + IdleEventPriority = require('react-reconciler/constants').IdleEventPriority;
114 + });
115 +
116 + // @gate enableActivity
117 + it('hydrates a parent even if a child Activity boundary is blocked', async () => {
118 + let suspend = false;
119 + let resolve;
120 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
121 + const ref = React.createRef();
122 +
123 + function Child() {
124 + if (suspend) {
125 + throw promise;
126 + } else {
127 + return 'Hello';
128 + }
129 + }
130 +
131 + function App() {
132 + return (
133 + <div>
134 + <Activity>
135 + <span ref={ref}>
136 + <Child />
137 + </span>
138 + </Activity>
139 + </div>
140 + );
141 + }
142 +
143 + // First we render the final HTML. With the streaming renderer
144 + // this may have suspense points on the server but here we want
145 + // to test the completed HTML. Don't suspend on the server.
146 + suspend = false;
147 + const finalHTML = ReactDOMServer.renderToString(<App />);
148 +
149 + const container = document.createElement('div');
150 + container.innerHTML = finalHTML;
151 +
152 + const span = container.getElementsByTagName('span')[0];
153 +
154 + // On the client we don't have all data yet but we want to start
155 + // hydrating anyway.
156 + suspend = true;
157 + ReactDOMClient.hydrateRoot(container, <App />);
158 + await waitForAll([]);
159 +
160 + expect(ref.current).toBe(null);
161 +
162 + // Resolving the promise should continue hydration
163 + suspend = false;
164 + resolve();
165 + await promise;
166 + await waitForAll([]);
167 +
168 + // We should now have hydrated with a ref on the existing span.
169 + expect(ref.current).toBe(span);
170 + });
171 +
172 + // @gate enableActivity
173 + it('can hydrate siblings of a suspended component without errors', async () => {
174 + let suspend = false;
175 + let resolve;
176 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
177 + function Child() {
178 + if (suspend) {
179 + throw promise;
180 + } else {
181 + return 'Hello';
182 + }
183 + }
184 +
185 + function App() {
186 + return (
187 + <Activity>
188 + <Child />
189 + <Activity>
190 + <div>Hello</div>
191 + </Activity>
192 + </Activity>
193 + );
194 + }
195 +
196 + // First we render the final HTML. With the streaming renderer
197 + // this may have suspense points on the server but here we want
198 + // to test the completed HTML. Don't suspend on the server.
199 + suspend = false;
200 + const finalHTML = ReactDOMServer.renderToString(<App />);
201 +
202 + const container = document.createElement('div');
203 + container.innerHTML = finalHTML;
204 + expect(container.textContent).toBe('HelloHello');
205 +
206 + // On the client we don't have all data yet but we want to start
207 + // hydrating anyway.
208 + suspend = true;
209 + ReactDOMClient.hydrateRoot(container, <App />, {
210 + onRecoverableError(error) {
211 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
212 + if (error.cause) {
213 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
214 + }
215 + },
216 + });
217 + await waitForAll([]);
218 +
219 + // Expect the server-generated HTML to stay intact.
220 + expect(container.textContent).toBe('HelloHello');
221 +
222 + // Resolving the promise should continue hydration
223 + suspend = false;
224 + resolve();
225 + await promise;
226 + await waitForAll([]);
227 + // Hydration should not change anything.
228 + expect(container.textContent).toBe('HelloHello');
229 + });
230 +
231 + // @gate enableActivity
232 + it('falls back to client rendering boundary on mismatch', async () => {
233 + let client = false;
234 + let suspend = false;
235 + let resolve;
236 + const promise = new Promise(resolvePromise => {
237 + resolve = () => {
238 + suspend = false;
239 + resolvePromise();
240 + };
241 + });
242 + function Child() {
243 + if (suspend) {
244 + Scheduler.log('Suspend');
245 + throw promise;
246 + } else {
247 + Scheduler.log('Hello');
248 + return 'Hello';
249 + }
250 + }
251 + function Component({shouldMismatch}) {
252 + Scheduler.log('Component');
253 + if (shouldMismatch && client) {
254 + return <article>Mismatch</article>;
255 + }
256 + return <div>Component</div>;
257 + }
258 + function App() {
259 + return (
260 + <Activity>
261 + <Child />
262 + <Component />
263 + <Component />
264 + <Component />
265 + <Component shouldMismatch={true} />
266 + </Activity>
267 + );
268 + }
269 + const finalHTML = ReactDOMServer.renderToString(<App />);
270 + const container = document.createElement('section');
271 + container.innerHTML = finalHTML;
272 + assertLog(['Hello', 'Component', 'Component', 'Component', 'Component']);
273 +
274 + expect(container.innerHTML).toBe(
275 + '<!--&-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/&-->',
276 + );
277 +
278 + suspend = true;
279 + client = true;
280 +
281 + ReactDOMClient.hydrateRoot(container, <App />, {
282 + onRecoverableError(error) {
283 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
284 + if (error.cause) {
285 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
286 + }
287 + },
288 + });
289 + await waitForAll(['Suspend']);
290 + jest.runAllTimers();
291 +
292 + // Unchanged
293 + expect(container.innerHTML).toBe(
294 + '<!--&-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/&-->',
295 + );
296 +
297 + suspend = false;
298 + resolve();
299 + await promise;
300 + await waitForAll([
301 + // first pass, mismatches at end
302 + 'Hello',
303 + 'Component',
304 + 'Component',
305 + 'Component',
306 + 'Component',
307 +
308 + // second pass as client render
309 + 'Hello',
310 + 'Component',
311 + 'Component',
312 + 'Component',
313 + 'Component',
314 + // Hydration mismatch is logged
315 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
316 + ]);
317 +
318 + // Client rendered - suspense comment nodes removed
319 + expect(container.innerHTML).toBe(
320 + 'Hello<div>Component</div><div>Component</div><div>Component</div><article>Mismatch</article>',
321 + );
322 + });
323 +
324 + // @gate enableActivity
325 + it('handles if mismatch is after suspending', async () => {
326 + let client = false;
327 + let suspend = false;
328 + let resolve;
329 + const promise = new Promise(resolvePromise => {
330 + resolve = () => {
331 + suspend = false;
332 + resolvePromise();
333 + };
334 + });
335 + function Child() {
336 + if (suspend) {
337 + Scheduler.log('Suspend');
338 + throw promise;
339 + } else {
340 + Scheduler.log('Hello');
341 + return 'Hello';
342 + }
343 + }
344 + function Component({shouldMismatch}) {
345 + Scheduler.log('Component');
346 + if (shouldMismatch && client) {
347 + return <article>Mismatch</article>;
348 + }
349 + return <div>Component</div>;
350 + }
351 + function App() {
352 + return (
353 + <Activity>
354 + <Child />
355 + <Component shouldMismatch={true} />
356 + </Activity>
357 + );
358 + }
359 + const finalHTML = ReactDOMServer.renderToString(<App />);
360 + const container = document.createElement('section');
361 + container.innerHTML = finalHTML;
362 + assertLog(['Hello', 'Component']);
363 +
364 + expect(container.innerHTML).toBe(
365 + '<!--&-->Hello<div>Component</div><!--/&-->',
366 + );
367 +
368 + suspend = true;
369 + client = true;
370 +
371 + ReactDOMClient.hydrateRoot(container, <App />, {
372 + onRecoverableError(error) {
373 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
374 + if (error.cause) {
375 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
376 + }
377 + },
378 + });
379 + await waitForAll(['Suspend']);
380 + jest.runAllTimers();
381 +
382 + // !! Unchanged, continue showing server content while suspended.
383 + expect(container.innerHTML).toBe(
384 + '<!--&-->Hello<div>Component</div><!--/&-->',
385 + );
386 +
387 + suspend = false;
388 + resolve();
389 + await promise;
390 + await waitForAll([
391 + // first pass, mismatches at end
392 + 'Hello',
393 + 'Component',
394 + 'Hello',
395 + 'Component',
396 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
397 + ]);
398 + jest.runAllTimers();
399 +
400 + // Client rendered - suspense comment nodes removed.
401 + expect(container.innerHTML).toBe('Hello<article>Mismatch</article>');
402 + });
403 +
404 + // @gate enableActivity
405 + it('handles if mismatch is child of suspended component', async () => {
406 + let client = false;
407 + let suspend = false;
408 + let resolve;
409 + const promise = new Promise(resolvePromise => {
410 + resolve = () => {
411 + suspend = false;
412 + resolvePromise();
413 + };
414 + });
415 + function Child({children}) {
416 + if (suspend) {
417 + Scheduler.log('Suspend');
418 + throw promise;
419 + } else {
420 + Scheduler.log('Hello');
421 + return <div>{children}</div>;
422 + }
423 + }
424 + function Component({shouldMismatch}) {
425 + Scheduler.log('Component');
426 + if (shouldMismatch && client) {
427 + return <article>Mismatch</article>;
428 + }
429 + return <div>Component</div>;
430 + }
431 + function App() {
432 + return (
433 + <Activity>
434 + <Child>
435 + <Component shouldMismatch={true} />
436 + </Child>
437 + </Activity>
438 + );
439 + }
440 + const finalHTML = ReactDOMServer.renderToString(<App />);
441 + const container = document.createElement('section');
442 + container.innerHTML = finalHTML;
443 + assertLog(['Hello', 'Component']);
444 +
445 + expect(container.innerHTML).toBe(
446 + '<!--&--><div><div>Component</div></div><!--/&-->',
447 + );
448 +
449 + suspend = true;
450 + client = true;
451 +
452 + ReactDOMClient.hydrateRoot(container, <App />, {
453 + onRecoverableError(error) {
454 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
455 + if (error.cause) {
456 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
457 + }
458 + },
459 + });
460 + await waitForAll(['Suspend']);
461 + jest.runAllTimers();
462 +
463 + // !! Unchanged, continue showing server content while suspended.
464 + expect(container.innerHTML).toBe(
465 + '<!--&--><div><div>Component</div></div><!--/&-->',
466 + );
467 +
468 + suspend = false;
469 + resolve();
470 + await promise;
471 + await waitForAll([
472 + // first pass, mismatches at end
473 + 'Hello',
474 + 'Component',
475 + 'Hello',
476 + 'Component',
477 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
478 + ]);
479 + jest.runAllTimers();
480 +
481 + // Client rendered - suspense comment nodes removed
482 + expect(container.innerHTML).toBe('<div><article>Mismatch</article></div>');
483 + });
484 +
485 + // @gate enableActivity
486 + it('handles if mismatch is parent and first child suspends', async () => {
487 + let client = false;
488 + let suspend = false;
489 + let resolve;
490 + const promise = new Promise(resolvePromise => {
491 + resolve = () => {
492 + suspend = false;
493 + resolvePromise();
494 + };
495 + });
496 + function Child({children}) {
497 + if (suspend) {
498 + Scheduler.log('Suspend');
499 + throw promise;
500 + } else {
501 + Scheduler.log('Hello');
502 + return <div>{children}</div>;
503 + }
504 + }
505 + function Component({shouldMismatch, children}) {
506 + Scheduler.log('Component');
507 + if (shouldMismatch && client) {
508 + return (
509 + <div>
510 + {children}
511 + <article>Mismatch</article>
512 + </div>
513 + );
514 + }
515 + return (
516 + <div>
517 + {children}
518 + <div>Component</div>
519 + </div>
520 + );
521 + }
522 + function App() {
523 + return (
524 + <Activity>
525 + <Component shouldMismatch={true}>
526 + <Child />
527 + </Component>
528 + </Activity>
529 + );
530 + }
531 + const finalHTML = ReactDOMServer.renderToString(<App />);
532 + const container = document.createElement('section');
533 + container.innerHTML = finalHTML;
534 + assertLog(['Component', 'Hello']);
535 +
536 + expect(container.innerHTML).toBe(
537 + '<!--&--><div><div></div><div>Component</div></div><!--/&-->',
538 + );
539 +
540 + suspend = true;
541 + client = true;
542 +
543 + ReactDOMClient.hydrateRoot(container, <App />, {
544 + onRecoverableError(error) {
545 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
546 + if (error.cause) {
547 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
548 + }
549 + },
550 + });
551 + await waitForAll(['Component', 'Suspend']);
552 + jest.runAllTimers();
553 +
554 + // !! Unchanged, continue showing server content while suspended.
555 + expect(container.innerHTML).toBe(
556 + '<!--&--><div><div></div><div>Component</div></div><!--/&-->',
557 + );
558 +
559 + suspend = false;
560 + resolve();
561 + await promise;
562 + await waitForAll([
563 + // first pass, mismatches at end
564 + 'Component',
565 + 'Hello',
566 + 'Component',
567 + 'Hello',
568 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
569 + ]);
570 + jest.runAllTimers();
571 +
572 + // Client rendered - suspense comment nodes removed
573 + expect(container.innerHTML).toBe(
574 + '<div><div></div><article>Mismatch</article></div>',
575 + );
576 + });
577 +
578 + // @gate enableActivity
579 + it('does show a parent fallback if mismatch is parent and second child suspends', async () => {
580 + let client = false;
581 + let suspend = false;
582 + let resolve;
583 + const promise = new Promise(resolvePromise => {
584 + resolve = () => {
585 + suspend = false;
586 + resolvePromise();
587 + };
588 + });
589 + function Child({children}) {
590 + if (suspend) {
591 + Scheduler.log('Suspend');
592 + throw promise;
593 + } else {
594 + Scheduler.log('Hello');
595 + return <div>{children}</div>;
596 + }
597 + }
598 + function Component({shouldMismatch, children}) {
599 + Scheduler.log('Component');
600 + if (shouldMismatch && client) {
601 + return (
602 + <div>
603 + <article>Mismatch</article>
604 + {children}
605 + </div>
606 + );
607 + }
608 + return (
609 + <div>
610 + <div>Component</div>
611 + {children}
612 + </div>
613 + );
614 + }
615 + function Fallback() {
616 + Scheduler.log('Fallback');
617 + return 'Loading...';
618 + }
619 + function App() {
620 + return (
621 + <Suspense fallback={<Fallback />}>
622 + <Activity>
623 + <Component shouldMismatch={true}>
624 + <Child />
625 + </Component>
626 + </Activity>
627 + </Suspense>
628 + );
629 + }
630 + const finalHTML = ReactDOMServer.renderToString(<App />);
631 + const container = document.createElement('section');
632 + container.innerHTML = finalHTML;
633 + assertLog(['Component', 'Hello']);
634 +
635 + const div = container.getElementsByTagName('div')[0];
636 +
637 + expect(container.innerHTML).toBe(
638 + '<!--$--><!--&--><div><div>Component</div><div></div></div><!--/&--><!--/$-->',
639 + );
640 +
641 + suspend = true;
642 + client = true;
643 +
644 + ReactDOMClient.hydrateRoot(container, <App />, {
645 + onRecoverableError(error) {
646 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
647 + if (error.cause) {
648 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
649 + }
650 + },
651 + });
652 + await waitForAll(['Component', 'Component', 'Suspend', 'Fallback']);
653 + jest.runAllTimers();
654 +
655 + // !! Client switches to suspense fallback. The dehydrated content is still hidden because we never
656 + // committed the client rendering.
657 + expect(container.innerHTML).toBe(
658 + '<!--$--><!--&--><div style="display: none;"><div>Component</div><div></div></div><!--/&--><!--/$-->' +
659 + 'Loading...',
660 + );
661 +
662 + suspend = false;
663 + resolve();
664 + await promise;
665 + if (gate(flags => flags.alwaysThrottleRetries)) {
666 + await waitForAll(['Component', 'Component', 'Hello']);
667 + } else {
668 + await waitForAll([
669 + 'Component',
670 + 'Component',
671 + 'Hello',
672 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
673 + ]);
674 + }
675 + jest.runAllTimers();
676 +
677 + // Now that we've hit the throttle timeout, we can commit the failed hydration.
678 + if (gate(flags => flags.alwaysThrottleRetries)) {
679 + assertLog([
680 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
681 + ]);
682 + }
683 +
684 + // Client rendered - activity comment nodes removed
685 + expect(container.innerHTML).toBe(
686 + '<!--$--><!--/$--><div><article>Mismatch</article><div></div></div>',
687 + );
688 + });
689 +
690 + // @gate enableActivity
691 + it('does show a parent fallback if mismatch is in parent element only', async () => {
692 + let client = false;
693 + let suspend = false;
694 + let resolve;
695 + const promise = new Promise(resolvePromise => {
696 + resolve = () => {
697 + suspend = false;
698 + resolvePromise();
699 + };
700 + });
701 + function Child({children}) {
702 + if (suspend) {
703 + Scheduler.log('Suspend');
704 + throw promise;
705 + } else {
706 + Scheduler.log('Hello');
707 + return <div>{children}</div>;
708 + }
709 + }
710 + function Component({shouldMismatch, children}) {
711 + Scheduler.log('Component');
712 + if (shouldMismatch && client) {
713 + return <article>{children}</article>;
714 + }
715 + return <div>{children}</div>;
716 + }
717 + function Fallback() {
718 + Scheduler.log('Fallback');
719 + return 'Loading...';
720 + }
721 + function App() {
722 + return (
723 + <Suspense fallback={<Fallback />}>
724 + <Activity>
725 + <Component shouldMismatch={true}>
726 + <Child />
727 + </Component>
728 + </Activity>
729 + </Suspense>
730 + );
731 + }
732 + const finalHTML = ReactDOMServer.renderToString(<App />);
733 + const container = document.createElement('section');
734 + container.innerHTML = finalHTML;
735 + assertLog(['Component', 'Hello']);
736 +
737 + expect(container.innerHTML).toBe(
738 + '<!--$--><!--&--><div><div></div></div><!--/&--><!--/$-->',
739 + );
740 +
741 + suspend = true;
742 + client = true;
743 +
744 + ReactDOMClient.hydrateRoot(container, <App />, {
745 + onRecoverableError(error) {
746 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
747 + if (error.cause) {
748 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
749 + }
750 + },
751 + });
752 + await waitForAll(['Component', 'Component', 'Suspend', 'Fallback']);
753 + jest.runAllTimers();
754 +
755 + // !! Client switches to suspense fallback. The dehydrated content is still hidden because we never
756 + // committed the client rendering.
757 + expect(container.innerHTML).toBe(
758 + '<!--$--><!--&--><div style="display: none;"><div></div></div><!--/&--><!--/$-->' +
759 + 'Loading...',
760 + );
761 +
762 + suspend = false;
763 + resolve();
764 + await promise;
765 + if (gate(flags => flags.alwaysThrottleRetries)) {
766 + await waitForAll(['Component', 'Component', 'Hello']);
767 + } else {
768 + await waitForAll([
769 + 'Component',
770 + 'Component',
771 + 'Hello',
772 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
773 + ]);
774 + }
775 + jest.runAllTimers();
776 +
777 + // Now that we've hit the throttle timeout, we can commit the failed hydration.
778 + if (gate(flags => flags.alwaysThrottleRetries)) {
779 + assertLog([
780 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
781 + ]);
782 + }
783 +
784 + // Client rendered - activity comment nodes removed
785 + expect(container.innerHTML).toBe(
786 + '<!--$--><!--/$--><article><div></div></article>',
787 + );
788 + });
789 +
790 + // @gate enableActivity
791 + it('does show a parent fallback if mismatch is before suspending', async () => {
792 + let client = false;
793 + let suspend = false;
794 + let resolve;
795 + const promise = new Promise(resolvePromise => {
796 + resolve = () => {
797 + suspend = false;
798 + resolvePromise();
799 + };
800 + });
801 + function Child() {
802 + if (suspend) {
803 + Scheduler.log('Suspend');
804 + throw promise;
805 + } else {
806 + Scheduler.log('Hello');
807 + return 'Hello';
808 + }
809 + }
810 + function Component({shouldMismatch}) {
811 + Scheduler.log('Component');
812 + if (shouldMismatch && client) {
813 + return <article>Mismatch</article>;
814 + }
815 + return <div>Component</div>;
816 + }
817 + function Fallback() {
818 + Scheduler.log('Fallback');
819 + return 'Loading...';
820 + }
821 + function App() {
822 + return (
823 + <Suspense fallback={<Fallback />}>
824 + <Activity>
825 + <Component shouldMismatch={true} />
826 + <Child />
827 + </Activity>
828 + </Suspense>
829 + );
830 + }
831 + const finalHTML = ReactDOMServer.renderToString(<App />);
832 + const container = document.createElement('section');
833 + container.innerHTML = finalHTML;
834 + assertLog(['Component', 'Hello']);
835 +
836 + expect(container.innerHTML).toBe(
837 + '<!--$--><!--&--><div>Component</div>Hello<!--/&--><!--/$-->',
838 + );
839 +
840 + suspend = true;
841 + client = true;
842 +
843 + ReactDOMClient.hydrateRoot(container, <App />, {
844 + onRecoverableError(error) {
845 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
846 + if (error.cause) {
847 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
848 + }
849 + },
850 + });
851 + await waitForAll(['Component', 'Component', 'Suspend', 'Fallback']);
852 + jest.runAllTimers();
853 +
854 + // !! Client switches to suspense fallback. The dehydrated content is still hidden because we never
855 + // committed the client rendering.
856 + expect(container.innerHTML).toBe(
857 + '<!--$--><!--&--><div style="display: none;">Component</div><!--/&--><!--/$-->' +
858 + 'Loading...',
859 + );
860 +
861 + suspend = false;
862 + resolve();
863 + await promise;
864 + if (gate(flags => flags.alwaysThrottleRetries)) {
865 + await waitForAll(['Component', 'Component', 'Hello']);
866 + } else {
867 + await waitForAll([
868 + 'Component',
869 + 'Component',
870 + 'Hello',
871 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
872 + ]);
873 + }
874 + jest.runAllTimers();
875 +
876 + // Now that we've hit the throttle timeout, we can commit the failed hydration.
877 + if (gate(flags => flags.alwaysThrottleRetries)) {
878 + assertLog([
879 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
880 + ]);
881 + }
882 +
883 + // Client rendered - activity comment nodes removed
884 + expect(container.innerHTML).toBe(
885 + '<!--$--><!--/$--><article>Mismatch</article>Hello',
886 + );
887 + });
888 +
889 + // @gate enableActivity
890 + it('does show a parent fallback if mismatch is before suspending in a child', async () => {
891 + let client = false;
892 + let suspend = false;
893 + let resolve;
894 + const promise = new Promise(resolvePromise => {
895 + resolve = () => {
896 + suspend = false;
897 + resolvePromise();
898 + };
899 + });
900 + function Child() {
901 + if (suspend) {
902 + Scheduler.log('Suspend');
903 + throw promise;
904 + } else {
905 + Scheduler.log('Hello');
906 + return 'Hello';
907 + }
908 + }
909 + function Component({shouldMismatch}) {
910 + Scheduler.log('Component');
911 + if (shouldMismatch && client) {
912 + return <article>Mismatch</article>;
913 + }
914 + return <div>Component</div>;
915 + }
916 + function Fallback() {
917 + Scheduler.log('Fallback');
918 + return 'Loading...';
919 + }
920 + function App() {
921 + return (
922 + <Suspense fallback={<Fallback />}>
923 + <Activity>
924 + <Component shouldMismatch={true} />
925 + <div>
926 + <Child />
927 + </div>
928 + </Activity>
929 + </Suspense>
930 + );
931 + }
932 + const finalHTML = ReactDOMServer.renderToString(<App />);
933 + const container = document.createElement('section');
934 + container.innerHTML = finalHTML;
935 + assertLog(['Component', 'Hello']);
936 +
937 + expect(container.innerHTML).toBe(
938 + '<!--$--><!--&--><div>Component</div><div>Hello</div><!--/&--><!--/$-->',
939 + );
940 +
941 + suspend = true;
942 + client = true;
943 +
944 + ReactDOMClient.hydrateRoot(container, <App />, {
945 + onRecoverableError(error) {
946 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
947 + if (error.cause) {
948 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
949 + }
950 + },
951 + });
952 + await waitForAll(['Component', 'Component', 'Suspend', 'Fallback']);
953 + jest.runAllTimers();
954 +
955 + // !! Client switches to suspense fallback. The dehydrated content is still hidden because we never
956 + // committed the client rendering.
957 + expect(container.innerHTML).toBe(
958 + '<!--$--><!--&--><div style="display: none;">Component</div><div style="display: none;">Hello</div><!--/&--><!--/$-->' +
959 + 'Loading...',
960 + );
961 +
962 + suspend = false;
963 + resolve();
964 + await promise;
965 + if (gate(flags => flags.alwaysThrottleRetries)) {
966 + await waitForAll(['Component', 'Component', 'Hello']);
967 + } else {
968 + await waitForAll([
969 + 'Component',
970 + 'Component',
971 + 'Hello',
972 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
973 + ]);
974 + }
975 + jest.runAllTimers();
976 +
977 + // Now that we've hit the throttle timeout, we can commit the failed hydration.
978 + if (gate(flags => flags.alwaysThrottleRetries)) {
979 + assertLog([
980 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
981 + ]);
982 + }
983 +
984 + // Client rendered - activity comment nodes removed
985 + expect(container.innerHTML).toBe(
986 + '<!--$--><!--/$--><article>Mismatch</article><div>Hello</div>',
987 + );
988 + });
989 +
990 + // @gate enableActivity
991 + it('calls the hydration callbacks after hydration or deletion', async () => {
992 + let suspend = false;
993 + let resolve;
994 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
995 + function Child() {
996 + if (suspend) {
997 + throw promise;
998 + } else {
999 + return 'Hello';
1000 + }
1001 + }
1002 +
1003 + let suspend2 = false;
1004 + const promise2 = new Promise(() => {});
1005 + function Child2({value}) {
1006 + if (suspend2 && !value) {
1007 + throw promise2;
1008 + } else {
1009 + return 'World';
1010 + }
1011 + }
1012 +
1013 + function App({value}) {
1014 + return (
1015 + <div>
1016 + <Activity>
1017 + <Child />
1018 + </Activity>
1019 + <Activity>
1020 + <Child2 value={value} />
1021 + </Activity>
1022 + </div>
1023 + );
1024 + }
1025 +
1026 + // First we render the final HTML. With the streaming renderer
1027 + // this may have suspense points on the server but here we want
1028 + // to test the completed HTML. Don't suspend on the server.
1029 + suspend = false;
1030 + suspend2 = false;
1031 + const finalHTML = ReactDOMServer.renderToString(<App />);
1032 +
1033 + const container = document.createElement('div');
1034 + container.innerHTML = finalHTML;
1035 +
1036 + const hydrated = [];
1037 + const deleted = [];
1038 +
1039 + // On the client we don't have all data yet but we want to start
1040 + // hydrating anyway.
1041 + suspend = true;
1042 + suspend2 = true;
1043 + const root = ReactDOMClient.hydrateRoot(container, <App />, {
1044 + onHydrated(node) {
1045 + hydrated.push(node);
1046 + },
1047 + onDeleted(node) {
1048 + deleted.push(node);
1049 + },
1050 + onRecoverableError(error) {
1051 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
1052 + if (error.cause) {
1053 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
1054 + }
1055 + },
1056 + });
1057 + await waitForAll([]);
1058 +
1059 + expect(hydrated.length).toBe(0);
1060 + expect(deleted.length).toBe(0);
1061 +
1062 + await act(async () => {
1063 + // Resolving the promise should continue hydration
1064 + suspend = false;
1065 + resolve();
1066 + await promise;
1067 + });
1068 +
1069 + expect(hydrated.length).toBe(1);
1070 + expect(deleted.length).toBe(0);
1071 +
1072 + // Performing an update should force it to delete the boundary if
1073 + // it could be unsuspended by the update.
1074 + await act(() => {
1075 + root.render(<App value={true} />);
1076 + });
1077 +
1078 + expect(hydrated.length).toBe(1);
1079 + expect(deleted.length).toBe(1);
1080 + });
1081 +
1082 + // @gate enableActivity
1083 + it('hydrates an empty activity boundary', async () => {
1084 + function App() {
1085 + return (
1086 + <div>
1087 + <Activity />
1088 + <div>Sibling</div>
1089 + </div>
1090 + );
1091 + }
1092 +
1093 + const finalHTML = ReactDOMServer.renderToString(<App />);
1094 +
1095 + const container = document.createElement('div');
1096 + container.innerHTML = finalHTML;
1097 +
1098 + ReactDOMClient.hydrateRoot(container, <App />);
1099 + await waitForAll([]);
1100 +
1101 + expect(container.innerHTML).toContain('<div>Sibling</div>');
1102 + });
1103 +
1104 + // @gate enableActivity
1105 + it('recovers with client render when server rendered additional nodes at suspense root', async () => {
1106 + function CheckIfHydrating({children}) {
1107 + // This is a trick to check whether we're hydrating or not, since React
1108 + // doesn't expose that information currently except
1109 + // via useSyncExternalStore.
1110 + let serverOrClient = '(unknown)';
1111 + useSyncExternalStore(
1112 + () => {},
1113 + () => {
1114 + serverOrClient = 'Client rendered';
1115 + return null;
1116 + },
1117 + () => {
1118 + serverOrClient = 'Server rendered';
1119 + return null;
1120 + },
1121 + );
1122 + Scheduler.log(serverOrClient);
1123 + return null;
1124 + }
1125 +
1126 + const ref = React.createRef();
1127 + function App({hasB}) {
1128 + return (
1129 + <div>
1130 + <Activity>
1131 + <span ref={ref}>A</span>
1132 + {hasB ? <span>B</span> : null}
1133 + <CheckIfHydrating />
1134 + </Activity>
1135 + <div>Sibling</div>
1136 + </div>
1137 + );
1138 + }
1139 +
1140 + const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);
1141 + assertLog(['Server rendered']);
1142 +
1143 + const container = document.createElement('div');
1144 + container.innerHTML = finalHTML;
1145 +
1146 + const span = container.getElementsByTagName('span')[0];
1147 +
1148 + expect(container.innerHTML).toContain('<span>A</span>');
1149 + expect(container.innerHTML).toContain('<span>B</span>');
1150 + expect(ref.current).toBe(null);
1151 +
1152 + await act(() => {
1153 + ReactDOMClient.hydrateRoot(container, <App hasB={false} />, {
1154 + onRecoverableError(error) {
1155 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
1156 + if (error.cause) {
1157 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
1158 + }
1159 + },
1160 + });
1161 + });
1162 +
1163 + expect(container.innerHTML).toContain('<span>A</span>');
1164 + expect(container.innerHTML).not.toContain('<span>B</span>');
1165 +
1166 + assertLog([
1167 + 'Server rendered',
1168 + 'Client rendered',
1169 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
1170 + ]);
1171 + expect(ref.current).not.toBe(span);
1172 + });
1173 +
1174 + // @gate enableActivity
1175 + it('recovers with client render when server rendered additional nodes at suspense root after unsuspending', async () => {
1176 + const ref = React.createRef();
1177 + let shouldSuspend = false;
1178 + let resolve;
1179 + const promise = new Promise(res => {
1180 + resolve = () => {
1181 + shouldSuspend = false;
1182 + res();
1183 + };
1184 + });
1185 + function Suspender() {
1186 + if (shouldSuspend) {
1187 + throw promise;
1188 + }
1189 + return <></>;
1190 + }
1191 + function App({hasB}) {
1192 + return (
1193 + <div>
1194 + <Activity>
1195 + <Suspender />
1196 + <span ref={ref}>A</span>
1197 + {hasB ? <span>B</span> : null}
1198 + </Activity>
1199 + <div>Sibling</div>
1200 + </div>
1201 + );
1202 + }
1203 + const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);
1204 +
1205 + const container = document.createElement('div');
1206 + container.innerHTML = finalHTML;
1207 +
1208 + const span = container.getElementsByTagName('span')[0];
1209 +
1210 + expect(container.innerHTML).toContain('<span>A</span>');
1211 + expect(container.innerHTML).toContain('<span>B</span>');
1212 + expect(ref.current).toBe(null);
1213 +
1214 + shouldSuspend = true;
1215 + await act(() => {
1216 + ReactDOMClient.hydrateRoot(container, <App hasB={false} />, {
1217 + onRecoverableError(error) {
1218 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
1219 + if (error.cause) {
1220 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
1221 + }
1222 + },
1223 + });
1224 + });
1225 +
1226 + await act(() => {
1227 + resolve();
1228 + });
1229 +
1230 + assertLog([
1231 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
1232 + ]);
1233 +
1234 + expect(container.innerHTML).toContain('<span>A</span>');
1235 + expect(container.innerHTML).not.toContain('<span>B</span>');
1236 + expect(ref.current).not.toBe(span);
1237 + });
1238 +
1239 + // @gate enableActivity
1240 + it('recovers with client render when server rendered additional nodes deep inside suspense root', async () => {
1241 + const ref = React.createRef();
1242 + function App({hasB}) {
1243 + return (
1244 + <div>
1245 + <Activity>
1246 + <div>
1247 + <span ref={ref}>A</span>
1248 + {hasB ? <span>B</span> : null}
1249 + </div>
1250 + </Activity>
1251 + <div>Sibling</div>
1252 + </div>
1253 + );
1254 + }
1255 +
1256 + const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);
1257 +
1258 + const container = document.createElement('div');
1259 + container.innerHTML = finalHTML;
1260 +
1261 + const span = container.getElementsByTagName('span')[0];
1262 +
1263 + expect(container.innerHTML).toContain('<span>A</span>');
1264 + expect(container.innerHTML).toContain('<span>B</span>');
1265 + expect(ref.current).toBe(null);
1266 +
1267 + await act(() => {
1268 + ReactDOMClient.hydrateRoot(container, <App hasB={false} />, {
1269 + onRecoverableError(error) {
1270 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
1271 + if (error.cause) {
1272 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
1273 + }
1274 + },
1275 + });
1276 + });
1277 + assertLog([
1278 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
1279 + ]);
1280 +
1281 + expect(container.innerHTML).toContain('<span>A</span>');
1282 + expect(container.innerHTML).not.toContain('<span>B</span>');
1283 + expect(ref.current).not.toBe(span);
1284 + });
1285 +
1286 + // @gate enableActivity
1287 + it('calls the onDeleted hydration callback if the parent gets deleted', async () => {
1288 + let suspend = false;
1289 + const promise = new Promise(() => {});
1290 + function Child() {
1291 + if (suspend) {
1292 + throw promise;
1293 + } else {
1294 + return 'Hello';
1295 + }
1296 + }
1297 +
1298 + function App({deleted}) {
1299 + if (deleted) {
1300 + return null;
1301 + }
1302 + return (
1303 + <div>
1304 + <Activity>
1305 + <Child />
1306 + </Activity>
1307 + </div>
1308 + );
1309 + }
1310 +
1311 + suspend = false;
1312 + const finalHTML = ReactDOMServer.renderToString(<App />);
1313 +
1314 + const container = document.createElement('div');
1315 + container.innerHTML = finalHTML;
1316 +
1317 + const deleted = [];
1318 +
1319 + // On the client we don't have all data yet but we want to start
1320 + // hydrating anyway.
1321 + suspend = true;
1322 + const root = await act(() => {
1323 + return ReactDOMClient.hydrateRoot(container, <App />, {
1324 + onDeleted(node) {
1325 + deleted.push(node);
1326 + },
1327 + });
1328 + });
1329 +
1330 + expect(deleted.length).toBe(0);
1331 +
1332 + await act(() => {
1333 + root.render(<App deleted={true} />);
1334 + });
1335 +
1336 + // The callback should have been invoked.
1337 + expect(deleted.length).toBe(1);
1338 + });
1339 +
1340 + // @gate enableActivity
1341 + it('can insert siblings before the dehydrated boundary', async () => {
1342 + let suspend = false;
1343 + const promise = new Promise(() => {});
1344 + let showSibling;
1345 +
1346 + function Child() {
1347 + if (suspend) {
1348 + throw promise;
1349 + } else {
1350 + return 'Second';
1351 + }
1352 + }
1353 +
1354 + function Sibling() {
1355 + const [visible, setVisibilty] = React.useState(false);
1356 + showSibling = () => setVisibilty(true);
1357 + if (visible) {
1358 + return <div>First</div>;
1359 + }
1360 + return null;
1361 + }
1362 +
1363 + function App() {
1364 + return (
1365 + <div>
1366 + <Sibling />
1367 + <Activity>
1368 + <span>
1369 + <Child />
1370 + </span>
1371 + </Activity>
1372 + </div>
1373 + );
1374 + }
1375 +
1376 + suspend = false;
1377 + const finalHTML = ReactDOMServer.renderToString(<App />);
1378 + const container = document.createElement('div');
1379 + container.innerHTML = finalHTML;
1380 +
1381 + // On the client we don't have all data yet but we want to start
1382 + // hydrating anyway.
1383 + suspend = true;
1384 +
1385 + await act(() => {
1386 + ReactDOMClient.hydrateRoot(container, <App />);
1387 + });
1388 +
1389 + expect(container.firstChild.firstChild.tagName).not.toBe('DIV');
1390 +
1391 + // In this state, we can still update the siblings.
1392 + await act(() => showSibling());
1393 +
1394 + expect(container.firstChild.firstChild.tagName).toBe('DIV');
1395 + expect(container.firstChild.firstChild.textContent).toBe('First');
1396 + });
1397 +
1398 + // @gate enableActivity
1399 + it('can delete the dehydrated boundary before it is hydrated', async () => {
1400 + let suspend = false;
1401 + const promise = new Promise(() => {});
1402 + let hideMiddle;
1403 +
1404 + function Child() {
1405 + if (suspend) {
1406 + throw promise;
1407 + } else {
1408 + return (
1409 + <>
1410 + <div>Middle</div>
1411 + Some text
1412 + </>
1413 + );
1414 + }
1415 + }
1416 +
1417 + function App() {
1418 + const [visible, setVisibilty] = React.useState(true);
1419 + hideMiddle = () => setVisibilty(false);
1420 +
1421 + return (
1422 + <div>
1423 + <div>Before</div>
1424 + {visible ? (
1425 + <Activity>
1426 + <Child />
1427 + </Activity>
1428 + ) : null}
1429 + <div>After</div>
1430 + </div>
1431 + );
1432 + }
1433 +
1434 + suspend = false;
1435 + const finalHTML = ReactDOMServer.renderToString(<App />);
1436 + const container = document.createElement('div');
1437 + container.innerHTML = finalHTML;
1438 +
1439 + // On the client we don't have all data yet but we want to start
1440 + // hydrating anyway.
1441 + suspend = true;
1442 + await act(() => {
1443 + ReactDOMClient.hydrateRoot(container, <App />);
1444 + });
1445 +
1446 + expect(container.firstChild.children[1].textContent).toBe('Middle');
1447 +
1448 + // In this state, we can still delete the boundary.
1449 + await act(() => hideMiddle());
1450 +
1451 + expect(container.firstChild.children[1].textContent).toBe('After');
1452 + });
1453 +
1454 + // @gate enableActivity
1455 + it('blocks updates to hydrate the content first if props have changed', async () => {
1456 + let suspend = false;
1457 + let resolve;
1458 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1459 + const ref = React.createRef();
1460 +
1461 + function Child({text}) {
1462 + if (suspend) {
1463 + throw promise;
1464 + } else {
1465 + return text;
1466 + }
1467 + }
1468 +
1469 + function App({text, className}) {
1470 + return (
1471 + <div>
1472 + <Activity>
1473 + <span ref={ref} className={className}>
1474 + <Child text={text} />
1475 + </span>
1476 + </Activity>
1477 + </div>
1478 + );
1479 + }
1480 +
1481 + suspend = false;
1482 + const finalHTML = ReactDOMServer.renderToString(
1483 + <App text="Hello" className="hello" />,
1484 + );
1485 + const container = document.createElement('div');
1486 + container.innerHTML = finalHTML;
1487 +
1488 + const span = container.getElementsByTagName('span')[0];
1489 +
1490 + // On the client we don't have all data yet but we want to start
1491 + // hydrating anyway.
1492 + suspend = true;
1493 + const root = ReactDOMClient.hydrateRoot(
1494 + container,
1495 + <App text="Hello" className="hello" />,
1496 + );
1497 + await waitForAll([]);
1498 +
1499 + expect(ref.current).toBe(null);
1500 + expect(span.textContent).toBe('Hello');
1501 +
1502 + // Render an update, which will be higher or the same priority as pinging the hydration.
1503 + root.render(<App text="Hi" className="hi" />);
1504 +
1505 + // At the same time, resolving the promise so that rendering can complete.
1506 + // This should first complete the hydration and then flush the update onto the hydrated state.
1507 + await act(async () => {
1508 + suspend = false;
1509 + resolve();
1510 + await promise;
1511 + });
1512 +
1513 + // The new span should be the same since we should have successfully hydrated
1514 + // before changing it.
1515 + const newSpan = container.getElementsByTagName('span')[0];
1516 + expect(span).toBe(newSpan);
1517 +
1518 + // We should now have fully rendered with a ref on the new span.
1519 + expect(ref.current).toBe(span);
1520 + expect(span.textContent).toBe('Hi');
1521 + // If we ended up hydrating the existing content, we won't have properly
1522 + // patched up the tree, which might mean we haven't patched the className.
1523 + expect(span.className).toBe('hi');
1524 + });
1525 +
1526 + // @gate enableActivity && www
1527 + it('blocks updates to hydrate the content first if props changed at idle priority', async () => {
1528 + let suspend = false;
1529 + let resolve;
1530 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1531 + const ref = React.createRef();
1532 +
1533 + function Child({text}) {
1534 + if (suspend) {
1535 + throw promise;
1536 + } else {
1537 + return text;
1538 + }
1539 + }
1540 +
1541 + function App({text, className}) {
1542 + return (
1543 + <div>
1544 + <Activity>
1545 + <span ref={ref} className={className}>
1546 + <Child text={text} />
1547 + </span>
1548 + </Activity>
1549 + </div>
1550 + );
1551 + }
1552 +
1553 + suspend = false;
1554 + const finalHTML = ReactDOMServer.renderToString(
1555 + <App text="Hello" className="hello" />,
1556 + );
1557 + const container = document.createElement('div');
1558 + container.innerHTML = finalHTML;
1559 +
1560 + const span = container.getElementsByTagName('span')[0];
1561 +
1562 + // On the client we don't have all data yet but we want to start
1563 + // hydrating anyway.
1564 + suspend = true;
1565 + const root = ReactDOMClient.hydrateRoot(
1566 + container,
1567 + <App text="Hello" className="hello" />,
1568 + );
1569 + await waitForAll([]);
1570 +
1571 + expect(ref.current).toBe(null);
1572 + expect(span.textContent).toBe('Hello');
1573 +
1574 + // Schedule an update at idle priority
1575 + ReactDOM.unstable_runWithPriority(IdleEventPriority, () => {
1576 + root.render(<App text="Hi" className="hi" />);
1577 + });
1578 +
1579 + // At the same time, resolving the promise so that rendering can complete.
1580 + suspend = false;
1581 + resolve();
1582 + await promise;
1583 +
1584 + // This should first complete the hydration and then flush the update onto the hydrated state.
1585 + await waitForAll([]);
1586 +
1587 + // The new span should be the same since we should have successfully hydrated
1588 + // before changing it.
1589 + const newSpan = container.getElementsByTagName('span')[0];
1590 + expect(span).toBe(newSpan);
1591 +
1592 + // We should now have fully rendered with a ref on the new span.
1593 + expect(ref.current).toBe(span);
1594 + expect(span.textContent).toBe('Hi');
1595 + // If we ended up hydrating the existing content, we won't have properly
1596 + // patched up the tree, which might mean we haven't patched the className.
1597 + expect(span.className).toBe('hi');
1598 + });
1599 +
1600 + // @gate enableActivity
1601 + it('shows the fallback of the parent if props have changed before hydration completes and is still suspended', async () => {
1602 + let suspend = false;
1603 + let resolve;
1604 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1605 + const outerRef = React.createRef();
1606 + const ref = React.createRef();
1607 +
1608 + function Child({text}) {
1609 + if (suspend) {
1610 + throw promise;
1611 + } else {
1612 + return text;
1613 + }
1614 + }
1615 +
1616 + function App({text, className}) {
1617 + return (
1618 + <Suspense fallback="Loading...">
1619 + <div ref={outerRef}>
1620 + <Activity>
1621 + <span ref={ref} className={className}>
1622 + <Child text={text} />
1623 + </span>
1624 + </Activity>
1625 + </div>
1626 + </Suspense>
1627 + );
1628 + }
1629 +
1630 + suspend = false;
1631 + const finalHTML = ReactDOMServer.renderToString(
1632 + <App text="Hello" className="hello" />,
1633 + );
1634 + const container = document.createElement('div');
1635 + container.innerHTML = finalHTML;
1636 +
1637 + // On the client we don't have all data yet but we want to start
1638 + // hydrating anyway.
1639 + suspend = true;
1640 + const root = ReactDOMClient.hydrateRoot(
1641 + container,
1642 + <App text="Hello" className="hello" />,
1643 + {
1644 + onRecoverableError(error) {
1645 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
1646 + if (error.cause) {
1647 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
1648 + }
1649 + },
1650 + },
1651 + );
1652 + await waitForAll([]);
1653 +
1654 + expect(container.getElementsByTagName('div').length).toBe(1); // hidden
1655 + const div = container.getElementsByTagName('div')[0];
1656 +
1657 + expect(outerRef.current).toBe(div);
1658 + expect(ref.current).toBe(null);
1659 +
1660 + // Render an update, but leave it still suspended.
1661 + await act(() => {
1662 + root.render(<App text="Hi" className="hi" />);
1663 + });
1664 +
1665 + // Flushing now should hide the existing content and show the fallback.
1666 +
1667 + expect(outerRef.current).toBe(null);
1668 + expect(div.style.display).toBe('none');
1669 + expect(container.getElementsByTagName('span').length).toBe(1); // hidden
1670 + expect(ref.current).toBe(null);
1671 + expect(container.textContent).toBe('HelloLoading...');
1672 +
1673 + // Unsuspending shows the content.
1674 + await act(async () => {
1675 + suspend = false;
1676 + resolve();
1677 + await promise;
1678 + });
1679 +
1680 + const span = container.getElementsByTagName('span')[0];
1681 + expect(span.textContent).toBe('Hi');
1682 + expect(span.className).toBe('hi');
1683 + expect(ref.current).toBe(span);
1684 + expect(container.textContent).toBe('Hi');
1685 + });
1686 +
1687 + // @gate enableActivity
1688 + it('clears nested activity boundaries if they did not hydrate yet', async () => {
1689 + let suspend = false;
1690 + const promise = new Promise(() => {});
1691 + const ref = React.createRef();
1692 +
1693 + function Child({text}) {
1694 + if (suspend && text !== 'Hi') {
1695 + throw promise;
1696 + } else {
1697 + return text;
1698 + }
1699 + }
1700 +
1701 + function App({text, className}) {
1702 + return (
1703 + <div>
1704 + <Activity>
1705 + <Activity>
1706 + <Child text={text} />
1707 + </Activity>{' '}
1708 + <span ref={ref} className={className}>
1709 + <Child text={text} />
1710 + </span>
1711 + </Activity>
1712 + </div>
1713 + );
1714 + }
1715 +
1716 + suspend = false;
1717 + const finalHTML = ReactDOMServer.renderToString(
1718 + <App text="Hello" className="hello" />,
1719 + );
1720 + const container = document.createElement('div');
1721 + container.innerHTML = finalHTML;
1722 +
1723 + // On the client we don't have all data yet but we want to start
1724 + // hydrating anyway.
1725 + suspend = true;
1726 + const root = ReactDOMClient.hydrateRoot(
1727 + container,
1728 + <App text="Hello" className="hello" />,
1729 + {
1730 + onRecoverableError(error) {
1731 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
1732 + if (error.cause) {
1733 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
1734 + }
1735 + },
1736 + },
1737 + );
1738 + await waitForAll([]);
1739 +
1740 + expect(ref.current).toBe(null);
1741 +
1742 + // Render an update, that unblocks.
1743 + // Flushing now should delete the existing content and show the update.
1744 + await act(() => {
1745 + root.render(<App text="Hi" className="hi" />);
1746 + });
1747 +
1748 + const span = container.getElementsByTagName('span')[0];
1749 + expect(span.textContent).toBe('Hi');
1750 + expect(span.className).toBe('hi');
1751 + expect(ref.current).toBe(span);
1752 + expect(container.textContent).toBe('Hi Hi');
1753 + });
1754 +
1755 + // @gate enableActivity
1756 + it('hydrates first if props changed but we are able to resolve within a timeout', async () => {
1757 + let suspend = false;
1758 + let resolve;
1759 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1760 + const ref = React.createRef();
1761 +
1762 + function Child({text}) {
1763 + if (suspend) {
1764 + throw promise;
1765 + } else {
1766 + return text;
1767 + }
1768 + }
1769 +
1770 + function App({text, className}) {
1771 + return (
1772 + <div>
1773 + <Activity>
1774 + <span ref={ref} className={className}>
1775 + <Child text={text} />
1776 + </span>
1777 + </Activity>
1778 + </div>
1779 + );
1780 + }
1781 +
1782 + suspend = false;
1783 + const finalHTML = ReactDOMServer.renderToString(
1784 + <App text="Hello" className="hello" />,
1785 + );
1786 + const container = document.createElement('div');
1787 + container.innerHTML = finalHTML;
1788 +
1789 + const span = container.getElementsByTagName('span')[0];
1790 +
1791 + // On the client we don't have all data yet but we want to start
1792 + // hydrating anyway.
1793 + suspend = true;
1794 + const root = ReactDOMClient.hydrateRoot(
1795 + container,
1796 + <App text="Hello" className="hello" />,
1797 + );
1798 + await waitForAll([]);
1799 +
1800 + expect(ref.current).toBe(null);
1801 + expect(container.textContent).toBe('Hello');
1802 +
1803 + // Render an update with a long timeout.
1804 + React.startTransition(() => root.render(<App text="Hi" className="hi" />));
1805 + // This shouldn't force the fallback yet.
1806 + await waitForAll([]);
1807 +
1808 + expect(ref.current).toBe(null);
1809 + expect(container.textContent).toBe('Hello');
1810 +
1811 + // Resolving the promise so that rendering can complete.
1812 + // This should first complete the hydration and then flush the update onto the hydrated state.
1813 + suspend = false;
1814 + await act(() => resolve());
1815 +
1816 + // The new span should be the same since we should have successfully hydrated
1817 + // before changing it.
1818 + const newSpan = container.getElementsByTagName('span')[0];
1819 + expect(span).toBe(newSpan);
1820 +
1821 + // We should now have fully rendered with a ref on the new span.
1822 + expect(ref.current).toBe(span);
1823 + expect(container.textContent).toBe('Hi');
1824 + // If we ended up hydrating the existing content, we won't have properly
1825 + // patched up the tree, which might mean we haven't patched the className.
1826 + expect(span.className).toBe('hi');
1827 + });
1828 +
1829 + // @gate enableActivity
1830 + it('warns but works if setState is called before commit in a dehydrated component', async () => {
1831 + let suspend = false;
1832 + let resolve;
1833 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1834 +
1835 + let updateText;
1836 +
1837 + function Child() {
1838 + const [state, setState] = React.useState('Hello');
1839 + updateText = setState;
1840 + Scheduler.log('Child');
1841 + if (suspend) {
1842 + throw promise;
1843 + } else {
1844 + return state;
1845 + }
1846 + }
1847 +
1848 + function Sibling() {
1849 + Scheduler.log('Sibling');
1850 + return null;
1851 + }
1852 +
1853 + function App() {
1854 + return (
1855 + <div>
1856 + <Activity>
1857 + <Child />
1858 + <Sibling />
1859 + </Activity>
1860 + </div>
1861 + );
1862 + }
1863 +
1864 + suspend = false;
1865 + const finalHTML = ReactDOMServer.renderToString(<App />);
1866 + assertLog(['Child', 'Sibling']);
1867 +
1868 + const container = document.createElement('div');
1869 + container.innerHTML = finalHTML;
1870 +
1871 + ReactDOMClient.hydrateRoot(
1872 + container,
1873 + <App text="Hello" className="hello" />,
1874 + );
1875 +
1876 + await act(async () => {
1877 + suspend = true;
1878 + await waitFor(['Child']);
1879 +
1880 + // While we're part way through the hydration, we update the state.
1881 + // This will schedule an update on the children of the activity boundary.
1882 + updateText('Hi');
1883 + assertConsoleErrorDev([
1884 + "Can't perform a React state update on a component that hasn't mounted yet. " +
1885 + 'This indicates that you have a side-effect in your render function that ' +
1886 + 'asynchronously later calls tries to update the component. Move this work to useEffect instead.\n' +
1887 + ' in App (at **)',
1888 + ]);
1889 +
1890 + // This will throw it away and rerender.
1891 + await waitForAll(['Child']);
1892 +
1893 + expect(container.textContent).toBe('Hello');
1894 +
1895 + suspend = false;
1896 + resolve();
1897 + await promise;
1898 + });
1899 + assertLog(['Child', 'Sibling']);
1900 +
1901 + expect(container.textContent).toBe('Hello');
1902 + });
1903 +
1904 + // @gate enableActivity
1905 + it('blocks the update to hydrate first if context has changed', async () => {
1906 + let suspend = false;
1907 + let resolve;
1908 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1909 + const ref = React.createRef();
1910 + const Context = React.createContext(null);
1911 +
1912 + function Child() {
1913 + const {text, className} = React.useContext(Context);
1914 + if (suspend) {
1915 + throw promise;
1916 + } else {
1917 + return (
1918 + <span ref={ref} className={className}>
1919 + {text}
1920 + </span>
1921 + );
1922 + }
1923 + }
1924 +
1925 + const App = React.memo(function App() {
1926 + return (
1927 + <div>
1928 + <Activity>
1929 + <Child />
1930 + </Activity>
1931 + </div>
1932 + );
1933 + });
1934 +
1935 + suspend = false;
1936 + const finalHTML = ReactDOMServer.renderToString(
1937 + <Context.Provider value={{text: 'Hello', className: 'hello'}}>
1938 + <App />
1939 + </Context.Provider>,
1940 + );
1941 + const container = document.createElement('div');
1942 + container.innerHTML = finalHTML;
1943 +
1944 + const span = container.getElementsByTagName('span')[0];
1945 +
1946 + // On the client we don't have all data yet but we want to start
1947 + // hydrating anyway.
1948 + suspend = true;
1949 + const root = ReactDOMClient.hydrateRoot(
1950 + container,
1951 + <Context.Provider value={{text: 'Hello', className: 'hello'}}>
1952 + <App />
1953 + </Context.Provider>,
1954 + );
1955 + await waitForAll([]);
1956 +
1957 + expect(ref.current).toBe(null);
1958 + expect(span.textContent).toBe('Hello');
1959 +
1960 + // Render an update, which will be higher or the same priority as pinging the hydration.
1961 + root.render(
1962 + <Context.Provider value={{text: 'Hi', className: 'hi'}}>
1963 + <App />
1964 + </Context.Provider>,
1965 + );
1966 +
1967 + // At the same time, resolving the promise so that rendering can complete.
1968 + // This should first complete the hydration and then flush the update onto the hydrated state.
1969 + await act(async () => {
1970 + suspend = false;
1971 + resolve();
1972 + await promise;
1973 + });
1974 +
1975 + // Since this should have been hydrated, this should still be the same span.
1976 + const newSpan = container.getElementsByTagName('span')[0];
1977 + expect(newSpan).toBe(span);
1978 +
1979 + // We should now have fully rendered with a ref on the new span.
1980 + expect(ref.current).toBe(span);
1981 + expect(span.textContent).toBe('Hi');
1982 + // If we ended up hydrating the existing content, we won't have properly
1983 + // patched up the tree, which might mean we haven't patched the className.
1984 + expect(span.className).toBe('hi');
1985 + });
1986 +
1987 + // @gate enableActivity
1988 + it('shows the parent fallback if context has changed before hydration completes and is still suspended', async () => {
1989 + let suspend = false;
1990 + let resolve;
1991 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1992 + const ref = React.createRef();
1993 + const Context = React.createContext(null);
1994 +
1995 + function Child() {
1996 + const {text, className} = React.useContext(Context);
1997 + if (suspend) {
1998 + throw promise;
1999 + } else {
2000 + return (
2001 + <span ref={ref} className={className}>
2002 + {text}
2003 + </span>
2004 + );
2005 + }
2006 + }
2007 +
2008 + const App = React.memo(function App() {
2009 + return (
2010 + <Suspense fallback="Loading...">
2011 + <div>
2012 + <Activity>
2013 + <Child />
2014 + </Activity>
2015 + </div>
2016 + </Suspense>
2017 + );
2018 + });
2019 +
2020 + suspend = false;
2021 + const finalHTML = ReactDOMServer.renderToString(
2022 + <Context.Provider value={{text: 'Hello', className: 'hello'}}>
2023 + <App />
2024 + </Context.Provider>,
2025 + );
2026 + const container = document.createElement('div');
2027 + container.innerHTML = finalHTML;
2028 +
2029 + // On the client we don't have all data yet but we want to start
2030 + // hydrating anyway.
2031 + suspend = true;
2032 + const root = ReactDOMClient.hydrateRoot(
2033 + container,
2034 + <Context.Provider value={{text: 'Hello', className: 'hello'}}>
2035 + <App />
2036 + </Context.Provider>,
2037 + {
2038 + onRecoverableError(error) {
2039 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
2040 + if (error.cause) {
2041 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
2042 + }
2043 + },
2044 + },
2045 + );
2046 + await waitForAll([]);
2047 +
2048 + expect(ref.current).toBe(null);
2049 +
2050 + // Render an update, but leave it still suspended.
2051 + // Flushing now should delete the existing content and show the fallback.
2052 + await act(() => {
2053 + root.render(
2054 + <Context.Provider value={{text: 'Hi', className: 'hi'}}>
2055 + <App />
2056 + </Context.Provider>,
2057 + );
2058 + });
2059 +
2060 + expect(container.getElementsByTagName('span').length).toBe(1); // hidden
2061 + expect(ref.current).toBe(null);
2062 + expect(container.textContent).toBe('HelloLoading...');
2063 +
2064 + // Unsuspending shows the content.
2065 + await act(async () => {
2066 + suspend = false;
2067 + resolve();
2068 + await promise;
2069 + });
2070 +
2071 + const span = container.getElementsByTagName('span')[0];
2072 + expect(span.textContent).toBe('Hi');
2073 + expect(span.className).toBe('hi');
2074 + expect(ref.current).toBe(span);
2075 + expect(container.textContent).toBe('Hi');
2076 + });
2077 +
2078 + // @gate enableActivity
2079 + it('can hydrate TWO activity boundaries', async () => {
2080 + const ref1 = React.createRef();
2081 + const ref2 = React.createRef();
2082 +
2083 + function App() {
2084 + return (
2085 + <div>
2086 + <Activity>
2087 + <span ref={ref1}>1</span>
2088 + </Activity>
2089 + <Activity>
2090 + <span ref={ref2}>2</span>
2091 + </Activity>
2092 + </div>
2093 + );
2094 + }
2095 +
2096 + // First we render the final HTML. With the streaming renderer
2097 + // this may have suspense points on the server but here we want
2098 + // to test the completed HTML. Don't suspend on the server.
2099 + const finalHTML = ReactDOMServer.renderToString(<App />);
2100 +
2101 + const container = document.createElement('div');
2102 + container.innerHTML = finalHTML;
2103 +
2104 + const span1 = container.getElementsByTagName('span')[0];
2105 + const span2 = container.getElementsByTagName('span')[1];
2106 +
2107 + // On the client we don't have all data yet but we want to start
2108 + // hydrating anyway.
2109 + ReactDOMClient.hydrateRoot(container, <App />);
2110 + await waitForAll([]);
2111 +
2112 + expect(ref1.current).toBe(span1);
2113 + expect(ref2.current).toBe(span2);
2114 + });
2115 +
2116 + // @gate enableActivity
2117 + it('regenerates if it cannot hydrate before changes to props/context expire', async () => {
2118 + let suspend = false;
2119 + const promise = new Promise(resolvePromise => {});
2120 + const ref = React.createRef();
2121 + const ClassName = React.createContext(null);
2122 +
2123 + function Child({text}) {
2124 + const className = React.useContext(ClassName);
2125 + if (suspend && className !== 'hi' && text !== 'Hi') {
2126 + // Never suspends on the newer data.
2127 + throw promise;
2128 + } else {
2129 + return (
2130 + <span ref={ref} className={className}>
2131 + {text}
2132 + </span>
2133 + );
2134 + }
2135 + }
2136 +
2137 + function App({text, className}) {
2138 + return (
2139 + <div>
2140 + <Activity>
2141 + <Child text={text} />
2142 + </Activity>
2143 + </div>
2144 + );
2145 + }
2146 +
2147 + suspend = false;
2148 + const finalHTML = ReactDOMServer.renderToString(
2149 + <ClassName.Provider value={'hello'}>
2150 + <App text="Hello" />
2151 + </ClassName.Provider>,
2152 + );
2153 + const container = document.createElement('div');
2154 + container.innerHTML = finalHTML;
2155 +
2156 + const span = container.getElementsByTagName('span')[0];
2157 +
2158 + // On the client we don't have all data yet but we want to start
2159 + // hydrating anyway.
2160 + suspend = true;
2161 + const root = ReactDOMClient.hydrateRoot(
2162 + container,
2163 + <ClassName.Provider value={'hello'}>
2164 + <App text="Hello" />
2165 + </ClassName.Provider>,
2166 + {
2167 + onRecoverableError(error) {
2168 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
2169 + if (error.cause) {
2170 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
2171 + }
2172 + },
2173 + },
2174 + );
2175 + await waitForAll([]);
2176 +
2177 + expect(ref.current).toBe(null);
2178 + expect(span.textContent).toBe('Hello');
2179 +
2180 + // Render an update, which will be higher or the same priority as pinging the hydration.
2181 + // The new update doesn't suspend.
2182 + // Since we're still suspended on the original data, we can't hydrate.
2183 + // This will force all expiration times to flush.
2184 + await act(() => {
2185 + root.render(
2186 + <ClassName.Provider value={'hi'}>
2187 + <App text="Hi" />
2188 + </ClassName.Provider>,
2189 + );
2190 + });
2191 +
2192 + // This will now be a new span because we weren't able to hydrate before
2193 + const newSpan = container.getElementsByTagName('span')[0];
2194 + expect(newSpan).not.toBe(span);
2195 +
2196 + // We should now have fully rendered with a ref on the new span.
2197 + expect(ref.current).toBe(newSpan);
2198 + expect(newSpan.textContent).toBe('Hi');
2199 + // If we ended up hydrating the existing content, we won't have properly
2200 + // patched up the tree, which might mean we haven't patched the className.
2201 + expect(newSpan.className).toBe('hi');
2202 + });
2203 +
2204 + // @gate enableActivity
2205 + it('does not invoke an event on a hydrated node until it commits', async () => {
2206 + let suspend = false;
2207 + let resolve;
2208 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2209 +
2210 + function Sibling({text}) {
2211 + if (suspend) {
2212 + throw promise;
2213 + } else {
2214 + return 'Hello';
2215 + }
2216 + }
2217 +
2218 + let clicks = 0;
2219 +
2220 + function Button() {
2221 + const [clicked, setClicked] = React.useState(false);
2222 + if (clicked) {
2223 + return null;
2224 + }
2225 + return (
2226 + <a
2227 + onClick={() => {
2228 + setClicked(true);
2229 + clicks++;
2230 + }}>
2231 + Click me
2232 + </a>
2233 + );
2234 + }
2235 +
2236 + function App() {
2237 + return (
2238 + <div>
2239 + <Activity>
2240 + <Button />
2241 + <Sibling />
2242 + </Activity>
2243 + </div>
2244 + );
2245 + }
2246 +
2247 + suspend = false;
2248 + const finalHTML = ReactDOMServer.renderToString(<App />);
2249 + const container = document.createElement('div');
2250 + container.innerHTML = finalHTML;
2251 +
2252 + // We need this to be in the document since we'll dispatch events on it.
2253 + document.body.appendChild(container);
2254 +
2255 + const a = container.getElementsByTagName('a')[0];
2256 +
2257 + // On the client we don't have all data yet but we want to start
2258 + // hydrating anyway.
2259 + suspend = true;
2260 + ReactDOMClient.hydrateRoot(container, <App />);
2261 + await waitForAll([]);
2262 +
2263 + expect(container.textContent).toBe('Click meHello');
2264 +
2265 + // We're now partially hydrated.
2266 + await act(() => {
2267 + a.click();
2268 + });
2269 + expect(clicks).toBe(0);
2270 +
2271 + // Resolving the promise so that rendering can complete.
2272 + await act(async () => {
2273 + suspend = false;
2274 + resolve();
2275 + await promise;
2276 + });
2277 +
2278 + expect(clicks).toBe(0);
2279 + expect(container.textContent).toBe('Click meHello');
2280 +
2281 + document.body.removeChild(container);
2282 + });
2283 +
2284 + // @gate enableActivity && www
2285 + it('does not invoke an event on a hydrated event handle until it commits', async () => {
2286 + const setClick = ReactDOM.unstable_createEventHandle('click');
2287 + let suspend = false;
2288 + let isServerRendering = true;
2289 + let resolve;
2290 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2291 +
2292 + function Sibling({text}) {
2293 + if (suspend) {
2294 + throw promise;
2295 + } else {
2296 + return 'Hello';
2297 + }
2298 + }
2299 +
2300 + const onEvent = jest.fn();
2301 +
2302 + function Button() {
2303 + const ref = React.useRef(null);
2304 + if (!isServerRendering) {
2305 + React.useLayoutEffect(() => {
2306 + return setClick(ref.current, onEvent);
2307 + });
2308 + }
2309 + return <a ref={ref}>Click me</a>;
2310 + }
2311 +
2312 + function App() {
2313 + return (
2314 + <div>
2315 + <Activity>
2316 + <Button />
2317 + <Sibling />
2318 + </Activity>
2319 + </div>
2320 + );
2321 + }
2322 +
2323 + suspend = false;
2324 + const finalHTML = ReactDOMServer.renderToString(<App />);
2325 + const container = document.createElement('div');
2326 + container.innerHTML = finalHTML;
2327 +
2328 + // We need this to be in the document since we'll dispatch events on it.
2329 + document.body.appendChild(container);
2330 +
2331 + const a = container.getElementsByTagName('a')[0];
2332 +
2333 + // On the client we don't have all data yet but we want to start
2334 + // hydrating anyway.
2335 + suspend = true;
2336 + isServerRendering = false;
2337 + ReactDOMClient.hydrateRoot(container, <App />);
2338 +
2339 + // We'll do one click before hydrating.
2340 + a.click();
2341 + // This should be delayed.
2342 + expect(onEvent).toHaveBeenCalledTimes(0);
2343 +
2344 + await waitForAll([]);
2345 +
2346 + // We're now partially hydrated.
2347 + await act(() => {
2348 + a.click();
2349 + });
2350 + // We should not have invoked the event yet because we're not
2351 + // yet hydrated.
2352 + expect(onEvent).toHaveBeenCalledTimes(0);
2353 +
2354 + // Resolving the promise so that rendering can complete.
2355 + await act(async () => {
2356 + suspend = false;
2357 + resolve();
2358 + await promise;
2359 + });
2360 +
2361 + expect(onEvent).toHaveBeenCalledTimes(0);
2362 +
2363 + document.body.removeChild(container);
2364 + });
2365 +
2366 + // @gate enableActivity
2367 + it('invokes discrete events on nested activity boundaries in a root (legacy system)', async () => {
2368 + let suspend = false;
2369 + let resolve;
2370 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2371 +
2372 + let clicks = 0;
2373 +
2374 + function Button() {
2375 + return (
2376 + <a
2377 + onClick={() => {
2378 + clicks++;
2379 + }}>
2380 + Click me
2381 + </a>
2382 + );
2383 + }
2384 +
2385 + function Child() {
2386 + if (suspend) {
2387 + throw promise;
2388 + } else {
2389 + return (
2390 + <Activity>
2391 + <Button />
2392 + </Activity>
2393 + );
2394 + }
2395 + }
2396 +
2397 + function App() {
2398 + return (
2399 + <Activity>
2400 + <Child />
2401 + </Activity>
2402 + );
2403 + }
2404 +
2405 + suspend = false;
2406 + const finalHTML = ReactDOMServer.renderToString(<App />);
2407 + const container = document.createElement('div');
2408 + container.innerHTML = finalHTML;
2409 +
2410 + // We need this to be in the document since we'll dispatch events on it.
2411 + document.body.appendChild(container);
2412 +
2413 + const a = container.getElementsByTagName('a')[0];
2414 +
2415 + // On the client we don't have all data yet but we want to start
2416 + // hydrating anyway.
2417 + suspend = true;
2418 + ReactDOMClient.hydrateRoot(container, <App />);
2419 +
2420 + // We'll do one click before hydrating.
2421 + await act(() => {
2422 + a.click();
2423 + });
2424 + // This should be delayed.
2425 + expect(clicks).toBe(0);
2426 +
2427 + await waitForAll([]);
2428 +
2429 + // We're now partially hydrated.
2430 + await act(() => {
2431 + a.click();
2432 + });
2433 + expect(clicks).toBe(0);
2434 +
2435 + // Resolving the promise so that rendering can complete.
2436 + await act(async () => {
2437 + suspend = false;
2438 + resolve();
2439 + await promise;
2440 + });
2441 +
2442 + expect(clicks).toBe(0);
2443 +
2444 + document.body.removeChild(container);
2445 + });
2446 +
2447 + // @gate enableActivity && www
2448 + it('invokes discrete events on nested activity boundaries in a root (createEventHandle)', async () => {
2449 + let suspend = false;
2450 + let isServerRendering = true;
2451 + let resolve;
2452 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2453 +
2454 + const onEvent = jest.fn();
2455 + const setClick = ReactDOM.unstable_createEventHandle('click');
2456 +
2457 + function Button() {
2458 + const ref = React.useRef(null);
2459 +
2460 + if (!isServerRendering) {
2461 + React.useLayoutEffect(() => {
2462 + return setClick(ref.current, onEvent);
2463 + });
2464 + }
2465 +
2466 + return <a ref={ref}>Click me</a>;
2467 + }
2468 +
2469 + function Child() {
2470 + if (suspend) {
2471 + throw promise;
2472 + } else {
2473 + return (
2474 + <Activity>
2475 + <Button />
2476 + </Activity>
2477 + );
2478 + }
2479 + }
2480 +
2481 + function App() {
2482 + return (
2483 + <Activity>
2484 + <Child />
2485 + </Activity>
2486 + );
2487 + }
2488 +
2489 + suspend = false;
2490 + const finalHTML = ReactDOMServer.renderToString(<App />);
2491 + const container = document.createElement('div');
2492 + container.innerHTML = finalHTML;
2493 +
2494 + // We need this to be in the document since we'll dispatch events on it.
2495 + document.body.appendChild(container);
2496 +
2497 + const a = container.getElementsByTagName('a')[0];
2498 +
2499 + // On the client we don't have all data yet but we want to start
2500 + // hydrating anyway.
2501 + suspend = true;
2502 + isServerRendering = false;
2503 + ReactDOMClient.hydrateRoot(container, <App />);
2504 +
2505 + // We'll do one click before hydrating.
2506 + a.click();
2507 + // This should be delayed.
2508 + expect(onEvent).toHaveBeenCalledTimes(0);
2509 +
2510 + await waitForAll([]);
2511 +
2512 + // We're now partially hydrated.
2513 + await act(() => {
2514 + a.click();
2515 + });
2516 + // We should not have invoked the event yet because we're not
2517 + // yet hydrated.
2518 + expect(onEvent).toHaveBeenCalledTimes(0);
2519 +
2520 + // Resolving the promise so that rendering can complete.
2521 + await act(async () => {
2522 + suspend = false;
2523 + resolve();
2524 + await promise;
2525 + });
2526 +
2527 + expect(onEvent).toHaveBeenCalledTimes(0);
2528 +
2529 + document.body.removeChild(container);
2530 + });
2531 +
2532 + // @gate enableActivity
2533 + it('does not invoke the parent of dehydrated boundary event', async () => {
2534 + let suspend = false;
2535 + let resolve;
2536 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2537 +
2538 + let clicksOnParent = 0;
2539 + let clicksOnChild = 0;
2540 +
2541 + function Child({text}) {
2542 + if (suspend) {
2543 + throw promise;
2544 + } else {
2545 + return (
2546 + <span
2547 + onClick={e => {
2548 + // The stopPropagation is showing an example why invoking
2549 + // the event on only a parent might not be correct.
2550 + e.stopPropagation();
2551 + clicksOnChild++;
2552 + }}>
2553 + Hello
2554 + </span>
2555 + );
2556 + }
2557 + }
2558 +
2559 + function App() {
2560 + return (
2561 + <div onClick={() => clicksOnParent++}>
2562 + <Activity>
2563 + <Child />
2564 + </Activity>
2565 + </div>
2566 + );
2567 + }
2568 +
2569 + suspend = false;
2570 + const finalHTML = ReactDOMServer.renderToString(<App />);
2571 + const container = document.createElement('div');
2572 + container.innerHTML = finalHTML;
2573 +
2574 + // We need this to be in the document since we'll dispatch events on it.
2575 + document.body.appendChild(container);
2576 +
2577 + const span = container.getElementsByTagName('span')[0];
2578 +
2579 + // On the client we don't have all data yet but we want to start
2580 + // hydrating anyway.
2581 + suspend = true;
2582 + ReactDOMClient.hydrateRoot(container, <App />);
2583 + await waitForAll([]);
2584 +
2585 + // We're now partially hydrated.
2586 + await act(() => {
2587 + span.click();
2588 + });
2589 + expect(clicksOnChild).toBe(0);
2590 + expect(clicksOnParent).toBe(0);
2591 +
2592 + // Resolving the promise so that rendering can complete.
2593 + await act(async () => {
2594 + suspend = false;
2595 + resolve();
2596 + await promise;
2597 + });
2598 +
2599 + expect(clicksOnChild).toBe(0);
2600 + expect(clicksOnParent).toBe(0);
2601 +
2602 + document.body.removeChild(container);
2603 + });
2604 +
2605 + // @gate enableActivity
2606 + it('does not invoke an event on a parent tree when a subtree is dehydrated', async () => {
2607 + let suspend = false;
2608 + let resolve;
2609 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2610 +
2611 + let clicks = 0;
2612 + const childSlotRef = React.createRef();
2613 +
2614 + function Parent() {
2615 + return <div onClick={() => clicks++} ref={childSlotRef} />;
2616 + }
2617 +
2618 + function Child({text}) {
2619 + if (suspend) {
2620 + throw promise;
2621 + } else {
2622 + return <a>Click me</a>;
2623 + }
2624 + }
2625 +
2626 + function App() {
2627 + // The root is a Suspense boundary.
2628 + return (
2629 + <Activity>
2630 + <Child />
2631 + </Activity>
2632 + );
2633 + }
2634 +
2635 + suspend = false;
2636 + const finalHTML = ReactDOMServer.renderToString(<App />);
2637 +
2638 + const parentContainer = document.createElement('div');
2639 + const childContainer = document.createElement('div');
2640 +
2641 + // We need this to be in the document since we'll dispatch events on it.
2642 + document.body.appendChild(parentContainer);
2643 +
2644 + // We're going to use a different root as a parent.
2645 + // This lets us detect whether an event goes through React's event system.
2646 + const parentRoot = ReactDOMClient.createRoot(parentContainer);
2647 + await act(() => parentRoot.render(<Parent />));
2648 +
2649 + childSlotRef.current.appendChild(childContainer);
2650 +
2651 + childContainer.innerHTML = finalHTML;
2652 +
2653 + const a = childContainer.getElementsByTagName('a')[0];
2654 +
2655 + suspend = true;
2656 +
2657 + // Hydrate asynchronously.
2658 + await act(() => ReactDOMClient.hydrateRoot(childContainer, <App />));
2659 +
2660 + // The Suspense boundary is not yet hydrated.
2661 + await act(() => {
2662 + a.click();
2663 + });
2664 + expect(clicks).toBe(0);
2665 +
2666 + // Resolving the promise so that rendering can complete.
2667 + await act(async () => {
2668 + suspend = false;
2669 + resolve();
2670 + await promise;
2671 + });
2672 +
2673 + expect(clicks).toBe(0);
2674 +
2675 + document.body.removeChild(parentContainer);
2676 + });
2677 +
2678 + // @gate enableActivity
2679 + it('blocks only on the last continuous event (legacy system)', async () => {
2680 + let suspend1 = false;
2681 + let resolve1;
2682 + const promise1 = new Promise(resolvePromise => (resolve1 = resolvePromise));
2683 + let suspend2 = false;
2684 + let resolve2;
2685 + const promise2 = new Promise(resolvePromise => (resolve2 = resolvePromise));
2686 +
2687 + function First({text}) {
2688 + if (suspend1) {
2689 + throw promise1;
2690 + } else {
2691 + return 'Hello';
2692 + }
2693 + }
2694 +
2695 + function Second({text}) {
2696 + if (suspend2) {
2697 + throw promise2;
2698 + } else {
2699 + return 'World';
2700 + }
2701 + }
2702 +
2703 + const ops = [];
2704 +
2705 + function App() {
2706 + return (
2707 + <div>
2708 + <Activity>
2709 + <span
2710 + onMouseEnter={() => ops.push('Mouse Enter First')}
2711 + onMouseLeave={() => ops.push('Mouse Leave First')}
2712 + />
2713 + {/* We suspend after to test what happens when we eager
2714 + attach the listener. */}
2715 + <First />
2716 + </Activity>
2717 + <Activity>
2718 + <span
2719 + onMouseEnter={() => ops.push('Mouse Enter Second')}
2720 + onMouseLeave={() => ops.push('Mouse Leave Second')}>
2721 + <Second />
2722 + </span>
2723 + </Activity>
2724 + </div>
2725 + );
2726 + }
2727 +
2728 + const finalHTML = ReactDOMServer.renderToString(<App />);
2729 + const container = document.createElement('div');
2730 + container.innerHTML = finalHTML;
2731 +
2732 + // We need this to be in the document since we'll dispatch events on it.
2733 + document.body.appendChild(container);
2734 +
2735 + const appDiv = container.getElementsByTagName('div')[0];
2736 + const firstSpan = appDiv.getElementsByTagName('span')[0];
2737 + const secondSpan = appDiv.getElementsByTagName('span')[1];
2738 + expect(firstSpan.textContent).toBe('');
2739 + expect(secondSpan.textContent).toBe('World');
2740 +
2741 + // On the client we don't have all data yet but we want to start
2742 + // hydrating anyway.
2743 + suspend1 = true;
2744 + suspend2 = true;
2745 + ReactDOMClient.hydrateRoot(container, <App />);
2746 +
2747 + await waitForAll([]);
2748 +
2749 + dispatchMouseEvent(appDiv, null);
2750 + dispatchMouseEvent(firstSpan, appDiv);
2751 + dispatchMouseEvent(secondSpan, firstSpan);
2752 +
2753 + // Neither target is yet hydrated.
2754 + expect(ops).toEqual([]);
2755 +
2756 + // Resolving the second promise so that rendering can complete.
2757 + suspend2 = false;
2758 + resolve2();
2759 + await promise2;
2760 +
2761 + await waitForAll([]);
2762 +
2763 + // We've unblocked the current hover target so we should be
2764 + // able to replay it now.
2765 + expect(ops).toEqual(['Mouse Enter Second']);
2766 +
2767 + // Resolving the first promise has no effect now.
2768 + suspend1 = false;
2769 + resolve1();
2770 + await promise1;
2771 +
2772 + await waitForAll([]);
2773 +
2774 + expect(ops).toEqual(['Mouse Enter Second']);
2775 +
2776 + document.body.removeChild(container);
2777 + });
2778 +
2779 + // @gate enableActivity
2780 + it('finishes normal pri work before continuing to hydrate a retry', async () => {
2781 + let suspend = false;
2782 + let resolve;
2783 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2784 + const ref = React.createRef();
2785 +
2786 + function Child() {
2787 + if (suspend) {
2788 + throw promise;
2789 + } else {
2790 + Scheduler.log('Child');
2791 + return 'Hello';
2792 + }
2793 + }
2794 +
2795 + function Sibling() {
2796 + Scheduler.log('Sibling');
2797 + React.useLayoutEffect(() => {
2798 + Scheduler.log('Commit Sibling');
2799 + });
2800 + return 'World';
2801 + }
2802 +
2803 + // Avoid rerendering the tree by hoisting it.
2804 + const tree = (
2805 + <Activity>
2806 + <span ref={ref}>
2807 + <Child />
2808 + </span>
2809 + </Activity>
2810 + );
2811 +
2812 + function App({showSibling}) {
2813 + return (
2814 + <div>
2815 + {tree}
2816 + {showSibling ? <Sibling /> : null}
2817 + </div>
2818 + );
2819 + }
2820 +
2821 + suspend = false;
2822 + const finalHTML = ReactDOMServer.renderToString(<App />);
2823 + assertLog(['Child']);
2824 +
2825 + const container = document.createElement('div');
2826 + container.innerHTML = finalHTML;
2827 +
2828 + suspend = true;
2829 + const root = ReactDOMClient.hydrateRoot(
2830 + container,
2831 + <App showSibling={false} />,
2832 + );
2833 + await waitForAll([]);
2834 +
2835 + expect(ref.current).toBe(null);
2836 + expect(container.textContent).toBe('Hello');
2837 +
2838 + // Resolving the promise should continue hydration
2839 + suspend = false;
2840 + resolve();
2841 + await promise;
2842 +
2843 + Scheduler.unstable_advanceTime(100);
2844 +
2845 + // Before we have a chance to flush it, we'll also render an update.
2846 + root.render(<App showSibling={true} />);
2847 +
2848 + // When we flush we expect the Normal pri render to take priority
2849 + // over hydration.
2850 + await waitFor(['Sibling', 'Commit Sibling']);
2851 +
2852 + // We shouldn't have hydrated the child yet.
2853 + expect(ref.current).toBe(null);
2854 + // But we did have a chance to update the content.
2855 + expect(container.textContent).toBe('HelloWorld');
2856 +
2857 + await waitForAll(['Child']);
2858 +
2859 + // Now we're hydrated.
2860 + expect(ref.current).not.toBe(null);
2861 + });
2862 +
2863 + // @gate enableActivity
2864 + it('regression test: does not overfire non-bubbling browser events', async () => {
2865 + let suspend = false;
2866 + let resolve;
2867 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
2868 +
2869 + function Sibling({text}) {
2870 + if (suspend) {
2871 + throw promise;
2872 + } else {
2873 + return 'Hello';
2874 + }
2875 + }
2876 +
2877 + let submits = 0;
2878 +
2879 + function Form() {
2880 + const [submitted, setSubmitted] = React.useState(false);
2881 + if (submitted) {
2882 + return null;
2883 + }
2884 + return (
2885 + <form
2886 + onSubmit={() => {
2887 + setSubmitted(true);
2888 + submits++;
2889 + }}>
2890 + Click me
2891 + </form>
2892 + );
2893 + }
2894 +
2895 + function App() {
2896 + return (
2897 + <div>
2898 + <Activity>
2899 + <Form />
2900 + <Sibling />
2901 + </Activity>
2902 + </div>
2903 + );
2904 + }
2905 +
2906 + suspend = false;
2907 + const finalHTML = ReactDOMServer.renderToString(<App />);
2908 + const container = document.createElement('div');
2909 + container.innerHTML = finalHTML;
2910 +
2911 + // We need this to be in the document since we'll dispatch events on it.
2912 + document.body.appendChild(container);
2913 +
2914 + const form = container.getElementsByTagName('form')[0];
2915 +
2916 + // On the client we don't have all data yet but we want to start
2917 + // hydrating anyway.
2918 + suspend = true;
2919 + ReactDOMClient.hydrateRoot(container, <App />);
2920 + await waitForAll([]);
2921 +
2922 + expect(container.textContent).toBe('Click meHello');
2923 +
2924 + // We're now partially hydrated.
2925 + await act(() => {
2926 + form.dispatchEvent(
2927 + new window.Event('submit', {
2928 + bubbles: true,
2929 + }),
2930 + );
2931 + });
2932 + expect(submits).toBe(0);
2933 +
2934 + // Resolving the promise so that rendering can complete.
2935 + await act(async () => {
2936 + suspend = false;
2937 + resolve();
2938 + await promise;
2939 + });
2940 +
2941 + // discrete event not replayed
2942 + expect(submits).toBe(0);
2943 + expect(container.textContent).toBe('Click meHello');
2944 +
2945 + document.body.removeChild(container);
2946 + });
2947 +
2948 + // @gate enableActivity
2949 + it('fallback to client render on hydration mismatch at root', async () => {
2950 + let suspend = true;
2951 + let resolve;
2952 + const promise = new Promise((res, rej) => {
2953 + resolve = () => {
2954 + suspend = false;
2955 + res();
2956 + };
2957 + });
2958 + function App({isClient}) {
2959 + return (
2960 + <>
2961 + <Activity>
2962 + <ChildThatSuspends id={1} isClient={isClient} />
2963 + </Activity>
2964 + {isClient ? <span>client</span> : <div>server</div>}
2965 + <Activity>
2966 + <ChildThatSuspends id={2} isClient={isClient} />
2967 + </Activity>
2968 + </>
2969 + );
2970 + }
2971 + function ChildThatSuspends({id, isClient}) {
2972 + if (isClient && suspend) {
2973 + throw promise;
2974 + }
2975 + return <div>{id}</div>;
2976 + }
2977 +
2978 + const finalHTML = ReactDOMServer.renderToString(<App isClient={false} />);
2979 +
2980 + const container = document.createElement('div');
2981 + document.body.appendChild(container);
2982 + container.innerHTML = finalHTML;
2983 +
2984 + await act(() => {
2985 + ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
2986 + onRecoverableError(error) {
2987 + Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
2988 + if (error.cause) {
2989 + Scheduler.log('Cause: ' + normalizeError(error.cause.message));
2990 + }
2991 + },
2992 + });
2993 + });
2994 +
2995 + // We suspend the root while we wait for the promises to resolve, leaving the
2996 + // existing content in place.
2997 + expect(container.innerHTML).toEqual(
2998 + '<!--&--><div>1</div><!--/&--><div>server</div><!--&--><div>2</div><!--/&-->',
2999 + );
3000 +
3001 + await act(async () => {
3002 + resolve();
3003 + await promise;
3004 + });
3005 +
3006 + assertLog([
3007 + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
3008 + ]);
3009 +
3010 + expect(container.innerHTML).toEqual(
3011 + '<div>1</div><span>client</span><div>2</div>',
3012 + );
3013 + });
3014 +});
packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydrationActivity-test.internal.js new
+1609
@@ -0,0 +1,1609 @@
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 + */
9 +
10 +'use strict';
11 +
12 +import {createEventTarget} from 'dom-event-testing-library';
13 +
14 +let React;
15 +let ReactDOM;
16 +let ReactDOMClient;
17 +let ReactDOMServer;
18 +let ReactFeatureFlags;
19 +let Scheduler;
20 +let Activity;
21 +let act;
22 +let assertLog;
23 +let waitForAll;
24 +let waitFor;
25 +let waitForPaint;
26 +
27 +let IdleEventPriority;
28 +let ContinuousEventPriority;
29 +
30 +function dispatchMouseHoverEvent(to, from) {
31 + if (!to) {
32 + to = null;
33 + }
34 + if (!from) {
35 + from = null;
36 + }
37 + if (from) {
38 + const mouseOutEvent = document.createEvent('MouseEvents');
39 + mouseOutEvent.initMouseEvent(
40 + 'mouseout',
41 + true,
42 + true,
43 + window,
44 + 0,
45 + 50,
46 + 50,
47 + 50,
48 + 50,
49 + false,
50 + false,
51 + false,
52 + false,
53 + 0,
54 + to,
55 + );
56 + from.dispatchEvent(mouseOutEvent);
57 + }
58 + if (to) {
59 + const mouseOverEvent = document.createEvent('MouseEvents');
60 + mouseOverEvent.initMouseEvent(
61 + 'mouseover',
62 + true,
63 + true,
64 + window,
65 + 0,
66 + 50,
67 + 50,
68 + 50,
69 + 50,
70 + false,
71 + false,
72 + false,
73 + false,
74 + 0,
75 + from,
76 + );
77 + to.dispatchEvent(mouseOverEvent);
78 + }
79 +}
80 +
81 +function dispatchClickEvent(target) {
82 + const mouseOutEvent = document.createEvent('MouseEvents');
83 + mouseOutEvent.initMouseEvent(
84 + 'click',
85 + true,
86 + true,
87 + window,
88 + 0,
89 + 50,
90 + 50,
91 + 50,
92 + 50,
93 + false,
94 + false,
95 + false,
96 + false,
97 + 0,
98 + target,
99 + );
100 + return target.dispatchEvent(mouseOutEvent);
101 +}
102 +
103 +// TODO: There's currently no React DOM API to opt into Idle priority updates,
104 +// and there's no native DOM event that maps to idle priority, so this is a
105 +// temporary workaround. Need something like ReactDOM.unstable_IdleUpdates.
106 +function TODO_scheduleIdleDOMSchedulerTask(fn) {
107 + ReactDOM.unstable_runWithPriority(IdleEventPriority, () => {
108 + const prevEvent = window.event;
109 + window.event = {type: 'message'};
110 + try {
111 + fn();
112 + } finally {
113 + window.event = prevEvent;
114 + }
115 + });
116 +}
117 +
118 +function TODO_scheduleContinuousSchedulerTask(fn) {
119 + ReactDOM.unstable_runWithPriority(ContinuousEventPriority, () => {
120 + const prevEvent = window.event;
121 + window.event = {type: 'message'};
122 + try {
123 + fn();
124 + } finally {
125 + window.event = prevEvent;
126 + }
127 + });
128 +}
129 +
130 +describe('ReactDOMServerSelectiveHydrationActivity', () => {
131 + beforeEach(() => {
132 + jest.resetModules();
133 +
134 + ReactFeatureFlags = require('shared/ReactFeatureFlags');
135 + ReactFeatureFlags.enableCreateEventHandleAPI = true;
136 + React = require('react');
137 + ReactDOM = require('react-dom');
138 + ReactDOMClient = require('react-dom/client');
139 + ReactDOMServer = require('react-dom/server');
140 + act = require('internal-test-utils').act;
141 + Scheduler = require('scheduler');
142 + Activity = React.unstable_Activity;
143 +
144 + const InternalTestUtils = require('internal-test-utils');
145 + assertLog = InternalTestUtils.assertLog;
146 + waitForAll = InternalTestUtils.waitForAll;
147 + waitFor = InternalTestUtils.waitFor;
148 + waitForPaint = InternalTestUtils.waitForPaint;
149 +
150 + IdleEventPriority = require('react-reconciler/constants').IdleEventPriority;
151 + ContinuousEventPriority =
152 + require('react-reconciler/constants').ContinuousEventPriority;
153 + });
154 +
155 + // @gate enableActivity
156 + it('hydrates the target boundary synchronously during a click', async () => {
157 + function Child({text}) {
158 + Scheduler.log(text);
159 + return (
160 + <span
161 + onClick={e => {
162 + e.preventDefault();
163 + Scheduler.log('Clicked ' + text);
164 + }}>
165 + {text}
166 + </span>
167 + );
168 + }
169 +
170 + function App() {
171 + Scheduler.log('App');
172 + return (
173 + <div>
174 + <Activity>
175 + <Child text="A" />
176 + </Activity>
177 + <Activity>
178 + <Child text="B" />
179 + </Activity>
180 + </div>
181 + );
182 + }
183 +
184 + const finalHTML = ReactDOMServer.renderToString(<App />);
185 +
186 + assertLog(['App', 'A', 'B']);
187 +
188 + const container = document.createElement('div');
189 + // We need this to be in the document since we'll dispatch events on it.
190 + document.body.appendChild(container);
191 +
192 + container.innerHTML = finalHTML;
193 +
194 + const span = container.getElementsByTagName('span')[1];
195 +
196 + ReactDOMClient.hydrateRoot(container, <App />);
197 +
198 + // Nothing has been hydrated so far.
199 + assertLog([]);
200 +
201 + // This should synchronously hydrate the root App and the second suspense
202 + // boundary.
203 + const result = dispatchClickEvent(span);
204 +
205 + // The event should have been canceled because we called preventDefault.
206 + expect(result).toBe(false);
207 +
208 + // We rendered App, B and then invoked the event without rendering A.
209 + assertLog(['App', 'B', 'Clicked B']);
210 +
211 + // After continuing the scheduler, we finally hydrate A.
212 + await waitForAll(['A']);
213 +
214 + document.body.removeChild(container);
215 + });
216 +
217 + // @gate enableActivity
218 + it('hydrates at higher pri if sync did not work first time', async () => {
219 + let suspend = false;
220 + let resolve;
221 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
222 +
223 + function Child({text}) {
224 + if ((text === 'A' || text === 'D') && suspend) {
225 + throw promise;
226 + }
227 + Scheduler.log(text);
228 + return (
229 + <span
230 + onClick={e => {
231 + e.preventDefault();
232 + Scheduler.log('Clicked ' + text);
233 + }}>
234 + {text}
235 + </span>
236 + );
237 + }
238 +
239 + function App() {
240 + Scheduler.log('App');
241 + return (
242 + <div>
243 + <Activity>
244 + <Child text="A" />
245 + </Activity>
246 + <Activity>
247 + <Child text="B" />
248 + </Activity>
249 + <Activity>
250 + <Child text="C" />
251 + </Activity>
252 + <Activity>
253 + <Child text="D" />
254 + </Activity>
255 + </div>
256 + );
257 + }
258 +
259 + const finalHTML = ReactDOMServer.renderToString(<App />);
260 +
261 + assertLog(['App', 'A', 'B', 'C', 'D']);
262 +
263 + const container = document.createElement('div');
264 + // We need this to be in the document since we'll dispatch events on it.
265 + document.body.appendChild(container);
266 +
267 + container.innerHTML = finalHTML;
268 +
269 + const spanD = container.getElementsByTagName('span')[3];
270 +
271 + suspend = true;
272 +
273 + // A and D will be suspended. We'll click on D which should take
274 + // priority, after we unsuspend.
275 + ReactDOMClient.hydrateRoot(container, <App />);
276 +
277 + // Nothing has been hydrated so far.
278 + assertLog([]);
279 +
280 + // This click target cannot be hydrated yet because it's suspended.
281 + await act(() => {
282 + const result = dispatchClickEvent(spanD);
283 + expect(result).toBe(true);
284 + });
285 + assertLog([
286 + 'App',
287 + // Continuing rendering will render B next.
288 + 'B',
289 + 'C',
290 + ]);
291 +
292 + await act(async () => {
293 + suspend = false;
294 + resolve();
295 + await promise;
296 + });
297 +
298 + assertLog(['D', 'A']);
299 +
300 + document.body.removeChild(container);
301 + });
302 +
303 + // @gate enableActivity
304 + it('hydrates at higher pri for secondary discrete events', async () => {
305 + let suspend = false;
306 + let resolve;
307 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
308 +
309 + function Child({text}) {
310 + if ((text === 'A' || text === 'D') && suspend) {
311 + throw promise;
312 + }
313 + Scheduler.log(text);
314 + return (
315 + <span
316 + onClick={e => {
317 + e.preventDefault();
318 + Scheduler.log('Clicked ' + text);
319 + }}>
320 + {text}
321 + </span>
322 + );
323 + }
324 +
325 + function App() {
326 + Scheduler.log('App');
327 + return (
328 + <div>
329 + <Activity>
330 + <Child text="A" />
331 + </Activity>
332 + <Activity>
333 + <Child text="B" />
334 + </Activity>
335 + <Activity>
336 + <Child text="C" />
337 + </Activity>
338 + <Activity>
339 + <Child text="D" />
340 + </Activity>
341 + </div>
342 + );
343 + }
344 +
345 + const finalHTML = ReactDOMServer.renderToString(<App />);
346 +
347 + assertLog(['App', 'A', 'B', 'C', 'D']);
348 +
349 + const container = document.createElement('div');
350 + // We need this to be in the document since we'll dispatch events on it.
351 + document.body.appendChild(container);
352 +
353 + container.innerHTML = finalHTML;
354 +
355 + const spanA = container.getElementsByTagName('span')[0];
356 + const spanC = container.getElementsByTagName('span')[2];
357 + const spanD = container.getElementsByTagName('span')[3];
358 +
359 + suspend = true;
360 +
361 + // A and D will be suspended. We'll click on D which should take
362 + // priority, after we unsuspend.
363 + ReactDOMClient.hydrateRoot(container, <App />);
364 +
365 + // Nothing has been hydrated so far.
366 + assertLog([]);
367 +
368 + // This click target cannot be hydrated yet because the first is Suspended.
369 + dispatchClickEvent(spanA);
370 + dispatchClickEvent(spanC);
371 + dispatchClickEvent(spanD);
372 +
373 + assertLog(['App', 'C', 'Clicked C']);
374 +
375 + await act(async () => {
376 + suspend = false;
377 + resolve();
378 + await promise;
379 + });
380 +
381 + assertLog([
382 + 'A',
383 + 'D',
384 + // B should render last since it wasn't clicked.
385 + 'B',
386 + ]);
387 +
388 + document.body.removeChild(container);
389 + });
390 +
391 + // @gate enableActivity && www
392 + it('hydrates the target boundary synchronously during a click (createEventHandle)', async () => {
393 + const setClick = ReactDOM.unstable_createEventHandle('click');
394 + let isServerRendering = true;
395 +
396 + function Child({text}) {
397 + const ref = React.useRef(null);
398 + Scheduler.log(text);
399 + if (!isServerRendering) {
400 + React.useLayoutEffect(() => {
401 + return setClick(ref.current, () => {
402 + Scheduler.log('Clicked ' + text);
403 + });
404 + });
405 + }
406 +
407 + return <span ref={ref}>{text}</span>;
408 + }
409 +
410 + function App() {
411 + Scheduler.log('App');
412 + return (
413 + <div>
414 + <Activity>
415 + <Child text="A" />
416 + </Activity>
417 + <Activity>
418 + <Child text="B" />
419 + </Activity>
420 + </div>
421 + );
422 + }
423 +
424 + const finalHTML = ReactDOMServer.renderToString(<App />);
425 +
426 + assertLog(['App', 'A', 'B']);
427 +
428 + const container = document.createElement('div');
429 + // We need this to be in the document since we'll dispatch events on it.
430 + document.body.appendChild(container);
431 +
432 + container.innerHTML = finalHTML;
433 +
434 + isServerRendering = false;
435 +
436 + ReactDOMClient.hydrateRoot(container, <App />);
437 +
438 + // Nothing has been hydrated so far.
439 + assertLog([]);
440 +
441 + const span = container.getElementsByTagName('span')[1];
442 +
443 + const target = createEventTarget(span);
444 +
445 + // This should synchronously hydrate the root App and the second suspense
446 + // boundary.
447 + target.virtualclick();
448 +
449 + // We rendered App, B and then invoked the event without rendering A.
450 + assertLog(['App', 'B', 'Clicked B']);
451 +
452 + // After continuing the scheduler, we finally hydrate A.
453 + await waitForAll(['A']);
454 +
455 + document.body.removeChild(container);
456 + });
457 +
458 + // @gate enableActivity && www
459 + it('hydrates at higher pri if sync did not work first time (createEventHandle)', async () => {
460 + let suspend = false;
461 + let isServerRendering = true;
462 + let resolve;
463 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
464 + const setClick = ReactDOM.unstable_createEventHandle('click');
465 +
466 + function Child({text}) {
467 + const ref = React.useRef(null);
468 + if ((text === 'A' || text === 'D') && suspend) {
469 + throw promise;
470 + }
471 + Scheduler.log(text);
472 +
473 + if (!isServerRendering) {
474 + React.useLayoutEffect(() => {
475 + return setClick(ref.current, () => {
476 + Scheduler.log('Clicked ' + text);
477 + });
478 + });
479 + }
480 +
481 + return <span ref={ref}>{text}</span>;
482 + }
483 +
484 + function App() {
485 + Scheduler.log('App');
486 + return (
487 + <div>
488 + <Activity>
489 + <Child text="A" />
490 + </Activity>
491 + <Activity>
492 + <Child text="B" />
493 + </Activity>
494 + <Activity>
495 + <Child text="C" />
496 + </Activity>
497 + <Activity>
498 + <Child text="D" />
499 + </Activity>
500 + </div>
501 + );
502 + }
503 +
504 + const finalHTML = ReactDOMServer.renderToString(<App />);
505 +
506 + assertLog(['App', 'A', 'B', 'C', 'D']);
507 +
508 + const container = document.createElement('div');
509 + // We need this to be in the document since we'll dispatch events on it.
510 + document.body.appendChild(container);
511 +
512 + container.innerHTML = finalHTML;
513 +
514 + const spanD = container.getElementsByTagName('span')[3];
515 +
516 + suspend = true;
517 + isServerRendering = false;
518 +
519 + // A and D will be suspended. We'll click on D which should take
520 + // priority, after we unsuspend.
521 + ReactDOMClient.hydrateRoot(container, <App />);
522 +
523 + // Nothing has been hydrated so far.
524 + assertLog([]);
525 +
526 + // Continuing rendering will render B next.
527 + await act(() => {
528 + const target = createEventTarget(spanD);
529 + target.virtualclick();
530 + });
531 + assertLog(['App', 'B', 'C']);
532 +
533 + // After the click, we should prioritize D and the Click first,
534 + // and only after that render A and C.
535 + await act(async () => {
536 + suspend = false;
537 + resolve();
538 + await promise;
539 + });
540 +
541 + // no replay
542 + assertLog(['D', 'A']);
543 +
544 + document.body.removeChild(container);
545 + });
546 +
547 + // @gate enableActivity && www
548 + it('hydrates at higher pri for secondary discrete events (createEventHandle)', async () => {
549 + const setClick = ReactDOM.unstable_createEventHandle('click');
550 + let suspend = false;
551 + let isServerRendering = true;
552 + let resolve;
553 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
554 +
555 + function Child({text}) {
556 + const ref = React.useRef(null);
557 + if ((text === 'A' || text === 'D') && suspend) {
558 + throw promise;
559 + }
560 + Scheduler.log(text);
561 +
562 + if (!isServerRendering) {
563 + React.useLayoutEffect(() => {
564 + return setClick(ref.current, () => {
565 + Scheduler.log('Clicked ' + text);
566 + });
567 + });
568 + }
569 + return <span ref={ref}>{text}</span>;
570 + }
571 +
572 + function App() {
573 + Scheduler.log('App');
574 + return (
575 + <div>
576 + <Activity>
577 + <Child text="A" />
578 + </Activity>
579 + <Activity>
580 + <Child text="B" />
581 + </Activity>
582 + <Activity>
583 + <Child text="C" />
584 + </Activity>
585 + <Activity>
586 + <Child text="D" />
587 + </Activity>
588 + </div>
589 + );
590 + }
591 +
592 + const finalHTML = ReactDOMServer.renderToString(<App />);
593 +
594 + assertLog(['App', 'A', 'B', 'C', 'D']);
595 +
596 + const container = document.createElement('div');
597 + // We need this to be in the document since we'll dispatch events on it.
598 + document.body.appendChild(container);
599 +
600 + container.innerHTML = finalHTML;
601 +
602 + const spanA = container.getElementsByTagName('span')[0];
603 + const spanC = container.getElementsByTagName('span')[2];
604 + const spanD = container.getElementsByTagName('span')[3];
605 +
606 + suspend = true;
607 + isServerRendering = false;
608 +
609 + // A and D will be suspended. We'll click on D which should take
610 + // priority, after we unsuspend.
611 + ReactDOMClient.hydrateRoot(container, <App />);
612 +
613 + // Nothing has been hydrated so far.
614 + assertLog([]);
615 +
616 + // This click target cannot be hydrated yet because the first is Suspended.
617 + createEventTarget(spanA).virtualclick();
618 + createEventTarget(spanC).virtualclick();
619 + createEventTarget(spanD).virtualclick();
620 +
621 + assertLog(['App', 'C', 'Clicked C']);
622 +
623 + await act(async () => {
624 + suspend = false;
625 + resolve();
626 + await promise;
627 + });
628 +
629 + assertLog([
630 + 'A',
631 + 'D',
632 + // B should render last since it wasn't clicked.
633 + 'B',
634 + ]);
635 +
636 + document.body.removeChild(container);
637 + });
638 +
639 + // @gate enableActivity
640 + it('hydrates the hovered targets as higher priority for continuous events', async () => {
641 + let suspend = false;
642 + let resolve;
643 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
644 + function Child({text}) {
645 + if ((text === 'A' || text === 'D') && suspend) {
646 + throw promise;
647 + }
648 + Scheduler.log(text);
649 + return (
650 + <span
651 + onClick={e => {
652 + e.preventDefault();
653 + Scheduler.log('Clicked ' + text);
654 + }}
655 + onMouseEnter={e => {
656 + e.preventDefault();
657 + Scheduler.log('Hover ' + text);
658 + }}>
659 + {text}
660 + </span>
661 + );
662 + }
663 +
664 + function App() {
665 + Scheduler.log('App');
666 + return (
667 + <div>
668 + <Activity>
669 + <Child text="A" />
670 + </Activity>
671 + <Activity>
672 + <Child text="B" />
673 + </Activity>
674 + <Activity>
675 + <Child text="C" />
676 + </Activity>
677 + <Activity>
678 + <Child text="D" />
679 + </Activity>
680 + </div>
681 + );
682 + }
683 + const finalHTML = ReactDOMServer.renderToString(<App />);
684 + assertLog(['App', 'A', 'B', 'C', 'D']);
685 + const container = document.createElement('div');
686 + // We need this to be in the document since we'll dispatch events on it.
687 + document.body.appendChild(container);
688 +
689 + container.innerHTML = finalHTML;
690 +
691 + const spanB = container.getElementsByTagName('span')[1];
692 + const spanC = container.getElementsByTagName('span')[2];
693 + const spanD = container.getElementsByTagName('span')[3];
694 +
695 + suspend = true;
696 +
697 + // A and D will be suspended. We'll click on D which should take
698 + // priority, after we unsuspend.
699 + ReactDOMClient.hydrateRoot(container, <App />);
700 +
701 + // Nothing has been hydrated so far.
702 + assertLog([]);
703 +
704 + await act(() => {
705 + // Click D
706 + dispatchMouseHoverEvent(spanD, null);
707 + dispatchClickEvent(spanD);
708 +
709 + // Hover over B and then C.
710 + dispatchMouseHoverEvent(spanB, spanD);
711 + dispatchMouseHoverEvent(spanC, spanB);
712 +
713 + assertLog(['App']);
714 +
715 + suspend = false;
716 + resolve();
717 + });
718 +
719 + // We should prioritize hydrating D first because we clicked it.
720 + // but event isnt replayed
721 + assertLog([
722 + 'D',
723 + 'B', // Ideally this should be later.
724 + 'C',
725 + 'Hover C',
726 + 'A',
727 + ]);
728 +
729 + document.body.removeChild(container);
730 + });
731 +
732 + // @gate enableActivity
733 + it('replays capture phase for continuous events and respects stopPropagation', async () => {
734 + let suspend = false;
735 + let resolve;
736 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
737 +
738 + function Child({text}) {
739 + if ((text === 'A' || text === 'D') && suspend) {
740 + throw promise;
741 + }
742 + Scheduler.log(text);
743 + return (
744 + <span
745 + id={text}
746 + onClickCapture={e => {
747 + e.preventDefault();
748 + Scheduler.log('Capture Clicked ' + text);
749 + }}
750 + onClick={e => {
751 + e.preventDefault();
752 + Scheduler.log('Clicked ' + text);
753 + }}
754 + onMouseEnter={e => {
755 + e.preventDefault();
756 + Scheduler.log('Mouse Enter ' + text);
757 + }}
758 + onMouseOut={e => {
759 + e.preventDefault();
760 + Scheduler.log('Mouse Out ' + text);
761 + }}
762 + onMouseOutCapture={e => {
763 + e.preventDefault();
764 + e.stopPropagation();
765 + Scheduler.log('Mouse Out Capture ' + text);
766 + }}
767 + onMouseOverCapture={e => {
768 + e.preventDefault();
769 + e.stopPropagation();
770 + Scheduler.log('Mouse Over Capture ' + text);
771 + }}
772 + onMouseOver={e => {
773 + e.preventDefault();
774 + Scheduler.log('Mouse Over ' + text);
775 + }}>
776 + <div
777 + onMouseOverCapture={e => {
778 + e.preventDefault();
779 + Scheduler.log('Mouse Over Capture Inner ' + text);
780 + }}>
781 + {text}
782 + </div>
783 + </span>
784 + );
785 + }
786 +
787 + function App() {
788 + Scheduler.log('App');
789 + return (
790 + <div
791 + onClickCapture={e => {
792 + e.preventDefault();
793 + Scheduler.log('Capture Clicked Parent');
794 + }}
795 + onMouseOverCapture={e => {
796 + Scheduler.log('Mouse Over Capture Parent');
797 + }}>
798 + <Activity>
799 + <Child text="A" />
800 + </Activity>
801 + <Activity>
802 + <Child text="B" />
803 + </Activity>
804 + <Activity>
805 + <Child text="C" />
806 + </Activity>
807 + <Activity>
808 + <Child text="D" />
809 + </Activity>
810 + </div>
811 + );
812 + }
813 +
814 + const finalHTML = ReactDOMServer.renderToString(<App />);
815 +
816 + assertLog(['App', 'A', 'B', 'C', 'D']);
817 +
818 + const container = document.createElement('div');
819 + // We need this to be in the document since we'll dispatch events on it.
820 + document.body.appendChild(container);
821 +
822 + container.innerHTML = finalHTML;
823 +
824 + const spanB = document.getElementById('B').firstChild;
825 + const spanC = document.getElementById('C').firstChild;
826 + const spanD = document.getElementById('D').firstChild;
827 +
828 + suspend = true;
829 +
830 + // A and D will be suspended. We'll click on D which should take
831 + // priority, after we unsuspend.
832 + ReactDOMClient.hydrateRoot(container, <App />);
833 +
834 + // Nothing has been hydrated so far.
835 + assertLog([]);
836 +
837 + await act(async () => {
838 + // Click D
839 + dispatchMouseHoverEvent(spanD, null);
840 + dispatchClickEvent(spanD);
841 + // Hover over B and then C.
842 + dispatchMouseHoverEvent(spanB, spanD);
843 + dispatchMouseHoverEvent(spanC, spanB);
844 +
845 + assertLog(['App']);
846 +
847 + suspend = false;
848 + resolve();
849 + });
850 +
851 + // We should prioritize hydrating D first because we clicked it.
852 + // but event isnt replayed
853 + assertLog([
854 + 'D',
855 + 'B', // Ideally this should be later.
856 + 'C',
857 + // Mouse out events aren't replayed
858 + // 'Mouse Out Capture B',
859 + // 'Mouse Out B',
860 + 'Mouse Over Capture Parent',
861 + 'Mouse Over Capture C',
862 + // Stop propagation stops these
863 + // 'Mouse Over Capture Inner C',
864 + // 'Mouse Over C',
865 + 'A',
866 + ]);
867 +
868 + // This test shows existing quirk where stopPropagation on mouseout
869 + // prevents mouseEnter from firing
870 + dispatchMouseHoverEvent(spanC, spanB);
871 + assertLog([
872 + 'Mouse Out Capture B',
873 + // stopPropagation stops these
874 + // 'Mouse Out B',
875 + // 'Mouse Enter C',
876 + 'Mouse Over Capture Parent',
877 + 'Mouse Over Capture C',
878 + // Stop propagation stops these
879 + // 'Mouse Over Capture Inner C',
880 + // 'Mouse Over C',
881 + ]);
882 +
883 + document.body.removeChild(container);
884 + });
885 +
886 + // @gate enableActivity
887 + it('replays event with null target when tree is dismounted', async () => {
888 + let suspend = false;
889 + let resolve;
890 + const promise = new Promise(resolvePromise => {
891 + resolve = () => {
892 + suspend = false;
893 + resolvePromise();
894 + };
895 + });
896 +
897 + function Child() {
898 + if (suspend) {
899 + throw promise;
900 + }
901 + Scheduler.log('Child');
902 + return (
903 + <div
904 + onMouseOver={() => {
905 + Scheduler.log('on mouse over');
906 + }}>
907 + Child
908 + </div>
909 + );
910 + }
911 +
912 + function App() {
913 + return (
914 + <Activity>
915 + <Child />
916 + </Activity>
917 + );
918 + }
919 +
920 + const finalHTML = ReactDOMServer.renderToString(<App />);
921 + assertLog(['Child']);
922 +
923 + const container = document.createElement('div');
924 +
925 + document.body.appendChild(container);
926 + container.innerHTML = finalHTML;
927 + suspend = true;
928 +
929 + ReactDOMClient.hydrateRoot(container, <App />);
930 +
931 + const childDiv = container.firstElementChild;
932 +
933 + await act(async () => {
934 + dispatchMouseHoverEvent(childDiv);
935 +
936 + // Not hydrated so event is saved for replay and stopPropagation is called
937 + assertLog([]);
938 +
939 + resolve();
940 + await waitFor(['Child']);
941 +
942 + ReactDOM.flushSync(() => {
943 + container.removeChild(childDiv);
944 +
945 + const container2 = document.createElement('div');
946 + container2.addEventListener('mouseover', () => {
947 + Scheduler.log('container2 mouse over');
948 + });
949 + container2.appendChild(childDiv);
950 + });
951 + });
952 +
953 + // Even though the tree is remove the event is still dispatched with native event handler
954 + // on the container firing.
955 + assertLog(['container2 mouse over']);
956 +
957 + document.body.removeChild(container);
958 + });
959 +
960 + // @gate enableActivity
961 + it('hydrates the last target path first for continuous events', async () => {
962 + let suspend = false;
963 + let resolve;
964 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
965 +
966 + function Child({text}) {
967 + if ((text === 'A' || text === 'D') && suspend) {
968 + throw promise;
969 + }
970 + Scheduler.log(text);
971 + return (
972 + <span
973 + onMouseEnter={e => {
974 + e.preventDefault();
975 + Scheduler.log('Hover ' + text);
976 + }}>
977 + {text}
978 + </span>
979 + );
980 + }
981 +
982 + function App() {
983 + Scheduler.log('App');
984 + return (
985 + <div>
986 + <Activity>
987 + <Child text="A" />
988 + </Activity>
989 + <Activity>
990 + <div>
991 + <Activity>
992 + <Child text="B" />
993 + </Activity>
994 + </div>
995 + <Child text="C" />
996 + </Activity>
997 + <Activity>
998 + <Child text="D" />
999 + </Activity>
1000 + </div>
1001 + );
1002 + }
1003 +
1004 + const finalHTML = ReactDOMServer.renderToString(<App />);
1005 +
1006 + assertLog(['App', 'A', 'B', 'C', 'D']);
1007 +
1008 + const container = document.createElement('div');
1009 + // We need this to be in the document since we'll dispatch events on it.
1010 + document.body.appendChild(container);
1011 +
1012 + container.innerHTML = finalHTML;
1013 +
1014 + const spanB = container.getElementsByTagName('span')[1];
1015 + const spanC = container.getElementsByTagName('span')[2];
1016 + const spanD = container.getElementsByTagName('span')[3];
1017 +
1018 + suspend = true;
1019 +
1020 + // A and D will be suspended. We'll click on D which should take
1021 + // priority, after we unsuspend.
1022 + ReactDOMClient.hydrateRoot(container, <App />);
1023 +
1024 + // Nothing has been hydrated so far.
1025 + assertLog([]);
1026 +
1027 + // Hover over B and then C.
1028 + dispatchMouseHoverEvent(spanB, spanD);
1029 + dispatchMouseHoverEvent(spanC, spanB);
1030 +
1031 + await act(async () => {
1032 + suspend = false;
1033 + resolve();
1034 + await promise;
1035 + });
1036 +
1037 + // We should prioritize hydrating D first because we clicked it.
1038 + // Next we should hydrate C since that's the current hover target.
1039 + // Next it doesn't matter if we hydrate A or B first but as an
1040 + // implementation detail we're currently hydrating B first since
1041 + // we at one point hovered over it and we never deprioritized it.
1042 + assertLog(['App', 'C', 'Hover C', 'A', 'B', 'D']);
1043 +
1044 + document.body.removeChild(container);
1045 + });
1046 +
1047 + // @gate enableActivity
1048 + it('hydrates the last explicitly hydrated target at higher priority', async () => {
1049 + function Child({text}) {
1050 + Scheduler.log(text);
1051 + return <span>{text}</span>;
1052 + }
1053 +
1054 + function App() {
1055 + Scheduler.log('App');
1056 + return (
1057 + <div>
1058 + <Activity>
1059 + <Child text="A" />
1060 + </Activity>
1061 + <Activity>
1062 + <Child text="B" />
1063 + </Activity>
1064 + <Activity>
1065 + <Child text="C" />
1066 + </Activity>
1067 + </div>
1068 + );
1069 + }
1070 +
1071 + const finalHTML = ReactDOMServer.renderToString(<App />);
1072 +
1073 + assertLog(['App', 'A', 'B', 'C']);
1074 +
1075 + const container = document.createElement('div');
1076 + container.innerHTML = finalHTML;
1077 +
1078 + const spanB = container.getElementsByTagName('span')[1];
1079 + const spanC = container.getElementsByTagName('span')[2];
1080 +
1081 + const root = ReactDOMClient.hydrateRoot(container, <App />);
1082 +
1083 + // Nothing has been hydrated so far.
1084 + assertLog([]);
1085 +
1086 + // Increase priority of B and then C.
1087 + root.unstable_scheduleHydration(spanB);
1088 + root.unstable_scheduleHydration(spanC);
1089 +
1090 + // We should prioritize hydrating C first because the last added
1091 + // gets highest priority followed by the next added.
1092 + await waitForAll(['App', 'C', 'B', 'A']);
1093 + });
1094 +
1095 + // @gate enableActivity && www
1096 + it('hydrates before an update even if hydration moves away from it', async () => {
1097 + function Child({text}) {
1098 + Scheduler.log(text);
1099 + return <span>{text}</span>;
1100 + }
1101 + const ChildWithBoundary = React.memo(function ({text}) {
1102 + return (
1103 + <Activity>
1104 + <Child text={text} />
1105 + <Child text={text.toLowerCase()} />
1106 + </Activity>
1107 + );
1108 + });
1109 +
1110 + function App({a}) {
1111 + Scheduler.log('App');
1112 + React.useEffect(() => {
1113 + Scheduler.log('Commit');
1114 + });
1115 + return (
1116 + <div>
1117 + <ChildWithBoundary text={a} />
1118 + <ChildWithBoundary text="B" />
1119 + <ChildWithBoundary text="C" />
1120 + </div>
1121 + );
1122 + }
1123 +
1124 + const finalHTML = ReactDOMServer.renderToString(<App a="A" />);
1125 +
1126 + assertLog(['App', 'A', 'a', 'B', 'b', 'C', 'c']);
1127 +
1128 + const container = document.createElement('div');
1129 + container.innerHTML = finalHTML;
1130 +
1131 + // We need this to be in the document since we'll dispatch events on it.
1132 + document.body.appendChild(container);
1133 +
1134 + const spanA = container.getElementsByTagName('span')[0];
1135 + const spanB = container.getElementsByTagName('span')[2];
1136 + const spanC = container.getElementsByTagName('span')[4];
1137 +
1138 + await act(async () => {
1139 + const root = ReactDOMClient.hydrateRoot(container, <App a="A" />);
1140 + // Hydrate the shell.
1141 + await waitFor(['App', 'Commit']);
1142 +
1143 + // Render an update at Idle priority that needs to update A.
1144 +
1145 + TODO_scheduleIdleDOMSchedulerTask(() => {
1146 + root.render(<App a="AA" />);
1147 + });
1148 +
1149 + // Start rendering. This will force the first boundary to hydrate
1150 + // by scheduling it at one higher pri than Idle.
1151 + await waitFor([
1152 + 'App',
1153 +
1154 + // Start hydrating A
1155 + 'A',
1156 + ]);
1157 +
1158 + // Hover over A which (could) schedule at one higher pri than Idle.
1159 + dispatchMouseHoverEvent(spanA, null);
1160 +
1161 + // Before, we're done we now switch to hover over B.
1162 + // This is meant to test that this doesn't cause us to forget that
1163 + // we still have to hydrate A. The first boundary.
1164 + // This also tests that we don't do the -1 down-prioritization of
1165 + // continuous hover events because that would decrease its priority
1166 + // to Idle.
1167 + dispatchMouseHoverEvent(spanB, spanA);
1168 +
1169 + // Also click C to prioritize that even higher which resets the
1170 + // priority levels.
1171 + dispatchClickEvent(spanC);
1172 +
1173 + assertLog([
1174 + // Hydrate C first since we clicked it.
1175 + 'C',
1176 + 'c',
1177 + ]);
1178 +
1179 + await waitForAll([
1180 + // Finish hydration of A since we forced it to hydrate.
1181 + 'A',
1182 + 'a',
1183 + // Also, hydrate B since we hovered over it.
1184 + // It's not important which one comes first. A or B.
1185 + // As long as they both happen before the Idle update.
1186 + 'B',
1187 + 'b',
1188 + // Begin the Idle update again.
1189 + 'App',
1190 + 'AA',
1191 + 'aa',
1192 + 'Commit',
1193 + ]);
1194 + });
1195 +
1196 + const spanA2 = container.getElementsByTagName('span')[0];
1197 + // This is supposed to have been hydrated, not replaced.
1198 + expect(spanA).toBe(spanA2);
1199 +
1200 + document.body.removeChild(container);
1201 + });
1202 +
1203 + // @gate enableActivity
1204 + it('fires capture event handlers and native events if content is hydratable during discrete event', async () => {
1205 + spyOnDev(console, 'error');
1206 + function Child({text}) {
1207 + Scheduler.log(text);
1208 + const ref = React.useRef();
1209 + React.useLayoutEffect(() => {
1210 + if (!ref.current) {
1211 + return;
1212 + }
1213 + ref.current.onclick = () => {
1214 + Scheduler.log('Native Click ' + text);
1215 + };
1216 + }, [text]);
1217 + return (
1218 + <span
1219 + ref={ref}
1220 + onClickCapture={() => {
1221 + Scheduler.log('Capture Clicked ' + text);
1222 + }}
1223 + onClick={e => {
1224 + Scheduler.log('Clicked ' + text);
1225 + }}>
1226 + {text}
1227 + </span>
1228 + );
1229 + }
1230 +
1231 + function App() {
1232 + Scheduler.log('App');
1233 + return (
1234 + <div>
1235 + <Activity>
1236 + <Child text="A" />
1237 + </Activity>
1238 + <Activity>
1239 + <Child text="B" />
1240 + </Activity>
1241 + </div>
1242 + );
1243 + }
1244 +
1245 + const finalHTML = ReactDOMServer.renderToString(<App />);
1246 +
1247 + assertLog(['App', 'A', 'B']);
1248 +
1249 + const container = document.createElement('div');
1250 + // We need this to be in the document since we'll dispatch events on it.
1251 + document.body.appendChild(container);
1252 +
1253 + container.innerHTML = finalHTML;
1254 +
1255 + const span = container.getElementsByTagName('span')[1];
1256 +
1257 + ReactDOMClient.hydrateRoot(container, <App />);
1258 +
1259 + // Nothing has been hydrated so far.
1260 + assertLog([]);
1261 +
1262 + // This should synchronously hydrate the root App and the second suspense
1263 + // boundary.
1264 + dispatchClickEvent(span);
1265 +
1266 + // We rendered App, B and then invoked the event without rendering A.
1267 + assertLog(['App', 'B', 'Capture Clicked B', 'Native Click B', 'Clicked B']);
1268 +
1269 + // After continuing the scheduler, we finally hydrate A.
1270 + await waitForAll(['A']);
1271 +
1272 + document.body.removeChild(container);
1273 + });
1274 +
1275 + // @gate enableActivity
1276 + it('does not propagate discrete event if it cannot be synchronously hydrated', async () => {
1277 + let triggeredParent = false;
1278 + let triggeredChild = false;
1279 + let suspend = false;
1280 + const promise = new Promise(() => {});
1281 + function Child() {
1282 + if (suspend) {
1283 + throw promise;
1284 + }
1285 + Scheduler.log('Child');
1286 + return (
1287 + <span
1288 + onClickCapture={e => {
1289 + e.stopPropagation();
1290 + triggeredChild = true;
1291 + }}>
1292 + Click me
1293 + </span>
1294 + );
1295 + }
1296 + function App() {
1297 + const onClick = () => {
1298 + triggeredParent = true;
1299 + };
1300 + Scheduler.log('App');
1301 + return (
1302 + <div
1303 + ref={n => {
1304 + if (n) n.onclick = onClick;
1305 + }}
1306 + onClick={onClick}>
1307 + <Activity>
1308 + <Child />
1309 + </Activity>
1310 + </div>
1311 + );
1312 + }
1313 + const finalHTML = ReactDOMServer.renderToString(<App />);
1314 +
1315 + assertLog(['App', 'Child']);
1316 +
1317 + const container = document.createElement('div');
1318 + document.body.appendChild(container);
1319 + container.innerHTML = finalHTML;
1320 +
1321 + suspend = true;
1322 +
1323 + ReactDOMClient.hydrateRoot(container, <App />);
1324 + // Nothing has been hydrated so far.
1325 + assertLog([]);
1326 +
1327 + const span = container.getElementsByTagName('span')[0];
1328 + dispatchClickEvent(span);
1329 +
1330 + assertLog(['App']);
1331 +
1332 + dispatchClickEvent(span);
1333 +
1334 + expect(triggeredParent).toBe(false);
1335 + expect(triggeredChild).toBe(false);
1336 + });
1337 +
1338 + // @gate enableActivity
1339 + it('can force hydration in response to sync update', async () => {
1340 + function Child({text}) {
1341 + Scheduler.log(`Child ${text}`);
1342 + return <span ref={ref => (spanRef = ref)}>{text}</span>;
1343 + }
1344 + function App({text}) {
1345 + Scheduler.log(`App ${text}`);
1346 + return (
1347 + <div>
1348 + <Activity>
1349 + <Child text={text} />
1350 + </Activity>
1351 + </div>
1352 + );
1353 + }
1354 +
1355 + let spanRef;
1356 + const finalHTML = ReactDOMServer.renderToString(<App text="A" />);
1357 + assertLog(['App A', 'Child A']);
1358 + const container = document.createElement('div');
1359 + document.body.appendChild(container);
1360 + container.innerHTML = finalHTML;
1361 + const initialSpan = container.getElementsByTagName('span')[0];
1362 + const root = ReactDOMClient.hydrateRoot(container, <App text="A" />);
1363 + await waitForPaint(['App A']);
1364 +
1365 + await act(() => {
1366 + ReactDOM.flushSync(() => {
1367 + root.render(<App text="B" />);
1368 + });
1369 + });
1370 + assertLog(['App B', 'Child A', 'App B', 'Child B']);
1371 + expect(initialSpan).toBe(spanRef);
1372 + });
1373 +
1374 + // @gate enableActivity && www
1375 + it('can force hydration in response to continuous update', async () => {
1376 + function Child({text}) {
1377 + Scheduler.log(`Child ${text}`);
1378 + return <span ref={ref => (spanRef = ref)}>{text}</span>;
1379 + }
1380 + function App({text}) {
1381 + Scheduler.log(`App ${text}`);
1382 + return (
1383 + <div>
1384 + <Activity>
1385 + <Child text={text} />
1386 + </Activity>
1387 + </div>
1388 + );
1389 + }
1390 +
1391 + let spanRef;
1392 + const finalHTML = ReactDOMServer.renderToString(<App text="A" />);
1393 + assertLog(['App A', 'Child A']);
1394 + const container = document.createElement('div');
1395 + document.body.appendChild(container);
1396 + container.innerHTML = finalHTML;
1397 + const initialSpan = container.getElementsByTagName('span')[0];
1398 + const root = ReactDOMClient.hydrateRoot(container, <App text="A" />);
1399 + await waitForPaint(['App A']);
1400 +
1401 + await act(() => {
1402 + TODO_scheduleContinuousSchedulerTask(() => {
1403 + root.render(<App text="B" />);
1404 + });
1405 + });
1406 +
1407 + assertLog(['App B', 'Child A', 'App B', 'Child B']);
1408 + expect(initialSpan).toBe(spanRef);
1409 + });
1410 +
1411 + // @gate enableActivity
1412 + it('can force hydration in response to default update', async () => {
1413 + function Child({text}) {
1414 + Scheduler.log(`Child ${text}`);
1415 + return <span ref={ref => (spanRef = ref)}>{text}</span>;
1416 + }
1417 + function App({text}) {
1418 + Scheduler.log(`App ${text}`);
1419 + return (
1420 + <div>
1421 + <Activity>
1422 + <Child text={text} />
1423 + </Activity>
1424 + </div>
1425 + );
1426 + }
1427 +
1428 + let spanRef;
1429 + const finalHTML = ReactDOMServer.renderToString(<App text="A" />);
1430 + assertLog(['App A', 'Child A']);
1431 + const container = document.createElement('div');
1432 + document.body.appendChild(container);
1433 + container.innerHTML = finalHTML;
1434 + const initialSpan = container.getElementsByTagName('span')[0];
1435 + const root = ReactDOMClient.hydrateRoot(container, <App text="A" />);
1436 + await waitForPaint(['App A']);
1437 + await act(() => {
1438 + root.render(<App text="B" />);
1439 + });
1440 + assertLog(['App B', 'Child A', 'App B', 'Child B']);
1441 + expect(initialSpan).toBe(spanRef);
1442 + });
1443 +
1444 + // @gate enableActivity && www
1445 + it('regression test: can unwind context on selective hydration interruption', async () => {
1446 + const Context = React.createContext('DefaultContext');
1447 +
1448 + function ContextReader(props) {
1449 + const value = React.useContext(Context);
1450 + Scheduler.log(value);
1451 + return null;
1452 + }
1453 +
1454 + function Child({text}) {
1455 + Scheduler.log(text);
1456 + return <span>{text}</span>;
1457 + }
1458 + const ChildWithBoundary = React.memo(function ({text}) {
1459 + return (
1460 + <Activity>
1461 + <Child text={text} />
1462 + </Activity>
1463 + );
1464 + });
1465 +
1466 + function App({a}) {
1467 + Scheduler.log('App');
1468 + React.useEffect(() => {
1469 + Scheduler.log('Commit');
1470 + });
1471 + return (
1472 + <>
1473 + <Context.Provider value="SiblingContext">
1474 + <ChildWithBoundary text={a} />
1475 + </Context.Provider>
1476 + <ContextReader />
1477 + </>
1478 + );
1479 + }
1480 + const finalHTML = ReactDOMServer.renderToString(<App a="A" />);
1481 + assertLog(['App', 'A', 'DefaultContext']);
1482 + const container = document.createElement('div');
1483 + container.innerHTML = finalHTML;
1484 + document.body.appendChild(container);
1485 +
1486 + const spanA = container.getElementsByTagName('span')[0];
1487 +
1488 + await act(async () => {
1489 + const root = ReactDOMClient.hydrateRoot(container, <App a="A" />);
1490 + await waitFor(['App', 'DefaultContext', 'Commit']);
1491 +
1492 + TODO_scheduleIdleDOMSchedulerTask(() => {
1493 + root.render(<App a="AA" />);
1494 + });
1495 + await waitFor(['App', 'A']);
1496 +
1497 + dispatchClickEvent(spanA);
1498 + assertLog(['A']);
1499 + await waitForAll(['App', 'AA', 'DefaultContext', 'Commit']);
1500 + });
1501 + });
1502 +
1503 + // @gate enableActivity
1504 + it('regression test: can unwind context on selective hydration interruption for sync updates', async () => {
1505 + const Context = React.createContext('DefaultContext');
1506 +
1507 + function ContextReader(props) {
1508 + const value = React.useContext(Context);
1509 + Scheduler.log(value);
1510 + return null;
1511 + }
1512 +
1513 + function Child({text}) {
1514 + Scheduler.log(text);
1515 + return <span>{text}</span>;
1516 + }
1517 + const ChildWithBoundary = React.memo(function ({text}) {
1518 + return (
1519 + <Activity>
1520 + <Child text={text} />
1521 + </Activity>
1522 + );
1523 + });
1524 +
1525 + function App({a}) {
1526 + Scheduler.log('App');
1527 + React.useEffect(() => {
1528 + Scheduler.log('Commit');
1529 + });
1530 + return (
1531 + <>
1532 + <Context.Provider value="SiblingContext">
1533 + <ChildWithBoundary text={a} />
1534 + </Context.Provider>
1535 + <ContextReader />
1536 + </>
1537 + );
1538 + }
1539 + const finalHTML = ReactDOMServer.renderToString(<App a="A" />);
1540 + assertLog(['App', 'A', 'DefaultContext']);
1541 + const container = document.createElement('div');
1542 + container.innerHTML = finalHTML;
1543 +
1544 + await act(async () => {
1545 + const root = ReactDOMClient.hydrateRoot(container, <App a="A" />);
1546 + await waitFor(['App', 'DefaultContext', 'Commit']);
1547 +
1548 + ReactDOM.flushSync(() => {
1549 + root.render(<App a="AA" />);
1550 + });
1551 + assertLog(['App', 'A', 'App', 'AA', 'DefaultContext', 'Commit']);
1552 + });
1553 + });
1554 +
1555 + // @gate enableActivity
1556 + it('regression: selective hydration does not contribute to "maximum update limit" count', async () => {
1557 + const outsideRef = React.createRef(null);
1558 + const insideRef = React.createRef(null);
1559 + function Child() {
1560 + return (
1561 + <Activity>
1562 + <div ref={insideRef} />
1563 + </Activity>
1564 + );
1565 + }
1566 +
1567 + let setIsMounted = false;
1568 + function App() {
1569 + const [isMounted, setState] = React.useState(false);
1570 + setIsMounted = setState;
1571 +
1572 + const children = [];
1573 + for (let i = 0; i < 100; i++) {
1574 + children.push(<Child key={i} isMounted={isMounted} />);
1575 + }
1576 +
1577 + return <div ref={outsideRef}>{children}</div>;
1578 + }
1579 +
1580 + const finalHTML = ReactDOMServer.renderToString(<App />);
1581 + const container = document.createElement('div');
1582 + container.innerHTML = finalHTML;
1583 +
1584 + await act(async () => {
1585 + ReactDOMClient.hydrateRoot(container, <App />);
1586 +
1587 + // Commit just the shell
1588 + await waitForPaint([]);
1589 +
1590 + // Assert that the shell has hydrated, but not the children
1591 + expect(outsideRef.current).not.toBe(null);
1592 + expect(insideRef.current).toBe(null);
1593 +
1594 + // Update the shell synchronously. The update will flow into the children,
1595 + // which haven't hydrated yet. This will trigger a cascade of commits
1596 + // caused by selective hydration. However, since there's really only one
1597 + // update, it should not be treated as an update loop.
1598 + // NOTE: It's unfortunate that every sibling boundary is separately
1599 + // committed in this case. We should be able to commit everything in a
1600 + // render phase, which we could do if we had resumable context stacks.
1601 + ReactDOM.flushSync(() => {
1602 + setIsMounted(true);
1603 + });
1604 + });
1605 +
1606 + // Should have successfully hydrated with no errors.
1607 + expect(insideRef.current).not.toBe(null);
1608 + });
1609 +});
packages/react-reconciler/src/ReactFiber.js
+2 -2
@@ -20,7 +20,7 @@ import type {RootTag} from './ReactRootTags';
20 import type {WorkTag} from './ReactWorkTags';
21 import type {TypeOfMode} from './ReactTypeOfMode';
22 import type {Lanes} from './ReactFiberLane';
23 -import type {SuspenseInstance} from './ReactFiberConfig';
23 +import type {ActivityInstance, SuspenseInstance} from './ReactFiberConfig';
24 import type {
25 LegacyHiddenProps,
26 OffscreenProps,
@@ -951,7 +951,7 @@ export function createFiberFromText(
951 }
952
953 export function createFiberFromDehydratedFragment(
954 - dehydratedNode: SuspenseInstance,
954 + dehydratedNode: SuspenseInstance | ActivityInstance,
955 ): Fiber {
956 const fiber = createFiber(DehydratedFragment, null, null, NoMode);
957 fiber.stateNode = dehydratedNode;
packages/react-reconciler/src/ReactFiberActivityComponent.js new
+25
@@ -0,0 +1,25 @@
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 + * @flow
8 + */
9 +
10 +import type {ActivityInstance} from './ReactFiberConfig';
11 +import type {CapturedValue} from './ReactCapturedValue';
12 +import type {Lane} from './ReactFiberLane';
13 +import type {TreeContext} from './ReactFiberTreeContext';
14 +
15 +// A non-null ActivityState represents a dehydrated Activity boundary.
16 +export type ActivityState = {
17 + dehydrated: ActivityInstance,
18 + treeContext: null | TreeContext,
19 + // Represents the lane we should attempt to hydrate a dehydrated boundary at.
20 + // OffscreenLane is the default for dehydrated boundaries.
21 + // NoLane is the default for normal boundaries, which turns into "normal" pri.
22 + retryLane: Lane,
23 + // Stashed Errors that happened while attempting to hydrate this boundary.
24 + hydrationErrors: Array<CapturedValue<mixed>> | null,
25 +};
packages/react-reconciler/src/ReactFiberBeginWork.js
+276 -28
@@ -22,6 +22,7 @@ import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
22 import type {Fiber, FiberRoot} from './ReactInternalTypes';
23 import type {TypeOfMode} from './ReactTypeOfMode';
24 import type {Lanes, Lane} from './ReactFiberLane';
25 +import type {ActivityState} from './ReactFiberActivityComponent';
26 import type {
27 SuspenseState,
28 SuspenseListRenderState,
@@ -185,7 +186,7 @@ import {
186 createHoistableInstance,
187 HostTransitionContext,
188 } from './ReactFiberConfig';
188 -import type {SuspenseInstance} from './ReactFiberConfig';
189 +import type {ActivityInstance, SuspenseInstance} from './ReactFiberConfig';
190 import {shouldError, shouldSuspend} from './ReactFiberReconciler';
191 import {
192 pushHostContext,
@@ -201,8 +202,10 @@ import {
202 setShallowSuspenseListContext,
203 pushPrimaryTreeSuspenseHandler,
204 pushFallbackTreeSuspenseHandler,
205 + pushDehydratedActivitySuspenseHandler,
206 pushOffscreenSuspenseHandler,
207 reuseSuspenseHandlerOnStack,
208 + popSuspenseHandler,
209 } from './ReactFiberSuspenseContext';
210 import {
211 pushHiddenContext,
@@ -239,6 +242,7 @@ import {
242 import {
243 getIsHydrating,
244 enterHydrationState,
245 + reenterHydrationStateFromDehydratedActivityInstance,
246 reenterHydrationStateFromDehydratedSuspenseInstance,
247 resetHydrationState,
248 claimHydratableSingleton,
@@ -713,14 +717,7 @@ function updateOffscreenComponent(
717 }
718 reuseHiddenContextOnStack(workInProgress);
719 pushOffscreenSuspenseHandler(workInProgress);
716 - } else if (
717 - !includesSomeLane(renderLanes, (OffscreenLane: Lane)) ||
718 - // SSR doesn't render hidden content (except legacy hidden) so it shouldn't hydrate,
719 - // even at offscreen lane. Defer to a client rendered offscreen lane.
720 - (getIsHydrating() &&
721 - (!enableLegacyHidden ||
722 - nextProps.mode !== 'unstable-defer-without-hiding'))
723 - ) {
720 + } else if (!includesSomeLane(renderLanes, (OffscreenLane: Lane))) {
721 // We're hidden, and we're not rendering at Offscreen. We will bail out
722 // and resume this tree later.
723
@@ -875,12 +872,11 @@ function updateLegacyHiddenComponent(
872 );
873 }
874
878 -function updateActivityComponent(
879 - current: null | Fiber,
875 +function mountActivityChildren(
876 workInProgress: Fiber,
877 + nextProps: ActivityProps,
878 renderLanes: Lanes,
879 ) {
883 - const nextProps: ActivityProps = workInProgress.pendingProps;
880 if (__DEV__) {
881 const hiddenProp = (nextProps: any).hidden;
882 if (hiddenProp !== undefined) {
@@ -904,25 +900,268 @@ function updateActivityComponent(
900 mode: nextMode,
901 children: nextChildren,
902 };
903 + const primaryChildFragment = mountWorkInProgressOffscreenFiber(
904 + offscreenChildProps,
905 + mode,
906 + renderLanes,
907 + );
908 + primaryChildFragment.ref = workInProgress.ref;
909 + workInProgress.child = primaryChildFragment;
910 + primaryChildFragment.return = workInProgress;
911 + return primaryChildFragment;
912 +}
913 +
914 +function retryActivityComponentWithoutHydrating(
915 + current: Fiber,
916 + workInProgress: Fiber,
917 + renderLanes: Lanes,
918 +) {
919 + // Falling back to client rendering. Because this has performance
920 + // implications, it's considered a recoverable error, even though the user
921 + // likely won't observe anything wrong with the UI.
922 +
923 + // This will add the old fiber to the deletion list
924 + reconcileChildFibers(workInProgress, current.child, null, renderLanes);
925 +
926 + // We're now not suspended nor dehydrated.
927 + const nextProps: ActivityProps = workInProgress.pendingProps;
928 + const primaryChildFragment = mountActivityChildren(
929 + workInProgress,
930 + nextProps,
931 + renderLanes,
932 + );
933 + // Needs a placement effect because the parent (the Activity boundary) already
934 + // mounted but this is a new fiber.
935 + primaryChildFragment.flags |= Placement;
936 +
937 + // If we're not going to hydrate we can't leave it dehydrated if something
938 + // suspends. In that case we want that to bubble to the nearest parent boundary
939 + // so we need to pop our own handler that we just pushed.
940 + popSuspenseHandler(workInProgress);
941 +
942 + workInProgress.memoizedState = null;
943 +
944 + return primaryChildFragment;
945 +}
946 +
947 +function mountDehydratedActivityComponent(
948 + workInProgress: Fiber,
949 + activityInstance: ActivityInstance,
950 + renderLanes: Lanes,
951 +): null | Fiber {
952 + // During the first pass, we'll bail out and not drill into the children.
953 + // Instead, we'll leave the content in place and try to hydrate it later.
954 + // We'll continue hydrating the rest at offscreen priority since we'll already
955 + // be showing the right content coming from the server, it is no rush.
956 + workInProgress.lanes = laneToLanes(OffscreenLane);
957 + return null;
958 +}
959 +
960 +function updateDehydratedActivityComponent(
961 + current: Fiber,
962 + workInProgress: Fiber,
963 + didSuspend: boolean,
964 + nextProps: ActivityProps,
965 + activityInstance: ActivityInstance,
966 + activityState: ActivityState,
967 + renderLanes: Lanes,
968 +): null | Fiber {
969 + // We'll handle suspending since if something suspends we can just leave
970 + // it dehydrated. We push early and then pop if we enter non-dehydrated attempts.
971 + pushDehydratedActivitySuspenseHandler(workInProgress);
972 + if (!didSuspend) {
973 + // This is the first render pass. Attempt to hydrate.
974 +
975 + // We should never be hydrating at this point because it is the first pass,
976 + // but after we've already committed once.
977 + warnIfHydrating();
978 +
979 + if (
980 + // TODO: Factoring is a little weird, since we check this right below, too.
981 + !didReceiveUpdate
982 + ) {
983 + // We need to check if any children have context before we decide to bail
984 + // out, so propagate the changes now.
985 + lazilyPropagateParentContextChanges(current, workInProgress, renderLanes);
986 + }
987 +
988 + // We use lanes to indicate that a child might depend on context, so if
989 + // any context has changed, we need to treat is as if the input might have changed.
990 + const hasContextChanged = includesSomeLane(renderLanes, current.childLanes);
991 + if (didReceiveUpdate || hasContextChanged) {
992 + // This boundary has changed since the first render. This means that we are now unable to
993 + // hydrate it. We might still be able to hydrate it using a higher priority lane.
994 + const root = getWorkInProgressRoot();
995 + if (root !== null) {
996 + const attemptHydrationAtLane = getBumpedLaneForHydration(
997 + root,
998 + renderLanes,
999 + );
1000 + if (
1001 + attemptHydrationAtLane !== NoLane &&
1002 + attemptHydrationAtLane !== activityState.retryLane
1003 + ) {
1004 + // Intentionally mutating since this render will get interrupted. This
1005 + // is one of the very rare times where we mutate the current tree
1006 + // during the render phase.
1007 + activityState.retryLane = attemptHydrationAtLane;
1008 + enqueueConcurrentRenderForLane(current, attemptHydrationAtLane);
1009 + scheduleUpdateOnFiber(root, current, attemptHydrationAtLane);
1010 +
1011 + // Throw a special object that signals to the work loop that it should
1012 + // interrupt the current render.
1013 + //
1014 + // Because we're inside a React-only execution stack, we don't
1015 + // strictly need to throw here — we could instead modify some internal
1016 + // work loop state. But using an exception means we don't need to
1017 + // check for this case on every iteration of the work loop. So doing
1018 + // it this way moves the check out of the fast path.
1019 + throw SelectiveHydrationException;
1020 + } else {
1021 + // We have already tried to ping at a higher priority than we're rendering with
1022 + // so if we got here, we must have failed to hydrate at those levels. We must
1023 + // now give up. Instead, we're going to delete the whole subtree and instead inject
1024 + // a new real Activity boundary to take its place. This might suspend for a while
1025 + // and if it does we might still have an opportunity to hydrate before this pass
1026 + // commits.
1027 + }
1028 + }
1029 +
1030 + // If we did not selectively hydrate, we'll continue rendering without
1031 + // hydrating. Mark this tree as suspended to prevent it from committing
1032 + // outside a transition.
1033 + //
1034 + // This path should only happen if the hydration lane already suspended.
1035 + renderDidSuspendDelayIfPossible();
1036 + return retryActivityComponentWithoutHydrating(
1037 + current,
1038 + workInProgress,
1039 + renderLanes,
1040 + );
1041 + } else {
1042 + // This is the first attempt.
1043 +
1044 + reenterHydrationStateFromDehydratedActivityInstance(
1045 + workInProgress,
1046 + activityInstance,
1047 + activityState.treeContext,
1048 + );
1049 +
1050 + const primaryChildFragment = mountActivityChildren(
1051 + workInProgress,
1052 + nextProps,
1053 + renderLanes,
1054 + );
1055 + // Mark the children as hydrating. This is a fast path to know whether this
1056 + // tree is part of a hydrating tree. This is used to determine if a child
1057 + // node has fully mounted yet, and for scheduling event replaying.
1058 + // Conceptually this is similar to Placement in that a new subtree is
1059 + // inserted into the React tree here. It just happens to not need DOM
1060 + // mutations because it already exists.
1061 + primaryChildFragment.flags |= Hydrating;
1062 + return primaryChildFragment;
1063 + }
1064 + } else {
1065 + // This is the second render pass. We already attempted to hydrated, but
1066 + // something either suspended or errored.
1067 +
1068 + if (workInProgress.flags & ForceClientRender) {
1069 + // Something errored during hydration. Try again without hydrating.
1070 + // The error should've already been logged in throwException.
1071 + workInProgress.flags &= ~ForceClientRender;
1072 + return retryActivityComponentWithoutHydrating(
1073 + current,
1074 + workInProgress,
1075 + renderLanes,
1076 + );
1077 + } else if ((workInProgress.memoizedState: null | ActivityState) !== null) {
1078 + // Something suspended and we should still be in dehydrated mode.
1079 + // Leave the existing child in place.
1080 +
1081 + workInProgress.child = current.child;
1082 + // The dehydrated completion pass expects this flag to be there
1083 + // but the normal offscreen pass doesn't.
1084 + workInProgress.flags |= DidCapture;
1085 + return null;
1086 + } else {
1087 + // We called retryActivityComponentWithoutHydrating and tried client rendering
1088 + // but now we suspended again. We should never arrive here because we should
1089 + // not have pushed a suspense handler during that second pass and it should
1090 + // instead have suspended above.
1091 + throw new Error(
1092 + 'Client rendering an Activity suspended it again. This is a bug in React.',
1093 + );
1094 + }
1095 + }
1096 +}
1097 +
1098 +function updateActivityComponent(
1099 + current: null | Fiber,
1100 + workInProgress: Fiber,
1101 + renderLanes: Lanes,
1102 +) {
1103 + const nextProps: ActivityProps = workInProgress.pendingProps;
1104 +
1105 + // Check if the first pass suspended.
1106 + const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;
1107 + workInProgress.flags &= ~DidCapture;
1108
1109 if (current === null) {
1110 + // Initial mount
1111 +
1112 + // Special path for hydration
1113 + // If we're currently hydrating, try to hydrate this boundary.
1114 + // Hidden Activity boundaries are not emitted on the server.
1115 if (getIsHydrating()) {
910 - claimNextHydratableActivityInstance(workInProgress);
1116 + if (nextProps.mode === 'hidden') {
1117 + // SSR doesn't render hidden Activity so it shouldn't hydrate,
1118 + // even at offscreen lane. Defer to a client rendered offscreen lane.
1119 + mountActivityChildren(workInProgress, nextProps, renderLanes);
1120 + workInProgress.lanes = laneToLanes(OffscreenLane);
1121 + return null;
1122 + } else {
1123 + // We must push the suspense handler context *before* attempting to
1124 + // hydrate, to avoid a mismatch in case it errors.
1125 + pushDehydratedActivitySuspenseHandler(workInProgress);
1126 + const dehydrated: ActivityInstance =
1127 + claimNextHydratableActivityInstance(workInProgress);
1128 + return mountDehydratedActivityComponent(
1129 + workInProgress,
1130 + dehydrated,
1131 + renderLanes,
1132 + );
1133 + }
1134 }
1135
913 - const primaryChildFragment = mountWorkInProgressOffscreenFiber(
914 - offscreenChildProps,
915 - mode,
916 - renderLanes,
917 - );
918 - primaryChildFragment.ref = workInProgress.ref;
919 - workInProgress.child = primaryChildFragment;
920 - primaryChildFragment.return = workInProgress;
921 -
922 - return primaryChildFragment;
1136 + return mountActivityChildren(workInProgress, nextProps, renderLanes);
1137 } else {
1138 + // This is an update.
1139 +
1140 + // Special path for hydration
1141 + const prevState: null | ActivityState = current.memoizedState;
1142 +
1143 + if (prevState !== null) {
1144 + const dehydrated = prevState.dehydrated;
1145 + return updateDehydratedActivityComponent(
1146 + current,
1147 + workInProgress,
1148 + didSuspend,
1149 + nextProps,
1150 + dehydrated,
1151 + prevState,
1152 + renderLanes,
1153 + );
1154 + }
1155 +
1156 const currentChild: Fiber = (current.child: any);
1157
1158 + const nextChildren = nextProps.children;
1159 + const nextMode = nextProps.mode;
1160 + const offscreenChildProps: OffscreenProps = {
1161 + mode: nextMode,
1162 + children: nextChildren,
1163 + };
1164 +
1165 const primaryChildFragment = updateWorkInProgressOffscreenFiber(
1166 currentChild,
1167 offscreenChildProps,
@@ -2801,11 +3040,6 @@ function updateDehydratedSuspenseComponent(
3040 // outside a transition.
3041 //
3042 // This path should only happen if the hydration lane already suspended.
2804 - // Currently, it also happens during sync updates because there is no
2805 - // hydration lane for sync updates.
2806 - // TODO: We should ideally have a sync hydration lane that we can apply to do
2807 - // a pass where we hydrate this subtree in place using the previous Context and then
2808 - // reapply the update afterwards.
3043 if (isSuspenseInstancePending(suspenseInstance)) {
3044 // This is a dehydrated suspense instance. We don't need to suspend
3045 // because we're already showing a fallback.
@@ -3705,6 +3939,20 @@ function attemptEarlyBailoutIfNoScheduledUpdate(
3939 }
3940 }
3941 break;
3942 + case ActivityComponent: {
3943 + const state: ActivityState | null = workInProgress.memoizedState;
3944 + if (state !== null) {
3945 + // We're dehydrated so we're not going to render the children. This is just
3946 + // to maintain push/pop symmetry.
3947 + // We know that this component will suspend again because if it has
3948 + // been unsuspended it has committed as a hydrated Activity component.
3949 + // If it needs to be retried, it should have work scheduled on it.
3950 + workInProgress.flags |= DidCapture;
3951 + pushDehydratedActivitySuspenseHandler(workInProgress);
3952 + return null;
3953 + }
3954 + break;
3955 + }
3956 case SuspenseComponent: {
3957 const state: SuspenseState | null = workInProgress.memoizedState;
3958 if (state !== null) {
packages/react-reconciler/src/ReactFiberCommitHostEffects.js
+21
@@ -10,6 +10,7 @@
10 import type {
11 Instance,
12 TextInstance,
13 + ActivityInstance,
14 SuspenseInstance,
15 Container,
16 ChildSet,
@@ -48,6 +49,7 @@ import {
49 unhideInstance,
50 unhideTextInstance,
51 commitHydratedContainer,
52 + commitHydratedActivityInstance,
53 commitHydratedSuspenseInstance,
54 removeChildFromContainer,
55 removeChild,
@@ -682,6 +684,25 @@ export function commitHostHydratedContainer(
684 }
685 }
686
687 +export function commitHostHydratedActivity(
688 + activityInstance: ActivityInstance,
689 + finishedWork: Fiber,
690 +) {
691 + try {
692 + if (__DEV__) {
693 + runWithFiberInDEV(
694 + finishedWork,
695 + commitHydratedActivityInstance,
696 + activityInstance,
697 + );
698 + } else {
699 + commitHydratedActivityInstance(activityInstance);
700 + }
701 + } catch (error) {
702 + captureCommitPhaseError(finishedWork, finishedWork.return, error);
703 + }
704 +}
705 +
706 export function commitHostHydratedSuspense(
707 suspenseInstance: SuspenseInstance,
708 finishedWork: Fiber,
packages/react-reconciler/src/ReactFiberCommitWork.js
+138 -2
@@ -10,6 +10,7 @@
10 import type {
11 Instance,
12 TextInstance,
13 + ActivityInstance,
14 SuspenseInstance,
15 Container,
16 HoistableRoot,
@@ -22,6 +23,7 @@ import {
23 includesOnlySuspenseyCommitEligibleLanes,
24 includesOnlyViewTransitionEligibleLanes,
25 } from './ReactFiberLane';
26 +import type {ActivityState} from './ReactFiberActivityComponent';
27 import type {SuspenseState, RetryQueue} from './ReactFiberSuspenseComponent';
28 import type {UpdateQueue} from './ReactFiberClassUpdateQueue';
29 import type {FunctionComponentUpdateQueue} from './ReactFiberHooks';
@@ -69,6 +71,7 @@ import {
71 HostText,
72 HostPortal,
73 Profiler,
74 + ActivityComponent,
75 SuspenseComponent,
76 DehydratedFragment,
77 IncompleteClassComponent,
@@ -234,6 +237,7 @@ import {
237 commitHostRootContainerChildren,
238 commitHostPortalContainerChildren,
239 commitHostHydratedContainer,
240 + commitHostHydratedActivity,
241 commitHostHydratedSuspense,
242 commitHostRemoveChildFromContainer,
243 commitHostRemoveChild,
@@ -294,7 +298,11 @@ let viewTransitionContextChanged: boolean = false;
298 let rootViewTransitionAffected: boolean = false;
299
300 function isHydratingParent(current: Fiber, finishedWork: Fiber): boolean {
297 - if (finishedWork.tag === SuspenseComponent) {
301 + if (finishedWork.tag === ActivityComponent) {
302 + const prevState: ActivityState | null = current.memoizedState;
303 + const nextState: ActivityState | null = finishedWork.memoizedState;
304 + return prevState !== null && nextState === null;
305 + } else if (finishedWork.tag === SuspenseComponent) {
306 const prevState: SuspenseState | null = current.memoizedState;
307 const nextState: SuspenseState | null = finishedWork.memoizedState;
308 return (
@@ -454,6 +462,7 @@ function commitBeforeMutationEffectsOnFiber(
462 if (!shouldFireAfterActiveInstanceBlur && focusedInstanceHandle !== null) {
463 // Check to see if the focused element was inside of a hidden (Suspense) subtree.
464 // TODO: Move this out of the hot path using a dedicated effect tag.
465 + // TODO: This should consider Offscreen in general and not just SuspenseComponent.
466 if (
467 finishedWork.tag === SuspenseComponent &&
468 isSuspenseBoundaryBeingHidden(current, finishedWork) &&
@@ -700,6 +709,17 @@ function commitLayoutEffectOnFiber(
709 }
710 break;
711 }
712 + case ActivityComponent: {
713 + recursivelyTraverseLayoutEffects(
714 + finishedRoot,
715 + finishedWork,
716 + committedLanes,
717 + );
718 + if (flags & Update) {
719 + commitActivityHydrationCallbacks(finishedRoot, finishedWork);
720 + }
721 + break;
722 + }
723 case SuspenseComponent: {
724 recursivelyTraverseLayoutEffects(
725 finishedRoot,
@@ -1508,7 +1528,9 @@ function commitDeletionEffectsOnFiber(
1528 try {
1529 const onDeleted = hydrationCallbacks.onDeleted;
1530 if (onDeleted) {
1511 - onDeleted((deletedFiber.stateNode: SuspenseInstance));
1531 + onDeleted(
1532 + (deletedFiber.stateNode: SuspenseInstance | ActivityInstance),
1533 + );
1534 }
1535 } catch (error) {
1536 captureCommitPhaseError(
@@ -1744,6 +1766,40 @@ function commitSuspenseCallback(finishedWork: Fiber) {
1766 }
1767 }
1768
1769 +function commitActivityHydrationCallbacks(
1770 + finishedRoot: FiberRoot,
1771 + finishedWork: Fiber,
1772 +) {
1773 + if (!supportsHydration) {
1774 + return;
1775 + }
1776 + const newState: ActivityState | null = finishedWork.memoizedState;
1777 + if (newState === null) {
1778 + const current = finishedWork.alternate;
1779 + if (current !== null) {
1780 + const prevState: ActivityState | null = current.memoizedState;
1781 + if (prevState !== null) {
1782 + const activityInstance = prevState.dehydrated;
1783 + commitHostHydratedActivity(activityInstance, finishedWork);
1784 + if (enableSuspenseCallback) {
1785 + try {
1786 + // TODO: Delete this feature. It's not properly covered by DEV features.
1787 + const hydrationCallbacks = finishedRoot.hydrationCallbacks;
1788 + if (hydrationCallbacks !== null) {
1789 + const onHydrated = hydrationCallbacks.onHydrated;
1790 + if (onHydrated) {
1791 + onHydrated(activityInstance);
1792 + }
1793 + }
1794 + } catch (error) {
1795 + captureCommitPhaseError(finishedWork, finishedWork.return, error);
1796 + }
1797 + }
1798 + }
1799 + }
1800 + }
1801 +}
1802 +
1803 function commitSuspenseHydrationCallbacks(
1804 finishedRoot: FiberRoot,
1805 finishedWork: Fiber,
@@ -1784,6 +1840,7 @@ function getRetryCache(finishedWork: Fiber) {
1840 // TODO: Unify the interface for the retry cache so we don't have to switch
1841 // on the tag like this.
1842 switch (finishedWork.tag) {
1843 + case ActivityComponent:
1844 case SuspenseComponent:
1845 case SuspenseListComponent: {
1846 let retryCache = finishedWork.stateNode;
@@ -2239,6 +2296,18 @@ function commitMutationEffectsOnFiber(
2296 }
2297 break;
2298 }
2299 + case ActivityComponent: {
2300 + recursivelyTraverseMutationEffects(root, finishedWork, lanes);
2301 + commitReconciliationEffects(finishedWork, lanes);
2302 + if (flags & Update) {
2303 + const retryQueue: RetryQueue | null = (finishedWork.updateQueue: any);
2304 + if (retryQueue !== null) {
2305 + finishedWork.updateQueue = null;
2306 + attachSuspenseRetryListeners(finishedWork, retryQueue);
2307 + }
2308 + }
2309 + break;
2310 + }
2311 case SuspenseComponent: {
2312 recursivelyTraverseMutationEffects(root, finishedWork, lanes);
2313 commitReconciliationEffects(finishedWork, lanes);
@@ -3020,6 +3089,19 @@ export function reappearLayoutEffects(
3089 }
3090 break;
3091 }
3092 + case ActivityComponent: {
3093 + recursivelyTraverseReappearLayoutEffects(
3094 + finishedRoot,
3095 + finishedWork,
3096 + includeWorkInProgressEffects,
3097 + );
3098 +
3099 + if (includeWorkInProgressEffects && flags & Update) {
3100 + // TODO: Delete this feature.
3101 + commitActivityHydrationCallbacks(finishedRoot, finishedWork);
3102 + }
3103 + break;
3104 + }
3105 case SuspenseComponent: {
3106 recursivelyTraverseReappearLayoutEffects(
3107 finishedRoot,
@@ -3581,6 +3663,60 @@ function commitPassiveMountOnFiber(
3663 }
3664 break;
3665 }
3666 + case ActivityComponent: {
3667 + const wasInHydratedSubtree = inHydratedSubtree;
3668 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
3669 + const prevState: ActivityState | null =
3670 + finishedWork.alternate !== null
3671 + ? finishedWork.alternate.memoizedState
3672 + : null;
3673 + const nextState: ActivityState | null = finishedWork.memoizedState;
3674 + if (prevState !== null && nextState === null) {
3675 + // This was dehydrated but is no longer dehydrated. We may have now either hydrated it
3676 + // or client rendered it.
3677 + const deletions = finishedWork.deletions;
3678 + if (
3679 + deletions !== null &&
3680 + deletions.length > 0 &&
3681 + deletions[0].tag === DehydratedFragment
3682 + ) {
3683 + // This was an abandoned hydration that deleted the dehydrated fragment. That means we
3684 + // are not hydrating this Suspense boundary.
3685 + inHydratedSubtree = false;
3686 + const hydrationErrors = prevState.hydrationErrors;
3687 + // If there were no hydration errors, that suggests that this was an intentional client
3688 + // rendered boundary. Such as postpone.
3689 + if (hydrationErrors !== null) {
3690 + const startTime: number = (finishedWork.actualStartTime: any);
3691 + logComponentErrored(
3692 + finishedWork,
3693 + startTime,
3694 + endTime,
3695 + hydrationErrors,
3696 + );
3697 + }
3698 + } else {
3699 + // If any children committed they were hydrated.
3700 + inHydratedSubtree = true;
3701 + }
3702 + } else {
3703 + inHydratedSubtree = false;
3704 + }
3705 + }
3706 +
3707 + recursivelyTraversePassiveMountEffects(
3708 + finishedRoot,
3709 + finishedWork,
3710 + committedLanes,
3711 + committedTransitions,
3712 + endTime,
3713 + );
3714 +
3715 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
3716 + inHydratedSubtree = wasInHydratedSubtree;
3717 + }
3718 + break;
3719 + }
3720 case SuspenseComponent: {
3721 const wasInHydratedSubtree = inHydratedSubtree;
3722 if (enableProfilerTimer && enableComponentPerformanceTrack) {
packages/react-reconciler/src/ReactFiberCompleteWork.js
+119 -6
@@ -19,6 +19,7 @@ import type {
19 ChildSet,
20 Resource,
21 } from './ReactFiberConfig';
22 +import type {ActivityState} from './ReactFiberActivityComponent';
23 import type {
24 SuspenseState,
25 SuspenseListRenderState,
@@ -154,6 +155,7 @@ import {popProvider} from './ReactFiberNewContext';
155 import {
156 prepareToHydrateHostInstance,
157 prepareToHydrateHostTextInstance,
158 + prepareToHydrateHostActivityInstance,
159 prepareToHydrateHostSuspenseInstance,
160 popHydrationState,
161 resetHydrationState,
@@ -897,6 +899,88 @@ function bubbleProperties(completedWork: Fiber) {
899 return didBailout;
900 }
901
902 +function completeDehydratedActivityBoundary(
903 + current: Fiber | null,
904 + workInProgress: Fiber,
905 + nextState: ActivityState | null,
906 +): boolean {
907 + const wasHydrated = popHydrationState(workInProgress);
908 +
909 + if (nextState !== null) {
910 + // We might be inside a hydration state the first time we're picking up this
911 + // Activity boundary, and also after we've reentered it for further hydration.
912 + if (current === null) {
913 + if (!wasHydrated) {
914 + throw new Error(
915 + 'A dehydrated suspense component was completed without a hydrated node. ' +
916 + 'This is probably a bug in React.',
917 + );
918 + }
919 + prepareToHydrateHostActivityInstance(workInProgress);
920 + bubbleProperties(workInProgress);
921 + if (enableProfilerTimer) {
922 + if ((workInProgress.mode & ProfileMode) !== NoMode) {
923 + const isTimedOutSuspense = nextState !== null;
924 + if (isTimedOutSuspense) {
925 + // Don't count time spent in a timed out Suspense subtree as part of the base duration.
926 + const primaryChildFragment = workInProgress.child;
927 + if (primaryChildFragment !== null) {
928 + // $FlowFixMe[unsafe-arithmetic] Flow doesn't support type casting in combination with the -= operator
929 + workInProgress.treeBaseDuration -=
930 + ((primaryChildFragment.treeBaseDuration: any): number);
931 + }
932 + }
933 + }
934 + }
935 + return false;
936 + } else {
937 + emitPendingHydrationWarnings();
938 + // We might have reentered this boundary to hydrate it. If so, we need to reset the hydration
939 + // state since we're now exiting out of it. popHydrationState doesn't do that for us.
940 + resetHydrationState();
941 + if ((workInProgress.flags & DidCapture) === NoFlags) {
942 + // This boundary did not suspend so it's now hydrated and unsuspended.
943 + nextState = workInProgress.memoizedState = null;
944 + }
945 + // If nothing suspended, we need to schedule an effect to mark this boundary
946 + // as having hydrated so events know that they're free to be invoked.
947 + // It's also a signal to replay events and the suspense callback.
948 + // If something suspended, schedule an effect to attach retry listeners.
949 + // So we might as well always mark this.
950 + workInProgress.flags |= Update;
951 + bubbleProperties(workInProgress);
952 + if (enableProfilerTimer) {
953 + if ((workInProgress.mode & ProfileMode) !== NoMode) {
954 + const isTimedOutSuspense = nextState !== null;
955 + if (isTimedOutSuspense) {
956 + // Don't count time spent in a timed out Suspense subtree as part of the base duration.
957 + const primaryChildFragment = workInProgress.child;
958 + if (primaryChildFragment !== null) {
959 + // $FlowFixMe[unsafe-arithmetic] Flow doesn't support type casting in combination with the -= operator
960 + workInProgress.treeBaseDuration -=
961 + ((primaryChildFragment.treeBaseDuration: any): number);
962 + }
963 + }
964 + }
965 + }
966 + return false;
967 + }
968 + } else {
969 + // Successfully completed this tree. If this was a forced client render,
970 + // there may have been recoverable errors during first hydration
971 + // attempt. If so, add them to a queue so we can log them in the
972 + // commit phase. We also add them to prev state so we can get to them
973 + // from the Suspense Boundary.
974 + const hydrationErrors = upgradeHydrationErrorsToRecoverable();
975 + if (current !== null && current.memoizedState !== null) {
976 + const prevState: ActivityState = current.memoizedState;
977 + prevState.hydrationErrors = hydrationErrors;
978 + }
979 + // Fall through to normal Offscreen path
980 + return true;
981 + }
982 +}
983 +
984 function completeDehydratedSuspenseBoundary(
985 current: Fiber | null,
986 workInProgress: Fiber,
@@ -938,7 +1022,7 @@ function completeDehydratedSuspenseBoundary(
1022 resetHydrationState();
1023 if ((workInProgress.flags & DidCapture) === NoFlags) {
1024 // This boundary did not suspend so it's now hydrated and unsuspended.
941 - workInProgress.memoizedState = null;
1025 + nextState = workInProgress.memoizedState = null;
1026 }
1027 // If nothing suspended, we need to schedule an effect to mark this boundary
1028 // as having hydrated so events know that they're free to be invoked.
@@ -1393,12 +1477,42 @@ function completeWork(
1477 return null;
1478 }
1479 case ActivityComponent: {
1396 - if (current === null) {
1397 - const wasHydrated = popHydrationState(workInProgress);
1398 - if (wasHydrated) {
1399 - // TODO: Implement prepareToHydrateActivityInstance
1480 + const nextState: null | ActivityState = workInProgress.memoizedState;
1481 +
1482 + if (current === null || current.memoizedState !== null) {
1483 + const fallthroughToNormalOffscreenPath =
1484 + completeDehydratedActivityBoundary(
1485 + current,
1486 + workInProgress,
1487 + nextState,
1488 + );
1489 + if (!fallthroughToNormalOffscreenPath) {
1490 + if (workInProgress.flags & ForceClientRender) {
1491 + popSuspenseHandler(workInProgress);
1492 + // Special case. There were remaining unhydrated nodes. We treat
1493 + // this as a mismatch. Revert to client rendering.
1494 + return workInProgress;
1495 + } else {
1496 + popSuspenseHandler(workInProgress);
1497 + // Did not finish hydrating, either because this is the initial
1498 + // render or because something suspended.
1499 + return null;
1500 + }
1501 + }
1502 +
1503 + if ((workInProgress.flags & DidCapture) !== NoFlags) {
1504 + // We called retryActivityComponentWithoutHydrating and tried client rendering
1505 + // but now we suspended again. We should never arrive here because we should
1506 + // not have pushed a suspense handler during that second pass and it should
1507 + // instead have suspended above.
1508 + throw new Error(
1509 + 'Client rendering an Activity suspended it again. This is a bug in React.',
1510 + );
1511 }
1512 +
1513 + // Continue with the normal Activity path.
1514 }
1515 +
1516 bubbleProperties(workInProgress);
1517 return null;
1518 }
@@ -1443,7 +1557,6 @@ function completeWork(
1557 if ((workInProgress.flags & DidCapture) !== NoFlags) {
1558 // Something suspended. Re-render with the fallback children.
1559 workInProgress.lanes = renderLanes;
1446 - // Do not reset the effect list.
1560 if (
1561 enableProfilerTimer &&
1562 (workInProgress.mode & ProfileMode) !== NoMode
packages/react-reconciler/src/ReactFiberHydrationContext.js
+80 -9
@@ -17,6 +17,7 @@ import type {
17 Container,
18 HostContext,
19 } from './ReactFiberConfig';
20 +import type {ActivityState} from './ReactFiberActivityComponent';
21 import type {SuspenseState} from './ReactFiberSuspenseComponent';
22 import type {TreeContext} from './ReactFiberTreeContext';
23 import type {CapturedValue} from './ReactCapturedValue';
@@ -50,6 +51,7 @@ import {
51 describeHydratableInstanceForDevWarnings,
52 hydrateTextInstance,
53 diffHydratedTextForDevWarnings,
54 + hydrateActivityInstance,
55 hydrateSuspenseInstance,
56 getNextHydratableInstanceAfterActivityInstance,
57 getNextHydratableInstanceAfterSuspenseInstance,
@@ -175,6 +177,28 @@ function enterHydrationState(fiber: Fiber): boolean {
177 return true;
178 }
179
180 +function reenterHydrationStateFromDehydratedActivityInstance(
181 + fiber: Fiber,
182 + activityInstance: ActivityInstance,
183 + treeContext: TreeContext | null,
184 +): boolean {
185 + if (!supportsHydration) {
186 + return false;
187 + }
188 + nextHydratableInstance =
189 + getFirstHydratableChildWithinActivityInstance(activityInstance);
190 + hydrationParentFiber = fiber;
191 + isHydrating = true;
192 + hydrationErrors = null;
193 + didSuspendOrErrorDEV = false;
194 + hydrationDiffRootDEV = null;
195 + rootOrSingletonContext = false;
196 + if (treeContext !== null) {
197 + restoreSuspendedTreeContext(fiber, treeContext);
198 + }
199 + return true;
200 +}
201 +
202 function reenterHydrationStateFromDehydratedSuspenseInstance(
203 fiber: Fiber,
204 suspenseInstance: SuspenseInstance,
@@ -281,18 +305,31 @@ function tryHydrateActivity(
305 fiber: Fiber,
306 nextInstance: any,
307 ): null | ActivityInstance {
284 - // fiber is a SuspenseComponent Fiber
308 + // fiber is a ActivityComponent Fiber
309 const activityInstance = canHydrateActivityInstance(
310 nextInstance,
311 rootOrSingletonContext,
312 );
313 if (activityInstance !== null) {
290 - // TODO: Implement dehydrated Activity state.
291 - // TODO: Delete this from stateNode. It's only used to skip past it.
292 - fiber.stateNode = activityInstance;
314 + const activityState: ActivityState = {
315 + dehydrated: activityInstance,
316 + treeContext: getSuspendedTreeContext(),
317 + retryLane: OffscreenLane,
318 + hydrationErrors: null,
319 + };
320 + fiber.memoizedState = activityState;
321 + // Store the dehydrated fragment as a child fiber.
322 + // This simplifies the code for getHostSibling and deleting nodes,
323 + // since it doesn't have to consider all Suspense boundaries and
324 + // check if they're dehydrated ones or not.
325 + const dehydratedFragment =
326 + createFiberFromDehydratedFragment(activityInstance);
327 + dehydratedFragment.return = fiber;
328 + fiber.child = dehydratedFragment;
329 hydrationParentFiber = fiber;
294 - nextHydratableInstance =
295 - getFirstHydratableChildWithinActivityInstance(activityInstance);
330 + // While an Activity Instance does have children, we won't step into
331 + // it during the first pass. Instead, we'll reenter it later.
332 + nextHydratableInstance = null;
333 }
334 return activityInstance;
335 }
@@ -592,6 +629,27 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): void {
629 }
630 }
631
632 +function prepareToHydrateHostActivityInstance(fiber: Fiber): void {
633 + if (!supportsHydration) {
634 + throw new Error(
635 + 'Expected prepareToHydrateHostActivityInstance() to never be called. ' +
636 + 'This error is likely caused by a bug in React. Please file an issue.',
637 + );
638 + }
639 + const activityState: null | ActivityState = fiber.memoizedState;
640 + const activityInstance: null | ActivityInstance =
641 + activityState !== null ? activityState.dehydrated : null;
642 +
643 + if (!activityInstance) {
644 + throw new Error(
645 + 'Expected to have a hydrated activity instance. ' +
646 + 'This error is likely caused by a bug in React. Please file an issue.',
647 + );
648 + }
649 +
650 + hydrateActivityInstance(activityInstance, fiber);
651 +}
652 +
653 function prepareToHydrateHostSuspenseInstance(fiber: Fiber): void {
654 if (!supportsHydration) {
655 throw new Error(
@@ -613,10 +671,22 @@ function prepareToHydrateHostSuspenseInstance(fiber: Fiber): void {
671
672 hydrateSuspenseInstance(suspenseInstance, fiber);
673 }
674 +
675 function skipPastDehydratedActivityInstance(
676 fiber: Fiber,
677 ): null | HydratableInstance {
619 - return getNextHydratableInstanceAfterActivityInstance(fiber.stateNode);
678 + const activityState: null | ActivityState = fiber.memoizedState;
679 + const activityInstance: null | ActivityInstance =
680 + activityState !== null ? activityState.dehydrated : null;
681 +
682 + if (!activityInstance) {
683 + throw new Error(
684 + 'Expected to have a hydrated suspense instance. ' +
685 + 'This error is likely caused by a bug in React. Please file an issue.',
686 + );
687 + }
688 +
689 + return getNextHydratableInstanceAfterActivityInstance(activityInstance);
690 }
691
692 function skipPastDehydratedSuspenseInstance(
@@ -647,6 +717,7 @@ function popToNextHostParent(fiber: Fiber): void {
717 while (hydrationParentFiber) {
718 switch (hydrationParentFiber.tag) {
719 case HostComponent:
720 + case ActivityComponent:
721 case SuspenseComponent:
722 rootOrSingletonContext = false;
723 return;
@@ -654,8 +725,6 @@ function popToNextHostParent(fiber: Fiber): void {
725 case HostRoot:
726 rootOrSingletonContext = true;
727 return;
657 - case ActivityComponent:
658 - return;
728 default:
729 hydrationParentFiber = hydrationParentFiber.return;
730 }
@@ -834,6 +903,7 @@ export {
903 warnIfHydrating,
904 enterHydrationState,
905 getIsHydrating,
906 + reenterHydrationStateFromDehydratedActivityInstance,
907 reenterHydrationStateFromDehydratedSuspenseInstance,
908 resetHydrationState,
909 claimHydratableSingleton,
@@ -843,6 +913,7 @@ export {
913 claimNextHydratableSuspenseInstance,
914 prepareToHydrateHostInstance,
915 prepareToHydrateHostTextInstance,
916 + prepareToHydrateHostActivityInstance,
917 prepareToHydrateHostSuspenseInstance,
918 popHydrationState,
919 };
packages/react-reconciler/src/ReactFiberReconciler.js
+7 -3
@@ -21,6 +21,7 @@ import type {
21 } from './ReactFiberConfig';
22 import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
23 import type {Lane} from './ReactFiberLane';
24 +import type {ActivityState} from './ReactFiberActivityComponent';
25 import type {SuspenseState} from './ReactFiberSuspenseComponent';
26
27 import {LegacyRoot} from './ReactRootTags';
@@ -35,6 +36,7 @@ import {
36 ClassComponent,
37 HostRoot,
38 SuspenseComponent,
39 + ActivityComponent,
40 } from './ReactWorkTags';
41 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
42 import isArray from 'shared/isArray';
@@ -484,6 +486,7 @@ export function attemptSynchronousHydration(fiber: Fiber): void {
486 }
487 break;
488 }
489 + case ActivityComponent:
490 case SuspenseComponent: {
491 const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
492 if (root !== null) {
@@ -501,7 +504,8 @@ export function attemptSynchronousHydration(fiber: Fiber): void {
504 }
505
506 function markRetryLaneImpl(fiber: Fiber, retryLane: Lane) {
504 - const suspenseState: null | SuspenseState = fiber.memoizedState;
507 + const suspenseState: null | SuspenseState | ActivityState =
508 + fiber.memoizedState;
509 if (suspenseState !== null && suspenseState.dehydrated !== null) {
510 suspenseState.retryLane = higherPriorityLane(
511 suspenseState.retryLane,
@@ -520,7 +524,7 @@ function markRetryLaneIfNotHydrated(fiber: Fiber, retryLane: Lane) {
524 }
525
526 export function attemptContinuousHydration(fiber: Fiber): void {
523 - if (fiber.tag !== SuspenseComponent) {
527 + if (fiber.tag !== SuspenseComponent && fiber.tag !== ActivityComponent) {
528 // We ignore HostRoots here because we can't increase
529 // their priority and they should not suspend on I/O,
530 // since you have to wrap anything that might suspend in
@@ -536,7 +540,7 @@ export function attemptContinuousHydration(fiber: Fiber): void {
540 }
541
542 export function attemptHydrationAtCurrentPriority(fiber: Fiber): void {
539 - if (fiber.tag !== SuspenseComponent) {
543 + if (fiber.tag !== SuspenseComponent && fiber.tag !== ActivityComponent) {
544 // We ignore HostRoots here because we can't increase
545 // their priority other than synchronously flush it.
546 return;
packages/react-reconciler/src/ReactFiberSuspenseContext.js
+16 -1
@@ -106,12 +106,27 @@ export function pushFallbackTreeSuspenseHandler(fiber: Fiber): void {
106 reuseSuspenseHandlerOnStack(fiber);
107 }
108
109 +export function pushDehydratedActivitySuspenseHandler(fiber: Fiber): void {
110 + // This is called when hydrating an Activity boundary. We can just leave it
111 + // dehydrated if it suspends.
112 + // A SuspenseList context is only pushed here to avoid a push/pop mismatch.
113 + // Reuse the current value on the stack.
114 + // TODO: We can avoid needing to push here by by forking popSuspenseHandler
115 + // into separate functions for Activity, Suspense and Offscreen.
116 + pushSuspenseListContext(fiber, suspenseStackCursor.current);
117 + push(suspenseHandlerStackCursor, fiber, fiber);
118 + if (shellBoundary === null) {
119 + // We can contain any suspense inside the Activity boundary.
120 + shellBoundary = fiber;
121 + }
122 +}
123 +
124 export function pushOffscreenSuspenseHandler(fiber: Fiber): void {
125 if (fiber.tag === OffscreenComponent) {
126 // A SuspenseList context is only pushed here to avoid a push/pop mismatch.
127 // Reuse the current value on the stack.
128 // TODO: We can avoid needing to push here by by forking popSuspenseHandler
114 - // into separate functions for Suspense and Offscreen.
129 + // into separate functions for Activity, Suspense and Offscreen.
130 pushSuspenseListContext(fiber, suspenseStackCursor.current);
131 push(suspenseHandlerStackCursor, fiber, fiber);
132 if (shellBoundary === null) {
packages/react-reconciler/src/ReactFiberThrow.js
+8 -6
@@ -24,6 +24,7 @@ import {
24 FunctionComponent,
25 ForwardRef,
26 SimpleMemoComponent,
27 + ActivityComponent,
28 SuspenseComponent,
29 OffscreenComponent,
30 } from './ReactWorkTags';
@@ -398,8 +399,9 @@ function throwException(
399 const suspenseBoundary = getSuspenseHandler();
400 if (suspenseBoundary !== null) {
401 switch (suspenseBoundary.tag) {
402 + case ActivityComponent:
403 case SuspenseComponent: {
402 - // If this suspense boundary is not already showing a fallback, mark
404 + // If this suspense/activity boundary is not already showing a fallback, mark
405 // the in-progress render as suspended. We try to perform this logic
406 // as soon as soon as possible during the render phase, so the work
407 // loop can know things like whether it's OK to switch to other tasks,
@@ -553,19 +555,19 @@ function throwException(
555 (disableLegacyMode || sourceFiber.mode & ConcurrentMode)
556 ) {
557 markDidThrowWhileHydratingDEV();
556 - const suspenseBoundary = getSuspenseHandler();
558 + const hydrationBoundary = getSuspenseHandler();
559 // If the error was thrown during hydration, we may be able to recover by
560 // discarding the dehydrated content and switching to a client render.
561 // Instead of surfacing the error, find the nearest Suspense boundary
562 // and render it again without hydration.
561 - if (suspenseBoundary !== null) {
562 - if ((suspenseBoundary.flags & ShouldCapture) === NoFlags) {
563 + if (hydrationBoundary !== null) {
564 + if ((hydrationBoundary.flags & ShouldCapture) === NoFlags) {
565 // Set a flag to indicate that we should try rendering the normal
566 // children again, not the fallback.
565 - suspenseBoundary.flags |= ForceClientRender;
567 + hydrationBoundary.flags |= ForceClientRender;
568 }
569 markSuspenseBoundaryShouldCapture(
568 - suspenseBoundary,
570 + hydrationBoundary,
571 returnFiber,
572 sourceFiber,
573 root,
packages/react-reconciler/src/ReactFiberTreeReflection.js
+14
@@ -14,6 +14,7 @@ import type {
14 SuspenseInstance,
15 Instance,
16 } from './ReactFiberConfig';
17 +import type {ActivityState} from './ReactFiberActivityComponent';
18 import type {SuspenseState} from './ReactFiberSuspenseComponent';
19
20 import {
@@ -23,6 +24,7 @@ import {
24 HostRoot,
25 HostPortal,
26 HostText,
27 + ActivityComponent,
28 SuspenseComponent,
29 OffscreenComponent,
30 } from './ReactWorkTags';
@@ -82,6 +84,18 @@ export function getSuspenseInstanceFromFiber(
84 export function getActivityInstanceFromFiber(
85 fiber: Fiber,
86 ): null | ActivityInstance {
87 + if (fiber.tag === ActivityComponent) {
88 + let activityState: ActivityState | null = fiber.memoizedState;
89 + if (activityState === null) {
90 + const current = fiber.alternate;
91 + if (current !== null) {
92 + activityState = current.memoizedState;
93 + }
94 + }
95 + if (activityState !== null) {
96 + return activityState.dehydrated;
97 + }
98 + }
99 // TODO: Implement this on ActivityComponent.
100 return null;
101 }
packages/react-reconciler/src/ReactFiberUnwindWork.js
+37
@@ -10,6 +10,7 @@
10 import type {ReactContext} from 'shared/ReactTypes';
11 import type {Fiber, FiberRoot} from './ReactInternalTypes';
12 import type {Lanes} from './ReactFiberLane';
13 +import type {ActivityState} from './ReactFiberActivityComponent';
14 import type {SuspenseState} from './ReactFiberSuspenseComponent';
15 import type {Cache} from './ReactFiberCacheComponent';
16 import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent';
@@ -22,6 +23,7 @@ import {
23 HostSingleton,
24 HostPortal,
25 ContextProvider,
26 + ActivityComponent,
27 SuspenseComponent,
28 SuspenseListComponent,
29 OffscreenComponent,
@@ -120,6 +122,35 @@ function unwindWork(
122 popHostContext(workInProgress);
123 return null;
124 }
125 + case ActivityComponent: {
126 + const activityState: null | ActivityState = workInProgress.memoizedState;
127 + if (activityState !== null) {
128 + popSuspenseHandler(workInProgress);
129 +
130 + if (workInProgress.alternate === null) {
131 + throw new Error(
132 + 'Threw in newly mounted dehydrated component. This is likely a bug in ' +
133 + 'React. Please file an issue.',
134 + );
135 + }
136 +
137 + resetHydrationState();
138 + }
139 +
140 + const flags = workInProgress.flags;
141 + if (flags & ShouldCapture) {
142 + workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
143 + // Captured a suspense effect. Re-render the boundary.
144 + if (
145 + enableProfilerTimer &&
146 + (workInProgress.mode & ProfileMode) !== NoMode
147 + ) {
148 + transferActualDuration(workInProgress);
149 + }
150 + return workInProgress;
151 + }
152 + return null;
153 + }
154 case SuspenseComponent: {
155 popSuspenseHandler(workInProgress);
156 const suspenseState: null | SuspenseState = workInProgress.memoizedState;
@@ -242,6 +273,12 @@ function unwindInterruptedWork(
273 case HostPortal:
274 popHostContainer(interruptedWork);
275 break;
276 + case ActivityComponent: {
277 + if (interruptedWork.memoizedState !== null) {
278 + popSuspenseHandler(interruptedWork);
279 + }
280 + break;
281 + }
282 case SuspenseComponent:
283 popSuspenseHandler(interruptedWork);
284 break;
packages/react-reconciler/src/ReactFiberWorkLoop.js
+5 -1
@@ -12,6 +12,7 @@ import {REACT_STRICT_MODE_TYPE} from 'shared/ReactSymbols';
12 import type {Wakeable, Thenable} from 'shared/ReactTypes';
13 import type {Fiber, FiberRoot} from './ReactInternalTypes';
14 import type {Lanes, Lane} from './ReactFiberLane';
15 +import type {ActivityState} from './ReactFiberActivityComponent';
16 import type {SuspenseState} from './ReactFiberSuspenseComponent';
17 import type {FunctionComponentUpdateQueue} from './ReactFiberHooks';
18 import type {Transition} from 'react/src/ReactStartTransition';
@@ -124,6 +125,7 @@ import {
125 import {
126 HostRoot,
127 ClassComponent,
128 + ActivityComponent,
129 SuspenseComponent,
130 SuspenseListComponent,
131 OffscreenComponent,
@@ -4489,9 +4491,11 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) {
4491 let retryLane: Lane = NoLane; // Default
4492 let retryCache: WeakSet<Wakeable> | Set<Wakeable> | null;
4493 switch (boundaryFiber.tag) {
4494 + case ActivityComponent:
4495 case SuspenseComponent:
4496 retryCache = boundaryFiber.stateNode;
4494 - const suspenseState: null | SuspenseState = boundaryFiber.memoizedState;
4497 + const suspenseState: null | SuspenseState | ActivityState =
4498 + boundaryFiber.memoizedState;
4499 if (suspenseState !== null) {
4500 retryLane = suspenseState.retryLane;
4501 }
packages/react-server/src/ReactFizzServer.js
+5 -5
@@ -2227,14 +2227,14 @@ function renderActivity(
2227 }
2228 } else {
2229 // Render
2230 - // An Activity boundary is delimited so that we can hydrate it separately.
2231 - pushStartActivityBoundary(segment.chunks, request.renderState);
2232 - segment.lastPushedText = false;
2230 const mode = props.mode;
2231 if (mode === 'hidden') {
2232 // A hidden Activity boundary is not server rendered. Prerendering happens
2233 // on the client.
2234 } else {
2235 + // An Activity boundary is delimited so that we can hydrate it separately.
2236 + pushStartActivityBoundary(segment.chunks, request.renderState);
2237 + segment.lastPushedText = false;
2238 // A visible Activity boundary has its children rendered inside the boundary.
2239 const prevKeyPath = task.keyPath;
2240 task.keyPath = keyPath;
@@ -2242,9 +2242,9 @@ function renderActivity(
2242 // need to pop back up and finish the end comment.
2243 renderNode(request, task, props.children, -1);
2244 task.keyPath = prevKeyPath;
2245 + pushEndActivityBoundary(segment.chunks, request.renderState);
2246 + segment.lastPushedText = false;
2247 }
2246 - pushEndActivityBoundary(segment.chunks, request.renderState);
2247 - segment.lastPushedText = false;
2248 }
2249 }
2250
scripts/error-codes/codes.json
+4 -1
@@ -540,5 +540,8 @@
540 "552": "Cannot use a startGestureTransition() on a detached root.",
541 "553": "A Timeline is required as the first argument to startGestureTransition.",
542 "554": "Cannot setState on regular state inside a startGestureTransition. Gestures can only update the useOptimistic() hook. There should be no side-effects associated with starting a Gesture until its Action is invoked. Move side-effects to the Action instead.",
543 - "555": "Cannot requestFormReset() inside a startGestureTransition. There should be no side-effects associated with starting a Gesture until its Action is invoked. Move side-effects to the Action instead."
543 + "555": "Cannot requestFormReset() inside a startGestureTransition. There should be no side-effects associated with starting a Gesture until its Action is invoked. Move side-effects to the Action instead.",
544 + "556": "Expected prepareToHydrateHostActivityInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.",
545 + "557": "Expected to have a hydrated activity instance. This error is likely caused by a bug in React. Please file an issue.",
546 + "558": "Client rendering an Activity suspended it again. This is a bug in React."
547 }