diff --git a/packages/react-reconciler/src/__tests__/ReactCache-test.js b/packages/react-reconciler/src/__tests__/ReactCache-test.js
index dd4a7c5d5fd..b32479b70bb 100644
--- a/packages/react-reconciler/src/__tests__/ReactCache-test.js
+++ b/packages/react-reconciler/src/__tests__/ReactCache-test.js
@@ -1,1616 +1,34 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @emails react-core
+ * @jest-environment node
+ */
+
+'use strict';
+
let React;
-let ReactNoop;
-let Cache;
-let getCacheSignal;
-let Scheduler;
-let assertLog;
-let act;
-let Suspense;
-let Activity;
-let useCacheRefresh;
-let startTransition;
-let useState;
+let ReactNoopFlightServer;
+let ReactNoopFlightClient;
let cache;
-let getTextCache;
-let textCaches;
-let seededCache;
-
describe('ReactCache', () => {
beforeEach(() => {
jest.resetModules();
-
+ jest.mock('react', () => require('react/react.react-server'));
React = require('react');
- ReactNoop = require('react-noop-renderer');
- Cache = React.unstable_Cache;
- Scheduler = require('scheduler');
- act = require('internal-test-utils').act;
- Suspense = React.Suspense;
- cache = React.cache;
- Activity = React.unstable_Activity;
- getCacheSignal = React.unstable_getCacheSignal;
- useCacheRefresh = React.unstable_useCacheRefresh;
- startTransition = React.startTransition;
- useState = React.useState;
-
- const InternalTestUtils = require('internal-test-utils');
- assertLog = InternalTestUtils.assertLog;
-
- textCaches = [];
- seededCache = null;
-
- 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;
- }
-
- 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 ? getCacheSignal() : null;
- const textCache = getTextCache();
- const record = textCache.data.get(text);
- if (record !== undefined) {
- if (!record.cleanupScheduled) {
- // This record was seeded prior to the abort signal being available:
- // schedule a cleanup function for it.
- // TODO: Add ability to cleanup entries seeded w useCacheRefresh()
- record.cleanupScheduled = true;
- if (getCacheSignal) {
- signal.addEventListener('abort', () => {
- Scheduler.log(`Cache cleanup: ${text} [v${textCache.version}]`);
- });
- }
- }
- switch (record.status) {
- case 'pending':
- throw record.value;
- case 'rejected':
- throw record.value;
- case 'resolved':
- return textCache.version;
- }
- } else {
- Scheduler.log(`Cache miss! [${text}]`);
-
- let resolve;
- let reject;
- const thenable = new Promise((res, rej) => {
- resolve = res;
- reject = rej;
- }).then(
- value => {
- if (newRecord.status === 'pending') {
- newRecord.status = 'resolved';
- newRecord.value = value;
- }
- },
- error => {
- if (newRecord.status === 'pending') {
- newRecord.status = 'rejected';
- newRecord.value = error;
- }
- },
- );
- thenable.resolve = resolve;
- thenable.reject = reject;
-
- const newRecord = {
- status: 'pending',
- value: thenable,
- cleanupScheduled: true,
- };
- textCache.data.set(text, newRecord);
-
- if (getCacheSignal) {
- signal.addEventListener('abort', () => {
- Scheduler.log(`Cache cleanup: ${text} [v${textCache.version}]`);
- });
- }
- throw thenable;
- }
- }
-
- function Text({text}) {
- Scheduler.log(text);
- return text;
- }
-
- function AsyncText({text, showVersion}) {
- const version = readText(text);
- const fullText = showVersion ? `${text} [v${version}]` : text;
- Scheduler.log(fullText);
- return fullText;
- }
-
- function seedNextTextCache(text) {
- if (seededCache === null) {
- seededCache = getTextCache();
- }
- seededCache.resolve(text);
- }
-
- function resolveMostRecentTextCache(text) {
- if (textCaches.length === 0) {
- throw Error('Cache does not exist.');
- } else {
- // Resolve the most recently created cache. An older cache can by
- // resolved with `textCaches[index].resolve(text)`.
- textCaches[textCaches.length - 1].resolve(text);
- }
- }
-
- // @gate enableCacheElement && enableCache
- test('render Cache component', async () => {
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render(Hi);
- });
- expect(root).toMatchRenderedOutput('Hi');
- });
-
- // @gate enableCacheElement && enableCache
- test('mount new data', async () => {
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render(
-
- }>
-
-
- ,
- );
- });
- assertLog(['Cache miss! [A]', 'Loading...']);
- expect(root).toMatchRenderedOutput('Loading...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A']);
- expect(root).toMatchRenderedOutput('A');
-
- await act(() => {
- root.render('Bye');
- });
- // no cleanup: cache is still retained at the root
- assertLog([]);
- expect(root).toMatchRenderedOutput('Bye');
- });
-
- // @gate enableCache
- test('root acts as implicit cache boundary', async () => {
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render(
- }>
-
- ,
- );
- });
- assertLog(['Cache miss! [A]', 'Loading...']);
- expect(root).toMatchRenderedOutput('Loading...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A']);
- expect(root).toMatchRenderedOutput('A');
-
- await act(() => {
- root.render('Bye');
- });
- // no cleanup: cache is still retained at the root
- assertLog([]);
- expect(root).toMatchRenderedOutput('Bye');
- });
-
- // @gate enableCacheElement && enableCache
- test('multiple new Cache boundaries in the same mount share the same, fresh root cache', async () => {
- function App() {
- return (
- <>
-
- }>
-
-
-
-
- }>
-
-
-
- >
- );
- }
-
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render();
- });
-
- // Even though there are two new trees, they should share the same
- // data cache. So there should be only a single cache miss for A.
- assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
- expect(root).toMatchRenderedOutput('Loading...Loading...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A', 'A']);
- expect(root).toMatchRenderedOutput('AA');
-
- await act(() => {
- root.render('Bye');
- });
- // no cleanup: cache is still retained at the root
- assertLog([]);
- expect(root).toMatchRenderedOutput('Bye');
- });
-
- // @gate enableCacheElement && enableCache
- test('multiple new Cache boundaries in the same update share the same, fresh cache', async () => {
- function App({showMore}) {
- return showMore ? (
- <>
-
- }>
-
-
-
-
- }>
-
-
-
- >
- ) : (
- '(empty)'
- );
- }
-
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render();
- });
- assertLog([]);
- expect(root).toMatchRenderedOutput('(empty)');
-
- await act(() => {
- root.render();
- });
- // Even though there are two new trees, they should share the same
- // data cache. So there should be only a single cache miss for A.
- assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
- expect(root).toMatchRenderedOutput('Loading...Loading...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A', 'A']);
- expect(root).toMatchRenderedOutput('AA');
-
- await act(() => {
- root.render('Bye');
- });
- // cleanup occurs for the cache shared by the inner cache boundaries (which
- // are not shared w the root because they were added in an update)
- // note that no cache is created for the root since the cache is never accessed
- assertLog(['Cache cleanup: A [v1]']);
- expect(root).toMatchRenderedOutput('Bye');
- });
-
- // @gate enableCacheElement && enableCache
- test(
- 'nested cache boundaries share the same cache as the root during ' +
- 'the initial render',
- async () => {
- function App() {
- return (
- }>
-
-
-
-
-
- );
- }
-
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render();
- });
- // Even though there is a nested boundary, it should share the same
- // data cache as the root. So there should be only a single cache miss for A.
- assertLog(['Cache miss! [A]', 'Loading...']);
- expect(root).toMatchRenderedOutput('Loading...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A', 'A']);
- expect(root).toMatchRenderedOutput('AA');
-
- await act(() => {
- root.render('Bye');
- });
- // no cleanup: cache is still retained at the root
- assertLog([]);
- expect(root).toMatchRenderedOutput('Bye');
- },
- );
-
- // @gate enableCacheElement && enableCache
- test('new content inside an existing Cache boundary should re-use already cached data', async () => {
- function App({showMore}) {
- return (
-
- }>
-
-
- {showMore ? (
- }>
-
-
- ) : null}
-
- );
- }
-
- const root = ReactNoop.createRoot();
- await act(() => {
- seedNextTextCache('A');
- root.render();
- });
- assertLog(['A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- // Add a new cache boundary
- await act(() => {
- root.render();
- });
- assertLog([
- 'A [v1]',
- // New tree should use already cached data
- 'A [v1]',
- ]);
- expect(root).toMatchRenderedOutput('A [v1]A [v1]');
-
- await act(() => {
- root.render('Bye');
- });
- // no cleanup: cache is still retained at the root
- assertLog([]);
- expect(root).toMatchRenderedOutput('Bye');
- });
-
- // @gate enableCacheElement && enableCache
- test('a new Cache boundary uses fresh cache', async () => {
- // The only difference from the previous test is that the "Show More"
- // content is wrapped in a nested boundary
- function App({showMore}) {
- return (
-
- }>
-
-
- {showMore ? (
-
- }>
-
-
-
- ) : null}
-
- );
- }
-
- const root = ReactNoop.createRoot();
- await act(() => {
- seedNextTextCache('A');
- root.render();
- });
- assertLog(['A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- // Add a new cache boundary
- await act(() => {
- root.render();
- });
- assertLog([
- 'A [v1]',
- // New tree should load fresh data.
- 'Cache miss! [A]',
- 'Loading...',
- ]);
- expect(root).toMatchRenderedOutput('A [v1]Loading...');
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A [v2]']);
- expect(root).toMatchRenderedOutput('A [v1]A [v2]');
-
- // Replace all the children: this should retain the root Cache instance,
- // but cleanup the separate cache instance created for the fresh cache
- // boundary
- await act(() => {
- root.render('Bye!');
- });
- // Cleanup occurs for the *second* cache instance: the first is still
- // referenced by the root
- assertLog(['Cache cleanup: A [v2]']);
- expect(root).toMatchRenderedOutput('Bye!');
- });
-
- // @gate enableCacheElement && enableCache
- test('inner/outer cache boundaries uses the same cache instance on initial render', async () => {
- const root = ReactNoop.createRoot();
-
- function App() {
- return (
-
- }>
- {/* The shell reads A */}
-
- {/* The inner content reads both A and B */}
- }>
-
-
-
-
-
-
-
- );
- }
-
- function Shell({children}) {
- readText('A');
- return (
- <>
-
-
-
- {children}
- >
- );
- }
-
- function Content() {
- readText('A');
- readText('B');
- return ;
- }
-
- await act(() => {
- root.render();
- });
- assertLog(['Cache miss! [A]', 'Loading shell...']);
- expect(root).toMatchRenderedOutput('Loading shell...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog([
- 'Shell',
- // There's a cache miss for B, because it hasn't been read yet. But not
- // A, because it was cached when we rendered the shell.
- 'Cache miss! [B]',
- 'Loading content...',
- ]);
- expect(root).toMatchRenderedOutput(
- <>
- Shell
- Loading content...
- >,
- );
-
- await act(() => {
- resolveMostRecentTextCache('B');
- });
- assertLog(['Content']);
- expect(root).toMatchRenderedOutput(
- <>
- Shell
- Content
- >,
- );
-
- await act(() => {
- root.render('Bye');
- });
- // no cleanup: cache is still retained at the root
- assertLog([]);
- expect(root).toMatchRenderedOutput('Bye');
- });
-
- // @gate enableCacheElement && enableCache
- test('inner/ outer cache boundaries added in the same update use the same cache instance', async () => {
- const root = ReactNoop.createRoot();
-
- function App({showMore}) {
- return showMore ? (
-
- }>
- {/* The shell reads A */}
-
- {/* The inner content reads both A and B */}
- }>
-
-
-
-
-
-
-
- ) : (
- '(empty)'
- );
- }
-
- function Shell({children}) {
- readText('A');
- return (
- <>
-
-
-
- {children}
- >
- );
- }
-
- function Content() {
- readText('A');
- readText('B');
- return ;
- }
-
- await act(() => {
- root.render();
- });
- assertLog([]);
- expect(root).toMatchRenderedOutput('(empty)');
-
- await act(() => {
- root.render();
- });
- assertLog(['Cache miss! [A]', 'Loading shell...']);
- expect(root).toMatchRenderedOutput('Loading shell...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog([
- 'Shell',
- // There's a cache miss for B, because it hasn't been read yet. But not
- // A, because it was cached when we rendered the shell.
- 'Cache miss! [B]',
- 'Loading content...',
- ]);
- expect(root).toMatchRenderedOutput(
- <>
- Shell
- Loading content...
- >,
- );
-
- await act(() => {
- resolveMostRecentTextCache('B');
- });
- assertLog(['Content']);
- expect(root).toMatchRenderedOutput(
- <>
- Shell
- Content
- >,
- );
-
- await act(() => {
- root.render('Bye');
- });
- assertLog(['Cache cleanup: A [v1]', 'Cache cleanup: B [v1]']);
- expect(root).toMatchRenderedOutput('Bye');
- });
-
- // @gate enableCache
- test('refresh a cache boundary', async () => {
- let refresh;
- function App() {
- refresh = useCacheRefresh();
- return ;
- }
-
- // Mount initial data
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render(
- }>
-
- ,
- );
- });
- assertLog(['Cache miss! [A]', 'Loading...']);
- expect(root).toMatchRenderedOutput('Loading...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- // Refresh for new data.
- await act(() => {
- startTransition(() => refresh());
- });
- assertLog(['Cache miss! [A]', 'Loading...']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- // Note that the version has updated
- if (getCacheSignal) {
- assertLog(['A [v2]', 'Cache cleanup: A [v1]']);
- } else {
- assertLog(['A [v2]']);
- }
- expect(root).toMatchRenderedOutput('A [v2]');
-
- await act(() => {
- root.render('Bye');
- });
- expect(root).toMatchRenderedOutput('Bye');
- });
-
- // @gate enableCacheElement && enableCache
- test('refresh the root cache', async () => {
- let refresh;
- function App() {
- refresh = useCacheRefresh();
- return ;
- }
-
- // Mount initial data
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render(
- }>
-
- ,
- );
- });
- assertLog(['Cache miss! [A]', 'Loading...']);
- expect(root).toMatchRenderedOutput('Loading...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- // Refresh for new data.
- await act(() => {
- startTransition(() => refresh());
- });
- assertLog(['Cache miss! [A]', 'Loading...']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- // Note that the version has updated, and the previous cache is cleared
- assertLog(['A [v2]', 'Cache cleanup: A [v1]']);
- expect(root).toMatchRenderedOutput('A [v2]');
-
- await act(() => {
- root.render('Bye');
- });
- // the original root cache already cleaned up when the refresh completed
- assertLog([]);
- expect(root).toMatchRenderedOutput('Bye');
- });
-
- // @gate enableCacheElement && enableCache
- test('refresh the root cache without a transition', async () => {
- let refresh;
- function App() {
- refresh = useCacheRefresh();
- return ;
- }
-
- // Mount initial data
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render(
- }>
-
- ,
- );
- });
- assertLog(['Cache miss! [A]', 'Loading...']);
- expect(root).toMatchRenderedOutput('Loading...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- // Refresh for new data.
- await act(() => {
- refresh();
- });
- assertLog([
- 'Cache miss! [A]',
- 'Loading...',
- // The v1 cache can be cleaned up since everything that references it has
- // been replaced by a fallback. When the boundary switches back to visible
- // it will use the v2 cache.
- 'Cache cleanup: A [v1]',
- ]);
- expect(root).toMatchRenderedOutput('Loading...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- // Note that the version has updated, and the previous cache is cleared
- assertLog(['A [v2]']);
- expect(root).toMatchRenderedOutput('A [v2]');
-
- await act(() => {
- root.render('Bye');
- });
- // the original root cache already cleaned up when the refresh completed
- assertLog([]);
- expect(root).toMatchRenderedOutput('Bye');
- });
-
- // @gate enableCacheElement && enableCache
- test('refresh a cache with seed data', async () => {
- let refreshWithSeed;
- function App() {
- const refresh = useCacheRefresh();
- const [seed, setSeed] = useState({fn: null});
- if (seed.fn) {
- seed.fn();
- seed.fn = null;
- }
- refreshWithSeed = fn => {
- setSeed({fn});
- refresh();
- };
- return ;
- }
-
- // Mount initial data
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render(
-
- }>
-
-
- ,
- );
- });
- assertLog(['Cache miss! [A]', 'Loading...']);
- expect(root).toMatchRenderedOutput('Loading...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- // Refresh for new data.
- await act(() => {
- // Refresh the cache with seeded data, like you would receive from a
- // server mutation.
- // TODO: Seeding multiple typed textCaches. Should work by calling `refresh`
- // multiple times with different key/value pairs
- 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
- assertLog(['A [v2]']);
- expect(root).toMatchRenderedOutput('A [v2]');
-
- await act(() => {
- root.render('Bye');
- });
- // the refreshed cache boundary is unmounted and cleans up
- assertLog(['Cache cleanup: A [v2]']);
- expect(root).toMatchRenderedOutput('Bye');
- });
-
- // @gate enableCacheElement && enableCache
- test('refreshing a parent cache also refreshes its children', async () => {
- let refreshShell;
- function RefreshShell() {
- refreshShell = useCacheRefresh();
- return null;
- }
-
- function App({showMore}) {
- return (
-
-
- }>
-
-
- {showMore ? (
-
- }>
-
-
-
- ) : null}
-
- );
- }
-
- const root = ReactNoop.createRoot();
- await act(() => {
- seedNextTextCache('A');
- root.render();
- });
- assertLog(['A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- // Add a new cache boundary
- await act(() => {
- seedNextTextCache('A');
- root.render();
- });
- assertLog([
- 'A [v1]',
- // New tree should load fresh data.
- 'A [v2]',
- ]);
- expect(root).toMatchRenderedOutput('A [v1]A [v2]');
-
- // Now refresh the shell. This should also cause the "Show More" contents to
- // refresh, since its cache is nested inside the outer one.
- await act(() => {
- startTransition(() => refreshShell());
- });
- assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
- expect(root).toMatchRenderedOutput('A [v1]A [v2]');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog([
- 'A [v3]',
- 'A [v3]',
- // once the refresh completes the inner showMore boundary frees its previous
- // cache instance, since it is now using the refreshed parent instance.
- 'Cache cleanup: A [v2]',
- ]);
- expect(root).toMatchRenderedOutput('A [v3]A [v3]');
-
- await act(() => {
- root.render('Bye!');
- });
- // Unmounting children releases the refreshed cache instance only; the root
- // still retains the original cache instance used for the first render
- assertLog(['Cache cleanup: A [v3]']);
- expect(root).toMatchRenderedOutput('Bye!');
- });
-
- // @gate enableCacheElement && enableCache
- test(
- 'refreshing a cache boundary does not refresh the other boundaries ' +
- 'that mounted at the same time (i.e. the ones that share the same cache)',
- async () => {
- let refreshFirstBoundary;
- function RefreshFirstBoundary() {
- refreshFirstBoundary = useCacheRefresh();
- return null;
- }
-
- function App({showMore}) {
- return showMore ? (
- <>
-
- }>
-
-
-
-
-
- }>
-
-
-
- >
- ) : null;
- }
-
- // First mount the initial shell without the nested boundaries. This is
- // necessary for this test because we want the two inner boundaries to be
- // treated like sibling providers that happen to share an underlying
- // cache, as opposed to consumers of the root-level cache.
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render();
- });
-
- // Now reveal the boundaries. In a real app this would be a navigation.
- await act(() => {
- root.render();
- });
-
- // Even though there are two new trees, they should share the same
- // data cache. So there should be only a single cache miss for A.
- assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
- expect(root).toMatchRenderedOutput('Loading...Loading...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A [v1]', 'A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]A [v1]');
-
- // Refresh the first boundary. It should not refresh the second boundary,
- // even though they previously shared the same underlying cache.
- await act(async () => {
- await refreshFirstBoundary();
- });
- assertLog(['Cache miss! [A]', 'Loading...']);
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A [v2]']);
- expect(root).toMatchRenderedOutput('A [v2]A [v1]');
-
- // Unmount children: this should clear *both* cache instances:
- // the root doesn't have a cache instance (since it wasn't accessed
- // during the initial render, and all subsequent cache accesses were within
- // a fresh boundary). Therefore this causes cleanup for both the fresh cache
- // instance in the refreshed first boundary and cleanup for the non-refreshed
- // sibling boundary.
- await act(() => {
- root.render('Bye!');
- });
- assertLog(['Cache cleanup: A [v2]', 'Cache cleanup: A [v1]']);
- expect(root).toMatchRenderedOutput('Bye!');
- },
- );
-
- // @gate enableCacheElement && enableCache
- test(
- 'mount a new Cache boundary in a sibling while simultaneously ' +
- 'resolving a Suspense boundary',
- async () => {
- function App({showMore}) {
- return (
- <>
- {showMore ? (
- }>
-
-
-
-
- ) : null}
- }>
-
- {' '}
- {' '}
-
-
-
- >
- );
- }
-
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render();
- });
- assertLog(['Cache miss! [A]', 'Loading...']);
- expect(root).toMatchRenderedOutput('Loading...');
-
- await act(() => {
- // This will resolve the content in the first cache
- resolveMostRecentTextCache('A');
- resolveMostRecentTextCache('B');
- // And mount the second tree, which includes new content
- root.render();
- });
- assertLog([
- // The new tree should use a fresh cache
- 'Cache miss! [A]',
- 'Loading...',
- // The other tree uses the cached responses. This demonstrates that the
- // requests are not dropped.
- 'A [v1]',
- 'B [v1]',
- ]);
- expect(root).toMatchRenderedOutput('Loading... A [v1] B [v1]');
-
- // Now resolve the second tree
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A [v2]']);
- expect(root).toMatchRenderedOutput('A [v2] A [v1] B [v1]');
-
- await act(() => {
- root.render('Bye!');
- });
- // Unmounting children releases both cache boundaries, but the original
- // cache instance (used by second boundary) is still referenced by the root.
- // only the second cache instance is freed.
- assertLog(['Cache cleanup: A [v2]']);
- expect(root).toMatchRenderedOutput('Bye!');
- },
- );
-
- // @gate enableCacheElement && enableCache
- test('cache pool is cleared once transitions that depend on it commit their shell', async () => {
- function Child({text}) {
- return (
-
-
-
- );
- }
-
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render(
- }>(empty),
- );
- });
- assertLog([]);
- expect(root).toMatchRenderedOutput('(empty)');
-
- await act(() => {
- startTransition(() => {
- root.render(
- }>
-
- ,
- );
- });
- });
- assertLog(['Cache miss! [A]', 'Loading...']);
- expect(root).toMatchRenderedOutput('(empty)');
-
- await act(() => {
- startTransition(() => {
- root.render(
- }>
-
-
- ,
- );
- });
- });
- assertLog([
- // No cache miss, because it uses the pooled cache
- 'Loading...',
- ]);
- expect(root).toMatchRenderedOutput('(empty)');
-
- // Resolve the request
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A [v1]', 'A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]A [v1]');
-
- // Now do another transition
- await act(() => {
- startTransition(() => {
- root.render(
- }>
-
-
-
- ,
- );
- });
- });
- assertLog([
- // First two children use the old cache because they already finished
- 'A [v1]',
- 'A [v1]',
- // The new child uses a fresh cache
- 'Cache miss! [A]',
- 'Loading...',
- ]);
- expect(root).toMatchRenderedOutput('A [v1]A [v1]');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A [v1]', 'A [v1]', 'A [v2]']);
- expect(root).toMatchRenderedOutput('A [v1]A [v1]A [v2]');
-
- // Unmount children: the first text cache instance is created only after the root
- // commits, so both fresh cache instances are released by their cache boundaries,
- // cleaning up v1 (used for the first two children which render together) and
- // v2 (used for the third boundary added later).
- await act(() => {
- root.render('Bye!');
- });
- assertLog(['Cache cleanup: A [v1]', 'Cache cleanup: A [v2]']);
- expect(root).toMatchRenderedOutput('Bye!');
- });
-
- // @gate enableCacheElement && enableCache
- test('cache pool is not cleared by arbitrary commits', async () => {
- function App() {
- return (
- <>
-
-
- >
- );
- }
-
- let showMore;
- function ShowMore() {
- const [shouldShow, _showMore] = useState(false);
- showMore = () => _showMore(true);
- return (
- <>
- }>
- {shouldShow ? (
-
-
-
- ) : null}
-
- >
- );
- }
-
- let updateUnrelated;
- function Unrelated() {
- const [count, _updateUnrelated] = useState(0);
- updateUnrelated = _updateUnrelated;
- return ;
- }
-
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render();
- });
- assertLog(['0']);
- expect(root).toMatchRenderedOutput('0');
-
- await act(() => {
- startTransition(() => {
- showMore();
- });
- });
- assertLog(['Cache miss! [A]', 'Loading...']);
- expect(root).toMatchRenderedOutput('0');
-
- await act(() => {
- updateUnrelated(1);
- });
- assertLog([
- '1',
-
- // Happens to re-render the fallback. Doesn't need to, but not relevant
- // to this test.
- 'Loading...',
- ]);
- expect(root).toMatchRenderedOutput('1');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]1');
-
- // Unmount children: the first text cache instance is created only after initial
- // render after calling showMore(). This instance is cleaned up when that boundary
- // is unmounted. Bc root cache instance is never accessed, the inner cache
- // boundary ends up at v1.
- await act(() => {
- root.render('Bye!');
- });
- assertLog(['Cache cleanup: A [v1]']);
- expect(root).toMatchRenderedOutput('Bye!');
- });
-
- // @gate enableCacheElement && enableCache
- test('cache boundary uses a fresh cache when its key changes', async () => {
- const root = ReactNoop.createRoot();
- seedNextTextCache('A');
- await act(() => {
- root.render(
-
-
-
-
- ,
- );
- });
- assertLog(['A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- seedNextTextCache('B');
- await act(() => {
- root.render(
-
-
-
-
- ,
- );
- });
- assertLog(['B [v2]']);
- expect(root).toMatchRenderedOutput('B [v2]');
-
- // Unmount children: the fresh cache instance for B cleans up since the cache boundary
- // is the only owner, while the original cache instance (for A) is still retained by
- // the root.
- await act(() => {
- root.render('Bye!');
- });
- assertLog(['Cache cleanup: B [v2]']);
- expect(root).toMatchRenderedOutput('Bye!');
- });
-
- // @gate enableCacheElement && enableCache
- test('overlapping transitions after an initial mount use the same fresh cache', async () => {
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render(
-
-
-
-
- ,
- );
- });
- assertLog(['Cache miss! [A]']);
- expect(root).toMatchRenderedOutput('Loading...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- // After a mount, subsequent transitions use a fresh cache
- await act(() => {
- startTransition(() => {
- root.render(
-
-
-
-
- ,
- );
- });
- });
- assertLog(['Cache miss! [B]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- // Update to a different text and with a different key for the cache
- // boundary: this should still use the fresh cache instance created
- // for the earlier transition
- await act(() => {
- startTransition(() => {
- root.render(
-
-
-
-
- ,
- );
- });
- });
- assertLog(['Cache miss! [C]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- await act(() => {
- resolveMostRecentTextCache('C');
- });
- assertLog(['C [v2]']);
- expect(root).toMatchRenderedOutput('C [v2]');
-
- // Unmount children: the fresh cache used for the updates is freed, while the
- // original cache (with A) is still retained at the root.
- await act(() => {
- root.render('Bye!');
- });
- assertLog(['Cache cleanup: B [v2]', 'Cache cleanup: C [v2]']);
- expect(root).toMatchRenderedOutput('Bye!');
- });
-
- // @gate enableCacheElement && enableCache
- test('overlapping updates after an initial mount use the same fresh cache', async () => {
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render(
-
-
-
-
- ,
- );
- });
- assertLog(['Cache miss! [A]']);
- expect(root).toMatchRenderedOutput('Loading...');
-
- await act(() => {
- resolveMostRecentTextCache('A');
- });
- assertLog(['A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- // After a mount, subsequent updates use a fresh cache
- await act(() => {
- root.render(
-
-
-
-
- ,
- );
- });
- assertLog(['Cache miss! [B]']);
- expect(root).toMatchRenderedOutput('Loading...');
-
- // A second update uses the same fresh cache: even though this is a new
- // Cache boundary, the render uses the fresh cache from the pending update.
- await act(() => {
- root.render(
-
-
-
-
- ,
- );
- });
- assertLog(['Cache miss! [C]']);
- expect(root).toMatchRenderedOutput('Loading...');
-
- await act(() => {
- resolveMostRecentTextCache('C');
- });
- assertLog(['C [v2]']);
- expect(root).toMatchRenderedOutput('C [v2]');
-
- // Unmount children: the fresh cache used for the updates is freed, while the
- // original cache (with A) is still retained at the root.
- await act(() => {
- root.render('Bye!');
- });
- assertLog(['Cache cleanup: B [v2]', 'Cache cleanup: C [v2]']);
- expect(root).toMatchRenderedOutput('Bye!');
- });
-
- // @gate enableCacheElement && enableCache
- test('cleans up cache only used in an aborted transition', async () => {
- const root = ReactNoop.createRoot();
- seedNextTextCache('A');
- await act(() => {
- root.render(
-
-
-
-
- ,
- );
- });
- assertLog(['A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- // Start a transition from A -> B..., which should create a fresh cache
- // for the new cache boundary (bc of the different key)
- await act(() => {
- startTransition(() => {
- root.render(
-
-
-
-
- ,
- );
- });
- });
- assertLog(['Cache miss! [B]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- // ...but cancel by transitioning "back" to A (which we never really left)
- await act(() => {
- startTransition(() => {
- root.render(
-
-
-
-
- ,
- );
- });
- });
- assertLog(['A [v1]', 'Cache cleanup: B [v2]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- // Unmount children: ...
- await act(() => {
- root.render('Bye!');
- });
- assertLog([]);
- expect(root).toMatchRenderedOutput('Bye!');
- });
-
- // @gate enableCacheElement && enableCache
- test.skip('if a root cache refresh never commits its fresh cache is released', async () => {
- const root = ReactNoop.createRoot();
- let refresh;
- function Example({text}) {
- refresh = useCacheRefresh();
- return ;
- }
- seedNextTextCache('A');
- await act(() => {
- root.render(
-
-
- ,
- );
- });
- assertLog(['A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- await act(() => {
- startTransition(() => {
- refresh();
- });
- });
- assertLog(['Cache miss! [A]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- await act(() => {
- root.render('Bye!');
- });
- assertLog([
- // TODO: the v1 cache should *not* be cleaned up, it is still retained by the root
- // The following line is presently yielded but should not be:
- // 'Cache cleanup: A [v1]',
- // TODO: the v2 cache *should* be cleaned up, it was created for the abandoned refresh
- // The following line is presently not yielded but should be:
- 'Cache cleanup: A [v2]',
- ]);
- expect(root).toMatchRenderedOutput('Bye!');
- });
+ ReactNoopFlightServer = require('react-noop-renderer/flight-server');
+ ReactNoopFlightClient = require('react-noop-renderer/flight-client');
- // @gate enableCacheElement && enableCache
- test.skip('if a cache boundary refresh never commits its fresh cache is released', async () => {
- const root = ReactNoop.createRoot();
- let refresh;
- function Example({text}) {
- refresh = useCacheRefresh();
- return ;
- }
- seedNextTextCache('A');
- await act(() => {
- root.render(
-
-
-
-
- ,
- );
- });
- assertLog(['A [v1]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- await act(() => {
- startTransition(() => {
- refresh();
- });
- });
- assertLog(['Cache miss! [A]']);
- expect(root).toMatchRenderedOutput('A [v1]');
-
- // Unmount the boundary before the refresh can complete
- await act(() => {
- root.render('Bye!');
- });
- assertLog([
- // TODO: the v2 cache *should* be cleaned up, it was created for the abandoned refresh
- // The following line is presently not yielded but should be:
- 'Cache cleanup: A [v2]',
- ]);
- expect(root).toMatchRenderedOutput('Bye!');
- });
-
- // @gate enableActivity
- // @gate enableCache
- test('prerender a new cache boundary inside an Activity tree', async () => {
- function App({prerenderMore}) {
- return (
-
-
- {prerenderMore ? (
-
-
-
- ) : null}
-
-
- );
- }
-
- const root = ReactNoop.createRoot();
- await act(() => {
- root.render();
- });
- assertLog([]);
- expect(root).toMatchRenderedOutput();
-
- seedNextTextCache('More');
- await act(() => {
- root.render();
- });
- assertLog(['More']);
- expect(root).toMatchRenderedOutput(More
);
+ cache = React.cache;
});
// @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 + ' ';
@@ -1629,101 +47,128 @@ describe('ReactCache', () => {
function MoreArgs({a, b}) {
return (types(a) === types(a, b)).toString() + ' ';
}
- await act(() => {
- root.render(
- <>
-
-
-
-
-
- >,
- );
- });
- expect(root).toMatchRenderedOutput('string string true false false false ');
- await act(() => {
- root.render(
- <>
-
-
-
-
-
- >,
- );
- });
- expect(root).toMatchRenderedOutput('string object true false false false ');
+
+ expect(
+ (
+ await ReactNoopFlightClient.read(
+ ReactNoopFlightServer.render(
+ <>
+
+
+
+
+
+ >,
+ ),
+ )
+ ).join(''),
+ ).toEqual('string string true false false false ');
+
+ expect(
+ (
+ await ReactNoopFlightClient.read(
+ ReactNoopFlightServer.render(
+ <>
+
+
+
+
+
+ >,
+ ),
+ )
+ ).join(''),
+ ).toEqual('string object true false false false ');
+
const obj = {};
- await act(() => {
- root.render(
- <>
-
-
-
-
-
- >,
- );
- });
- expect(root).toMatchRenderedOutput('string object true false false false ');
+ expect(
+ (
+ await ReactNoopFlightClient.read(
+ ReactNoopFlightServer.render(
+ <>
+
+
+
+
+
+ >,
+ ),
+ )
+ ).join(''),
+ ).toEqual('string object true false false false ');
+
const sameObj = {};
- await act(() => {
- root.render(
- <>
-
-
-
-
-
- >,
- );
- });
- expect(root).toMatchRenderedOutput('object object true true false false ');
+ expect(
+ (
+ await ReactNoopFlightClient.read(
+ ReactNoopFlightServer.render(
+ <>
+
+
+
+
+
+ >,
+ ),
+ )
+ ).join(''),
+ ).toEqual('object object true true false false ');
+
const objA = {};
const objB = {};
- await act(() => {
- root.render(
- <>
-
-
-
-
-
- >,
- );
- });
- expect(root).toMatchRenderedOutput('object object true false false false ');
+ expect(
+ (
+ await ReactNoopFlightClient.read(
+ ReactNoopFlightServer.render(
+ <>
+
+
+
+
+
+ >,
+ ),
+ )
+ ).join(''),
+ ).toEqual('object object true false false false ');
+
const sameSymbol = Symbol();
- await act(() => {
- root.render(
- <>
-
-
-
-
-
- >,
- );
- });
- expect(root).toMatchRenderedOutput('symbol symbol true true false false ');
+ expect(
+ (
+ await ReactNoopFlightClient.read(
+ ReactNoopFlightServer.render(
+ <>
+
+
+
+
+
+ >,
+ ),
+ )
+ ).join(''),
+ ).toEqual('symbol symbol true true false false ');
+
const notANumber = +'nan';
- await act(() => {
- root.render(
- <>
-
-
-
-
-
- >,
- );
- });
- expect(root).toMatchRenderedOutput('number number true false false false ');
+ expect(
+ (
+ await ReactNoopFlightClient.read(
+ ReactNoopFlightServer.render(
+ <>
+
+
+
+
+
+ >,
+ ),
+ )
+ ).join(''),
+ ).toEqual('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);
});
@@ -1749,10 +194,30 @@ describe('ReactCache', () => {
return 'Blank';
}
- await act(() => {
- root.render();
- });
+
+ ReactNoopFlightServer.render();
expect(x).toBe(y);
expect(z).not.toBe(x);
});
+
+ // @gate enableCache
+ it('introspection of returned wrapper function is same on client and server', async () => {
+ // When the variant flag is true, test the client version of `cache`.
+ if (gate(flags => flags.variant)) {
+ jest.resetModules();
+ jest.mock('react', () => jest.requireActual('react'));
+ const ClientReact = require('react');
+ cache = ClientReact.cache;
+ }
+
+ function foo(a, b, c) {
+ return a + b + c;
+ }
+ foo.displayName = 'Custom display name';
+
+ const cachedFoo = cache(foo);
+ expect(cachedFoo).not.toBe(foo);
+ expect(cachedFoo.length).toBe(0);
+ expect(cachedFoo.displayName).toBe(undefined);
+ });
});
diff --git a/packages/react-reconciler/src/__tests__/ReactCacheElement-test.js b/packages/react-reconciler/src/__tests__/ReactCacheElement-test.js
new file mode 100644
index 00000000000..19d3180af65
--- /dev/null
+++ b/packages/react-reconciler/src/__tests__/ReactCacheElement-test.js
@@ -0,0 +1,1597 @@
+let React;
+let ReactNoop;
+let Cache;
+let getCacheSignal;
+let getCacheForType;
+let Scheduler;
+let assertLog;
+let act;
+let Suspense;
+let Activity;
+let useCacheRefresh;
+let startTransition;
+let useState;
+
+let textCaches;
+let seededCache;
+
+describe('ReactCacheElement', () => {
+ beforeEach(() => {
+ jest.resetModules();
+
+ React = require('react');
+ ReactNoop = require('react-noop-renderer');
+ Cache = React.unstable_Cache;
+ Scheduler = require('scheduler');
+ act = require('internal-test-utils').act;
+ Suspense = React.Suspense;
+ Activity = React.unstable_Activity;
+ getCacheSignal = React.unstable_getCacheSignal;
+ getCacheForType = React.unstable_getCacheForType;
+ useCacheRefresh = React.unstable_useCacheRefresh;
+ startTransition = React.startTransition;
+ useState = React.useState;
+
+ const InternalTestUtils = require('internal-test-utils');
+ assertLog = InternalTestUtils.assertLog;
+
+ 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 textCache = seededCache;
+ seededCache = null;
+ return textCache;
+ }
+
+ 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 ? getCacheSignal() : null;
+ const textCache = getCacheForType(createTextCache);
+ const record = textCache.data.get(text);
+ if (record !== undefined) {
+ if (!record.cleanupScheduled) {
+ // This record was seeded prior to the abort signal being available:
+ // schedule a cleanup function for it.
+ // TODO: Add ability to cleanup entries seeded w useCacheRefresh()
+ record.cleanupScheduled = true;
+ if (getCacheSignal) {
+ signal.addEventListener('abort', () => {
+ Scheduler.log(`Cache cleanup: ${text} [v${textCache.version}]`);
+ });
+ }
+ }
+ switch (record.status) {
+ case 'pending':
+ throw record.value;
+ case 'rejected':
+ throw record.value;
+ case 'resolved':
+ return textCache.version;
+ }
+ } else {
+ Scheduler.log(`Cache miss! [${text}]`);
+
+ let resolve;
+ let reject;
+ const thenable = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ }).then(
+ value => {
+ if (newRecord.status === 'pending') {
+ newRecord.status = 'resolved';
+ newRecord.value = value;
+ }
+ },
+ error => {
+ if (newRecord.status === 'pending') {
+ newRecord.status = 'rejected';
+ newRecord.value = error;
+ }
+ },
+ );
+ thenable.resolve = resolve;
+ thenable.reject = reject;
+
+ const newRecord = {
+ status: 'pending',
+ value: thenable,
+ cleanupScheduled: true,
+ };
+ textCache.data.set(text, newRecord);
+
+ if (getCacheSignal) {
+ signal.addEventListener('abort', () => {
+ Scheduler.log(`Cache cleanup: ${text} [v${textCache.version}]`);
+ });
+ }
+ throw thenable;
+ }
+ }
+
+ function Text({text}) {
+ Scheduler.log(text);
+ return text;
+ }
+
+ function AsyncText({text, showVersion}) {
+ const version = readText(text);
+ const fullText = showVersion ? `${text} [v${version}]` : text;
+ Scheduler.log(fullText);
+ return fullText;
+ }
+
+ function seedNextTextCache(text) {
+ if (seededCache === null) {
+ seededCache = createTextCache();
+ }
+ seededCache.resolve(text);
+ }
+
+ function resolveMostRecentTextCache(text) {
+ if (textCaches.length === 0) {
+ throw Error('Cache does not exist.');
+ } else {
+ // Resolve the most recently created cache. An older cache can by
+ // resolved with `textCaches[index].resolve(text)`.
+ textCaches[textCaches.length - 1].resolve(text);
+ }
+ }
+
+ // @gate enableCacheElement
+ test('render Cache component', async () => {
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render(Hi);
+ });
+ expect(root).toMatchRenderedOutput('Hi');
+ });
+
+ // @gate enableCacheElement
+ test('mount new data', async () => {
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render(
+
+ }>
+
+
+ ,
+ );
+ });
+ assertLog(['Cache miss! [A]', 'Loading...']);
+ expect(root).toMatchRenderedOutput('Loading...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A']);
+ expect(root).toMatchRenderedOutput('A');
+
+ await act(() => {
+ root.render('Bye');
+ });
+ // no cleanup: cache is still retained at the root
+ assertLog([]);
+ expect(root).toMatchRenderedOutput('Bye');
+ });
+
+ // @gate enableCacheElement
+ test('root acts as implicit cache boundary', async () => {
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render(
+ }>
+
+ ,
+ );
+ });
+ assertLog(['Cache miss! [A]', 'Loading...']);
+ expect(root).toMatchRenderedOutput('Loading...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A']);
+ expect(root).toMatchRenderedOutput('A');
+
+ await act(() => {
+ root.render('Bye');
+ });
+ // no cleanup: cache is still retained at the root
+ assertLog([]);
+ expect(root).toMatchRenderedOutput('Bye');
+ });
+
+ // @gate enableCacheElement
+ test('multiple new Cache boundaries in the same mount share the same, fresh root cache', async () => {
+ function App() {
+ return (
+ <>
+
+ }>
+
+
+
+
+ }>
+
+
+
+ >
+ );
+ }
+
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render();
+ });
+
+ // Even though there are two new trees, they should share the same
+ // data cache. So there should be only a single cache miss for A.
+ assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
+ expect(root).toMatchRenderedOutput('Loading...Loading...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A', 'A']);
+ expect(root).toMatchRenderedOutput('AA');
+
+ await act(() => {
+ root.render('Bye');
+ });
+ // no cleanup: cache is still retained at the root
+ assertLog([]);
+ expect(root).toMatchRenderedOutput('Bye');
+ });
+
+ // @gate enableCacheElement
+ test('multiple new Cache boundaries in the same update share the same, fresh cache', async () => {
+ function App({showMore}) {
+ return showMore ? (
+ <>
+
+ }>
+
+
+
+
+ }>
+
+
+
+ >
+ ) : (
+ '(empty)'
+ );
+ }
+
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render();
+ });
+ assertLog([]);
+ expect(root).toMatchRenderedOutput('(empty)');
+
+ await act(() => {
+ root.render();
+ });
+ // Even though there are two new trees, they should share the same
+ // data cache. So there should be only a single cache miss for A.
+ assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
+ expect(root).toMatchRenderedOutput('Loading...Loading...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A', 'A']);
+ expect(root).toMatchRenderedOutput('AA');
+
+ await act(() => {
+ root.render('Bye');
+ });
+ // cleanup occurs for the cache shared by the inner cache boundaries (which
+ // are not shared w the root because they were added in an update)
+ // note that no cache is created for the root since the cache is never accessed
+ assertLog(['Cache cleanup: A [v1]']);
+ expect(root).toMatchRenderedOutput('Bye');
+ });
+
+ // @gate enableCacheElement
+ test(
+ 'nested cache boundaries share the same cache as the root during ' +
+ 'the initial render',
+ async () => {
+ function App() {
+ return (
+ }>
+
+
+
+
+
+ );
+ }
+
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render();
+ });
+ // Even though there is a nested boundary, it should share the same
+ // data cache as the root. So there should be only a single cache miss for A.
+ assertLog(['Cache miss! [A]', 'Loading...']);
+ expect(root).toMatchRenderedOutput('Loading...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A', 'A']);
+ expect(root).toMatchRenderedOutput('AA');
+
+ await act(() => {
+ root.render('Bye');
+ });
+ // no cleanup: cache is still retained at the root
+ assertLog([]);
+ expect(root).toMatchRenderedOutput('Bye');
+ },
+ );
+
+ // @gate enableCacheElement
+ test('new content inside an existing Cache boundary should re-use already cached data', async () => {
+ function App({showMore}) {
+ return (
+
+ }>
+
+
+ {showMore ? (
+ }>
+
+
+ ) : null}
+
+ );
+ }
+
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ seedNextTextCache('A');
+ root.render();
+ });
+ assertLog(['A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ // Add a new cache boundary
+ await act(() => {
+ root.render();
+ });
+ assertLog([
+ 'A [v1]',
+ // New tree should use already cached data
+ 'A [v1]',
+ ]);
+ expect(root).toMatchRenderedOutput('A [v1]A [v1]');
+
+ await act(() => {
+ root.render('Bye');
+ });
+ // no cleanup: cache is still retained at the root
+ assertLog([]);
+ expect(root).toMatchRenderedOutput('Bye');
+ });
+
+ // @gate enableCacheElement
+ test('a new Cache boundary uses fresh cache', async () => {
+ // The only difference from the previous test is that the "Show More"
+ // content is wrapped in a nested boundary
+ function App({showMore}) {
+ return (
+
+ }>
+
+
+ {showMore ? (
+
+ }>
+
+
+
+ ) : null}
+
+ );
+ }
+
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ seedNextTextCache('A');
+ root.render();
+ });
+ assertLog(['A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ // Add a new cache boundary
+ await act(() => {
+ root.render();
+ });
+ assertLog([
+ 'A [v1]',
+ // New tree should load fresh data.
+ 'Cache miss! [A]',
+ 'Loading...',
+ ]);
+ expect(root).toMatchRenderedOutput('A [v1]Loading...');
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A [v2]']);
+ expect(root).toMatchRenderedOutput('A [v1]A [v2]');
+
+ // Replace all the children: this should retain the root Cache instance,
+ // but cleanup the separate cache instance created for the fresh cache
+ // boundary
+ await act(() => {
+ root.render('Bye!');
+ });
+ // Cleanup occurs for the *second* cache instance: the first is still
+ // referenced by the root
+ assertLog(['Cache cleanup: A [v2]']);
+ expect(root).toMatchRenderedOutput('Bye!');
+ });
+
+ // @gate enableCacheElement
+ test('inner/outer cache boundaries uses the same cache instance on initial render', async () => {
+ const root = ReactNoop.createRoot();
+
+ function App() {
+ return (
+
+ }>
+ {/* The shell reads A */}
+
+ {/* The inner content reads both A and B */}
+ }>
+
+
+
+
+
+
+
+ );
+ }
+
+ function Shell({children}) {
+ readText('A');
+ return (
+ <>
+
+
+
+ {children}
+ >
+ );
+ }
+
+ function Content() {
+ readText('A');
+ readText('B');
+ return ;
+ }
+
+ await act(() => {
+ root.render();
+ });
+ assertLog(['Cache miss! [A]', 'Loading shell...']);
+ expect(root).toMatchRenderedOutput('Loading shell...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog([
+ 'Shell',
+ // There's a cache miss for B, because it hasn't been read yet. But not
+ // A, because it was cached when we rendered the shell.
+ 'Cache miss! [B]',
+ 'Loading content...',
+ ]);
+ expect(root).toMatchRenderedOutput(
+ <>
+ Shell
+ Loading content...
+ >,
+ );
+
+ await act(() => {
+ resolveMostRecentTextCache('B');
+ });
+ assertLog(['Content']);
+ expect(root).toMatchRenderedOutput(
+ <>
+ Shell
+ Content
+ >,
+ );
+
+ await act(() => {
+ root.render('Bye');
+ });
+ // no cleanup: cache is still retained at the root
+ assertLog([]);
+ expect(root).toMatchRenderedOutput('Bye');
+ });
+
+ // @gate enableCacheElement
+ test('inner/ outer cache boundaries added in the same update use the same cache instance', async () => {
+ const root = ReactNoop.createRoot();
+
+ function App({showMore}) {
+ return showMore ? (
+
+ }>
+ {/* The shell reads A */}
+
+ {/* The inner content reads both A and B */}
+ }>
+
+
+
+
+
+
+
+ ) : (
+ '(empty)'
+ );
+ }
+
+ function Shell({children}) {
+ readText('A');
+ return (
+ <>
+
+
+
+ {children}
+ >
+ );
+ }
+
+ function Content() {
+ readText('A');
+ readText('B');
+ return ;
+ }
+
+ await act(() => {
+ root.render();
+ });
+ assertLog([]);
+ expect(root).toMatchRenderedOutput('(empty)');
+
+ await act(() => {
+ root.render();
+ });
+ assertLog(['Cache miss! [A]', 'Loading shell...']);
+ expect(root).toMatchRenderedOutput('Loading shell...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog([
+ 'Shell',
+ // There's a cache miss for B, because it hasn't been read yet. But not
+ // A, because it was cached when we rendered the shell.
+ 'Cache miss! [B]',
+ 'Loading content...',
+ ]);
+ expect(root).toMatchRenderedOutput(
+ <>
+ Shell
+ Loading content...
+ >,
+ );
+
+ await act(() => {
+ resolveMostRecentTextCache('B');
+ });
+ assertLog(['Content']);
+ expect(root).toMatchRenderedOutput(
+ <>
+ Shell
+ Content
+ >,
+ );
+
+ await act(() => {
+ root.render('Bye');
+ });
+ assertLog(['Cache cleanup: A [v1]', 'Cache cleanup: B [v1]']);
+ expect(root).toMatchRenderedOutput('Bye');
+ });
+
+ // @gate enableCacheElement
+ test('refresh a cache boundary', async () => {
+ let refresh;
+ function App() {
+ refresh = useCacheRefresh();
+ return ;
+ }
+
+ // Mount initial data
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render(
+ }>
+
+ ,
+ );
+ });
+ assertLog(['Cache miss! [A]', 'Loading...']);
+ expect(root).toMatchRenderedOutput('Loading...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ // Refresh for new data.
+ await act(() => {
+ startTransition(() => refresh());
+ });
+ assertLog(['Cache miss! [A]', 'Loading...']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ // Note that the version has updated
+ if (getCacheSignal) {
+ assertLog(['A [v2]', 'Cache cleanup: A [v1]']);
+ } else {
+ assertLog(['A [v2]']);
+ }
+ expect(root).toMatchRenderedOutput('A [v2]');
+
+ await act(() => {
+ root.render('Bye');
+ });
+ expect(root).toMatchRenderedOutput('Bye');
+ });
+
+ // @gate enableCacheElement
+ test('refresh the root cache', async () => {
+ let refresh;
+ function App() {
+ refresh = useCacheRefresh();
+ return ;
+ }
+
+ // Mount initial data
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render(
+ }>
+
+ ,
+ );
+ });
+ assertLog(['Cache miss! [A]', 'Loading...']);
+ expect(root).toMatchRenderedOutput('Loading...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ // Refresh for new data.
+ await act(() => {
+ startTransition(() => refresh());
+ });
+ assertLog(['Cache miss! [A]', 'Loading...']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ // Note that the version has updated, and the previous cache is cleared
+ assertLog(['A [v2]', 'Cache cleanup: A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v2]');
+
+ await act(() => {
+ root.render('Bye');
+ });
+ // the original root cache already cleaned up when the refresh completed
+ assertLog([]);
+ expect(root).toMatchRenderedOutput('Bye');
+ });
+
+ // @gate enableCacheElement
+ test('refresh the root cache without a transition', async () => {
+ let refresh;
+ function App() {
+ refresh = useCacheRefresh();
+ return ;
+ }
+
+ // Mount initial data
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render(
+ }>
+
+ ,
+ );
+ });
+ assertLog(['Cache miss! [A]', 'Loading...']);
+ expect(root).toMatchRenderedOutput('Loading...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ // Refresh for new data.
+ await act(() => {
+ refresh();
+ });
+ assertLog([
+ 'Cache miss! [A]',
+ 'Loading...',
+ // The v1 cache can be cleaned up since everything that references it has
+ // been replaced by a fallback. When the boundary switches back to visible
+ // it will use the v2 cache.
+ 'Cache cleanup: A [v1]',
+ ]);
+ expect(root).toMatchRenderedOutput('Loading...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ // Note that the version has updated, and the previous cache is cleared
+ assertLog(['A [v2]']);
+ expect(root).toMatchRenderedOutput('A [v2]');
+
+ await act(() => {
+ root.render('Bye');
+ });
+ // the original root cache already cleaned up when the refresh completed
+ assertLog([]);
+ expect(root).toMatchRenderedOutput('Bye');
+ });
+
+ // @gate enableCacheElement
+ test('refresh a cache with seed data', async () => {
+ let refresh;
+ function App() {
+ refresh = useCacheRefresh();
+ return ;
+ }
+
+ // Mount initial data
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render(
+
+ }>
+
+
+ ,
+ );
+ });
+ assertLog(['Cache miss! [A]', 'Loading...']);
+ expect(root).toMatchRenderedOutput('Loading...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ // Refresh for new data.
+ await act(() => {
+ // Refresh the cache with seeded data, like you would receive from a
+ // server mutation.
+ // TODO: Seeding multiple typed textCaches. Should work by calling `refresh`
+ // multiple times with different key/value pairs
+ startTransition(() => {
+ const textCache = createTextCache();
+ textCache.resolve('A');
+ startTransition(() => refresh(createTextCache, textCache));
+ });
+ });
+ // The root should re-render without a cache miss.
+ // The cache is not cleared up yet, since it's still reference by the root
+ assertLog(['A [v2]']);
+ expect(root).toMatchRenderedOutput('A [v2]');
+
+ await act(() => {
+ root.render('Bye');
+ });
+ // the refreshed cache boundary is unmounted and cleans up
+ assertLog(['Cache cleanup: A [v2]']);
+ expect(root).toMatchRenderedOutput('Bye');
+ });
+
+ // @gate enableCacheElement
+ test('refreshing a parent cache also refreshes its children', async () => {
+ let refreshShell;
+ function RefreshShell() {
+ refreshShell = useCacheRefresh();
+ return null;
+ }
+
+ function App({showMore}) {
+ return (
+
+
+ }>
+
+
+ {showMore ? (
+
+ }>
+
+
+
+ ) : null}
+
+ );
+ }
+
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ seedNextTextCache('A');
+ root.render();
+ });
+ assertLog(['A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ // Add a new cache boundary
+ await act(() => {
+ seedNextTextCache('A');
+ root.render();
+ });
+ assertLog([
+ 'A [v1]',
+ // New tree should load fresh data.
+ 'A [v2]',
+ ]);
+ expect(root).toMatchRenderedOutput('A [v1]A [v2]');
+
+ // Now refresh the shell. This should also cause the "Show More" contents to
+ // refresh, since its cache is nested inside the outer one.
+ await act(() => {
+ startTransition(() => refreshShell());
+ });
+ assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
+ expect(root).toMatchRenderedOutput('A [v1]A [v2]');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog([
+ 'A [v3]',
+ 'A [v3]',
+ // once the refresh completes the inner showMore boundary frees its previous
+ // cache instance, since it is now using the refreshed parent instance.
+ 'Cache cleanup: A [v2]',
+ ]);
+ expect(root).toMatchRenderedOutput('A [v3]A [v3]');
+
+ await act(() => {
+ root.render('Bye!');
+ });
+ // Unmounting children releases the refreshed cache instance only; the root
+ // still retains the original cache instance used for the first render
+ assertLog(['Cache cleanup: A [v3]']);
+ expect(root).toMatchRenderedOutput('Bye!');
+ });
+
+ // @gate enableCacheElement
+ test(
+ 'refreshing a cache boundary does not refresh the other boundaries ' +
+ 'that mounted at the same time (i.e. the ones that share the same cache)',
+ async () => {
+ let refreshFirstBoundary;
+ function RefreshFirstBoundary() {
+ refreshFirstBoundary = useCacheRefresh();
+ return null;
+ }
+
+ function App({showMore}) {
+ return showMore ? (
+ <>
+
+ }>
+
+
+
+
+
+ }>
+
+
+
+ >
+ ) : null;
+ }
+
+ // First mount the initial shell without the nested boundaries. This is
+ // necessary for this test because we want the two inner boundaries to be
+ // treated like sibling providers that happen to share an underlying
+ // cache, as opposed to consumers of the root-level cache.
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render();
+ });
+
+ // Now reveal the boundaries. In a real app this would be a navigation.
+ await act(() => {
+ root.render();
+ });
+
+ // Even though there are two new trees, they should share the same
+ // data cache. So there should be only a single cache miss for A.
+ assertLog(['Cache miss! [A]', 'Loading...', 'Loading...']);
+ expect(root).toMatchRenderedOutput('Loading...Loading...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A [v1]', 'A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]A [v1]');
+
+ // Refresh the first boundary. It should not refresh the second boundary,
+ // even though they previously shared the same underlying cache.
+ await act(async () => {
+ await refreshFirstBoundary();
+ });
+ assertLog(['Cache miss! [A]', 'Loading...']);
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A [v2]']);
+ expect(root).toMatchRenderedOutput('A [v2]A [v1]');
+
+ // Unmount children: this should clear *both* cache instances:
+ // the root doesn't have a cache instance (since it wasn't accessed
+ // during the initial render, and all subsequent cache accesses were within
+ // a fresh boundary). Therefore this causes cleanup for both the fresh cache
+ // instance in the refreshed first boundary and cleanup for the non-refreshed
+ // sibling boundary.
+ await act(() => {
+ root.render('Bye!');
+ });
+ assertLog(['Cache cleanup: A [v2]', 'Cache cleanup: A [v1]']);
+ expect(root).toMatchRenderedOutput('Bye!');
+ },
+ );
+
+ // @gate enableCacheElement
+ test(
+ 'mount a new Cache boundary in a sibling while simultaneously ' +
+ 'resolving a Suspense boundary',
+ async () => {
+ function App({showMore}) {
+ return (
+ <>
+ {showMore ? (
+ }>
+
+
+
+
+ ) : null}
+ }>
+
+ {' '}
+ {' '}
+
+
+
+ >
+ );
+ }
+
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render();
+ });
+ assertLog(['Cache miss! [A]', 'Loading...']);
+ expect(root).toMatchRenderedOutput('Loading...');
+
+ await act(() => {
+ // This will resolve the content in the first cache
+ resolveMostRecentTextCache('A');
+ resolveMostRecentTextCache('B');
+ // And mount the second tree, which includes new content
+ root.render();
+ });
+ assertLog([
+ // The new tree should use a fresh cache
+ 'Cache miss! [A]',
+ 'Loading...',
+ // The other tree uses the cached responses. This demonstrates that the
+ // requests are not dropped.
+ 'A [v1]',
+ 'B [v1]',
+ ]);
+ expect(root).toMatchRenderedOutput('Loading... A [v1] B [v1]');
+
+ // Now resolve the second tree
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A [v2]']);
+ expect(root).toMatchRenderedOutput('A [v2] A [v1] B [v1]');
+
+ await act(() => {
+ root.render('Bye!');
+ });
+ // Unmounting children releases both cache boundaries, but the original
+ // cache instance (used by second boundary) is still referenced by the root.
+ // only the second cache instance is freed.
+ assertLog(['Cache cleanup: A [v2]']);
+ expect(root).toMatchRenderedOutput('Bye!');
+ },
+ );
+
+ // @gate enableCacheElement
+ test('cache pool is cleared once transitions that depend on it commit their shell', async () => {
+ function Child({text}) {
+ return (
+
+
+
+ );
+ }
+
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render(
+ }>(empty),
+ );
+ });
+ assertLog([]);
+ expect(root).toMatchRenderedOutput('(empty)');
+
+ await act(() => {
+ startTransition(() => {
+ root.render(
+ }>
+
+ ,
+ );
+ });
+ });
+ assertLog(['Cache miss! [A]', 'Loading...']);
+ expect(root).toMatchRenderedOutput('(empty)');
+
+ await act(() => {
+ startTransition(() => {
+ root.render(
+ }>
+
+
+ ,
+ );
+ });
+ });
+ assertLog([
+ // No cache miss, because it uses the pooled cache
+ 'Loading...',
+ ]);
+ expect(root).toMatchRenderedOutput('(empty)');
+
+ // Resolve the request
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A [v1]', 'A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]A [v1]');
+
+ // Now do another transition
+ await act(() => {
+ startTransition(() => {
+ root.render(
+ }>
+
+
+
+ ,
+ );
+ });
+ });
+ assertLog([
+ // First two children use the old cache because they already finished
+ 'A [v1]',
+ 'A [v1]',
+ // The new child uses a fresh cache
+ 'Cache miss! [A]',
+ 'Loading...',
+ ]);
+ expect(root).toMatchRenderedOutput('A [v1]A [v1]');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A [v1]', 'A [v1]', 'A [v2]']);
+ expect(root).toMatchRenderedOutput('A [v1]A [v1]A [v2]');
+
+ // Unmount children: the first text cache instance is created only after the root
+ // commits, so both fresh cache instances are released by their cache boundaries,
+ // cleaning up v1 (used for the first two children which render together) and
+ // v2 (used for the third boundary added later).
+ await act(() => {
+ root.render('Bye!');
+ });
+ assertLog(['Cache cleanup: A [v1]', 'Cache cleanup: A [v2]']);
+ expect(root).toMatchRenderedOutput('Bye!');
+ });
+
+ // @gate enableCacheElement
+ test('cache pool is not cleared by arbitrary commits', async () => {
+ function App() {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ let showMore;
+ function ShowMore() {
+ const [shouldShow, _showMore] = useState(false);
+ showMore = () => _showMore(true);
+ return (
+ <>
+ }>
+ {shouldShow ? (
+
+
+
+ ) : null}
+
+ >
+ );
+ }
+
+ let updateUnrelated;
+ function Unrelated() {
+ const [count, _updateUnrelated] = useState(0);
+ updateUnrelated = _updateUnrelated;
+ return ;
+ }
+
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render();
+ });
+ assertLog(['0']);
+ expect(root).toMatchRenderedOutput('0');
+
+ await act(() => {
+ startTransition(() => {
+ showMore();
+ });
+ });
+ assertLog(['Cache miss! [A]', 'Loading...']);
+ expect(root).toMatchRenderedOutput('0');
+
+ await act(() => {
+ updateUnrelated(1);
+ });
+ assertLog([
+ '1',
+
+ // Happens to re-render the fallback. Doesn't need to, but not relevant
+ // to this test.
+ 'Loading...',
+ ]);
+ expect(root).toMatchRenderedOutput('1');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]1');
+
+ // Unmount children: the first text cache instance is created only after initial
+ // render after calling showMore(). This instance is cleaned up when that boundary
+ // is unmounted. Bc root cache instance is never accessed, the inner cache
+ // boundary ends up at v1.
+ await act(() => {
+ root.render('Bye!');
+ });
+ assertLog(['Cache cleanup: A [v1]']);
+ expect(root).toMatchRenderedOutput('Bye!');
+ });
+
+ // @gate enableCacheElement
+ test('cache boundary uses a fresh cache when its key changes', async () => {
+ const root = ReactNoop.createRoot();
+ seedNextTextCache('A');
+ await act(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ assertLog(['A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ seedNextTextCache('B');
+ await act(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ assertLog(['B [v2]']);
+ expect(root).toMatchRenderedOutput('B [v2]');
+
+ // Unmount children: the fresh cache instance for B cleans up since the cache boundary
+ // is the only owner, while the original cache instance (for A) is still retained by
+ // the root.
+ await act(() => {
+ root.render('Bye!');
+ });
+ assertLog(['Cache cleanup: B [v2]']);
+ expect(root).toMatchRenderedOutput('Bye!');
+ });
+
+ // @gate enableCacheElement
+ test('overlapping transitions after an initial mount use the same fresh cache', async () => {
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ assertLog(['Cache miss! [A]']);
+ expect(root).toMatchRenderedOutput('Loading...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ // After a mount, subsequent transitions use a fresh cache
+ await act(() => {
+ startTransition(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ });
+ assertLog(['Cache miss! [B]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ // Update to a different text and with a different key for the cache
+ // boundary: this should still use the fresh cache instance created
+ // for the earlier transition
+ await act(() => {
+ startTransition(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ });
+ assertLog(['Cache miss! [C]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ await act(() => {
+ resolveMostRecentTextCache('C');
+ });
+ assertLog(['C [v2]']);
+ expect(root).toMatchRenderedOutput('C [v2]');
+
+ // Unmount children: the fresh cache used for the updates is freed, while the
+ // original cache (with A) is still retained at the root.
+ await act(() => {
+ root.render('Bye!');
+ });
+ assertLog(['Cache cleanup: B [v2]', 'Cache cleanup: C [v2]']);
+ expect(root).toMatchRenderedOutput('Bye!');
+ });
+
+ // @gate enableCacheElement
+ test('overlapping updates after an initial mount use the same fresh cache', async () => {
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ assertLog(['Cache miss! [A]']);
+ expect(root).toMatchRenderedOutput('Loading...');
+
+ await act(() => {
+ resolveMostRecentTextCache('A');
+ });
+ assertLog(['A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ // After a mount, subsequent updates use a fresh cache
+ await act(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ assertLog(['Cache miss! [B]']);
+ expect(root).toMatchRenderedOutput('Loading...');
+
+ // A second update uses the same fresh cache: even though this is a new
+ // Cache boundary, the render uses the fresh cache from the pending update.
+ await act(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ assertLog(['Cache miss! [C]']);
+ expect(root).toMatchRenderedOutput('Loading...');
+
+ await act(() => {
+ resolveMostRecentTextCache('C');
+ });
+ assertLog(['C [v2]']);
+ expect(root).toMatchRenderedOutput('C [v2]');
+
+ // Unmount children: the fresh cache used for the updates is freed, while the
+ // original cache (with A) is still retained at the root.
+ await act(() => {
+ root.render('Bye!');
+ });
+ assertLog(['Cache cleanup: B [v2]', 'Cache cleanup: C [v2]']);
+ expect(root).toMatchRenderedOutput('Bye!');
+ });
+
+ // @gate enableCacheElement
+ test('cleans up cache only used in an aborted transition', async () => {
+ const root = ReactNoop.createRoot();
+ seedNextTextCache('A');
+ await act(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ assertLog(['A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ // Start a transition from A -> B..., which should create a fresh cache
+ // for the new cache boundary (bc of the different key)
+ await act(() => {
+ startTransition(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ });
+ assertLog(['Cache miss! [B]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ // ...but cancel by transitioning "back" to A (which we never really left)
+ await act(() => {
+ startTransition(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ });
+ assertLog(['A [v1]', 'Cache cleanup: B [v2]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ // Unmount children: ...
+ await act(() => {
+ root.render('Bye!');
+ });
+ assertLog([]);
+ expect(root).toMatchRenderedOutput('Bye!');
+ });
+
+ // @gate enableCacheElement
+ test.skip('if a root cache refresh never commits its fresh cache is released', async () => {
+ const root = ReactNoop.createRoot();
+ let refresh;
+ function Example({text}) {
+ refresh = useCacheRefresh();
+ return ;
+ }
+ seedNextTextCache('A');
+ await act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+ assertLog(['A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ await act(() => {
+ startTransition(() => {
+ refresh();
+ });
+ });
+ assertLog(['Cache miss! [A]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ await act(() => {
+ root.render('Bye!');
+ });
+ assertLog([
+ // TODO: the v1 cache should *not* be cleaned up, it is still retained by the root
+ // The following line is presently yielded but should not be:
+ // 'Cache cleanup: A [v1]',
+
+ // TODO: the v2 cache *should* be cleaned up, it was created for the abandoned refresh
+ // The following line is presently not yielded but should be:
+ 'Cache cleanup: A [v2]',
+ ]);
+ expect(root).toMatchRenderedOutput('Bye!');
+ });
+
+ // @gate enableCacheElement
+ test.skip('if a cache boundary refresh never commits its fresh cache is released', async () => {
+ const root = ReactNoop.createRoot();
+ let refresh;
+ function Example({text}) {
+ refresh = useCacheRefresh();
+ return ;
+ }
+ seedNextTextCache('A');
+ await act(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ assertLog(['A [v1]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ await act(() => {
+ startTransition(() => {
+ refresh();
+ });
+ });
+ assertLog(['Cache miss! [A]']);
+ expect(root).toMatchRenderedOutput('A [v1]');
+
+ // Unmount the boundary before the refresh can complete
+ await act(() => {
+ root.render('Bye!');
+ });
+ assertLog([
+ // TODO: the v2 cache *should* be cleaned up, it was created for the abandoned refresh
+ // The following line is presently not yielded but should be:
+ 'Cache cleanup: A [v2]',
+ ]);
+ expect(root).toMatchRenderedOutput('Bye!');
+ });
+
+ // @gate enableActivity
+ // @gate enableCache
+ test('prerender a new cache boundary inside an Activity tree', async () => {
+ function App({prerenderMore}) {
+ return (
+
+
+ {prerenderMore ? (
+
+
+
+ ) : null}
+
+
+ );
+ }
+
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render();
+ });
+ assertLog([]);
+ expect(root).toMatchRenderedOutput();
+
+ seedNextTextCache('More');
+ await act(() => {
+ root.render();
+ });
+ assertLog(['More']);
+ expect(root).toMatchRenderedOutput(More
);
+ });
+});
diff --git a/packages/react-reconciler/src/__tests__/ReactUse-test.js b/packages/react-reconciler/src/__tests__/ReactUse-test.js
index 7f96506260d..fd49f8c8d78 100644
--- a/packages/react-reconciler/src/__tests__/ReactUse-test.js
+++ b/packages/react-reconciler/src/__tests__/ReactUse-test.js
@@ -11,7 +11,6 @@ let useMemo;
let useEffect;
let Suspense;
let startTransition;
-let cache;
let pendingTextRequests;
let waitFor;
let waitForPaint;
@@ -34,7 +33,6 @@ describe('ReactUse', () => {
useEffect = React.useEffect;
Suspense = React.Suspense;
startTransition = React.startTransition;
- cache = React.cache;
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
@@ -643,10 +641,10 @@ describe('ReactUse', () => {
});
test('when waiting for data to resolve, an update on a different root does not cause work to be dropped', async () => {
- const getCachedAsyncText = cache(getAsyncText);
+ const promise = getAsyncText('Hi');
function App() {
- return ;
+ return ;
}
const root1 = ReactNoop.createRoot();
@@ -998,39 +996,46 @@ describe('ReactUse', () => {
);
test('load multiple nested Suspense boundaries', async () => {
- const getCachedAsyncText = cache(getAsyncText);
+ const promiseA = getAsyncText('A');
+ const promiseB = getAsyncText('B');
+ const promiseC = getAsyncText('C');
+ assertLog([
+ 'Async text requested [A]',
+ 'Async text requested [B]',
+ 'Async text requested [C]',
+ ]);
- function AsyncText({text}) {
- return ;
+ function AsyncText({promise}) {
+ return ;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(
}>
-
+
}>
-
+
}>
-
+
,
);
});
- assertLog(['Async text requested [A]', '(Loading A...)']);
+ assertLog(['(Loading A...)']);
expect(root).toMatchRenderedOutput('(Loading A...)');
await act(() => {
resolveTextRequests('A');
});
- assertLog(['A', 'Async text requested [B]', '(Loading B...)']);
+ assertLog(['A', '(Loading B...)']);
expect(root).toMatchRenderedOutput('A(Loading B...)');
await act(() => {
resolveTextRequests('B');
});
- assertLog(['B', 'Async text requested [C]', '(Loading C...)']);
+ assertLog(['B', '(Loading C...)']);
expect(root).toMatchRenderedOutput('AB(Loading C...)');
await act(() => {
@@ -1584,34 +1589,38 @@ describe('ReactUse', () => {
});
test('regression test: updates while component is suspended should not be mistaken for render phase updates', async () => {
- const getCachedAsyncText = cache(getAsyncText);
+ const promiseA = getAsyncText('A');
+ const promiseB = getAsyncText('B');
+ const promiseC = getAsyncText('C');
+ assertLog([
+ 'Async text requested [A]',
+ 'Async text requested [B]',
+ 'Async text requested [C]',
+ ]);
let setState;
function App() {
- const [state, _setState] = useState('A');
+ const [state, _setState] = useState(promiseA);
setState = _setState;
- return ;
+ return ;
}
// Initial render
const root = ReactNoop.createRoot();
await act(() => root.render());
- assertLog(['Async text requested [A]']);
expect(root).toMatchRenderedOutput(null);
await act(() => resolveTextRequests('A'));
assertLog(['A']);
expect(root).toMatchRenderedOutput('A');
// Update to B. This will suspend.
- await act(() => startTransition(() => setState('B')));
- assertLog(['Async text requested [B]']);
+ await act(() => startTransition(() => setState(promiseB)));
expect(root).toMatchRenderedOutput('A');
// While B is suspended, update to C. This should immediately interrupt
// the render for B. In the regression, this update was mistakenly treated
// as a render phase update.
- ReactNoop.flushSync(() => setState('C'));
- assertLog(['Async text requested [C]']);
+ ReactNoop.flushSync(() => setState(promiseC));
// Finish rendering.
await act(() => resolveTextRequests('C'));
diff --git a/packages/react/src/ReactCacheClient.js b/packages/react/src/ReactCacheClient.js
new file mode 100644
index 00000000000..e752a110a5c
--- /dev/null
+++ b/packages/react/src/ReactCacheClient.js
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) Meta Platforms, 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
+ */
+
+export function cache, T>(fn: (...A) => T): (...A) => T {
+ // On the client (i.e. not a Server Components environment) `cache` has
+ // no caching behavior. We just 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. But it
+ // does mean they need to be aware of the behavioral difference.
+ //
+ // The rest of the behavior is the same as the server implementation — it
+ // returns a new reference, extra properties like `displayName` are not
+ // preserved, the length of the new function is 0, etc. That way apps can't
+ // accidentally depend on those details.
+ return function () {
+ // $FlowFixMe[incompatible-call]: We don't want to use rest arguments since we transpile the code.
+ return fn.apply(null, arguments);
+ };
+}
diff --git a/packages/react/src/ReactCache.js b/packages/react/src/ReactCacheServer.js
similarity index 100%
rename from packages/react/src/ReactCache.js
rename to packages/react/src/ReactCacheServer.js
diff --git a/packages/react/src/ReactClient.js b/packages/react/src/ReactClient.js
index 5aaae5aaf8e..b6257102485 100644
--- a/packages/react/src/ReactClient.js
+++ b/packages/react/src/ReactClient.js
@@ -35,7 +35,7 @@ import {createContext} from './ReactContext';
import {lazy} from './ReactLazy';
import {forwardRef} from './ReactForwardRef';
import {memo} from './ReactMemo';
-import {cache} from './ReactCache';
+import {cache} from './ReactCacheClient';
import {postpone} from './ReactPostpone';
import {
getCacheSignal,
diff --git a/packages/react/src/ReactServer.experimental.js b/packages/react/src/ReactServer.experimental.js
index 330707b7b59..6b6003385c8 100644
--- a/packages/react/src/ReactServer.experimental.js
+++ b/packages/react/src/ReactServer.experimental.js
@@ -38,7 +38,7 @@ import {
import {forwardRef} from './ReactForwardRef';
import {lazy} from './ReactLazy';
import {memo} from './ReactMemo';
-import {cache} from './ReactCache';
+import {cache} from './ReactCacheServer';
import {startTransition} from './ReactStartTransition';
import {postpone} from './ReactPostpone';
import version from 'shared/ReactVersion';
diff --git a/packages/react/src/ReactServer.js b/packages/react/src/ReactServer.js
index 5631319132f..7912562a586 100644
--- a/packages/react/src/ReactServer.js
+++ b/packages/react/src/ReactServer.js
@@ -35,7 +35,7 @@ import {
import {forwardRef} from './ReactForwardRef';
import {lazy} from './ReactLazy';
import {memo} from './ReactMemo';
-import {cache} from './ReactCache';
+import {cache} from './ReactCacheServer';
import {startTransition} from './ReactStartTransition';
import version from 'shared/ReactVersion';