From 459658a257e6917f88450d3c63feb69982e8042c Mon Sep 17 00:00:00 2001 From: Kris Baumgartner Date: Tue, 30 Apr 2024 16:15:23 -0700 Subject: [PATCH] feat(cache): promise based caching with workers --- packages/fiber/src/core/cache.ts | 74 +++++++++++++++++++++ packages/fiber/src/core/hooks.tsx | 103 ++++++++++++++++++------------ packages/shared/setupTests.ts | 42 ++++++++++++ 3 files changed, 178 insertions(+), 41 deletions(-) create mode 100644 packages/fiber/src/core/cache.ts diff --git a/packages/fiber/src/core/cache.ts b/packages/fiber/src/core/cache.ts new file mode 100644 index 0000000000..7debf9373e --- /dev/null +++ b/packages/fiber/src/core/cache.ts @@ -0,0 +1,74 @@ +export const promiseCaches = new Set() + +export class PromiseCache { + promises = new Map>() + cachePromise: Promise + + constructor(cache: string | Cache | Promise) { + this.cachePromise = Promise.resolve(cache).then((cache) => { + if (typeof cache === 'string') return caches.open(cache) + return cache + }) + + promiseCaches.add(this) + } + + async run(url: string, handler: (url: string) => any) { + if (this.promises.has(url)) { + return this.promises.get(url)! + } + + const promise = new Promise(async (resolve, reject) => { + const blob = await this.fetch(url) + const blobUrl = URL.createObjectURL(blob) + + try { + const result = await handler(blobUrl) + resolve(result) + } catch (error) { + reject(error) + } finally { + URL.revokeObjectURL(blobUrl) + } + }) + + this.promises.set(url, promise) + + return promise + } + + async fetch(url: string): Promise { + const cache = await this.cachePromise + + let response = await cache.match(url) + + if (!response) { + const fetchResponse = await fetch(url) + if (fetchResponse.ok) { + await cache.put(url, fetchResponse.clone()) + response = fetchResponse + } + } + + return response!.blob() + } + + add(url: string, promise: Promise) { + this.promises.set(url, promise) + } + + get(url: string) { + return this.promises.get(url) + } + + has(url: string) { + return this.promises.has(url) + } + + async delete(url: string): Promise { + this.promises.delete(url) + return this.cachePromise.then((cache) => cache.delete(url)) + } +} + +export const cacheName = 'assets' diff --git a/packages/fiber/src/core/hooks.tsx b/packages/fiber/src/core/hooks.tsx index f5df469fe2..b7f304d72e 100644 --- a/packages/fiber/src/core/hooks.tsx +++ b/packages/fiber/src/core/hooks.tsx @@ -5,6 +5,7 @@ import { context, RootState, RenderCallback, UpdateCallback, StageTypes, RootSto import { buildGraph, ObjectMap, is, useMutableCallback, useIsomorphicLayoutEffect, isObject3D } from './utils' import { Stages } from './stages' import type { Instance } from './reconciler' +import { PromiseCache, cacheName } from './cache' /** * Exposes an object's {@link Instance}. @@ -91,43 +92,39 @@ export type LoaderResult = T extends { scene: THREE.Object3D } ? T & ObjectMa export type Extensions = (loader: Loader) => void const memoizedLoaders = new WeakMap, Loader>() +const loaderCaches = new Map, PromiseCache>() const isConstructor = (value: unknown): value is LoaderProto => typeof value === 'function' && value?.prototype?.constructor === value -function loadingFn(extensions?: Extensions, onProgress?: (event: ProgressEvent) => void) { - return async function (Proto: Loader | LoaderProto, ...input: string[]) { - let loader: Loader - - // Construct and cache loader if constructor was passed - if (isConstructor(Proto)) { - loader = memoizedLoaders.get(Proto)! - if (!loader) { - loader = new Proto() - memoizedLoaders.set(Proto, loader) - } - } else { - loader = Proto +function prepareLoaderInstance(loader: Loader | LoaderProto, extensions?: Extensions): Loader { + let loaderInstance: Loader + + // Construct and cache loader if constructor was passed + if (isConstructor(loader)) { + loaderInstance = memoizedLoaders.get(loader)! + if (!loaderInstance) { + loaderInstance = new loader() + memoizedLoaders.set(loader, loaderInstance) } + } else { + loaderInstance = loader as Loader + } - // Apply loader extensions - if (extensions) extensions(loader) - - // Go through the urls and load them - return Promise.all( - input.map( - (input) => - new Promise>((res, reject) => - loader.load( - input, - (data) => res(isObject3D(data?.scene) ? Object.assign(data, buildGraph(data.scene)) : data), - onProgress, - (error) => reject(new Error(`Could not load ${input}: ${(error as ErrorEvent)?.message}`)), - ), - ), - ), - ) + // Apply loader extensions + if (extensions) extensions(loaderInstance) + + if (!loaderCaches.has(loaderInstance)) { + loaderCaches.set(loaderInstance, new PromiseCache(cacheName)) } + + return loaderInstance +} + +async function loadAsset(url: string, loaderInstance: Loader, onProgress?: (event: ProgressEvent) => void) { + const result = await loaderInstance.loadAsync(url, onProgress) + const graph = isObject3D(result?.scene) ? Object.assign(result, buildGraph(result.scene)) : result + return graph } /** @@ -141,14 +138,21 @@ export function useLoader( input: U, extensions?: Extensions, onProgress?: (event: ProgressEvent) => void, -) { - // Use suspense to load async assets - const keys = (Array.isArray(input) ? input : [input]) as string[] - const results = suspend(loadingFn(extensions, onProgress), [loader, ...keys], { equal: is.equ }) +): U extends any[] ? LoaderResult[] : LoaderResult { + const urls = (Array.isArray(input) ? input : [input]) as string[] + const loaderInstance = prepareLoaderInstance(loader, extensions) + const cache = loaderCaches.get(loaderInstance)! + + let results: any[] = [] + + for (const url of urls) { + if (!cache.has(url)) cache.run(url, async (cacheUrl) => loadAsset(cacheUrl, loaderInstance, onProgress)) + const result = React.use(cache.get(url)!) + results.push(result) + } + // Return the object(s) - return (Array.isArray(input) ? results : results[0]) as unknown as U extends any[] - ? LoaderResult[] - : LoaderResult + return (Array.isArray(input) ? results : results[0]) as any } /** @@ -159,8 +163,20 @@ useLoader.preload = function ( input: U, extensions?: Extensions, ): void { - const keys = (Array.isArray(input) ? input : [input]) as string[] - return preload(loadingFn(extensions), [loader, ...keys]) + const urls = (Array.isArray(input) ? input : [input]) as string[] + const loaderInstance = prepareLoaderInstance(loader, extensions) + const cache = loaderCaches.get(loaderInstance)! + + for (const url of urls) { + if (!cache.has(url)) cache.run(url, async (cacheUrl) => loadAsset(cacheUrl, loaderInstance)) + + // We do this hack to simulate having processed the the promise with `use` already. + const promise = cache.get(url)! as Promise & { status: 'pending' | 'fulfilled'; value: any } + promise.then((result) => { + promise.status = 'fulfilled' + promise.value = result + }) + } } /** @@ -170,6 +186,11 @@ useLoader.clear = function ( loader: Loader | LoaderProto, input: U, ): void { - const keys = (Array.isArray(input) ? input : [input]) as string[] - return clear([loader, ...keys]) + const urls = (Array.isArray(input) ? input : [input]) as string[] + const loaderInstance = prepareLoaderInstance(loader) + const cache = loaderCaches.get(loaderInstance)! + + for (const url of urls) { + cache.delete(url) + } } diff --git a/packages/shared/setupTests.ts b/packages/shared/setupTests.ts index 640b6f69d4..0932706db9 100644 --- a/packages/shared/setupTests.ts +++ b/packages/shared/setupTests.ts @@ -46,3 +46,45 @@ HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement) { // Extend catalogue for render API in tests extend(THREE as any) + +// Mock caches API +class MockCache { + store: Map + + constructor() { + this.store = new Map() + } + + async match(url: string) { + return this.store.get(url) + } + + async put(url: string, response: Response) { + this.store.set(url, response) + } + + async delete(url: string) { + return this.store.delete(url) + } +} + +class MockCacheStorage { + caches: Map + + constructor() { + this.caches = new Map() + } + + async open(cacheName: string) { + if (!this.caches.has(cacheName)) { + this.caches.set(cacheName, new MockCache()) + } + return this.caches.get(cacheName) + } + + async delete(cacheName: string) { + return this.caches.delete(cacheName) + } +} + +globalThis.caches = new MockCacheStorage() as any