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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ JARL ("JARL: Atomic Routing Library") is a controlled-component router for React
- `infra/` — AWS CDK app provisioning the hosting for jarl.randomdev.co.uk. Also a separate
npm project, kept out of the workspaces so it is never published: `infra/README.md`.

A new atom's name says what it returns: `*RouteAtom` if its value is a `RouteAtom`, `*Atom`
otherwise. See `packages/jarl-atoms/DESIGN-NOTES.md`.

The two packages are deliberately separate import paths: `jarl-react` does **not** re-export
`jarl-atoms`. Consumers get route atoms from `jarl-atoms` and the React bindings from
`jarl-react`, so the framework boundary stays visible and `jarl-atoms` is usable on its own.
Expand Down
43 changes: 25 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,30 @@ wanted something that did just this job extremely well, but without getting in t
dictating application structure, and without forcing route matching logic into the component
tree itself, where it never seemed to belong. JARL builds that mapping out of composable atoms
using [jotai](https://jotai.org/) under the hood: each route is its own atom, with a link to a
parent atom and so on up to the [`rootAtom`](/api/jarl-atoms#rootatom); each one matching a
piece of the URL (normally a path segment) and telling you both whether it *currently* matches,
parent atom and so on up to the [`rootRoute`](/api/jarl-atoms#rootroute); each one matching a
piece of the URL (normally a path segment) and telling you both whether it _currently_ matches,
as well as **how to build a URL _to_ that route** based on a given state. Routing decisions in
your application then decompose to very simple logic based on the current states of these
atoms; a simple `switch` statement or series of `if`s is enough to decide what components to
render, and navigation can be performed by *calling the atom setter*. (Convenience components
render, and navigation can be performed by _calling the atom setter_. (Convenience components
like [`<Route>`](/api/jarl-react#route) and [`<Switch>`](/api/jarl-react#switch) and of course
the ubiquitous [`<Link>`](/api/jarl-react#link) are of course provided in the React package, if
you want to build more compositionally; they all just accept atoms for parameters instead of
type-unsafe strings.)

Because each route atom is an independent, subscribable unit of jotai state, a component that
reads one only re-renders when *that atom's* derived value actually changes - it turns out this
reads one only re-renders when _that atom's_ derived value actually changes - it turns out this
is incredibly efficient.

## Features

* Map URLs directly to state (and back again) - the URL becomes the source of truth
* Composable route atoms - build nested/dynamic routes out of small, independent pieces
* Framework-agnostic core (`jarl-atoms`) with lightweight React bindings (`jarl-react`)
* Full querystring matching support
* Resolve promises during routing (via jotai's own async atoms) and redirect if required
* SSR/SSG-safe: the resolved location atom is hydratable per-render on the server
* And much more...
- Map URLs directly to state (and back again) - the URL becomes the source of truth
- Composable route atoms - build nested/dynamic routes out of small, independent pieces
- Framework-agnostic core (`jarl-atoms`) with lightweight React bindings (`jarl-react`)
- Full querystring matching support
- Resolve promises during routing (via jotai's own async atoms) and redirect if required
- SSR/SSG-safe: the resolved location atom is hydratable per-render on the server
- And much more...

## Concrete Example

Expand All @@ -54,9 +54,9 @@ Declare some route atoms:

```ts
// routes.ts
import { rootAtom, staticRouteAtom, paramRouteAtom } from "jarl-atoms";
import { rootRoute, staticRouteAtom, paramRouteAtom } from "jarl-atoms";

export const homeRoute = rootAtom;
export const homeRoute = rootRoute;
export const aboutRoute = staticRouteAtom("about");
export const productsRoute = staticRouteAtom("products");
// The `productId` segment is bound into `values` when this route matches:
Expand All @@ -75,7 +75,7 @@ import App from "./App";
createRoot(document.getElementById("root")!).render(
<Provider>
<App />
</Provider>
</Provider>,
);
```

Expand Down Expand Up @@ -110,7 +110,9 @@ import { Link } from "jarl-react";

const MainMenu = () => (
<nav>
<Link route={homeRoute} exact>Home</Link>
<Link route={homeRoute} exact>
Home
</Link>
<Link route={aboutRoute}>About</Link>
<Link route={productRoute} to={{ productId: "123" }}>
Our Best Product Ever!
Expand All @@ -129,10 +131,10 @@ the `useNavigate` hook instead:
```tsx
import { atom, useAtom } from "jotai";
import { useNavigate } from "jarl-react";
import { queryParamAtom } from "jarl-atoms";
import { queryParamRouteAtom } from "jarl-atoms";

// A single named query-string param is its own composable route atom too:
const searchQueryRoute = queryParamAtom("q");
const searchQueryRoute = queryParamRouteAtom("q");

// Controlled search input value also tracked in an atom
const searchTextAtom = atom("");
Expand All @@ -141,7 +143,12 @@ const SearchForm = () => {
const [searchText, setSearchText] = useAtom(searchTextAtom);
const navigate = useNavigate(searchQueryRoute);
return (
<form onSubmit={(e) => { e.preventDefault(); navigate({ q: searchText }); }}>
<form
onSubmit={(e) => {
e.preventDefault();
navigate({ q: searchText });
}}
>
<input
type="text"
value={searchText}
Expand Down
6 changes: 3 additions & 3 deletions e2e/fixture-app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useAtomValue } from "jotai";
import type { ComponentType } from "react";
import { rootAtom } from "./routes";
import { rootRoute } from "./routes";
import Shell from "./pages/Shell";
import BasicRouting from "./pages/BasicRouting";
import AdvancedRouting from "./pages/AdvancedRouting";
Expand All @@ -10,7 +10,7 @@ import NavigationGuards from "./pages/NavigationGuards";

// Top-level segment -> demo. The v2 route atoms don't have a "first match
// wins" switch/exclusivity primitive yet, so this dispatch is done in plain
// component code (reading rootAtom directly) rather than by composing
// component code (reading rootRoute directly) rather than by composing
// several independent <Route> elements, which would all render at once
// since nothing here excludes them from each other.
const DEMOS: Record<string, ComponentType> = {
Expand All @@ -22,7 +22,7 @@ const DEMOS: Record<string, ComponentType> = {
};

const App = () => {
const root = useAtomValue(rootAtom);
const root = useAtomValue(rootRoute);
const section = root.match ? root.rest.path[0] : undefined;
const Demo = section && DEMOS[section];
return Demo ? <Demo /> : <Shell />;
Expand Down
6 changes: 3 additions & 3 deletions e2e/fixture-app/src/pages/Shell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useAtomValue } from "jotai";
import { useEffect } from "react";
import { rootAtom, changelogAtom, shellMissingAtom } from "../routes";
import { rootRoute, changelogAtom, shellMissingAtom } from "../routes";
import { Link } from "jarl-react";

const useTitle = (title: string) => {
Expand Down Expand Up @@ -43,7 +43,7 @@ const NotFound = ({ missingPath }: { missingPath: string }) => {
// Top-level "shell" of the fixture app: home/about, changelog, and the
// catch-all 404. Mirrors demo/cypress/integration/00DemosShell.js.
const Shell = () => {
const root = useAtomValue(rootAtom);
const root = useAtomValue(rootRoute);
const changelog = useAtomValue(changelogAtom);
const missing = useAtomValue(shellMissingAtom);

Expand All @@ -63,7 +63,7 @@ const Shell = () => {
return (
<div>
<nav>
<Link route={rootAtom} data-test="home-nav-link">
<Link route={rootRoute} data-test="home-nav-link">
Home
</Link>{" "}
<Link route={changelogAtom} data-test="changelog-nav-link">
Expand Down
10 changes: 5 additions & 5 deletions e2e/fixture-app/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,23 @@
* can exercise realistic nested/param routes.
*
* NOTE: this file only *composes* the primitives jarl-atoms exports
* (rootAtom, staticRouteAtom, paramRouteAtom, redirectAtom, asyncRouteAtom). It
* (rootRoute, staticRouteAtom, paramRouteAtom, redirectRouteAtom, asyncRouteAtom). It
* does not add routing features to the library.
*/
import { atom } from "jotai/vanilla";
import { loadable } from "jotai/utils";
import {
rootAtom,
rootRoute,
staticRouteAtom,
paramRouteAtom,
redirectAtom,
redirectRouteAtom,
asyncRouteAtom,
redirect,
navigationGuardAtom,
} from "jarl-atoms";

// --- Shell (demo/cypress/integration/00DemosShell.js) ---
export { rootAtom };
export { rootRoute };
export const changelogAtom = staticRouteAtom("changelog");
// Catches any single unmatched top-level segment, e.g. /asdfghjkl
export const shellMissingAtom = paramRouteAtom("missingPath");
Expand Down Expand Up @@ -74,7 +74,7 @@ export const redirectsContentSlugAtom = paramRouteAtom("slug", {
// landing page. Read via its `match`, not `followRedirects` - see the
// comment on `reasonSearchParams` in Redirects.tsx for why the actual
// navigation is handled there instead.
export const redirectsMovedRedirectAtom = redirectAtom("/redirects", {
export const redirectsMovedRedirectAtom = redirectRouteAtom("/redirects", {
parent: redirectsMovedAtom,
});

Expand Down
6 changes: 3 additions & 3 deletions packages/docs/src/content/guides/DataLoading.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,14 @@ the current location with its target (`history.replaceState`, so the abandoned U
linger in the back-button history).

If a route should redirect unconditionally - with no data fetch involved at all -
`redirectAtom`/`followRedirects` do the same job without a loader:
`redirectRouteAtom`/`followRedirects` do the same job without a loader:

```ts
import { redirectAtom, followRedirects } from "jarl-atoms";
import { redirectRouteAtom, followRedirects } from "jarl-atoms";
import { staticRouteAtom } from "jarl-atoms";

export const oldAboutRoute = staticRouteAtom("about-us");
export const oldAboutRedirect = redirectAtom("/about", { parent: oldAboutRoute });
export const oldAboutRedirect = redirectRouteAtom("/about", { parent: oldAboutRoute });

followRedirects(store, [oldAboutRedirect]);
```
6 changes: 3 additions & 3 deletions packages/docs/src/content/guides/GettingStarted.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ root of your app, and `Link`/`Route` from `jarl-react` to navigate and render.
routes.ts:

```ts
import { rootAtom, staticRouteAtom } from "jarl-atoms";
import { rootRoute, staticRouteAtom } from "jarl-atoms";

export const homeRoute = rootAtom;
export const homeRoute = rootRoute;
export const aboutRoute = staticRouteAtom("about");
```

Each of these is a **route atom**: a jotai atom that, when read, tells you whether its path
currently matches (`match`, `exact`, `values`) and how to build a URL for it (`reverse`); when
_written_, it navigates there. `rootAtom` matches `/` itself and is the implicit parent every
_written_, it navigates there. `rootRoute` matches `/` itself and is the implicit parent every
other route atom builds on unless you give it a different `parent`. `staticRouteAtom("about")`
matches a single fixed path segment - here, `/about`.

Expand Down
8 changes: 4 additions & 4 deletions packages/docs/src/content/guides/PathVariables.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Now we've seen how to set up some static routes, let's look at something a bit m
Routing isn't much use if we have to define every single URL statically!

`paramRouteAtom` binds a single dynamic path segment to a named value, composed on top of a
parent route atom (any route atom - `rootAtom`, a `staticRouteAtom`, or another
parent route atom (any route atom - `rootRoute`, a `staticRouteAtom`, or another
`paramRouteAtom`, for nested dynamic segments):

routes.ts:
Expand Down Expand Up @@ -58,15 +58,15 @@ Linking to a dynamic route works the same way as a static one - just pass the pa

## Query parameters

Dynamic _path_ segments aren't the only way to carry a value in a URL - `queryParamAtom` (from
Dynamic _path_ segments aren't the only way to carry a value in a URL - `queryParamRouteAtom` (from
`jarl-atoms`) does the same job for a single named query-string parameter, composed on top of a
parent route atom exactly like `paramRouteAtom`, except it doesn't consume a path segment:

```ts
import { staticRouteAtom, queryParamAtom } from "jarl-atoms";
import { staticRouteAtom, queryParamRouteAtom } from "jarl-atoms";

export const searchRoute = staticRouteAtom("search");
export const searchQueryRoute = queryParamAtom("q", { parent: searchRoute });
export const searchQueryRoute = queryParamRouteAtom("q", { parent: searchRoute });
```

`searchQueryRoute` matches whenever `/search` does, with `values.q` set to the current `?q=`
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ on every navigation regardless of relevance. The v2 atoms
override under Node - so a location can be seeded server-side for prerendering,
which is exactly what lets this docs site itself be statically generated.
- `routeAtom(matchPath, makePath, { parent })` derives a **route atom** from a parent
route atom (defaulting to a `rootAtom`), matching one path segment at a time and
route atom (defaulting to a `rootRoute`), matching one path segment at a time and
carrying a `rest.path` of unconsumed segments down to child route atoms - so nested
routes compose as a chain of atoms instead of a nested-array route table walked by a
single `RouteMap.match` call.
Expand Down
6 changes: 3 additions & 3 deletions packages/docs/src/demos/BlogRoutingApp.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createRootAtom, numericRouteAtom, paramRouteAtom, validateAtom } from "jarl-atoms";
import { rootRouteAtom, numericRouteAtom, paramRouteAtom, validateRouteAtom } from "jarl-atoms";
import { Link, Route, Switch } from "jarl-react";
import {
BlogPost,
Expand Down Expand Up @@ -30,13 +30,13 @@ const MONTH_NAMES = [
const formatDate = (post: BlogPost) => `${MONTH_NAMES[post.month - 1]} ${post.day}, ${post.year}`;

// 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 blogRoot = rootRouteAtom({ basePath: "/demos/blog-routing" });
const yearRoute = numericRouteAtom("year", { parent: blogRoot });
const monthRoute = numericRouteAtom("month", { parent: yearRoute, min: 1, max: 12 });
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 dayRoute = validateRouteAtom(daySegment, ({ year, month, day }) => isValidCalendarDate(year, month, day));
const postRoute = paramRouteAtom("slug", { parent: dayRoute });

const BlogNav = () => (
Expand Down
10 changes: 5 additions & 5 deletions packages/docs/src/demos/DataGridApp.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { createRootAtom, queryParamAtom, requireMatch, transformRouteAtom } from "jarl-atoms";
import { rootRouteAtom, queryParamRouteAtom, requireMatch, transformRouteAtom } from "jarl-atoms";
import { useRequiredRoute } from "jarl-react";
import { Table } from "./DataGridTable";
import { Ware, wares } from "./wares";
Expand Down Expand Up @@ -51,23 +51,23 @@ const sortWares = (rows: Ware[], key: SortKey, direction: SortDirection) => {
};

// The page this demo is mounted on, so everything below it is a plain module-level atom.
const gridRoot = createRootAtom({ basePath: "/demos/data-grid" });
const gridRoot = rootRouteAtom({ basePath: "/demos/data-grid" });

// The raw "sort" query segment.
const sortParam = queryParamAtom("sort", { parent: gridRoot });
const sortParam = queryParamRouteAtom("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.
// Up: serialize back to the raw string queryParamRouteAtom 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 });
const filterRoute = queryParamRouteAtom("filter", { parent: sortRoute });

// 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
Expand Down
4 changes: 2 additions & 2 deletions packages/docs/src/pages/Changelog.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createRootAtom, paramRouteAtom } from "jarl-atoms";
import { rootRouteAtom, paramRouteAtom } from "jarl-atoms";
import { Link, Route, Switch } from "jarl-react";
import Markdown from "../lib/Markdown";
import { changelogEntries, changelogEntryFor, fullChangelog, ChangelogEntry } from "./changelogEntries";

// The page this is mounted on, so its version route below is a plain module-level atom.
const changelogRoot = createRootAtom({ basePath: "/changelog" });
const changelogRoot = rootRouteAtom({ basePath: "/changelog" });
const versionRoute = paramRouteAtom("version", { parent: changelogRoot });

const ChangelogNav = () => (
Expand Down
4 changes: 2 additions & 2 deletions packages/docs/src/pages/DemosIndex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export const DemosIndex = () => (
<Link route={dataGridDemoRoute} to={{}}>
Data grid filter/sort
</Link>{" "}
&mdash; a table whose filter text and sort column live in <code>queryParamAtom</code>s chained off the mount
route, so the grid&apos;s state is shareable and moves with back/forward navigation.
&mdash; a table whose filter text and sort column live in <code>queryParamRouteAtom</code>s chained off the
mount route, so the grid&apos;s state is shareable and moves with back/forward navigation.
</li>
<li>
<Link route={asyncLookupDemoRoute} to={{}}>
Expand Down
4 changes: 2 additions & 2 deletions packages/docs/src/router/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
* `jarl-atoms`' server-seedable `locationAtom`.
*/
import { atom } from "jotai";
import { asyncRouteAtom, notAtom, rootAtom, staticRouteAtom, paramRouteAtom } from "jarl-atoms";
import { asyncRouteAtom, notAtom, rootRoute, staticRouteAtom, paramRouteAtom } from "jarl-atoms";
import { blogStaticPaths } from "../demos/blogPosts";
import { articleSlugs, findArticle } from "../demos/asyncArticles";
import { changelogStaticPaths } from "../pages/changelogEntries";

export const homeRoute = rootAtom;
export const homeRoute = rootRoute;

export const docsSectionRoute = staticRouteAtom("docs");
export const docPageRoute = paramRouteAtom("docName", { parent: docsSectionRoute });
Expand Down
Loading
Loading