16
let Suspense;
17
let useState;
18
let useTransition;
19
+let startTransition;
20
let act;
21
+let getCacheForType;
22
+
23
+let caches;
24
+let seededCache;
25
26
describe('ReactTransition', () => {
27
beforeEach(() => {
32
useState = React.useState;
33
useTransition = React.unstable_useTransition;
34
Suspense = React.Suspense;
35
+ startTransition = React.unstable_startTransition;
36
+ getCacheForType = React.unstable_getCacheForType;
37
act = ReactNoop.act;
38
+
39
+ caches = [];
40
+ seededCache = null;
41
});
42
33
- function Text(props) {
34
- Scheduler.unstable_yieldValue(props.text);
35
- return props.text;
43
+ function createTextCache() {
44
+ if (seededCache !== null) {
45
+ // Trick to seed a cache before it exists.
46
+ // TODO: Need a built-in API to seed data before the initial render (i.e.
47
+ // not a refresh because nothing has mounted yet).
48
+ const cache = seededCache;
49
+ seededCache = null;
50
+ return cache;
51
+ }
52
+
53
+ const data = new Map();
54
+ const version = caches.length + 1;
55
+ const cache = {
56
+ version,
57
+ data,
58
+ resolve(text) {
59
+ const record = data.get(text);
60
+ if (record === undefined) {
61
+ const newRecord = {
62
+ status: 'resolved',
63
+ value: text,
64
+ };
65
+ data.set(text, newRecord);
66
+ } else if (record.status === 'pending') {
67
+ const thenable = record.value;
68
+ record.status = 'resolved';
69
+ record.value = text;
70
+ thenable.pings.forEach(t => t());
71
+ }
72
+ },
73
+ reject(text, error) {
74
+ const record = data.get(text);
75
+ if (record === undefined) {
76
+ const newRecord = {
77
+ status: 'rejected',
78
+ value: error,
79
+ };
80
+ data.set(text, newRecord);
81
+ } else if (record.status === 'pending') {
82
+ const thenable = record.value;
83
+ record.status = 'rejected';
84
+ record.value = error;
85
+ thenable.pings.forEach(t => t());
86
+ }
87
+ },
88
+ };
89
+ caches.push(cache);
90
+ return cache;
91
}
92
38
- function createAsyncText(text) {
39
- let resolved = false;
40
- const Component = function() {
41
- if (!resolved) {
42
- Scheduler.unstable_yieldValue('Suspend! [' + text + ']');
43
- throw promise;
93
+ function readText(text) {
94
+ const textCache = getCacheForType(createTextCache);
95
+ const record = textCache.data.get(text);
96
+ if (record !== undefined) {
97
+ switch (record.status) {
98
+ case 'pending':
99
+ Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
100
+ throw record.value;
101
+ case 'rejected':
102
+ Scheduler.unstable_yieldValue(`Error! [${text}]`);
103
+ throw record.value;
104
+ case 'resolved':
105
+ return textCache.version;
106
}
45
- return <Text text={text} />;
46
- };
47
- const promise = new Promise(resolve => {
48
- Component.resolve = function() {
49
- resolved = true;
50
- return resolve();
107
+ } else {
108
+ Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
109
+
110
+ const thenable = {
111
+ pings: [],
112
+ then(resolve) {
113
+ if (newRecord.status === 'pending') {
114
+ thenable.pings.push(resolve);
115
+ } else {
116
+ Promise.resolve().then(() => resolve(newRecord.value));
117
+ }
118
+ },
119
};
52
- });
53
- return Component;
120
+
121
+ const newRecord = {
122
+ status: 'pending',
123
+ value: thenable,
124
+ };
125
+ textCache.data.set(text, newRecord);
126
+
127
+ throw thenable;
128
+ }
129
+ }
130
+
131
+ function Text({text}) {
132
+ Scheduler.unstable_yieldValue(text);
133
+ return text;
134
+ }
135
+
136
+ function AsyncText({text}) {
137
+ readText(text);
138
+ Scheduler.unstable_yieldValue(text);
139
+ return text;
140
+ }
141
+
142
+ function seedNextTextCache(text) {
143
+ if (seededCache === null) {
144
+ seededCache = createTextCache();
145
+ }
146
+ seededCache.resolve(text);
147
+ }
148
+
149
+ function resolveText(text) {
150
+ if (caches.length === 0) {
151
+ throw Error('Cache does not exist.');
152
+ } else {
153
+ // Resolve the most recently created cache. An older cache can by
154
+ // resolved with `caches[index].resolve(text)`.
155
+ caches[caches.length - 1].resolve(text);
156
+ }
157
}
158
159
// @gate experimental
57
- it('isPending works even if called from outside an input event', async () => {
58
- const Async = createAsyncText('Async');
160
+ // @gate enableCache
161
+ test('isPending works even if called from outside an input event', async () => {
162
let start;
163
function App() {
164
const [show, setShow] = useState(false);
62
- const [startTransition, isPending] = useTransition();
63
- start = () => startTransition(() => setShow(true));
165
+ const [_start, isPending] = useTransition();
166
+ start = () => _start(() => setShow(true));
167
return (
168
<Suspense fallback={<Text text="Loading..." />}>
169
{isPending ? <Text text="Pending..." /> : null}
67
- {show ? <Async /> : <Text text="(empty)" />}
170
+ {show ? <AsyncText text="Async" /> : <Text text="(empty)" />}
171
</Suspense>
172
);
173
}
192
193
expect(root).toMatchRenderedOutput('Pending...(empty)');
194
92
- await Async.resolve();
195
+ await resolveText('Async');
196
});
197
expect(Scheduler).toHaveYielded(['Async']);
198
expect(root).toMatchRenderedOutput('Async');
199
});
200
+
201
+ // @gate experimental
202
+ // @gate enableCache
203
+ test(
204
+ 'when multiple transitions update the same queue, only the most recent ' +
205
+ 'one is allowed to finish (no intermediate states)',
206
+ async () => {
207
+ let update;
208
+ function App() {
209
+ const [startContentChange, isContentPending] = useTransition();
210
+ const [label, setLabel] = useState('A');
211
+ const [contents, setContents] = useState('A');
212
+ update = value => {
213
+ ReactNoop.discreteUpdates(() => {
214
+ setLabel(value);
215
+ startContentChange(() => {
216
+ setContents(value);
217
+ });
218
+ });
219
+ };
220
+ return (
221
+ <>
222
+ <Text
223
+ text={
224
+ label + ' label' + (isContentPending ? ' (loading...)' : '')
225
+ }
226
+ />
227
+ <div>
228
+ <Suspense fallback={<Text text="Loading..." />}>
229
+ <AsyncText text={contents + ' content'} />
230
+ </Suspense>
231
+ </div>
232
+ </>
233
+ );
234
+ }
235
+
236
+ // Initial render
237
+ const root = ReactNoop.createRoot();
238
+ await act(async () => {
239
+ seedNextTextCache('A content');
240
+ root.render(<App />);
241
+ });
242
+ expect(Scheduler).toHaveYielded(['A label', 'A content']);
243
+ expect(root).toMatchRenderedOutput(
244
+ <>
245
+ A label<div>A content</div>
246
+ </>,
247
+ );
248
+
249
+ // Switch to B
250
+ await act(async () => {
251
+ update('B');
252
+ });
253
+ expect(Scheduler).toHaveYielded([
254
+ // Commit pending state
255
+ 'B label (loading...)',
256
+ 'A content',
257
+
258
+ // Attempt to render B, but it suspends
259
+ 'B label',
260
+ 'Suspend! [B content]',
261
+ 'Loading...',
262
+ ]);
263
+ // This is a refresh transition so it shouldn't show a fallback
264
+ expect(root).toMatchRenderedOutput(
265
+ <>
266
+ B label (loading...)<div>A content</div>
267
+ </>,
268
+ );
269
+
270
+ // Before B finishes loading, switch to C
271
+ await act(async () => {
272
+ update('C');
273
+ });
274
+ expect(Scheduler).toHaveYielded([
275
+ // Commit pending state
276
+ 'C label (loading...)',
277
+ 'A content',
278
+
279
+ // Attempt to render C, but it suspends
280
+ 'C label',
281
+ 'Suspend! [C content]',
282
+ 'Loading...',
283
+ ]);
284
+ expect(root).toMatchRenderedOutput(
285
+ <>
286
+ C label (loading...)<div>A content</div>
287
+ </>,
288
+ );
289
+
290
+ // Finish loading B. But we're not allowed to render B because it's
291
+ // entangled with C. So we're still pending.
292
+ await act(async () => {
293
+ resolveText('B content');
294
+ });
295
+ expect(Scheduler).toHaveYielded([
296
+ // Attempt to render C, but it suspends
297
+ 'C label',
298
+ 'Suspend! [C content]',
299
+ 'Loading...',
300
+ ]);
301
+ expect(root).toMatchRenderedOutput(
302
+ <>
303
+ C label (loading...)<div>A content</div>
304
+ </>,
305
+ );
306
+
307
+ // Now finish loading C. This is the terminal update, so it can finish.
308
+ await act(async () => {
309
+ resolveText('C content');
310
+ });
311
+ expect(Scheduler).toHaveYielded(['C label', 'C content']);
312
+ expect(root).toMatchRenderedOutput(
313
+ <>
314
+ C label<div>C content</div>
315
+ </>,
316
+ );
317
+ },
318
+ );
319
+
320
+ // Same as previous test, but for class update queue.
321
+ // @gate experimental
322
+ // @gate enableCache
323
+ test(
324
+ 'when multiple transitions update the same queue, only the most recent ' +
325
+ 'one is allowed to finish (no intermediate states) (classes)',
326
+ async () => {
327
+ let update;
328
+ class App extends React.Component {
329
+ state = {
330
+ label: 'A',
331
+ contents: 'A',
332
+ };
333
+ render() {
334
+ update = value => {
335
+ ReactNoop.discreteUpdates(() => {
336
+ this.setState({label: value});
337
+ startTransition(() => {
338
+ this.setState({contents: value});
339
+ });
340
+ });
341
+ };
342
+ const label = this.state.label;
343
+ const contents = this.state.contents;
344
+ const isContentPending = label !== contents;
345
+ return (
346
+ <>
347
+ <Text
348
+ text={
349
+ label + ' label' + (isContentPending ? ' (loading...)' : '')
350
+ }
351
+ />
352
+ <div>
353
+ <Suspense fallback={<Text text="Loading..." />}>
354
+ <AsyncText text={contents + ' content'} />
355
+ </Suspense>
356
+ </div>
357
+ </>
358
+ );
359
+ }
360
+ }
361
+
362
+ // Initial render
363
+ const root = ReactNoop.createRoot();
364
+ await act(async () => {
365
+ seedNextTextCache('A content');
366
+ root.render(<App />);
367
+ });
368
+ expect(Scheduler).toHaveYielded(['A label', 'A content']);
369
+ expect(root).toMatchRenderedOutput(
370
+ <>
371
+ A label<div>A content</div>
372
+ </>,
373
+ );
374
+
375
+ // Switch to B
376
+ await act(async () => {
377
+ update('B');
378
+ });
379
+ expect(Scheduler).toHaveYielded([
380
+ // Commit pending state
381
+ 'B label (loading...)',
382
+ 'A content',
383
+
384
+ // Attempt to render B, but it suspends
385
+ 'B label',
386
+ 'Suspend! [B content]',
387
+ 'Loading...',
388
+ ]);
389
+ // This is a refresh transition so it shouldn't show a fallback
390
+ expect(root).toMatchRenderedOutput(
391
+ <>
392
+ B label (loading...)<div>A content</div>
393
+ </>,
394
+ );
395
+
396
+ // Before B finishes loading, switch to C
397
+ await act(async () => {
398
+ update('C');
399
+ });
400
+ expect(Scheduler).toHaveYielded([
401
+ // Commit pending state
402
+ 'C label (loading...)',
403
+ 'A content',
404
+
405
+ // Attempt to render C, but it suspends
406
+ 'C label',
407
+ 'Suspend! [C content]',
408
+ 'Loading...',
409
+ ]);
410
+ expect(root).toMatchRenderedOutput(
411
+ <>
412
+ C label (loading...)<div>A content</div>
413
+ </>,
414
+ );
415
+
416
+ // Finish loading B. But we're not allowed to render B because it's
417
+ // entangled with C. So we're still pending.
418
+ await act(async () => {
419
+ resolveText('B content');
420
+ });
421
+ expect(Scheduler).toHaveYielded([
422
+ // Attempt to render C, but it suspends
423
+ 'C label',
424
+ 'Suspend! [C content]',
425
+ 'Loading...',
426
+ ]);
427
+ expect(root).toMatchRenderedOutput(
428
+ <>
429
+ C label (loading...)<div>A content</div>
430
+ </>,
431
+ );
432
+
433
+ // Now finish loading C. This is the terminal update, so it can finish.
434
+ await act(async () => {
435
+ resolveText('C content');
436
+ });
437
+ expect(Scheduler).toHaveYielded(['C label', 'C content']);
438
+ expect(root).toMatchRenderedOutput(
439
+ <>
440
+ C label<div>C content</div>
441
+ </>,
442
+ );
443
+ },
444
+ );
445
+
446
+ // @gate experimental
447
+ // @gate enableCache
448
+ test(
449
+ 'when multiple transitions update overlapping queues, all the transitions ' +
450
+ 'across all the queues are entangled',
451
+ async () => {
452
+ let setShowA;
453
+ let setShowB;
454
+ let setShowC;
455
+ function App() {
456
+ const [showA, _setShowA] = useState(false);
457
+ const [showB, _setShowB] = useState(false);
458
+ const [showC, _setShowC] = useState(false);
459
+ setShowA = _setShowA;
460
+ setShowB = _setShowB;
461
+ setShowC = _setShowC;
462
+
463
+ // Only one of these children should be visible at a time. Except
464
+ // instead of being modeled as a single state, it's three separate
465
+ // states that are updated simultaneously. This may seem a bit
466
+ // contrived, but it's more common than you might think. Usually via
467
+ // a framework or indirection. For example, consider a tooltip manager
468
+ // that only shows a single tooltip at a time. Or a router that
469
+ // highlights links to the active route.
470
+ return (
471
+ <>
472
+ <Suspense fallback={<Text text="Loading..." />}>
473
+ {showA ? <AsyncText text="A" /> : null}
474
+ {showB ? <AsyncText text="B" /> : null}
475
+ {showC ? <AsyncText text="C" /> : null}
476
+ </Suspense>
477
+ </>
478
+ );
479
+ }
480
+
481
+ // Initial render. Start with all children hidden.
482
+ const root = ReactNoop.createRoot();
483
+ await act(async () => {
484
+ root.render(<App />);
485
+ });
486
+ expect(Scheduler).toHaveYielded([]);
487
+ expect(root).toMatchRenderedOutput(null);
488
+
489
+ // Switch to A.
490
+ await act(async () => {
491
+ startTransition(() => {
492
+ setShowA(true);
493
+ });
494
+ });
495
+ expect(Scheduler).toHaveYielded(['Suspend! [A]', 'Loading...']);
496
+ expect(root).toMatchRenderedOutput(null);
497
+
498
+ // Before A loads, switch to B. This should entangle A with B.
499
+ await act(async () => {
500
+ startTransition(() => {
501
+ setShowA(false);
502
+ setShowB(true);
503
+ });
504
+ });
505
+ expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...']);
506
+ expect(root).toMatchRenderedOutput(null);
507
+
508
+ // Before A or B loads, switch to C. This should entangle C with B, and
509
+ // transitively entangle C with A.
510
+ await act(async () => {
511
+ startTransition(() => {
512
+ setShowB(false);
513
+ setShowC(true);
514
+ });
515
+ });
516
+ expect(Scheduler).toHaveYielded(['Suspend! [C]', 'Loading...']);
517
+ expect(root).toMatchRenderedOutput(null);
518
+
519
+ // Now the data starts resolving out of order.
520
+
521
+ // First resolve B. This will attempt to render C, since everything is
522
+ // entangled.
523
+ await act(async () => {
524
+ startTransition(() => {
525
+ resolveText('B');
526
+ });
527
+ });
528
+ expect(Scheduler).toHaveYielded(['Suspend! [C]', 'Loading...']);
529
+ expect(root).toMatchRenderedOutput(null);
530
+
531
+ // Now resolve A. Again, this will attempt to render C, since everything
532
+ // is entangled.
533
+ await act(async () => {
534
+ startTransition(() => {
535
+ resolveText('A');
536
+ });
537
+ });
538
+ expect(Scheduler).toHaveYielded(['Suspend! [C]', 'Loading...']);
539
+ expect(root).toMatchRenderedOutput(null);
540
+
541
+ // Finally, resolve C. This time we can finish.
542
+ await act(async () => {
543
+ startTransition(() => {
544
+ resolveText('C');
545
+ });
546
+ });
547
+ expect(Scheduler).toHaveYielded(['C']);
548
+ expect(root).toMatchRenderedOutput('C');
549
+ },
550
+ );
551
});