UNPKG

@tobq/loadable

Version:

A library for simplifying asynchronous operations in React

1,100 lines (1,020 loc) 36.3 kB
import { DependencyList, useCallback, useEffect, useRef, useState, } from "react" /** * Represents an integer timestamp (e.g. milliseconds since epoch or any monotonic count). * * @public */ export type TimeStamp = number /** * Returns the current time as a `TimeStamp`. * * @remarks * By default, this is just `Date.now()`. You can replace this with a custom * monotonic clock or high-resolution timer if desired. * * @returns The current time in milliseconds. * * @example * ```ts * const now = currentTimestamp() * console.log("The time is", now) * ``` * * @public */ export function currentTimestamp(): TimeStamp { return Date.now() } /** * Provides a stable function that, when called, creates (or re-creates) * an `AbortController` and returns its `signal`. * * @remarks * Each render, the same function reference is returned. Calling it effectively * aborts any in-flight request and starts a fresh `AbortController`. * * @returns A function which, when called, returns a fresh `AbortSignal`. * * @example * ```ts * function MyComponent() { * const createAbortSignal = useAbort() * * useEffect(() => { * const signal = createAbortSignal() * fetch('/api', { signal }).catch(e => { ... }) * }, []) * ... * } * ``` * * @public */ export function useAbort() { const abortControllerRef = useRef<AbortController | null>(null) return useCallback(() => { if (abortControllerRef.current) { // If we already had a controller, abort it abortControllerRef.current.abort() } abortControllerRef.current = new AbortController() return abortControllerRef.current.signal }, []) } // ------------------------------------------------------------------- // Loading Symbol + LoadingToken // ------------------------------------------------------------------- /** * A class-based token to represent a unique "loading" state instance. * * @remarks * Using a `LoadingToken` instead of the default `loading` symbol allows you * to store additional metadata—e.g., timestamps, request IDs, etc. This can * facilitate debugging or concurrency strategies that rely on distinct tokens. * * @example * ```ts * import { LoadingToken } from "./useLoadable" * * const token = new LoadingToken() * console.log("Loading started at:", token.startTime) * ``` * * @public */ export class LoadingToken { /** * Creates a new `LoadingToken`. * * @param startTime - When this token was created. Defaults to currentTimestamp(). */ constructor( public readonly startTime: TimeStamp = currentTimestamp() ) {} } /** * A unique symbol representing a "loading" state. * * @remarks * This symbol is used by default in loadable data when an async request is in-flight. * Using a symbol is a simple approach for representing loading without additional metadata. * * @public */ export const loading: unique symbol = Symbol("loading") /** * A union type that can be either the default `loading` symbol or a class-based `LoadingToken`. * * @public */ export type Loading = typeof loading | LoadingToken /** * Checks if the given value represents a "loading" state. * * @param value - The value to check. * @returns True if it’s either `loading` (symbol) or an instance of `LoadingToken`. * * @example * ```ts * if (isLoadingValue(loadable)) { * return <Spinner /> * } * ``` * * @public */ export function isLoadingValue(value: unknown): value is Loading { return value === loading || value instanceof LoadingToken } // ------------------------------------------------------------------- // Error for load failures // ------------------------------------------------------------------- /** * Represents an error that occurred while loading or fetching data. * * @remarks * Wraps the original `cause` and optionally overrides the error message. * * @example * ```ts * // If a fetch fails, we might return a LoadError instead of a generic Error. * throw new LoadError(err, "Failed to load user info") * ``` * * @public */ export class LoadError extends Error { /** * Creates a new `LoadError`. * * @param cause - The underlying reason for the load failure (e.g., an Error object). * @param message - An optional descriptive message. Defaults to the cause’s message. */ constructor(public readonly cause: unknown, message?: string) { super( message ?? (cause instanceof Error ? cause.message : String(cause)) ) } } // ------------------------------------------------------------------- // Loadable types // ------------------------------------------------------------------- /** * A union type that can be either a "start" (e.g., `loading`) or a "result" (success or failure). * * @remarks * - `Start` usually represents a `Loading` state. * - `Result` can be the successful data type `T` or `LoadError`. * * @public */ export type Reaction<Start, Result> = Start | Result /** * A `Loadable<T>` can be: * - `loading` or `LoadingToken` (in-flight), * - a loaded value of type `T`, or * - a `LoadError` (failed). * * @public */ export type Loadable<T> = Reaction<Loading, T | LoadError> /** * Extracts the loaded type from a `Loadable<T>`, excluding `loading` or `LoadError`. * * @public */ export type Loaded<T> = Exclude<T, Loading | LoadError> /** * Checks if a `Loadable<T>` has fully loaded (i.e., is neither loading nor an error). * * @param loadable - The loadable value to check. * @returns True if it’s the successful data of type `T`. * * @public */ export function hasLoaded<T>(loadable: Loadable<T>): loadable is Loaded<T> { return !isLoadingValue(loadable) && !loadFailed(loadable) } /** * Checks if a `Loadable<T>` is a load failure (`LoadError`). * * @param loadable - The loadable value to check. * @returns True if it’s a `LoadError`. * * @public */ export function loadFailed<T>(loadable: Loadable<T>): loadable is LoadError { return loadable instanceof LoadError } /** * Applies a mapper function to a loadable if it’s successfully loaded, returning a new loadable. * * @remarks * If `loadable` is an error or loading, it’s returned unchanged. * * @param loadable - The original loadable. * @param mapper - A function that transforms the loaded data `T` into `R`. * @returns A new loadable with data of type `R`, or the same loading/error state. * * @public */ export function map<T, R>(loadable: Loadable<T>, mapper: (loaded: T) => R): Loadable<R> { if (loadFailed(loadable)) return loadable if (isLoadingValue(loadable)) return loadable return mapper(loadable) } /** * Combines multiple loadables into one. If any are still loading or have failed, returns `loading`. * * @remarks * In reality, `all()` returns `loading` if ANY have not loaded. If all are loaded, it returns an array * of their loaded values (typed to match each item in `loadables`). * * @param loadables - The loadable values to combine. * @returns A single loadable that is `loading` if any item is not loaded, else an array of loaded items. * * @example * ```ts * const combined = all(userLoadable, postsLoadable, statsLoadable) * if (!hasLoaded(combined)) { * return <Spinner /> * } * const [user, posts, stats] = combined * ``` * * @public */ export function all<T extends Loadable<unknown>[]>(...loadables: T): Loadable<{ [K in keyof T]: Loaded<T[K]> }> { if (loadables.some(l => !hasLoaded(l))) { return loading } return loadables.map(l => l) as { [K in keyof T]: Loaded<T[K]> } } /** * Converts a loadable to `undefined` if not fully loaded, or the loaded value otherwise. * * @param loadable - The loadable value to unwrap. * @returns `T` if loaded, otherwise `undefined`. * * @public */ export function toOptional<T>(loadable: Loadable<T>): T | undefined { return hasLoaded(loadable) ? loadable : undefined } /** * Returns the loaded value if `loadable` is fully loaded, otherwise `defaultValue`. * * @param loadable - The loadable value to unwrap. * @param defaultValue - The fallback if loadable is not loaded. * @returns The loaded `T` or the provided `defaultValue`. * * @public */ export function orElse<T, R>(loadable: Loadable<T>, defaultValue: R): T | R { return hasLoaded(loadable) ? loadable : defaultValue } /** * Checks if a loadable is fully loaded AND not null/undefined. * * @param loadable - A loadable that could be `null` or `undefined` once loaded. * @returns True if the loadable is successfully loaded and non-nullish. * * @public */ export function isUsable<T>(loadable: Loadable<T | null | undefined>): loadable is T { return hasLoaded(loadable) && loadable != null } // ------------------------------------------------------------------- // Basic fetcher type // ------------------------------------------------------------------- /** * A function type that fetches data and returns a promise, using an `AbortSignal`. * * @param signal - The `AbortSignal` to handle cancellations. * @returns A promise resolving to the fetched data of type `T`. * * @public */ export type Fetcher<T> = (signal: AbortSignal) => Promise<T> // ------------------------------------------------------------------- // Caching shapes // ------------------------------------------------------------------- /** * Defines the shape of a cache option with a key and an optional store. * * @public */ export interface CacheOption { /** * The key to store in the cache (e.g., "myUserData"). */ key: string /** * The store used for caching. Defaults to `"localStorage"`. */ store?: "memory" | "localStorage" | "indexedDB" } /** * Parses a `cache` field that could be a string or an object, returning a normalized object. * * @param cache - Either a string or `{ key, store }`. * @returns An object with `key` and `store`. * * @internal */ function parseCacheOption( cache?: string | CacheOption ): { key?: string; store: "memory" | "localStorage" | "indexedDB" } { if (!cache) { return { key: undefined, store: "localStorage" } } if (typeof cache === "string") { // If user passed a string, that is the cache key, default to localStorage return { key: cache, store: "localStorage" } } // Otherwise, user passed an object { key, store? } return { key: cache.key, store: cache.store ?? "localStorage", } } // ------------------------------------------------------------------- // Our caching utilities // ------------------------------------------------------------------- /** @internal */ const memoryCache = new Map<string, unknown>() /** * Reads data from the specified cache store. * * @internal * @param key - The cache key. * @param store - Which store to use ("memory", "localStorage", or "indexedDB"). * @returns The cached data or `undefined` if not found. */ async function readCache<T>( key: string, store: "memory" | "localStorage" | "indexedDB" ): Promise<T | undefined> { switch (store) { case "memory": { return memoryCache.get(key) as T | undefined } case "localStorage": { const json = window.localStorage.getItem(key) if (!json) return undefined try { return JSON.parse(json) as T } catch { return undefined } } case "indexedDB": { return await readFromIndexedDB<T>(key) } } } /** * Writes data to the specified cache store. * * @internal * @param key - The cache key. * @param data - The data to store. * @param store - The store to use. */ async function writeCache<T>( key: string, data: T, store: "memory" | "localStorage" | "indexedDB" ): Promise<void> { switch (store) { case "memory": { memoryCache.set(key, data) break } case "localStorage": { window.localStorage.setItem(key, JSON.stringify(data)) break } case "indexedDB": { await writeToIndexedDB(key, data) break } } } /** * Opens (and initializes) an IndexedDB database named "myReactCacheDB" with an object store "idbCache". * * @internal * @returns A promise resolving to the opened IDBDatabase. */ function openCacheDB(): Promise<IDBDatabase> { return new Promise((resolve, reject) => { const request = indexedDB.open("myReactCacheDB", 1) request.onupgradeneeded = () => { const db = request.result if (!db.objectStoreNames.contains("idbCache")) { db.createObjectStore("idbCache") } } request.onsuccess = () => resolve(request.result) request.onerror = () => reject(request.error) }) } /** * Reads an item from the "idbCache" store in our "myReactCacheDB" IndexedDB. * * @internal */ async function readFromIndexedDB<T>(key: string): Promise<T | undefined> { const db = await openCacheDB() return new Promise<T | undefined>((resolve, reject) => { const tx = db.transaction("idbCache", "readonly") const store = tx.objectStore("idbCache") const getReq = store.get(key) getReq.onsuccess = () => resolve(getReq.result) getReq.onerror = () => reject(getReq.error) }) } /** * Writes an item to the "idbCache" store in our "myReactCacheDB" IndexedDB. * * @internal */ async function writeToIndexedDB<T>(key: string, data: T): Promise<void> { const db = await openCacheDB() return new Promise<void>((resolve, reject) => { const tx = db.transaction("idbCache", "readwrite") const store = tx.objectStore("idbCache") const putReq = store.put(data, key) putReq.onsuccess = () => resolve() putReq.onerror = () => reject(putReq.error) }) } // ------------------------------------------------------------------- // Options for useLoadable // ------------------------------------------------------------------- /** * The options object for `useLoadable`. * * @typeParam T - The data type we expect to load. * * @public */ export interface UseLoadableOptions<T = any> { /** * A prefetched loadable value, if available (used instead of calling the fetcher). */ prefetched?: Loadable<T> /** * An optional callback for load errors. Called with the raw error object. */ onError?: (error: unknown) => void /** * If true, once we have a loaded value, do **not** revert to `loading` * on subsequent fetches; instead, keep the old value until the new fetch * finishes or fails. */ hideReload?: boolean /** * Caching configuration. Can be: * - A string: used as the cache key (store defaults to `"localStorage"`). * - An object: `{ key: string, store?: "memory" | "localStorage" | "indexedDB" }`. */ cache?: string | CacheOption } // ------------------------------------------------------------------- // A custom hook for state with timestamps // ------------------------------------------------------------------- /** * A hook that manages a piece of state (`T`) alongside a timestamp, allowing you * to ignore stale updates with older timestamps. * * @remarks * Internally, it stores the current `value` plus a `loadStart` timestamp. Each time * you set a new value, you can provide an optional new timestamp. If that timestamp * is older than the current state's `loadStart`, the update is ignored. * * @param initial - The initial state value. * @returns A tuple: `[value, setValue, loadStart]`. * * @example * ```ts * const [myValue, setMyValue, lastUpdated] = useLatestState(0) * * function handleUpdate(newVal: number) { * // We'll pass a timestamp * setMyValue(newVal, performance.now()) * } * ``` * * @public */ export function useLatestState<T>( initial: T ): [T, (value: T | ((current: T) => T), loadStart?: TimeStamp) => void, TimeStamp] { const [value, setValue] = useState<{ value: T loadStart: TimeStamp }>({ value: initial, loadStart: 0, }) function updateValue( newValue: T | ((current: T) => T), loadStart: TimeStamp = currentTimestamp() ) { setValue(current => { if (current.loadStart > loadStart) { // Ignore older updates return current } const nextValue = typeof newValue === "function" ? (newValue as (c: T) => T)(current.value) : newValue return { value: nextValue, loadStart, } }) } return [value.value, updateValue, value.loadStart] } // ------------------------------------------------------------------- // For debugging (optional) // ------------------------------------------------------------------- /** * A Set of timestamps indicating which requests are currently in-flight. * * @remarks * This is used internally for debugging and to signal "prerenderReady" when no requests remain. * Attach it to `window` in dev environments if desired. * * @internal */ const currentlyLoading = new Set<number>() // @ts-ignore if (typeof window !== "undefined") { ;(window as any).currentlyLoading = currentlyLoading } // ------------------------------------------------------------------- // Overloads for useLoadable // ------------------------------------------------------------------- /** * Overload: `useLoadable(waitable, readyCondition, fetcher, dependencies, optionsOrOnError?)` */ export function useLoadable<W, R>( waitable: W, readyCondition: (loaded: W) => boolean, fetcher: (loaded: W, abort: AbortSignal) => Promise<R>, dependencies: DependencyList, optionsOrOnError?: ((e: unknown) => void) | UseLoadableOptions<R> ): Loadable<R> /** * Overload: `useLoadable(fetcher, deps, options?)` */ export function useLoadable<T>( fetcher: Fetcher<T>, deps: DependencyList, options?: UseLoadableOptions<T> ): Loadable<T> /** * The core hook that returns a `Loadable<T>` by calling an async fetcher. * * @remarks * Has two main usage patterns: * 1. **Waitable** form: You pass a "waitable" value plus a `readyCondition`, and a `fetcher`. * - The effect only runs when `readyCondition(waitable)` is true. * - If `hideReload` is false, it will revert to loading each time the waitable changes. * * 2. **Simple** form: You pass just a `fetcher`, dependencies, and optional `UseLoadableOptions`. * - The fetcher is called whenever dependencies change. * - The result is stored in a loadable: `loading` until success or `LoadError` on failure. * * Caching: * - If `cache` is provided (string or `{ key, store }`), it tries to read from that cache first. * If found, returns it immediately. Then (optionally) re-fetches in the background, or * according to `hideReload`. * * @typeParam T - The successful data type when using the simple form. * @typeParam W - The waitable type (for advanced usage). * @typeParam R - The successful data type when using the waitable form. * * @public */ export function useLoadable<T, W, R>( fetcherOrWaitable: Fetcher<T> | W, depsOrReadyCondition: DependencyList | ((loaded: W) => boolean), optionsOrFetcher?: | UseLoadableOptions<T> | ((loaded: W, abort: AbortSignal) => Promise<R>), dependencies: DependencyList = [], lastParam?: ((e: unknown) => void) | UseLoadableOptions<R> ): Loadable<T> | Loadable<R> { // ============================ // CASE 1: waitable + readyCondition + fetcher // ============================ if (typeof depsOrReadyCondition === "function") { const waitable = fetcherOrWaitable as W const readyCondition = depsOrReadyCondition as (loaded: W) => boolean const fetcher = optionsOrFetcher as ( loaded: W, abort: AbortSignal ) => Promise<R> let onErrorCb: ((e: unknown) => void) | undefined let hideReload = false let cacheObj: ReturnType<typeof parseCacheOption> = { key: undefined, store: "localStorage", } if (typeof lastParam === "function") { onErrorCb = lastParam } else if (lastParam && typeof lastParam === "object") { onErrorCb = lastParam.onError hideReload = !!lastParam.hideReload cacheObj = parseCacheOption(lastParam.cache) } const [value, setValue] = useLatestState<Loadable<R>>(loading) const abort = useAbort() const ready = readyCondition(waitable) useEffect(() => { const startTime = currentTimestamp() // If hideReload=false or not yet loaded, revert to 'loading' if (!hideReload || !hasLoaded(value)) { setValue(loading, startTime) } if (ready) { // Before fetching, try reading from cache (if provided) if (cacheObj.key) { // Attempt to read ;(async () => { const cachedData = await readCache<R>( cacheObj.key!, cacheObj.store ) if (cachedData !== undefined) { // We found a valid cached value // You could do stale-while-revalidate or just set it: setValue(cachedData, startTime) } doFetch() })() } else { doFetch() } function doFetch() { currentlyLoading.add(startTime) const signal = abort() fetcher(waitable, signal) .then(result => { // On success, write to cache if key if (cacheObj.key) { writeCache(cacheObj.key, result, cacheObj.store).catch( console.error ) } setValue(result, startTime) }) .catch(e => { onErrorCb?.(e) setValue(new LoadError(e), startTime) }) .finally(() => { currentlyLoading.delete(startTime) if ( currentlyLoading.size === 0 && typeof window !== "undefined" && "prerenderReady" in window ) { ;(window as any).prerenderReady = true } }) } } return () => { abort() currentlyLoading.delete(startTime) } }, [...dependencies, ready, hideReload]) return value } // ============================ // CASE 2: fetcher + deps + options // ============================ const fetcher = fetcherOrWaitable as Fetcher<T> const deps = depsOrReadyCondition as DependencyList const options = optionsOrFetcher as UseLoadableOptions<T> | undefined // Parse the cache field const { key: cacheKey, store: cacheStore } = parseCacheOption(options?.cache) // We'll piggyback on the waitable approach, with a "dummy" waitable always ready return useLoadable( loading, () => true, async (_ignored, signal) => { // // 1) Attempt to read from cache (if we have cacheKey) // if (cacheKey) { const cachedData = await readCache<T>(cacheKey, cacheStore) if (cachedData !== undefined) { // Found a valid cached value return cachedData } } // // 2) If there's a prefetched loadable // if (options?.prefetched !== undefined) { if (options.prefetched === loading) { return fetcher(signal) } else if (options.prefetched instanceof LoadError) { throw options.prefetched } else if (isLoadingValue(options.prefetched)) { // e.g. a LoadingToken return fetcher(signal) } else { // Otherwise it's a T if (cacheKey) { await writeCache(cacheKey, options.prefetched, cacheStore) } return options.prefetched } } // // 3) Normal fetch // const data = await fetcher(signal) if (cacheKey) { await writeCache(cacheKey, data, cacheStore) } return data }, deps, { onError: options?.onError, hideReload: options?.hideReload, } ) as Loadable<T> } // ------------------------------------------------------------------- // useThen + useAllThen // ------------------------------------------------------------------- /** * A hook that waits for a `loadable` to finish, then calls another async `fetcher`. * * @remarks * If `loadable` is still loading or has failed, this hook returns the same `loadable` state. * Otherwise, if `loadable` is loaded, it calls `fetcher(loadedValue)` and returns the result * as a new `Loadable<R>`. * * @param loadable - The initial loadable value. * @param fetcher - A function that takes the successfully loaded data plus an abort signal, returning a promise. * @param dependencies - An optional list of dependencies to trigger re-runs. Defaults to `[hasLoaded(loadable)]`. * @param options - Optional `UseLoadableOptions` for error handling, caching, etc. * @returns A `Loadable<R>` that is `loading` until the chained fetch finishes, or a `LoadError` if it fails. * * @example * ```ts * const user = useLoadable(() => fetchUser(userId), [userId]) * const posts = useThen(user, (u) => fetchPostsForUser(u.id)) * ``` * * @public */ export function useThen<T, R>( loadable: Loadable<T>, fetcher: (loaded: T, abort: AbortSignal) => Promise<R>, dependencies: DependencyList = [hasLoaded(loadable)], options?: UseLoadableOptions<R> ): Loadable<R> { return useLoadable( loadable, l => hasLoaded(l), async (val, abort) => map(val, v => fetcher(v, abort)), dependencies, options ) } /** @internal */ type UnwrapLoadable<T> = T extends Loadable<infer U> ? U : never /** @internal */ type LoadableParameters<T extends Loadable<any>[]> = { [K in keyof T]: UnwrapLoadable<T[K]> } /** * A hook that waits for multiple loadables to finish, then calls a `fetcher` using all their loaded values. * * @remarks * Internally, it calls `all(...loadables)`. If any loadable is still loading or fails, the combined is `loading`. * Once all are loaded, calls `fetcher(...loadedValues, signal)` and returns a `Loadable<R>`. * * @param loadables - An array (spread) of loadable values, e.g. `[user, stats, posts]`. * @param fetcher - A function that takes each loaded value plus an `AbortSignal`. * @param dependencies - An optional list of dependencies to re-run the effect. Defaults to the loadables array. * @param options - Optional config for error handling, caching, etc. * @returns A loadable result of type `R`. * * @example * ```ts * const user = useLoadable(fetchUser, []) * const stats = useLoadable(fetchStats, []) * * const combined = useAllThen( * [user, stats], * (u, s, signal) => fetchDashboard(u, s, signal), * [] * ) * ``` * * @public */ export function useAllThen<T extends Loadable<any>[], R>( loadables: [...T], fetcher: (...args: [...LoadableParameters<T>, AbortSignal]) => Promise<R>, dependencies: DependencyList = loadables, options?: UseLoadableOptions<R> ): Loadable<R> { const combined = all(...loadables) return useThen( combined, (vals, signal) => fetcher(...(vals as LoadableParameters<T>), signal), dependencies, options ) } // ------------------------------------------------------------------- // useLoadableWithCleanup // ------------------------------------------------------------------- /** * Overload: `useLoadableWithCleanup(waitable, readyCondition, fetcher, deps, optionsOrOnError?)`. */ export function useLoadableWithCleanup<W, R>( waitable: W, readyCondition: (loaded: W) => boolean, fetcher: (loaded: W, abort: AbortSignal) => Promise<R>, dependencies: DependencyList, optionsOrOnError?: ((e: unknown) => void) | UseLoadableOptions<R> ): [Loadable<R>, () => void] /** * Overload: `useLoadableWithCleanup(fetcher, deps, options?)`. */ export function useLoadableWithCleanup<T>( fetcher: Fetcher<T>, deps: DependencyList, options?: UseLoadableOptions<T> ): [Loadable<T>, () => void] /** * A variant of `useLoadable` that returns a `[Loadable<T>, cleanupFunc]` tuple. * * @remarks * This lets you manually call `cleanupFunc()` to abort any in-flight request, * instead of waiting for an unmount or effect re-run. * * @returns A tuple: `[Loadable<T>, cleanupFunc]`. * * @example * ```ts * const [userLoadable, cleanup] = useLoadableWithCleanup(fetchUser, []) * * // Manually abort the current fetch: * cleanup() * ``` * * @public */ export function useLoadableWithCleanup<T, W, R>( fetcherOrWaitable: Fetcher<T> | W, depsOrReadyCondition: DependencyList | ((loaded: W) => boolean), optionsOrFetcher?: | UseLoadableOptions<T> | ((loaded: W, abort: AbortSignal) => Promise<R>), dependencies: DependencyList = [], lastParam?: ((e: unknown) => void) | UseLoadableOptions<R> ): [Loadable<T> | Loadable<R>, () => void] { const [value, setValue] = useLatestState<Loadable<any>>(loading) const abortControllerRef = useRef<AbortController | null>(null) let isCase1 = false let waitableVal: W | undefined let readyFn: ((w: W) => boolean) | undefined let actualFetcher: ((w: W, signal: AbortSignal) => Promise<any>) | undefined let hideReload = false let onErrorCb: ((e: unknown) => void) | undefined let deps: DependencyList let cacheObj = parseCacheOption() if (typeof depsOrReadyCondition === "function") { // CASE 1 isCase1 = true waitableVal = fetcherOrWaitable as W readyFn = depsOrReadyCondition as (w: W) => boolean actualFetcher = optionsOrFetcher as (w: W, signal: AbortSignal) => Promise<R> deps = dependencies if (typeof lastParam === "function") { onErrorCb = lastParam } else if (lastParam && typeof lastParam === "object") { onErrorCb = lastParam.onError hideReload = !!lastParam.hideReload cacheObj = parseCacheOption(lastParam.cache) } } else { // CASE 2 const fetcher = fetcherOrWaitable as Fetcher<T> deps = depsOrReadyCondition as DependencyList const options = optionsOrFetcher as UseLoadableOptions<T> | undefined onErrorCb = options?.onError hideReload = !!options?.hideReload cacheObj = parseCacheOption(options?.cache) // always "ready" readyFn = () => true // The actual fetcher that either reads from cache or calls the original fetcher actualFetcher = async (_ignored: W, signal: AbortSignal) => { // Read from cache if possible if (cacheObj.key) { const cachedData = await readCache<T>(cacheObj.key, cacheObj.store) if (cachedData !== undefined) { return cachedData } } // If prefetched is available if (options?.prefetched !== undefined) { if (options.prefetched === loading) { return fetcher(signal) } else if (options.prefetched instanceof LoadError) { throw options.prefetched } else if (isLoadingValue(options.prefetched)) { return fetcher(signal) } else { // T if (cacheObj.key) { await writeCache(cacheObj.key, options.prefetched, cacheObj.store) } return options.prefetched } } // Normal fetch const data = await fetcher(signal) if (cacheObj.key) { await writeCache(cacheObj.key, data, cacheObj.store) } return data } } useEffect(() => { const startTime = currentTimestamp() const isReady = readyFn?.(waitableVal as W) ?? true // If hideReload=false or current is not loaded, revert to 'loading' if (!hideReload || !hasLoaded(value)) { setValue(loading, startTime) } if (isReady && actualFetcher) { abortControllerRef.current = new AbortController() const signal = abortControllerRef.current.signal currentlyLoading.add(startTime) actualFetcher(waitableVal as W, signal) .then(result => { setValue(result, startTime) }) .catch(e => { onErrorCb?.(e) setValue(new LoadError(e), startTime) }) .finally(() => { currentlyLoading.delete(startTime) if ( currentlyLoading.size === 0 && typeof window !== "undefined" && "prerenderReady" in window ) { ;(window as any).prerenderReady = true } }) } return () => { abortControllerRef.current?.abort() currentlyLoading.delete(startTime) } }, [ isCase1, waitableVal, readyFn, actualFetcher, hideReload, onErrorCb, value, setValue, ...deps, ]) /** * Cancels any current request immediately. This is the second element * in the returned tuple from `useLoadableWithCleanup`. */ const cleanupFunc = useCallback(() => { abortControllerRef.current?.abort() }, []) return [value, cleanupFunc] }