From cd2184b2457ba78b05cd7629174ae87178afaccd Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Thu, 20 Aug 2026 03:05:02 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(jarl-atoms):=20685=20=E2=80=94=20requi?= =?UTF-8?q?reMatch=20narrows=20a=20route=20read=20whose=20match=20is=20alr?= =?UTF-8?q?eady=20guaranteed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket: 685 --- packages/jarl-atoms/DESIGN-NOTES.md | 29 ++++++++ .../src/__tests__/requireMatch.test.ts | 66 +++++++++++++++++++ packages/jarl-atoms/src/index.ts | 1 + packages/jarl-atoms/src/requireMatch.ts | 15 +++++ packages/jarl-atoms/src/types.ts | 3 + 5 files changed, 114 insertions(+) create mode 100644 packages/jarl-atoms/src/__tests__/requireMatch.test.ts create mode 100644 packages/jarl-atoms/src/requireMatch.ts diff --git a/packages/jarl-atoms/DESIGN-NOTES.md b/packages/jarl-atoms/DESIGN-NOTES.md index 7189c85..bc63b42 100644 --- a/packages/jarl-atoms/DESIGN-NOTES.md +++ b/packages/jarl-atoms/DESIGN-NOTES.md @@ -109,3 +109,32 @@ without knowing where they are mounted. Two mechanisms exist and neither works: `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. + +## A derived "this chain always matches" bit on `RouteAtom` + +Rejected in favour of `requireMatch`/`useRequiredRoute`. + +Whether a chain can miss is a property of how its atoms compose, so the obvious design is to +derive it: `RouteAtom`, `RouteReturn` collapsing +to the matched branch when `Always` is `true`, and every constructor threading the bit through +`RouteOptions` from its parent. It typechecks — `Extract`/conditional types are enough, no +type-surgery dependency — and assignability survives, because `RouteAtom` is covariant in its +read type. Three things sink it anyway: + +- **The provable class is almost empty.** Only `rootAtom` (or `createRootAtom()` with no + `basePath`) is unconditionally total, and every path route can miss by construction, so a + chain qualifies only if it binds nothing off the path at all: optional `queryParamAtom`s and + total `transformRouteAtom`s, and nothing else. +- **It doesn't cover the case that motivated it.** The data-grid demo roots on + `createRootAtom({ basePath: "/demos/data-grid" })`, which reports `match: false` for any + location outside the prefix. A sound derivation has to call that chain partial. What actually + guarantees the match is the `` the demo is mounted under — knowledge that lives above + the atoms and can't be recovered from them. +- **`transformRouteAtom` can't report it.** Its getter is declared `=> Return | undefined`, so + totality would have to be inferred from the callback's own return type, changing how `Return` + is inferred for every existing caller. + +A manual `alwaysMatches: true` opt-in avoids the derivation entirely and was rejected on +soundness: it makes the type assert something nothing checks, so a wrong guarantee surfaces as +`undefined` field access far from the claim. `requireMatch` is the same assertion made by the +same caller, checked, and thrown at the point it is wrong. diff --git a/packages/jarl-atoms/src/__tests__/requireMatch.test.ts b/packages/jarl-atoms/src/__tests__/requireMatch.test.ts new file mode 100644 index 0000000..0c9b7a5 --- /dev/null +++ b/packages/jarl-atoms/src/__tests__/requireMatch.test.ts @@ -0,0 +1,66 @@ +import { createStore } from "jotai/vanilla"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { locationAtom } from "../locationAtom"; +import { queryParamAtom } from "../queryAtom"; +import { requireMatch } from "../requireMatch"; +import { staticRouteAtom } from "../staticRouteAtom"; +import { MatchedRoute, RouteReturn } from "../types"; + +type SortValues = { readonly sort: string | undefined }; + +const seed = (store: ReturnType, pathname: string, search = "") => { + store.set(locationAtom, { pathname, searchParams: new URLSearchParams(search) }); +}; + +const readSort = (search: string): RouteReturn => { + const store = createStore(); + seed(store, "/", search); + return store.get(queryParamAtom("sort")); +}; + +describe("requireMatch", () => { + it("returns a matched read unchanged", () => { + const store = createStore(); + seed(store, "/about"); + + const route = requireMatch(store.get(staticRouteAtom("about"))); + + expect(route.exact).toBe(true); + expect(route.rest.path).toEqual([]); + }); + + it("throws on a read that did not match, naming the route", () => { + const store = createStore(); + seed(store, "/elsewhere"); + + expect(() => requireMatch(store.get(staticRouteAtom("about")), "aboutRoute")).toThrow( + "aboutRoute does not match the current location", + ); + }); + + it("narrows the read to its matched branch", () => { + const read = readSort("sort=-price"); + + const matched = requireMatch(read); + + expectTypeOf(matched).toEqualTypeOf>(); + expectTypeOf(matched.values).toEqualTypeOf(); + expect(matched.values.sort).toBe("-price"); + }); + + it("removes the fallback an unnarrowed read forces on the caller", () => { + const read = readSort("sort=-price"); + + // @ts-expect-error - `values` is `SortValues | undefined` until the match is narrowed + const withoutRequireMatch: SortValues = read.values; + const withRequireMatch: SortValues = requireMatch(read).values; + + expect(withRequireMatch).toEqual(withoutRequireMatch); + }); + + it("still matches when the optional param it binds is absent", () => { + const { values } = requireMatch(readSort("")); + + expect(values.sort).toBeUndefined(); + }); +}); diff --git a/packages/jarl-atoms/src/index.ts b/packages/jarl-atoms/src/index.ts index c0cfbff..1d57be4 100644 --- a/packages/jarl-atoms/src/index.ts +++ b/packages/jarl-atoms/src/index.ts @@ -4,6 +4,7 @@ // jotai/react. The React components and hooks that consume these atoms // live in the sibling `jarl-react` package. export * from "./types"; +export * from "./requireMatch"; export * from "./locationAtom"; export * from "./routeAtom"; export * from "./rootAtom"; diff --git a/packages/jarl-atoms/src/requireMatch.ts b/packages/jarl-atoms/src/requireMatch.ts new file mode 100644 index 0000000..f92e51a --- /dev/null +++ b/packages/jarl-atoms/src/requireMatch.ts @@ -0,0 +1,15 @@ +import { DefaultParams, MatchedRoute, RouteReturn } from "./types"; + +/** + * Narrows a route read to its matched branch, throwing if it did not match. For the reads whose + * match is guaranteed by something the types can't see - an atom read only from inside a route + * that has already matched, or a chain binding nothing but optional params - where narrowing on + * `match` would mean inventing a fallback that can never be reached. Narrow on `match` instead + * wherever a miss is a case worth handling. `name` labels the route in the thrown message. + */ +export const requireMatch = (route: RouteReturn, name = "route"): MatchedRoute => { + if (!route.match) { + throw new Error(`${name} does not match the current location`); + } + return route; +}; diff --git a/packages/jarl-atoms/src/types.ts b/packages/jarl-atoms/src/types.ts index 916cddf..7b0fb2a 100644 --- a/packages/jarl-atoms/src/types.ts +++ b/packages/jarl-atoms/src/types.ts @@ -53,6 +53,9 @@ export type RouteReturn = { } ); +/** The matched branch of a `RouteReturn`: the one carrying `values` and `rest`. */ +export type MatchedRoute = Extract, { match: true }>; + // jotai's WritableAtom takes its write-side arguments as a tuple (Args) plus // a Result type, rather than the single-Update-type shape older jotai // versions used — hence `[T]` (a single-argument tuple) and `void` here. From 568d4722336e0d2551f2ec5432b3de5a5bfe0d67 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Thu, 20 Aug 2026 03:05:02 +0100 Subject: [PATCH 2/3] =?UTF-8?q?feat(jarl-react):=20685=20=E2=80=94=20useRe?= =?UTF-8?q?quiredRoute,=20with=20isActive=20on=20the=20shared=20MatchedRou?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket: 685 --- .../jarl-react/src/__tests__/hooks.test.tsx | 36 +++++++++++++++++-- packages/jarl-react/src/hooks.ts | 15 +++++++- packages/jarl-react/src/isActive.ts | 8 ++--- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/jarl-react/src/__tests__/hooks.test.tsx b/packages/jarl-react/src/__tests__/hooks.test.tsx index b098536..8346890 100644 --- a/packages/jarl-react/src/__tests__/hooks.test.tsx +++ b/packages/jarl-react/src/__tests__/hooks.test.tsx @@ -1,7 +1,9 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { ReactElement } from "react"; +import { describe, it, expect, beforeEach, expectTypeOf, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; -import { rootAtom } from "jarl-atoms"; -import { useRoute, useNavigate, useIsActive, useHref, useLink } from "../hooks"; +import { Provider, createStore } from "jotai"; +import { locationAtom, rootAtom } from "jarl-atoms"; +import { useRoute, useRequiredRoute, useNavigate, useIsActive, useHref, useLink } from "../hooks"; import { aboutAtom, teamAtom, userAtom, usersAtom } from "./fixtures"; const goTo = (path: string) => window.history.pushState(null, "", path); @@ -32,6 +34,34 @@ describe("useRoute", () => { }); }); +describe("useRequiredRoute", () => { + // Seeded through a store of its own rather than `goTo`: these assert on the very first render, + // and a bare `history.pushState` only reaches the location atom once it is mounted and listening. + const renderAt = (path: string, ui: ReactElement) => { + const store = createStore(); + store.set(locationAtom, { pathname: path, searchParams: new URLSearchParams() }); + return render({ui}); + }; + + it("gives the matched values with no undefined left to narrow away", () => { + const Probe = () => { + const { values, exact } = useRequiredRoute(userAtom); + expectTypeOf(values.id).toEqualTypeOf(); + return
{`${values.id}/${exact}`}
; + }; + renderAt("/users/42", ); + expect(screen.getByTestId("probe")).toHaveTextContent("42/true"); + }); + + it("throws when the route it was promised would match does not", () => { + const Probe = () =>
{useRequiredRoute(userAtom, "userAtom").values.id}
; + // React logs the error on its way back out; only the rethrow is worth asserting on. + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => renderAt("/about", )).toThrow("userAtom does not match the current location"); + errorLog.mockRestore(); + }); +}); + describe("useNavigate", () => { it("pushes a new location when called", () => { goTo("/"); diff --git a/packages/jarl-react/src/hooks.ts b/packages/jarl-react/src/hooks.ts index 68a9ad4..01af2d5 100644 --- a/packages/jarl-react/src/hooks.ts +++ b/packages/jarl-react/src/hooks.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo } from "react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { DefaultParams, RouteAtom } from "jarl-atoms"; +import { DefaultParams, MatchedRoute, RouteAtom, requireMatch } from "jarl-atoms"; import { isActive } from "./isActive"; // Re-export jotai's own primitive hooks. Per jotai convention (see @@ -19,6 +19,19 @@ export function useRoute(routeAtom: RouteAtom) { return useAtomValue(routeAtom); } +/** + * `useRoute` for a route atom whose match is guaranteed by where the component renders - under the + * `` that already matched it, or on a chain binding nothing but optional params. Returns the + * matched branch, so `values` needs no fallback, and throws if it turns out not to match. + * + * Without the Navigation API (Firefox/Safari today), a `history.pushState` made outside jarl can + * leave the read one render stale, so that throw can fire where `useRoute` would render stale + * values instead. Navigate through jarl to avoid it. + */ +export function useRequiredRoute(routeAtom: RouteAtom, name?: string): MatchedRoute { + return requireMatch(useAtomValue(routeAtom), name); +} + /** * Returns a stable `navigate` function bound to one route atom. Calling it with param values * pushes a new location. diff --git a/packages/jarl-react/src/isActive.ts b/packages/jarl-react/src/isActive.ts index 01a148d..0a927c4 100644 --- a/packages/jarl-react/src/isActive.ts +++ b/packages/jarl-react/src/isActive.ts @@ -1,11 +1,9 @@ -import { DefaultParams, RouteReturn } from "jarl-atoms"; +import { DefaultParams, MatchedRoute, RouteReturn } from "jarl-atoms"; /** * The single definition of "this route is currently showing", shared by * `Route`, `Switch` and the `useIsActive`/`useLink` hooks so a `Switch` picks * the same child that child would have picked for itself. */ -export const isActive = ( - route: RouteReturn, - exact?: boolean, -): route is Extract, { match: true }> => (exact ? route.exact : route.match); +export const isActive = (route: RouteReturn, exact?: boolean): route is MatchedRoute => + exact ? route.exact : route.match; From 7cd13bbc0efac1cacf4abc082f6b387985a48828 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Thu, 20 Aug 2026 03:05:02 +0100 Subject: [PATCH 3/3] =?UTF-8?q?refactor(demos):=20685=20=E2=80=94=20drop?= =?UTF-8?q?=20the=20data-grid=20demo=20fallback=20that=20only=20existed=20?= =?UTF-8?q?for=20the=20type=20checker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket: 685 --- packages/docs/src/demos/DataGridApp.tsx | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/docs/src/demos/DataGridApp.tsx b/packages/docs/src/demos/DataGridApp.tsx index 6ff94c0..bed6f45 100644 --- a/packages/docs/src/demos/DataGridApp.tsx +++ b/packages/docs/src/demos/DataGridApp.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; -import { atom, useAtom, useAtomValue } from "jotai"; -import { createRootAtom, queryParamAtom, transformRouteAtom } from "jarl-atoms"; +import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; +import { createRootAtom, queryParamAtom, requireMatch, transformRouteAtom } from "jarl-atoms"; +import { useRequiredRoute } from "jarl-react"; import { Table } from "./DataGridTable"; import { Ware, wares } from "./wares"; @@ -68,32 +69,30 @@ const sortRoute = transformRouteAtom( // 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. +// A plain read off the chain's tip - no useMemo in the component needed for this. Only the +// component below reads it, and the site only mounts that under /demos/data-grid, which is the +// guarantee `requireMatch` stands on. const rowsAtom = atom((get) => { - const values = get(filterRoute).values ?? { ...parseSort(undefined), filter: undefined }; + const { values } = requireMatch(get(filterRoute), "filterRoute"); return sortWares(filterWares(wares, values.filter), values.key, values.direction); }); // 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; -// but since this can't be confirmed with the types, use a default -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. 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 chain matches wherever + * its root does - no `` needed, just reading the atoms directly, with `useRequiredRoute` + * standing in for the match the mount point already guarantees. */ export const DataGridApp = () => { - const [filter, setFilter] = useAtom(filterRoute); + const { values: currentFilter } = useRequiredRoute(filterRoute, "filterRoute"); + const setFilter = useSetAtom(filterRoute); const rows = useAtomValue(rowsAtom); const [filterInput, setFilterInput] = useAtom(filterInputAtom); - const currentFilter = filter.values ?? defaultFilter; - // Keeps the input in sync with the URL when it changes some other way (back/forward, a // shared link) - the input is otherwise free-standing scratch state, not live-searching. useEffect(() => setFilterInput(currentFilter.filter ?? ""), [currentFilter.filter, setFilterInput]);