"use client"; import { isValidElement, startTransition, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { OutletContext, type OutletContextValue } from "./outlet-context.js"; import { loaderStore, type LoaderEntry } from "./loader-store.js"; import type { LoaderDefinition, LoadOptions } from "./types.js"; /** * A shareable GET — a `load()` call that reads data (GET or defaulted method) * with no request body. Params are allowed. This is the gate for keyed sharing: * when a hook is given an explicit `key`, every shareable GET writes to the * keyed bucket so co-keyed readers (including parameterized views) refresh * together. Non-GET methods and body-bearing calls are mutations and stay local * to the call site. */ function isShareableGet(options: LoadOptions | undefined): boolean { if (!options) return true; if (options.method && options.method !== "GET") return false; if ("body" in options && (options as { body?: unknown }).body !== undefined) { return false; } return true; } /** * Plain route-context refetch — a `load()` call with no options or a * trivially-defaulted GET (no params, no body). Results from these are * broadcast to every component reading the same loader id via the shared * store, so a layout's refetch button updates page + parallel-slot reads * automatically. * * Calls with explicit `params`, an explicit non-GET method, or a `body` * stay local to the call site — that preserves the today-semantics of * `useFetchLoader(SearchLoader).load({ params: { q } })` style code where * each component owns its own fetched view. (An explicit `key` opts a * parameterized GET back into sharing; see `isShareableGet`.) */ function isPlainRefetch(options: LoadOptions | undefined): boolean { if (!isShareableGet(options)) return false; if (options?.params && Object.keys(options.params).length > 0) return false; return true; } // Per-hook unique suffix for grouped reads that have no explicit `key`. Such a // read must NOT share the bare `loader.$$id` bucket, or a cross-loader group // refresh would leak into unrelated unkeyed readers of the same loader (which // the contract keeps local). Sharing within a group is opt-in via an explicit // `key`; without one, each grouped read gets its own private bucket. The value // is only ever used as a client-side store bucket key (never rendered), so the // counter has no SSR/hydration consistency requirement. let privateGroupBucketSeq = 0; /** * Extract a specific loader's data from a content ReactNode. * * When a route registers loaders via loader(), the resolved data lives in * the route's OutletProvider (rendered as content). Parallel * slots are siblings of , so they can't find it by walking * the parent context chain. This helper traverses wrapper elements * (MountContextProvider, ViewTransition, etc.) to reach the OutletProvider * and extract the loader data directly. */ const NOT_FOUND = Symbol("not-found"); function extractContentLoaderData( node: ReactNode, loaderId: string, ): unknown | typeof NOT_FOUND { if (!isValidElement(node)) return NOT_FOUND; const props = node.props as Record | undefined; if (!props) return NOT_FOUND; // Direct OutletProvider with loaderData if (props.loaderData && loaderId in props.loaderData) { return props.loaderData[loaderId]; } // LoaderBoundary: loaderIds + loaderDataPromise (already resolved array). // When the segment has loading(), loaderData is resolved inside // LoaderBoundary via use(). If the promise was pre-awaited (forceAwait // or isAction), the prop is a raw array we can index into. if ( props.loaderIds && Array.isArray(props.loaderIds) && props.loaderDataPromise && !(props.loaderDataPromise instanceof Promise) ) { const idx = (props.loaderIds as string[]).indexOf(loaderId); if (idx !== -1) { const data = (props.loaderDataPromise as any[])[idx]; // loaderDataPromise entries may be { ok, data } result objects if (data && typeof data === "object" && "ok" in data) { return data.ok ? data.data : NOT_FOUND; } return data; } } // Traverse into wrapper elements (MountContextProvider, ViewTransition, // Suspense wrappers, etc.) if (props.children) return extractContentLoaderData(props.children, loaderId); return NOT_FOUND; } /** * Payload returned by loader RSC requests */ interface LoaderRscPayload { loaderResult: T; loaderError?: { message: string; name: string }; } /** * Load function type for fetching loader data from the client */ export type LoadFunction = (options?: LoadOptions) => Promise; /** * Result type for useLoader hook (strict - data is required) */ export interface UseLoaderResult { /** The loaded data - guaranteed to exist when loader is registered on route */ data: T; /** True while a load() is in progress */ isLoading: boolean; /** Error from the most recent load attempt, null if successful */ error: Error | null; /** Function to trigger a fetch (only works if loader is fetchable) */ load: LoadFunction; /** Alias for load */ refetch: LoadFunction; } /** * Result type for useFetchLoader hook (flexible - data is optional) */ export interface UseFetchLoaderResult { /** The loaded data - may be undefined if not yet fetched or not in context */ data: T | undefined; /** True while a load() is in progress */ isLoading: boolean; /** Error from the most recent load attempt, null if successful */ error: Error | null; /** Function to trigger a fetch (only works if loader is fetchable) */ load: LoadFunction; /** Alias for load */ refetch: LoadFunction; } /** * Options for useLoader hook */ export interface UseLoaderOptions { /** * If true (default), errors from load() will be thrown to the nearest error boundary. * If false, errors are only captured in the `error` state. * @default true */ throwOnError?: boolean; /** * Client refresh key. Partitions the shared refresh store so that only hooks * using the same `key` refresh together when one of them calls `load()`. * * Without a `key` (default), a plain `load()` on a route-registered loader * broadcasts to every reader of that loader, and any parameterized / unregistered * load stays local to the calling hook. With a `key`: * - readers of the same loader that share a `key` form one refresh group — * a `load()` from any of them updates the whole group, including * parameterized GETs; * - readers with different keys are independent; * - it works even when the loader is NOT registered on the route (keyed * `useFetchLoader`), letting unrelated components opt into sharing. * * This is a client-side refresh identity only. It is unrelated to the server * `cache({ key })` option and to `revalidate()`; it never changes the request * sent to the server. */ key?: string; /** * Cross-loader refresh group. Tag reads of DIFFERENT loaders with the same * `refreshGroup` name, then call `useRefreshLoaders(name)()` to refresh the * whole group at once. Each member is refreshed with a plain GET against the * current route URL — no params, no body, no mutation methods — because a * group spans heterogeneous loaders with different param/return shapes. * * For parameterized sharing of a SINGLE loader, use `key` instead; group * members should be registered or non-parameterized-keyed reads (a plain-GET * group refresh would drop any per-call params). */ refreshGroup?: string; } /** * Internal hook implementation shared by useLoader and useFetchLoader */ function useLoaderInternal( loader: LoaderDefinition, options?: UseLoaderOptions, ): UseFetchLoaderResult { const context = useContext(OutletContext); // Get data from context (SSR/navigation). `hasContextData` distinguishes // "loader registered on the route, value happens to be undefined" from // "loader is not in any parent's context at all". The shared store is // only consulted when the loader really is in route context — that // preserves per-component isolation for ad-hoc useFetchLoader callers // who use the same fetchable loader without registering it. const { contextData, hasContextData } = useMemo((): { contextData: T | undefined; hasContextData: boolean; } => { let current: OutletContextValue | null | undefined = context; while (current) { if (current.loaderData && loader.$$id in current.loaderData) { return { contextData: current.loaderData[loader.$$id] as T, hasContextData: true, }; } // Check content element — the route's OutletProvider is rendered as // content (a child), so its loaderData isn't in the parent // chain. Parallel slots need to reach into it to find route-level loaders. const contentData = extractContentLoaderData( current.content, loader.$$id, ); if (contentData !== NOT_FOUND) { return { contextData: contentData as T, hasContextData: true }; } current = current.parent; } return { contextData: undefined, hasContextData: false }; }, [context, loader.$$id]); // Shared subscription: every component reading the same loader id sees // the same snapshot, so a plain refetch from one component propagates to // the others. Mirrors the convention used by useParams / useLinkStatus — // useState seeded from the store, useEffect subscribes for updates and // calls setState inside startTransition so subscriber re-renders don't // trip Suspense fallbacks during a refetch (matches the per-hook // startTransition the old code wrapped setFetchedData in). const loaderId = loader.$$id; // Client refresh key. The shared store is partitioned by bucket key so that // only hooks with the same `key` refresh together. Default (no key) keeps the // historical behavior: one bucket per loader id. const key = options?.key; const refreshGroup = options?.refreshGroup; // A grouped reader with no explicit key gets a private per-hook bucket so a // cross-loader group refresh cannot leak into the bare `loader.$$id` bucket // shared by unrelated unkeyed readers. Sharing within a group is opt-in via // an explicit `key`. const privateBucketIdRef = useRef(null); if ( refreshGroup !== undefined && key === undefined && privateBucketIdRef.current === null ) { privateBucketIdRef.current = `__rg${privateGroupBucketSeq++}`; } const effectiveKey = key ?? (refreshGroup !== undefined ? privateBucketIdRef.current! : undefined); const bucketKey = effectiveKey === undefined ? loaderId : `${loaderId}::${effectiveKey}`; // Plain-GET refresh thunk registered with the store for cross-loader group // refresh (useRefreshLoaders). Always shares into this hook's bucket, never // touches lastSharedRequestIdRef (so a group refresh never render-throws — // errors surface via `error` and reject the refreshGroup() promise instead), // and sends no params/body. Stable across navigations (depends only on // loaderId + bucketKey), so the store keeps one current thunk per bucket. const groupRefetch = useCallback(async (): Promise => { if (!loaderId) return; const requestId = loaderStore.reserveRequestId(bucketKey); loaderStore.beginRequest(bucketKey, requestId); try { const url = new URL(window.location.href); url.searchParams.set("_rsc_loader", loaderId); const response = fetch(url.toString(), { method: "GET", headers: { Accept: "text/x-component" }, }); const { createFromFetch } = await import("./deps/browser.js"); const payload = await createFromFetch>(response); if (payload.loaderError) { throw new Error(payload.loaderError.message); } loaderStore.finishData(bucketKey, requestId, payload.loaderResult); } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); loaderStore.finishError(bucketKey, requestId, err); throw err; } finally { loaderStore.setLoading(bucketKey, requestId, false); } }, [loaderId, bucketKey]); const [sharedState, setSharedState] = useState<{ bucketKey: string; snapshot: LoaderEntry; }>(() => ({ bucketKey, snapshot: loaderStore.getSnapshot(bucketKey), })); const sharedSnapshot = sharedState.bucketKey === bucketKey ? sharedState.snapshot : loaderStore.getSnapshot(bucketKey); useEffect(() => { // Sync any value the store committed between this hook's lazy // initializer and effect-time (e.g. a sibling that mounted earlier // already triggered a load()). const initial = loaderStore.getSnapshot(bucketKey); if (initial !== sharedSnapshot) { startTransition(() => { setSharedState({ bucketKey, snapshot: initial }); }); } // ephemeral: a reader with no route context has no route-context reset // trigger, so its keyed bucket is reference-counted by the store. A // route-registered reader makes the bucket sticky (reset via clearFamily). return loaderStore.subscribe( bucketKey, () => { const next = loaderStore.getSnapshot(bucketKey); startTransition(() => { setSharedState({ bucketKey, snapshot: next }); }); }, { loaderId, ephemeral: !hasContextData, group: refreshGroup, refetch: refreshGroup !== undefined ? groupRefetch : undefined, }, ); // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: // sharedSnapshot is captured for the one-shot init sync; we don't want // to re-subscribe on every snapshot change. bucketKey, hasContextData, // refreshGroup, and groupRefetch are the only inputs that require a fresh // subscription (groupRefetch is stable per bucketKey). }, [bucketKey, hasContextData, refreshGroup, groupRefetch]); // Local state holds the result of: // - parameterized / mutation `load()` calls (load({ params }), POST, // etc.) — stay scoped so concurrent same-loader different-params // fetches don't clobber each other through the shared store; // - any `load()` made by hooks that are NOT in route context (i.e. // useFetchLoader of an unregistered loader) — keeping those local // prevents two unrelated components from accidentally sharing data // through the global store just because they reference the same // loader id. const [localFetchedData, setLocalFetchedData] = useState( undefined, ); const [localIsLoading, setLocalIsLoading] = useState(false); const [localError, setLocalError] = useState(null); // Local request id, mirrors the per-hook gating the previous // implementation provided. Two quick parameterized loads from the same // hook (e.g. load({ params: { q: "a" } }) then load({ params: { q: "b" } })) // can resolve out of order — only the latest must commit. const localRequestIdRef = useRef(0); // Tracks the request id of the most recent SHARED load() this hook // initiated. The render-throw rule below uses it to scope the throw // to the originating hook only — sibling readers see the error in // `error` but don't blow up their own boundaries. const lastSharedRequestIdRef = useRef(null); // Reset on navigation. clear() bumps the entry's latest request id so // any pre-navigation load() promise that resolves later fails its gate // and is dropped — fixes the race where a stale fetch overwrites the // new route's context. const prevContextDataRef = useRef(contextData); useEffect(() => { if (prevContextDataRef.current !== contextData) { setLocalFetchedData(undefined); setLocalIsLoading(false); setLocalError(null); lastSharedRequestIdRef.current = null; // Reset every sticky bucket of this loader (keyed or not). Ephemeral // (unregistered keyed) buckets are left to their refcount lifecycle. loaderStore.clearFamily(loaderId); prevContextDataRef.current = contextData; } }, [contextData, loaderId]); // Read priority: a parameterized load() result overrides the shared // snapshot; the shared snapshot overrides the server-seeded context. const data = localFetchedData ?? (sharedSnapshot.value as T | undefined) ?? contextData; const isLoading = localIsLoading || sharedSnapshot.isLoading; const error = localError ?? sharedSnapshot.error; const throwOnError = options?.throwOnError ?? true; // Refs for values used inside load() that should NOT cause callback identity // churn. loader.$$id can change if a reusable component receives a different // loader without remounting; data changes on every navigation. Refs keep the // callback stable while always reading the latest values. const loaderIdRef = useRef(loaderId); loaderIdRef.current = loaderId; const bucketKeyRef = useRef(bucketKey); bucketKeyRef.current = bucketKey; const dataRef = useRef(data); dataRef.current = data; const hasContextDataRef = useRef(hasContextData); hasContextDataRef.current = hasContextData; // Load function for fetching data via the ?_rsc_loader endpoint. // Supports GET (data fetching) and POST/PUT/PATCH/DELETE (mutations). const load = useCallback( async (loadOptions?: LoadOptions): Promise => { const id = loaderIdRef.current; if (!id) { throw new Error( `Loader is missing $$id. Make sure the exposeLoaderId Vite plugin is enabled.`, ); } const bucket = bucketKeyRef.current; // A dedicated bucket means this read owns a bucket distinct from the bare // loader id — either an explicit `key` (`$$id::key`) or a refreshGroup's // private bucket (`$$id::`). const hasDedicatedBucket = bucket !== id; // Deciding shared vs local: // - With a dedicated bucket, every shareable GET (params allowed) writes // to that bucket — the key/group is an explicit opt-in to sharing, and // a direct load() must land in the same bucket a group refresh uses. // - On the bare loader-id bucket, sharing is only correct when the // loader is registered on the route and the call is a plain refetch — // otherwise two unrelated components calling load() on the same // fetchable loader would overwrite each other's local view. // Mutations (non-GET / body) stay local in both cases. const shared = hasDedicatedBucket ? isShareableGet(loadOptions) : isPlainRefetch(loadOptions) && hasContextDataRef.current; let sharedRequestId = -1; let localRequestId = -1; if (shared) { sharedRequestId = loaderStore.reserveRequestId(bucket); lastSharedRequestIdRef.current = sharedRequestId; // beginRequest flips loading on AND clears any prior error so a // throwOnError: false consumer doesn't keep showing the stale // error during the retry. Gated on requestId === latest. loaderStore.beginRequest(bucket, sharedRequestId); } else { localRequestId = ++localRequestIdRef.current; setLocalIsLoading(true); setLocalError(null); } try { const url = new URL(window.location.href); url.searchParams.set("_rsc_loader", id); const method = loadOptions?.method ?? "GET"; const isBodyMethod = method !== "GET"; let fetchOptions: RequestInit; if (isBodyMethod) { const bodyValue = "body" in (loadOptions ?? {}) ? (loadOptions as any).body : undefined; const hasParams = loadOptions?.params && Object.keys(loadOptions.params).length > 0; if (bodyValue instanceof FormData) { // FormData body — send as multipart/form-data (preserves File objects). // Params are appended as a JSON string in a special field. if (hasParams) { bodyValue.set( "_rsc_loader_params", JSON.stringify(loadOptions!.params), ); } fetchOptions = { method, headers: { Accept: "text/x-component" }, body: bodyValue, }; } else { // JSON body — send params and body as JSON const bodyPayload: { params?: Record; body?: unknown; } = {}; if (hasParams) { bodyPayload.params = loadOptions!.params; } if (bodyValue !== undefined) { bodyPayload.body = bodyValue; } fetchOptions = { method, headers: { Accept: "text/x-component", "Content-Type": "application/json", }, body: JSON.stringify(bodyPayload), }; } } else { // GET - send params in query string if ( loadOptions?.params && Object.keys(loadOptions.params).length > 0 ) { url.searchParams.set( "_rsc_loader_params", JSON.stringify(loadOptions.params), ); } fetchOptions = { method: "GET", headers: { Accept: "text/x-component", }, }; } const response = fetch(url.toString(), fetchOptions); const { createFromFetch } = await import("./deps/browser.js"); const payload = await createFromFetch>(response); if (payload.loaderError) { throw new Error(payload.loaderError.message); } const result = payload.loaderResult; if (shared) { // finishData is gated on requestId; a stale response is dropped. loaderStore.finishData(bucket, sharedRequestId, result); } else if (localRequestId === localRequestIdRef.current) { // Local-branch gate, mirrors the shared-branch requestId check: // if a newer load() was issued from this hook before this one // resolved, drop the stale result. startTransition(() => { setLocalFetchedData(result); setLocalIsLoading(false); }); } return result; } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); if (shared) { loaderStore.finishError(bucket, sharedRequestId, err); } else if (localRequestId === localRequestIdRef.current) { setLocalError(err); setLocalIsLoading(false); } if (throwOnError) { throw err; } // When throwOnError is false, return the latest data snapshot (previous // successful value or undefined). Caller should check error state. return dataRef.current as T; } finally { if (shared) { // setLoading is gated; only the latest request flips the flag off. loaderStore.setLoading(bucket, sharedRequestId, false); } } }, [throwOnError], ); // Throw during render if there's an error and throwOnError is true. // - Local errors always belong to this hook, so always throw on opt-in. // - Shared errors throw only when this hook initiated the failing // request (entry.requestId matches lastSharedRequestIdRef). Sibling // readers expose the error via `error` but do not throw, so a // throwOnError: true reader never explodes because of someone else's // throwOnError: false load() failure. if (throwOnError) { if (localError) throw localError; if ( sharedSnapshot.error && lastSharedRequestIdRef.current !== null && sharedSnapshot.requestId === lastSharedRequestIdRef.current ) { throw sharedSnapshot.error; } } return { data, isLoading, error, load, refetch: load, }; } /** * Hook to access loader data from route context (strict version) * * Use this when the loader is registered on the route via `loader()`. * The data is guaranteed to exist - throws an error if not found. * * For on-demand fetching or when loader might not be in context, * use `useFetchLoader` instead. * * @param loader - The loader definition (must be registered on route) * @param options - Optional configuration * @returns Object with data (guaranteed), isLoading, error, load, and refetch * @throws Error if loader data is not found in context * * @example Basic usage - accessing route loader data * ```tsx * "use client"; * import { useLoader } from "rsc-router/client"; * import { CartLoader } from "../loaders/cart"; * * // In route definition: loader(CartLoader) * * export function CartIcon() { * const { data } = useLoader(CartLoader); * // data is guaranteed to be CartData, not CartData | undefined * return Cart ({data.items.length}); * } * ``` */ export function useLoader( loader: LoaderDefinition, options?: UseLoaderOptions, ): UseLoaderResult> { const result = useLoaderInternal(loader, options); // Strict mode: throw if data is not in context if (result.data === undefined) { throw new Error( `useLoader: Loader "${loader.$$id}" data not found in context. ` + `Make sure the loader is registered on the route with loader(). ` + `If you need on-demand fetching, use useFetchLoader() instead.`, ); } return result as UseLoaderResult>; } /** * Hook to access loader data with optional fetching (flexible version) * * Use this when: * - The loader might not be registered on the route * - You want to fetch data on-demand from the client * - You're building a reusable component that doesn't assume route context * * If the loader IS registered on the route, it will still get the initial * data from context - you just have to handle the `undefined` case in types. * * @param loader - The loader definition * @param options - Optional configuration * @returns Object with data (may be undefined), isLoading, error, load, and refetch * * @example On-demand fetching * ```tsx * "use client"; * import { useFetchLoader } from "rsc-router/client"; * import { SearchLoader } from "../loaders/search"; * * export function SearchResults() { * const { data, load, isLoading } = useFetchLoader(SearchLoader); * * const handleSearch = async (query: string) => { * await load({ params: { query } }); * }; * * return ( *
* * {isLoading && Loading...} * {data?.results.map(r =>
{r.name}
)} *
* ); * } * ``` * * @example With route context (hybrid usage) * ```tsx * // Loader registered on route: loader(UserLoader) * // useFetchLoader still works - gets initial data from context * const { data, load } = useFetchLoader(UserLoader); * // data is UserData | undefined (even though it will have initial value) * ``` */ export function useFetchLoader( loader: LoaderDefinition, options?: UseLoaderOptions, ): UseFetchLoaderResult> { return useLoaderInternal(loader, options) as UseFetchLoaderResult< Rango.FlightSerialize >; } /** * Refresh every loader tagged with a shared `refreshGroup` name. * * Returns a stable async function that refreshes all currently-mounted reads * in the group with a plain GET against the current route URL. This is the * cross-loader counterpart to the single-loader `key`: use it to refresh a set * of DIFFERENT loaders together (e.g. profile + orders after an account * switch). Members are tagged via `useLoader(Loader, { refreshGroup })` / * `useFetchLoader(Loader, { refreshGroup })`. * * Group refresh never render-throws: a failing member surfaces its error via * that read's `error` state, and the returned promise rejects with an * `AggregateError` of the failures so the caller can handle them at the await * site. Each loader is refreshed in place — no params, no body, no mutations. * * @example * ```tsx * "use client"; * import { useLoader, useRefreshLoaders } from "rsc-router/client"; * * function Profile() { * const { data } = useLoader(ProfileLoader, { key: userId, refreshGroup: "account" }); * return {data.name}; * } * function Orders() { * const { data } = useLoader(OrdersLoader, { key: userId, refreshGroup: "account" }); * return {data.count} orders; * } * function RefreshButton() { * const refreshAccount = useRefreshLoaders("account"); * return ; * } * ``` */ export function useRefreshLoaders(group: string): () => Promise { return useCallback(() => loaderStore.refreshGroup(group), [group]); }