diff --git a/packages/react-reconciler/src/__tests__/ReactCache-test.js b/packages/react-reconciler/src/__tests__/ReactCache-test.js index 970bf940f27cd..6bf494b1e0ad9 100644 --- a/packages/react-reconciler/src/__tests__/ReactCache-test.js +++ b/packages/react-reconciler/src/__tests__/ReactCache-test.js @@ -2,7 +2,6 @@ let React; let ReactNoop; let Cache; let getCacheSignal; -let getCacheForType; let Scheduler; let act; let Suspense; @@ -10,8 +9,10 @@ let Offscreen; let useCacheRefresh; let startTransition; let useState; +let cache; -let caches; +let getTextCache; +let textCaches; let seededCache; describe('ReactCache', () => { @@ -24,66 +25,68 @@ describe('ReactCache', () => { Scheduler = require('scheduler'); act = require('jest-react').act; Suspense = React.Suspense; + cache = React.experimental_cache; Offscreen = React.unstable_Offscreen; getCacheSignal = React.unstable_getCacheSignal; - getCacheForType = React.unstable_getCacheForType; useCacheRefresh = React.unstable_useCacheRefresh; startTransition = React.startTransition; useState = React.useState; - caches = []; + textCaches = []; seededCache = null; - }); - - function createTextCache() { - if (seededCache !== null) { - // Trick to seed a cache before it exists. - // TODO: Need a built-in API to seed data before the initial render (i.e. - // not a refresh because nothing has mounted yet). - const cache = seededCache; - seededCache = null; - return cache; - } - const data = new Map(); - const version = caches.length + 1; - const cache = { - version, - data, - resolve(text) { - const record = data.get(text); - if (record === undefined) { - const newRecord = { - status: 'resolved', - value: text, - cleanupScheduled: false, - }; - data.set(text, newRecord); - } else if (record.status === 'pending') { - record.value.resolve(); + if (gate(flags => flags.enableCache)) { + getTextCache = cache(() => { + if (seededCache !== null) { + // Trick to seed a cache before it exists. + // TODO: Need a built-in API to seed data before the initial render (i.e. + // not a refresh because nothing has mounted yet). + const textCache = seededCache; + seededCache = null; + return textCache; } - }, - reject(text, error) { - const record = data.get(text); - if (record === undefined) { - const newRecord = { - status: 'rejected', - value: error, - cleanupScheduled: false, - }; - data.set(text, newRecord); - } else if (record.status === 'pending') { - record.value.reject(); - } - }, - }; - caches.push(cache); - return cache; - } + + const data = new Map(); + const version = textCaches.length + 1; + const textCache = { + version, + data, + resolve(text) { + const record = data.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + cleanupScheduled: false, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + record.value.resolve(); + } + }, + reject(text, error) { + const record = data.get(text); + if (record === undefined) { + const newRecord = { + status: 'rejected', + value: error, + cleanupScheduled: false, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + record.value.reject(); + } + }, + }; + textCaches.push(textCache); + return textCache; + }); + } + }); function readText(text) { const signal = getCacheSignal(); - const textCache = getCacheForType(createTextCache); + const textCache = getTextCache(); const record = textCache.data.get(text); if (record !== undefined) { if (!record.cleanupScheduled) { @@ -160,18 +163,18 @@ describe('ReactCache', () => { function seedNextTextCache(text) { if (seededCache === null) { - seededCache = createTextCache(); + seededCache = getTextCache(); } seededCache.resolve(text); } function resolveMostRecentTextCache(text) { - if (caches.length === 0) { + if (textCaches.length === 0) { throw Error('Cache does not exist.'); } else { // Resolve the most recently created cache. An older cache can by - // resolved with `caches[index].resolve(text)`. - caches[caches.length - 1].resolve(text); + // resolved with `textCaches[index].resolve(text)`. + textCaches[textCaches.length - 1].resolve(text); } } @@ -815,9 +818,18 @@ describe('ReactCache', () => { // @gate experimental || www test('refresh a cache with seed data', async () => { - let refresh; + let refreshWithSeed; function App() { - refresh = useCacheRefresh(); + const refresh = useCacheRefresh(); + const [seed, setSeed] = useState({fn: null}); + if (seed.fn) { + seed.fn(); + seed.fn = null; + } + refreshWithSeed = fn => { + setSeed({fn}); + refresh(); + }; return ; } @@ -845,11 +857,14 @@ describe('ReactCache', () => { await act(async () => { // Refresh the cache with seeded data, like you would receive from a // server mutation. - // TODO: Seeding multiple typed caches. Should work by calling `refresh` + // TODO: Seeding multiple typed textCaches. Should work by calling `refresh` // multiple times with different key/value pairs - const cache = createTextCache(); - cache.resolve('A'); - startTransition(() => refresh(createTextCache, cache)); + startTransition(() => + refreshWithSeed(() => { + const textCache = getTextCache(); + textCache.resolve('A'); + }), + ); }); // The root should re-render without a cache miss. // The cache is not cleared up yet, since it's still reference by the root @@ -1624,4 +1639,152 @@ describe('ReactCache', () => { expect(Scheduler).toHaveYielded(['More']); expect(root).toMatchRenderedOutput(); }); + + // @gate enableCache + it('cache objects and primitive arguments and a mix of them', async () => { + const root = ReactNoop.createRoot(); + const types = cache((a, b) => ({a: typeof a, b: typeof b})); + function Print({a, b}) { + return types(a, b).a + ' ' + types(a, b).b + ' '; + } + function Same({a, b}) { + const x = types(a, b); + const y = types(a, b); + return (x === y).toString() + ' '; + } + function FlippedOrder({a, b}) { + return (types(a, b) === types(b, a)).toString() + ' '; + } + function FewerArgs({a, b}) { + return (types(a, b) === types(a)).toString() + ' '; + } + function MoreArgs({a, b}) { + return (types(a) === types(a, b)).toString() + ' '; + } + await act(async () => { + root.render( + <> + + + + + + , + ); + }); + expect(root).toMatchRenderedOutput('string string true false false false '); + await act(async () => { + root.render( + <> + + + + + + , + ); + }); + expect(root).toMatchRenderedOutput('string object true false false false '); + const obj = {}; + await act(async () => { + root.render( + <> + + + + + + , + ); + }); + expect(root).toMatchRenderedOutput('string object true false false false '); + const sameObj = {}; + await act(async () => { + root.render( + <> + + + + + + , + ); + }); + expect(root).toMatchRenderedOutput('object object true true false false '); + const objA = {}; + const objB = {}; + await act(async () => { + root.render( + <> + + + + + + , + ); + }); + expect(root).toMatchRenderedOutput('object object true false false false '); + const sameSymbol = Symbol(); + await act(async () => { + root.render( + <> + + + + + + , + ); + }); + expect(root).toMatchRenderedOutput('symbol symbol true true false false '); + const notANumber = +'nan'; + await act(async () => { + root.render( + <> + + + + + + , + ); + }); + expect(root).toMatchRenderedOutput('number number true false false false '); + }); + + // @gate enableCache + it('cached functions that throw should cache the error', async () => { + const root = ReactNoop.createRoot(); + const throws = cache(v => { + throw new Error(v); + }); + let x; + let y; + let z; + function Test() { + try { + throws(1); + } catch (e) { + x = e; + } + try { + throws(1); + } catch (e) { + y = e; + } + try { + throws(2); + } catch (e) { + z = e; + } + + return 'Blank'; + } + await act(async () => { + root.render(); + }); + expect(x).toBe(y); + expect(z).not.toBe(x); + }); }); diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index b18fc3d7ad30f..7b62dda775c67 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -32,6 +32,7 @@ export { isValidElement, lazy, memo, + experimental_cache, startTransition, startTransition as unstable_startTransition, // TODO: Remove once call sights updated to startTransition unstable_Cache, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 2d36e38836144..095e0898ffb52 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -29,6 +29,7 @@ export { isValidElement, lazy, memo, + experimental_cache, startTransition, unstable_Cache, unstable_DebugTracingMode, diff --git a/packages/react/index.js b/packages/react/index.js index 9db87fa8921ab..25c4ba1b1904f 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -54,6 +54,7 @@ export { isValidElement, lazy, memo, + experimental_cache, startTransition, unstable_Cache, unstable_DebugTracingMode, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index ad440565309f8..11f6ba663a76a 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -31,6 +31,7 @@ export { isValidElement, lazy, memo, + experimental_cache, startTransition, startTransition as unstable_startTransition, // TODO: Remove once call sights updated to startTransition unstable_Cache, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 5edcd9e83049b..df84fbeaebf44 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -35,6 +35,7 @@ import {createContext} from './ReactContext'; import {lazy} from './ReactLazy'; import {forwardRef} from './ReactForwardRef'; import {memo} from './ReactMemo'; +import {cache} from './ReactCache'; import { getCacheSignal, getCacheForType, @@ -100,6 +101,7 @@ export { forwardRef, lazy, memo, + cache as experimental_cache, useCallback, useContext, useEffect, diff --git a/packages/react/src/ReactCache.js b/packages/react/src/ReactCache.js new file mode 100644 index 0000000000000..b308ad9e16d24 --- /dev/null +++ b/packages/react/src/ReactCache.js @@ -0,0 +1,126 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import ReactCurrentCache from './ReactCurrentCache'; + +const UNTERMINATED = 0; +const TERMINATED = 1; +const ERRORED = 2; + +type UnterminatedCacheNode = { + s: 0, + v: void, + o: null | WeakMap>, + p: null | Map>, +}; + +type TerminatedCacheNode = { + s: 1, + v: T, + o: null | WeakMap>, + p: null | Map>, +}; + +type ErroredCacheNode = { + s: 2, + v: mixed, + o: null | WeakMap>, + p: null | Map>, +}; + +type CacheNode = + | TerminatedCacheNode + | UnterminatedCacheNode + | ErroredCacheNode; + +function createCacheRoot(): WeakMap> { + return new WeakMap(); +} + +function createCacheNode(): CacheNode { + return { + s: UNTERMINATED, // status, represents whether the cached computation returned a value or threw an error + v: undefined, // value, either the cached result or an error, depending on s + o: null, // object cache, a WeakMap where non-primitive arguments are stored + p: null, // primitive cache, a regular Map where primitive arguments are stored. + }; +} + +export function cache, T>(fn: (...A) => T): (...A) => T { + return function() { + const dispatcher = ReactCurrentCache.current; + if (!dispatcher) { + // If there is no dispatcher, then we treat this as not being cached. + // $FlowFixMe: We don't want to use rest arguments since we transpile the code. + return fn.apply(null, arguments); + } + const fnMap = dispatcher.getCacheForType(createCacheRoot); + const fnNode = fnMap.get(fn); + let cacheNode: CacheNode; + if (fnNode === undefined) { + cacheNode = createCacheNode(); + fnMap.set(fn, cacheNode); + } else { + cacheNode = fnNode; + } + for (let i = 0, l = arguments.length; i < l; i++) { + const arg = arguments[i]; + if ( + typeof arg === 'function' || + (typeof arg === 'object' && arg !== null) + ) { + // Objects go into a WeakMap + let objectCache = cacheNode.o; + if (objectCache === null) { + cacheNode.o = objectCache = new WeakMap(); + } + const objectNode = objectCache.get(arg); + if (objectNode === undefined) { + cacheNode = createCacheNode(); + objectCache.set(arg, cacheNode); + } else { + cacheNode = objectNode; + } + } else { + // Primitives go into a regular Map + let primitiveCache = cacheNode.p; + if (primitiveCache === null) { + cacheNode.p = primitiveCache = new Map(); + } + const primitiveNode = primitiveCache.get(arg); + if (primitiveNode === undefined) { + cacheNode = createCacheNode(); + primitiveCache.set(arg, cacheNode); + } else { + cacheNode = primitiveNode; + } + } + } + if (cacheNode.s === TERMINATED) { + return cacheNode.v; + } + if (cacheNode.s === ERRORED) { + throw cacheNode.v; + } + try { + // $FlowFixMe: We don't want to use rest arguments since we transpile the code. + const result = fn.apply(null, arguments); + const terminatedNode: TerminatedCacheNode = (cacheNode: any); + terminatedNode.s = TERMINATED; + terminatedNode.v = result; + return result; + } catch (error) { + // We store the first error that's thrown and rethrow it. + const erroredNode: ErroredCacheNode = (cacheNode: any); + erroredNode.s = ERRORED; + erroredNode.v = error; + throw error; + } + }; +} diff --git a/packages/react/src/ReactSharedSubset.experimental.js b/packages/react/src/ReactSharedSubset.experimental.js index b05ec07302ed3..684030d6daa0d 100644 --- a/packages/react/src/ReactSharedSubset.experimental.js +++ b/packages/react/src/ReactSharedSubset.experimental.js @@ -24,6 +24,7 @@ export { isValidElement, lazy, memo, + experimental_cache, startTransition, unstable_DebugTracingMode, unstable_getCacheSignal,