Rindle

API index and search · Build metadata

Source snapshot

packages/tanstack/src/index.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
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