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
+});