Skip to content

Commit

Permalink
Add cache() API (#25506)
Browse files Browse the repository at this point in the history
Like memo() but longer lived.
  • Loading branch information
sebmarkbage committed Oct 18, 2022
1 parent 9cdf8a9 commit 8e2bde6
Show file tree
Hide file tree
Showing 8 changed files with 356 additions and 60 deletions.
283 changes: 223 additions & 60 deletions packages/react-reconciler/src/__tests__/ReactCache-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@ let React;
let ReactNoop;
let Cache;
let getCacheSignal;
let getCacheForType;
let Scheduler;
let act;
let Suspense;
let Offscreen;
let useCacheRefresh;
let startTransition;
let useState;
let cache;

let caches;
let getTextCache;
let textCaches;
let seededCache;

describe('ReactCache', () => {
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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 <AsyncText showVersion={true} text="A" />;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1624,4 +1639,152 @@ describe('ReactCache', () => {
expect(Scheduler).toHaveYielded(['More']);
expect(root).toMatchRenderedOutput(<div hidden={true}>More</div>);
});

// @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(
<>
<Print a="e" b="f" />
<Same a="a" b="b" />
<FlippedOrder a="c" b="d" />
<FewerArgs a="e" b="f" />
<MoreArgs a="g" b="h" />
</>,
);
});
expect(root).toMatchRenderedOutput('string string true false false false ');
await act(async () => {
root.render(
<>
<Print a="e" b={null} />
<Same a="a" b={null} />
<FlippedOrder a="c" b={null} />
<FewerArgs a="e" b={null} />
<MoreArgs a="g" b={null} />
</>,
);
});
expect(root).toMatchRenderedOutput('string object true false false false ');
const obj = {};
await act(async () => {
root.render(
<>
<Print a="e" b={obj} />
<Same a="a" b={obj} />
<FlippedOrder a="c" b={obj} />
<FewerArgs a="e" b={obj} />
<MoreArgs a="g" b={obj} />
</>,
);
});
expect(root).toMatchRenderedOutput('string object true false false false ');
const sameObj = {};
await act(async () => {
root.render(
<>
<Print a={sameObj} b={sameObj} />
<Same a={sameObj} b={sameObj} />
<FlippedOrder a={sameObj} b={sameObj} />
<FewerArgs a={sameObj} b={sameObj} />
<MoreArgs a={sameObj} b={sameObj} />
</>,
);
});
expect(root).toMatchRenderedOutput('object object true true false false ');
const objA = {};
const objB = {};
await act(async () => {
root.render(
<>
<Print a={objA} b={objB} />
<Same a={objA} b={objB} />
<FlippedOrder a={objA} b={objB} />
<FewerArgs a={objA} b={objB} />
<MoreArgs a={objA} b={objB} />
</>,
);
});
expect(root).toMatchRenderedOutput('object object true false false false ');
const sameSymbol = Symbol();
await act(async () => {
root.render(
<>
<Print a={sameSymbol} b={sameSymbol} />
<Same a={sameSymbol} b={sameSymbol} />
<FlippedOrder a={sameSymbol} b={sameSymbol} />
<FewerArgs a={sameSymbol} b={sameSymbol} />
<MoreArgs a={sameSymbol} b={sameSymbol} />
</>,
);
});
expect(root).toMatchRenderedOutput('symbol symbol true true false false ');
const notANumber = +'nan';
await act(async () => {
root.render(
<>
<Print a={1} b={notANumber} />
<Same a={1} b={notANumber} />
<FlippedOrder a={1} b={notANumber} />
<FewerArgs a={1} b={notANumber} />
<MoreArgs a={1} b={notANumber} />
</>,
);
});
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(<Test />);
});
expect(x).toBe(y);
expect(z).not.toBe(x);
});
});
1 change: 1 addition & 0 deletions packages/react/index.classic.fb.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/react/index.experimental.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export {
isValidElement,
lazy,
memo,
experimental_cache,
startTransition,
unstable_Cache,
unstable_DebugTracingMode,
Expand Down
1 change: 1 addition & 0 deletions packages/react/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export {
isValidElement,
lazy,
memo,
experimental_cache,
startTransition,
unstable_Cache,
unstable_DebugTracingMode,
Expand Down
1 change: 1 addition & 0 deletions packages/react/index.modern.fb.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 8e2bde6

Please sign in to comment.