/** * Handle definition for accumulating data across route segments. * * Handles allow server-side route handlers to pass accumulated data to client * components. Unlike loaders (which fetch data for specific routes), handles * accumulate data across all matched route segments. * * @example * ```ts * // Define a handle (name auto-generated from file + export) * export const Breadcrumbs = createHandle(); * * // Use in handler * const push = ctx.use(Breadcrumbs); * push({ label: "Home", href: "/" }); * * // Consume on client * const crumbs = useHandle(Breadcrumbs); * ``` */ export interface Handle { /** * Brand to distinguish handles from loaders in ctx.use() */ readonly __brand: "handle"; /** * Auto-generated unique ID for this handle (used as key in storage) * Format: "filePath#ExportName" in dev, "hash#ExportName" in production */ readonly $$id: string; } /** * Default collect function that flattens segment arrays into a single array. */ function defaultCollect(segments: T[][]): T[] { return segments.flat(); } // Module-level registry mapping $$id to collect functions. // Populated when createHandle() runs (both server and client). // Used by useHandle() to recover collect when handle is deserialized from RSC prop. const collectRegistry = new Map unknown>(); /** * Look up a collect function from the registry by handle $$id. * Returns undefined if not registered (falls back to defaultCollect in useHandle). */ export function getCollectFn( id: string, ): ((segments: unknown[][]) => unknown) | undefined { return collectRegistry.get(id); } /** * Create a handle definition for accumulating data across route segments. * * The $$id is auto-generated by the Vite exposeInternalIds plugin based on * file path and export name. No manual naming required. * * @param collect - Optional collect function (default: flatten into array) * @param __injectedId - Auto-injected by Vite plugin, do not provide manually * * @example * ```ts * // Default: flatten into array * export const Breadcrumbs = createHandle(); * // Result type: BreadcrumbItem[] * * // Custom: last value wins * export const PageTitle = createHandle( * (segments) => segments.flat().at(-1) ?? "Default Title" * ); * // Result type: string * * // Custom: object merge * export const Meta = createHandle, MetaTags>( * (segments) => Object.assign({ robots: "index,follow" }, ...segments.flat()) * ); * // Result type: MetaTags * * // Custom: dedupe by href * export const Breadcrumbs = createHandle( * (segments) => { * const all = segments.flat(); * return all.filter((item, i) => all.findIndex(x => x.href === item.href) === i); * } * ); * ``` */ export function createHandle( collect?: (segments: TData[][]) => TAccumulated, __injectedId?: string, ): Handle { const handleId = __injectedId ?? ""; if (!handleId && process.env.NODE_ENV === "development") { throw new Error( "[rango] Handle is missing $$id. " + "Make sure the exposeInternalIds Vite plugin is enabled and " + "the handle is exported with: export const MyHandle = createHandle(...)", ); } const collectFn = collect ?? (defaultCollect as unknown as (segments: TData[][]) => TAccumulated); // Register collect in module-level registry so useHandle() can recover it // when the handle is deserialized from RSC props (toJSON strips collect). if (handleId) { collectRegistry.set( handleId, collectFn as (segments: unknown[][]) => unknown, ); } return { __brand: "handle" as const, $$id: handleId, }; } /** * Type guard to check if a value is a Handle. */ export function isHandle(value: unknown): value is Handle { return ( typeof value === "object" && value !== null && "__brand" in value && (value as { __brand: unknown }).__brand === "handle" ); } /** * Collect handle data from a HandleData map, applying the handle's collect * function over segments in order. Shared between server-side rendered() * reads and client-side useHandle(). * * @param handle - The handle to collect data for * @param data - Full handle data map (handleName -> segmentId -> entries[]) * @param segmentOrder - Segment IDs in parent -> child resolution order */ export function collectHandleData( handle: Handle, data: Record>, segmentOrder: string[], ): TAccumulated { const collectFn = getCollectFn(handle.$$id); if (!collectFn && process.env.NODE_ENV !== "production") { console.warn( `[rango] Handle "${handle.$$id}" has no registered collect function. ` + `Falling back to flat array. Ensure the handle module is imported so ` + `createHandle() runs and registers the collect function.`, ); } const collect = (collectFn ?? (defaultCollect as unknown as (segments: unknown[][]) => unknown)) as ( segments: TData[][], ) => TAccumulated; const segmentData = data[handle.$$id]; if (!segmentData) return collect([]); const segmentArrays: TData[][] = []; for (const segmentId of segmentOrder) { const entries = segmentData[segmentId]; if (entries && entries.length > 0) { segmentArrays.push(entries as TData[]); } } return collect(segmentArrays); }