Skip to content

Commit c9abb0a

Browse files
josephsavonazhengjitf
authored andcommitted
Initial implementation of cache cleanup (facebook#22510)
This is an initial, partial implementation of a cleanup mechanism for the experimental Cache API. The idea is that consumers of the Cache API can register to be informed when a given Cache instance is no longer needed so that they can perform associated cleanup tasks to free resources stored in the cache. A canonical example would be cancelling pending network requests. An overview of the high-level changes: * Changes the `Cache` type from a Map of cache instances to be an object with the original Map of instances, a reference count (to count roughly "active references" to the cache instances - more below), and an AbortController. * Adds a new public API, `unstable_getCacheSignal(): AbortSignal`, which is callable during render. It returns an AbortSignal tied to the lifetime of the cache - developers can listen for the 'abort' event on the signal, which React now triggers when a given cache instance is no longer referenced. * Note that `AbortSignal` is a web standard that is supported by other platform APIs; for example a signal can be passed to `fetch()` to trigger cancellation of an HTTP request. * Implements the above - triggering the 'abort' event - by handling passive mount/unmount for HostRoot and CacheComponent fiber nodes. Cases handled: * Aborted transitions: we clean up a new cache created for an aborted transition * Suspense: we retain a fresh cache instance until a suspended tree resolves For follow-ups: * When a subsequent cache refresh is issued before a previous refresh completes, the refreshes are queued. Fresh cache instances for previous refreshes in the queue should be cleared, retaining only the most recent cache. I plan to address this in a follow-up PR. * If a refresh is cancelled, the fresh cache should be cleaned up.
1 parent 1048b02 commit c9abb0a

29 files changed

+1482
-199
lines changed

packages/react-devtools-shared/src/__tests__/__snapshots__/profilingCache-test.js.snap

+2-2
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Object {
7373
},
7474
},
7575
"duration": 15,
76-
"effectDuration": null,
76+
"effectDuration": 0,
7777
"fiberActualDurations": Map {
7878
1 => 15,
7979
2 => 15,
@@ -86,7 +86,7 @@ Object {
8686
3 => 3,
8787
4 => 2,
8888
},
89-
"passiveEffectDuration": null,
89+
"passiveEffectDuration": 0,
9090
"priorityLevel": "Immediate",
9191
"timestamp": 15,
9292
"updaters": Array [

packages/react-dom/src/server/ReactPartialRendererHooks.js

+5
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ export function resetHooksState(): void {
216216
workInProgressHook = null;
217217
}
218218

219+
function getCacheSignal() {
220+
throw new Error('Not implemented.');
221+
}
222+
219223
function getCacheForType<T>(resourceType: () => T): T {
220224
throw new Error('Not implemented.');
221225
}
@@ -551,6 +555,7 @@ export const Dispatcher: DispatcherType = {
551555
};
552556

553557
if (enableCache) {
558+
Dispatcher.getCacheSignal = getCacheSignal;
554559
Dispatcher.getCacheForType = getCacheForType;
555560
Dispatcher.useCacheRefresh = useCacheRefresh;
556561
}

packages/react-reconciler/src/ReactFiberCacheComponent.new.js

+80-5
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@ import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';
1818
import {isPrimaryRenderer} from './ReactFiberHostConfig';
1919
import {createCursor, push, pop} from './ReactFiberStack.new';
2020
import {pushProvider, popProvider} from './ReactFiberNewContext.new';
21+
import * as Scheduler from 'scheduler';
2122

22-
export type Cache = Map<() => mixed, mixed>;
23+
export type Cache = {|
24+
controller: AbortController,
25+
data: Map<() => mixed, mixed>,
26+
refCount: number,
27+
|};
2328

2429
export type CacheComponentState = {|
2530
+parent: Cache,
@@ -31,6 +36,13 @@ export type SpawnedCachePool = {|
3136
+pool: Cache,
3237
|};
3338

39+
// Intentionally not named imports because Rollup would
40+
// use dynamic dispatch for CommonJS interop named imports.
41+
const {
42+
unstable_scheduleCallback: scheduleCallback,
43+
unstable_NormalPriority: NormalPriority,
44+
} = Scheduler;
45+
3446
export const CacheContext: ReactContext<Cache> = enableCache
3547
? {
3648
$$typeof: REACT_CONTEXT_TYPE,
@@ -57,6 +69,58 @@ let pooledCache: Cache | null = null;
5769
// cache from the render that suspended.
5870
const prevFreshCacheOnStack: StackCursor<Cache | null> = createCursor(null);
5971

72+
// Creates a new empty Cache instance with a ref-count of 0. The caller is responsible
73+
// for retaining the cache once it is in use (retainCache), and releasing the cache
74+
// once it is no longer needed (releaseCache).
75+
export function createCache(): Cache {
76+
if (!enableCache) {
77+
return (null: any);
78+
}
79+
const cache: Cache = {
80+
controller: new AbortController(),
81+
data: new Map(),
82+
refCount: 0,
83+
};
84+
85+
return cache;
86+
}
87+
88+
export function retainCache(cache: Cache) {
89+
if (!enableCache) {
90+
return;
91+
}
92+
if (__DEV__) {
93+
if (cache.controller.signal.aborted) {
94+
console.warn(
95+
'A cache instance was retained after it was already freed. ' +
96+
'This likely indicates a bug in React.',
97+
);
98+
}
99+
}
100+
cache.refCount++;
101+
}
102+
103+
// Cleanup a cache instance, potentially freeing it if there are no more references
104+
export function releaseCache(cache: Cache) {
105+
if (!enableCache) {
106+
return;
107+
}
108+
cache.refCount--;
109+
if (__DEV__) {
110+
if (cache.refCount < 0) {
111+
console.warn(
112+
'A cache instance was released after it was already freed. ' +
113+
'This likely indicates a bug in React.',
114+
);
115+
}
116+
}
117+
if (cache.refCount === 0) {
118+
scheduleCallback(NormalPriority, () => {
119+
cache.controller.abort();
120+
});
121+
}
122+
}
123+
60124
export function pushCacheProvider(workInProgress: Fiber, cache: Cache) {
61125
if (!enableCache) {
62126
return;
@@ -78,8 +142,14 @@ export function requestCacheFromPool(renderLanes: Lanes): Cache {
78142
if (pooledCache !== null) {
79143
return pooledCache;
80144
}
81-
// Create a fresh cache.
82-
pooledCache = new Map();
145+
// Create a fresh cache. The pooled cache must be owned - it is freed
146+
// in releaseRootPooledCache() - but the cache instance handed out
147+
// is retained/released in the commit phase of the component that
148+
// references is (ie the host root, cache boundary, suspense component)
149+
// Ie, pooledCache is conceptually an Option<Arc<Cache>> (owned),
150+
// whereas the return value of this function is a &Arc<Cache> (borrowed).
151+
pooledCache = createCache();
152+
retainCache(pooledCache);
83153
return pooledCache;
84154
}
85155

@@ -91,7 +161,13 @@ export function pushRootCachePool(root: FiberRoot) {
91161
// from `root.pooledCache`. If it's currently `null`, we will lazily
92162
// initialize it the first type it's requested. However, we only mutate
93163
// the root itself during the complete/unwind phase of the HostRoot.
94-
pooledCache = root.pooledCache;
164+
const rootCache = root.pooledCache;
165+
if (rootCache != null) {
166+
pooledCache = rootCache;
167+
root.pooledCache = null;
168+
} else {
169+
pooledCache = null;
170+
}
95171
}
96172

97173
export function popRootCachePool(root: FiberRoot, renderLanes: Lanes) {
@@ -157,7 +233,6 @@ export function getSuspendedCachePool(): SpawnedCachePool | null {
157233
if (!enableCache) {
158234
return null;
159235
}
160-
161236
// We check the cache on the stack first, since that's the one any new Caches
162237
// would have accessed.
163238
let pool = pooledCache;

packages/react-reconciler/src/ReactFiberCacheComponent.old.js

+80-5
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@ import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';
1818
import {isPrimaryRenderer} from './ReactFiberHostConfig';
1919
import {createCursor, push, pop} from './ReactFiberStack.old';
2020
import {pushProvider, popProvider} from './ReactFiberNewContext.old';
21+
import * as Scheduler from 'scheduler';
2122

22-
export type Cache = Map<() => mixed, mixed>;
23+
export type Cache = {|
24+
controller: AbortController,
25+
data: Map<() => mixed, mixed>,
26+
refCount: number,
27+
|};
2328

2429
export type CacheComponentState = {|
2530
+parent: Cache,
@@ -31,6 +36,13 @@ export type SpawnedCachePool = {|
3136
+pool: Cache,
3237
|};
3338

39+
// Intentionally not named imports because Rollup would
40+
// use dynamic dispatch for CommonJS interop named imports.
41+
const {
42+
unstable_scheduleCallback: scheduleCallback,
43+
unstable_NormalPriority: NormalPriority,
44+
} = Scheduler;
45+
3446
export const CacheContext: ReactContext<Cache> = enableCache
3547
? {
3648
$$typeof: REACT_CONTEXT_TYPE,
@@ -57,6 +69,58 @@ let pooledCache: Cache | null = null;
5769
// cache from the render that suspended.
5870
const prevFreshCacheOnStack: StackCursor<Cache | null> = createCursor(null);
5971

72+
// Creates a new empty Cache instance with a ref-count of 0. The caller is responsible
73+
// for retaining the cache once it is in use (retainCache), and releasing the cache
74+
// once it is no longer needed (releaseCache).
75+
export function createCache(): Cache {
76+
if (!enableCache) {
77+
return (null: any);
78+
}
79+
const cache: Cache = {
80+
controller: new AbortController(),
81+
data: new Map(),
82+
refCount: 0,
83+
};
84+
85+
return cache;
86+
}
87+
88+
export function retainCache(cache: Cache) {
89+
if (!enableCache) {
90+
return;
91+
}
92+
if (__DEV__) {
93+
if (cache.controller.signal.aborted) {
94+
console.warn(
95+
'A cache instance was retained after it was already freed. ' +
96+
'This likely indicates a bug in React.',
97+
);
98+
}
99+
}
100+
cache.refCount++;
101+
}
102+
103+
// Cleanup a cache instance, potentially freeing it if there are no more references
104+
export function releaseCache(cache: Cache) {
105+
if (!enableCache) {
106+
return;
107+
}
108+
cache.refCount--;
109+
if (__DEV__) {
110+
if (cache.refCount < 0) {
111+
console.warn(
112+
'A cache instance was released after it was already freed. ' +
113+
'This likely indicates a bug in React.',
114+
);
115+
}
116+
}
117+
if (cache.refCount === 0) {
118+
scheduleCallback(NormalPriority, () => {
119+
cache.controller.abort();
120+
});
121+
}
122+
}
123+
60124
export function pushCacheProvider(workInProgress: Fiber, cache: Cache) {
61125
if (!enableCache) {
62126
return;
@@ -78,8 +142,14 @@ export function requestCacheFromPool(renderLanes: Lanes): Cache {
78142
if (pooledCache !== null) {
79143
return pooledCache;
80144
}
81-
// Create a fresh cache.
82-
pooledCache = new Map();
145+
// Create a fresh cache. The pooled cache must be owned - it is freed
146+
// in releaseRootPooledCache() - but the cache instance handed out
147+
// is retained/released in the commit phase of the component that
148+
// references is (ie the host root, cache boundary, suspense component)
149+
// Ie, pooledCache is conceptually an Option<Arc<Cache>> (owned),
150+
// whereas the return value of this function is a &Arc<Cache> (borrowed).
151+
pooledCache = createCache();
152+
retainCache(pooledCache);
83153
return pooledCache;
84154
}
85155

@@ -91,7 +161,13 @@ export function pushRootCachePool(root: FiberRoot) {
91161
// from `root.pooledCache`. If it's currently `null`, we will lazily
92162
// initialize it the first type it's requested. However, we only mutate
93163
// the root itself during the complete/unwind phase of the HostRoot.
94-
pooledCache = root.pooledCache;
164+
const rootCache = root.pooledCache;
165+
if (rootCache != null) {
166+
pooledCache = rootCache;
167+
root.pooledCache = null;
168+
} else {
169+
pooledCache = null;
170+
}
95171
}
96172

97173
export function popRootCachePool(root: FiberRoot, renderLanes: Lanes) {
@@ -157,7 +233,6 @@ export function getSuspendedCachePool(): SpawnedCachePool | null {
157233
if (!enableCache) {
158234
return null;
159235
}
160-
161236
// We check the cache on the stack first, since that's the one any new Caches
162237
// would have accessed.
163238
let pool = pooledCache;

0 commit comments

Comments
 (0)