From c848f2c7b531f2736004036780595fba1da8f7a7 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Tue, 18 Aug 2026 04:44:51 +0100 Subject: [PATCH 1/4] refactor(demos): scope the blog and data-grid demos with createRootAtom, dropping the rootAtom prop Both demos declare their route atoms as plain module-level values under a basePath-scoped root, so no factory, prop or memoisation stands between the reader and the atom chain. Ticket: 679 --- packages/docs/src/App.tsx | 2 +- packages/docs/src/demos/BlogRoutingApp.tsx | 126 ++++++++------------ packages/docs/src/demos/DataGridApp.tsx | 67 +++++------ packages/docs/src/pages/BlogRoutingDemo.tsx | 3 +- packages/docs/src/pages/Changelog.tsx | 71 +++++------ packages/docs/src/pages/DataGridDemo.tsx | 3 +- packages/docs/src/router/routes.ts | 8 +- 7 files changed, 120 insertions(+), 160 deletions(-) diff --git a/packages/docs/src/App.tsx b/packages/docs/src/App.tsx index 7cf3ca5..b31e7dc 100644 --- a/packages/docs/src/App.tsx +++ b/packages/docs/src/App.tsx @@ -56,7 +56,7 @@ export const App = () => ( {/* Changelog and the demos below are self-contained apps: one static mount each, matched non-exact, with everything under it routed inside the component. */} - + diff --git a/packages/docs/src/demos/BlogRoutingApp.tsx b/packages/docs/src/demos/BlogRoutingApp.tsx index e2004b1..3a674f5 100644 --- a/packages/docs/src/demos/BlogRoutingApp.tsx +++ b/packages/docs/src/demos/BlogRoutingApp.tsx @@ -1,5 +1,4 @@ -import { useMemo } from "react"; -import { rootAtom as defaultRootAtom, numericRouteAtom, paramRouteAtom, DefaultParams, RouteAtom } from "jarl-atoms"; +import { createRootAtom, numericRouteAtom, paramRouteAtom } from "jarl-atoms"; import { Link, Route, Switch } from "jarl-react"; import { BlogPost, @@ -30,43 +29,38 @@ const MONTH_NAMES = [ const formatDate = (post: BlogPost) => `${MONTH_NAMES[post.month - 1]} ${post.day}, ${post.year}`; -// The demo's whole route tree hangs off whatever root it is given, so the app -// never knows the URL it is mounted on. -const createBlogRoutes = (root: RouteAtom) => { - const year = numericRouteAtom("year", { parent: root }); - const month = numericRouteAtom("month", { parent: year, min: 1, max: 12 }); - const day = numericRouteAtom("day", { parent: month, min: 1, max: 31 }); - const post = paramRouteAtom("slug", { parent: day }); - return { root, year, month, day, post }; -}; - -type BlogRoutes = ReturnType; +// The page this demo is mounted on, so its whole tree below is plain module-level atoms. +const blogRoot = createRootAtom({ basePath: "/demos/blog-routing" }); +const yearRoute = numericRouteAtom("year", { parent: blogRoot }); +const monthRoute = numericRouteAtom("month", { parent: yearRoute, min: 1, max: 12 }); +const dayRoute = numericRouteAtom("day", { parent: monthRoute, min: 1, max: 31 }); +const postRoute = paramRouteAtom("slug", { parent: dayRoute }); -const BlogNav = ({ routes }: { routes: BlogRoutes }) => ( +const BlogNav = () => ( ); -const BlogNotFound = ({ routes, reason }: { routes: BlogRoutes; reason: string }) => ( +const BlogNotFound = ({ reason }: { reason: string }) => (

Not found

{reason}

- + Back to all posts

); -const PostList = ({ routes, posts }: { routes: BlogRoutes; posts: BlogPost[] }) => ( +const PostList = ({ posts }: { posts: BlogPost[] }) => (
    {posts.map((post) => (
  • - + {post.title} {" "} — {formatDate(post)} @@ -75,13 +69,13 @@ const PostList = ({ routes, posts }: { routes: BlogRoutes; posts: BlogPost[] })
); -const BlogIndex = ({ routes }: { routes: BlogRoutes }) => ( +const BlogIndex = () => (

Blog

    {yearsWithPosts().map((year) => (
  • - + {year} {" "} ({postsForYear(year).length} posts) @@ -91,10 +85,10 @@ const BlogIndex = ({ routes }: { routes: BlogRoutes }) => (
); -const YearPage = ({ routes, year }: { routes: BlogRoutes; year: number }) => { +const YearPage = ({ year }: { year: number }) => { const posts = postsForYear(year); if (posts.length === 0) { - return ; + return ; } return (
@@ -102,22 +96,22 @@ const YearPage = ({ routes, year }: { routes: BlogRoutes; year: number }) => {
    {monthsInYear(year).map((month) => (
  • - + {MONTH_NAMES[month - 1]} {" "} ({postsForMonth(year, month).length})
  • ))}
- +
); }; -const MonthPage = ({ routes, year, month }: { routes: BlogRoutes; year: number; month: number }) => { +const MonthPage = ({ year, month }: { year: number; month: number }) => { const posts = postsForMonth(year, month); if (posts.length === 0) { - return ; + return ; } return (
@@ -127,52 +121,40 @@ const MonthPage = ({ routes, year, month }: { routes: BlogRoutes; year: number;
    {daysInMonth(year, month).map((day) => (
  • - + {day} {" "} ({postsForDay(year, month, day).length})
  • ))}
- +
); }; -const DayPage = ({ routes, year, month, day }: { routes: BlogRoutes; year: number; month: number; day: number }) => { +const DayPage = ({ year, month, day }: { year: number; month: number; day: number }) => { if (!isValidCalendarDate(year, month, day)) { - return ; + return ; } const posts = postsForDay(year, month, day); if (posts.length === 0) { - return ; + return ; } return (

{MONTH_NAMES[month - 1]} {day}, {year}

- +
); }; -const PostPage = ({ - routes, - year, - month, - day, - slug, -}: { - routes: BlogRoutes; - year: number; - month: number; - day: number; - slug: string; -}) => { +const PostPage = ({ year, month, day, slug }: { year: number; month: number; day: number; slug: string }) => { const post = postBySlug(year, month, day, slug); if (!post) { - return ; + return ; } return (
@@ -187,33 +169,29 @@ const PostPage = ({ /** * Self-contained demo of a classic /blog/:year/:month/:day/:slug tree: URL-shape 404s via the - * Switch fallback, content-level 404s via isValidCalendarDate and empty-list checks. Pass the - * route atom it is mounted on as `rootAtom` and it builds its own tree under that. + * Switch fallback, content-level 404s via isValidCalendarDate and empty-list checks. */ -export const BlogRoutingApp = ({ rootAtom = defaultRootAtom }: { rootAtom?: RouteAtom }) => { - const routes = useMemo(() => createBlogRoutes(rootAtom), [rootAtom]); - return ( - <> - - }> - - - - - {({ year }) => } - - - {({ year, month }) => } - - - {({ year, month, day }) => } - - - {({ year, month, day, slug }) => } - - - - ); -}; +export const BlogRoutingApp = () => ( + <> + + }> + + + + + {({ year }) => } + + + {({ year, month }) => } + + + {({ year, month, day }) => } + + + {({ year, month, day, slug }) => } + + + +); export default BlogRoutingApp; diff --git a/packages/docs/src/demos/DataGridApp.tsx b/packages/docs/src/demos/DataGridApp.tsx index 0b36203..6ff94c0 100644 --- a/packages/docs/src/demos/DataGridApp.tsx +++ b/packages/docs/src/demos/DataGridApp.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import { atom, useAtom, useAtomValue } from "jotai"; -import { DefaultParams, queryParamAtom, rootAtom as defaultRootAtom, RouteAtom, transformRouteAtom } from "jarl-atoms"; +import { createRootAtom, queryParamAtom, transformRouteAtom } from "jarl-atoms"; import { Table } from "./DataGridTable"; import { Ware, wares } from "./wares"; @@ -49,33 +49,32 @@ const sortWares = (rows: Ware[], key: SortKey, direction: SortDirection) => { return direction === "desc" ? sorted.reverse() : sorted; }; -// Only a factory because this demo harness mounts at any root - a real app declares these as -// static atoms. Memoised in the component below so they stay stable references across renders. -const createGridRoutes = (root: RouteAtom) => { - // The raw "sort" query segment, chained off whatever root this demo is mounted on. - const sort = queryParamAtom("sort", { parent: root }); - const parsedSort = transformRouteAtom( - sort, - // Down: parse the raw query value into the shape the UI actually wants. - (values) => parseSort(values.sort), - // Up: serialize back to the raw string queryParamAtom expects to write. - (values) => ({ sort: stringifySort(values.key, values.direction) }), - ); - // Chains off parsedSort, not sort - so filter's own values carry the already-parsed sort - // alongside the filter text. Writing here re-composes the whole chain back into a URL, so - // whichever field didn't change comes along for free via the current match. - const filter = queryParamAtom("filter", { parent: parsedSort }); - // A plain read off the chain's tip - no useMemo in the component needed for this. - const rows = atom((get) => { - const values = get(filter).values ?? { ...parseSort(undefined), filter: undefined }; - return sortWares(filterWares(wares, values.filter), values.key, values.direction); - }); - return { filter, rows }; -}; +// The page this demo is mounted on, so everything below it is a plain module-level atom. +const gridRoot = createRootAtom({ basePath: "/demos/data-grid" }); + +// The raw "sort" query segment. +const sortParam = queryParamAtom("sort", { parent: gridRoot }); + +const sortRoute = transformRouteAtom( + sortParam, + // Down: parse the raw query value into the shape the UI actually wants. + (values) => parseSort(values.sort), + // Up: serialize back to the raw string queryParamAtom expects to write. + (values) => ({ sort: stringifySort(values.key, values.direction) }), +); + +// Chains off sortRoute, not sortParam - so filter's own values carry the already-parsed sort +// alongside the filter text. Writing here re-composes the whole chain back into a URL, so +// whichever field didn't change comes along for free via the current match. +const filterRoute = queryParamAtom("filter", { parent: sortRoute }); + +// A plain read off the chain's tip - no useMemo in the component needed for this. +const rowsAtom = atom((get) => { + const values = get(filterRoute).values ?? { ...parseSort(undefined), filter: undefined }; + return sortWares(filterWares(wares, values.filter), values.key, values.direction); +}); -// Doesn't depend on root, so it's a single static atom rather than one more thing -// createGridRoutes has to build per instance. Local and un-navigated - only reaches the chain -// (and the URL) via filter's setter on submit. +// Local and un-navigated - only reaches the chain (and the URL) via filter's setter on submit. const filterInputAtom = atom(""); // State is never really undefined because the optional query params always match; @@ -85,14 +84,12 @@ const defaultFilter = { ...defaultSort, filter: undefined }; /** * Self-contained demo: a data grid whose filter text and sort column/direction both live in the * URL query string (`?sort=-price&filter=axe`), so the grid's state is shareable/bookmarkable and - * moves with back/forward navigation. Pass the route atom it is mounted on as `rootAtom`. Both - * query params are optional, so the route always matches - no `` needed, just reading the - * atoms directly. + * moves with back/forward navigation. Both query params are optional, so the route always matches - + * no `` needed, just reading the atoms directly. */ -export const DataGridApp = ({ rootAtom = defaultRootAtom }: { rootAtom?: RouteAtom }) => { - const routes = useMemo(() => createGridRoutes(rootAtom), [rootAtom]); - const [filter, setFilter] = useAtom(routes.filter); - const rows = useAtomValue(routes.rows); +export const DataGridApp = () => { + const [filter, setFilter] = useAtom(filterRoute); + const rows = useAtomValue(rowsAtom); const [filterInput, setFilterInput] = useAtom(filterInputAtom); const currentFilter = filter.values ?? defaultFilter; diff --git a/packages/docs/src/pages/BlogRoutingDemo.tsx b/packages/docs/src/pages/BlogRoutingDemo.tsx index 32d5f53..d7bc0fa 100644 --- a/packages/docs/src/pages/BlogRoutingDemo.tsx +++ b/packages/docs/src/pages/BlogRoutingDemo.tsx @@ -1,4 +1,3 @@ -import { blogRoutingDemoRoute } from "../router/routes"; import { BlogRoutingApp } from "../demos/BlogRoutingApp"; import DemoPage from "../lib/DemoPage"; import demoSource from "../demos/BlogRoutingApp.tsx?raw"; @@ -9,7 +8,7 @@ export const BlogRoutingDemo = () => ( sourcePath="packages/docs/src/demos/BlogRoutingApp.tsx" source={demoSource} > - + ); diff --git a/packages/docs/src/pages/Changelog.tsx b/packages/docs/src/pages/Changelog.tsx index ab8fa3e..841684d 100644 --- a/packages/docs/src/pages/Changelog.tsx +++ b/packages/docs/src/pages/Changelog.tsx @@ -1,21 +1,15 @@ -import { useMemo } from "react"; -import { rootAtom as defaultRootAtom, paramRouteAtom, DefaultParams, RouteAtom } from "jarl-atoms"; +import { createRootAtom, paramRouteAtom } from "jarl-atoms"; import { Link, Route, Switch } from "jarl-react"; import Markdown from "../lib/Markdown"; import { changelogEntries, changelogEntryFor, fullChangelog, ChangelogEntry } from "./changelogEntries"; -// Owns its own route tree, same shape as BlogRoutingApp: one param route for the version, -// parented on whatever root it is mounted under. -const createChangelogRoutes = (root: RouteAtom) => ({ - root, - version: paramRouteAtom("version", { parent: root }), -}); +// The page this is mounted on, so its version route below is a plain module-level atom. +const changelogRoot = createRootAtom({ basePath: "/changelog" }); +const versionRoute = paramRouteAtom("version", { parent: changelogRoot }); -type ChangelogRoutes = ReturnType; - -const ChangelogNav = ({ routes }: { routes: ChangelogRoutes }) => ( +const ChangelogNav = () => ( @@ -23,13 +17,13 @@ const ChangelogNav = ({ routes }: { routes: ChangelogRoutes }) => ( // Just tracks version history - distinct from the History page, which documents the v1 // architecture and why v2 moved to atoms. -const ChangelogIndex = ({ routes }: { routes: ChangelogRoutes }) => ( +const ChangelogIndex = () => ( <>

Changelog

    {changelogEntries.map((entry) => (
  • - + {entry.version} {entry.date && — {entry.date}} @@ -43,24 +37,24 @@ const ChangelogIndex = ({ routes }: { routes: ChangelogRoutes }) => ( ); -const ChangelogNotFound = ({ routes, version }: { routes: ChangelogRoutes; version: string }) => ( +const ChangelogNotFound = ({ version }: { version: string }) => ( <>

    Not found

    No release named “{version}”.

    - + Back to the changelog

    ); -const ChangelogVersionPage = ({ routes, entry }: { routes: ChangelogRoutes; entry: ChangelogEntry }) => ( +const ChangelogVersionPage = ({ entry }: { entry: ChangelogEntry }) => ( <> {entry.body ? :

    No release notes recorded for this version.

    }

    - + Back to the changelog

    @@ -69,30 +63,23 @@ const ChangelogVersionPage = ({ routes, entry }: { routes: ChangelogRoutes; entr /** * Browsable release history: an index of versions parsed out of the generated CHANGELOG.md, - * with one route per version. Pass the route atom it is mounted on as `rootAtom`. + * with one route per version. */ -export const Changelog = ({ rootAtom = defaultRootAtom }: { rootAtom?: RouteAtom }) => { - const routes = useMemo(() => createChangelogRoutes(rootAtom), [rootAtom]); - return ( - <> - - }> - - - - - {({ version }) => { - const entry = changelogEntryFor(version); - return entry ? ( - - ) : ( - - ); - }} - - - - ); -}; +export const Changelog = () => ( + <> + + }> + + + + + {({ version }) => { + const entry = changelogEntryFor(version); + return entry ? : ; + }} + + + +); export default Changelog; diff --git a/packages/docs/src/pages/DataGridDemo.tsx b/packages/docs/src/pages/DataGridDemo.tsx index b5dc3aa..d2ff6fb 100644 --- a/packages/docs/src/pages/DataGridDemo.tsx +++ b/packages/docs/src/pages/DataGridDemo.tsx @@ -1,4 +1,3 @@ -import { dataGridDemoRoute } from "../router/routes"; import { DataGridApp } from "../demos/DataGridApp"; import DemoPage from "../lib/DemoPage"; import demoSource from "../demos/DataGridApp.tsx?raw"; @@ -9,7 +8,7 @@ export const DataGridDemo = () => ( sourcePath="packages/docs/src/demos/DataGridApp.tsx" source={demoSource} > - + ); diff --git a/packages/docs/src/router/routes.ts b/packages/docs/src/router/routes.ts index f2def08..ff232c7 100644 --- a/packages/docs/src/router/routes.ts +++ b/packages/docs/src/router/routes.ts @@ -31,12 +31,12 @@ export const demosIndexRoute = staticRouteAtom("demos"); export const basicRoutingDemoRoute = staticRouteAtom("basic-routing", { parent: demosIndexRoute }); export const basicRoutingDemoPageRoute = paramRouteAtom("page", { parent: basicRoutingDemoRoute }); -// Blog routing demo: just the static mount point. The demo's own /:year/:month/:day/:slug -// tree lives inside BlogRoutingApp, parented on whatever root atom it is handed. +// Blog routing demo: the site's own mount point. The demo's own /:year/:month/:day/:slug tree +// lives inside BlogRoutingApp, on its own basePath-scoped root. export const blogRoutingDemoRoute = staticRouteAtom("blog-routing", { parent: demosIndexRoute }); -// Data grid demo: just the static mount point. Filter/sort state lives entirely in query -// params chained inside DataGridApp, parented on whatever root atom it is handed. +// Data grid demo: the site's own mount point. Filter/sort state lives entirely in query params +// chained inside DataGridApp, on its own basePath-scoped root. export const dataGridDemoRoute = staticRouteAtom("data-grid", { parent: demosIndexRoute }); // Async-lookup demo: /demos/async-lookup/:slug exists only if the demo's fake database has an From e1a61d8b10931510146cad3256553af2d5e5b2b0 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Tue, 18 Aug 2026 04:44:51 +0100 Subject: [PATCH 2/4] docs(atoms): record why a jotai store context can't scope locationAtom Ticket: 679 --- packages/jarl-atoms/DESIGN-NOTES.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/jarl-atoms/DESIGN-NOTES.md b/packages/jarl-atoms/DESIGN-NOTES.md index 27b50b0..7189c85 100644 --- a/packages/jarl-atoms/DESIGN-NOTES.md +++ b/packages/jarl-atoms/DESIGN-NOTES.md @@ -4,7 +4,8 @@ This file preserves the intent behind exploratory sketches that were originally left as commented-out code in `src/routeAtom.ts` on the first (uncommitted) draft of the v2 atoms core. They were lifted out here — rather than deleted — so the alternative designs they were exploring aren't lost, in case a later -ticket (atom coverage gaps, React bindings, etc.) wants to revisit them. +ticket (atom coverage gaps, React bindings, etc.) wants to revisit them. Designs +explored and rejected since are recorded here too. ## Tuple-shaped `RouteReturn` @@ -86,3 +87,25 @@ approach were revived instead of the segment-composition one. A leftover return-type annotation for the pattern-string `routeAtom` sketch above, referencing a `Match` type that was never defined in this file. Dead in isolation; only relevant if the pattern-string sketch is revived. + +## Scoping `locationAtom` to a path prefix with a jotai store + +Rejected in favour of `createRootAtom({ basePath })`. + +The idea was to mount a subtree in its own jotai store whose `locationAtom` reads +and writes relative to a prefix, so the subtree's route atoms could be declared +without knowing where they are mounted. Two mechanisms exist and neither works: + +- **A nested ``.** jotai stores don't inherit, so + the subtree gets its own `atomWithLocation`, which only refreshes on `popstate`. + Navigating inside the subtree calls `history.pushState`, which fires no + `popstate`, so the outer store keeps serving the old pathname: the URL changes + while every route atom outside the subtree still matches the previous location. +- **A store that shares state with its parent but overrides `locationAtom`.** + jotai 2.20 exposes this only as `INTERNAL_buildStoreRev3` and friends — private, + revision-numbered API that `jotai-scope` is built on. Not a dependency a router + can take on a peer's internals. + +`createRootAtom({ basePath })` gets the useful half of the idea in one store: route +atoms below it are static module-level values, and the prefix is named once, on the +root, where a `reverse()` can prepend it again. From 29a0fb88ea1dd37496c62e3f96d5df397d33df19 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Wed, 19 Aug 2026 01:51:13 +0100 Subject: [PATCH 3/4] feat(atoms): add validateAtom for constraints spanning several segments Ticket: 683 Ticket: 679 --- .../src/__tests__/validateAtom.test.ts | 103 ++++++++++++++++++ packages/jarl-atoms/src/index.ts | 1 + packages/jarl-atoms/src/validateAtom.ts | 20 ++++ 3 files changed, 124 insertions(+) create mode 100644 packages/jarl-atoms/src/__tests__/validateAtom.test.ts create mode 100644 packages/jarl-atoms/src/validateAtom.ts diff --git a/packages/jarl-atoms/src/__tests__/validateAtom.test.ts b/packages/jarl-atoms/src/__tests__/validateAtom.test.ts new file mode 100644 index 0000000..3fa7873 --- /dev/null +++ b/packages/jarl-atoms/src/__tests__/validateAtom.test.ts @@ -0,0 +1,103 @@ +import { atom, createStore } from "jotai/vanilla"; +import { describe, expect, it } from "vitest"; +import { locationAtom } from "../locationAtom"; +import { numericRouteAtom } from "../numericRouteAtom"; +import { paramRouteAtom } from "../paramRouteAtom"; +import { staticRouteAtom } from "../staticRouteAtom"; +import { validateAtom } from "../validateAtom"; + +const seed = (store: ReturnType, pathname: string) => { + store.set(locationAtom, { pathname, searchParams: new URLSearchParams() }); +}; + +const isValidCalendarDate = (year: number, month: number, day: number) => { + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; +}; + +const calendarRoute = () => { + 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 }); + return validateAtom(day, (values) => isValidCalendarDate(values.year, values.month, values.day)); +}; + +describe("validateAtom", () => { + it("matches, with the wrapped route's values, when the predicate accepts", () => { + const store = createStore(); + const date = calendarRoute(); + seed(store, "/blog/2024/02/29"); + + const result = store.get(date); + + expect(result.match).toBe(true); + expect(result.exact).toBe(true); + expect(result.values).toEqual({ year: 2024, month: 2, day: 29 }); + }); + + it("does not match when the predicate rejects", () => { + const store = createStore(); + const date = calendarRoute(); + // 2023 isn't a leap year, so this is the same URL shape with no calendar date behind it. + seed(store, "/blog/2023/02/29"); + + const result = store.get(date); + + expect(result.match).toBe(false); + expect(result.exact).toBe(false); + expect(result.values).toBeUndefined(); + }); + + it("unmatches every child route below a rejected value", () => { + const store = createStore(); + const date = calendarRoute(); + const post = paramRouteAtom("slug", { parent: date }); + seed(store, "/blog/2023/02/29/hello-world"); + + expect(store.get(post).match).toBe(false); + + seed(store, "/blog/2024/02/29/hello-world"); + + const result = store.get(post); + expect(result.match).toBe(true); + expect(result.values).toEqual({ year: 2024, month: 2, day: 29, slug: "hello-world" }); + }); + + it("stays exact-aware, matching non-exactly when a child segment follows", () => { + const store = createStore(); + const date = calendarRoute(); + seed(store, "/blog/2024/02/29/hello-world"); + + const result = store.get(date); + + expect(result.match).toBe(true); + expect(result.exact).toBe(false); + }); + + it("navigates and reverses through to the wrapped route", () => { + const store = createStore(); + const date = calendarRoute(); + + expect(store.get(date).reverse({ year: 2024, month: 2, day: 29 })).toBe("/blog/2024/2/29"); + + store.set(date, { year: 2024, month: 2, day: 29 }); + + expect(store.get(locationAtom).pathname).toBe("/blog/2024/2/29"); + expect(store.get(date).match).toBe(true); + }); + + it("re-evaluates when an atom the predicate reads changes", () => { + const store = createStore(); + const openYears = atom([2024]); + const year = numericRouteAtom("year", { parent: staticRouteAtom("blog") }); + const open = validateAtom(year, (values, get) => get(openYears).includes(values.year)); + seed(store, "/blog/2023"); + + expect(store.get(open).match).toBe(false); + + store.set(openYears, [2023, 2024]); + + expect(store.get(open).match).toBe(true); + }); +}); diff --git a/packages/jarl-atoms/src/index.ts b/packages/jarl-atoms/src/index.ts index 4ed3503..5296784 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 "./validateAtom"; export * from "./notAtom"; export * from "./href"; export * from "./queryAtom"; diff --git a/packages/jarl-atoms/src/validateAtom.ts b/packages/jarl-atoms/src/validateAtom.ts new file mode 100644 index 0000000..f8431cc --- /dev/null +++ b/packages/jarl-atoms/src/validateAtom.ts @@ -0,0 +1,20 @@ +import { Getter } from "jotai/vanilla"; +import { transformRouteAtom } from "./transformRouteAtom"; +import { DefaultParams, RouteAtom } from "./types"; + +/** + * Narrows a route to the values a predicate accepts, leaving the rest unmatched: `validateAtom(day, + * ({ year, month, day }) => isValidCalendarDate(year, month, day))` matches `/:year/:month/:day` + * only on real dates, so 31 February falls through to whatever handles a non-matching URL. Use it + * for constraints spanning several segments, which no single segment's own options can express. + * The predicate also gets a `Getter`, so it can validate against other atoms. + */ +export const validateAtom = ( + parentAtom: RouteAtom, + isValid: (values: T, get: Getter) => boolean, +): RouteAtom => + transformRouteAtom( + parentAtom, + (values, get) => (isValid(values, get) ? values : undefined), + (values) => values, + ); From 1339a1ae8ca40664b5dedc8918d16af2ddd87e22 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Tue, 18 Aug 2026 04:55:47 +0100 Subject: [PATCH 4/4] docs(demos): explain the blog route chain's URL-nesting shape and validate the date in the atoms Ticket: 683 Ticket: 679 --- packages/docs/src/demos/BlogRoutingApp.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/docs/src/demos/BlogRoutingApp.tsx b/packages/docs/src/demos/BlogRoutingApp.tsx index 3a674f5..a9fd3af 100644 --- a/packages/docs/src/demos/BlogRoutingApp.tsx +++ b/packages/docs/src/demos/BlogRoutingApp.tsx @@ -1,4 +1,4 @@ -import { createRootAtom, numericRouteAtom, paramRouteAtom } from "jarl-atoms"; +import { createRootAtom, numericRouteAtom, paramRouteAtom, validateAtom } from "jarl-atoms"; import { Link, Route, Switch } from "jarl-react"; import { BlogPost, @@ -33,7 +33,10 @@ const formatDate = (post: BlogPost) => `${MONTH_NAMES[post.month - 1]} ${post.da const blogRoot = createRootAtom({ basePath: "/demos/blog-routing" }); const yearRoute = numericRouteAtom("year", { parent: blogRoot }); const monthRoute = numericRouteAtom("month", { parent: yearRoute, min: 1, max: 12 }); -const dayRoute = numericRouteAtom("day", { parent: monthRoute, min: 1, max: 31 }); +const daySegment = numericRouteAtom("day", { parent: monthRoute }); +// A segment's own min/max only bounds it in isolation; a real calendar date needs all three +// together, so the whole date is validated as part of matching rather than in a page component. +const dayRoute = validateAtom(daySegment, ({ year, month, day }) => isValidCalendarDate(year, month, day)); const postRoute = paramRouteAtom("slug", { parent: dayRoute }); const BlogNav = () => ( @@ -134,9 +137,6 @@ const MonthPage = ({ year, month }: { year: number; month: number }) => { }; const DayPage = ({ year, month, day }: { year: number; month: number; day: number }) => { - if (!isValidCalendarDate(year, month, day)) { - return ; - } const posts = postsForDay(year, month, day); if (posts.length === 0) { return ; @@ -168,8 +168,8 @@ const PostPage = ({ year, month, day, slug }: { year: number; month: number; day }; /** - * Self-contained demo of a classic /blog/:year/:month/:day/:slug tree: URL-shape 404s via the - * Switch fallback, content-level 404s via isValidCalendarDate and empty-list checks. + * Self-contained demo of a classic /blog/:year/:month/:day/:slug tree: URL-shape 404s (an + * impossible date included) via the Switch fallback, content-level 404s via empty-list checks. */ export const BlogRoutingApp = () => ( <>