Remove client caching from cache() API (#27977)
We haven't yet decided how we want `cache` to work on the client. The lifetime of the cache is more complex than on the server, where it only has to live as long as a single request. Since it's more important to ship this on the server, we're removing the existing behavior from the client for now. On the client (i.e. not a Server Components environment) `cache` will have not have any caching behavior. `cache(fn)` will return the function as-is. We intend to implement client caching in a future major release. In the meantime, it's only exposed as an API so that Shared Components can use per-request caching on the server without breaking on the client.
Andrew Clark committed
Jan 16, 2024 at 20:27 UTC
5c607369ceebe56d85175df84b7b6ad58dd25e1f
8 files changed
+1810
-1712
packages/react-reconciler/src/__tests__/ReactCache-test.js
+153
-1688
@@ -1,1616 +1,34 @@
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 node
9
+ */
10
+
11
+'use strict';
12
+
13
let React;
2
-let ReactNoop;
3
-let Cache;
4
-let getCacheSignal;
5
-let Scheduler;
6
-let assertLog;
7
-let act;
8
-let Suspense;
9
-let Activity;
10
-let useCacheRefresh;
11
-let startTransition;
12
-let useState;
14
+let ReactNoopFlightServer;
15
+let ReactNoopFlightClient;
16
let cache;
17
15
-let getTextCache;
16
-let textCaches;
17
-let seededCache;
18
-
18
describe('ReactCache', () => {
19
beforeEach(() => {
20
jest.resetModules();
22
-
21
+ jest.mock('react', () => require('react/react.react-server'));
22
React = require('react');
24
- ReactNoop = require('react-noop-renderer');
25
- Cache = React.unstable_Cache;
26
- Scheduler = require('scheduler');
27
- act = require('internal-test-utils').act;
28
- Suspense = React.Suspense;
29
- cache = React.cache;
30
- Activity = React.unstable_Activity;
31
- getCacheSignal = React.unstable_getCacheSignal;
32
- useCacheRefresh = React.unstable_useCacheRefresh;
33
- startTransition = React.startTransition;
34
- useState = React.useState;
35
-
36
- const InternalTestUtils = require('internal-test-utils');
37
- assertLog = InternalTestUtils.assertLog;
38
-
39
- textCaches = [];
40
- seededCache = null;
41
-
42
- if (gate(flags => flags.enableCache)) {
43
- getTextCache = cache(() => {
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 textCache = seededCache;
49
- seededCache = null;
50
- return textCache;
51
- }
52
-
53
- const data = new Map();
54
- const version = textCaches.length + 1;
55
- const textCache = {
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
- cleanupScheduled: false,
65
- };
66
- data.set(text, newRecord);
67
- } else if (record.status === 'pending') {
68
- record.value.resolve();
69
- }
70
- },
71
- reject(text, error) {
72
- const record = data.get(text);
73
- if (record === undefined) {
74
- const newRecord = {
75
- status: 'rejected',
76
- value: error,
77
- cleanupScheduled: false,
78
- };
79
- data.set(text, newRecord);
80
- } else if (record.status === 'pending') {
81
- record.value.reject();
82
- }
83
- },
84
- };
85
- textCaches.push(textCache);
86
- return textCache;
87
- });
88
- }
89
- });
90
-
91
- function readText(text) {
92
- const signal = getCacheSignal ? getCacheSignal() : null;
93
- const textCache = getTextCache();
94
- const record = textCache.data.get(text);
95
- if (record !== undefined) {
96
- if (!record.cleanupScheduled) {
97
- // This record was seeded prior to the abort signal being available:
98
- // schedule a cleanup function for it.
99
- // TODO: Add ability to cleanup entries seeded w useCacheRefresh()
100
- record.cleanupScheduled = true;
101
- if (getCacheSignal) {
102
- signal.addEventListener('abort', () => {
103
- Scheduler.log(`Cache cleanup: ${text} [v${textCache.version}]`);
104
- });
105
- }
106
- }
107
- switch (record.status) {
108
- case 'pending':
109
- throw record.value;
110
- case 'rejected':
111
- throw record.value;
112
- case 'resolved':
113
- return textCache.version;
114
- }
115
- } else {
116
- Scheduler.log(`Cache miss! [${text}]`);
117
-
118
- let resolve;
119
- let reject;
120
- const thenable = new Promise((res, rej) => {
121
- resolve = res;
122
- reject = rej;
123
- }).then(
124
- value => {
125
- if (newRecord.status === 'pending') {
126
- newRecord.status = 'resolved';
127
- newRecord.value = value;
128
- }
129
- },
130
- error => {
131
- if (newRecord.status === 'pending') {
132
- newRecord.status = 'rejected';
133
- newRecord.value = error;
134
- }
135
- },
136
- );
137
- thenable.resolve = resolve;
138
- thenable.reject = reject;
139
-
140
- const newRecord = {
141
- status: 'pending',
142
- value: thenable,
143
- cleanupScheduled: true,
144
- };
145
- textCache.data.set(text, newRecord);
146
-
147
- if (getCacheSignal) {
148
- signal.addEventListener('abort', () => {
149
- Scheduler.log(`Cache cleanup: ${text} [v${textCache.version}]`);
150
- });
151
- }
152
- throw thenable;
153
- }
154
- }
155
-
156
- function Text({text}) {
157
- Scheduler.log(text);
158
- return text;
159
- }
160
-
161
- function AsyncText({text, showVersion}) {
162
- const version = readText(text);
163
- const fullText = showVersion ? `${text} [v${version}]` : text;
164
- Scheduler.log(fullText);
165
- return fullText;
166
- }
167
-
168
- function seedNextTextCache(text) {
169
- if (seededCache === null) {
170
- seededCache = getTextCache();
171
- }
172
- seededCache.resolve(text);
173
- }
174
-
175
- function resolveMostRecentTextCache(text) {
176
- if (textCaches.length === 0) {
177
- throw Error('Cache does not exist.');
178
- } else {
179
- // Resolve the most recently created cache. An older cache can by
180
- // resolved with `textCaches[index].resolve(text)`.
181
- textCaches[textCaches.length - 1].resolve(text);
182
- }
183
- }
184
-
185
- // @gate enableCacheElement && enableCache
186
- test('render Cache component', async () => {
187
- const root = ReactNoop.createRoot();
188
- await act(() => {
189
- root.render(<Cache>Hi</Cache>);
190
- });
191
- expect(root).toMatchRenderedOutput('Hi');
192
- });
193
-
194
- // @gate enableCacheElement && enableCache
195
- test('mount new data', async () => {
196
- const root = ReactNoop.createRoot();
197
- await act(() => {
198
- root.render(
199
- <Cache>
200
- <Suspense fallback={<Text text="Loading..." />}>
201
- <AsyncText text="A" />
202
- </Suspense>
203
- </Cache>,
204
- );
205
- });
206
- assertLog(['Cache miss! [A]', 'Loading...']);
207
- expect(root).toMatchRenderedOutput('Loading...');
208
-
209
- await act(() => {
210
- resolveMostRecentTextCache('A');
211
- });
212
- assertLog(['A']);
213
- expect(root).toMatchRenderedOutput('A');
214
-
215
- await act(() => {
216
- root.render('Bye');
217
- });
218
- // no cleanup: cache is still retained at the root
219
- assertLog([]);
220
- expect(root).toMatchRenderedOutput('Bye');
221
- });
222
-
223
- // @gate enableCache
224
- test('root acts as implicit cache boundary', async () => {
225
- const root = ReactNoop.createRoot();
226
- await act(() => {
227
- root.render(
228
- <Suspense fallback={<Text text="Loading..." />}>
229
- <AsyncText text="A" />
230
- </Suspense>,
231
- );
232
- });
233
- assertLog(['Cache miss! [A]', 'Loading...']);
234
- expect(root).toMatchRenderedOutput('Loading...');
235
-
236
- await act(() => {
237
- resolveMostRecentTextCache('A');
238
- });
239
- assertLog(['A']);
240
- expect(root).toMatchRenderedOutput('A');
241
-
242
- await act(() => {
243
- root.render('Bye');
244
- });
245
- // no cleanup: cache is still retained at the root
246
- assertLog([]);
247
- expect(root).toMatchRenderedOutput('Bye');
248
- });
249
-
250
- // @gate enableCacheElement && enableCache
251
- test('multiple new Cache boundaries in the same mount share the same, fresh root cache', async () => {
252
- function App() {
253
- return (
254
- <>
255
- <Cache>
256
- <Suspense fallback={<Text text="Loading..." />}>
257
- <AsyncText text="A" />
258
- </Suspense>
259
- </Cache>
260
- <Cache>
261
- <Suspense fallback={<Text text="Loading..." />}>
262
- <AsyncText text="A" />
263
- </Suspense>
264
- </Cache>
265
- </>
266
- );
267
- }
268
-
269
- const root = ReactNoop.createRoot();
270
- await act(() => {
271
- root.render(<App showMore={false} />);
272
- });
273
-
274
- // Even though there are two new <Cache /> trees, they should share the same
275
- // data cache. So there should be only a single cache miss for A.
276
- assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
277
- expect(root).toMatchRenderedOutput('Loading...Loading...');
278
-
279
- await act(() => {
280
- resolveMostRecentTextCache('A');
281
- });
282
- assertLog(['A', 'A']);
283
- expect(root).toMatchRenderedOutput('AA');
284
-
285
- await act(() => {
286
- root.render('Bye');
287
- });
288
- // no cleanup: cache is still retained at the root
289
- assertLog([]);
290
- expect(root).toMatchRenderedOutput('Bye');
291
- });
292
-
293
- // @gate enableCacheElement && enableCache
294
- test('multiple new Cache boundaries in the same update share the same, fresh cache', async () => {
295
- function App({showMore}) {
296
- return showMore ? (
297
- <>
298
- <Cache>
299
- <Suspense fallback={<Text text="Loading..." />}>
300
- <AsyncText text="A" />
301
- </Suspense>
302
- </Cache>
303
- <Cache>
304
- <Suspense fallback={<Text text="Loading..." />}>
305
- <AsyncText text="A" />
306
- </Suspense>
307
- </Cache>
308
- </>
309
- ) : (
310
- '(empty)'
311
- );
312
- }
313
-
314
- const root = ReactNoop.createRoot();
315
- await act(() => {
316
- root.render(<App showMore={false} />);
317
- });
318
- assertLog([]);
319
- expect(root).toMatchRenderedOutput('(empty)');
320
-
321
- await act(() => {
322
- root.render(<App showMore={true} />);
323
- });
324
- // Even though there are two new <Cache /> trees, they should share the same
325
- // data cache. So there should be only a single cache miss for A.
326
- assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
327
- expect(root).toMatchRenderedOutput('Loading...Loading...');
328
-
329
- await act(() => {
330
- resolveMostRecentTextCache('A');
331
- });
332
- assertLog(['A', 'A']);
333
- expect(root).toMatchRenderedOutput('AA');
334
-
335
- await act(() => {
336
- root.render('Bye');
337
- });
338
- // cleanup occurs for the cache shared by the inner cache boundaries (which
339
- // are not shared w the root because they were added in an update)
340
- // note that no cache is created for the root since the cache is never accessed
341
- assertLog(['Cache cleanup: A [v1]']);
342
- expect(root).toMatchRenderedOutput('Bye');
343
- });
344
-
345
- // @gate enableCacheElement && enableCache
346
- test(
347
- 'nested cache boundaries share the same cache as the root during ' +
348
- 'the initial render',
349
- async () => {
350
- function App() {
351
- return (
352
- <Suspense fallback={<Text text="Loading..." />}>
353
- <AsyncText text="A" />
354
- <Cache>
355
- <AsyncText text="A" />
356
- </Cache>
357
- </Suspense>
358
- );
359
- }
360
-
361
- const root = ReactNoop.createRoot();
362
- await act(() => {
363
- root.render(<App />);
364
- });
365
- // Even though there is a nested <Cache /> boundary, it should share the same
366
- // data cache as the root. So there should be only a single cache miss for A.
367
- assertLog(['Cache miss! [A]', 'Loading...']);
368
- expect(root).toMatchRenderedOutput('Loading...');
369
-
370
- await act(() => {
371
- resolveMostRecentTextCache('A');
372
- });
373
- assertLog(['A', 'A']);
374
- expect(root).toMatchRenderedOutput('AA');
375
-
376
- await act(() => {
377
- root.render('Bye');
378
- });
379
- // no cleanup: cache is still retained at the root
380
- assertLog([]);
381
- expect(root).toMatchRenderedOutput('Bye');
382
- },
383
- );
384
-
385
- // @gate enableCacheElement && enableCache
386
- test('new content inside an existing Cache boundary should re-use already cached data', async () => {
387
- function App({showMore}) {
388
- return (
389
- <Cache>
390
- <Suspense fallback={<Text text="Loading..." />}>
391
- <AsyncText showVersion={true} text="A" />
392
- </Suspense>
393
- {showMore ? (
394
- <Suspense fallback={<Text text="Loading..." />}>
395
- <AsyncText showVersion={true} text="A" />
396
- </Suspense>
397
- ) : null}
398
- </Cache>
399
- );
400
- }
401
-
402
- const root = ReactNoop.createRoot();
403
- await act(() => {
404
- seedNextTextCache('A');
405
- root.render(<App showMore={false} />);
406
- });
407
- assertLog(['A [v1]']);
408
- expect(root).toMatchRenderedOutput('A [v1]');
409
-
410
- // Add a new cache boundary
411
- await act(() => {
412
- root.render(<App showMore={true} />);
413
- });
414
- assertLog([
415
- 'A [v1]',
416
- // New tree should use already cached data
417
- 'A [v1]',
418
- ]);
419
- expect(root).toMatchRenderedOutput('A [v1]A [v1]');
420
-
421
- await act(() => {
422
- root.render('Bye');
423
- });
424
- // no cleanup: cache is still retained at the root
425
- assertLog([]);
426
- expect(root).toMatchRenderedOutput('Bye');
427
- });
428
-
429
- // @gate enableCacheElement && enableCache
430
- test('a new Cache boundary uses fresh cache', async () => {
431
- // The only difference from the previous test is that the "Show More"
432
- // content is wrapped in a nested <Cache /> boundary
433
- function App({showMore}) {
434
- return (
435
- <Cache>
436
- <Suspense fallback={<Text text="Loading..." />}>
437
- <AsyncText showVersion={true} text="A" />
438
- </Suspense>
439
- {showMore ? (
440
- <Cache>
441
- <Suspense fallback={<Text text="Loading..." />}>
442
- <AsyncText showVersion={true} text="A" />
443
- </Suspense>
444
- </Cache>
445
- ) : null}
446
- </Cache>
447
- );
448
- }
449
-
450
- const root = ReactNoop.createRoot();
451
- await act(() => {
452
- seedNextTextCache('A');
453
- root.render(<App showMore={false} />);
454
- });
455
- assertLog(['A [v1]']);
456
- expect(root).toMatchRenderedOutput('A [v1]');
457
-
458
- // Add a new cache boundary
459
- await act(() => {
460
- root.render(<App showMore={true} />);
461
- });
462
- assertLog([
463
- 'A [v1]',
464
- // New tree should load fresh data.
465
- 'Cache miss! [A]',
466
- 'Loading...',
467
- ]);
468
- expect(root).toMatchRenderedOutput('A [v1]Loading...');
469
- await act(() => {
470
- resolveMostRecentTextCache('A');
471
- });
472
- assertLog(['A [v2]']);
473
- expect(root).toMatchRenderedOutput('A [v1]A [v2]');
474
-
475
- // Replace all the children: this should retain the root Cache instance,
476
- // but cleanup the separate cache instance created for the fresh cache
477
- // boundary
478
- await act(() => {
479
- root.render('Bye!');
480
- });
481
- // Cleanup occurs for the *second* cache instance: the first is still
482
- // referenced by the root
483
- assertLog(['Cache cleanup: A [v2]']);
484
- expect(root).toMatchRenderedOutput('Bye!');
485
- });
486
-
487
- // @gate enableCacheElement && enableCache
488
- test('inner/outer cache boundaries uses the same cache instance on initial render', async () => {
489
- const root = ReactNoop.createRoot();
490
-
491
- function App() {
492
- return (
493
- <Cache>
494
- <Suspense fallback={<Text text="Loading shell..." />}>
495
- {/* The shell reads A */}
496
- <Shell>
497
- {/* The inner content reads both A and B */}
498
- <Suspense fallback={<Text text="Loading content..." />}>
499
- <Cache>
500
- <Content />
501
- </Cache>
502
- </Suspense>
503
- </Shell>
504
- </Suspense>
505
- </Cache>
506
- );
507
- }
508
-
509
- function Shell({children}) {
510
- readText('A');
511
- return (
512
- <>
513
- <div>
514
- <Text text="Shell" />
515
- </div>
516
- <div>{children}</div>
517
- </>
518
- );
519
- }
520
-
521
- function Content() {
522
- readText('A');
523
- readText('B');
524
- return <Text text="Content" />;
525
- }
526
-
527
- await act(() => {
528
- root.render(<App />);
529
- });
530
- assertLog(['Cache miss! [A]', 'Loading shell...']);
531
- expect(root).toMatchRenderedOutput('Loading shell...');
532
-
533
- await act(() => {
534
- resolveMostRecentTextCache('A');
535
- });
536
- assertLog([
537
- 'Shell',
538
- // There's a cache miss for B, because it hasn't been read yet. But not
539
- // A, because it was cached when we rendered the shell.
540
- 'Cache miss! [B]',
541
- 'Loading content...',
542
- ]);
543
- expect(root).toMatchRenderedOutput(
544
- <>
545
- <div>Shell</div>
546
- <div>Loading content...</div>
547
- </>,
548
- );
549
-
550
- await act(() => {
551
- resolveMostRecentTextCache('B');
552
- });
553
- assertLog(['Content']);
554
- expect(root).toMatchRenderedOutput(
555
- <>
556
- <div>Shell</div>
557
- <div>Content</div>
558
- </>,
559
- );
560
-
561
- await act(() => {
562
- root.render('Bye');
563
- });
564
- // no cleanup: cache is still retained at the root
565
- assertLog([]);
566
- expect(root).toMatchRenderedOutput('Bye');
567
- });
568
-
569
- // @gate enableCacheElement && enableCache
570
- test('inner/ outer cache boundaries added in the same update use the same cache instance', async () => {
571
- const root = ReactNoop.createRoot();
572
-
573
- function App({showMore}) {
574
- return showMore ? (
575
- <Cache>
576
- <Suspense fallback={<Text text="Loading shell..." />}>
577
- {/* The shell reads A */}
578
- <Shell>
579
- {/* The inner content reads both A and B */}
580
- <Suspense fallback={<Text text="Loading content..." />}>
581
- <Cache>
582
- <Content />
583
- </Cache>
584
- </Suspense>
585
- </Shell>
586
- </Suspense>
587
- </Cache>
588
- ) : (
589
- '(empty)'
590
- );
591
- }
592
-
593
- function Shell({children}) {
594
- readText('A');
595
- return (
596
- <>
597
- <div>
598
- <Text text="Shell" />
599
- </div>
600
- <div>{children}</div>
601
- </>
602
- );
603
- }
604
-
605
- function Content() {
606
- readText('A');
607
- readText('B');
608
- return <Text text="Content" />;
609
- }
610
-
611
- await act(() => {
612
- root.render(<App showMore={false} />);
613
- });
614
- assertLog([]);
615
- expect(root).toMatchRenderedOutput('(empty)');
616
-
617
- await act(() => {
618
- root.render(<App showMore={true} />);
619
- });
620
- assertLog(['Cache miss! [A]', 'Loading shell...']);
621
- expect(root).toMatchRenderedOutput('Loading shell...');
622
-
623
- await act(() => {
624
- resolveMostRecentTextCache('A');
625
- });
626
- assertLog([
627
- 'Shell',
628
- // There's a cache miss for B, because it hasn't been read yet. But not
629
- // A, because it was cached when we rendered the shell.
630
- 'Cache miss! [B]',
631
- 'Loading content...',
632
- ]);
633
- expect(root).toMatchRenderedOutput(
634
- <>
635
- <div>Shell</div>
636
- <div>Loading content...</div>
637
- </>,
638
- );
639
-
640
- await act(() => {
641
- resolveMostRecentTextCache('B');
642
- });
643
- assertLog(['Content']);
644
- expect(root).toMatchRenderedOutput(
645
- <>
646
- <div>Shell</div>
647
- <div>Content</div>
648
- </>,
649
- );
650
-
651
- await act(() => {
652
- root.render('Bye');
653
- });
654
- assertLog(['Cache cleanup: A [v1]', 'Cache cleanup: B [v1]']);
655
- expect(root).toMatchRenderedOutput('Bye');
656
- });
657
-
658
- // @gate enableCache
659
- test('refresh a cache boundary', async () => {
660
- let refresh;
661
- function App() {
662
- refresh = useCacheRefresh();
663
- return <AsyncText showVersion={true} text="A" />;
664
- }
665
-
666
- // Mount initial data
667
- const root = ReactNoop.createRoot();
668
- await act(() => {
669
- root.render(
670
- <Suspense fallback={<Text text="Loading..." />}>
671
- <App />
672
- </Suspense>,
673
- );
674
- });
675
- assertLog(['Cache miss! [A]', 'Loading...']);
676
- expect(root).toMatchRenderedOutput('Loading...');
677
-
678
- await act(() => {
679
- resolveMostRecentTextCache('A');
680
- });
681
- assertLog(['A [v1]']);
682
- expect(root).toMatchRenderedOutput('A [v1]');
683
-
684
- // Refresh for new data.
685
- await act(() => {
686
- startTransition(() => refresh());
687
- });
688
- assertLog(['Cache miss! [A]', 'Loading...']);
689
- expect(root).toMatchRenderedOutput('A [v1]');
690
-
691
- await act(() => {
692
- resolveMostRecentTextCache('A');
693
- });
694
- // Note that the version has updated
695
- if (getCacheSignal) {
696
- assertLog(['A [v2]', 'Cache cleanup: A [v1]']);
697
- } else {
698
- assertLog(['A [v2]']);
699
- }
700
- expect(root).toMatchRenderedOutput('A [v2]');
701
-
702
- await act(() => {
703
- root.render('Bye');
704
- });
705
- expect(root).toMatchRenderedOutput('Bye');
706
- });
707
-
708
- // @gate enableCacheElement && enableCache
709
- test('refresh the root cache', async () => {
710
- let refresh;
711
- function App() {
712
- refresh = useCacheRefresh();
713
- return <AsyncText showVersion={true} text="A" />;
714
- }
715
-
716
- // Mount initial data
717
- const root = ReactNoop.createRoot();
718
- await act(() => {
719
- root.render(
720
- <Suspense fallback={<Text text="Loading..." />}>
721
- <App />
722
- </Suspense>,
723
- );
724
- });
725
- assertLog(['Cache miss! [A]', 'Loading...']);
726
- expect(root).toMatchRenderedOutput('Loading...');
727
-
728
- await act(() => {
729
- resolveMostRecentTextCache('A');
730
- });
731
- assertLog(['A [v1]']);
732
- expect(root).toMatchRenderedOutput('A [v1]');
733
-
734
- // Refresh for new data.
735
- await act(() => {
736
- startTransition(() => refresh());
737
- });
738
- assertLog(['Cache miss! [A]', 'Loading...']);
739
- expect(root).toMatchRenderedOutput('A [v1]');
740
-
741
- await act(() => {
742
- resolveMostRecentTextCache('A');
743
- });
744
- // Note that the version has updated, and the previous cache is cleared
745
- assertLog(['A [v2]', 'Cache cleanup: A [v1]']);
746
- expect(root).toMatchRenderedOutput('A [v2]');
747
-
748
- await act(() => {
749
- root.render('Bye');
750
- });
751
- // the original root cache already cleaned up when the refresh completed
752
- assertLog([]);
753
- expect(root).toMatchRenderedOutput('Bye');
754
- });
755
-
756
- // @gate enableCacheElement && enableCache
757
- test('refresh the root cache without a transition', async () => {
758
- let refresh;
759
- function App() {
760
- refresh = useCacheRefresh();
761
- return <AsyncText showVersion={true} text="A" />;
762
- }
763
-
764
- // Mount initial data
765
- const root = ReactNoop.createRoot();
766
- await act(() => {
767
- root.render(
768
- <Suspense fallback={<Text text="Loading..." />}>
769
- <App />
770
- </Suspense>,
771
- );
772
- });
773
- assertLog(['Cache miss! [A]', 'Loading...']);
774
- expect(root).toMatchRenderedOutput('Loading...');
775
-
776
- await act(() => {
777
- resolveMostRecentTextCache('A');
778
- });
779
- assertLog(['A [v1]']);
780
- expect(root).toMatchRenderedOutput('A [v1]');
781
-
782
- // Refresh for new data.
783
- await act(() => {
784
- refresh();
785
- });
786
- assertLog([
787
- 'Cache miss! [A]',
788
- 'Loading...',
789
- // The v1 cache can be cleaned up since everything that references it has
790
- // been replaced by a fallback. When the boundary switches back to visible
791
- // it will use the v2 cache.
792
- 'Cache cleanup: A [v1]',
793
- ]);
794
- expect(root).toMatchRenderedOutput('Loading...');
795
-
796
- await act(() => {
797
- resolveMostRecentTextCache('A');
798
- });
799
- // Note that the version has updated, and the previous cache is cleared
800
- assertLog(['A [v2]']);
801
- expect(root).toMatchRenderedOutput('A [v2]');
802
-
803
- await act(() => {
804
- root.render('Bye');
805
- });
806
- // the original root cache already cleaned up when the refresh completed
807
- assertLog([]);
808
- expect(root).toMatchRenderedOutput('Bye');
809
- });
810
-
811
- // @gate enableCacheElement && enableCache
812
- test('refresh a cache with seed data', async () => {
813
- let refreshWithSeed;
814
- function App() {
815
- const refresh = useCacheRefresh();
816
- const [seed, setSeed] = useState({fn: null});
817
- if (seed.fn) {
818
- seed.fn();
819
- seed.fn = null;
820
- }
821
- refreshWithSeed = fn => {
822
- setSeed({fn});
823
- refresh();
824
- };
825
- return <AsyncText showVersion={true} text="A" />;
826
- }
827
-
828
- // Mount initial data
829
- const root = ReactNoop.createRoot();
830
- await act(() => {
831
- root.render(
832
- <Cache>
833
- <Suspense fallback={<Text text="Loading..." />}>
834
- <App />
835
- </Suspense>
836
- </Cache>,
837
- );
838
- });
839
- assertLog(['Cache miss! [A]', 'Loading...']);
840
- expect(root).toMatchRenderedOutput('Loading...');
841
-
842
- await act(() => {
843
- resolveMostRecentTextCache('A');
844
- });
845
- assertLog(['A [v1]']);
846
- expect(root).toMatchRenderedOutput('A [v1]');
847
-
848
- // Refresh for new data.
849
- await act(() => {
850
- // Refresh the cache with seeded data, like you would receive from a
851
- // server mutation.
852
- // TODO: Seeding multiple typed textCaches. Should work by calling `refresh`
853
- // multiple times with different key/value pairs
854
- startTransition(() =>
855
- refreshWithSeed(() => {
856
- const textCache = getTextCache();
857
- textCache.resolve('A');
858
- }),
859
- );
860
- });
861
- // The root should re-render without a cache miss.
862
- // The cache is not cleared up yet, since it's still reference by the root
863
- assertLog(['A [v2]']);
864
- expect(root).toMatchRenderedOutput('A [v2]');
865
-
866
- await act(() => {
867
- root.render('Bye');
868
- });
869
- // the refreshed cache boundary is unmounted and cleans up
870
- assertLog(['Cache cleanup: A [v2]']);
871
- expect(root).toMatchRenderedOutput('Bye');
872
- });
873
-
874
- // @gate enableCacheElement && enableCache
875
- test('refreshing a parent cache also refreshes its children', async () => {
876
- let refreshShell;
877
- function RefreshShell() {
878
- refreshShell = useCacheRefresh();
879
- return null;
880
- }
881
-
882
- function App({showMore}) {
883
- return (
884
- <Cache>
885
- <RefreshShell />
886
- <Suspense fallback={<Text text="Loading..." />}>
887
- <AsyncText showVersion={true} text="A" />
888
- </Suspense>
889
- {showMore ? (
890
- <Cache>
891
- <Suspense fallback={<Text text="Loading..." />}>
892
- <AsyncText showVersion={true} text="A" />
893
- </Suspense>
894
- </Cache>
895
- ) : null}
896
- </Cache>
897
- );
898
- }
899
-
900
- const root = ReactNoop.createRoot();
901
- await act(() => {
902
- seedNextTextCache('A');
903
- root.render(<App showMore={false} />);
904
- });
905
- assertLog(['A [v1]']);
906
- expect(root).toMatchRenderedOutput('A [v1]');
907
-
908
- // Add a new cache boundary
909
- await act(() => {
910
- seedNextTextCache('A');
911
- root.render(<App showMore={true} />);
912
- });
913
- assertLog([
914
- 'A [v1]',
915
- // New tree should load fresh data.
916
- 'A [v2]',
917
- ]);
918
- expect(root).toMatchRenderedOutput('A [v1]A [v2]');
919
-
920
- // Now refresh the shell. This should also cause the "Show More" contents to
921
- // refresh, since its cache is nested inside the outer one.
922
- await act(() => {
923
- startTransition(() => refreshShell());
924
- });
925
- assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
926
- expect(root).toMatchRenderedOutput('A [v1]A [v2]');
927
-
928
- await act(() => {
929
- resolveMostRecentTextCache('A');
930
- });
931
- assertLog([
932
- 'A [v3]',
933
- 'A [v3]',
934
- // once the refresh completes the inner showMore boundary frees its previous
935
- // cache instance, since it is now using the refreshed parent instance.
936
- 'Cache cleanup: A [v2]',
937
- ]);
938
- expect(root).toMatchRenderedOutput('A [v3]A [v3]');
939
-
940
- await act(() => {
941
- root.render('Bye!');
942
- });
943
- // Unmounting children releases the refreshed cache instance only; the root
944
- // still retains the original cache instance used for the first render
945
- assertLog(['Cache cleanup: A [v3]']);
946
- expect(root).toMatchRenderedOutput('Bye!');
947
- });
948
-
949
- // @gate enableCacheElement && enableCache
950
- test(
951
- 'refreshing a cache boundary does not refresh the other boundaries ' +
952
- 'that mounted at the same time (i.e. the ones that share the same cache)',
953
- async () => {
954
- let refreshFirstBoundary;
955
- function RefreshFirstBoundary() {
956
- refreshFirstBoundary = useCacheRefresh();
957
- return null;
958
- }
959
-
960
- function App({showMore}) {
961
- return showMore ? (
962
- <>
963
- <Cache>
964
- <Suspense fallback={<Text text="Loading..." />}>
965
- <RefreshFirstBoundary />
966
- <AsyncText showVersion={true} text="A" />
967
- </Suspense>
968
- </Cache>
969
- <Cache>
970
- <Suspense fallback={<Text text="Loading..." />}>
971
- <AsyncText showVersion={true} text="A" />
972
- </Suspense>
973
- </Cache>
974
- </>
975
- ) : null;
976
- }
977
-
978
- // First mount the initial shell without the nested boundaries. This is
979
- // necessary for this test because we want the two inner boundaries to be
980
- // treated like sibling providers that happen to share an underlying
981
- // cache, as opposed to consumers of the root-level cache.
982
- const root = ReactNoop.createRoot();
983
- await act(() => {
984
- root.render(<App showMore={false} />);
985
- });
986
-
987
- // Now reveal the boundaries. In a real app this would be a navigation.
988
- await act(() => {
989
- root.render(<App showMore={true} />);
990
- });
991
-
992
- // Even though there are two new <Cache /> trees, they should share the same
993
- // data cache. So there should be only a single cache miss for A.
994
- assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
995
- expect(root).toMatchRenderedOutput('Loading...Loading...');
996
-
997
- await act(() => {
998
- resolveMostRecentTextCache('A');
999
- });
1000
- assertLog(['A [v1]', 'A [v1]']);
1001
- expect(root).toMatchRenderedOutput('A [v1]A [v1]');
1002
-
1003
- // Refresh the first boundary. It should not refresh the second boundary,
1004
- // even though they previously shared the same underlying cache.
1005
- await act(async () => {
1006
- await refreshFirstBoundary();
1007
- });
1008
- assertLog(['Cache miss! [A]', 'Loading...']);
1009
-
1010
- await act(() => {
1011
- resolveMostRecentTextCache('A');
1012
- });
1013
- assertLog(['A [v2]']);
1014
- expect(root).toMatchRenderedOutput('A [v2]A [v1]');
1015
-
1016
- // Unmount children: this should clear *both* cache instances:
1017
- // the root doesn't have a cache instance (since it wasn't accessed
1018
- // during the initial render, and all subsequent cache accesses were within
1019
- // a fresh boundary). Therefore this causes cleanup for both the fresh cache
1020
- // instance in the refreshed first boundary and cleanup for the non-refreshed
1021
- // sibling boundary.
1022
- await act(() => {
1023
- root.render('Bye!');
1024
- });
1025
- assertLog(['Cache cleanup: A [v2]', 'Cache cleanup: A [v1]']);
1026
- expect(root).toMatchRenderedOutput('Bye!');
1027
- },
1028
- );
1029
-
1030
- // @gate enableCacheElement && enableCache
1031
- test(
1032
- 'mount a new Cache boundary in a sibling while simultaneously ' +
1033
- 'resolving a Suspense boundary',
1034
- async () => {
1035
- function App({showMore}) {
1036
- return (
1037
- <>
1038
- {showMore ? (
1039
- <Suspense fallback={<Text text="Loading..." />}>
1040
- <Cache>
1041
- <AsyncText showVersion={true} text="A" />
1042
- </Cache>
1043
- </Suspense>
1044
- ) : null}
1045
- <Suspense fallback={<Text text="Loading..." />}>
1046
- <Cache>
1047
- {' '}
1048
- <AsyncText showVersion={true} text="A" />{' '}
1049
- <AsyncText showVersion={true} text="B" />
1050
- </Cache>
1051
- </Suspense>
1052
- </>
1053
- );
1054
- }
1055
-
1056
- const root = ReactNoop.createRoot();
1057
- await act(() => {
1058
- root.render(<App showMore={false} />);
1059
- });
1060
- assertLog(['Cache miss! [A]', 'Loading...']);
1061
- expect(root).toMatchRenderedOutput('Loading...');
1062
-
1063
- await act(() => {
1064
- // This will resolve the content in the first cache
1065
- resolveMostRecentTextCache('A');
1066
- resolveMostRecentTextCache('B');
1067
- // And mount the second tree, which includes new content
1068
- root.render(<App showMore={true} />);
1069
- });
1070
- assertLog([
1071
- // The new tree should use a fresh cache
1072
- 'Cache miss! [A]',
1073
- 'Loading...',
1074
- // The other tree uses the cached responses. This demonstrates that the
1075
- // requests are not dropped.
1076
- 'A [v1]',
1077
- 'B [v1]',
1078
- ]);
1079
- expect(root).toMatchRenderedOutput('Loading... A [v1] B [v1]');
1080
-
1081
- // Now resolve the second tree
1082
- await act(() => {
1083
- resolveMostRecentTextCache('A');
1084
- });
1085
- assertLog(['A [v2]']);
1086
- expect(root).toMatchRenderedOutput('A [v2] A [v1] B [v1]');
1087
-
1088
- await act(() => {
1089
- root.render('Bye!');
1090
- });
1091
- // Unmounting children releases both cache boundaries, but the original
1092
- // cache instance (used by second boundary) is still referenced by the root.
1093
- // only the second cache instance is freed.
1094
- assertLog(['Cache cleanup: A [v2]']);
1095
- expect(root).toMatchRenderedOutput('Bye!');
1096
- },
1097
- );
1098
-
1099
- // @gate enableCacheElement && enableCache
1100
- test('cache pool is cleared once transitions that depend on it commit their shell', async () => {
1101
- function Child({text}) {
1102
- return (
1103
- <Cache>
1104
- <AsyncText showVersion={true} text={text} />
1105
- </Cache>
1106
- );
1107
- }
1108
-
1109
- const root = ReactNoop.createRoot();
1110
- await act(() => {
1111
- root.render(
1112
- <Suspense fallback={<Text text="Loading..." />}>(empty)</Suspense>,
1113
- );
1114
- });
1115
- assertLog([]);
1116
- expect(root).toMatchRenderedOutput('(empty)');
1117
-
1118
- await act(() => {
1119
- startTransition(() => {
1120
- root.render(
1121
- <Suspense fallback={<Text text="Loading..." />}>
1122
- <Child text="A" />
1123
- </Suspense>,
1124
- );
1125
- });
1126
- });
1127
- assertLog(['Cache miss! [A]', 'Loading...']);
1128
- expect(root).toMatchRenderedOutput('(empty)');
1129
-
1130
- await act(() => {
1131
- startTransition(() => {
1132
- root.render(
1133
- <Suspense fallback={<Text text="Loading..." />}>
1134
- <Child text="A" />
1135
- <Child text="A" />
1136
- </Suspense>,
1137
- );
1138
- });
1139
- });
1140
- assertLog([
1141
- // No cache miss, because it uses the pooled cache
1142
- 'Loading...',
1143
- ]);
1144
- expect(root).toMatchRenderedOutput('(empty)');
1145
-
1146
- // Resolve the request
1147
- await act(() => {
1148
- resolveMostRecentTextCache('A');
1149
- });
1150
- assertLog(['A [v1]', 'A [v1]']);
1151
- expect(root).toMatchRenderedOutput('A [v1]A [v1]');
1152
-
1153
- // Now do another transition
1154
- await act(() => {
1155
- startTransition(() => {
1156
- root.render(
1157
- <Suspense fallback={<Text text="Loading..." />}>
1158
- <Child text="A" />
1159
- <Child text="A" />
1160
- <Child text="A" />
1161
- </Suspense>,
1162
- );
1163
- });
1164
- });
1165
- assertLog([
1166
- // First two children use the old cache because they already finished
1167
- 'A [v1]',
1168
- 'A [v1]',
1169
- // The new child uses a fresh cache
1170
- 'Cache miss! [A]',
1171
- 'Loading...',
1172
- ]);
1173
- expect(root).toMatchRenderedOutput('A [v1]A [v1]');
1174
-
1175
- await act(() => {
1176
- resolveMostRecentTextCache('A');
1177
- });
1178
- assertLog(['A [v1]', 'A [v1]', 'A [v2]']);
1179
- expect(root).toMatchRenderedOutput('A [v1]A [v1]A [v2]');
1180
-
1181
- // Unmount children: the first text cache instance is created only after the root
1182
- // commits, so both fresh cache instances are released by their cache boundaries,
1183
- // cleaning up v1 (used for the first two children which render together) and
1184
- // v2 (used for the third boundary added later).
1185
- await act(() => {
1186
- root.render('Bye!');
1187
- });
1188
- assertLog(['Cache cleanup: A [v1]', 'Cache cleanup: A [v2]']);
1189
- expect(root).toMatchRenderedOutput('Bye!');
1190
- });
1191
-
1192
- // @gate enableCacheElement && enableCache
1193
- test('cache pool is not cleared by arbitrary commits', async () => {
1194
- function App() {
1195
- return (
1196
- <>
1197
- <ShowMore />
1198
- <Unrelated />
1199
- </>
1200
- );
1201
- }
1202
-
1203
- let showMore;
1204
- function ShowMore() {
1205
- const [shouldShow, _showMore] = useState(false);
1206
- showMore = () => _showMore(true);
1207
- return (
1208
- <>
1209
- <Suspense fallback={<Text text="Loading..." />}>
1210
- {shouldShow ? (
1211
- <Cache>
1212
- <AsyncText showVersion={true} text="A" />
1213
- </Cache>
1214
- ) : null}
1215
- </Suspense>
1216
- </>
1217
- );
1218
- }
1219
-
1220
- let updateUnrelated;
1221
- function Unrelated() {
1222
- const [count, _updateUnrelated] = useState(0);
1223
- updateUnrelated = _updateUnrelated;
1224
- return <Text text={String(count)} />;
1225
- }
1226
-
1227
- const root = ReactNoop.createRoot();
1228
- await act(() => {
1229
- root.render(<App />);
1230
- });
1231
- assertLog(['0']);
1232
- expect(root).toMatchRenderedOutput('0');
1233
-
1234
- await act(() => {
1235
- startTransition(() => {
1236
- showMore();
1237
- });
1238
- });
1239
- assertLog(['Cache miss! [A]', 'Loading...']);
1240
- expect(root).toMatchRenderedOutput('0');
1241
-
1242
- await act(() => {
1243
- updateUnrelated(1);
1244
- });
1245
- assertLog([
1246
- '1',
1247
-
1248
- // Happens to re-render the fallback. Doesn't need to, but not relevant
1249
- // to this test.
1250
- 'Loading...',
1251
- ]);
1252
- expect(root).toMatchRenderedOutput('1');
1253
-
1254
- await act(() => {
1255
- resolveMostRecentTextCache('A');
1256
- });
1257
- assertLog(['A [v1]']);
1258
- expect(root).toMatchRenderedOutput('A [v1]1');
1259
-
1260
- // Unmount children: the first text cache instance is created only after initial
1261
- // render after calling showMore(). This instance is cleaned up when that boundary
1262
- // is unmounted. Bc root cache instance is never accessed, the inner cache
1263
- // boundary ends up at v1.
1264
- await act(() => {
1265
- root.render('Bye!');
1266
- });
1267
- assertLog(['Cache cleanup: A [v1]']);
1268
- expect(root).toMatchRenderedOutput('Bye!');
1269
- });
1270
-
1271
- // @gate enableCacheElement && enableCache
1272
- test('cache boundary uses a fresh cache when its key changes', async () => {
1273
- const root = ReactNoop.createRoot();
1274
- seedNextTextCache('A');
1275
- await act(() => {
1276
- root.render(
1277
- <Suspense fallback="Loading...">
1278
- <Cache key="A">
1279
- <AsyncText showVersion={true} text="A" />
1280
- </Cache>
1281
- </Suspense>,
1282
- );
1283
- });
1284
- assertLog(['A [v1]']);
1285
- expect(root).toMatchRenderedOutput('A [v1]');
1286
-
1287
- seedNextTextCache('B');
1288
- await act(() => {
1289
- root.render(
1290
- <Suspense fallback="Loading...">
1291
- <Cache key="B">
1292
- <AsyncText showVersion={true} text="B" />
1293
- </Cache>
1294
- </Suspense>,
1295
- );
1296
- });
1297
- assertLog(['B [v2]']);
1298
- expect(root).toMatchRenderedOutput('B [v2]');
1299
-
1300
- // Unmount children: the fresh cache instance for B cleans up since the cache boundary
1301
- // is the only owner, while the original cache instance (for A) is still retained by
1302
- // the root.
1303
- await act(() => {
1304
- root.render('Bye!');
1305
- });
1306
- assertLog(['Cache cleanup: B [v2]']);
1307
- expect(root).toMatchRenderedOutput('Bye!');
1308
- });
1309
-
1310
- // @gate enableCacheElement && enableCache
1311
- test('overlapping transitions after an initial mount use the same fresh cache', async () => {
1312
- const root = ReactNoop.createRoot();
1313
- await act(() => {
1314
- root.render(
1315
- <Suspense fallback="Loading...">
1316
- <Cache key="A">
1317
- <AsyncText showVersion={true} text="A" />
1318
- </Cache>
1319
- </Suspense>,
1320
- );
1321
- });
1322
- assertLog(['Cache miss! [A]']);
1323
- expect(root).toMatchRenderedOutput('Loading...');
1324
-
1325
- await act(() => {
1326
- resolveMostRecentTextCache('A');
1327
- });
1328
- assertLog(['A [v1]']);
1329
- expect(root).toMatchRenderedOutput('A [v1]');
1330
-
1331
- // After a mount, subsequent transitions use a fresh cache
1332
- await act(() => {
1333
- startTransition(() => {
1334
- root.render(
1335
- <Suspense fallback="Loading...">
1336
- <Cache key="B">
1337
- <AsyncText showVersion={true} text="B" />
1338
- </Cache>
1339
- </Suspense>,
1340
- );
1341
- });
1342
- });
1343
- assertLog(['Cache miss! [B]']);
1344
- expect(root).toMatchRenderedOutput('A [v1]');
1345
-
1346
- // Update to a different text and with a different key for the cache
1347
- // boundary: this should still use the fresh cache instance created
1348
- // for the earlier transition
1349
- await act(() => {
1350
- startTransition(() => {
1351
- root.render(
1352
- <Suspense fallback="Loading...">
1353
- <Cache key="C">
1354
- <AsyncText showVersion={true} text="C" />
1355
- </Cache>
1356
- </Suspense>,
1357
- );
1358
- });
1359
- });
1360
- assertLog(['Cache miss! [C]']);
1361
- expect(root).toMatchRenderedOutput('A [v1]');
1362
-
1363
- await act(() => {
1364
- resolveMostRecentTextCache('C');
1365
- });
1366
- assertLog(['C [v2]']);
1367
- expect(root).toMatchRenderedOutput('C [v2]');
1368
-
1369
- // Unmount children: the fresh cache used for the updates is freed, while the
1370
- // original cache (with A) is still retained at the root.
1371
- await act(() => {
1372
- root.render('Bye!');
1373
- });
1374
- assertLog(['Cache cleanup: B [v2]', 'Cache cleanup: C [v2]']);
1375
- expect(root).toMatchRenderedOutput('Bye!');
1376
- });
1377
-
1378
- // @gate enableCacheElement && enableCache
1379
- test('overlapping updates after an initial mount use the same fresh cache', async () => {
1380
- const root = ReactNoop.createRoot();
1381
- await act(() => {
1382
- root.render(
1383
- <Suspense fallback="Loading...">
1384
- <Cache key="A">
1385
- <AsyncText showVersion={true} text="A" />
1386
- </Cache>
1387
- </Suspense>,
1388
- );
1389
- });
1390
- assertLog(['Cache miss! [A]']);
1391
- expect(root).toMatchRenderedOutput('Loading...');
1392
-
1393
- await act(() => {
1394
- resolveMostRecentTextCache('A');
1395
- });
1396
- assertLog(['A [v1]']);
1397
- expect(root).toMatchRenderedOutput('A [v1]');
1398
-
1399
- // After a mount, subsequent updates use a fresh cache
1400
- await act(() => {
1401
- root.render(
1402
- <Suspense fallback="Loading...">
1403
- <Cache key="B">
1404
- <AsyncText showVersion={true} text="B" />
1405
- </Cache>
1406
- </Suspense>,
1407
- );
1408
- });
1409
- assertLog(['Cache miss! [B]']);
1410
- expect(root).toMatchRenderedOutput('Loading...');
1411
-
1412
- // A second update uses the same fresh cache: even though this is a new
1413
- // Cache boundary, the render uses the fresh cache from the pending update.
1414
- await act(() => {
1415
- root.render(
1416
- <Suspense fallback="Loading...">
1417
- <Cache key="C">
1418
- <AsyncText showVersion={true} text="C" />
1419
- </Cache>
1420
- </Suspense>,
1421
- );
1422
- });
1423
- assertLog(['Cache miss! [C]']);
1424
- expect(root).toMatchRenderedOutput('Loading...');
1425
-
1426
- await act(() => {
1427
- resolveMostRecentTextCache('C');
1428
- });
1429
- assertLog(['C [v2]']);
1430
- expect(root).toMatchRenderedOutput('C [v2]');
1431
-
1432
- // Unmount children: the fresh cache used for the updates is freed, while the
1433
- // original cache (with A) is still retained at the root.
1434
- await act(() => {
1435
- root.render('Bye!');
1436
- });
1437
- assertLog(['Cache cleanup: B [v2]', 'Cache cleanup: C [v2]']);
1438
- expect(root).toMatchRenderedOutput('Bye!');
1439
- });
1440
-
1441
- // @gate enableCacheElement && enableCache
1442
- test('cleans up cache only used in an aborted transition', async () => {
1443
- const root = ReactNoop.createRoot();
1444
- seedNextTextCache('A');
1445
- await act(() => {
1446
- root.render(
1447
- <Suspense fallback="Loading...">
1448
- <Cache key="A">
1449
- <AsyncText showVersion={true} text="A" />
1450
- </Cache>
1451
- </Suspense>,
1452
- );
1453
- });
1454
- assertLog(['A [v1]']);
1455
- expect(root).toMatchRenderedOutput('A [v1]');
1456
-
1457
- // Start a transition from A -> B..., which should create a fresh cache
1458
- // for the new cache boundary (bc of the different key)
1459
- await act(() => {
1460
- startTransition(() => {
1461
- root.render(
1462
- <Suspense fallback="Loading...">
1463
- <Cache key="B">
1464
- <AsyncText showVersion={true} text="B" />
1465
- </Cache>
1466
- </Suspense>,
1467
- );
1468
- });
1469
- });
1470
- assertLog(['Cache miss! [B]']);
1471
- expect(root).toMatchRenderedOutput('A [v1]');
1472
-
1473
- // ...but cancel by transitioning "back" to A (which we never really left)
1474
- await act(() => {
1475
- startTransition(() => {
1476
- root.render(
1477
- <Suspense fallback="Loading...">
1478
- <Cache key="A">
1479
- <AsyncText showVersion={true} text="A" />
1480
- </Cache>
1481
- </Suspense>,
1482
- );
1483
- });
1484
- });
1485
- assertLog(['A [v1]', 'Cache cleanup: B [v2]']);
1486
- expect(root).toMatchRenderedOutput('A [v1]');
1487
-
1488
- // Unmount children: ...
1489
- await act(() => {
1490
- root.render('Bye!');
1491
- });
1492
- assertLog([]);
1493
- expect(root).toMatchRenderedOutput('Bye!');
1494
- });
1495
-
1496
- // @gate enableCacheElement && enableCache
1497
- test.skip('if a root cache refresh never commits its fresh cache is released', async () => {
1498
- const root = ReactNoop.createRoot();
1499
- let refresh;
1500
- function Example({text}) {
1501
- refresh = useCacheRefresh();
1502
- return <AsyncText showVersion={true} text={text} />;
1503
- }
1504
- seedNextTextCache('A');
1505
- await act(() => {
1506
- root.render(
1507
- <Suspense fallback="Loading...">
1508
- <Example text="A" />
1509
- </Suspense>,
1510
- );
1511
- });
1512
- assertLog(['A [v1]']);
1513
- expect(root).toMatchRenderedOutput('A [v1]');
1514
-
1515
- await act(() => {
1516
- startTransition(() => {
1517
- refresh();
1518
- });
1519
- });
1520
- assertLog(['Cache miss! [A]']);
1521
- expect(root).toMatchRenderedOutput('A [v1]');
1522
-
1523
- await act(() => {
1524
- root.render('Bye!');
1525
- });
1526
- assertLog([
1527
- // TODO: the v1 cache should *not* be cleaned up, it is still retained by the root
1528
- // The following line is presently yielded but should not be:
1529
- // 'Cache cleanup: A [v1]',
23
1531
- // TODO: the v2 cache *should* be cleaned up, it was created for the abandoned refresh
1532
- // The following line is presently not yielded but should be:
1533
- 'Cache cleanup: A [v2]',
1534
- ]);
1535
- expect(root).toMatchRenderedOutput('Bye!');
1536
- });
24
+ ReactNoopFlightServer = require('react-noop-renderer/flight-server');
25
+ ReactNoopFlightClient = require('react-noop-renderer/flight-client');
26
1538
- // @gate enableCacheElement && enableCache
1539
- test.skip('if a cache boundary refresh never commits its fresh cache is released', async () => {
1540
- const root = ReactNoop.createRoot();
1541
- let refresh;
1542
- function Example({text}) {
1543
- refresh = useCacheRefresh();
1544
- return <AsyncText showVersion={true} text={text} />;
1545
- }
1546
- seedNextTextCache('A');
1547
- await act(() => {
1548
- root.render(
1549
- <Suspense fallback="Loading...">
1550
- <Cache>
1551
- <Example text="A" />
1552
- </Cache>
1553
- </Suspense>,
1554
- );
1555
- });
1556
- assertLog(['A [v1]']);
1557
- expect(root).toMatchRenderedOutput('A [v1]');
1558
-
1559
- await act(() => {
1560
- startTransition(() => {
1561
- refresh();
1562
- });
1563
- });
1564
- assertLog(['Cache miss! [A]']);
1565
- expect(root).toMatchRenderedOutput('A [v1]');
1566
-
1567
- // Unmount the boundary before the refresh can complete
1568
- await act(() => {
1569
- root.render('Bye!');
1570
- });
1571
- assertLog([
1572
- // TODO: the v2 cache *should* be cleaned up, it was created for the abandoned refresh
1573
- // The following line is presently not yielded but should be:
1574
- 'Cache cleanup: A [v2]',
1575
- ]);
1576
- expect(root).toMatchRenderedOutput('Bye!');
1577
- });
1578
-
1579
- // @gate enableActivity
1580
- // @gate enableCache
1581
- test('prerender a new cache boundary inside an Activity tree', async () => {
1582
- function App({prerenderMore}) {
1583
- return (
1584
- <Activity mode="hidden">
1585
- <div>
1586
- {prerenderMore ? (
1587
- <Cache>
1588
- <AsyncText text="More" />
1589
- </Cache>
1590
- ) : null}
1591
- </div>
1592
- </Activity>
1593
- );
1594
- }
1595
-
1596
- const root = ReactNoop.createRoot();
1597
- await act(() => {
1598
- root.render(<App prerenderMore={false} />);
1599
- });
1600
- assertLog([]);
1601
- expect(root).toMatchRenderedOutput(<div hidden={true} />);
1602
-
1603
- seedNextTextCache('More');
1604
- await act(() => {
1605
- root.render(<App prerenderMore={true} />);
1606
- });
1607
- assertLog(['More']);
1608
- expect(root).toMatchRenderedOutput(<div hidden={true}>More</div>);
27
+ cache = React.cache;
28
});
29
30
// @gate enableCache
31
it('cache objects and primitive arguments and a mix of them', async () => {
1613
- const root = ReactNoop.createRoot();
32
const types = cache((a, b) => ({a: typeof a, b: typeof b}));
33
function Print({a, b}) {
34
return types(a, b).a + ' ' + types(a, b).b + ' ';
@@ -1629,101 +47,128 @@ describe('ReactCache', () => {
47
function MoreArgs({a, b}) {
48
return (types(a) === types(a, b)).toString() + ' ';
49
}
1632
- await act(() => {
1633
- root.render(
1634
- <>
1635
- <Print a="e" b="f" />
1636
- <Same a="a" b="b" />
1637
- <FlippedOrder a="c" b="d" />
1638
- <FewerArgs a="e" b="f" />
1639
- <MoreArgs a="g" b="h" />
1640
- </>,
1641
- );
1642
- });
1643
- expect(root).toMatchRenderedOutput('string string true false false false ');
1644
- await act(() => {
1645
- root.render(
1646
- <>
1647
- <Print a="e" b={null} />
1648
- <Same a="a" b={null} />
1649
- <FlippedOrder a="c" b={null} />
1650
- <FewerArgs a="e" b={null} />
1651
- <MoreArgs a="g" b={null} />
1652
- </>,
1653
- );
1654
- });
1655
- expect(root).toMatchRenderedOutput('string object true false false false ');
50
+
51
+ expect(
52
+ (
53
+ await ReactNoopFlightClient.read(
54
+ ReactNoopFlightServer.render(
55
+ <>
56
+ <Print a="e" b="f" />
57
+ <Same a="a" b="b" />
58
+ <FlippedOrder a="c" b="d" />
59
+ <FewerArgs a="e" b="f" />
60
+ <MoreArgs a="g" b="h" />
61
+ </>,
62
+ ),
63
+ )
64
+ ).join(''),
65
+ ).toEqual('string string true false false false ');
66
+
67
+ expect(
68
+ (
69
+ await ReactNoopFlightClient.read(
70
+ ReactNoopFlightServer.render(
71
+ <>
72
+ <Print a="e" b={null} />
73
+ <Same a="a" b={null} />
74
+ <FlippedOrder a="c" b={null} />
75
+ <FewerArgs a="e" b={null} />
76
+ <MoreArgs a="g" b={null} />
77
+ </>,
78
+ ),
79
+ )
80
+ ).join(''),
81
+ ).toEqual('string object true false false false ');
82
+
83
const obj = {};
1657
- await act(() => {
1658
- root.render(
1659
- <>
1660
- <Print a="e" b={obj} />
1661
- <Same a="a" b={obj} />
1662
- <FlippedOrder a="c" b={obj} />
1663
- <FewerArgs a="e" b={obj} />
1664
- <MoreArgs a="g" b={obj} />
1665
- </>,
1666
- );
1667
- });
1668
- expect(root).toMatchRenderedOutput('string object true false false false ');
84
+ expect(
85
+ (
86
+ await ReactNoopFlightClient.read(
87
+ ReactNoopFlightServer.render(
88
+ <>
89
+ <Print a="e" b={obj} />
90
+ <Same a="a" b={obj} />
91
+ <FlippedOrder a="c" b={obj} />
92
+ <FewerArgs a="e" b={obj} />
93
+ <MoreArgs a="g" b={obj} />
94
+ </>,
95
+ ),
96
+ )
97
+ ).join(''),
98
+ ).toEqual('string object true false false false ');
99
+
100
const sameObj = {};
1670
- await act(() => {
1671
- root.render(
1672
- <>
1673
- <Print a={sameObj} b={sameObj} />
1674
- <Same a={sameObj} b={sameObj} />
1675
- <FlippedOrder a={sameObj} b={sameObj} />
1676
- <FewerArgs a={sameObj} b={sameObj} />
1677
- <MoreArgs a={sameObj} b={sameObj} />
1678
- </>,
1679
- );
1680
- });
1681
- expect(root).toMatchRenderedOutput('object object true true false false ');
101
+ expect(
102
+ (
103
+ await ReactNoopFlightClient.read(
104
+ ReactNoopFlightServer.render(
105
+ <>
106
+ <Print a={sameObj} b={sameObj} />
107
+ <Same a={sameObj} b={sameObj} />
108
+ <FlippedOrder a={sameObj} b={sameObj} />
109
+ <FewerArgs a={sameObj} b={sameObj} />
110
+ <MoreArgs a={sameObj} b={sameObj} />
111
+ </>,
112
+ ),
113
+ )
114
+ ).join(''),
115
+ ).toEqual('object object true true false false ');
116
+
117
const objA = {};
118
const objB = {};
1684
- await act(() => {
1685
- root.render(
1686
- <>
1687
- <Print a={objA} b={objB} />
1688
- <Same a={objA} b={objB} />
1689
- <FlippedOrder a={objA} b={objB} />
1690
- <FewerArgs a={objA} b={objB} />
1691
- <MoreArgs a={objA} b={objB} />
1692
- </>,
1693
- );
1694
- });
1695
- expect(root).toMatchRenderedOutput('object object true false false false ');
119
+ expect(
120
+ (
121
+ await ReactNoopFlightClient.read(
122
+ ReactNoopFlightServer.render(
123
+ <>
124
+ <Print a={objA} b={objB} />
125
+ <Same a={objA} b={objB} />
126
+ <FlippedOrder a={objA} b={objB} />
127
+ <FewerArgs a={objA} b={objB} />
128
+ <MoreArgs a={objA} b={objB} />
129
+ </>,
130
+ ),
131
+ )
132
+ ).join(''),
133
+ ).toEqual('object object true false false false ');
134
+
135
const sameSymbol = Symbol();
1697
- await act(() => {
1698
- root.render(
1699
- <>
1700
- <Print a={sameSymbol} b={sameSymbol} />
1701
- <Same a={sameSymbol} b={sameSymbol} />
1702
- <FlippedOrder a={sameSymbol} b={sameSymbol} />
1703
- <FewerArgs a={sameSymbol} b={sameSymbol} />
1704
- <MoreArgs a={sameSymbol} b={sameSymbol} />
1705
- </>,
1706
- );
1707
- });
1708
- expect(root).toMatchRenderedOutput('symbol symbol true true false false ');
136
+ expect(
137
+ (
138
+ await ReactNoopFlightClient.read(
139
+ ReactNoopFlightServer.render(
140
+ <>
141
+ <Print a={sameSymbol} b={sameSymbol} />
142
+ <Same a={sameSymbol} b={sameSymbol} />
143
+ <FlippedOrder a={sameSymbol} b={sameSymbol} />
144
+ <FewerArgs a={sameSymbol} b={sameSymbol} />
145
+ <MoreArgs a={sameSymbol} b={sameSymbol} />
146
+ </>,
147
+ ),
148
+ )
149
+ ).join(''),
150
+ ).toEqual('symbol symbol true true false false ');
151
+
152
const notANumber = +'nan';
1710
- await act(() => {
1711
- root.render(
1712
- <>
1713
- <Print a={1} b={notANumber} />
1714
- <Same a={1} b={notANumber} />
1715
- <FlippedOrder a={1} b={notANumber} />
1716
- <FewerArgs a={1} b={notANumber} />
1717
- <MoreArgs a={1} b={notANumber} />
1718
- </>,
1719
- );
1720
- });
1721
- expect(root).toMatchRenderedOutput('number number true false false false ');
153
+ expect(
154
+ (
155
+ await ReactNoopFlightClient.read(
156
+ ReactNoopFlightServer.render(
157
+ <>
158
+ <Print a={1} b={notANumber} />
159
+ <Same a={1} b={notANumber} />
160
+ <FlippedOrder a={1} b={notANumber} />
161
+ <FewerArgs a={1} b={notANumber} />
162
+ <MoreArgs a={1} b={notANumber} />
163
+ </>,
164
+ ),
165
+ )
166
+ ).join(''),
167
+ ).toEqual('number number true false false false ');
168
});
169
170
// @gate enableCache
171
it('cached functions that throw should cache the error', async () => {
1726
- const root = ReactNoop.createRoot();
172
const throws = cache(v => {
173
throw new Error(v);
174
});
@@ -1749,10 +194,30 @@ describe('ReactCache', () => {
194
195
return 'Blank';
196
}
1752
- await act(() => {
1753
- root.render(<Test />);
1754
- });
197
+
198
+ ReactNoopFlightServer.render(<Test />);
199
expect(x).toBe(y);
200
expect(z).not.toBe(x);
201
});
202
+
203
+ // @gate enableCache
204
+ it('introspection of returned wrapper function is same on client and server', async () => {
205
+ // When the variant flag is true, test the client version of `cache`.
206
+ if (gate(flags => flags.variant)) {
207
+ jest.resetModules();
208
+ jest.mock('react', () => jest.requireActual('react'));
209
+ const ClientReact = require('react');
210
+ cache = ClientReact.cache;
211
+ }
212
+
213
+ function foo(a, b, c) {
214
+ return a + b + c;
215
+ }
216
+ foo.displayName = 'Custom display name';
217
+
218
+ const cachedFoo = cache(foo);
219
+ expect(cachedFoo).not.toBe(foo);
220
+ expect(cachedFoo.length).toBe(0);
221
+ expect(cachedFoo.displayName).toBe(undefined);
222
+ });
223
});
packages/react-reconciler/src/__tests__/ReactCacheElement-test.js
new
+1597
@@ -0,0 +1,1597 @@
1
+let React;
2
+let ReactNoop;
3
+let Cache;
4
+let getCacheSignal;
5
+let getCacheForType;
6
+let Scheduler;
7
+let assertLog;
8
+let act;
9
+let Suspense;
10
+let Activity;
11
+let useCacheRefresh;
12
+let startTransition;
13
+let useState;
14
+
15
+let textCaches;
16
+let seededCache;
17
+
18
+describe('ReactCacheElement', () => {
19
+ beforeEach(() => {
20
+ jest.resetModules();
21
+
22
+ React = require('react');
23
+ ReactNoop = require('react-noop-renderer');
24
+ Cache = React.unstable_Cache;
25
+ Scheduler = require('scheduler');
26
+ act = require('internal-test-utils').act;
27
+ Suspense = React.Suspense;
28
+ Activity = React.unstable_Activity;
29
+ getCacheSignal = React.unstable_getCacheSignal;
30
+ getCacheForType = React.unstable_getCacheForType;
31
+ useCacheRefresh = React.unstable_useCacheRefresh;
32
+ startTransition = React.startTransition;
33
+ useState = React.useState;
34
+
35
+ const InternalTestUtils = require('internal-test-utils');
36
+ assertLog = InternalTestUtils.assertLog;
37
+
38
+ textCaches = [];
39
+ seededCache = null;
40
+ });
41
+
42
+ function createTextCache() {
43
+ if (seededCache !== null) {
44
+ // Trick to seed a cache before it exists.
45
+ // TODO: Need a built-in API to seed data before the initial render (i.e.
46
+ // not a refresh because nothing has mounted yet).
47
+ const textCache = seededCache;
48
+ seededCache = null;
49
+ return textCache;
50
+ }
51
+
52
+ const data = new Map();
53
+ const version = textCaches.length + 1;
54
+ const textCache = {
55
+ version,
56
+ data,
57
+ resolve(text) {
58
+ const record = data.get(text);
59
+ if (record === undefined) {
60
+ const newRecord = {
61
+ status: 'resolved',
62
+ value: text,
63
+ cleanupScheduled: false,
64
+ };
65
+ data.set(text, newRecord);
66
+ } else if (record.status === 'pending') {
67
+ record.value.resolve();
68
+ }
69
+ },
70
+ reject(text, error) {
71
+ const record = data.get(text);
72
+ if (record === undefined) {
73
+ const newRecord = {
74
+ status: 'rejected',
75
+ value: error,
76
+ cleanupScheduled: false,
77
+ };
78
+ data.set(text, newRecord);
79
+ } else if (record.status === 'pending') {
80
+ record.value.reject();
81
+ }
82
+ },
83
+ };
84
+ textCaches.push(textCache);
85
+ return textCache;
86
+ }
87
+
88
+ function readText(text) {
89
+ const signal = getCacheSignal ? getCacheSignal() : null;
90
+ const textCache = getCacheForType(createTextCache);
91
+ const record = textCache.data.get(text);
92
+ if (record !== undefined) {
93
+ if (!record.cleanupScheduled) {
94
+ // This record was seeded prior to the abort signal being available:
95
+ // schedule a cleanup function for it.
96
+ // TODO: Add ability to cleanup entries seeded w useCacheRefresh()
97
+ record.cleanupScheduled = true;
98
+ if (getCacheSignal) {
99
+ signal.addEventListener('abort', () => {
100
+ Scheduler.log(`Cache cleanup: ${text} [v${textCache.version}]`);
101
+ });
102
+ }
103
+ }
104
+ switch (record.status) {
105
+ case 'pending':
106
+ throw record.value;
107
+ case 'rejected':
108
+ throw record.value;
109
+ case 'resolved':
110
+ return textCache.version;
111
+ }
112
+ } else {
113
+ Scheduler.log(`Cache miss! [${text}]`);
114
+
115
+ let resolve;
116
+ let reject;
117
+ const thenable = new Promise((res, rej) => {
118
+ resolve = res;
119
+ reject = rej;
120
+ }).then(
121
+ value => {
122
+ if (newRecord.status === 'pending') {
123
+ newRecord.status = 'resolved';
124
+ newRecord.value = value;
125
+ }
126
+ },
127
+ error => {
128
+ if (newRecord.status === 'pending') {
129
+ newRecord.status = 'rejected';
130
+ newRecord.value = error;
131
+ }
132
+ },
133
+ );
134
+ thenable.resolve = resolve;
135
+ thenable.reject = reject;
136
+
137
+ const newRecord = {
138
+ status: 'pending',
139
+ value: thenable,
140
+ cleanupScheduled: true,
141
+ };
142
+ textCache.data.set(text, newRecord);
143
+
144
+ if (getCacheSignal) {
145
+ signal.addEventListener('abort', () => {
146
+ Scheduler.log(`Cache cleanup: ${text} [v${textCache.version}]`);
147
+ });
148
+ }
149
+ throw thenable;
150
+ }
151
+ }
152
+
153
+ function Text({text}) {
154
+ Scheduler.log(text);
155
+ return text;
156
+ }
157
+
158
+ function AsyncText({text, showVersion}) {
159
+ const version = readText(text);
160
+ const fullText = showVersion ? `${text} [v${version}]` : text;
161
+ Scheduler.log(fullText);
162
+ return fullText;
163
+ }
164
+
165
+ function seedNextTextCache(text) {
166
+ if (seededCache === null) {
167
+ seededCache = createTextCache();
168
+ }
169
+ seededCache.resolve(text);
170
+ }
171
+
172
+ function resolveMostRecentTextCache(text) {
173
+ if (textCaches.length === 0) {
174
+ throw Error('Cache does not exist.');
175
+ } else {
176
+ // Resolve the most recently created cache. An older cache can by
177
+ // resolved with `textCaches[index].resolve(text)`.
178
+ textCaches[textCaches.length - 1].resolve(text);
179
+ }
180
+ }
181
+
182
+ // @gate enableCacheElement
183
+ test('render Cache component', async () => {
184
+ const root = ReactNoop.createRoot();
185
+ await act(() => {
186
+ root.render(<Cache>Hi</Cache>);
187
+ });
188
+ expect(root).toMatchRenderedOutput('Hi');
189
+ });
190
+
191
+ // @gate enableCacheElement
192
+ test('mount new data', async () => {
193
+ const root = ReactNoop.createRoot();
194
+ await act(() => {
195
+ root.render(
196
+ <Cache>
197
+ <Suspense fallback={<Text text="Loading..." />}>
198
+ <AsyncText text="A" />
199
+ </Suspense>
200
+ </Cache>,
201
+ );
202
+ });
203
+ assertLog(['Cache miss! [A]', 'Loading...']);
204
+ expect(root).toMatchRenderedOutput('Loading...');
205
+
206
+ await act(() => {
207
+ resolveMostRecentTextCache('A');
208
+ });
209
+ assertLog(['A']);
210
+ expect(root).toMatchRenderedOutput('A');
211
+
212
+ await act(() => {
213
+ root.render('Bye');
214
+ });
215
+ // no cleanup: cache is still retained at the root
216
+ assertLog([]);
217
+ expect(root).toMatchRenderedOutput('Bye');
218
+ });
219
+
220
+ // @gate enableCacheElement
221
+ test('root acts as implicit cache boundary', async () => {
222
+ const root = ReactNoop.createRoot();
223
+ await act(() => {
224
+ root.render(
225
+ <Suspense fallback={<Text text="Loading..." />}>
226
+ <AsyncText text="A" />
227
+ </Suspense>,
228
+ );
229
+ });
230
+ assertLog(['Cache miss! [A]', 'Loading...']);
231
+ expect(root).toMatchRenderedOutput('Loading...');
232
+
233
+ await act(() => {
234
+ resolveMostRecentTextCache('A');
235
+ });
236
+ assertLog(['A']);
237
+ expect(root).toMatchRenderedOutput('A');
238
+
239
+ await act(() => {
240
+ root.render('Bye');
241
+ });
242
+ // no cleanup: cache is still retained at the root
243
+ assertLog([]);
244
+ expect(root).toMatchRenderedOutput('Bye');
245
+ });
246
+
247
+ // @gate enableCacheElement
248
+ test('multiple new Cache boundaries in the same mount share the same, fresh root cache', async () => {
249
+ function App() {
250
+ return (
251
+ <>
252
+ <Cache>
253
+ <Suspense fallback={<Text text="Loading..." />}>
254
+ <AsyncText text="A" />
255
+ </Suspense>
256
+ </Cache>
257
+ <Cache>
258
+ <Suspense fallback={<Text text="Loading..." />}>
259
+ <AsyncText text="A" />
260
+ </Suspense>
261
+ </Cache>
262
+ </>
263
+ );
264
+ }
265
+
266
+ const root = ReactNoop.createRoot();
267
+ await act(() => {
268
+ root.render(<App showMore={false} />);
269
+ });
270
+
271
+ // Even though there are two new <Cache /> trees, they should share the same
272
+ // data cache. So there should be only a single cache miss for A.
273
+ assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
274
+ expect(root).toMatchRenderedOutput('Loading...Loading...');
275
+
276
+ await act(() => {
277
+ resolveMostRecentTextCache('A');
278
+ });
279
+ assertLog(['A', 'A']);
280
+ expect(root).toMatchRenderedOutput('AA');
281
+
282
+ await act(() => {
283
+ root.render('Bye');
284
+ });
285
+ // no cleanup: cache is still retained at the root
286
+ assertLog([]);
287
+ expect(root).toMatchRenderedOutput('Bye');
288
+ });
289
+
290
+ // @gate enableCacheElement
291
+ test('multiple new Cache boundaries in the same update share the same, fresh cache', async () => {
292
+ function App({showMore}) {
293
+ return showMore ? (
294
+ <>
295
+ <Cache>
296
+ <Suspense fallback={<Text text="Loading..." />}>
297
+ <AsyncText text="A" />
298
+ </Suspense>
299
+ </Cache>
300
+ <Cache>
301
+ <Suspense fallback={<Text text="Loading..." />}>
302
+ <AsyncText text="A" />
303
+ </Suspense>
304
+ </Cache>
305
+ </>
306
+ ) : (
307
+ '(empty)'
308
+ );
309
+ }
310
+
311
+ const root = ReactNoop.createRoot();
312
+ await act(() => {
313
+ root.render(<App showMore={false} />);
314
+ });
315
+ assertLog([]);
316
+ expect(root).toMatchRenderedOutput('(empty)');
317
+
318
+ await act(() => {
319
+ root.render(<App showMore={true} />);
320
+ });
321
+ // Even though there are two new <Cache /> trees, they should share the same
322
+ // data cache. So there should be only a single cache miss for A.
323
+ assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
324
+ expect(root).toMatchRenderedOutput('Loading...Loading...');
325
+
326
+ await act(() => {
327
+ resolveMostRecentTextCache('A');
328
+ });
329
+ assertLog(['A', 'A']);
330
+ expect(root).toMatchRenderedOutput('AA');
331
+
332
+ await act(() => {
333
+ root.render('Bye');
334
+ });
335
+ // cleanup occurs for the cache shared by the inner cache boundaries (which
336
+ // are not shared w the root because they were added in an update)
337
+ // note that no cache is created for the root since the cache is never accessed
338
+ assertLog(['Cache cleanup: A [v1]']);
339
+ expect(root).toMatchRenderedOutput('Bye');
340
+ });
341
+
342
+ // @gate enableCacheElement
343
+ test(
344
+ 'nested cache boundaries share the same cache as the root during ' +
345
+ 'the initial render',
346
+ async () => {
347
+ function App() {
348
+ return (
349
+ <Suspense fallback={<Text text="Loading..." />}>
350
+ <AsyncText text="A" />
351
+ <Cache>
352
+ <AsyncText text="A" />
353
+ </Cache>
354
+ </Suspense>
355
+ );
356
+ }
357
+
358
+ const root = ReactNoop.createRoot();
359
+ await act(() => {
360
+ root.render(<App />);
361
+ });
362
+ // Even though there is a nested <Cache /> boundary, it should share the same
363
+ // data cache as the root. So there should be only a single cache miss for A.
364
+ assertLog(['Cache miss! [A]', 'Loading...']);
365
+ expect(root).toMatchRenderedOutput('Loading...');
366
+
367
+ await act(() => {
368
+ resolveMostRecentTextCache('A');
369
+ });
370
+ assertLog(['A', 'A']);
371
+ expect(root).toMatchRenderedOutput('AA');
372
+
373
+ await act(() => {
374
+ root.render('Bye');
375
+ });
376
+ // no cleanup: cache is still retained at the root
377
+ assertLog([]);
378
+ expect(root).toMatchRenderedOutput('Bye');
379
+ },
380
+ );
381
+
382
+ // @gate enableCacheElement
383
+ test('new content inside an existing Cache boundary should re-use already cached data', async () => {
384
+ function App({showMore}) {
385
+ return (
386
+ <Cache>
387
+ <Suspense fallback={<Text text="Loading..." />}>
388
+ <AsyncText showVersion={true} text="A" />
389
+ </Suspense>
390
+ {showMore ? (
391
+ <Suspense fallback={<Text text="Loading..." />}>
392
+ <AsyncText showVersion={true} text="A" />
393
+ </Suspense>
394
+ ) : null}
395
+ </Cache>
396
+ );
397
+ }
398
+
399
+ const root = ReactNoop.createRoot();
400
+ await act(() => {
401
+ seedNextTextCache('A');
402
+ root.render(<App showMore={false} />);
403
+ });
404
+ assertLog(['A [v1]']);
405
+ expect(root).toMatchRenderedOutput('A [v1]');
406
+
407
+ // Add a new cache boundary
408
+ await act(() => {
409
+ root.render(<App showMore={true} />);
410
+ });
411
+ assertLog([
412
+ 'A [v1]',
413
+ // New tree should use already cached data
414
+ 'A [v1]',
415
+ ]);
416
+ expect(root).toMatchRenderedOutput('A [v1]A [v1]');
417
+
418
+ await act(() => {
419
+ root.render('Bye');
420
+ });
421
+ // no cleanup: cache is still retained at the root
422
+ assertLog([]);
423
+ expect(root).toMatchRenderedOutput('Bye');
424
+ });
425
+
426
+ // @gate enableCacheElement
427
+ test('a new Cache boundary uses fresh cache', async () => {
428
+ // The only difference from the previous test is that the "Show More"
429
+ // content is wrapped in a nested <Cache /> boundary
430
+ function App({showMore}) {
431
+ return (
432
+ <Cache>
433
+ <Suspense fallback={<Text text="Loading..." />}>
434
+ <AsyncText showVersion={true} text="A" />
435
+ </Suspense>
436
+ {showMore ? (
437
+ <Cache>
438
+ <Suspense fallback={<Text text="Loading..." />}>
439
+ <AsyncText showVersion={true} text="A" />
440
+ </Suspense>
441
+ </Cache>
442
+ ) : null}
443
+ </Cache>
444
+ );
445
+ }
446
+
447
+ const root = ReactNoop.createRoot();
448
+ await act(() => {
449
+ seedNextTextCache('A');
450
+ root.render(<App showMore={false} />);
451
+ });
452
+ assertLog(['A [v1]']);
453
+ expect(root).toMatchRenderedOutput('A [v1]');
454
+
455
+ // Add a new cache boundary
456
+ await act(() => {
457
+ root.render(<App showMore={true} />);
458
+ });
459
+ assertLog([
460
+ 'A [v1]',
461
+ // New tree should load fresh data.
462
+ 'Cache miss! [A]',
463
+ 'Loading...',
464
+ ]);
465
+ expect(root).toMatchRenderedOutput('A [v1]Loading...');
466
+ await act(() => {
467
+ resolveMostRecentTextCache('A');
468
+ });
469
+ assertLog(['A [v2]']);
470
+ expect(root).toMatchRenderedOutput('A [v1]A [v2]');
471
+
472
+ // Replace all the children: this should retain the root Cache instance,
473
+ // but cleanup the separate cache instance created for the fresh cache
474
+ // boundary
475
+ await act(() => {
476
+ root.render('Bye!');
477
+ });
478
+ // Cleanup occurs for the *second* cache instance: the first is still
479
+ // referenced by the root
480
+ assertLog(['Cache cleanup: A [v2]']);
481
+ expect(root).toMatchRenderedOutput('Bye!');
482
+ });
483
+
484
+ // @gate enableCacheElement
485
+ test('inner/outer cache boundaries uses the same cache instance on initial render', async () => {
486
+ const root = ReactNoop.createRoot();
487
+
488
+ function App() {
489
+ return (
490
+ <Cache>
491
+ <Suspense fallback={<Text text="Loading shell..." />}>
492
+ {/* The shell reads A */}
493
+ <Shell>
494
+ {/* The inner content reads both A and B */}
495
+ <Suspense fallback={<Text text="Loading content..." />}>
496
+ <Cache>
497
+ <Content />
498
+ </Cache>
499
+ </Suspense>
500
+ </Shell>
501
+ </Suspense>
502
+ </Cache>
503
+ );
504
+ }
505
+
506
+ function Shell({children}) {
507
+ readText('A');
508
+ return (
509
+ <>
510
+ <div>
511
+ <Text text="Shell" />
512
+ </div>
513
+ <div>{children}</div>
514
+ </>
515
+ );
516
+ }
517
+
518
+ function Content() {
519
+ readText('A');
520
+ readText('B');
521
+ return <Text text="Content" />;
522
+ }
523
+
524
+ await act(() => {
525
+ root.render(<App />);
526
+ });
527
+ assertLog(['Cache miss! [A]', 'Loading shell...']);
528
+ expect(root).toMatchRenderedOutput('Loading shell...');
529
+
530
+ await act(() => {
531
+ resolveMostRecentTextCache('A');
532
+ });
533
+ assertLog([
534
+ 'Shell',
535
+ // There's a cache miss for B, because it hasn't been read yet. But not
536
+ // A, because it was cached when we rendered the shell.
537
+ 'Cache miss! [B]',
538
+ 'Loading content...',
539
+ ]);
540
+ expect(root).toMatchRenderedOutput(
541
+ <>
542
+ <div>Shell</div>
543
+ <div>Loading content...</div>
544
+ </>,
545
+ );
546
+
547
+ await act(() => {
548
+ resolveMostRecentTextCache('B');
549
+ });
550
+ assertLog(['Content']);
551
+ expect(root).toMatchRenderedOutput(
552
+ <>
553
+ <div>Shell</div>
554
+ <div>Content</div>
555
+ </>,
556
+ );
557
+
558
+ await act(() => {
559
+ root.render('Bye');
560
+ });
561
+ // no cleanup: cache is still retained at the root
562
+ assertLog([]);
563
+ expect(root).toMatchRenderedOutput('Bye');
564
+ });
565
+
566
+ // @gate enableCacheElement
567
+ test('inner/ outer cache boundaries added in the same update use the same cache instance', async () => {
568
+ const root = ReactNoop.createRoot();
569
+
570
+ function App({showMore}) {
571
+ return showMore ? (
572
+ <Cache>
573
+ <Suspense fallback={<Text text="Loading shell..." />}>
574
+ {/* The shell reads A */}
575
+ <Shell>
576
+ {/* The inner content reads both A and B */}
577
+ <Suspense fallback={<Text text="Loading content..." />}>
578
+ <Cache>
579
+ <Content />
580
+ </Cache>
581
+ </Suspense>
582
+ </Shell>
583
+ </Suspense>
584
+ </Cache>
585
+ ) : (
586
+ '(empty)'
587
+ );
588
+ }
589
+
590
+ function Shell({children}) {
591
+ readText('A');
592
+ return (
593
+ <>
594
+ <div>
595
+ <Text text="Shell" />
596
+ </div>
597
+ <div>{children}</div>
598
+ </>
599
+ );
600
+ }
601
+
602
+ function Content() {
603
+ readText('A');
604
+ readText('B');
605
+ return <Text text="Content" />;
606
+ }
607
+
608
+ await act(() => {
609
+ root.render(<App showMore={false} />);
610
+ });
611
+ assertLog([]);
612
+ expect(root).toMatchRenderedOutput('(empty)');
613
+
614
+ await act(() => {
615
+ root.render(<App showMore={true} />);
616
+ });
617
+ assertLog(['Cache miss! [A]', 'Loading shell...']);
618
+ expect(root).toMatchRenderedOutput('Loading shell...');
619
+
620
+ await act(() => {
621
+ resolveMostRecentTextCache('A');
622
+ });
623
+ assertLog([
624
+ 'Shell',
625
+ // There's a cache miss for B, because it hasn't been read yet. But not
626
+ // A, because it was cached when we rendered the shell.
627
+ 'Cache miss! [B]',
628
+ 'Loading content...',
629
+ ]);
630
+ expect(root).toMatchRenderedOutput(
631
+ <>
632
+ <div>Shell</div>
633
+ <div>Loading content...</div>
634
+ </>,
635
+ );
636
+
637
+ await act(() => {
638
+ resolveMostRecentTextCache('B');
639
+ });
640
+ assertLog(['Content']);
641
+ expect(root).toMatchRenderedOutput(
642
+ <>
643
+ <div>Shell</div>
644
+ <div>Content</div>
645
+ </>,
646
+ );
647
+
648
+ await act(() => {
649
+ root.render('Bye');
650
+ });
651
+ assertLog(['Cache cleanup: A [v1]', 'Cache cleanup: B [v1]']);
652
+ expect(root).toMatchRenderedOutput('Bye');
653
+ });
654
+
655
+ // @gate enableCacheElement
656
+ test('refresh a cache boundary', async () => {
657
+ let refresh;
658
+ function App() {
659
+ refresh = useCacheRefresh();
660
+ return <AsyncText showVersion={true} text="A" />;
661
+ }
662
+
663
+ // Mount initial data
664
+ const root = ReactNoop.createRoot();
665
+ await act(() => {
666
+ root.render(
667
+ <Suspense fallback={<Text text="Loading..." />}>
668
+ <App />
669
+ </Suspense>,
670
+ );
671
+ });
672
+ assertLog(['Cache miss! [A]', 'Loading...']);
673
+ expect(root).toMatchRenderedOutput('Loading...');
674
+
675
+ await act(() => {
676
+ resolveMostRecentTextCache('A');
677
+ });
678
+ assertLog(['A [v1]']);
679
+ expect(root).toMatchRenderedOutput('A [v1]');
680
+
681
+ // Refresh for new data.
682
+ await act(() => {
683
+ startTransition(() => refresh());
684
+ });
685
+ assertLog(['Cache miss! [A]', 'Loading...']);
686
+ expect(root).toMatchRenderedOutput('A [v1]');
687
+
688
+ await act(() => {
689
+ resolveMostRecentTextCache('A');
690
+ });
691
+ // Note that the version has updated
692
+ if (getCacheSignal) {
693
+ assertLog(['A [v2]', 'Cache cleanup: A [v1]']);
694
+ } else {
695
+ assertLog(['A [v2]']);
696
+ }
697
+ expect(root).toMatchRenderedOutput('A [v2]');
698
+
699
+ await act(() => {
700
+ root.render('Bye');
701
+ });
702
+ expect(root).toMatchRenderedOutput('Bye');
703
+ });
704
+
705
+ // @gate enableCacheElement
706
+ test('refresh the root cache', async () => {
707
+ let refresh;
708
+ function App() {
709
+ refresh = useCacheRefresh();
710
+ return <AsyncText showVersion={true} text="A" />;
711
+ }
712
+
713
+ // Mount initial data
714
+ const root = ReactNoop.createRoot();
715
+ await act(() => {
716
+ root.render(
717
+ <Suspense fallback={<Text text="Loading..." />}>
718
+ <App />
719
+ </Suspense>,
720
+ );
721
+ });
722
+ assertLog(['Cache miss! [A]', 'Loading...']);
723
+ expect(root).toMatchRenderedOutput('Loading...');
724
+
725
+ await act(() => {
726
+ resolveMostRecentTextCache('A');
727
+ });
728
+ assertLog(['A [v1]']);
729
+ expect(root).toMatchRenderedOutput('A [v1]');
730
+
731
+ // Refresh for new data.
732
+ await act(() => {
733
+ startTransition(() => refresh());
734
+ });
735
+ assertLog(['Cache miss! [A]', 'Loading...']);
736
+ expect(root).toMatchRenderedOutput('A [v1]');
737
+
738
+ await act(() => {
739
+ resolveMostRecentTextCache('A');
740
+ });
741
+ // Note that the version has updated, and the previous cache is cleared
742
+ assertLog(['A [v2]', 'Cache cleanup: A [v1]']);
743
+ expect(root).toMatchRenderedOutput('A [v2]');
744
+
745
+ await act(() => {
746
+ root.render('Bye');
747
+ });
748
+ // the original root cache already cleaned up when the refresh completed
749
+ assertLog([]);
750
+ expect(root).toMatchRenderedOutput('Bye');
751
+ });
752
+
753
+ // @gate enableCacheElement
754
+ test('refresh the root cache without a transition', async () => {
755
+ let refresh;
756
+ function App() {
757
+ refresh = useCacheRefresh();
758
+ return <AsyncText showVersion={true} text="A" />;
759
+ }
760
+
761
+ // Mount initial data
762
+ const root = ReactNoop.createRoot();
763
+ await act(() => {
764
+ root.render(
765
+ <Suspense fallback={<Text text="Loading..." />}>
766
+ <App />
767
+ </Suspense>,
768
+ );
769
+ });
770
+ assertLog(['Cache miss! [A]', 'Loading...']);
771
+ expect(root).toMatchRenderedOutput('Loading...');
772
+
773
+ await act(() => {
774
+ resolveMostRecentTextCache('A');
775
+ });
776
+ assertLog(['A [v1]']);
777
+ expect(root).toMatchRenderedOutput('A [v1]');
778
+
779
+ // Refresh for new data.
780
+ await act(() => {
781
+ refresh();
782
+ });
783
+ assertLog([
784
+ 'Cache miss! [A]',
785
+ 'Loading...',
786
+ // The v1 cache can be cleaned up since everything that references it has
787
+ // been replaced by a fallback. When the boundary switches back to visible
788
+ // it will use the v2 cache.
789
+ 'Cache cleanup: A [v1]',
790
+ ]);
791
+ expect(root).toMatchRenderedOutput('Loading...');
792
+
793
+ await act(() => {
794
+ resolveMostRecentTextCache('A');
795
+ });
796
+ // Note that the version has updated, and the previous cache is cleared
797
+ assertLog(['A [v2]']);
798
+ expect(root).toMatchRenderedOutput('A [v2]');
799
+
800
+ await act(() => {
801
+ root.render('Bye');
802
+ });
803
+ // the original root cache already cleaned up when the refresh completed
804
+ assertLog([]);
805
+ expect(root).toMatchRenderedOutput('Bye');
806
+ });
807
+
808
+ // @gate enableCacheElement
809
+ test('refresh a cache with seed data', async () => {
810
+ let refresh;
811
+ function App() {
812
+ refresh = useCacheRefresh();
813
+ return <AsyncText showVersion={true} text="A" />;
814
+ }
815
+
816
+ // Mount initial data
817
+ const root = ReactNoop.createRoot();
818
+ await act(() => {
819
+ root.render(
820
+ <Cache>
821
+ <Suspense fallback={<Text text="Loading..." />}>
822
+ <App />
823
+ </Suspense>
824
+ </Cache>,
825
+ );
826
+ });
827
+ assertLog(['Cache miss! [A]', 'Loading...']);
828
+ expect(root).toMatchRenderedOutput('Loading...');
829
+
830
+ await act(() => {
831
+ resolveMostRecentTextCache('A');
832
+ });
833
+ assertLog(['A [v1]']);
834
+ expect(root).toMatchRenderedOutput('A [v1]');
835
+
836
+ // Refresh for new data.
837
+ await act(() => {
838
+ // Refresh the cache with seeded data, like you would receive from a
839
+ // server mutation.
840
+ // TODO: Seeding multiple typed textCaches. Should work by calling `refresh`
841
+ // multiple times with different key/value pairs
842
+ startTransition(() => {
843
+ const textCache = createTextCache();
844
+ textCache.resolve('A');
845
+ startTransition(() => refresh(createTextCache, textCache));
846
+ });
847
+ });
848
+ // The root should re-render without a cache miss.
849
+ // The cache is not cleared up yet, since it's still reference by the root
850
+ assertLog(['A [v2]']);
851
+ expect(root).toMatchRenderedOutput('A [v2]');
852
+
853
+ await act(() => {
854
+ root.render('Bye');
855
+ });
856
+ // the refreshed cache boundary is unmounted and cleans up
857
+ assertLog(['Cache cleanup: A [v2]']);
858
+ expect(root).toMatchRenderedOutput('Bye');
859
+ });
860
+
861
+ // @gate enableCacheElement
862
+ test('refreshing a parent cache also refreshes its children', async () => {
863
+ let refreshShell;
864
+ function RefreshShell() {
865
+ refreshShell = useCacheRefresh();
866
+ return null;
867
+ }
868
+
869
+ function App({showMore}) {
870
+ return (
871
+ <Cache>
872
+ <RefreshShell />
873
+ <Suspense fallback={<Text text="Loading..." />}>
874
+ <AsyncText showVersion={true} text="A" />
875
+ </Suspense>
876
+ {showMore ? (
877
+ <Cache>
878
+ <Suspense fallback={<Text text="Loading..." />}>
879
+ <AsyncText showVersion={true} text="A" />
880
+ </Suspense>
881
+ </Cache>
882
+ ) : null}
883
+ </Cache>
884
+ );
885
+ }
886
+
887
+ const root = ReactNoop.createRoot();
888
+ await act(() => {
889
+ seedNextTextCache('A');
890
+ root.render(<App showMore={false} />);
891
+ });
892
+ assertLog(['A [v1]']);
893
+ expect(root).toMatchRenderedOutput('A [v1]');
894
+
895
+ // Add a new cache boundary
896
+ await act(() => {
897
+ seedNextTextCache('A');
898
+ root.render(<App showMore={true} />);
899
+ });
900
+ assertLog([
901
+ 'A [v1]',
902
+ // New tree should load fresh data.
903
+ 'A [v2]',
904
+ ]);
905
+ expect(root).toMatchRenderedOutput('A [v1]A [v2]');
906
+
907
+ // Now refresh the shell. This should also cause the "Show More" contents to
908
+ // refresh, since its cache is nested inside the outer one.
909
+ await act(() => {
910
+ startTransition(() => refreshShell());
911
+ });
912
+ assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
913
+ expect(root).toMatchRenderedOutput('A [v1]A [v2]');
914
+
915
+ await act(() => {
916
+ resolveMostRecentTextCache('A');
917
+ });
918
+ assertLog([
919
+ 'A [v3]',
920
+ 'A [v3]',
921
+ // once the refresh completes the inner showMore boundary frees its previous
922
+ // cache instance, since it is now using the refreshed parent instance.
923
+ 'Cache cleanup: A [v2]',
924
+ ]);
925
+ expect(root).toMatchRenderedOutput('A [v3]A [v3]');
926
+
927
+ await act(() => {
928
+ root.render('Bye!');
929
+ });
930
+ // Unmounting children releases the refreshed cache instance only; the root
931
+ // still retains the original cache instance used for the first render
932
+ assertLog(['Cache cleanup: A [v3]']);
933
+ expect(root).toMatchRenderedOutput('Bye!');
934
+ });
935
+
936
+ // @gate enableCacheElement
937
+ test(
938
+ 'refreshing a cache boundary does not refresh the other boundaries ' +
939
+ 'that mounted at the same time (i.e. the ones that share the same cache)',
940
+ async () => {
941
+ let refreshFirstBoundary;
942
+ function RefreshFirstBoundary() {
943
+ refreshFirstBoundary = useCacheRefresh();
944
+ return null;
945
+ }
946
+
947
+ function App({showMore}) {
948
+ return showMore ? (
949
+ <>
950
+ <Cache>
951
+ <Suspense fallback={<Text text="Loading..." />}>
952
+ <RefreshFirstBoundary />
953
+ <AsyncText showVersion={true} text="A" />
954
+ </Suspense>
955
+ </Cache>
956
+ <Cache>
957
+ <Suspense fallback={<Text text="Loading..." />}>
958
+ <AsyncText showVersion={true} text="A" />
959
+ </Suspense>
960
+ </Cache>
961
+ </>
962
+ ) : null;
963
+ }
964
+
965
+ // First mount the initial shell without the nested boundaries. This is
966
+ // necessary for this test because we want the two inner boundaries to be
967
+ // treated like sibling providers that happen to share an underlying
968
+ // cache, as opposed to consumers of the root-level cache.
969
+ const root = ReactNoop.createRoot();
970
+ await act(() => {
971
+ root.render(<App showMore={false} />);
972
+ });
973
+
974
+ // Now reveal the boundaries. In a real app this would be a navigation.
975
+ await act(() => {
976
+ root.render(<App showMore={true} />);
977
+ });
978
+
979
+ // Even though there are two new <Cache /> trees, they should share the same
980
+ // data cache. So there should be only a single cache miss for A.
981
+ assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
982
+ expect(root).toMatchRenderedOutput('Loading...Loading...');
983
+
984
+ await act(() => {
985
+ resolveMostRecentTextCache('A');
986
+ });
987
+ assertLog(['A [v1]', 'A [v1]']);
988
+ expect(root).toMatchRenderedOutput('A [v1]A [v1]');
989
+
990
+ // Refresh the first boundary. It should not refresh the second boundary,
991
+ // even though they previously shared the same underlying cache.
992
+ await act(async () => {
993
+ await refreshFirstBoundary();
994
+ });
995
+ assertLog(['Cache miss! [A]', 'Loading...']);
996
+
997
+ await act(() => {
998
+ resolveMostRecentTextCache('A');
999
+ });
1000
+ assertLog(['A [v2]']);
1001
+ expect(root).toMatchRenderedOutput('A [v2]A [v1]');
1002
+
1003
+ // Unmount children: this should clear *both* cache instances:
1004
+ // the root doesn't have a cache instance (since it wasn't accessed
1005
+ // during the initial render, and all subsequent cache accesses were within
1006
+ // a fresh boundary). Therefore this causes cleanup for both the fresh cache
1007
+ // instance in the refreshed first boundary and cleanup for the non-refreshed
1008
+ // sibling boundary.
1009
+ await act(() => {
1010
+ root.render('Bye!');
1011
+ });
1012
+ assertLog(['Cache cleanup: A [v2]', 'Cache cleanup: A [v1]']);
1013
+ expect(root).toMatchRenderedOutput('Bye!');
1014
+ },
1015
+ );
1016
+
1017
+ // @gate enableCacheElement
1018
+ test(
1019
+ 'mount a new Cache boundary in a sibling while simultaneously ' +
1020
+ 'resolving a Suspense boundary',
1021
+ async () => {
1022
+ function App({showMore}) {
1023
+ return (
1024
+ <>
1025
+ {showMore ? (
1026
+ <Suspense fallback={<Text text="Loading..." />}>
1027
+ <Cache>
1028
+ <AsyncText showVersion={true} text="A" />
1029
+ </Cache>
1030
+ </Suspense>
1031
+ ) : null}
1032
+ <Suspense fallback={<Text text="Loading..." />}>
1033
+ <Cache>
1034
+ {' '}
1035
+ <AsyncText showVersion={true} text="A" />{' '}
1036
+ <AsyncText showVersion={true} text="B" />
1037
+ </Cache>
1038
+ </Suspense>
1039
+ </>
1040
+ );
1041
+ }
1042
+
1043
+ const root = ReactNoop.createRoot();
1044
+ await act(() => {
1045
+ root.render(<App showMore={false} />);
1046
+ });
1047
+ assertLog(['Cache miss! [A]', 'Loading...']);
1048
+ expect(root).toMatchRenderedOutput('Loading...');
1049
+
1050
+ await act(() => {
1051
+ // This will resolve the content in the first cache
1052
+ resolveMostRecentTextCache('A');
1053
+ resolveMostRecentTextCache('B');
1054
+ // And mount the second tree, which includes new content
1055
+ root.render(<App showMore={true} />);
1056
+ });
1057
+ assertLog([
1058
+ // The new tree should use a fresh cache
1059
+ 'Cache miss! [A]',
1060
+ 'Loading...',
1061
+ // The other tree uses the cached responses. This demonstrates that the
1062
+ // requests are not dropped.
1063
+ 'A [v1]',
1064
+ 'B [v1]',
1065
+ ]);
1066
+ expect(root).toMatchRenderedOutput('Loading... A [v1] B [v1]');
1067
+
1068
+ // Now resolve the second tree
1069
+ await act(() => {
1070
+ resolveMostRecentTextCache('A');
1071
+ });
1072
+ assertLog(['A [v2]']);
1073
+ expect(root).toMatchRenderedOutput('A [v2] A [v1] B [v1]');
1074
+
1075
+ await act(() => {
1076
+ root.render('Bye!');
1077
+ });
1078
+ // Unmounting children releases both cache boundaries, but the original
1079
+ // cache instance (used by second boundary) is still referenced by the root.
1080
+ // only the second cache instance is freed.
1081
+ assertLog(['Cache cleanup: A [v2]']);
1082
+ expect(root).toMatchRenderedOutput('Bye!');
1083
+ },
1084
+ );
1085
+
1086
+ // @gate enableCacheElement
1087
+ test('cache pool is cleared once transitions that depend on it commit their shell', async () => {
1088
+ function Child({text}) {
1089
+ return (
1090
+ <Cache>
1091
+ <AsyncText showVersion={true} text={text} />
1092
+ </Cache>
1093
+ );
1094
+ }
1095
+
1096
+ const root = ReactNoop.createRoot();
1097
+ await act(() => {
1098
+ root.render(
1099
+ <Suspense fallback={<Text text="Loading..." />}>(empty)</Suspense>,
1100
+ );
1101
+ });
1102
+ assertLog([]);
1103
+ expect(root).toMatchRenderedOutput('(empty)');
1104
+
1105
+ await act(() => {
1106
+ startTransition(() => {
1107
+ root.render(
1108
+ <Suspense fallback={<Text text="Loading..." />}>
1109
+ <Child text="A" />
1110
+ </Suspense>,
1111
+ );
1112
+ });
1113
+ });
1114
+ assertLog(['Cache miss! [A]', 'Loading...']);
1115
+ expect(root).toMatchRenderedOutput('(empty)');
1116
+
1117
+ await act(() => {
1118
+ startTransition(() => {
1119
+ root.render(
1120
+ <Suspense fallback={<Text text="Loading..." />}>
1121
+ <Child text="A" />
1122
+ <Child text="A" />
1123
+ </Suspense>,
1124
+ );
1125
+ });
1126
+ });
1127
+ assertLog([
1128
+ // No cache miss, because it uses the pooled cache
1129
+ 'Loading...',
1130
+ ]);
1131
+ expect(root).toMatchRenderedOutput('(empty)');
1132
+
1133
+ // Resolve the request
1134
+ await act(() => {
1135
+ resolveMostRecentTextCache('A');
1136
+ });
1137
+ assertLog(['A [v1]', 'A [v1]']);
1138
+ expect(root).toMatchRenderedOutput('A [v1]A [v1]');
1139
+
1140
+ // Now do another transition
1141
+ await act(() => {
1142
+ startTransition(() => {
1143
+ root.render(
1144
+ <Suspense fallback={<Text text="Loading..." />}>
1145
+ <Child text="A" />
1146
+ <Child text="A" />
1147
+ <Child text="A" />
1148
+ </Suspense>,
1149
+ );
1150
+ });
1151
+ });
1152
+ assertLog([
1153
+ // First two children use the old cache because they already finished
1154
+ 'A [v1]',
1155
+ 'A [v1]',
1156
+ // The new child uses a fresh cache
1157
+ 'Cache miss! [A]',
1158
+ 'Loading...',
1159
+ ]);
1160
+ expect(root).toMatchRenderedOutput('A [v1]A [v1]');
1161
+
1162
+ await act(() => {
1163
+ resolveMostRecentTextCache('A');
1164
+ });
1165
+ assertLog(['A [v1]', 'A [v1]', 'A [v2]']);
1166
+ expect(root).toMatchRenderedOutput('A [v1]A [v1]A [v2]');
1167
+
1168
+ // Unmount children: the first text cache instance is created only after the root
1169
+ // commits, so both fresh cache instances are released by their cache boundaries,
1170
+ // cleaning up v1 (used for the first two children which render together) and
1171
+ // v2 (used for the third boundary added later).
1172
+ await act(() => {
1173
+ root.render('Bye!');
1174
+ });
1175
+ assertLog(['Cache cleanup: A [v1]', 'Cache cleanup: A [v2]']);
1176
+ expect(root).toMatchRenderedOutput('Bye!');
1177
+ });
1178
+
1179
+ // @gate enableCacheElement
1180
+ test('cache pool is not cleared by arbitrary commits', async () => {
1181
+ function App() {
1182
+ return (
1183
+ <>
1184
+ <ShowMore />
1185
+ <Unrelated />
1186
+ </>
1187
+ );
1188
+ }
1189
+
1190
+ let showMore;
1191
+ function ShowMore() {
1192
+ const [shouldShow, _showMore] = useState(false);
1193
+ showMore = () => _showMore(true);
1194
+ return (
1195
+ <>
1196
+ <Suspense fallback={<Text text="Loading..." />}>
1197
+ {shouldShow ? (
1198
+ <Cache>
1199
+ <AsyncText showVersion={true} text="A" />
1200
+ </Cache>
1201
+ ) : null}
1202
+ </Suspense>
1203
+ </>
1204
+ );
1205
+ }
1206
+
1207
+ let updateUnrelated;
1208
+ function Unrelated() {
1209
+ const [count, _updateUnrelated] = useState(0);
1210
+ updateUnrelated = _updateUnrelated;
1211
+ return <Text text={String(count)} />;
1212
+ }
1213
+
1214
+ const root = ReactNoop.createRoot();
1215
+ await act(() => {
1216
+ root.render(<App />);
1217
+ });
1218
+ assertLog(['0']);
1219
+ expect(root).toMatchRenderedOutput('0');
1220
+
1221
+ await act(() => {
1222
+ startTransition(() => {
1223
+ showMore();
1224
+ });
1225
+ });
1226
+ assertLog(['Cache miss! [A]', 'Loading...']);
1227
+ expect(root).toMatchRenderedOutput('0');
1228
+
1229
+ await act(() => {
1230
+ updateUnrelated(1);
1231
+ });
1232
+ assertLog([
1233
+ '1',
1234
+
1235
+ // Happens to re-render the fallback. Doesn't need to, but not relevant
1236
+ // to this test.
1237
+ 'Loading...',
1238
+ ]);
1239
+ expect(root).toMatchRenderedOutput('1');
1240
+
1241
+ await act(() => {
1242
+ resolveMostRecentTextCache('A');
1243
+ });
1244
+ assertLog(['A [v1]']);
1245
+ expect(root).toMatchRenderedOutput('A [v1]1');
1246
+
1247
+ // Unmount children: the first text cache instance is created only after initial
1248
+ // render after calling showMore(). This instance is cleaned up when that boundary
1249
+ // is unmounted. Bc root cache instance is never accessed, the inner cache
1250
+ // boundary ends up at v1.
1251
+ await act(() => {
1252
+ root.render('Bye!');
1253
+ });
1254
+ assertLog(['Cache cleanup: A [v1]']);
1255
+ expect(root).toMatchRenderedOutput('Bye!');
1256
+ });
1257
+
1258
+ // @gate enableCacheElement
1259
+ test('cache boundary uses a fresh cache when its key changes', async () => {
1260
+ const root = ReactNoop.createRoot();
1261
+ seedNextTextCache('A');
1262
+ await act(() => {
1263
+ root.render(
1264
+ <Suspense fallback="Loading...">
1265
+ <Cache key="A">
1266
+ <AsyncText showVersion={true} text="A" />
1267
+ </Cache>
1268
+ </Suspense>,
1269
+ );
1270
+ });
1271
+ assertLog(['A [v1]']);
1272
+ expect(root).toMatchRenderedOutput('A [v1]');
1273
+
1274
+ seedNextTextCache('B');
1275
+ await act(() => {
1276
+ root.render(
1277
+ <Suspense fallback="Loading...">
1278
+ <Cache key="B">
1279
+ <AsyncText showVersion={true} text="B" />
1280
+ </Cache>
1281
+ </Suspense>,
1282
+ );
1283
+ });
1284
+ assertLog(['B [v2]']);
1285
+ expect(root).toMatchRenderedOutput('B [v2]');
1286
+
1287
+ // Unmount children: the fresh cache instance for B cleans up since the cache boundary
1288
+ // is the only owner, while the original cache instance (for A) is still retained by
1289
+ // the root.
1290
+ await act(() => {
1291
+ root.render('Bye!');
1292
+ });
1293
+ assertLog(['Cache cleanup: B [v2]']);
1294
+ expect(root).toMatchRenderedOutput('Bye!');
1295
+ });
1296
+
1297
+ // @gate enableCacheElement
1298
+ test('overlapping transitions after an initial mount use the same fresh cache', async () => {
1299
+ const root = ReactNoop.createRoot();
1300
+ await act(() => {
1301
+ root.render(
1302
+ <Suspense fallback="Loading...">
1303
+ <Cache key="A">
1304
+ <AsyncText showVersion={true} text="A" />
1305
+ </Cache>
1306
+ </Suspense>,
1307
+ );
1308
+ });
1309
+ assertLog(['Cache miss! [A]']);
1310
+ expect(root).toMatchRenderedOutput('Loading...');
1311
+
1312
+ await act(() => {
1313
+ resolveMostRecentTextCache('A');
1314
+ });
1315
+ assertLog(['A [v1]']);
1316
+ expect(root).toMatchRenderedOutput('A [v1]');
1317
+
1318
+ // After a mount, subsequent transitions use a fresh cache
1319
+ await act(() => {
1320
+ startTransition(() => {
1321
+ root.render(
1322
+ <Suspense fallback="Loading...">
1323
+ <Cache key="B">
1324
+ <AsyncText showVersion={true} text="B" />
1325
+ </Cache>
1326
+ </Suspense>,
1327
+ );
1328
+ });
1329
+ });
1330
+ assertLog(['Cache miss! [B]']);
1331
+ expect(root).toMatchRenderedOutput('A [v1]');
1332
+
1333
+ // Update to a different text and with a different key for the cache
1334
+ // boundary: this should still use the fresh cache instance created
1335
+ // for the earlier transition
1336
+ await act(() => {
1337
+ startTransition(() => {
1338
+ root.render(
1339
+ <Suspense fallback="Loading...">
1340
+ <Cache key="C">
1341
+ <AsyncText showVersion={true} text="C" />
1342
+ </Cache>
1343
+ </Suspense>,
1344
+ );
1345
+ });
1346
+ });
1347
+ assertLog(['Cache miss! [C]']);
1348
+ expect(root).toMatchRenderedOutput('A [v1]');
1349
+
1350
+ await act(() => {
1351
+ resolveMostRecentTextCache('C');
1352
+ });
1353
+ assertLog(['C [v2]']);
1354
+ expect(root).toMatchRenderedOutput('C [v2]');
1355
+
1356
+ // Unmount children: the fresh cache used for the updates is freed, while the
1357
+ // original cache (with A) is still retained at the root.
1358
+ await act(() => {
1359
+ root.render('Bye!');
1360
+ });
1361
+ assertLog(['Cache cleanup: B [v2]', 'Cache cleanup: C [v2]']);
1362
+ expect(root).toMatchRenderedOutput('Bye!');
1363
+ });
1364
+
1365
+ // @gate enableCacheElement
1366
+ test('overlapping updates after an initial mount use the same fresh cache', async () => {
1367
+ const root = ReactNoop.createRoot();
1368
+ await act(() => {
1369
+ root.render(
1370
+ <Suspense fallback="Loading...">
1371
+ <Cache key="A">
1372
+ <AsyncText showVersion={true} text="A" />
1373
+ </Cache>
1374
+ </Suspense>,
1375
+ );
1376
+ });
1377
+ assertLog(['Cache miss! [A]']);
1378
+ expect(root).toMatchRenderedOutput('Loading...');
1379
+
1380
+ await act(() => {
1381
+ resolveMostRecentTextCache('A');
1382
+ });
1383
+ assertLog(['A [v1]']);
1384
+ expect(root).toMatchRenderedOutput('A [v1]');
1385
+
1386
+ // After a mount, subsequent updates use a fresh cache
1387
+ await act(() => {
1388
+ root.render(
1389
+ <Suspense fallback="Loading...">
1390
+ <Cache key="B">
1391
+ <AsyncText showVersion={true} text="B" />
1392
+ </Cache>
1393
+ </Suspense>,
1394
+ );
1395
+ });
1396
+ assertLog(['Cache miss! [B]']);
1397
+ expect(root).toMatchRenderedOutput('Loading...');
1398
+
1399
+ // A second update uses the same fresh cache: even though this is a new
1400
+ // Cache boundary, the render uses the fresh cache from the pending update.
1401
+ await act(() => {
1402
+ root.render(
1403
+ <Suspense fallback="Loading...">
1404
+ <Cache key="C">
1405
+ <AsyncText showVersion={true} text="C" />
1406
+ </Cache>
1407
+ </Suspense>,
1408
+ );
1409
+ });
1410
+ assertLog(['Cache miss! [C]']);
1411
+ expect(root).toMatchRenderedOutput('Loading...');
1412
+
1413
+ await act(() => {
1414
+ resolveMostRecentTextCache('C');
1415
+ });
1416
+ assertLog(['C [v2]']);
1417
+ expect(root).toMatchRenderedOutput('C [v2]');
1418
+
1419
+ // Unmount children: the fresh cache used for the updates is freed, while the
1420
+ // original cache (with A) is still retained at the root.
1421
+ await act(() => {
1422
+ root.render('Bye!');
1423
+ });
1424
+ assertLog(['Cache cleanup: B [v2]', 'Cache cleanup: C [v2]']);
1425
+ expect(root).toMatchRenderedOutput('Bye!');
1426
+ });
1427
+
1428
+ // @gate enableCacheElement
1429
+ test('cleans up cache only used in an aborted transition', async () => {
1430
+ const root = ReactNoop.createRoot();
1431
+ seedNextTextCache('A');
1432
+ await act(() => {
1433
+ root.render(
1434
+ <Suspense fallback="Loading...">
1435
+ <Cache key="A">
1436
+ <AsyncText showVersion={true} text="A" />
1437
+ </Cache>
1438
+ </Suspense>,
1439
+ );
1440
+ });
1441
+ assertLog(['A [v1]']);
1442
+ expect(root).toMatchRenderedOutput('A [v1]');
1443
+
1444
+ // Start a transition from A -> B..., which should create a fresh cache
1445
+ // for the new cache boundary (bc of the different key)
1446
+ await act(() => {
1447
+ startTransition(() => {
1448
+ root.render(
1449
+ <Suspense fallback="Loading...">
1450
+ <Cache key="B">
1451
+ <AsyncText showVersion={true} text="B" />
1452
+ </Cache>
1453
+ </Suspense>,
1454
+ );
1455
+ });
1456
+ });
1457
+ assertLog(['Cache miss! [B]']);
1458
+ expect(root).toMatchRenderedOutput('A [v1]');
1459
+
1460
+ // ...but cancel by transitioning "back" to A (which we never really left)
1461
+ await act(() => {
1462
+ startTransition(() => {
1463
+ root.render(
1464
+ <Suspense fallback="Loading...">
1465
+ <Cache key="A">
1466
+ <AsyncText showVersion={true} text="A" />
1467
+ </Cache>
1468
+ </Suspense>,
1469
+ );
1470
+ });
1471
+ });
1472
+ assertLog(['A [v1]', 'Cache cleanup: B [v2]']);
1473
+ expect(root).toMatchRenderedOutput('A [v1]');
1474
+
1475
+ // Unmount children: ...
1476
+ await act(() => {
1477
+ root.render('Bye!');
1478
+ });
1479
+ assertLog([]);
1480
+ expect(root).toMatchRenderedOutput('Bye!');
1481
+ });
1482
+
1483
+ // @gate enableCacheElement
1484
+ test.skip('if a root cache refresh never commits its fresh cache is released', async () => {
1485
+ const root = ReactNoop.createRoot();
1486
+ let refresh;
1487
+ function Example({text}) {
1488
+ refresh = useCacheRefresh();
1489
+ return <AsyncText showVersion={true} text={text} />;
1490
+ }
1491
+ seedNextTextCache('A');
1492
+ await act(() => {
1493
+ root.render(
1494
+ <Suspense fallback="Loading...">
1495
+ <Example text="A" />
1496
+ </Suspense>,
1497
+ );
1498
+ });
1499
+ assertLog(['A [v1]']);
1500
+ expect(root).toMatchRenderedOutput('A [v1]');
1501
+
1502
+ await act(() => {
1503
+ startTransition(() => {
1504
+ refresh();
1505
+ });
1506
+ });
1507
+ assertLog(['Cache miss! [A]']);
1508
+ expect(root).toMatchRenderedOutput('A [v1]');
1509
+
1510
+ await act(() => {
1511
+ root.render('Bye!');
1512
+ });
1513
+ assertLog([
1514
+ // TODO: the v1 cache should *not* be cleaned up, it is still retained by the root
1515
+ // The following line is presently yielded but should not be:
1516
+ // 'Cache cleanup: A [v1]',
1517
+
1518
+ // TODO: the v2 cache *should* be cleaned up, it was created for the abandoned refresh
1519
+ // The following line is presently not yielded but should be:
1520
+ 'Cache cleanup: A [v2]',
1521
+ ]);
1522
+ expect(root).toMatchRenderedOutput('Bye!');
1523
+ });
1524
+
1525
+ // @gate enableCacheElement
1526
+ test.skip('if a cache boundary refresh never commits its fresh cache is released', async () => {
1527
+ const root = ReactNoop.createRoot();
1528
+ let refresh;
1529
+ function Example({text}) {
1530
+ refresh = useCacheRefresh();
1531
+ return <AsyncText showVersion={true} text={text} />;
1532
+ }
1533
+ seedNextTextCache('A');
1534
+ await act(() => {
1535
+ root.render(
1536
+ <Suspense fallback="Loading...">
1537
+ <Cache>
1538
+ <Example text="A" />
1539
+ </Cache>
1540
+ </Suspense>,
1541
+ );
1542
+ });
1543
+ assertLog(['A [v1]']);
1544
+ expect(root).toMatchRenderedOutput('A [v1]');
1545
+
1546
+ await act(() => {
1547
+ startTransition(() => {
1548
+ refresh();
1549
+ });
1550
+ });
1551
+ assertLog(['Cache miss! [A]']);
1552
+ expect(root).toMatchRenderedOutput('A [v1]');
1553
+
1554
+ // Unmount the boundary before the refresh can complete
1555
+ await act(() => {
1556
+ root.render('Bye!');
1557
+ });
1558
+ assertLog([
1559
+ // TODO: the v2 cache *should* be cleaned up, it was created for the abandoned refresh
1560
+ // The following line is presently not yielded but should be:
1561
+ 'Cache cleanup: A [v2]',
1562
+ ]);
1563
+ expect(root).toMatchRenderedOutput('Bye!');
1564
+ });
1565
+
1566
+ // @gate enableActivity
1567
+ // @gate enableCache
1568
+ test('prerender a new cache boundary inside an Activity tree', async () => {
1569
+ function App({prerenderMore}) {
1570
+ return (
1571
+ <Activity mode="hidden">
1572
+ <div>
1573
+ {prerenderMore ? (
1574
+ <Cache>
1575
+ <AsyncText text="More" />
1576
+ </Cache>
1577
+ ) : null}
1578
+ </div>
1579
+ </Activity>
1580
+ );
1581
+ }
1582
+
1583
+ const root = ReactNoop.createRoot();
1584
+ await act(() => {
1585
+ root.render(<App prerenderMore={false} />);
1586
+ });
1587
+ assertLog([]);
1588
+ expect(root).toMatchRenderedOutput(<div hidden={true} />);
1589
+
1590
+ seedNextTextCache('More');
1591
+ await act(() => {
1592
+ root.render(<App prerenderMore={true} />);
1593
+ });
1594
+ assertLog(['More']);
1595
+ expect(root).toMatchRenderedOutput(<div hidden={true}>More</div>);
1596
+ });
1597
+});
packages/react-reconciler/src/__tests__/ReactUse-test.js
+30
-21
@@ -11,7 +11,6 @@ let useMemo;
11
let useEffect;
12
let Suspense;
13
let startTransition;
14
-let cache;
14
let pendingTextRequests;
15
let waitFor;
16
let waitForPaint;
@@ -34,7 +33,6 @@ describe('ReactUse', () => {
33
useEffect = React.useEffect;
34
Suspense = React.Suspense;
35
startTransition = React.startTransition;
37
- cache = React.cache;
36
37
const InternalTestUtils = require('internal-test-utils');
38
waitForAll = InternalTestUtils.waitForAll;
@@ -643,10 +641,10 @@ describe('ReactUse', () => {
641
});
642
643
test('when waiting for data to resolve, an update on a different root does not cause work to be dropped', async () => {
646
- const getCachedAsyncText = cache(getAsyncText);
644
+ const promise = getAsyncText('Hi');
645
646
function App() {
649
- return <Text text={use(getCachedAsyncText('Hi'))} />;
647
+ return <Text text={use(promise)} />;
648
}
649
650
const root1 = ReactNoop.createRoot();
@@ -998,39 +996,46 @@ describe('ReactUse', () => {
996
);
997
998
test('load multiple nested Suspense boundaries', async () => {
1001
- const getCachedAsyncText = cache(getAsyncText);
999
+ const promiseA = getAsyncText('A');
1000
+ const promiseB = getAsyncText('B');
1001
+ const promiseC = getAsyncText('C');
1002
+ assertLog([
1003
+ 'Async text requested [A]',
1004
+ 'Async text requested [B]',
1005
+ 'Async text requested [C]',
1006
+ ]);
1007
1003
- function AsyncText({text}) {
1004
- return <Text text={use(getCachedAsyncText(text))} />;
1008
+ function AsyncText({promise}) {
1009
+ return <Text text={use(promise)} />;
1010
}
1011
1012
const root = ReactNoop.createRoot();
1013
await act(() => {
1014
root.render(
1015
<Suspense fallback={<Text text="(Loading A...)" />}>
1011
- <AsyncText text="A" />
1016
+ <AsyncText promise={promiseA} />
1017
<Suspense fallback={<Text text="(Loading B...)" />}>
1013
- <AsyncText text="B" />
1018
+ <AsyncText promise={promiseB} />
1019
<Suspense fallback={<Text text="(Loading C...)" />}>
1015
- <AsyncText text="C" />
1020
+ <AsyncText promise={promiseC} />
1021
</Suspense>
1022
</Suspense>
1023
</Suspense>,
1024
);
1025
});
1021
- assertLog(['Async text requested [A]', '(Loading A...)']);
1026
+ assertLog(['(Loading A...)']);
1027
expect(root).toMatchRenderedOutput('(Loading A...)');
1028
1029
await act(() => {
1030
resolveTextRequests('A');
1031
});
1027
- assertLog(['A', 'Async text requested [B]', '(Loading B...)']);
1032
+ assertLog(['A', '(Loading B...)']);
1033
expect(root).toMatchRenderedOutput('A(Loading B...)');
1034
1035
await act(() => {
1036
resolveTextRequests('B');
1037
});
1033
- assertLog(['B', 'Async text requested [C]', '(Loading C...)']);
1038
+ assertLog(['B', '(Loading C...)']);
1039
expect(root).toMatchRenderedOutput('AB(Loading C...)');
1040
1041
await act(() => {
@@ -1584,34 +1589,38 @@ describe('ReactUse', () => {
1589
});
1590
1591
test('regression test: updates while component is suspended should not be mistaken for render phase updates', async () => {
1587
- const getCachedAsyncText = cache(getAsyncText);
1592
+ const promiseA = getAsyncText('A');
1593
+ const promiseB = getAsyncText('B');
1594
+ const promiseC = getAsyncText('C');
1595
+ assertLog([
1596
+ 'Async text requested [A]',
1597
+ 'Async text requested [B]',
1598
+ 'Async text requested [C]',
1599
+ ]);
1600
1601
let setState;
1602
function App() {
1591
- const [state, _setState] = useState('A');
1603
+ const [state, _setState] = useState(promiseA);
1604
setState = _setState;
1593
- return <Text text={use(getCachedAsyncText(state))} />;
1605
+ return <Text text={use(state)} />;
1606
}
1607
1608
// Initial render
1609
const root = ReactNoop.createRoot();
1610
await act(() => root.render(<App />));
1599
- assertLog(['Async text requested [A]']);
1611
expect(root).toMatchRenderedOutput(null);
1612
await act(() => resolveTextRequests('A'));
1613
assertLog(['A']);
1614
expect(root).toMatchRenderedOutput('A');
1615
1616
// Update to B. This will suspend.
1606
- await act(() => startTransition(() => setState('B')));
1607
- assertLog(['Async text requested [B]']);
1617
+ await act(() => startTransition(() => setState(promiseB)));
1618
expect(root).toMatchRenderedOutput('A');
1619
1620
// While B is suspended, update to C. This should immediately interrupt
1621
// the render for B. In the regression, this update was mistakenly treated
1622
// as a render phase update.
1613
- ReactNoop.flushSync(() => setState('C'));
1614
- assertLog(['Async text requested [C]']);
1623
+ ReactNoop.flushSync(() => setState(promiseC));
1624
1625
// Finish rendering.
1626
await act(() => resolveTextRequests('C'));
packages/react/src/ReactCacheClient.js
new
+27
@@ -0,0 +1,27 @@
1
+/**
2
+ * Copyright (c) Meta Platforms, 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
+ * @flow
8
+ */
9
+
10
+export function cache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
11
+ // On the client (i.e. not a Server Components environment) `cache` has
12
+ // no caching behavior. We just return the function as-is.
13
+ //
14
+ // We intend to implement client caching in a future major release. In the
15
+ // meantime, it's only exposed as an API so that Shared Components can use
16
+ // per-request caching on the server without breaking on the client. But it
17
+ // does mean they need to be aware of the behavioral difference.
18
+ //
19
+ // The rest of the behavior is the same as the server implementation — it
20
+ // returns a new reference, extra properties like `displayName` are not
21
+ // preserved, the length of the new function is 0, etc. That way apps can't
22
+ // accidentally depend on those details.
23
+ return function () {
24
+ // $FlowFixMe[incompatible-call]: We don't want to use rest arguments since we transpile the code.
25
+ return fn.apply(null, arguments);
26
+ };
27
+}
packages/react/src/ReactCacheServer.js
renamed
packages/react/src/ReactClient.js
+1
-1
@@ -35,7 +35,7 @@ import {createContext} from './ReactContext';
35
import {lazy} from './ReactLazy';
36
import {forwardRef} from './ReactForwardRef';
37
import {memo} from './ReactMemo';
38
-import {cache} from './ReactCache';
38
+import {cache} from './ReactCacheClient';
39
import {postpone} from './ReactPostpone';
40
import {
41
getCacheSignal,
packages/react/src/ReactServer.experimental.js
+1
-1
@@ -38,7 +38,7 @@ import {
38
import {forwardRef} from './ReactForwardRef';
39
import {lazy} from './ReactLazy';
40
import {memo} from './ReactMemo';
41
-import {cache} from './ReactCache';
41
+import {cache} from './ReactCacheServer';
42
import {startTransition} from './ReactStartTransition';
43
import {postpone} from './ReactPostpone';
44
import version from 'shared/ReactVersion';
packages/react/src/ReactServer.js
+1
-1
@@ -35,7 +35,7 @@ import {
35
import {forwardRef} from './ReactForwardRef';
36
import {lazy} from './ReactLazy';
37
import {memo} from './ReactMemo';
38
-import {cache} from './ReactCache';
38
+import {cache} from './ReactCacheServer';
39
import {startTransition} from './ReactStartTransition';
40
import version from 'shared/ReactVersion';
41