diff --git a/packages/docs/src/content/guides/DataLoading.md b/packages/docs/src/content/guides/DataLoading.md index ecf3b492..e5fa848b 100644 --- a/packages/docs/src/content/guides/DataLoading.md +++ b/packages/docs/src/content/guides/DataLoading.md @@ -103,8 +103,8 @@ const html = renderToString( 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. +`notFoundAtom` there is `notAtom(unionRouteAtom(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: diff --git a/packages/docs/src/router/routes.ts b/packages/docs/src/router/routes.ts index f2def08e..943228c3 100644 --- a/packages/docs/src/router/routes.ts +++ b/packages/docs/src/router/routes.ts @@ -6,7 +6,7 @@ * `jarl-atoms`' server-seedable `locationAtom`. */ import { atom } from "jotai"; -import { asyncRouteAtom, notAtom, rootAtom, staticRouteAtom, paramRouteAtom } from "jarl-atoms"; +import { asyncRouteAtom, notAtom, rootAtom, staticRouteAtom, paramRouteAtom, unionRouteAtom } from "jarl-atoms"; import { blogStaticPaths } from "../demos/blogPosts"; import { articleSlugs, findArticle } from "../demos/asyncArticles"; import { changelogStaticPaths } from "../pages/changelogEntries"; @@ -51,20 +51,22 @@ export const asyncArticleRoute = asyncRouteAtom(asyncLookupSlugRoute, "article", export const asyncRoutes = [asyncArticleRoute]; const exactRouteMissedAtom = notAtom( - homeRoute, - docsSectionRoute, - docPageRoute, - apiSectionRoute, - apiPageRoute, - changelogRoute, - historyRoute, - demosIndexRoute, - basicRoutingDemoRoute, - basicRoutingDemoPageRoute, - blogRoutingDemoRoute, - dataGridDemoRoute, - asyncLookupDemoRoute, - asyncArticleRoute, + unionRouteAtom([ + homeRoute, + docsSectionRoute, + docPageRoute, + apiSectionRoute, + apiPageRoute, + changelogRoute, + historyRoute, + demosIndexRoute, + basicRoutingDemoRoute, + basicRoutingDemoPageRoute, + blogRoutingDemoRoute, + dataGridDemoRoute, + asyncLookupDemoRoute, + asyncArticleRoute, + ]), ); /** diff --git a/packages/jarl-atoms/src/__tests__/asyncRouteAtom.test.ts b/packages/jarl-atoms/src/__tests__/asyncRouteAtom.test.ts index 613bfb3e..91166e82 100644 --- a/packages/jarl-atoms/src/__tests__/asyncRouteAtom.test.ts +++ b/packages/jarl-atoms/src/__tests__/asyncRouteAtom.test.ts @@ -6,6 +6,7 @@ import { notAtom } from "../notAtom"; import { paramRouteAtom } from "../paramRouteAtom"; import { redirect } from "../redirectAtom"; import { staticRouteAtom } from "../staticRouteAtom"; +import { unionRouteAtom } from "../unionRouteAtom"; type Post = { slug: string; title: string }; @@ -52,7 +53,7 @@ describe("asyncRouteAtom", () => { it("doesn't match when the lookup finds nothing, so notAtom reports a not-found", async () => { const postRoute = asyncRouteAtom(slugRoute, "post", findPost); - const notFound = notAtom(blogRoute, postRoute); + const notFound = notAtom(unionRouteAtom([blogRoute, postRoute])); seed(store, "/blog/no-such-post"); await preloadRoutes(store, [postRoute]); diff --git a/packages/jarl-atoms/src/__tests__/notAtom.test.ts b/packages/jarl-atoms/src/__tests__/notAtom.test.ts index fd2c81eb..2b56c1d0 100644 --- a/packages/jarl-atoms/src/__tests__/notAtom.test.ts +++ b/packages/jarl-atoms/src/__tests__/notAtom.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { locationAtom } from "../locationAtom"; import { notAtom } from "../notAtom"; import { staticRouteAtom } from "../staticRouteAtom"; +import { unionRouteAtom } from "../unionRouteAtom"; const seed = (store: ReturnType, pathname: string) => { store.set(locationAtom, { pathname, searchParams: new URLSearchParams() }); @@ -15,7 +16,7 @@ describe("notAtom", () => { const users = staticRouteAtom("users"); seed(store, "/nowhere"); - expect(store.get(notAtom(about, users))).toBe(true); + expect(store.get(notAtom(unionRouteAtom([about, users])))).toBe(true); }); it("does not match when the only given route matches", () => { @@ -33,7 +34,7 @@ describe("notAtom", () => { const contact = staticRouteAtom("contact"); seed(store, "/users"); - expect(store.get(notAtom(about, users, contact))).toBe(false); + expect(store.get(notAtom(unionRouteAtom([about, users, contact])))).toBe(false); }); it("checks exactness rather than a bare match", () => { @@ -47,6 +48,15 @@ describe("notAtom", () => { expect(store.get(about).match).toBe(true); expect(store.get(about).exact).toBe(false); expect(store.get(notAtom(about))).toBe(true); - expect(store.get(notAtom(about, team))).toBe(false); + expect(store.get(notAtom(unionRouteAtom([about, team])))).toBe(false); + }); + + it("counts an ancestor match too when exactness is switched off", () => { + const store = createStore(); + const about = staticRouteAtom("about"); + staticRouteAtom("team", { parent: about }); + seed(store, "/about/team"); + + expect(store.get(notAtom(about, { exact: false }))).toBe(false); }); }); diff --git a/packages/jarl-atoms/src/__tests__/unionRouteAtom.test.ts b/packages/jarl-atoms/src/__tests__/unionRouteAtom.test.ts new file mode 100644 index 00000000..8bcdb773 --- /dev/null +++ b/packages/jarl-atoms/src/__tests__/unionRouteAtom.test.ts @@ -0,0 +1,102 @@ +import { createStore } from "jotai/vanilla"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { locationAtom } from "../locationAtom"; +import { numericRouteAtom } from "../numericRouteAtom"; +import { paramRouteAtom } from "../paramRouteAtom"; +import { staticRouteAtom } from "../staticRouteAtom"; +import { unionRouteAtom } from "../unionRouteAtom"; + +const seed = (store: ReturnType, pathname: string) => { + store.set(locationAtom, { pathname, searchParams: new URLSearchParams() }); +}; + +const blog = staticRouteAtom("blog"); +const year = numericRouteAtom("year", { parent: blog }); +const month = numericRouteAtom("month", { parent: year, min: 1, max: 12 }); +const day = numericRouteAtom("day", { parent: month, min: 1, max: 31 }); +const anyDate = unionRouteAtom([year, month, day]); + +describe("unionRouteAtom", () => { + it("matches wherever any of its members does", () => { + const store = createStore(); + + for (const pathname of ["/blog/2024", "/blog/2024/06", "/blog/2024/06/12"]) { + seed(store, pathname); + expect(store.get(anyDate).exact).toBe(true); + } + }); + + it("does not match where no member does", () => { + const store = createStore(); + seed(store, "/elsewhere"); + + const route = store.get(anyDate); + + expect(route.match).toBe(false); + expect(route.values).toBeUndefined(); + }); + + it("binds the values of the member that matched", () => { + const store = createStore(); + seed(store, "/blog/2024/06"); + + expect(store.get(anyDate).values).toEqual({ year: 2024, month: 6 }); + }); + + it("takes the exactly matching member whatever order the members are in", () => { + const store = createStore(); + seed(store, "/blog/2024/06/12"); + + // `year` and `month` match this location too, as non-exact ancestors. + for (const route of [anyDate, unionRouteAtom([day, month, year])]) { + expect(store.get(route).values).toEqual({ year: 2024, month: 6, day: 12 }); + } + }); + + it("falls back to the first member that matches when none matches exactly", () => { + const store = createStore(); + seed(store, "/blog/2024/06/12/deeper"); + + expect(store.get(anyDate).values).toEqual({ year: 2024 }); + expect(store.get(unionRouteAtom([day, month, year])).values).toEqual({ year: 2024, month: 6, day: 12 }); + }); + + it("binds a union of its members' param types", () => { + const store = createStore(); + seed(store, "/blog/2024"); + + const { values } = store.get(anyDate); + + expectTypeOf(values).toEqualTypeOf< + | { year: number } + | ({ month: number } & { year: number }) + | ({ day: number } & { month: number } & { year: number }) + | undefined + >(); + }); + + it("composes as a parent, nesting under whichever member matched", () => { + const store = createStore(); + const slug = paramRouteAtom("slug", { parent: unionRouteAtom([month, year]) }); + seed(store, "/blog/2024/06/some-post"); + + expect(store.get(slug).values).toEqual({ year: 2024, month: 6, slug: "some-post" }); + }); + + it("reverses and navigates through the member that matches", () => { + const store = createStore(); + seed(store, "/blog/2024/06"); + + expect(store.get(anyDate).reverse({ year: 2025, month: 3 })).toBe("/blog/2025/3"); + + store.set(anyDate, { year: 2025, month: 3 }); + expect(store.get(locationAtom).pathname).toBe("/blog/2025/3"); + }); + + it("reverses through the first member when nothing matches", () => { + const store = createStore(); + seed(store, "/elsewhere"); + + expect(store.get(anyDate).reverse({ year: 2024 })).toBe("/blog/2024"); + }); +}); diff --git a/packages/jarl-atoms/src/index.ts b/packages/jarl-atoms/src/index.ts index 4ed35036..063a19ea 100644 --- a/packages/jarl-atoms/src/index.ts +++ b/packages/jarl-atoms/src/index.ts @@ -11,6 +11,7 @@ export * from "./staticRouteAtom"; export * from "./paramRouteAtom"; export * from "./numericRouteAtom"; export * from "./transformRouteAtom"; +export * from "./unionRouteAtom"; export * from "./notAtom"; export * from "./href"; export * from "./queryAtom"; diff --git a/packages/jarl-atoms/src/notAtom.ts b/packages/jarl-atoms/src/notAtom.ts index 786ffbbf..8bdede99 100644 --- a/packages/jarl-atoms/src/notAtom.ts +++ b/packages/jarl-atoms/src/notAtom.ts @@ -1,12 +1,27 @@ import { Atom, atom } from "jotai/vanilla"; import { RouteAtom } from "./types"; +/** Options for `notAtom`. */ +export type NotOptions = { + /** + * Whether only an exact (leaf) match counts as matching, rather than an ancestor match too. + * Defaults to `true`. + */ + exact?: boolean; +}; + /** - * Matches when none of the given route atoms are an exact match - the - * inverse of a router's full route list, for a catch-all/not-found case. - * Checks `exact` rather than `match`: an ancestor route (or `rootAtom` - * itself) can be `match: true` without being the leaf that actually - * rendered, and only the leaf's exactness should count. + * Matches when `route` doesn't - the inverse of a router's full route list, for a + * catch-all/not-found case. Combine every route the app renders into one `unionRouteAtom` to + * negate the lot: `notAtom(unionRouteAtom([homeRoute, postRoute, ...]))`. + * + * Exactness is what counts by default: an ancestor route (or `rootAtom` itself) can be + * `match: true` without being the leaf that actually rendered, and only the leaf's exactness + * should decide whether anything was found. Pass `{ exact: false }` where an ancestor match + * should count too. */ -export const notAtom = (...routes: RouteAtom[]): Atom => - atom((get) => !routes.some((route) => get(route).exact)); +export const notAtom = (route: RouteAtom, { exact = true }: NotOptions = {}): Atom => + atom((get) => { + const matched = get(route); + return !(exact ? matched.exact : matched.match); + }); diff --git a/packages/jarl-atoms/src/types.ts b/packages/jarl-atoms/src/types.ts index 916cddf6..f2da5d6c 100644 --- a/packages/jarl-atoms/src/types.ts +++ b/packages/jarl-atoms/src/types.ts @@ -59,6 +59,9 @@ export type RouteReturn = { /** A route: read it for its `RouteReturn` match state, write param values to it to navigate. */ export type RouteAtom = WritableAtom, [T, NavOptions?], void>; +/** The param values a route atom binds: `RouteValues` is that route's `values`. */ +export type RouteValues> = Route extends RouteAtom ? T : never; + /** Common options for every route atom constructor. */ export type RouteOptions = { /** Route this one nests under, matching the segment after its parent's. Defaults to `rootAtom`. */ diff --git a/packages/jarl-atoms/src/unionRouteAtom.ts b/packages/jarl-atoms/src/unionRouteAtom.ts new file mode 100644 index 00000000..c6112ac0 --- /dev/null +++ b/packages/jarl-atoms/src/unionRouteAtom.ts @@ -0,0 +1,39 @@ +import { Getter, atom } from "jotai/vanilla"; +import { RouteAtom, RouteReturn, RouteValues } from "./types"; + +/** One or more route atoms, in the precedence order `unionRouteAtom` falls back through. */ +export type UnionRoutes = readonly [RouteAtom, ...RouteAtom[]]; + +/** + * Combines several routes into the one route that matches wherever any of them does: it matches + * when any member matches, and exactly when any member matches exactly. `values`, `rest` and + * `reverse` come from the member that actually matched - the exact one where there is one, + * otherwise the first to match, in the order given - so a chain of increasingly specific routes + * reads as one route bound to a union of their param types. + * + * It composes like any other route: as a `parent`, in ``, or as the whole route + * list handed to `notAtom`. A union has no URL shape of its own, though, so `reverse` and writes + * go to whichever member matches, and to the first listed when none does - navigate through the + * specific member you mean rather than through the union. + */ +export const unionRouteAtom = (routes: Routes): RouteAtom> => { + type Values = RouteValues; + // An ancestor is `match: true` for every location beneath it, so the leaf that actually + // rendered is the honest source of values wherever one of the members is it. + const matched = (get: Getter) => routes.find((route) => get(route).exact) ?? routes.find((route) => get(route).match); + return atom( + (get) => { + const route = matched(get); + if (route) { + return get(route) as RouteReturn; + } + // Nothing matched, so nothing bound values or consumed path - but the first member is + // still the union's only answer to "where would this point". + const { reverse } = get(routes[0]); + return { match: false, exact: false, values: undefined, reverse }; + }, + (get, set, values, navOptions) => { + set(matched(get) ?? routes[0], values, navOptions); + }, + ); +}; diff --git a/packages/jarl-react/src/__tests__/notAtom.test.tsx b/packages/jarl-react/src/__tests__/notAtom.test.tsx index 5cb59504..3a91abf5 100644 --- a/packages/jarl-react/src/__tests__/notAtom.test.tsx +++ b/packages/jarl-react/src/__tests__/notAtom.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { useAtomValue } from "jotai"; -import { notAtom } from "jarl-atoms"; +import { notAtom, unionRouteAtom } from "jarl-atoms"; import { Route } from "../Route"; import { aboutAtom, usersAtom } from "./fixtures"; @@ -11,7 +11,7 @@ beforeEach(() => { goTo("/"); }); -const notFoundAtom = notAtom(aboutAtom, usersAtom); +const notFoundAtom = notAtom(unionRouteAtom([aboutAtom, usersAtom])); const NotFound = () => (useAtomValue(notFoundAtom) ?
Not found
: null);