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
+let JSDOM;
11
+let React;
12
+let ReactDOM;
13
+let Scheduler;
14
+let clientAct;
15
+let ReactDOMFizzServer;
16
+let Stream;
17
+let Suspense;
18
+let useId;
19
+let document;
20
+let writable;
21
+let container;
22
+let buffer = '';
23
+let hasErrored = false;
24
+let fatalError = undefined;
25
+
26
+describe('useId', () => {
27
+ beforeEach(() => {
28
+ jest.resetModules();
29
+ JSDOM = require('jsdom').JSDOM;
30
+ React = require('react');
31
+ ReactDOM = require('react-dom');
32
+ Scheduler = require('scheduler');
33
+ clientAct = require('jest-react').act;
34
+ ReactDOMFizzServer = require('react-dom/server');
35
+ Stream = require('stream');
36
+ Suspense = React.Suspense;
37
+ useId = React.unstable_useId;
38
+
39
+ // Test Environment
40
+ const jsdom = new JSDOM(
41
+ '<!DOCTYPE html><html><head></head><body><div id="container">',
42
+ {
43
+ runScripts: 'dangerously',
44
+ },
45
+ );
46
+ document = jsdom.window.document;
47
+ container = document.getElementById('container');
48
+
49
+ buffer = '';
50
+ hasErrored = false;
51
+
52
+ writable = new Stream.PassThrough();
53
+ writable.setEncoding('utf8');
54
+ writable.on('data', chunk => {
55
+ buffer += chunk;
56
+ });
57
+ writable.on('error', error => {
58
+ hasErrored = true;
59
+ fatalError = error;
60
+ });
61
+ });
62
+
63
+ async function serverAct(callback) {
64
+ await callback();
65
+ // Await one turn around the event loop.
66
+ // This assumes that we'll flush everything we have so far.
67
+ await new Promise(resolve => {
68
+ setImmediate(resolve);
69
+ });
70
+ if (hasErrored) {
71
+ throw fatalError;
72
+ }
73
+ // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
74
+ // We also want to execute any scripts that are embedded.
75
+ // We assume that we have now received a proper fragment of HTML.
76
+ const bufferedContent = buffer;
77
+ buffer = '';
78
+ const fakeBody = document.createElement('body');
79
+ fakeBody.innerHTML = bufferedContent;
80
+ while (fakeBody.firstChild) {
81
+ const node = fakeBody.firstChild;
82
+ if (node.nodeName === 'SCRIPT') {
83
+ const script = document.createElement('script');
84
+ script.textContent = node.textContent;
85
+ fakeBody.removeChild(node);
86
+ container.appendChild(script);
87
+ } else {
88
+ container.appendChild(node);
89
+ }
90
+ }
91
+ }
92
+
93
+ function normalizeTreeIdForTesting(id) {
94
+ const [serverClientPrefix, base32, hookIndex] = id.split(':');
95
+ if (serverClientPrefix === 'r') {
96
+ // Client ids aren't stable. For testing purposes, strip out the counter.
97
+ return (
98
+ 'CLIENT_GENERATED_ID' +
99
+ (hookIndex !== undefined ? ` (${hookIndex})` : '')
100
+ );
101
+ }
102
+ // Formats the tree id as a binary sequence, so it's easier to visualize
103
+ // the structure.
104
+ return (
105
+ parseInt(base32, 32).toString(2) +
106
+ (hookIndex !== undefined ? ` (${hookIndex})` : '')
107
+ );
108
+ }
109
+
110
+ function DivWithId({children}) {
111
+ const id = normalizeTreeIdForTesting(useId());
112
+ return <div id={id}>{children}</div>;
113
+ }
114
+
115
+ test('basic example', async () => {
116
+ function App() {
117
+ return (
118
+ <div>
119
+ <div>
120
+ <DivWithId />
121
+ <DivWithId />
122
+ </div>
123
+ <DivWithId />
124
+ </div>
125
+ );
126
+ }
127
+
128
+ await serverAct(async () => {
129
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
130
+ pipe(writable);
131
+ });
132
+ await clientAct(async () => {
133
+ ReactDOM.hydrateRoot(container, <App />);
134
+ });
135
+ expect(container).toMatchInlineSnapshot(`
136
+ <div
137
+ id="container"
138
+ >
139
+ <div>
140
+ <div>
141
+ <div
142
+ id="101"
143
+ />
144
+ <div
145
+ id="1001"
146
+ />
147
+ </div>
148
+ <div
149
+ id="10"
150
+ />
151
+ </div>
152
+ </div>
153
+ `);
154
+ });
155
+
156
+ test('indirections', async () => {
157
+ function App() {
158
+ // There are no forks in this tree, but the parent and the child should
159
+ // have different ids.
160
+ return (
161
+ <DivWithId>
162
+ <div>
163
+ <div>
164
+ <div>
165
+ <DivWithId />
166
+ </div>
167
+ </div>
168
+ </div>
169
+ </DivWithId>
170
+ );
171
+ }
172
+
173
+ await serverAct(async () => {
174
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
175
+ pipe(writable);
176
+ });
177
+ await clientAct(async () => {
178
+ ReactDOM.hydrateRoot(container, <App />);
179
+ });
180
+ expect(container).toMatchInlineSnapshot(`
181
+ <div
182
+ id="container"
183
+ >
184
+ <div
185
+ id="0"
186
+ >
187
+ <div>
188
+ <div>
189
+ <div>
190
+ <div
191
+ id="1"
192
+ />
193
+ </div>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ </div>
198
+ `);
199
+ });
200
+
201
+ test('empty (null) children', async () => {
202
+ // We don't treat empty children different from non-empty ones, which means
203
+ // they get allocated a slot when generating ids. There's no inherent reason
204
+ // to do this; Fiber happens to allocate a fiber for null children that
205
+ // appear in a list, which is not ideal for performance. For the purposes
206
+ // of id generation, though, what matters is that Fizz and Fiber
207
+ // are consistent.
208
+ function App() {
209
+ return (
210
+ <>
211
+ {null}
212
+ <DivWithId />
213
+ {null}
214
+ <DivWithId />
215
+ </>
216
+ );
217
+ }
218
+
219
+ await serverAct(async () => {
220
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
221
+ pipe(writable);
222
+ });
223
+ await clientAct(async () => {
224
+ ReactDOM.hydrateRoot(container, <App />);
225
+ });
226
+ expect(container).toMatchInlineSnapshot(`
227
+ <div
228
+ id="container"
229
+ >
230
+ <div
231
+ id="10"
232
+ />
233
+ <div
234
+ id="100"
235
+ />
236
+ </div>
237
+ `);
238
+ });
239
+
240
+ test('large ids', async () => {
241
+ // The component in this test outputs a recursive tree of nodes with ids,
242
+ // where the underlying binary representation is an alternating series of 1s
243
+ // and 0s. In other words, they are all of the form 101010101.
244
+ //
245
+ // Because we use base 32 encoding, the resulting id should consist of
246
+ // alternating 'a' (01010) and 'l' (10101) characters, except for the the
247
+ // 'R:' prefix, and the first character after that, which may not correspond
248
+ // to a complete set of 5 bits.
249
+ //
250
+ // Example: R:clalalalalalalala...
251
+ //
252
+ // We can use this pattern to test large ids that exceed the bitwise
253
+ // safe range (32 bits). The algorithm should theoretically support ids
254
+ // of any size.
255
+
256
+ function Child({children}) {
257
+ const id = useId();
258
+ return <div id={id}>{children}</div>;
259
+ }
260
+
261
+ function App() {
262
+ let tree = <Child />;
263
+ for (let i = 0; i < 50; i++) {
264
+ tree = (
265
+ <>
266
+ <Child />
267
+ {tree}
268
+ </>
269
+ );
270
+ }
271
+ return tree;
272
+ }
273
+
274
+ await serverAct(async () => {
275
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
276
+ pipe(writable);
277
+ });
278
+ await clientAct(async () => {
279
+ ReactDOM.hydrateRoot(container, <App />);
280
+ });
281
+ const divs = container.querySelectorAll('div');
282
+
283
+ // Confirm that every id matches the expected pattern
284
+ for (let i = 0; i < divs.length; i++) {
285
+ // Example: R:clalalalalalalala...
286
+ expect(divs[i].id).toMatch(/^R:.(((al)*a?)((la)*l?))*$/);
287
+ }
288
+ });
289
+
290
+ test('multiple ids in a single component', async () => {
291
+ function App() {
292
+ const id1 = useId();
293
+ const id2 = useId();
294
+ const id3 = useId();
295
+ return `${id1}, ${id2}, ${id3}`;
296
+ }
297
+
298
+ await serverAct(async () => {
299
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
300
+ pipe(writable);
301
+ });
302
+ await clientAct(async () => {
303
+ ReactDOM.hydrateRoot(container, <App />);
304
+ });
305
+ // We append a suffix to the end of the id to distinguish them
306
+ expect(container).toMatchInlineSnapshot(`
307
+ <div
308
+ id="container"
309
+ >
310
+ R:0, R:0:1, R:0:2
311
+ <!-- -->
312
+ </div>
313
+ `);
314
+ });
315
+
316
+ test('basic incremental hydration', async () => {
317
+ function App() {
318
+ return (
319
+ <div>
320
+ <Suspense fallback="Loading...">
321
+ <DivWithId label="A" />
322
+ <DivWithId label="B" />
323
+ </Suspense>
324
+ <DivWithId label="C" />
325
+ </div>
326
+ );
327
+ }
328
+
329
+ await serverAct(async () => {
330
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
331
+ pipe(writable);
332
+ });
333
+ await clientAct(async () => {
334
+ ReactDOM.hydrateRoot(container, <App />);
335
+ });
336
+ expect(container).toMatchInlineSnapshot(`
337
+ <div
338
+ id="container"
339
+ >
340
+ <div>
341
+ <!--$-->
342
+ <div
343
+ id="101"
344
+ />
345
+ <div
346
+ id="1001"
347
+ />
348
+ <!--/$-->
349
+ <div
350
+ id="10"
351
+ />
352
+ </div>
353
+ </div>
354
+ `);
355
+ });
356
+
357
+ test('inserting/deleting siblings outside a dehydrated Suspense boundary', async () => {
358
+ const span = React.createRef(null);
359
+ function App({swap}) {
360
+ // Note: Using a dynamic array so these are treated as insertions and
361
+ // deletions instead of updates, because Fiber currently allocates a node
362
+ // even for empty children.
363
+ const children = [
364
+ <DivWithId key="A" />,
365
+ swap ? <DivWithId key="C" /> : <DivWithId key="B" />,
366
+ <DivWithId key="D" />,
367
+ ];
368
+ return (
369
+ <>
370
+ {children}
371
+ <Suspense key="boundary" fallback="Loading...">
372
+ <DivWithId />
373
+ <span ref={span} />
374
+ </Suspense>
375
+ </>
376
+ );
377
+ }
378
+
379
+ await serverAct(async () => {
380
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
381
+ pipe(writable);
382
+ });
383
+ const dehydratedSpan = container.getElementsByTagName('span')[0];
384
+ await clientAct(async () => {
385
+ const root = ReactDOM.hydrateRoot(container, <App />);
386
+ expect(Scheduler).toFlushUntilNextPaint([]);
387
+ expect(container).toMatchInlineSnapshot(`
388
+ <div
389
+ id="container"
390
+ >
391
+ <div
392
+ id="101"
393
+ />
394
+ <div
395
+ id="1001"
396
+ />
397
+ <div
398
+ id="1101"
399
+ />
400
+ <!--$-->
401
+ <div
402
+ id="110"
403
+ />
404
+ <span />
405
+ <!--/$-->
406
+ </div>
407
+ `);
408
+
409
+ // The inner boundary hasn't hydrated yet
410
+ expect(span.current).toBe(null);
411
+
412
+ // Swap B for C
413
+ root.render(<App swap={true} />);
414
+ });
415
+ // The swap should not have caused a mismatch.
416
+ expect(container).toMatchInlineSnapshot(`
417
+ <div
418
+ id="container"
419
+ >
420
+ <div
421
+ id="101"
422
+ />
423
+ <div
424
+ id="CLIENT_GENERATED_ID"
425
+ />
426
+ <div
427
+ id="1101"
428
+ />
429
+ <!--$-->
430
+ <div
431
+ id="110"
432
+ />
433
+ <span />
434
+ <!--/$-->
435
+ </div>
436
+ `);
437
+ // Should have hydrated successfully
438
+ expect(span.current).toBe(dehydratedSpan);
439
+ });
440
+
441
+ test('inserting/deleting siblings inside a dehydrated Suspense boundary', async () => {
442
+ const span = React.createRef(null);
443
+ function App({swap}) {
444
+ // Note: Using a dynamic array so these are treated as insertions and
445
+ // deletions instead of updates, because Fiber currently allocates a node
446
+ // even for empty children.
447
+ const children = [
448
+ <DivWithId key="A" />,
449
+ swap ? <DivWithId key="C" /> : <DivWithId key="B" />,
450
+ <DivWithId key="D" />,
451
+ ];
452
+ return (
453
+ <Suspense key="boundary" fallback="Loading...">
454
+ {children}
455
+ <span ref={span} />
456
+ </Suspense>
457
+ );
458
+ }
459
+
460
+ await serverAct(async () => {
461
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
462
+ pipe(writable);
463
+ });
464
+ const dehydratedSpan = container.getElementsByTagName('span')[0];
465
+ await clientAct(async () => {
466
+ const root = ReactDOM.hydrateRoot(container, <App />);
467
+ expect(Scheduler).toFlushUntilNextPaint([]);
468
+ expect(container).toMatchInlineSnapshot(`
469
+ <div
470
+ id="container"
471
+ >
472
+ <!--$-->
473
+ <div
474
+ id="101"
475
+ />
476
+ <div
477
+ id="1001"
478
+ />
479
+ <div
480
+ id="1101"
481
+ />
482
+ <span />
483
+ <!--/$-->
484
+ </div>
485
+ `);
486
+
487
+ // The inner boundary hasn't hydrated yet
488
+ expect(span.current).toBe(null);
489
+
490
+ // Swap B for C
491
+ root.render(<App swap={true} />);
492
+ });
493
+ // The swap should not have caused a mismatch.
494
+ expect(container).toMatchInlineSnapshot(`
495
+ <div
496
+ id="container"
497
+ >
498
+ <!--$-->
499
+ <div
500
+ id="101"
501
+ />
502
+ <div
503
+ id="CLIENT_GENERATED_ID"
504
+ />
505
+ <div
506
+ id="1101"
507
+ />
508
+ <span />
509
+ <!--/$-->
510
+ </div>
511
+ `);
512
+ // Should have hydrated successfully
513
+ expect(span.current).toBe(dehydratedSpan);
514
+ });
515
+});