raf-effect-atom
TanStack Start
raf-effect-atom/tanstack-router makes a registry per request, puts the loader verbs on the router context, and streams the registry to the browser.
raf-effect-atom/tanstack-router connects the loader verbs to TanStack Router and TanStack Start. This site uses it too: each page is an atom its route's loader starts, prerendered to static HTML.
The router
getRouter runs once per request on the server and once in the browser:
export function function getRouter(): RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>
getRouter() {
// A request registry on the server, the browser's registry in the browser.
const const registry: AtomRegistryregistry = function makeRegistry(options?: {
readonly defaultIdleTTL?: number | undefined;
readonly initialValues?: InitialValues | undefined;
readonly seed?: Seed | undefined;
}): AtomRegistry
makeRegistry();
const const router: RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>
router = createRouter<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>(options: RouterConstructorOptions<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>): RouterCore<...>
createRouter({
routeTree?: Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined> | undefined
routeTree,
context: {
atoms: Loader.Loader;
}
context: { atoms: Loader.Loaderatoms: import LoaderLoader.const make: (registry: AtomRegistry, options?: Loader.MakeOptions) => Loader.Loadermake(const registry: AtomRegistryregistry) },
defaultPreload?: false | "intent" | "viewport" | "render" | undefineddefaultPreload: "intent",
});
setupRouterAtoms<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>>(options: RouterAtomsOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
atoms: Loader.Loader;
}, AnyContext, AnyContext, ... 6 more ..., undefined>, "never", false, RouterHistory, Record<...>>>): void
setupRouterAtoms({ RouterAtomsOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, { atoms: Loader.Loader; }, AnyContext, AnyContext, {}, undefined, ... 4 more ..., undefined>, "never", false, RouterHistory, Record<...>>>.router: RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>
router, RouterAtomsOptions<TRouter extends AnyRouter>.registry: AtomRegistryregistry });
return const router: RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>
router;
}makeRegistry()returns a request registry on the server and a browser registry in the browser. The browser's registry keeps an atom no component reads for 400 ms, as@effect/atom-react's default registry does;makeRegistry({ defaultIdleTTL: 60_000 })keeps it for a minute, so navigating back shows it at once. An atom's ownAtom.setIdleTTLorAtom.keepAlivewins.makeRegistry({ initialValues })starts both registries with values, asAtomRegistry.make'sinitialValuesdoes: pairs fromAtom.initialValue, such as the session the server verified. Unlikeregistry.setin a seed, they reach readable atoms, such as one that provides the request'sfetchto anAtomHttpApiclient's layer. The server's request registry keeps each of these atoms mounted until it's disposed. The browser's registry drops an atom no component reads, value included, so make one whose value must outlive its readersAtom.keepAlive.makeRegistry({ seed })runsseedon each request's registry before the loaders start atoms in it, to set what they need from the request, such as an atom holding its cookie for an RPC protocol that forwards it. It can be async;setupRouterAtomswaits for it, and for the atoms loaders started meanwhile, before it dehydrates. The browser's registry isn't seeded.Loader.make(registry)putsprefetch,ensure,fetchandprefetchInBrowseron the router context.setupRouterAtomswraps the app in the registry'sRegistryContext, addsHydration.dehydrate(registry)to the router's dehydrated state once the loaders have run, hydrates it in the browser before the app renders, and disposes the server's registry when the response finishes. It keeps the router's ownWrap,dehydrateandhydrate: itsWraprenders inside the registry's provider, and its dehydrated state gets one more key,raf-effect-atom.
An app that streams the registry its own way can leave setupRouterAtoms out and use the pieces it's made of: makeRegistry or RequestRegistry.make for the registry, Loader.make(registry) on the router context, RegistryContext around the app, and on the server, after the loaders, await RequestRegistry.settled(registry) then Hydration.dehydrate(registry) into its own dehydrated state, which the browser's hydrate passes to Hydration.hydrate(registry, state). The verbs, useAtomResult and the boundaries only need the registry to be a request registry on the server.
The root route types the context:
export const const Route: RootRoute<Register, undefined, {
atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>
Route = createRootRouteWithContext<{
atoms: Loader.Loader;
}>(): <TRegister, TRouteContextFn, TBeforeLoadFn, TSearchValidator, TLoaderDeps, TLoaderFn, TSSR, TServerMiddlewares>(options?: RootRouteOptions<TRegister, TSearchValidator, {
atoms: Loader.Loader;
}, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, TServerMiddlewares, undefined> | undefined) => RootRoute<...>
createRootRouteWithContext<{ atoms: Loader.Loaderatoms: import LoaderLoader.Loader }>()({
component?: RouteComponent | undefinedcomponent: () => (
// In development, a component that starts an atom no loader started throws.
<const WaterfallDetection: (props: {
readonly children?: React.ReactNode | undefined;
readonly mode: WaterfallMode;
}) => React.ReactNode
WaterfallDetection mode: Modemode="throw">
<const AppBoundary: ErrorBoundaryComponent<"RpcClientError">AppBoundary>
<const AuthBoundary: ErrorBoundaryComponent<"Unauthorized" | "RpcClientError">AuthBoundary>
<const Outlet: React.MemoExoticComponent<() => import("react").JSX.Element | null>Outlet />
</const AuthBoundary: ErrorBoundaryComponent<"Unauthorized" | "RpcClientError">AuthBoundary>
</const AppBoundary: ErrorBoundaryComponent<"RpcClientError">AppBoundary>
</const WaterfallDetection: (props: {
readonly children?: React.ReactNode | undefined;
readonly mode: WaterfallMode;
}) => React.ReactNode
WaterfallDetection>
),
});Loaders
export const const Route: Route<Register, RootRoute<Register, undefined, {
atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/issues/$id", "/issues/$id", "/issues/$id", "/issues/$id", undefined, {
readonly id: number & Brand<"IssueId">;
}, AnyContext, AnyContext, AnyContext, {}, ({ context, params }: LoaderFnContext<Register, RootRoute<Register, undefined, {
atoms: Loader;
}, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, ... 4 more ..., undefined>
Route = createFileRoute<"/issues/$id", RootRoute<Register, undefined, {
atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/issues/$id", "/issues/$id", "/issues/$id">(path?: "/issues/$id" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>
createFileRoute("/issues/$id")({
params?: {
parse?: ParseParamsFn<"/issues/$id", {
readonly id: number & Brand<"IssueId">;
}> | undefined;
priority?: number;
stringify?: StringifyParamsFn<"/issues/$id", {
readonly id: number & Brand<"IssueId">;
}> | undefined;
} | undefined
params: {
parse?: ParseParamsFn<"/issues/$id", {
readonly id: number & Brand<"IssueId">;
}> | undefined
parse: (params: {
id: string;
}
params) => import SchemaSchema.function decodeUnknownSync<Schema.Struct<{
readonly id: Schema.compose<Schema.brand<Schema.Int, "IssueId">, Schema.NumberFromString>;
}>>(schema: Schema.Struct<{
readonly id: Schema.compose<Schema.brand<Schema.Int, "IssueId">, Schema.NumberFromString>;
}>, options?: ParseOptions): (input: unknown, options?: ParseOptions) => {
readonly id: number & Brand<"IssueId">;
}
decodeUnknownSync(const IssueParams: Schema.Struct<{
readonly id: Schema.compose<Schema.brand<Schema.Int, "IssueId">, Schema.NumberFromString>;
}>
IssueParams)(params: {
id: string;
}
params),
stringify?: StringifyParamsFn<"/issues/$id", {
readonly id: number & Brand<"IssueId">;
}> | undefined
stringify: import SchemaSchema.const encodeSync: <Schema.Struct<{
readonly id: Schema.compose<Schema.brand<Schema.Int, "IssueId">, Schema.NumberFromString>;
}>>(schema: Schema.Struct<{
readonly id: Schema.compose<Schema.brand<Schema.Int, "IssueId">, Schema.NumberFromString>;
}>, options?: ParseOptions) => (input: {
readonly id: number & Brand<"IssueId">;
}, options?: ParseOptions) => {
readonly id: string;
}
encodeSync(const IssueParams: Schema.Struct<{
readonly id: Schema.compose<Schema.brand<Schema.Int, "IssueId">, Schema.NumberFromString>;
}>
IssueParams),
},
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, { atoms: Loader; }, AnyContext, AnyContext, {}, undefined, ... 4 more ..., undefined>, ... 12 more ..., undefined>.loader?: Constrain<({ context, params }: LoaderFnContext<Register, RootRoute<Register, undefined, {
atoms: Loader;
}, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader: async ({ context: {
atoms: Loader;
}
context, params: {
readonly id: number & Brand<"IssueId">;
}
params }) => ({
// Starts the assignee; it streams in while the page renders.
assignee: Handle<(issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>assignee: context: {
atoms: Loader;
}
context.atoms: Loaderatoms.Loader.prefetch: <(issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>(source: (issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>, issueId: number & Brand<"IssueId">) => Handle<(issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>prefetch(const assignee: (issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>assignee, params: {
readonly id: number & Brand<"IssueId">;
}
params.id: number & Brand<"IssueId">id),
// Waits for the issue, so the page renders with it.
issue: LoadedHandle<(id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issue: await context: {
atoms: Loader;
}
context.atoms: Loaderatoms.Loader.ensure: <(id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>(source: (id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>, id: number & Brand<"IssueId">) => Promise<LoadedHandle<(id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>>ensure(const issue: (id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue, params: {
readonly id: number & Brand<"IssueId">;
}
params.id: number & Brand<"IssueId">id),
}),
UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: () => (
<const IssueBoundary: ErrorBoundaryComponent<"IssueNotFound" | "Unauthorized" | "RpcClientError">IssueBoundary>
<function IssuePage(): React.JSX.ElementIssuePage />
</const IssueBoundary: ErrorBoundaryComponent<"IssueNotFound" | "Unauthorized" | "RpcClientError">IssueBoundary>
),
});
params.parse decodes $id with a Schema built on the contract's IssueId, the one the RPCs decode with, and params.stringify encodes it back. The loader, the page and links all take an IssueId, and an $id that isn't one goes to the route's error component.
Only atoms in the registry when the loaders finish are sent to the browser. An atom first read during render runs again there, so start every atom a page reads from its loader.
With defaultPreload: "intent", hovering a link runs its loader, so the atoms start before the click.