Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 12 additions & 13 deletions packages/docs/src/demos/DataGridApp.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 `<Route>` 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 `<Route>` 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]);
Expand Down
29 changes: 29 additions & 0 deletions packages/jarl-atoms/DESIGN-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, Always extends boolean = boolean>`, `RouteReturn<T, Always>` 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 `<Route>` 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.
66 changes: 66 additions & 0 deletions packages/jarl-atoms/src/__tests__/requireMatch.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createStore>, pathname: string, search = "") => {
store.set(locationAtom, { pathname, searchParams: new URLSearchParams(search) });
};

const readSort = (search: string): RouteReturn<SortValues> => {
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<MatchedRoute<SortValues>>();
expectTypeOf(matched.values).toEqualTypeOf<SortValues>();
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();
});
});
1 change: 1 addition & 0 deletions packages/jarl-atoms/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
15 changes: 15 additions & 0 deletions packages/jarl-atoms/src/requireMatch.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends DefaultParams>(route: RouteReturn<T>, name = "route"): MatchedRoute<T> => {
if (!route.match) {
throw new Error(`${name} does not match the current location`);
}
return route;
};
3 changes: 3 additions & 0 deletions packages/jarl-atoms/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ export type RouteReturn<T extends DefaultParams = DefaultParams> = {
}
);

/** The matched branch of a `RouteReturn`: the one carrying `values` and `rest`. */
export type MatchedRoute<T extends DefaultParams = DefaultParams> = Extract<RouteReturn<T>, { 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.
Expand Down
36 changes: 33 additions & 3 deletions packages/jarl-react/src/__tests__/hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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(<Provider store={store}>{ui}</Provider>);
};

it("gives the matched values with no undefined left to narrow away", () => {
const Probe = () => {
const { values, exact } = useRequiredRoute(userAtom);
expectTypeOf(values.id).toEqualTypeOf<string>();
return <div data-testid="probe">{`${values.id}/${exact}`}</div>;
};
renderAt("/users/42", <Probe />);
expect(screen.getByTestId("probe")).toHaveTextContent("42/true");
});

it("throws when the route it was promised would match does not", () => {
const Probe = () => <div>{useRequiredRoute(userAtom, "userAtom").values.id}</div>;
// 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", <Probe />)).toThrow("userAtom does not match the current location");
errorLog.mockRestore();
});
});

describe("useNavigate", () => {
it("pushes a new location when called", () => {
goTo("/");
Expand Down
15 changes: 14 additions & 1 deletion packages/jarl-react/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,6 +19,19 @@ export function useRoute<T extends DefaultParams>(routeAtom: RouteAtom<T>) {
return useAtomValue(routeAtom);
}

/**
* `useRoute` for a route atom whose match is guaranteed by where the component renders - under the
* `<Route>` 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<T extends DefaultParams>(routeAtom: RouteAtom<T>, name?: string): MatchedRoute<T> {
return requireMatch(useAtomValue(routeAtom), name);
}

/**
* Returns a stable `navigate` function bound to one route atom. Calling it with param values
* pushes a new location.
Expand Down
8 changes: 3 additions & 5 deletions packages/jarl-react/src/isActive.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends DefaultParams>(
route: RouteReturn<T>,
exact?: boolean,
): route is Extract<RouteReturn<T>, { match: true }> => (exact ? route.exact : route.match);
export const isActive = <T extends DefaultParams>(route: RouteReturn<T>, exact?: boolean): route is MatchedRoute<T> =>
exact ? route.exact : route.match;
Loading