Skip to content

Commit

Permalink
feat(cache): promise based caching with workers
Browse files Browse the repository at this point in the history
  • Loading branch information
krispya committed Apr 30, 2024
1 parent 1141515 commit 459658a
Show file tree
Hide file tree
Showing 3 changed files with 178 additions and 41 deletions.
74 changes: 74 additions & 0 deletions packages/fiber/src/core/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export const promiseCaches = new Set<PromiseCache>()

export class PromiseCache {
promises = new Map<string, Promise<any>>()
cachePromise: Promise<Cache>

constructor(cache: string | Cache | Promise<Cache>) {
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<any>(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<Blob> {
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<any>) {
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<boolean> {
this.promises.delete(url)
return this.cachePromise.then((cache) => cache.delete(url))
}
}

export const cacheName = 'assets'
103 changes: 62 additions & 41 deletions packages/fiber/src/core/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down Expand Up @@ -91,43 +92,39 @@ export type LoaderResult<T> = T extends { scene: THREE.Object3D } ? T & ObjectMa
export type Extensions<T> = (loader: Loader<T>) => void

const memoizedLoaders = new WeakMap<LoaderProto<any>, Loader<any>>()
const loaderCaches = new Map<Loader<any>, PromiseCache>()

const isConstructor = <T,>(value: unknown): value is LoaderProto<T> =>
typeof value === 'function' && value?.prototype?.constructor === value

function loadingFn<T>(extensions?: Extensions<T>, onProgress?: (event: ProgressEvent) => void) {
return async function (Proto: Loader<T> | LoaderProto<T>, ...input: string[]) {
let loader: Loader<any>

// 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<any> | LoaderProto<any>, extensions?: Extensions<any>): Loader<any> {
let loaderInstance: Loader<any>

// 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<any>
}

// Apply loader extensions
if (extensions) extensions(loader)

// Go through the urls and load them
return Promise.all(
input.map(
(input) =>
new Promise<LoaderResult<T>>((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<any>, 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
}

/**
Expand All @@ -141,14 +138,21 @@ export function useLoader<T, U extends string | string[] | string[][]>(
input: U,
extensions?: Extensions<T>,
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<T>[] : LoaderResult<T> {
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<T>[]
: LoaderResult<T>
return (Array.isArray(input) ? results : results[0]) as any
}

/**
Expand All @@ -159,8 +163,20 @@ useLoader.preload = function <T, U extends string | string[] | string[][]>(
input: U,
extensions?: Extensions<T>,
): 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<any> & { status: 'pending' | 'fulfilled'; value: any }
promise.then((result) => {
promise.status = 'fulfilled'
promise.value = result
})
}
}

/**
Expand All @@ -170,6 +186,11 @@ useLoader.clear = function <T, U extends string | string[] | string[][]>(
loader: Loader<T> | LoaderProto<T>,
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)
}
}
42 changes: 42 additions & 0 deletions packages/shared/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Response>

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<string, MockCache>

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

0 comments on commit 459658a

Please sign in to comment.