Skip to content

Commit

Permalink
Initial implementation of cache cleanup (#22510)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
josephsavona committed Oct 21, 2021
1 parent bfb4022 commit fa9bea0
Show file tree
Hide file tree
Showing 29 changed files with 1,482 additions and 199 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Object {
},
},
"duration": 15,
"effectDuration": null,
"effectDuration": 0,
"fiberActualDurations": Map {
1 => 15,
2 => 15,
Expand All @@ -86,7 +86,7 @@ Object {
3 => 3,
4 => 2,
},
"passiveEffectDuration": null,
"passiveEffectDuration": 0,
"priorityLevel": "Immediate",
"timestamp": 15,
"updaters": Array [
Expand Down
5 changes: 5 additions & 0 deletions packages/react-dom/src/server/ReactPartialRendererHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ export function resetHooksState(): void {
workInProgressHook = null;
}

function getCacheSignal() {
throw new Error('Not implemented.');
}

function getCacheForType<T>(resourceType: () => T): T {
throw new Error('Not implemented.');
}
Expand Down Expand Up @@ -551,6 +555,7 @@ export const Dispatcher: DispatcherType = {
};

if (enableCache) {
Dispatcher.getCacheSignal = getCacheSignal;
Dispatcher.getCacheForType = getCacheForType;
Dispatcher.useCacheRefresh = useCacheRefresh;
}
85 changes: 80 additions & 5 deletions packages/react-reconciler/src/ReactFiberCacheComponent.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@ import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';
import {isPrimaryRenderer} from './ReactFiberHostConfig';
import {createCursor, push, pop} from './ReactFiberStack.new';
import {pushProvider, popProvider} from './ReactFiberNewContext.new';
import * as Scheduler from 'scheduler';

export type Cache = Map<() => mixed, mixed>;
export type Cache = {|
controller: AbortController,
data: Map<() => mixed, mixed>,
refCount: number,
|};

export type CacheComponentState = {|
+parent: Cache,
Expand All @@ -31,6 +36,13 @@ export type SpawnedCachePool = {|
+pool: Cache,
|};

// Intentionally not named imports because Rollup would
// use dynamic dispatch for CommonJS interop named imports.
const {
unstable_scheduleCallback: scheduleCallback,
unstable_NormalPriority: NormalPriority,
} = Scheduler;

export const CacheContext: ReactContext<Cache> = enableCache
? {
$$typeof: REACT_CONTEXT_TYPE,
Expand All @@ -57,6 +69,58 @@ let pooledCache: Cache | null = null;
// cache from the render that suspended.
const prevFreshCacheOnStack: StackCursor<Cache | null> = createCursor(null);

// Creates a new empty Cache instance with a ref-count of 0. The caller is responsible
// for retaining the cache once it is in use (retainCache), and releasing the cache
// once it is no longer needed (releaseCache).
export function createCache(): Cache {
if (!enableCache) {
return (null: any);
}
const cache: Cache = {
controller: new AbortController(),
data: new Map(),
refCount: 0,
};

return cache;
}

export function retainCache(cache: Cache) {
if (!enableCache) {
return;
}
if (__DEV__) {
if (cache.controller.signal.aborted) {
console.warn(
'A cache instance was retained after it was already freed. ' +
'This likely indicates a bug in React.',
);
}
}
cache.refCount++;
}

// Cleanup a cache instance, potentially freeing it if there are no more references
export function releaseCache(cache: Cache) {
if (!enableCache) {
return;
}
cache.refCount--;
if (__DEV__) {
if (cache.refCount < 0) {
console.warn(
'A cache instance was released after it was already freed. ' +
'This likely indicates a bug in React.',
);
}
}
if (cache.refCount === 0) {
scheduleCallback(NormalPriority, () => {
cache.controller.abort();
});
}
}

export function pushCacheProvider(workInProgress: Fiber, cache: Cache) {
if (!enableCache) {
return;
Expand All @@ -78,8 +142,14 @@ export function requestCacheFromPool(renderLanes: Lanes): Cache {
if (pooledCache !== null) {
return pooledCache;
}
// Create a fresh cache.
pooledCache = new Map();
// Create a fresh cache. The pooled cache must be owned - it is freed
// in releaseRootPooledCache() - but the cache instance handed out
// is retained/released in the commit phase of the component that
// references is (ie the host root, cache boundary, suspense component)
// Ie, pooledCache is conceptually an Option<Arc<Cache>> (owned),
// whereas the return value of this function is a &Arc<Cache> (borrowed).
pooledCache = createCache();
retainCache(pooledCache);
return pooledCache;
}

Expand All @@ -91,7 +161,13 @@ export function pushRootCachePool(root: FiberRoot) {
// from `root.pooledCache`. If it's currently `null`, we will lazily
// initialize it the first type it's requested. However, we only mutate
// the root itself during the complete/unwind phase of the HostRoot.
pooledCache = root.pooledCache;
const rootCache = root.pooledCache;
if (rootCache != null) {
pooledCache = rootCache;
root.pooledCache = null;
} else {
pooledCache = null;
}
}

export function popRootCachePool(root: FiberRoot, renderLanes: Lanes) {
Expand Down Expand Up @@ -157,7 +233,6 @@ export function getSuspendedCachePool(): SpawnedCachePool | null {
if (!enableCache) {
return null;
}

// We check the cache on the stack first, since that's the one any new Caches
// would have accessed.
let pool = pooledCache;
Expand Down
85 changes: 80 additions & 5 deletions packages/react-reconciler/src/ReactFiberCacheComponent.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@ import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';
import {isPrimaryRenderer} from './ReactFiberHostConfig';
import {createCursor, push, pop} from './ReactFiberStack.old';
import {pushProvider, popProvider} from './ReactFiberNewContext.old';
import * as Scheduler from 'scheduler';

export type Cache = Map<() => mixed, mixed>;
export type Cache = {|
controller: AbortController,
data: Map<() => mixed, mixed>,
refCount: number,
|};

export type CacheComponentState = {|
+parent: Cache,
Expand All @@ -31,6 +36,13 @@ export type SpawnedCachePool = {|
+pool: Cache,
|};

// Intentionally not named imports because Rollup would
// use dynamic dispatch for CommonJS interop named imports.
const {
unstable_scheduleCallback: scheduleCallback,
unstable_NormalPriority: NormalPriority,
} = Scheduler;

export const CacheContext: ReactContext<Cache> = enableCache
? {
$$typeof: REACT_CONTEXT_TYPE,
Expand All @@ -57,6 +69,58 @@ let pooledCache: Cache | null = null;
// cache from the render that suspended.
const prevFreshCacheOnStack: StackCursor<Cache | null> = createCursor(null);

// Creates a new empty Cache instance with a ref-count of 0. The caller is responsible
// for retaining the cache once it is in use (retainCache), and releasing the cache
// once it is no longer needed (releaseCache).
export function createCache(): Cache {
if (!enableCache) {
return (null: any);
}
const cache: Cache = {
controller: new AbortController(),
data: new Map(),
refCount: 0,
};

return cache;
}

export function retainCache(cache: Cache) {
if (!enableCache) {
return;
}
if (__DEV__) {
if (cache.controller.signal.aborted) {
console.warn(
'A cache instance was retained after it was already freed. ' +
'This likely indicates a bug in React.',
);
}
}
cache.refCount++;
}

// Cleanup a cache instance, potentially freeing it if there are no more references
export function releaseCache(cache: Cache) {
if (!enableCache) {
return;
}
cache.refCount--;
if (__DEV__) {
if (cache.refCount < 0) {
console.warn(
'A cache instance was released after it was already freed. ' +
'This likely indicates a bug in React.',
);
}
}
if (cache.refCount === 0) {
scheduleCallback(NormalPriority, () => {
cache.controller.abort();
});
}
}

export function pushCacheProvider(workInProgress: Fiber, cache: Cache) {
if (!enableCache) {
return;
Expand All @@ -78,8 +142,14 @@ export function requestCacheFromPool(renderLanes: Lanes): Cache {
if (pooledCache !== null) {
return pooledCache;
}
// Create a fresh cache.
pooledCache = new Map();
// Create a fresh cache. The pooled cache must be owned - it is freed
// in releaseRootPooledCache() - but the cache instance handed out
// is retained/released in the commit phase of the component that
// references is (ie the host root, cache boundary, suspense component)
// Ie, pooledCache is conceptually an Option<Arc<Cache>> (owned),
// whereas the return value of this function is a &Arc<Cache> (borrowed).
pooledCache = createCache();
retainCache(pooledCache);
return pooledCache;
}

Expand All @@ -91,7 +161,13 @@ export function pushRootCachePool(root: FiberRoot) {
// from `root.pooledCache`. If it's currently `null`, we will lazily
// initialize it the first type it's requested. However, we only mutate
// the root itself during the complete/unwind phase of the HostRoot.
pooledCache = root.pooledCache;
const rootCache = root.pooledCache;
if (rootCache != null) {
pooledCache = rootCache;
root.pooledCache = null;
} else {
pooledCache = null;
}
}

export function popRootCachePool(root: FiberRoot, renderLanes: Lanes) {
Expand Down Expand Up @@ -157,7 +233,6 @@ export function getSuspendedCachePool(): SpawnedCachePool | null {
if (!enableCache) {
return null;
}

// We check the cache on the stack first, since that's the one any new Caches
// would have accessed.
let pool = pooledCache;
Expand Down
Loading

0 comments on commit fa9bea0

Please sign in to comment.