Skip to content
Merged
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
21 changes: 13 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,6 @@ on:
description: "Release everything outstanding since the last tag"
type: boolean
default: false
registry_smoke:
description: "Smoke-test the currently published packages"
type: boolean
default: false

jobs:
build-and-test:
Expand Down Expand Up @@ -115,18 +111,27 @@ jobs:
run: npm run test:e2e

registry-smoke:
name: Registry smoke test (published packages)
name: Registry smoke test (packed local build)
runs-on: ubuntu-latest
# Tests the published packages, not this commit — pointless on a PR build; run by hand after a release.
if: github.event_name == 'workflow_dispatch' && inputs.registry_smoke
needs: build-and-test
# Ticket 823: this used to install from the npm registry at `latest`, gated behind
# workflow_dispatch only — so a source rename here couldn't fail until the next release
# republished, in a job nobody was watching. It now packs jarl-atoms/jarl-react from this
# commit as `npm pack` would for a release and installs those tarballs as a real npm
# dependency (see e2e/registry-smoke/README.md, "Tracking source renames"), so drift shows up
# on the PR that introduced it instead of after a release ships broken.
steps:
- uses: actions/checkout@v5

- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm

- name: Install dependencies
run: npm ci

- name: Install the smoke consumer from the registry
- name: Pack local build and install the smoke consumer
run: npm run test:smoke:install

- name: Run the smoke consumer
Expand Down
14 changes: 9 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@ JARL ("JARL: Atomic Routing Library") is a controlled-component router for React
dogfoods the two packages above for its own navigation
- `e2e/` — Playwright suite plus the minimal Vite fixture app it drives. A separate
npm project (not a workspace) with its own deps: `npm run test:e2e:install` first.
- `e2e/registry-smoke/` — a consumer project that installs both packages from the npm
registry and uses them unlinked, so the published tarballs get exercised. Also a
separate npm project; run it after a release, not against working-tree changes.
`cjs-nodenext/` inside it is the exception: it packs and installs from the working
tree, to typecheck a `node16`-resolution CommonJS consumer against uncommitted builds.
- `e2e/registry-smoke/` — a consumer project that installs both packages as a real npm
dependency, unlinked from the workspace — `pack-local.mjs` packs them from the working
tree and installs the tarballs, so it exercises the same `dist`/`exports`/`.d.ts` a
release would ship, on every PR (see its README, "Tracking source renames"). Also a
separate npm project. `cjs-nodenext/` inside it does the same pack-and-install, to
typecheck a `node16`-resolution CommonJS consumer.
- `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
47 changes: 27 additions & 20 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 Expand Up @@ -197,8 +204,8 @@ npm run test:e2e:install # once, to install the suite's deps and browsers
npm run test:e2e
```

