From 6a282594ed4f9479e0691e1e34e0eba5a1b33f53 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Wed, 19 Aug 2026 14:51:09 +0100 Subject: [PATCH 1/3] feat(atoms)!: one asyncRouteAtom API for async route data `resolvedAtom` and `asyncRouteAtom` were two entry points over one mechanism: `asyncRouteAtom` already built on `resolvedAtom`, and their loader types were the same shape under two names. They differed only in whether the load gates the parent route's match. That axis is now which member you read, not which function you call. `asyncRouteAtom(route, name, load)` exposes `.data` - a plain async atom of the loaded value, with no bearing on matching and no lifecycle to wire up, exactly what `resolvedAtom` was. Reading the returned atom as a route instead gates the match on the load, as before. Promoting data loading to route gating is dropping `.data`. BREAKING CHANGE: `resolvedAtom` is removed. `resolvedAtom(route, load)` becomes `asyncRouteAtom(route, name, load).data`, where `name` is what the loaded object binds to if the route is ever used for gating. `followResolvedRedirects` is renamed `followAsyncRedirects` and takes `.data` atoms. `preloadRoutes` is renamed `preloadAsyncRoutes`, matching `hydrateAsyncRoutes`/`followAsyncRoutes`. The `Resolver` and `RouteLookup` types are replaced by one `RouteLoader`, and `AsyncRouteAtom.lookup` is renamed `data`. `hydrateAsyncRoutes`, `followAsyncRoutes` and `AsyncRouteSnapshot` are unchanged. Ticket: 675 --- .../src/__tests__/asyncRouteAtom.test.ts | 185 ++++++++++++++---- .../src/__tests__/resolvedAtom.test.ts | 111 ----------- packages/jarl-atoms/src/asyncRouteAtom.ts | 119 +++++++---- packages/jarl-atoms/src/index.ts | 1 - packages/jarl-atoms/src/redirectAtom.ts | 2 +- packages/jarl-atoms/src/resolvedAtom.ts | 74 ------- 6 files changed, 226 insertions(+), 266 deletions(-) delete mode 100644 packages/jarl-atoms/src/__tests__/resolvedAtom.test.ts delete mode 100644 packages/jarl-atoms/src/resolvedAtom.ts diff --git a/packages/jarl-atoms/src/__tests__/asyncRouteAtom.test.ts b/packages/jarl-atoms/src/__tests__/asyncRouteAtom.test.ts index 613bfb3..096feff 100644 --- a/packages/jarl-atoms/src/__tests__/asyncRouteAtom.test.ts +++ b/packages/jarl-atoms/src/__tests__/asyncRouteAtom.test.ts @@ -1,18 +1,24 @@ -import { createStore } from "jotai/vanilla"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { asyncRouteAtom, followAsyncRoutes, hydrateAsyncRoutes, preloadRoutes } from "../asyncRouteAtom"; +import { atom, createStore } from "jotai/vanilla"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + asyncRouteAtom, + followAsyncRedirects, + followAsyncRoutes, + hydrateAsyncRoutes, + preloadAsyncRoutes, +} from "../asyncRouteAtom"; import { locationAtom } from "../locationAtom"; import { notAtom } from "../notAtom"; import { paramRouteAtom } from "../paramRouteAtom"; -import { redirect } from "../redirectAtom"; +import { isRedirect, redirect } from "../redirectAtom"; import { staticRouteAtom } from "../staticRouteAtom"; type Post = { slug: string; title: string }; const POSTS: Post[] = [{ slug: "hello", title: "Hello" }]; -const seed = (store: ReturnType, pathname: string) => { - store.set(locationAtom, { pathname, searchParams: new URLSearchParams() }); +const seed = (store: ReturnType, pathname: string, search = "") => { + store.set(locationAtom, { pathname, searchParams: new URLSearchParams(search) }); }; const blogRoute = staticRouteAtom("blog"); @@ -20,41 +26,108 @@ const slugRoute = paramRouteAtom("slug", { parent: blogRoute }); const findPost = async ({ slug }: { slug: string }) => POSTS.find((post) => post.slug === slug); -describe("asyncRouteAtom", () => { +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("asyncRouteAtom data", () => { + let store: ReturnType; + + beforeEach(() => { + store = createStore(); + }); + + it("is undefined when the route doesn't match, without calling the loader", async () => { + const galleryRoute = staticRouteAtom("gallery"); + const load = vi.fn(async () => ({ items: [] })); + const galleryData = asyncRouteAtom(galleryRoute, "gallery", load).data; + seed(store, "/somewhere-else"); + + expect(await store.get(galleryData)).toBeUndefined(); + expect(load).not.toHaveBeenCalled(); + }); + + it("resolves the loader's data once the route matches", async () => { + const galleryRoute = staticRouteAtom("gallery"); + const galleryData = asyncRouteAtom(galleryRoute, "gallery", async () => ({ items: ["a", "b"] })).data; + seed(store, "/gallery"); + + expect(await store.get(galleryData)).toEqual({ items: ["a", "b"] }); + }); + + it("passes matched route values into the loader", async () => { + const galleryRoute = staticRouteAtom("gallery"); + const load = vi.fn(async (values) => ({ values })); + const galleryData = asyncRouteAtom(galleryRoute, "gallery", load).data; + seed(store, "/gallery"); + await store.get(galleryData); + + expect(load).toHaveBeenCalledWith({}, expect.any(Function)); + }); + + it("a loader can return a Redirect instead of data", async () => { + const adminRoute = staticRouteAtom("admin"); + const adminData = asyncRouteAtom(adminRoute, "admin", async () => redirect("/login")).data; + seed(store, "/admin"); + + expect(await store.get(adminData)).toEqual(redirect("/login")); + }); + + it("composes: a dependent atom can await another route's data", async () => { + const userRoute = staticRouteAtom("user"); + const userData = asyncRouteAtom(userRoute, "user", async () => ({ id: "u1" })).data; + const userPostsData = atom(async (get) => { + const user = await get(userData); + if (!user || isRedirect(user)) return undefined; + return { postsFor: user.id }; + }); + seed(store, "/user"); + + expect(await store.get(userPostsData)).toEqual({ postsFor: "u1" }); + }); + + it("needs no settling to be read, even though the route it came from has not matched", async () => { + const postRoute = asyncRouteAtom(slugRoute, "post", findPost); + seed(store, "/blog/hello"); + + expect(await store.get(postRoute.data)).toEqual(POSTS[0]); + expect(store.get(postRoute).match).toBe(false); + }); +}); + +describe("asyncRouteAtom matching", () => { let store: ReturnType; beforeEach(() => { store = createStore(); }); - it("doesn't match until its lookup has been settled", async () => { + it("doesn't match until its load has been settled", async () => { const postRoute = asyncRouteAtom(slugRoute, "post", findPost); seed(store, "/blog/hello"); expect(store.get(postRoute).match).toBe(false); expect(store.get(postRoute.pending)).toBe(true); - await preloadRoutes(store, [postRoute]); + await preloadAsyncRoutes(store, [postRoute]); expect(store.get(postRoute.pending)).toBe(false); expect(store.get(postRoute).match).toBe(true); }); - it("puts the looked-up object on the route's values under the given name", async () => { + it("puts the loaded object on the route's values under the given name", async () => { const postRoute = asyncRouteAtom(slugRoute, "post", findPost); seed(store, "/blog/hello"); - await preloadRoutes(store, [postRoute]); + await preloadAsyncRoutes(store, [postRoute]); const route = store.get(postRoute); expect(route.values).toEqual({ slug: "hello", post: { slug: "hello", title: "Hello" } }); expect(route.exact).toBe(true); }); - it("doesn't match when the lookup finds nothing, so notAtom reports a not-found", async () => { + it("doesn't match when the load finds nothing, so notAtom reports a not-found", async () => { const postRoute = asyncRouteAtom(slugRoute, "post", findPost); const notFound = notAtom(blogRoute, postRoute); seed(store, "/blog/no-such-post"); - await preloadRoutes(store, [postRoute]); + await preloadAsyncRoutes(store, [postRoute]); expect(store.get(postRoute).match).toBe(false); expect(store.get(postRoute.pending)).toBe(false); @@ -63,45 +136,45 @@ describe("asyncRouteAtom", () => { it("is neither pending nor matched where its parent doesn't match at all", async () => { const postRoute = asyncRouteAtom(slugRoute, "post", findPost); - const lookup = vi.fn(findPost); + const load = vi.fn(findPost); seed(store, "/elsewhere"); - await preloadRoutes(store, [asyncRouteAtom(slugRoute, "post", lookup)]); + await preloadAsyncRoutes(store, [asyncRouteAtom(slugRoute, "post", load)]); expect(store.get(postRoute.pending)).toBe(false); expect(store.get(postRoute).match).toBe(false); - expect(lookup).not.toHaveBeenCalled(); + expect(load).not.toHaveBeenCalled(); }); it("stops matching once a snapshot is stale for the new location", async () => { const postRoute = asyncRouteAtom(slugRoute, "post", findPost); seed(store, "/blog/hello"); - await preloadRoutes(store, [postRoute]); + await preloadAsyncRoutes(store, [postRoute]); seed(store, "/blog/other"); expect(store.get(postRoute).match).toBe(false); expect(store.get(postRoute.pending)).toBe(true); }); - it("reverses through its parent, ignoring the looked-up object", async () => { + it("reverses through its parent, ignoring the loaded object", async () => { const postRoute = asyncRouteAtom(slugRoute, "post", findPost); seed(store, "/blog/hello"); - await preloadRoutes(store, [postRoute]); + await preloadAsyncRoutes(store, [postRoute]); expect(store.get(postRoute).reverse({ slug: "other", post: { slug: "other", title: "Other" } })).toBe( "/blog/other", ); }); - it("treats a lookup that redirects as no match, leaving followResolvedRedirects to navigate", async () => { + it("treats a load that redirects as no match, leaving followAsyncRedirects to navigate", async () => { const gatedRoute = asyncRouteAtom(slugRoute, "post", async () => redirect("/login")); seed(store, "/blog/hello"); - await preloadRoutes(store, [gatedRoute]); + await preloadAsyncRoutes(store, [gatedRoute]); expect(store.get(gatedRoute).match).toBe(false); }); }); -describe("preloadRoutes", () => { +describe("preloadAsyncRoutes", () => { let store: ReturnType; beforeEach(() => { @@ -112,41 +185,39 @@ describe("preloadRoutes", () => { const postRoute = asyncRouteAtom(slugRoute, "post", findPost); seed(store, "/blog/hello"); - expect(await preloadRoutes(store, [postRoute])).toEqual([ + expect(await preloadAsyncRoutes(store, [postRoute])).toEqual([ { pathname: "/blog/hello", data: { slug: "hello", title: "Hello" } }, ]); }); - it("doesn't look a route up again once it is settled for this location", async () => { - const lookup = vi.fn(findPost); - const postRoute = asyncRouteAtom(slugRoute, "post", lookup); + it("doesn't load a route again once it is settled for this location", async () => { + const load = vi.fn(findPost); + const postRoute = asyncRouteAtom(slugRoute, "post", load); seed(store, "/blog/hello"); - await preloadRoutes(store, [postRoute]); - await preloadRoutes(store, [postRoute]); + await preloadAsyncRoutes(store, [postRoute]); + await preloadAsyncRoutes(store, [postRoute]); - expect(lookup).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledTimes(1); }); }); describe("hydrateAsyncRoutes", () => { - it("makes a route match from a server render's snapshot, without looking anything up", () => { - const lookup = vi.fn(findPost); - const postRoute = asyncRouteAtom(slugRoute, "post", lookup); + it("makes a route match from a server render's snapshot, without loading anything", () => { + const load = vi.fn(findPost); + const postRoute = asyncRouteAtom(slugRoute, "post", load); const store = createStore(); seed(store, "/blog/hello"); hydrateAsyncRoutes(store, [postRoute], [{ pathname: "/blog/hello", data: POSTS[0] }]); expect(store.get(postRoute).values).toEqual({ slug: "hello", post: POSTS[0] }); - expect(lookup).not.toHaveBeenCalled(); + expect(load).not.toHaveBeenCalled(); }); }); describe("followAsyncRoutes", () => { - const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - - it("settles the lookup for each new location", async () => { + it("settles the load for each new location", async () => { const postRoute = asyncRouteAtom(slugRoute, "post", findPost); const store = createStore(); seed(store, "/blog/no-such-post"); @@ -162,9 +233,9 @@ describe("followAsyncRoutes", () => { unsubscribe(); }); - it("leaves a hydrated route alone rather than looking it up a second time", async () => { - const lookup = vi.fn(findPost); - const postRoute = asyncRouteAtom(slugRoute, "post", lookup); + it("leaves a hydrated route alone rather than loading it a second time", async () => { + const load = vi.fn(findPost); + const postRoute = asyncRouteAtom(slugRoute, "post", load); const store = createStore(); seed(store, "/blog/hello"); hydrateAsyncRoutes(store, [postRoute], [{ pathname: "/blog/hello", data: POSTS[0] }]); @@ -172,7 +243,41 @@ describe("followAsyncRoutes", () => { const unsubscribe = followAsyncRoutes(store, [postRoute]); await flush(); - expect(lookup).not.toHaveBeenCalled(); + expect(load).not.toHaveBeenCalled(); + unsubscribe(); + }); +}); + +describe("followAsyncRedirects", () => { + let store: ReturnType; + + beforeEach(() => { + store = createStore(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("follows a redirect produced by a loader", async () => { + const adminRoute = staticRouteAtom("admin"); + const adminData = asyncRouteAtom(adminRoute, "admin", async () => redirect("/login")).data; + seed(store, "/admin"); + + const unsubscribe = followAsyncRedirects(store, [adminData]); + await flush(); + expect(store.get(locationAtom).pathname).toBe("/login"); + unsubscribe(); + }); + + it("does not navigate when the loader returns normal data", async () => { + const galleryRoute = staticRouteAtom("gallery"); + const galleryData = asyncRouteAtom(galleryRoute, "gallery", async () => ({ items: [] })).data; + seed(store, "/gallery"); + + const unsubscribe = followAsyncRedirects(store, [galleryData]); + await flush(); + expect(store.get(locationAtom).pathname).toBe("/gallery"); unsubscribe(); }); }); diff --git a/packages/jarl-atoms/src/__tests__/resolvedAtom.test.ts b/packages/jarl-atoms/src/__tests__/resolvedAtom.test.ts deleted file mode 100644 index 7466ca5..0000000 --- a/packages/jarl-atoms/src/__tests__/resolvedAtom.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { atom, createStore } from "jotai/vanilla"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { locationAtom } from "../locationAtom"; -import { isRedirect, redirect } from "../redirectAtom"; -import { followResolvedRedirects, resolvedAtom } from "../resolvedAtom"; -import { staticRouteAtom } from "../staticRouteAtom"; - -const seed = (store: ReturnType, pathname: string, search = "") => { - store.set(locationAtom, { pathname, searchParams: new URLSearchParams(search) }); -}; - -describe("resolvedAtom", () => { - let store: ReturnType; - - beforeEach(() => { - store = createStore(); - }); - - it("is undefined when the route doesn't match, without calling the resolver", async () => { - const galleryAtom = staticRouteAtom("gallery"); - const resolver = vi.fn(async () => ({ items: [] })); - const galleryData = resolvedAtom(galleryAtom, resolver); - seed(store, "/somewhere-else"); - const value = await store.get(galleryData); - expect(value).toBeUndefined(); - expect(resolver).not.toHaveBeenCalled(); - }); - - it("resolves the loader's data once the route matches", async () => { - const galleryAtom = staticRouteAtom("gallery"); - const galleryData = resolvedAtom(galleryAtom, async () => ({ - items: ["a", "b"], - })); - seed(store, "/gallery"); - const value = await store.get(galleryData); - expect(value).toEqual({ items: ["a", "b"] }); - }); - - it("passes matched route values into the loader", async () => { - const galleryAtom = staticRouteAtom("gallery"); - const loader = vi.fn(async (values) => ({ values })); - const galleryData = resolvedAtom(galleryAtom, loader); - seed(store, "/gallery"); - await store.get(galleryData); - expect(loader).toHaveBeenCalledWith({}, expect.any(Function)); - }); - - it("a resolver can return a Redirect instead of data", async () => { - const adminAtom = staticRouteAtom("admin"); - const adminData = resolvedAtom(adminAtom, async () => redirect("/login")); - seed(store, "/admin"); - const value = await store.get(adminData); - expect(value).toEqual(redirect("/login")); - }); - - it("composes: a dependent resolvedAtom can await another one (v1's 'nested resolves run in series')", async () => { - const userAtom = staticRouteAtom("user"); - const userData = resolvedAtom(userAtom, async () => ({ id: "u1" })); - // A second resolved atom that depends on the first resolving, expressed - // as an ordinary derived async atom - no bespoke "series" plumbing - // needed, jotai's own async dependency graph handles the ordering. - const userPostsData = atom(async (get) => { - const user = await get(userData); - // resolvedAtom resolves to the route's data, a Redirect, or undefined - - // narrow the redirect case out before touching the data shape. - if (!user || isRedirect(user)) return undefined; - return { postsFor: user.id }; - }); - seed(store, "/user"); - const value = await store.get(userPostsData); - expect(value).toEqual({ postsFor: "u1" }); - }); -}); - -describe("followResolvedRedirects", () => { - let store: ReturnType; - - beforeEach(() => { - store = createStore(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - - it("follows a redirect produced by a resolver", async () => { - const adminAtom = staticRouteAtom("admin"); - const adminData = resolvedAtom(adminAtom, async () => redirect("/login")); - seed(store, "/admin"); - - const unsubscribe = followResolvedRedirects(store, [adminData]); - await flush(); - expect(store.get(locationAtom).pathname).toBe("/login"); - unsubscribe(); - }); - - it("does not navigate when the resolver returns normal data", async () => { - const galleryAtom = staticRouteAtom("gallery"); - const galleryData = resolvedAtom(galleryAtom, async () => ({ - items: [], - })); - seed(store, "/gallery"); - - const unsubscribe = followResolvedRedirects(store, [galleryData]); - await flush(); - expect(store.get(locationAtom).pathname).toBe("/gallery"); - unsubscribe(); - }); -}); diff --git a/packages/jarl-atoms/src/asyncRouteAtom.ts b/packages/jarl-atoms/src/asyncRouteAtom.ts index edc4396..bbda5e3 100644 --- a/packages/jarl-atoms/src/asyncRouteAtom.ts +++ b/packages/jarl-atoms/src/asyncRouteAtom.ts @@ -1,66 +1,85 @@ import { Atom, Getter, WritableAtom, atom } from "jotai/vanilla"; +import { splitHref } from "./href"; import { locationAtom } from "./locationAtom"; import { Redirect, Store, isRedirect } from "./redirectAtom"; -import { Resolver, resolvedAtom } from "./resolvedAtom"; import { transformRouteAtom } from "./transformRouteAtom"; import { DefaultParams, RouteAtom } from "./types"; /** - * Looks a matched route's params up in an async source. `undefined` means nothing exists at that - * address, so the route doesn't match; a `Redirect` sends the app elsewhere instead, via - * `followResolvedRedirects`. + * Loads what a matched route needs. `undefined` means nothing exists at that address, so a route + * gating on this loader doesn't match; a `Redirect` sends the app elsewhere instead, via + * `followAsyncRedirects`. */ -export type RouteLookup = Resolver; +export type RouteLoader = ( + values: T, + get: Getter, +) => Promise; -/** An async route's matched values: its parent's, plus the looked-up object under `Name`. */ +/** An async route's matched values: its parent's, plus the loaded object under `Name`. */ export type AsyncRouteValues = T & { readonly [key in Name]: Data; }; -/** One async route's settled lookup, carried from a server render into client hydration. */ +/** One async route's settled load, carried from a server render into client hydration. */ export type AsyncRouteSnapshot = { pathname: string; - /** The looked-up object, or `undefined` for "nothing exists here". */ + /** The loaded object, or `undefined` for "nothing exists here". */ data: unknown; }; -/** What `asyncRouteAtom` returns: a route atom, plus the seams its lookup is driven through. */ +/** What `asyncRouteAtom` returns: a route atom, plus the seams its loader is driven through. */ export type AsyncRouteAtom = RouteAtom< AsyncRouteValues > & { - /** The lookup itself, to consume with `useAtomValue` under Suspense or pass to `followResolvedRedirects`. */ - readonly lookup: Atom>; - /** Whether the lookup for the current location has yet to settle - true only while the parent route matches. */ + /** The load on its own, with no bearing on matching and no lifecycle to wire up. */ + readonly data: Atom>; + /** Whether the load for the current location has yet to settle - true only while the parent route matches. */ readonly pending: Atom; - /** The settled lookup the match reads, written by `preloadRoutes`, `hydrateAsyncRoutes` and `followAsyncRoutes`. */ + /** The settled load the match reads, written by `preloadAsyncRoutes`, `hydrateAsyncRoutes` and `followAsyncRoutes`. */ readonly settled: WritableAtom; }; const pathnameOf = (get: Getter): string => get(locationAtom).pathname ?? "/"; /** - * A route that exists only if an async lookup says so. `lookup` runs against the parent route's - * matched values; `undefined` back means this route doesn't match, so a `Switch` fallback or - * `notAtom` renders the not-found case, and anything else matches with that object bound to - * `name` in the route's own `values` - one lookup answering both "does this exist" and "what is - * it". + * Attaches an async load to a route, answering both "what data does this route have" and, if you + * want it to, "does this route exist at all". `load` runs against the parent route's matched + * values whenever the parent matches. + * + * Read `.data` and that is all it is - a plain async atom of the loaded value, alongside a route + * that already matched, consumed however suits: `useAtomValue` under Suspense, jotai/utils + * `loadable()`, or `await store.get(...)` outside React. Nothing else needs wiring up. + * + * ```ts + * const productData = asyncRouteAtom(productRoute, "product", ({ id }) => api.product(id)).data; + * ``` + * + * Read the atom itself as a route and the load decides the match: `undefined` back means this + * route doesn't match, so a `Switch` fallback or `notAtom` renders the not-found case, and + * anything else matches with that object bound to `name` in the route's own `values`. * * ```ts * const postRoute = asyncRouteAtom(slugRoute, "post", ({ slug }) => db.findPost(slug)); * // {({ post }) => } * ``` * - * Matching stays synchronous, so the lookup must be settled into the store first: `await - * preloadRoutes(store, routes)` before a server render, `hydrateAsyncRoutes` to seed the client - * with what that render already loaded, and `followAsyncRoutes` once at startup to keep it - * settled across client navigation. + * Matching stays synchronous, so that second use needs the load settled into the store first: + * `await preloadAsyncRoutes(store, routes)` before a server render, `hydrateAsyncRoutes` to seed + * the client with what that render already loaded, and `followAsyncRoutes` once at startup to + * keep it settled across client navigation. */ export const asyncRouteAtom = ( parentAtom: RouteAtom, name: Name, - lookup: RouteLookup, + load: RouteLoader, ): AsyncRouteAtom => { - const lookupAtom = resolvedAtom(parentAtom, lookup); + const dataAtom = atom(async (get) => { + const route = get(parentAtom); + if (!route.match) { + return undefined; + } + return load(route.values, get); + }); const settledAtom = atom(null); // A snapshot taken at a different pathname says nothing about this location. const settledHere = (get: Getter): AsyncRouteSnapshot | undefined => { @@ -83,21 +102,21 @@ export const asyncRouteAtom = get(parentAtom).match && !settledHere(get)), settled: settledAtom, }); }; -const load = async (store: Store, route: AsyncRouteAtom): Promise => { +const settle = async (store: Store, route: AsyncRouteAtom): Promise => { const pathname = store.get(locationAtom).pathname ?? "/"; const settled = store.get(route.settled); if (settled?.pathname === pathname) { return settled; } - const value = await store.get(route.lookup); + const value = await store.get(route.data); const snapshot: AsyncRouteSnapshot = { pathname, data: isRedirect(value) ? undefined : value }; - // A navigation overtook this lookup; the load it triggered publishes instead. + // A navigation overtook this load; the one it triggered publishes instead. if ((store.get(locationAtom).pathname ?? "/") === pathname) { store.set(route.settled, snapshot); } @@ -105,21 +124,21 @@ const load = async (store: Store, route: AsyncRouteAtom): Promise }; /** - * Awaits each route's lookup for the store's current location and settles it, so a render that + * Awaits each route's load for the store's current location and settles it, so a render that * follows sees the routes match, or not, synchronously - the step a server render needs before * it can produce HTML and a status code. Returns the snapshots, in the order given, to serialise - * into the page for `hydrateAsyncRoutes`. Routes already settled for that location aren't looked - * up again. + * into the page for `hydrateAsyncRoutes`. Routes already settled for that location aren't loaded + * again. */ -export const preloadRoutes = ( +export const preloadAsyncRoutes = ( store: Store, routes: ReadonlyArray>, -): Promise => Promise.all(routes.map((route) => load(store, route))); +): Promise => Promise.all(routes.map((route) => settle(store, route))); /** * Seeds a server render's snapshots into a client store, so hydration matches the HTML it - * received without looking anything up a second time. `snapshots` are matched to `routes` by - * position, exactly as `preloadRoutes` returned them. + * received without loading anything a second time. `snapshots` are matched to `routes` by + * position, exactly as `preloadAsyncRoutes` returned them. */ export const hydrateAsyncRoutes = ( store: Store, @@ -135,15 +154,37 @@ export const hydrateAsyncRoutes = ( }; /** - * Keeps async routes settled as the location changes: re-runs each lookup on navigation and + * Keeps async routes settled as the location changes: re-runs each load on navigation and * publishes what it finds. Call once near the root of a client app, after `hydrateAsyncRoutes`. * Returns an unsubscribe function. */ export const followAsyncRoutes = (store: Store, routes: ReadonlyArray>): (() => void) => { - // Subscribed to the location rather than to each `lookup`: subscribing to an async atom mounts - // it, which would run every lookup up front, including ones hydration already answered. - const reload = () => routes.forEach((route) => void load(store, route)); + // Subscribed to the location rather than to each `data` atom: subscribing to an async atom + // mounts it, which would run every load up front, including ones hydration already answered. + const reload = () => routes.forEach((route) => void settle(store, route)); const unsubscribe = store.sub(locationAtom, reload); reload(); return unsubscribe; }; + +/** + * Follows any `Redirect` a loader produces, replace-navigating to its target - the async-loading + * counterpart of `followRedirects`. Takes the `data` atoms to watch. Returns an unsubscribe + * function. + */ +export const followAsyncRedirects = (store: Store, dataAtoms: ReadonlyArray>>): (() => void) => { + const unsubs = dataAtoms.map((data) => { + const check = () => { + store.get(data).then((value) => { + if (isRedirect(value)) { + const [pathname, searchParams] = splitHref(value.to); + store.set(locationAtom, (prev) => ({ ...prev, pathname, searchParams }), { replace: true }); + } + }); + }; + const unsub = store.sub(data, check); + check(); + return unsub; + }); + return () => unsubs.forEach((unsub) => unsub()); +}; diff --git a/packages/jarl-atoms/src/index.ts b/packages/jarl-atoms/src/index.ts index 5296784..a0e4634 100644 --- a/packages/jarl-atoms/src/index.ts +++ b/packages/jarl-atoms/src/index.ts @@ -16,5 +16,4 @@ export * from "./notAtom"; export * from "./href"; export * from "./queryAtom"; export * from "./redirectAtom"; -export * from "./resolvedAtom"; export * from "./asyncRouteAtom"; diff --git a/packages/jarl-atoms/src/redirectAtom.ts b/packages/jarl-atoms/src/redirectAtom.ts index f5f6a11..33315dd 100644 --- a/packages/jarl-atoms/src/redirectAtom.ts +++ b/packages/jarl-atoms/src/redirectAtom.ts @@ -33,7 +33,7 @@ export class Redirect { } /** - * Constructs a `Redirect` sentinel object - typically returned from a `resolvedAtom` loader to + * Constructs a `Redirect` sentinel object - typically returned from an `asyncRouteAtom` loader to * defer a redirect decision until after data has loaded. */ export const redirect = (to: Path): Redirect => new Redirect(to); diff --git a/packages/jarl-atoms/src/resolvedAtom.ts b/packages/jarl-atoms/src/resolvedAtom.ts deleted file mode 100644 index 9a0feb5..0000000 --- a/packages/jarl-atoms/src/resolvedAtom.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Per-route async data loading, covering v1's `resolve`/`resolved`/ -// `mapResolved` (RoutingProvider.js `doNavigation`'s Promise-chain reduction, -// routing.js's `mapResolvedToProps`), used by the advanced-routing gallery -// demo to fetch gallery item data once a route matches. -// -// v1 had to hand-build all of this: a reducer chain to run resolvers in -// series, a `resolved` object threaded through context, `mapResolvedToProps` -// to pick specific props out of it, and special-casing so a resolver -// returning a Redirect aborts the chain (RoutingProvider.js:255-270). -// -// None of that state machine needs porting: it's exactly what jotai's async -// atoms already give you. `resolvedAtom` below is a plain -// `atom(async (get) => ...)` - reading it through `get()` inside another -// async atom's body automatically awaits it and dedupes/caches via jotai's -// own dependency graph, which is the "run resolvers in series, only once" -// behaviour v1 built by hand. "mapResolved" likewise doesn't need its own -// primitive: it's just `atom(async (get) => mapFn(await get(resolvedAtom)))` -// - ordinary jotai derivation, so it's deliberately not reimplemented here. -// A resolver returning a Redirect (see redirectAtom.ts) is exposed as-is on -// the resolved value; `followResolvedRedirects` is the effect that turns -// that into an actual navigation, mirroring `followRedirects` for -// redirectAtom. - -import { Atom, Getter, atom } from "jotai/vanilla"; -import { splitHref } from "./href"; -import { locationAtom } from "./locationAtom"; -import { Redirect, isRedirect } from "./redirectAtom"; -import type { Store } from "./redirectAtom"; -import { DefaultParams, RouteAtom } from "./types"; - -/** Loads the data a matched route needs. Returning a `Redirect` sends the app elsewhere instead. */ -export type Resolver = (values: T, get: Getter) => Promise; - -/** - * Runs `resolver` whenever `routeAtom` matches, resolving to `undefined` when it doesn't. This - * is a plain async atom, so observe it however suits: `useAtomValue` + Suspense, jotai/utils - * `loadable()` for a non-suspending pending/hasData/hasError view, or `await - * store.get(resolvedAtom)` outside React entirely. - */ -export const resolvedAtom = ( - routeAtom: RouteAtom, - resolver: Resolver, -): Atom> => - atom(async (get) => { - const route = get(routeAtom); - if (!route.match) { - return undefined; - } - return resolver(route.values, get); - }); - -/** - * Follows any `Redirect` a resolver produces, replace-navigating to its target - the - * async-loading counterpart of `followRedirects`. Returns an unsubscribe function. - */ -export const followResolvedRedirects = ( - store: Store, - resolvedAtoms: ReadonlyArray>>, -): (() => void) => { - const unsubs = resolvedAtoms.map((resolved) => { - const check = () => { - store.get(resolved).then((value) => { - if (isRedirect(value)) { - const [pathname, searchParams] = splitHref(value.to); - store.set(locationAtom, (prev) => ({ ...prev, pathname, searchParams }), { replace: true }); - } - }); - }; - const unsub = store.sub(resolved, check); - check(); - return unsub; - }); - return () => unsubs.forEach((unsub) => unsub()); -}; From 8955fb3b4588e3d76b938600f514b674d546912f Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Wed, 19 Aug 2026 14:51:41 +0100 Subject: [PATCH 2/3] refactor(docs,e2e): move the demo site and fixture app onto the merged API Ticket: 675 --- e2e/fixture-app/src/routes.ts | 12 ++++++------ packages/docs/src/entry-server.tsx | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/e2e/fixture-app/src/routes.ts b/e2e/fixture-app/src/routes.ts index b0082b3..50e8800 100644 --- a/e2e/fixture-app/src/routes.ts +++ b/e2e/fixture-app/src/routes.ts @@ -7,12 +7,12 @@ * can exercise realistic nested/param routes. * * NOTE: this file only *composes* the primitives jarl-atoms exports - * (rootAtom, staticRouteAtom, paramRouteAtom, redirectAtom, resolvedAtom). It + * (rootAtom, staticRouteAtom, paramRouteAtom, redirectAtom, asyncRouteAtom). It * does not add routing features to the library. */ import { atom } from "jotai/vanilla"; import { loadable } from "jotai/utils"; -import { rootAtom, staticRouteAtom, paramRouteAtom, redirectAtom, resolvedAtom, redirect } from "jarl-atoms"; +import { rootAtom, staticRouteAtom, paramRouteAtom, redirectAtom, asyncRouteAtom, redirect } from "jarl-atoms"; // --- Shell (demo/cypress/integration/00DemosShell.js) --- export { rootAtom }; @@ -77,20 +77,20 @@ const CONTENT: Record = { "about-us": "A jarl was a Norse or Danish chief, a rank of nobility above a freeman and below a king.", }; -export const redirectsAdminDataAtom = resolvedAtom(redirectsAdminAtom, async (_values, get) => { +export const redirectsAdminDataAtom = asyncRouteAtom(redirectsAdminAtom, "admin", async (_values, get) => { if (!get(isAdminAuthenticatedAtom)) { return redirect("/redirects"); } return { body: "This is the super secret admin page." }; -}); +}).data; -export const redirectsContentDataAtom = resolvedAtom(redirectsContentSlugAtom, async ({ slug }) => { +export const redirectsContentDataAtom = asyncRouteAtom(redirectsContentSlugAtom, "content", async ({ slug }) => { const body = CONTENT[slug]; if (!body) { return redirect("/redirects"); } return { body }; -}); +}).data; // loadable() lets the pages read these without a Suspense boundary. export const redirectsAdminDataLoadableAtom = loadable(redirectsAdminDataAtom); diff --git a/packages/docs/src/entry-server.tsx b/packages/docs/src/entry-server.tsx index 76e203d..953b7b0 100644 --- a/packages/docs/src/entry-server.tsx +++ b/packages/docs/src/entry-server.tsx @@ -4,7 +4,7 @@ import { CacheProvider } from "@emotion/react"; import createCache from "@emotion/cache"; import createEmotionServer from "@emotion/server/create-instance"; import App from "./App"; -import { locationAtom, preloadRoutes } from "jarl-atoms"; +import { locationAtom, preloadAsyncRoutes } from "jarl-atoms"; import { asyncRoutes, notFoundAtom } from "./router/routes"; // Re-exported so the plain-Node prerender script (scripts/build.mjs) can drive the @@ -39,7 +39,7 @@ export const render = async (path: string): Promise => { pathname: rawPathname || "/", searchParams: new URLSearchParams(rawSearch), }); - const routeData = await preloadRoutes(store, asyncRoutes); + const routeData = await preloadAsyncRoutes(store, asyncRoutes); // The default key is what the browser-side cache adopts server-rendered styles under. const cache = createCache({ key: "css" }); const { extractCriticalToChunks, constructStyleTagsFromChunks } = createEmotionServer(cache); From 12050bacb37ca8c22a17ef325377ab5f880deb5a Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Wed, 19 Aug 2026 14:52:27 +0100 Subject: [PATCH 3/3] docs: describe async route data as one API Ticket: 675 --- README.md | 4 +- packages/docs/src/content/api-jarl-react.md | 2 +- .../docs/src/content/guides/DataLoading.md | 66 ++++++++++--------- packages/jarl-atoms/README.md | 8 +-- 4 files changed, 43 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index f9a27d7..47b288d 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,8 @@ export default SearchForm; That's all the basics! Hopefully this gave a flavour of the power and simplicity of this routing system. See the [docs site](https://jarl.randomdev.co.uk) for query strings, redirects, and -data loading (resolving promises as part of a route match, `jarl-atoms`' `resolvedAtom`) in more -depth. +data loading (resolving promises as part of a route match, `jarl-atoms`' `asyncRouteAtom`) in +more depth. ## Documentation diff --git a/packages/docs/src/content/api-jarl-react.md b/packages/docs/src/content/api-jarl-react.md index 40615fd..e615e2f 100644 --- a/packages/docs/src/content/api-jarl-react.md +++ b/packages/docs/src/content/api-jarl-react.md @@ -8,7 +8,7 @@ re-export `jarl-atoms`: get your route atoms from `jarl-atoms` and these compone Every hook here takes a route atom as its first argument. `jarl-react` also re-exports jotai's own `useAtom`, `useAtomValue` and `useSetAtom`, so composing directly with a route atom (or with -`jarl-atoms` primitives like `resolvedAtom`) never needs a separate direct dependency on +`jarl-atoms` primitives like `asyncRouteAtom`) never needs a separate direct dependency on `jotai`. The reference below is generated from the doc comments on each export. Components list only diff --git a/packages/docs/src/content/guides/DataLoading.md b/packages/docs/src/content/guides/DataLoading.md index ecf3b49..d866786 100644 --- a/packages/docs/src/content/guides/DataLoading.md +++ b/packages/docs/src/content/guides/DataLoading.md @@ -6,34 +6,35 @@ page layout churn as everything resolves, JARL lets you attach a loader to a rou resolve everything it needs before the route ever renders - jotai's own async-atom machinery, behind a single `Suspense` boundary in React, handles the wait. -`resolvedAtom` (from `jarl-atoms`) takes a route atom and a loader function, and resolves once -that route matches: +`asyncRouteAtom` (from `jarl-atoms`) takes a route atom, a name for what the loader produces, +and the loader itself. Its `.data` is a plain jotai async atom that resolves once that route +matches: routes.ts: ```ts -import { staticRouteAtom, paramRouteAtom, resolvedAtom } from "jarl-atoms"; +import { staticRouteAtom, paramRouteAtom, asyncRouteAtom } from "jarl-atoms"; export const productsRoute = staticRouteAtom("products"); export const productRoute = paramRouteAtom("productId", { parent: productsRoute }); -export const productDataRoute = resolvedAtom(productRoute, async ({ productId }) => { +export const productData = asyncRouteAtom(productRoute, "product", async ({ productId }) => { const result = await fetch(`/api/products/${productId}`); return result.json(); -}); +}).data; ``` -`resolvedAtom` is a plain jotai async atom (`Atom>`), so -any of jotai's usual ways of consuming one work - the most idiomatic in React is `useAtomValue` -under a `Suspense` boundary: +`.data` is `Atom>`, so any of jotai's usual ways of +consuming an async atom work - the most idiomatic in React is `useAtomValue` under a `Suspense` +boundary: ```tsx import { Suspense } from "react"; import { useAtomValue } from "jarl-react"; -import { productDataRoute } from "./routes"; +import { productData } from "./routes"; const ProductPage = () => { - const product = useAtomValue(productDataRoute); + const product = useAtomValue(productData); return ; }; @@ -45,17 +46,20 @@ export default () => ( ``` By loading data as part of the route atom itself, the data is guaranteed to exist (or the -resolver's `Promise` is still pending, transparently handled by `Suspense`) by the time +loader's `Promise` is still pending, transparently handled by `Suspense`) by the time `ProductPage` renders - no separate loading flag to plumb through. If you'd rather not suspend, jotai/utils' `loadable()` wraps any async atom into a synchronous `{ state: "hasData" | "loading" | "hasError", ... }` value instead. +Read this way, `.data` has no bearing on whether the route matches and needs nothing else wired +up. The name you passed (`"product"` above) is only used by the next section. + ## Routes that only exist if the data does Sometimes the lookup _is_ the route. Whether `/blog/some-slug` is a page at all is a question only the database can answer, and answering it twice - once to decide, once to render - is a wasted -call. `asyncRouteAtom` wraps a route atom so that its match depends on a lookup, and binds -whatever the lookup found to the route's own values: +call. Drop the `.data` and keep the atom itself: the load then decides the match, and whatever it +found is bound to the route's own values under the name you gave it. ```ts import { staticRouteAtom, paramRouteAtom, asyncRouteAtom } from "jarl-atoms"; @@ -70,7 +74,7 @@ export const postRoute = asyncRouteAtom(slugRoute, "post", ({ slug }) => db.find export const asyncRoutes = [postRoute]; ``` -`undefined` back from the lookup means the route doesn't match, so a `Switch` fallback (or +`undefined` back from the loader means the route doesn't match, so a `Switch` fallback (or `notAtom`) renders your not-found case. A hit matches, with the loaded object typed onto `values`: ```tsx @@ -86,7 +90,7 @@ its own. ### Server rendering -Route matching is synchronous everywhere in JARL, so the lookup has to have settled before a +Route matching is synchronous everywhere in JARL, so the load has to have settled before a render can read it. On the server that is a single `await`, and it is what lets the response carry a real 404 status rather than a 200 whose body happens to say "not found": @@ -94,7 +98,7 @@ carry a real 404 status rather than a 200 whose body happens to say "not found": const store = createStore(); store.set(locationAtom, { pathname, searchParams }); -const routeData = await preloadRoutes(store, asyncRoutes); +const routeData = await preloadAsyncRoutes(store, asyncRoutes); const html = renderToString( @@ -106,8 +110,8 @@ const status = store.get(notFoundAtom) ? 404 : 200; `notFoundAtom` there is `notAtom(...everyRouteYouRender)` - listing `postRoute` rather than `slugRoute`, so an unknown slug counts as a miss. -`preloadRoutes` returns one snapshot per route, in the order given. Serialise them into the page -and the client picks up where the server left off, without repeating the lookup: +`preloadAsyncRoutes` returns one snapshot per route, in the order given. Serialise them into the +page and the client picks up where the server left off, without repeating the load: ```tsx hydrateAsyncRoutes(store, asyncRoutes, window.__ROUTE_DATA__ ?? []); @@ -115,48 +119,50 @@ followAsyncRoutes(store, asyncRoutes); hydrateRoot(root, {}); ``` -`followAsyncRoutes` keeps the routes settled from then on, re-running each lookup as the location +`followAsyncRoutes` keeps the routes settled from then on, re-running each load as the location changes. While one is in flight, `postRoute.pending` is `true` - render a spinner on it, or the not-found case will flash before the answer arrives. +This lifecycle is what gating costs. A route read only through `.data` never needs any of it. + ## Redirecting Sometimes a route shouldn't render at all, and should instead send the visitor somewhere else - -an auth gate, a canonical-URL redirect, or (as below) a resolver that didn't find what it was +an auth gate, a canonical-URL redirect, or (as below) a loader that didn't find what it was looking for. `redirect(to)` marks that outcome: ```ts -import { staticRouteAtom, paramRouteAtom, resolvedAtom, redirect } from "jarl-atoms"; +import { staticRouteAtom, paramRouteAtom, asyncRouteAtom, redirect } from "jarl-atoms"; export const productBySlugRoute = paramRouteAtom("productSlug", { parent: productsRoute }); -export const productBySlugDataRoute = resolvedAtom(productBySlugRoute, async ({ productSlug }) => { +export const productBySlugData = asyncRouteAtom(productBySlugRoute, "product", async ({ productSlug }) => { const response = await fetch(`/api/productsBySlug?slug=${productSlug}`); if (!response.ok) { return redirect("/products/not-found"); } return response.json(); -}); +}).data; ``` -A `Redirect` returned from a resolver doesn't navigate anywhere by itself - reading the atom +A `Redirect` returned from a loader doesn't navigate anywhere by itself - reading the atom just tells you a redirect _would_ happen, which keeps it composable and testable like any other -value. To actually perform the navigation, wire `followResolvedRedirects` up once near the root +value. To actually perform the navigation, wire `followAsyncRedirects` up once near the root of your app (typically alongside where you create your jotai store): ```ts -import { followResolvedRedirects } from "jarl-atoms"; -import { productBySlugDataRoute } from "./routes"; +import { followAsyncRedirects } from "jarl-atoms"; +import { productBySlugData } from "./routes"; -const unsubscribe = followResolvedRedirects(store, [productBySlugDataRoute]); +const unsubscribe = followAsyncRedirects(store, [productBySlugData]); ``` -It subscribes to each resolved atom given and, the moment one produces a `Redirect`, replaces +It subscribes to each `.data` atom given and, the moment one produces a `Redirect`, replaces the current location with its target (`history.replaceState`, so the abandoned URL doesn't linger in the back-button history). If a route should redirect unconditionally - with no data fetch involved at all - -`redirectAtom`/`followRedirects` do the same job without the `resolvedAtom` wrapper: +`redirectAtom`/`followRedirects` do the same job without a loader: ```ts import { redirectAtom, followRedirects } from "jarl-atoms"; diff --git a/packages/jarl-atoms/README.md b/packages/jarl-atoms/README.md index a06d6a1..dc25cb3 100644 --- a/packages/jarl-atoms/README.md +++ b/packages/jarl-atoms/README.md @@ -50,10 +50,10 @@ const href = store.get(docAtom).reverse({ docName: "getting-started" }); ``` Other exports: `queryAtom`/`queryParamAtom` (query-string state, composable -the same way as path atoms), `redirectAtom`, `resolvedAtom`, and `asyncRouteAtom` -(a route that exists only if an async lookup finds it, with what it found bound -to the route's values). See the full -docs and demos for the complete model: +the same way as path atoms), `redirectAtom`, and `asyncRouteAtom` (async data for +a route, read as `.data`; read as a route instead and the route exists only if +the load found something, with what it found bound to the route's values). See +the full docs and demos for the complete model: [JARL demos and documentation](https://jarl.randomdev.co.uk)