Add cache() API (#25506)
Like memo() but longer lived.
Sebastian Markbåge committed
Oct 18, 2022 at 16:55 UTC
8e2bde6f2751aa6335f3cef488c05c3ea08e074a
8 files changed
+356
-60
packages/react-reconciler/src/__tests__/ReactCache-test.js
+223
-60
@@ -2,7 +2,6 @@ let React;
2
let ReactNoop;
3
let Cache;
4
let getCacheSignal;
5
-let getCacheForType;
5
let Scheduler;
6
let act;
7
let Suspense;
@@ -10,8 +9,10 @@ let Offscreen;
9
let useCacheRefresh;
10
let startTransition;
11
let useState;
12
+let cache;
13
14
-let caches;
14
+let getTextCache;
15
+let textCaches;
16
let seededCache;
17
18
describe('ReactCache', () => {
@@ -24,66 +25,68 @@ describe('ReactCache', () => {
25
Scheduler = require('scheduler');
26
act = require('jest-react').act;
27
Suspense = React.Suspense;
28
+ cache = React.experimental_cache;
29
Offscreen = React.unstable_Offscreen;
30
getCacheSignal = React.unstable_getCacheSignal;
29
- getCacheForType = React.unstable_getCacheForType;
31
useCacheRefresh = React.unstable_useCacheRefresh;
32
startTransition = React.startTransition;
33
useState = React.useState;
34
34
- caches = [];
35
+ textCaches = [];
36
seededCache = null;
36
- });
37
-
38
- function createTextCache() {
39
- if (seededCache !== null) {
40
- // Trick to seed a cache before it exists.
41
- // TODO: Need a built-in API to seed data before the initial render (i.e.
42
- // not a refresh because nothing has mounted yet).
43
- const cache = seededCache;
44
- seededCache = null;
45
- return cache;
46
- }
37
48
- const data = new Map();
49
- const version = caches.length + 1;
50
- const cache = {
51
- version,
52
- data,
53
- resolve(text) {
54
- const record = data.get(text);
55
- if (record === undefined) {
56
- const newRecord = {
57
- status: 'resolved',
58
- value: text,
59
- cleanupScheduled: false,
60
- };
61
- data.set(text, newRecord);
62
- } else if (record.status === 'pending') {
63
- record.value.resolve();
38
+ if (gate(flags => flags.enableCache)) {
39
+ getTextCache = cache(() => {
40
+ if (seededCache !== null) {
41
+ // Trick to seed a cache before it exists.
42
+ // TODO: Need a built-in API to seed data before the initial render (i.e.
43
+ // not a refresh because nothing has mounted yet).
44
+ const textCache = seededCache;
45
+ seededCache = null;
46
+ return textCache;
47
}
65
- },
66
- reject(text, error) {
67
- const record = data.get(text);
68
- if (record === undefined) {
69
- const newRecord = {
70
- status: 'rejected',
71
- value: error,
72
- cleanupScheduled: false,
73
- };
74
- data.set(text, newRecord);
75
- } else if (record.status === 'pending') {
76
- record.value.reject();
77
- }
78
- },
79
- };
80
- caches.push(cache);
81
- return cache;
82
- }
48
+
49
+ const data = new Map();
50
+ const version = textCaches.length + 1;
51
+ const textCache = {
52
+ version,
53
+ data,
54
+ resolve(text) {
55
+ const record = data.get(text);
56
+ if (record === undefined) {
57
+ const newRecord = {
58
+ status: 'resolved',
59
+ value: text,
60
+ cleanupScheduled: false,
61
+ };
62
+ data.set(text, newRecord);
63
+ } else if (record.status === 'pending') {
64
+ record.value.resolve();
65
+ }
66
+ },
67
+ reject(text, error) {
68
+ const record = data.get(text);
69
+ if (record === undefined) {
70
+ const newRecord = {
71
+ status: 'rejected',
72
+ value: error,
73
+ cleanupScheduled: false,
74
+ };
75
+ data.set(text, newRecord);
76
+ } else if (record.status === 'pending') {
77
+ record.value.reject();
78
+ }
79
+ },
80
+ };
81
+ textCaches.push(textCache);
82
+ return textCache;
83
+ });
84
+ }
85
+ });
86
87
function readText(text) {
88
const signal = getCacheSignal();
86
- const textCache = getCacheForType(createTextCache);
89
+ const textCache = getTextCache();
90
const record = textCache.data.get(text);
91
if (record !== undefined) {
92
if (!record.cleanupScheduled) {
@@ -160,18 +163,18 @@ describe('ReactCache', () => {
163
164
function seedNextTextCache(text) {
165
if (seededCache === null) {
163
- seededCache = createTextCache();
166
+ seededCache = getTextCache();
167
}
168
seededCache.resolve(text);
169
}
170
171
function resolveMostRecentTextCache(text) {
169
- if (caches.length === 0) {
172
+ if (textCaches.length === 0) {
173
throw Error('Cache does not exist.');
174
} else {
175
// Resolve the most recently created cache. An older cache can by
173
- // resolved with `caches[index].resolve(text)`.
174
- caches[caches.length - 1].resolve(text);
176
+ // resolved with `textCaches[index].resolve(text)`.
177
+ textCaches[textCaches.length - 1].resolve(text);
178
}
179
}
180
@@ -815,9 +818,18 @@ describe('ReactCache', () => {
818
819
// @gate experimental || www
820
test('refresh a cache with seed data', async () => {
818
- let refresh;
821
+ let refreshWithSeed;
822
function App() {
820
- refresh = useCacheRefresh();
823
+ const refresh = useCacheRefresh();
824
+ const [seed, setSeed] = useState({fn: null});
825
+ if (seed.fn) {
826
+ seed.fn();
827
+ seed.fn = null;
828
+ }
829
+ refreshWithSeed = fn => {
830
+ setSeed({fn});
831
+ refresh();
832
+ };
833
return <AsyncText showVersion={true} text="A" />;
834
}
835
@@ -845,11 +857,14 @@ describe('ReactCache', () => {
857
await act(async () => {
858
// Refresh the cache with seeded data, like you would receive from a
859
// server mutation.
848
- // TODO: Seeding multiple typed caches. Should work by calling `refresh`
860
+ // TODO: Seeding multiple typed textCaches. Should work by calling `refresh`
861
// multiple times with different key/value pairs
850
- const cache = createTextCache();
851
- cache.resolve('A');
852
- startTransition(() => refresh(createTextCache, cache));
862
+ startTransition(() =>
863
+ refreshWithSeed(() => {
864
+ const textCache = getTextCache();
865
+ textCache.resolve('A');
866
+ }),
867
+ );
868
});
869
// The root should re-render without a cache miss.
870
// The cache is not cleared up yet, since it's still reference by the root
@@ -1624,4 +1639,152 @@ describe('ReactCache', () => {
1639
expect(Scheduler).toHaveYielded(['More']);
1640
expect(root).toMatchRenderedOutput(<div hidden={true}>More</div>);
1641
});
1642
+
1643
+ // @gate enableCache
1644
+ it('cache objects and primitive arguments and a mix of them', async () => {
1645
+ const root = ReactNoop.createRoot();
1646
+ const types = cache((a, b) => ({a: typeof a, b: typeof b}));
1647
+ function Print({a, b}) {
1648
+ return types(a, b).a + ' ' + types(a, b).b + ' ';
1649
+ }
1650
+ function Same({a, b}) {
1651
+ const x = types(a, b);
1652
+ const y = types(a, b);
1653
+ return (x === y).toString() + ' ';
1654
+ }
1655
+ function FlippedOrder({a, b}) {
1656
+ return (types(a, b) === types(b, a)).toString() + ' ';
1657
+ }
1658
+ function FewerArgs({a, b}) {
1659
+ return (types(a, b) === types(a)).toString() + ' ';
1660
+ }
1661
+ function MoreArgs({a, b}) {
1662
+ return (types(a) === types(a, b)).toString() + ' ';
1663
+ }
1664
+ await act(async () => {
1665
+ root.render(
1666
+ <>
1667
+ <Print a="e" b="f" />
1668
+ <Same a="a" b="b" />
1669
+ <FlippedOrder a="c" b="d" />
1670
+ <FewerArgs a="e" b="f" />
1671
+ <MoreArgs a="g" b="h" />
1672
+ </>,
1673
+ );
1674
+ });
1675
+ expect(root).toMatchRenderedOutput('string string true false false false ');
1676
+ await act(async () => {
1677
+ root.render(
1678
+ <>
1679
+ <Print a="e" b={null} />
1680
+ <Same a="a" b={null} />
1681
+ <FlippedOrder a="c" b={null} />
1682
+ <FewerArgs a="e" b={null} />
1683
+ <MoreArgs a="g" b={null} />
1684
+ </>,
1685
+ );
1686
+ });
1687
+ expect(root).toMatchRenderedOutput('string object true false false false ');
1688
+ const obj = {};
1689
+ await act(async () => {
1690
+ root.render(
1691
+ <>
1692
+ <Print a="e" b={obj} />
1693
+ <Same a="a" b={obj} />
1694
+ <FlippedOrder a="c" b={obj} />
1695
+ <FewerArgs a="e" b={obj} />
1696
+ <MoreArgs a="g" b={obj} />
1697
+ </>,
1698
+ );
1699
+ });
1700
+ expect(root).toMatchRenderedOutput('string object true false false false ');
1701
+ const sameObj = {};
1702
+ await act(async () => {
1703
+ root.render(
1704
+ <>
1705
+ <Print a={sameObj} b={sameObj} />
1706
+ <Same a={sameObj} b={sameObj} />
1707
+ <FlippedOrder a={sameObj} b={sameObj} />
1708
+ <FewerArgs a={sameObj} b={sameObj} />
1709
+ <MoreArgs a={sameObj} b={sameObj} />
1710
+ </>,
1711
+ );
1712
+ });
1713
+ expect(root).toMatchRenderedOutput('object object true true false false ');
1714
+ const objA = {};
1715
+ const objB = {};
1716
+ await act(async () => {
1717
+ root.render(
1718
+ <>
1719
+ <Print a={objA} b={objB} />
1720
+ <Same a={objA} b={objB} />
1721
+ <FlippedOrder a={objA} b={objB} />
1722
+ <FewerArgs a={objA} b={objB} />
1723
+ <MoreArgs a={objA} b={objB} />
1724
+ </>,
1725
+ );
1726
+ });
1727
+ expect(root).toMatchRenderedOutput('object object true false false false ');
1728
+ const sameSymbol = Symbol();
1729
+ await act(async () => {
1730
+ root.render(
1731
+ <>
1732
+ <Print a={sameSymbol} b={sameSymbol} />
1733
+ <Same a={sameSymbol} b={sameSymbol} />
1734
+ <FlippedOrder a={sameSymbol} b={sameSymbol} />
1735
+ <FewerArgs a={sameSymbol} b={sameSymbol} />
1736
+ <MoreArgs a={sameSymbol} b={sameSymbol} />
1737
+ </>,
1738
+ );
1739
+ });
1740
+ expect(root).toMatchRenderedOutput('symbol symbol true true false false ');
1741
+ const notANumber = +'nan';
1742
+ await act(async () => {
1743
+ root.render(
1744
+ <>
1745
+ <Print a={1} b={notANumber} />
1746
+ <Same a={1} b={notANumber} />
1747
+ <FlippedOrder a={1} b={notANumber} />
1748
+ <FewerArgs a={1} b={notANumber} />
1749
+ <MoreArgs a={1} b={notANumber} />
1750
+ </>,
1751
+ );
1752
+ });
1753
+ expect(root).toMatchRenderedOutput('number number true false false false ');
1754
+ });
1755
+
1756
+ // @gate enableCache
1757
+ it('cached functions that throw should cache the error', async () => {
1758
+ const root = ReactNoop.createRoot();
1759
+ const throws = cache(v => {
1760
+ throw new Error(v);
1761
+ });
1762
+ let x;
1763
+ let y;
1764
+ let z;
1765
+ function Test() {
1766
+ try {
1767
+ throws(1);
1768
+ } catch (e) {
1769
+ x = e;
1770
+ }
1771
+ try {
1772
+ throws(1);
1773
+ } catch (e) {
1774
+ y = e;
1775
+ }
1776
+ try {
1777
+ throws(2);
1778
+ } catch (e) {
1779
+ z = e;
1780
+ }
1781
+
1782
+ return 'Blank';
1783
+ }
1784
+ await act(async () => {
1785
+ root.render(<Test />);
1786
+ });
1787
+ expect(x).toBe(y);
1788
+ expect(z).not.toBe(x);
1789
+ });
1790
});
packages/react/index.classic.fb.js
+1
@@ -32,6 +32,7 @@ export {
32
isValidElement,
33
lazy,
34
memo,
35
+ experimental_cache,
36
startTransition,
37
startTransition as unstable_startTransition, // TODO: Remove once call sights updated to startTransition
38
unstable_Cache,
packages/react/index.experimental.js
+1
@@ -29,6 +29,7 @@ export {
29
isValidElement,
30
lazy,
31
memo,
32
+ experimental_cache,
33
startTransition,
34
unstable_Cache,
35
unstable_DebugTracingMode,
packages/react/index.js
+1
@@ -54,6 +54,7 @@ export {
54
isValidElement,
55
lazy,
56
memo,
57
+ experimental_cache,
58
startTransition,
59
unstable_Cache,
60
unstable_DebugTracingMode,
packages/react/index.modern.fb.js
+1
@@ -31,6 +31,7 @@ export {
31
isValidElement,
32
lazy,
33
memo,
34
+ experimental_cache,
35
startTransition,
36
startTransition as unstable_startTransition, // TODO: Remove once call sights updated to startTransition
37
unstable_Cache,
packages/react/src/React.js
+2
@@ -35,6 +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';
39
import {
40
getCacheSignal,
41
getCacheForType,
@@ -100,6 +101,7 @@ export {
101
forwardRef,
102
lazy,
103
memo,
104
+ cache as experimental_cache,
105
useCallback,
106
useContext,
107
useEffect,
packages/react/src/ReactCache.js
new
+126
@@ -0,0 +1,126 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import ReactCurrentCache from './ReactCurrentCache';
11
+
12
+const UNTERMINATED = 0;
13
+const TERMINATED = 1;
14
+const ERRORED = 2;
15
+
16
+type UnterminatedCacheNode<T> = {
17
+ s: 0,
18
+ v: void,
19
+ o: null | WeakMap<Function | Object, CacheNode<T>>,
20
+ p: null | Map<string | number | null | void | symbol | boolean, CacheNode<T>>,
21
+};
22
+
23
+type TerminatedCacheNode<T> = {
24
+ s: 1,
25
+ v: T,
26
+ o: null | WeakMap<Function | Object, CacheNode<T>>,
27
+ p: null | Map<string | number | null | void | symbol | boolean, CacheNode<T>>,
28
+};
29
+
30
+type ErroredCacheNode<T> = {
31
+ s: 2,
32
+ v: mixed,
33
+ o: null | WeakMap<Function | Object, CacheNode<T>>,
34
+ p: null | Map<string | number | null | void | symbol | boolean, CacheNode<T>>,
35
+};
36
+
37
+type CacheNode<T> =
38
+ | TerminatedCacheNode<T>
39
+ | UnterminatedCacheNode<T>
40
+ | ErroredCacheNode<T>;
41
+
42
+function createCacheRoot<T>(): WeakMap<Function | Object, CacheNode<T>> {
43
+ return new WeakMap();
44
+}
45
+
46
+function createCacheNode<T>(): CacheNode<T> {
47
+ return {
48
+ s: UNTERMINATED, // status, represents whether the cached computation returned a value or threw an error
49
+ v: undefined, // value, either the cached result or an error, depending on s
50
+ o: null, // object cache, a WeakMap where non-primitive arguments are stored
51
+ p: null, // primitive cache, a regular Map where primitive arguments are stored.
52
+ };
53
+}
54
+
55
+export function cache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
56
+ return function() {
57
+ const dispatcher = ReactCurrentCache.current;
58
+ if (!dispatcher) {
59
+ // If there is no dispatcher, then we treat this as not being cached.
60
+ // $FlowFixMe: We don't want to use rest arguments since we transpile the code.
61
+ return fn.apply(null, arguments);
62
+ }
63
+ const fnMap = dispatcher.getCacheForType(createCacheRoot);
64
+ const fnNode = fnMap.get(fn);
65
+ let cacheNode: CacheNode<T>;
66
+ if (fnNode === undefined) {
67
+ cacheNode = createCacheNode();
68
+ fnMap.set(fn, cacheNode);
69
+ } else {
70
+ cacheNode = fnNode;
71
+ }
72
+ for (let i = 0, l = arguments.length; i < l; i++) {
73
+ const arg = arguments[i];
74
+ if (
75
+ typeof arg === 'function' ||
76
+ (typeof arg === 'object' && arg !== null)
77
+ ) {
78
+ // Objects go into a WeakMap
79
+ let objectCache = cacheNode.o;
80
+ if (objectCache === null) {
81
+ cacheNode.o = objectCache = new WeakMap();
82
+ }
83
+ const objectNode = objectCache.get(arg);
84
+ if (objectNode === undefined) {
85
+ cacheNode = createCacheNode();
86
+ objectCache.set(arg, cacheNode);
87
+ } else {
88
+ cacheNode = objectNode;
89
+ }
90
+ } else {
91
+ // Primitives go into a regular Map
92
+ let primitiveCache = cacheNode.p;
93
+ if (primitiveCache === null) {
94
+ cacheNode.p = primitiveCache = new Map();
95
+ }
96
+ const primitiveNode = primitiveCache.get(arg);
97
+ if (primitiveNode === undefined) {
98
+ cacheNode = createCacheNode();
99
+ primitiveCache.set(arg, cacheNode);
100
+ } else {
101
+ cacheNode = primitiveNode;
102
+ }
103
+ }
104
+ }
105
+ if (cacheNode.s === TERMINATED) {
106
+ return cacheNode.v;
107
+ }
108
+ if (cacheNode.s === ERRORED) {
109
+ throw cacheNode.v;
110
+ }
111
+ try {
112
+ // $FlowFixMe: We don't want to use rest arguments since we transpile the code.
113
+ const result = fn.apply(null, arguments);
114
+ const terminatedNode: TerminatedCacheNode<T> = (cacheNode: any);
115
+ terminatedNode.s = TERMINATED;
116
+ terminatedNode.v = result;
117
+ return result;
118
+ } catch (error) {
119
+ // We store the first error that's thrown and rethrow it.
120
+ const erroredNode: ErroredCacheNode<T> = (cacheNode: any);
121
+ erroredNode.s = ERRORED;
122
+ erroredNode.v = error;
123
+ throw error;
124
+ }
125
+ };
126
+}
packages/react/src/ReactSharedSubset.experimental.js
+1
@@ -24,6 +24,7 @@ export {
24
isValidElement,
25
lazy,
26
memo,
27
+ experimental_cache,
28
startTransition,
29
unstable_DebugTracingMode,
30
unstable_getCacheSignal,