To check the packages as actually published on npm — installed from the registry into a
clean consumer project, with no workspace linking (see
To check the packages as a real npm dependency would see them — installed from a locally
packed tarball into a clean consumer project, with no workspace linking (see
[`e2e/registry-smoke`](./e2e/registry-smoke)):

```
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
3 changes: 2 additions & 1 deletion e2e/registry-smoke/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules/
# Committing one would pin the versions under test to whatever was latest the day it was written.
tarballs/
# Committing one would pin the tarball integrity hash to whatever pack-local.mjs last produced.
package-lock.json
67 changes: 43 additions & 24 deletions e2e/registry-smoke/README.md
Original file line number Diff line number Diff line change
@@ -1,56 +1,75 @@
# Registry smoke test

A throwaway consumer project that installs `jarl-atoms` and `jarl-react` **from the npm
registry** and uses them the way a third party would. Nothing here resolves to
`packages/*` — no workspace link, no path alias — so it is the only test in this repo that
exercises the actual published tarballs: the bundled `dist/`, the `exports` map, the
emitted `.d.ts`, and the dependency ranges npm resolves from the manifest.

Run it after a release; it says nothing useful about uncommitted work.
A throwaway consumer project that installs `jarl-atoms` and `jarl-react` **as a real npm
dependency** — a `file:` tarball, not a workspace link or path alias — and uses them the way a
third party would. It exercises the actual packed artifacts: the bundled `dist/`, the `exports`
map, the emitted `.d.ts`, and the dependency ranges npm resolves from the manifest.

```bash
npm run test:smoke:install # from the repo root, once per version under test
npm run test:smoke:install # builds packages/{jarl-atoms,jarl-react} and packs them here
npm run test:smoke
```

`npm test` here runs three checks in order:

1. **`typecheck`** — `tsc` over the consumer sources with `skipLibCheck` off, so the
published declaration files are themselves typechecked.
packed declaration files are themselves typechecked.
2. **`test:entrypoints`** — `esm-smoke.mjs` and `cjs-smoke.cjs` load both packages through
real Node resolution (not Vite's), asserting the full export list is present and that
route matching works with no DOM.
3. **`test:unit`** — a jsdom app covering routing, link reversal, click and programmatic
navigation, query params, redirects, `resolvedAtom` and server rendering from a seeded
navigation, query params, redirects, `asyncRouteAtom` and server rendering from a seeded
location.

## Versions under test
## Tracking source renames (ticket 823)

Until 2026-08-21 this project installed `jarl-atoms`/`jarl-react` **from the npm registry at
`latest`**, on a job wired to `workflow_dispatch` only — never a PR. That tests the *published*
package, which is the point, but it meant a source rename (`resolvedAtom` removed by ticket 675,
five atoms gaining a `Route` suffix by ticket 789) went unnoticed here: the test kept passing
against whatever was already on the registry and would only have broken in a job nobody watches,
the moment the next release republished.

Three ways to close that were on the table:

1. update this project in lockstep with every source rename — it then tests the *next* release
rather than the current one, and still can't fail until someone remembers to touch it;
2. pin to an explicit `jarl-atoms`/`jarl-react` version, so a mismatch is at least legible when
someone looks;
3. run it on every PR against a **locally-packed tarball** of this branch, so drift is caught the
moment it's introduced.

Both packages are declared as `latest` and the lockfile is gitignored, so `npm install`
always fetches whatever is currently published. To pin a specific version instead:
(3) is what's implemented, via `pack-local.mjs` and the `registry-smoke` CI job running
unconditionally alongside `build-and-test` (see `.github/workflows/ci.yml`) rather than only on
manual dispatch. **What this trades away**: the job no longer proves the currently-published npm
tarball works end-to-end — a botched `npm publish`, a stale `files`/`exports` entry that only a
real publish would expose, can't be caught this way. What it buys back: the build this job packs
uses the same `files`/`main`/`exports`/`types` fields and the same `dist/` a release would ship,
so it catches everything short of the publish step itself, and it catches it on every PR rather
than after a release ships broken.

To point it at a real published version instead — e.g. to actually smoke-test a release —
override the dependency after install:

```bash
npm --prefix e2e/registry-smoke install jarl-atoms@2.0.1 jarl-react@2.0.1
npm --prefix e2e/registry-smoke install jarl-atoms@2.7.0 jarl-react@2.7.0
```

`pack-local.mjs` packs both packages from the working tree and installs the tarballs here.
Repeat runs must remove the previously-extracted `node_modules/` copies and `package-lock.json`
first — npm treats an unchanged `file:` dependency spec as satisfied and won't re-read a
same-named tarball whose contents changed. (The script does this itself.)

## CommonJS consumer under `node16`/`nodenext` resolution

`cjs-nodenext/` type-checks a CommonJS consumer against `dist/index.d.cts` — the
declaration file the `require` condition's `types` points at — under
`moduleResolution: node16` (the only setting that actually raises TS1479 for a
masquerading-as-ESM package; `nodenext` resolves the same files but the compiler's
own gate for that diagnostic excludes it).

It runs against **local tarballs**, not the registry: this check exists to catch
regressions before a release, so it must work against uncommitted `dist/` output,
and separately the registry can carry a broken version of either package.
own gate for that diagnostic excludes it). It uses the same locally-packed-tarball
approach as the rest of this project, via its own `pack-local.mjs`:

```bash
npm run build --workspace packages/jarl-atoms --workspace packages/jarl-react
npm --prefix e2e/registry-smoke run test:cjs-nodenext
```

`cjs-nodenext/pack-local.mjs` packs both packages from the working tree and installs
the tarballs here. Repeat runs must remove the previously-extracted `node_modules/`
copies and `package-lock.json` first — npm treats an unchanged `file:` dependency
spec as satisfied and won't re-read a same-named tarball whose contents changed.
2 changes: 1 addition & 1 deletion e2e/registry-smoke/cjs-smoke.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const atoms = require("jarl-atoms");
const bindings = require("jarl-react");

assert.equal(typeof atoms.staticRouteAtom, "function");
assert.equal(typeof atoms.redirectAtom, "function");
assert.equal(typeof atoms.redirectRouteAtom, "function");
assert.equal(typeof bindings.Link, "function");
assert.equal(typeof bindings.Route, "function");
assert.equal(typeof bindings.useNavigate, "function");
Expand Down
Loading
Loading