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 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. 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, + );