@samitouri / QOS-React-2 / commits / 8e2bde6f27

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,