1
+/**
2
+ * Copyright (c) Facebook, Inc. and its 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
+let React;
13
+let ReactDOM;
14
+let ReactDOMServer;
15
+let ReactFeatureFlags;
16
+let Suspense;
17
+let act;
18
+
19
+describe('ReactDOMServerPartialHydration', () => {
20
+ beforeEach(() => {
21
+ jest.resetModuleRegistry();
22
+
23
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
24
+ ReactFeatureFlags.enableSuspenseServerRenderer = true;
25
+
26
+ React = require('react');
27
+ ReactDOM = require('react-dom');
28
+ act = require('react-dom/test-utils').act;
29
+ ReactDOMServer = require('react-dom/server');
30
+ Suspense = React.Suspense;
31
+ });
32
+
33
+ it('hydrates a parent even if a child Suspense boundary is blocked', async () => {
34
+ let suspend = false;
35
+ let resolve;
36
+ let promise = new Promise(resolvePromise => (resolve = resolvePromise));
37
+ let ref = React.createRef();
38
+
39
+ function Child() {
40
+ if (suspend) {
41
+ throw promise;
42
+ } else {
43
+ return 'Hello';
44
+ }
45
+ }
46
+
47
+ function App() {
48
+ return (
49
+ <div>
50
+ <Suspense fallback="Loading...">
51
+ <span ref={ref}>
52
+ <Child />
53
+ </span>
54
+ </Suspense>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ // First we render the final HTML. With the streaming renderer
60
+ // this may have suspense points on the server but here we want
61
+ // to test the completed HTML. Don't suspend on the server.
62
+ suspend = false;
63
+ let finalHTML = ReactDOMServer.renderToString(<App />);
64
+
65
+ let container = document.createElement('div');
66
+ container.innerHTML = finalHTML;
67
+
68
+ let span = container.getElementsByTagName('span')[0];
69
+
70
+ // On the client we don't have all data yet but we want to start
71
+ // hydrating anyway.
72
+ suspend = true;
73
+ let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
74
+ root.render(<App />);
75
+ jest.runAllTimers();
76
+
77
+ expect(ref.current).toBe(null);
78
+
79
+ // Resolving the promise should continue hydration
80
+ suspend = false;
81
+ resolve();
82
+ await promise;
83
+ jest.runAllTimers();
84
+
85
+ // We should now have hydrated with a ref on the existing span.
86
+ expect(ref.current).toBe(span);
87
+ });
88
+
89
+ it('can insert siblings before the dehydrated boundary', () => {
90
+ let suspend = false;
91
+ let promise = new Promise(() => {});
92
+ let showSibling;
93
+
94
+ function Child() {
95
+ if (suspend) {
96
+ throw promise;
97
+ } else {
98
+ return 'Second';
99
+ }
100
+ }
101
+
102
+ function Sibling() {
103
+ let [visible, setVisibilty] = React.useState(false);
104
+ showSibling = () => setVisibilty(true);
105
+ if (visible) {
106
+ return <div>First</div>;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ function App() {
112
+ return (
113
+ <div>
114
+ <Sibling />
115
+ <Suspense fallback="Loading...">
116
+ <span>
117
+ <Child />
118
+ </span>
119
+ </Suspense>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ suspend = false;
125
+ let finalHTML = ReactDOMServer.renderToString(<App />);
126
+ let container = document.createElement('div');
127
+ container.innerHTML = finalHTML;
128
+
129
+ // On the client we don't have all data yet but we want to start
130
+ // hydrating anyway.
131
+ suspend = true;
132
+
133
+ act(() => {
134
+ ReactDOM.hydrate(<App />, container);
135
+ });
136
+
137
+ expect(container.firstChild.firstChild.tagName).not.toBe('DIV');
138
+
139
+ // In this state, we can still update the siblings.
140
+ act(() => showSibling());
141
+
142
+ expect(container.firstChild.firstChild.tagName).toBe('DIV');
143
+ expect(container.firstChild.firstChild.textContent).toBe('First');
144
+ });
145
+
146
+ it('can delete the dehydrated boundary before it is hydrated', () => {
147
+ let suspend = false;
148
+ let promise = new Promise(() => {});
149
+ let hideMiddle;
150
+
151
+ function Child() {
152
+ if (suspend) {
153
+ throw promise;
154
+ } else {
155
+ return (
156
+ <React.Fragment>
157
+ <div>Middle</div>
158
+ Some text
159
+ </React.Fragment>
160
+ );
161
+ }
162
+ }
163
+
164
+ function App() {
165
+ let [visible, setVisibilty] = React.useState(true);
166
+ hideMiddle = () => setVisibilty(false);
167
+
168
+ return (
169
+ <div>
170
+ <div>Before</div>
171
+ {visible ? (
172
+ <Suspense fallback="Loading...">
173
+ <Child />
174
+ </Suspense>
175
+ ) : null}
176
+ <div>After</div>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ suspend = false;
182
+ let finalHTML = ReactDOMServer.renderToString(<App />);
183
+ let container = document.createElement('div');
184
+ container.innerHTML = finalHTML;
185
+
186
+ // On the client we don't have all data yet but we want to start
187
+ // hydrating anyway.
188
+ suspend = true;
189
+ act(() => {
190
+ ReactDOM.hydrate(<App />, container);
191
+ });
192
+
193
+ expect(container.firstChild.children[1].textContent).toBe('Middle');
194
+
195
+ // In this state, we can still delete the boundary.
196
+ act(() => hideMiddle());
197
+
198
+ expect(container.firstChild.children[1].textContent).toBe('After');
199
+ });
200
+
201
+ it('regenerates the content if props have changed before hydration completes', async () => {
202
+ let suspend = false;
203
+ let resolve;
204
+ let promise = new Promise(resolvePromise => (resolve = resolvePromise));
205
+ let ref = React.createRef();
206
+
207
+ function Child({text}) {
208
+ if (suspend) {
209
+ throw promise;
210
+ } else {
211
+ return text;
212
+ }
213
+ }
214
+
215
+ function App({text, className}) {
216
+ return (
217
+ <div>
218
+ <Suspense fallback="Loading...">
219
+ <span ref={ref} className={className}>
220
+ <Child text={text} />
221
+ </span>
222
+ </Suspense>
223
+ </div>
224
+ );
225
+ }
226
+
227
+ suspend = false;
228
+ let finalHTML = ReactDOMServer.renderToString(
229
+ <App text="Hello" className="hello" />,
230
+ );
231
+ let container = document.createElement('div');
232
+ container.innerHTML = finalHTML;
233
+
234
+ let span = container.getElementsByTagName('span')[0];
235
+
236
+ // On the client we don't have all data yet but we want to start
237
+ // hydrating anyway.
238
+ suspend = true;
239
+ let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
240
+ root.render(<App text="Hello" className="hello" />);
241
+ jest.runAllTimers();
242
+
243
+ expect(ref.current).toBe(null);
244
+ expect(span.textContent).toBe('Hello');
245
+
246
+ // Render an update, which will be higher or the same priority as pinging the hydration.
247
+ root.render(<App text="Hi" className="hi" />);
248
+
249
+ // At the same time, resolving the promise so that rendering can complete.
250
+ suspend = false;
251
+ resolve();
252
+ await promise;
253
+
254
+ // Flushing both of these in the same batch won't be able to hydrate so we'll
255
+ // probably throw away the existing subtree.
256
+ jest.runAllTimers();
257
+
258
+ // Pick up the new span. In an ideal implementation this might be the same span
259
+ // but patched up. At the time of writing, this will be a new span though.
260
+ span = container.getElementsByTagName('span')[0];
261
+
262
+ // We should now have fully rendered with a ref on the new span.
263
+ expect(ref.current).toBe(span);
264
+ expect(span.textContent).toBe('Hi');
265
+ // If we ended up hydrating the existing content, we won't have properly
266
+ // patched up the tree, which might mean we haven't patched the className.
267
+ expect(span.className).toBe('hi');
268
+ });
269
+
270
+ it('shows the fallback if props have changed before hydration completes and is still suspended', async () => {
271
+ let suspend = false;
272
+ let resolve;
273
+ let promise = new Promise(resolvePromise => (resolve = resolvePromise));
274
+ let ref = React.createRef();
275
+
276
+ function Child({text}) {
277
+ if (suspend) {
278
+ throw promise;
279
+ } else {
280
+ return text;
281
+ }
282
+ }
283
+
284
+ function App({text, className}) {
285
+ return (
286
+ <div>
287
+ <Suspense fallback="Loading...">
288
+ <span ref={ref} className={className}>
289
+ <Child text={text} />
290
+ </span>
291
+ </Suspense>
292
+ </div>
293
+ );
294
+ }
295
+
296
+ suspend = false;
297
+ let finalHTML = ReactDOMServer.renderToString(
298
+ <App text="Hello" className="hello" />,
299
+ );
300
+ let container = document.createElement('div');
301
+ container.innerHTML = finalHTML;
302
+
303
+ // On the client we don't have all data yet but we want to start
304
+ // hydrating anyway.
305
+ suspend = true;
306
+ let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
307
+ root.render(<App text="Hello" className="hello" />);
308
+ jest.runAllTimers();
309
+
310
+ expect(ref.current).toBe(null);
311
+
312
+ // Render an update, but leave it still suspended.
313
+ root.render(<App text="Hi" className="hi" />);
314
+
315
+ // Flushing now should delete the existing content and show the fallback.
316
+ jest.runAllTimers();
317
+
318
+ expect(container.getElementsByTagName('span').length).toBe(0);
319
+ expect(ref.current).toBe(null);
320
+ expect(container.textContent).toBe('Loading...');
321
+
322
+ // Unsuspending shows the content.
323
+ suspend = false;
324
+ resolve();
325
+ await promise;
326
+
327
+ jest.runAllTimers();
328
+
329
+ let span = container.getElementsByTagName('span')[0];
330
+ expect(span.textContent).toBe('Hi');
331
+ expect(span.className).toBe('hi');
332
+ expect(ref.current).toBe(span);
333
+ expect(container.textContent).toBe('Hi');
334
+ });
335
+
336
+ it('shows the fallback of the outer if fallback is missing', async () => {
337
+ // This is the same exact test as above but with a nested Suspense without a fallback.
338
+ // This should be a noop.
339
+ let suspend = false;
340
+ let resolve;
341
+ let promise = new Promise(resolvePromise => (resolve = resolvePromise));
342
+ let ref = React.createRef();
343
+
344
+ function Child({text}) {
345
+ if (suspend) {
346
+ throw promise;
347
+ } else {
348
+ return text;
349
+ }
350
+ }
351
+
352
+ function App({text, className}) {
353
+ return (
354
+ <div>
355
+ <Suspense fallback="Loading...">
356
+ <span ref={ref} className={className}>
357
+ <Suspense maxDuration={200}>
358
+ <Child text={text} />
359
+ </Suspense>
360
+ </span>
361
+ </Suspense>
362
+ </div>
363
+ );
364
+ }
365
+
366
+ suspend = false;
367
+ let finalHTML = ReactDOMServer.renderToString(
368
+ <App text="Hello" className="hello" />,
369
+ );
370
+ let container = document.createElement('div');
371
+ container.innerHTML = finalHTML;
372
+
373
+ // On the client we don't have all data yet but we want to start
374
+ // hydrating anyway.
375
+ suspend = true;
376
+ let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
377
+ root.render(<App text="Hello" className="hello" />);
378
+ jest.runAllTimers();
379
+
380
+ expect(ref.current).toBe(null);
381
+
382
+ // Render an update, but leave it still suspended.
383
+ root.render(<App text="Hi" className="hi" />);
384
+
385
+ // Flushing now should delete the existing content and show the fallback.
386
+ jest.runAllTimers();
387
+
388
+ expect(container.getElementsByTagName('span').length).toBe(0);
389
+ expect(ref.current).toBe(null);
390
+ expect(container.textContent).toBe('Loading...');
391
+
392
+ // Unsuspending shows the content.
393
+ suspend = false;
394
+ resolve();
395
+ await promise;
396
+
397
+ jest.runAllTimers();
398
+
399
+ let span = container.getElementsByTagName('span')[0];
400
+ expect(span.textContent).toBe('Hi');
401
+ expect(span.className).toBe('hi');
402
+ expect(ref.current).toBe(span);
403
+ expect(container.textContent).toBe('Hi');
404
+ });
405
+
406
+ it('clears nested suspense boundaries if they did not hydrate yet', async () => {
407
+ let suspend = false;
408
+ let resolve;
409
+ let promise = new Promise(resolvePromise => (resolve = resolvePromise));
410
+ let ref = React.createRef();
411
+
412
+ function Child({text}) {
413
+ if (suspend) {
414
+ throw promise;
415
+ } else {
416
+ return text;
417
+ }
418
+ }
419
+
420
+ function App({text, className}) {
421
+ return (
422
+ <div>
423
+ <Suspense fallback="Loading...">
424
+ <Suspense fallback="Never happens">
425
+ <Child text={text} />
426
+ </Suspense>{' '}
427
+ <span ref={ref} className={className}>
428
+ <Child text={text} />
429
+ </span>
430
+ </Suspense>
431
+ </div>
432
+ );
433
+ }
434
+
435
+ suspend = false;
436
+ let finalHTML = ReactDOMServer.renderToString(
437
+ <App text="Hello" className="hello" />,
438
+ );
439
+ let container = document.createElement('div');
440
+ container.innerHTML = finalHTML;
441
+
442
+ // On the client we don't have all data yet but we want to start
443
+ // hydrating anyway.
444
+ suspend = true;
445
+ let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
446
+ root.render(<App text="Hello" className="hello" />);
447
+ jest.runAllTimers();
448
+
449
+ expect(ref.current).toBe(null);
450
+
451
+ // Render an update, but leave it still suspended.
452
+ root.render(<App text="Hi" className="hi" />);
453
+
454
+ // Flushing now should delete the existing content and show the fallback.
455
+ jest.runAllTimers();
456
+
457
+ expect(container.getElementsByTagName('span').length).toBe(0);
458
+ expect(ref.current).toBe(null);
459
+ expect(container.textContent).toBe('Loading...');
460
+
461
+ // Unsuspending shows the content.
462
+ suspend = false;
463
+ resolve();
464
+ await promise;
465
+
466
+ jest.runAllTimers();
467
+
468
+ let span = container.getElementsByTagName('span')[0];
469
+ expect(span.textContent).toBe('Hi');
470
+ expect(span.className).toBe('hi');
471
+ expect(ref.current).toBe(span);
472
+ expect(container.textContent).toBe('Hi Hi');
473
+ });
474
+
475
+ it('regenerates the content if context has changed before hydration completes', async () => {
476
+ let suspend = false;
477
+ let resolve;
478
+ let promise = new Promise(resolvePromise => (resolve = resolvePromise));
479
+ let ref = React.createRef();
480
+ let Context = React.createContext(null);
481
+
482
+ function Child() {
483
+ let {text, className} = React.useContext(Context);
484
+ if (suspend) {
485
+ throw promise;
486
+ } else {
487
+ return (
488
+ <span ref={ref} className={className}>
489
+ {text}
490
+ </span>
491
+ );
492
+ }
493
+ }
494
+
495
+ const App = React.memo(function App() {
496
+ return (
497
+ <div>
498
+ <Suspense fallback="Loading...">
499
+ <Child />
500
+ </Suspense>
501
+ </div>
502
+ );
503
+ });
504
+
505
+ suspend = false;
506
+ let finalHTML = ReactDOMServer.renderToString(
507
+ <Context.Provider value={{text: 'Hello', className: 'hello'}}>
508
+ <App />
509
+ </Context.Provider>,
510
+ );
511
+ let container = document.createElement('div');
512
+ container.innerHTML = finalHTML;
513
+
514
+ let span = container.getElementsByTagName('span')[0];
515
+
516
+ // On the client we don't have all data yet but we want to start
517
+ // hydrating anyway.
518
+ suspend = true;
519
+ let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
520
+ root.render(
521
+ <Context.Provider value={{text: 'Hello', className: 'hello'}}>
522
+ <App />
523
+ </Context.Provider>,
524
+ );
525
+ jest.runAllTimers();
526
+
527
+ expect(ref.current).toBe(null);
528
+ expect(span.textContent).toBe('Hello');
529
+
530
+ // Render an update, which will be higher or the same priority as pinging the hydration.
531
+ root.render(
532
+ <Context.Provider value={{text: 'Hi', className: 'hi'}}>
533
+ <App />
534
+ </Context.Provider>,
535
+ );
536
+
537
+ // At the same time, resolving the promise so that rendering can complete.
538
+ suspend = false;
539
+ resolve();
540
+ await promise;
541
+
542
+ // Flushing both of these in the same batch won't be able to hydrate so we'll
543
+ // probably throw away the existing subtree.
544
+ jest.runAllTimers();
545
+
546
+ // Pick up the new span. In an ideal implementation this might be the same span
547
+ // but patched up. At the time of writing, this will be a new span though.
548
+ span = container.getElementsByTagName('span')[0];
549
+
550
+ // We should now have fully rendered with a ref on the new span.
551
+ expect(ref.current).toBe(span);
552
+ expect(span.textContent).toBe('Hi');
553
+ // If we ended up hydrating the existing content, we won't have properly
554
+ // patched up the tree, which might mean we haven't patched the className.
555
+ expect(span.className).toBe('hi');
556
+ });
557
+
558
+ it('shows the fallback if context has changed before hydration completes and is still suspended', async () => {
559
+ let suspend = false;
560
+ let resolve;
561
+ let promise = new Promise(resolvePromise => (resolve = resolvePromise));
562
+ let ref = React.createRef();
563
+ let Context = React.createContext(null);
564
+
565
+ function Child() {
566
+ let {text, className} = React.useContext(Context);
567
+ if (suspend) {
568
+ throw promise;
569
+ } else {
570
+ return (
571
+ <span ref={ref} className={className}>
572
+ {text}
573
+ </span>
574
+ );
575
+ }
576
+ }
577
+
578
+ const App = React.memo(function App() {
579
+ return (
580
+ <div>
581
+ <Suspense fallback="Loading...">
582
+ <Child />
583
+ </Suspense>
584
+ </div>
585
+ );
586
+ });
587
+
588
+ suspend = false;
589
+ let finalHTML = ReactDOMServer.renderToString(
590
+ <Context.Provider value={{text: 'Hello', className: 'hello'}}>
591
+ <App />
592
+ </Context.Provider>,
593
+ );
594
+ let container = document.createElement('div');
595
+ container.innerHTML = finalHTML;
596
+
597
+ // On the client we don't have all data yet but we want to start
598
+ // hydrating anyway.
599
+ suspend = true;
600
+ let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
601
+ root.render(
602
+ <Context.Provider value={{text: 'Hello', className: 'hello'}}>
603
+ <App />
604
+ </Context.Provider>,
605
+ );
606
+ jest.runAllTimers();
607
+
608
+ expect(ref.current).toBe(null);
609
+
610
+ // Render an update, but leave it still suspended.
611
+ root.render(
612
+ <Context.Provider value={{text: 'Hi', className: 'hi'}}>
613
+ <App />
614
+ </Context.Provider>,
615
+ );
616
+
617
+ // Flushing now should delete the existing content and show the fallback.
618
+ jest.runAllTimers();
619
+
620
+ expect(container.getElementsByTagName('span').length).toBe(0);
621
+ expect(ref.current).toBe(null);
622
+ expect(container.textContent).toBe('Loading...');
623
+
624
+ // Unsuspending shows the content.
625
+ suspend = false;
626
+ resolve();
627
+ await promise;
628
+
629
+ jest.runAllTimers();
630
+
631
+ let span = container.getElementsByTagName('span')[0];
632
+ expect(span.textContent).toBe('Hi');
633
+ expect(span.className).toBe('hi');
634
+ expect(ref.current).toBe(span);
635
+ expect(container.textContent).toBe('Hi');
636
+ });
637
+});