React v18では以下のようにCache APIの関数をimportできるのだが、あまりに情報がないので2023年の現時点でのコードを少し読んでみる。
Reactのコミットヒストリを見る限りCache APIは2022年10月ごろにmainへマージされたらしい
github.com
Cache, CacheContext, createCache
まずは cache
が呼ばれた際に、そのデータがどこに格納されるのかを知りたい。
少し読んでみると CacheContext
という名前で以下のコードが見つかる。
export type Cache = {
controller: AbortController,
data: Map<() => mixed, mixed>,
refCount: number,
};
export const CacheContext: ReactContext<Cache> = enableCache
? {
$$typeof: REACT_CONTEXT_TYPE,
Consumer: (null: any),
Provider: (null: any),
_currentValue: (null: any),
_currentValue2: (null: any),
_threadCount: 0,
_defaultValue: (null: any),
_globalName: (null: any),
}
: (null: any);
export function createCache(): Cache {
if (!enableCache) {
return (null: any);
}
const cache: Cache = {
controller: new AbortControllerLocal(),
data: new Map(),
refCount: 0,
};
return cache;
}
上記のコードのうちCache型のフィールドにある data
というMap型の値が、実際にキャッシュを保持していると思われる。
Reactアプリケーションのライフサイクルの中ではルートのFiber生成時に初期化が行われるよう。あとはReactFiberTransitionと呼ばれるモジュールの中でキャッシュプールを生成する際にも使われているようだが、ここは複雑すぎてよく分からなかった。
なお、上記の ReactContext<T>
はReact Contextそのものであり、内部的なインメモリキャッシュの実装もReact Contextを用いて行われていることが分かる。
getCacheForType
createCache
で生成されたキャッシュはMapをキャッシュデータとして保持するが、実際にほかのモジュールがキャッシュの値を参照するにあたっては getCacheForType
という関数を経由する。
function getCacheForType<T>(resourceType: () => T): T {
if (!enableCache) {
throw new Error('Not implemented.');
}
const cache: Cache = readContext(CacheContext);
let cacheForType: T | void = (cache.data.get(resourceType): any);
if (cacheForType === undefined) {
cacheForType = resourceType();
cache.data.set(resourceType, cacheForType);
}
return cacheForType;
}
export const DefaultCacheDispatcher: CacheDispatcher = {
getCacheSignal,
getCacheForType,
};
この関数は、Reactにおけるキャッシュが必要なあらゆる機能群で使われることが想定されているようである。
例えばReactによるfetch関数の拡張*1にも使われいるし、ユーザーレベルにも unstable_getCacheForType
としてexportされている。
cache
さて、とうとう核心部分であるが、以下が cache
の関数の実装になる。
getCacheForType
のレシーバとなっている dispatcher
という変数は、上記で触れた CacheDispatcher
インターフェイスを実装したもの。
export function cache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
return function () {
const dispatcher = ReactCurrentCache.current;
if (!dispatcher) {
return fn.apply(null, arguments);
}
const fnMap: WeakMap<any, CacheNode<T>> = dispatcher.getCacheForType(
createCacheRoot,
);
const fnNode = fnMap.get(fn);
let cacheNode: CacheNode<T>;
if (fnNode === undefined) {
cacheNode = createCacheNode();
fnMap.set(fn, cacheNode);
} else {
cacheNode = fnNode;
}
for (let i = 0, l = arguments.length; i < l; i++) {
const arg = arguments[i];
if (
typeof arg === 'function' ||
(typeof arg === 'object' && arg !== null)
) {
let objectCache = cacheNode.o;
if (objectCache === null) {
cacheNode.o = objectCache = new WeakMap();
}
const objectNode = objectCache.get(arg);
if (objectNode === undefined) {
cacheNode = createCacheNode();
objectCache.set(arg, cacheNode);
} else {
cacheNode = objectNode;
}
} else {
let primitiveCache = cacheNode.p;
if (primitiveCache === null) {
cacheNode.p = primitiveCache = new Map();
}
const primitiveNode = primitiveCache.get(arg);
if (primitiveNode === undefined) {
cacheNode = createCacheNode();
primitiveCache.set(arg, cacheNode);
} else {
cacheNode = primitiveNode;
}
}
}
if (cacheNode.s === TERMINATED) {
return cacheNode.v;
}
if (cacheNode.s === ERRORED) {
throw cacheNode.v;
}
try {
const result = fn.apply(null, arguments);
const terminatedNode: TerminatedCacheNode<T> = (cacheNode: any);
terminatedNode.s = TERMINATED;
terminatedNode.v = result;
return result;
} catch (error) {
const erroredNode: ErroredCacheNode<T> = (cacheNode: any);
erroredNode.s = ERRORED;
erroredNode.v = error;
throw error;
}
};
}
細かい説明は面倒なので省くが、ざっくり引数で与えられた関数の arguments
をキーにして、再帰的にネストするツリー構造のような形の CacheNode
と呼ばれる型の値を生成している。
すでに関数が実行済み (CacheNode.s === TERMINATED
) であれば CacheNode.v
に保存されている値を返す。
自分はあまりキャッシュアルゴリズムなどの理論に詳しくないため、なぜこのようなキャッシュの構造になっているのかは実装されたPRのdescriptionを読んでもよく分からなかった...
refreshCache
cache
関数の実装は分かったので、次に unstable_useCacheRefresh
のほうの実装を見ていく。
いきなり実装から行くが、以下のコードがキャッシュのinvalidationを行うものになる。
unstable_useCacheRefresh
はhookであるため、hookを呼び出したコンポーネントの所属するCacheContextが createCache
によって初期化される。
function refreshCache<T>(fiber: Fiber, seedKey: ?() => T, seedValue: T): void {
if (!enableCache) {
return;
}
TODO
TODO
let provider = fiber.return;
while (provider !== null) {
switch (provider.tag) {
case CacheComponent:
case HostRoot: {
const lane = requestUpdateLane(provider);
const refreshUpdate = createLegacyQueueUpdate(lane);
const root = enqueueLegacyQueueUpdate(provider, refreshUpdate, lane);
if (root !== null) {
scheduleUpdateOnFiber(root, provider, lane);
entangleLegacyQueueTransitions(root, provider, lane);
}
TODO
const seededCache = createCache();
if (seedKey !== null && seedKey !== undefined && root !== null) {
if (enableLegacyCache) {
seededCache.data.set(seedKey, seedValue);
} else {
if (__DEV__) {
console.error(
'The seed argument is not enabled outside experimental channels.',
);
}
}
}
const payload = {
cache: seededCache,
};
refreshUpdate.payload = payload;
return;
}
}
provider = provider.return;
}
TODO
}
ReactCacheのテストコードを読むとこのあたりの挙動はわかりやすい。
おそらく Suspense
がひとつのCache boundaryという扱いだと思われる。
test('refresh a cache boundary', async () => {
let refresh;
function App() {
refresh = useCacheRefresh();
return <AsyncText showVersion={true} text="A" />;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(
<Suspense fallback={<Text text="Loading..." />}>
<App />
</Suspense>,
);
});
assertLog(['Cache miss! [A]', 'Loading...']);
expect(root).toMatchRenderedOutput('Loading...');
await act(() => {
resolveMostRecentTextCache('A');
});
assertLog(['A [v1]']);
expect(root).toMatchRenderedOutput('A [v1]');
await act(() => {
startTransition(() => refresh());
});
assertLog(['Cache miss! [A]', 'Loading...']);
expect(root).toMatchRenderedOutput('A [v1]');
await act(() => {
resolveMostRecentTextCache('A');
});
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');
});
こういうのはやはりテストコードを読むといい。
今回の学び
- 近い将来Reactに
fetch
をCache APIでラップした実装が入る。Canaryなら今すぐ使える。
- これが入ればfetchに関してはUse APIと組み合わせてすぐにSuspenseなコンポーネントに使うことができるのですごく嬉しい。
- CacheのinvalidationはCache boundaryやSuspenseなどの単位で行われる。
- Next.jsのServer-sideで用意されているような、任意のCacheキーを指定して選択的にboundaryを横断してinvalidationするようなことは現状できなさそう。
- 今回読んだCache APIにまつわる詳しいscrapboxの記事があった → Built-in Suspense Cache APIをどう使うか - tosuke