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/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/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/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); 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) 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()); -};