diff --git a/src/components/cache.tsx b/src/components/cache.tsx index 0081fc85..5a671f7c 100644 --- a/src/components/cache.tsx +++ b/src/components/cache.tsx @@ -1,15 +1,13 @@ import { createCacheComponent } from "@rsc-cache/next"; -import { cache } from "react"; import fs from "fs/promises"; import { kv } from "~/lib/server/kv/index.server"; import { DEFAULT_CACHE_TTL } from "~/lib/shared/constants"; +import { lifetimeCache } from "~/lib/shared/lifetime-cache"; -const devBuildId = new Date().getTime().toString(); -const getBuildId = cache(async () => { - if (process.env.NODE_ENV === "development") { - return devBuildId; - } - return await fs.readFile(".next/BUILD_ID", "utf-8"); +const getBuildId = lifetimeCache(async () => { + return process.env.NODE_ENV === "development" + ? new Date().getTime().toString() + : await fs.readFile(".next/BUILD_ID", "utf-8"); }); export const Cache = createCacheComponent({ diff --git a/src/lib/shared/fn-cache.ts b/src/lib/shared/fn-cache.ts deleted file mode 100644 index 65e70727..00000000 --- a/src/lib/shared/fn-cache.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { DEFAULT_CACHE_TTL } from "~/lib/shared/constants"; - -/** - * Custom `cache` function as `React.cache` doesn't work in the server, - * this function is intended to be used with `React.use` but you can - * use it to memoize function calls. - */ -export function fnCache Promise>(fn: T) { - const cache = new PromiseCache>>(500); - - return function cachedFn( - ...args: Parameters - ): Promise>> { - return cache.get(args, async () => await fn(...args)); - }; -} - -/** - * LRU Cache utility for usage with fnCache - */ -class PromiseCache { - #cache: Map< - any[], - { value: Promise; timestamp: number; fetchFn: () => Promise } - >; - #maxSize: number; - - constructor(maxSize: number) { - this.#cache = new Map(); - this.#maxSize = maxSize; - } - - get(args: any[], fetchFn: () => Promise): Promise { - const existingKey = Array.from(this.#cache.keys()).find((key) => { - return ( - args.length === key.length && - args.every((arg, index) => arg === key[index]) - ); - }); - - const cachedItem = existingKey ? this.#cache.get(existingKey) : null; - const now = Date.now(); - - if (cachedItem && now - cachedItem.timestamp <= DEFAULT_CACHE_TTL * 1000) { - // Return the cached value if it's not stale - return cachedItem.value; - } - - /** - * We override the promise result and add `status` - * so that if used with `React.use`, - * the component doesn't suspend if the function is preloaded. - */ - const pending = fetchFn() - .then((value) => { - // @ts-expect-error - if (pending.status === "pending") { - const fulfilledThenable = pending as any; - fulfilledThenable.status = "fulfilled"; - fulfilledThenable.value = value; - } - return value; - }) - .catch((error) => { - // @ts-expect-error - if (pending.status === "pending") { - const rejectedThenable = pending as any; - rejectedThenable.status = "rejected"; - rejectedThenable.reason = error; - } - throw error; - }); - // @ts-expect-error - pending.status = "pending"; - - // Fetch or revalidate the data - const value = pending; - this.set(args, value, fetchFn); - return value; - } - - private set( - key: any[], - valuePromise: Promise, - fetchFn: () => Promise - ): void { - if (this.#cache.size >= this.#maxSize && !this.#cache.has(key)) { - // Evict the least recently used item - const firstKey = this.#cache.keys().next().value; - this.#cache.delete(firstKey); - } - - this.#cache.set(key, { - value: valuePromise, - timestamp: Date.now(), - fetchFn - }); - } -} diff --git a/src/lib/shared/lifetime-cache.ts b/src/lib/shared/lifetime-cache.ts new file mode 100644 index 00000000..04b9700c --- /dev/null +++ b/src/lib/shared/lifetime-cache.ts @@ -0,0 +1,127 @@ +/** + * Custom `cache` but for caching items for the scope for the lifetime of the server, + * on the client it will cache the result until the tab is refreshed. + * + * It has the same behavior as React `cache`, when called with the same arguments, it will + * return the cached result, if you give it other arguments, it will return a different result + * and cache that. + * + * However this cache is not limitless, it acts as an LRU cache with only a limited set of items in it + * and evicts the least recently used items. + */ +export function lifetimeCache Promise>( + fn: T +) { + const cache = new PromiseCache>>(500); + + return function cachedFn( + ...args: Parameters + ): Thenable>> { + return cache.get(args, async () => await fn(...args)); + }; +} + +/** + * LRU Cache utility for usage with fnCache + */ +class PromiseCache { + #cache: Map< + any[], + { + value: Thenable; + fetchFn: () => Promise; + } + >; + #maxSize: number; + + constructor(maxSize: number) { + this.#cache = new Map(); + this.#maxSize = maxSize; + } + + get(args: any[], fetchFn: () => Promise): Thenable { + const existingKey = Array.from(this.#cache.keys()).find((key) => { + return ( + args.length === key.length && + args.every((arg, index) => arg === key[index]) + ); + }); + + const cachedItem = existingKey ? this.#cache.get(existingKey) : null; + + if (cachedItem) { + return cachedItem.value; + } + + /** + * We override the promise result and add `status` + * so that if used with `React.use`, + * the component doesn't suspend if the function is preloaded. + */ + const thenableValue = fetchFn() as Thenable; + + thenableValue + .then((value) => { + if (thenableValue.status === "pending") { + const fulfilledThenable = + thenableValue as unknown as FulfilledThenable; + fulfilledThenable.status = "fulfilled"; + fulfilledThenable.value = value; + } + return value; + }) + .catch((error) => { + if (thenableValue.status === "pending") { + const rejectedThenable = + thenableValue as unknown as RejectedThenable; + rejectedThenable.status = "rejected"; + rejectedThenable.reason = error; + } + throw error; + }); + thenableValue.status = "pending"; + + // Fetch or revalidate the data + this.set(args, thenableValue, fetchFn); + return thenableValue; + } + + private set( + key: any[], + valuePromise: Thenable, + fetchFn: () => Promise + ): void { + if (this.#cache.size >= this.#maxSize && !this.#cache.has(key)) { + // Evict the least recently used item + const firstKey = this.#cache.keys().next().value; + this.#cache.delete(firstKey); + } + + this.#cache.set(key, { + value: valuePromise, + fetchFn + }); + } +} + +/** + * From the React `use` internals + */ +interface FulfilledThenable extends Promise { + status: "fulfilled"; + value: T; +} + +interface PendingThenable extends Promise { + status: "pending"; +} + +interface RejectedThenable extends Promise { + status: "rejected"; + reason: any; +} + +export type Thenable = + | FulfilledThenable + | PendingThenable + | RejectedThenable;