API index and search · Build metadata
Source snapshot
packages/tanstack/src/index.ts
1// @rindle/tanstack — the route-level bridge between TanStack Router and Rindle. Routes declare2// data intent; this adapter owns SSR/client branching, loader dehydration, navigation readiness,3// stale re-entry behavior, cancellation, and the SSR-seed → live-store provider handoff.45import { createElement, useMemo, type ReactNode } from "react";6import { useMatches } from "@tanstack/react-router";7import { stableKey } from "@rindle/client";8import type {9 AnyQuery,10 ColsMap,11 DehydratedState,12 EnsureQueryUntil,13 QueryEnsurer,14 Schema,15 Store,16} from "@rindle/client";17import { RindleSSR } from "@rindle/react";1819/** The loader-data field serialized by TanStack Start and consumed by {@link Provider}. */20export const RINDLE_LOADER_DATA_KEY = "rindle" as const;2122/** The stable subset of TanStack's loader context exposed to route query factories. TanStack passes23 * a richer object at runtime; keeping this structural avoids coupling the adapter to a generated24 * router's route-tree generics while still contextually typing the common fields. */25export interface RindleLoaderContext {26 abortController: AbortController;27 preload: boolean;28 params: Record<string, string>;29 deps: Record<string, unknown>;30 context: unknown;31 location: unknown;32 cause: "preload" | "enter" | "stay";33}3435export interface RindleLoaderData {36 rindle: DehydratedState;37}3839export interface RindleTanStackClient<S extends ColsMap> extends QueryEnsurer {40 store: Store<S>;41}4243export interface RindleTanStackOptions<S extends ColsMap> {44 schema: Schema<S>;45 /** Browser-only lazy client boot. The adapter calls it at most once and shares that client46 * between route loaders and the provider. It is never called during SSR. */47 boot: () => Promise<RindleTanStackClient<S>>;48 /** Server-side named-query preload. A dynamic implementation keeps server authority code out of49 * the browser bundle; the full loader context is forwarded for request-scoped auth/policy. */50 preload: (51 queries: readonly AnyQuery[],52 context: RindleLoaderContext,53 ) => Promise<DehydratedState>;54}5556type QueryList = AnyQuery | readonly AnyQuery[];5758export interface RindleRouteLoaderOptions<Context extends RindleLoaderContext = RindleLoaderContext> {59 /** Query (or queries) that blocks client route entry and is always included in the SSR seed. */60 query?: (context: Context) => QueryList;61 /** Additional queries needed for SSR first paint but not worth blocking client navigation on. */62 ssr?: (context: Context) => QueryList;63 /** Client readiness policy for `query`. Defaults to local-first `present`; set `complete` when64 * route entry must wait for a server-authoritative result. */65 until?: EnsureQueryUntil;66}6768export interface RindleRouteLoader<Context extends RindleLoaderContext = RindleLoaderContext> {69 handler(context: Context): Promise<RindleLoaderData>;70 /** Re-entry must not commit a stale match while its Rindle query is being warmed. */71 staleReloadMode: "blocking";72}7374export interface RindleProviderProps {75 children?: ReactNode;76}7778export interface RindleTanStackIntegration<S extends ColsMap> {79 loader<Context extends RindleLoaderContext = RindleLoaderContext>(80 options: RindleRouteLoaderOptions<Context>,81 ): RindleRouteLoader<Context>;82 Provider(props: RindleProviderProps): ReturnType<typeof createElement>;83}8485/**86 * Bind one Rindle client/server pair to TanStack Router. The returned `loader` is used by routes;87 * `Provider` replaces the app's manual `useMatches()` dehydration merge plus `<RindleSSR>` glue.88 */89export function createRindleTanStack<S extends ColsMap>(90 options: RindleTanStackOptions<S>,91): RindleTanStackIntegration<S> {92 let bootPromise: Promise<RindleTanStackClient<S>> | undefined;93 const boot = (): Promise<RindleTanStackClient<S>> => {94 // Both TanStack loaders and <RindleSSR> can be the first browser caller. Cache the in-flight95 // result so they always retain and render through the same live store.96 bootPromise ??= Promise.resolve().then(() => options.boot());97 return bootPromise;98 };99100 const loader = <Context extends RindleLoaderContext = RindleLoaderContext>(101 route: RindleRouteLoaderOptions<Context>,102 ): RindleRouteLoader<Context> => ({103 staleReloadMode: "blocking",104 handler: async (context: Context): Promise<RindleLoaderData> => {105 if (!route.query && !route.ssr) {106 throw new Error("rindle.loader: expected at least one query or ssr query.");107 }108 const primary = route.query ? queryList(route.query(context)) : [];109 if (typeof window === "undefined") {110 const extras = route.ssr ? queryList(route.ssr(context)) : [];111 return { rindle: await options.preload(uniqueQueries([...primary, ...extras]), context) };112 }113114 // An SSR-only declaration contributes to the first document render, but it should be a no-op115 // when TanStack invokes the same loader during browser navigation.116 if (primary.length === 0) return { rindle: {} };117118 const client = await boot();119 await Promise.all(120 primary.map((query) =>121 client.ensure(query, {122 until: route.until ?? "present",123 signal: context.abortController.signal,124 }),125 ),126 );127 return { rindle: {} };128 },129 });130131 function Provider({ children }: RindleProviderProps): ReturnType<typeof createElement> {132 const matches = useMatches() as readonly { loaderData?: unknown }[];133 const ssrState = useMemo(134 () => mergeRindleLoaderData(matches.map((match) => match.loaderData)),135 [matches],136 );137 return createElement(RindleSSR<S>, {138 schema: options.schema,139 ssrState,140 boot,141 children,142 });143 }144145 return { loader, Provider };146}147148/** Merge the serialized Rindle slice from every matched route. Exported for custom providers/tests. */149export function mergeRindleLoaderData(loaderData: readonly unknown[]): DehydratedState {150 const merged: DehydratedState = {};151 for (const data of loaderData) {152 if (data === null || typeof data !== "object") continue;153 const slice = (data as Partial<RindleLoaderData>)[RINDLE_LOADER_DATA_KEY];154 // Later matched routes intentionally win when two slices contain the same query key.155 if (slice) Object.assign(merged, slice);156 }157 return merged;158}159160function queryList(value: QueryList): readonly AnyQuery[] {161 return Array.isArray(value) ? value : [value as AnyQuery];162}163164function uniqueQueries(queries: readonly AnyQuery[]): readonly AnyQuery[] {165 const seen = new Set<string>();166 return queries.filter((query) => {167 const remote = typeof query.name === "string" ? { name: query.name, args: query.args } : undefined;168 const key = stableKey({ ast: query.ast(), remote });169 if (seen.has(key)) return false;170 seen.add(key);171 return true;172 });173}174