Skip to content
Draft
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
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"react-dom": "^19.2.8"
},
"devDependencies": {
"@faker-js/faker": "^10.6.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
Expand Down
21 changes: 15 additions & 6 deletions packages/docs/scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,29 +41,38 @@ async function main() {
const template = await fs.readFile(templatePath, "utf-8");

const entryServerUrl = pathToFileURL(path.join(ssrOutDir, "entry-server.js")).href;
/** @type {{ render: (path: string) => { html: string, head: string }, staticPaths: string[] }} */
/** @type {{ render: (path: string) => Promise<{ html: string, head: string, status: number }>, staticPaths: string[] }} */
const { render, staticPaths } = await import(entryServerUrl);

// Emotion's extracted <style> markup goes in <head>, so a prerendered page is fully
// styled from the first paint rather than after hydration.
const fillTemplate = (routePath) => {
const { html, head } = render(routePath);
return template.replace("<!--app-head-->", head).replace("<!--app-html-->", html);
const fillTemplate = async (routePath) => {
const { html, head, status } = await render(routePath);
return { status, page: template.replace("<!--app-head-->", head).replace("<!--app-html-->", html) };
};

// eslint-disable-next-line no-console
console.log(`[docs:build] prerendering ${staticPaths.length} routes...`);
await Promise.all(
staticPaths.map(async (routePath) => {
const { page, status } = await fillTemplate(routePath);
// A prerendered path that renders a 404 means staticPaths has drifted from the route table.
if (status !== 200) {
throw new Error(`[docs:build] ${routePath} is in staticPaths but rendered a ${status}`);
}
const outFile = routePath === "/" ? path.join(outDir, "index.html") : path.join(outDir, routePath, "index.html");
await fs.mkdir(path.dirname(outFile), { recursive: true });
await fs.writeFile(outFile, fillTemplate(routePath), "utf-8");
await fs.writeFile(outFile, page, "utf-8");
}),
);

// A 404.html at the root - the convention most static hosts (S3 + CloudFront,
// GitHub Pages, etc.) use for their "not found" error document.
await fs.writeFile(path.join(outDir, "404.html"), fillTemplate("/__not_found__"), "utf-8");
const notFound = await fillTemplate("/__not_found__");
if (notFound.status !== 404) {
throw new Error(`[docs:build] the 404 page rendered a ${notFound.status}`);
}
await fs.writeFile(path.join(outDir, "404.html"), notFound.page, "utf-8");

// The SSR bundle is a build-time-only tool; the deployable output is dist/ (static
// files only).
Expand Down
4 changes: 2 additions & 2 deletions packages/docs/scripts/dev-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ async function main() {
let template = await fs.readFile(path.resolve(root, "index.html"), "utf-8");
template = await vite.transformIndexHtml(url, template);
const { render } = await vite.ssrLoadModule("/src/entry-server.tsx");
const { html, head } = render(url);
const { html, head, status } = await render(url);
const page = template.replace("<!--app-head-->", head).replace("<!--app-html-->", html);
res.statusCode = 200;
res.statusCode = status;
res.setHeader("Content-Type", "text/html");
res.end(page);
} catch (error) {
Expand Down
32 changes: 32 additions & 0 deletions packages/docs/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import {
demosIndexRoute,
basicRoutingDemoRoute,
basicRoutingDemoPageRoute,
blogRoutingDemoRoute,
blogYearRoute,
blogMonthRoute,
blogDayRoute,
blogPostRoute,
asyncLookupDemoRoute,
asyncLookupSlugRoute,
} from "./router/routes";
import Home from "./pages/Home";
import { DocsIndex, DocPage } from "./pages/Docs";
Expand All @@ -20,6 +27,8 @@ import Changelog from "./pages/Changelog";
import History from "./pages/History";
import DemosIndex from "./pages/DemosIndex";
import BasicRoutingDemo from "./pages/BasicRoutingDemo";
import BlogRoutingDemo from "./pages/BlogRoutingDemo";
import AsyncLookupDemo from "./pages/AsyncLookupDemo";
import NotFound from "./pages/NotFound";

export const App = () => (
Expand Down Expand Up @@ -57,6 +66,29 @@ export const App = () => (
<Route on={basicRoutingDemoPageRoute} exact>
<BasicRoutingDemo />
</Route>
<Route on={blogRoutingDemoRoute} exact>
<BlogRoutingDemo />
</Route>
<Route on={blogYearRoute} exact>
<BlogRoutingDemo />
</Route>
<Route on={blogMonthRoute} exact>
<BlogRoutingDemo />
</Route>
<Route on={blogDayRoute} exact>
<BlogRoutingDemo />
</Route>
<Route on={blogPostRoute} exact>
<BlogRoutingDemo />
</Route>
<Route on={asyncLookupDemoRoute} exact>
<AsyncLookupDemo />
</Route>
{/* The slug route, not the async one: an unknown slug still renders the demo, showing
its own not-found view, while `notFoundAtom` makes the response a real 404. */}
<Route on={asyncLookupSlugRoute} exact>
<AsyncLookupDemo />
</Route>
</Switch>
</Layout>
</>
Expand Down
64 changes: 64 additions & 0 deletions packages/docs/src/content/guides/CustomRouteAtoms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Custom Route Atoms

`staticRouteAtom` and `paramRouteAtom` cover most real routes, but both are themselves built
from a smaller primitive: `routeAtom`. Reach for it directly when a segment's syntax doesn't fit
either - a two-letter locale code, a regex-constrained slug, anything `matchPath`/`makePath`
can express that a bare string or bare variable can't.

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

export const shopRoute = staticRouteAtom("shop");

export const localeRoute = routeAtom<{ locale: string }>(
(path) => (/^[a-z]{2}$/.test(path) ? { locale: path } : undefined),
({ locale }) => locale,
{ parent: shopRoute },
);
```

`matchPath` receives the next unconsumed path segment and either returns the values it binds, or
`undefined` for "this atom doesn't match here". `makePath` is its inverse, used by `reverse()` and
writes. Both also receive jotai's `get`, so a match can depend on other atoms - a feature flag, a
locale list fetched at startup.

## Reshaping values with `transformRouteAtom`

`transformRouteAtom` doesn't match path segments itself - it wraps another route atom and
reshapes its `values`, both for reading and for `reverse()`/write:

```ts
import { paramRouteAtom, transformRouteAtom } from "jarl-atoms";

const idParam = paramRouteAtom("id");
export const numericIdRoute = transformRouteAtom<{ id: string }, { id: number }>(
idParam,
(values) => (isNaN(Number(values.id)) ? undefined : { id: Number(values.id) }),
(values) => ({ id: String(values.id) }),
);
```

`getter` only runs once the wrapped atom matches, and returning `undefined` from it rejects the
match entirely - the mechanism a constrained segment uses to say "matched syntactically, but not
semantically". `setter` is the inverse: it must produce values the wrapped atom itself accepts,
since `reverse`/write pass straight through to it. Get this wrong (an id that doesn't round-trip
through both directions) and `reverse()` will build a URL a real navigation won't match.

## `numericRouteAtom`

The numeric case above is common enough to ship pre-built: `numericRouteAtom` is exactly a
`paramRouteAtom` plus a `transformRouteAtom` that only matches all-digit segments, converts them
to a `number`, and can reject values outside an inclusive `min`/`max` range:

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

export const blogRoute = staticRouteAtom("blog");
export const yearRoute = numericRouteAtom("year", { parent: blogRoute });
export const monthRoute = numericRouteAtom("month", { parent: yearRoute, min: 1, max: 12 });
```

`/blog/2024/13` doesn't match `monthRoute` at all (13 is outside `max`), rather than matching
with an invalid month - so a bad month never reaches your component as data to validate. Reading
a matched route hands back `{ year: 2024 }` as a real `number`, not a string you'd otherwise have
to parse yourself.
69 changes: 69 additions & 0 deletions packages/docs/src/content/guides/DataLoading.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,75 @@ resolver's `Promise` is still pending, transparently handled by `Suspense`) by t
jotai/utils' `loadable()` wraps any async atom into a synchronous `{ state: "hasData" | "loading"
| "hasError", ... }` value instead.

## Routes that only exist if the data does

Sometimes the lookup _is_ the route. Whether `/blog/some-slug` is a page at all is a question only
the database can answer, and answering it twice - once to decide, once to render - is a wasted
call. `asyncRouteAtom` wraps a route atom so that its match depends on a lookup, and binds
whatever the lookup found to the route's own values:

```ts
import { staticRouteAtom, paramRouteAtom, asyncRouteAtom } from "jarl-atoms";
import { db } from "./db";

export const blogRoute = staticRouteAtom("blog");
export const slugRoute = paramRouteAtom("slug", { parent: blogRoute });

export const postRoute = asyncRouteAtom(slugRoute, "post", ({ slug }) => db.findPost(slug));

/** Every async route in the app, in one list to preload, hydrate and follow. */
export const asyncRoutes = [postRoute];
```

`undefined` back from the lookup means the route doesn't match, so a `Switch` fallback (or
`notAtom`) renders your not-found case. A hit matches, with the loaded object typed onto `values`:

```tsx
<Switch fallback={<NotFound />}>
<Route on={postRoute} exact>
{({ post }) => <PostView post={post} />}
</Route>
</Switch>
```

`post` here is a `Post`, not an `unknown` you have to narrow, and `PostView` fetches nothing of
its own.

### Server rendering

Route matching is synchronous everywhere in JARL, so the lookup has to have settled before a
render can read it. On the server that is a single `await`, and it is what lets the response
carry a real 404 status rather than a 200 whose body happens to say "not found":

```tsx
const store = createStore();
store.set(locationAtom, { pathname, searchParams });

const routeData = await preloadRoutes(store, asyncRoutes);
const html = renderToString(
<Provider store={store}>
<App />
</Provider>,
);
const status = store.get(notFoundAtom) ? 404 : 200;
```

`notFoundAtom` there is `notAtom(...everyRouteYouRender)` - listing `postRoute` rather than
`slugRoute`, so an unknown slug counts as a miss.

`preloadRoutes` returns one snapshot per route, in the order given. Serialise them into the page
and the client picks up where the server left off, without repeating the lookup:

```tsx
hydrateAsyncRoutes(store, asyncRoutes, window.__ROUTE_DATA__ ?? []);
followAsyncRoutes(store, asyncRoutes);
hydrateRoot(root, <Provider store={store}>{<App />}</Provider>);
```

`followAsyncRoutes` keeps the routes settled from then on, re-running each lookup as the location
changes. While one is in flight, `postRoute.pending` is `true` - render a spinner on it, or the
not-found case will flash before the answer arrives.

## Redirecting

Sometimes a route shouldn't render at all, and should instead send the visitor somewhere else -
Expand Down
65 changes: 65 additions & 0 deletions packages/docs/src/content/guides/HooksAndLinks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Hooks & Links

The [Getting Started](/docs/getting-started) guide covers the basic shape of `Link`. This page
covers the hooks it's built from - useful directly whenever the UI you need isn't literally a
`<Route>`/`<a>` pair - and `Link`'s fuller surface.

## The hooks

`jarl-react`'s hooks are all thin wrappers over `useAtom`/`useAtomValue`/`useSetAtom` for a route
atom, named for what they're for rather than what they do under the hood:

- **`useRoute(routeAtom)`** - the route's current match state (`match`, `exact`, `values`,
`reverse`). Equivalent to `useAtomValue(routeAtom)`.
- **`useIsActive(routeAtom, { exact? })`** - just the boolean, for nav-highlighting logic that
doesn't need the rest of the match state.
- **`useHref(routeAtom, values)`** - reverses `values` into a URL string, without a click handler.
Useful for a canonical `<link>` tag, a share URL, or a prefetch `href` that isn't a nav link.
- **`useNavigate(routeAtom)`** - a stable `navigate(values)` function for imperative navigation
outside of rendering a link, e.g. after a form submits:

```tsx
const navigate = useNavigate(productRoute);

const onSubmit = async (form: FormData) => {
const { id } = await createProduct(form);
navigate({ productId: id });
};
```

`navigate` always pushes. For a `replace` navigation, use `useSetAtom(routeAtom)` directly and
pass `{ replace: true }` as its second argument.

- **`useLink(routeAtom, values, { exact? })`** - what `Link` itself is built on: `href`, `active`
and an `onClick` handler in one call, for link-like UI that isn't an `<a>` - a styled `<div>`
card that navigates on click, for instance.

`useHref` and `useLink` both still subscribe to `routeAtom`, since `reverse()` can depend on
ancestor route state - so a component using either re-renders on navigation even when the `href`
it computes doesn't change.

## `Link`'s fuller surface

Beyond `route`/`to`, `Link` takes:

- **`exact`** - only report itself `active` (see below) for an exact match, not an ancestor one.
- **`activeClassName`** - appended to `className` while active.
- **`element`** - render as something other than `<a>` (ignored when `children` is a function).
- Any standard anchor attribute, forwarded straight through.

Whether a link is "active" is also exposed as a `data-active` attribute (present only when active,
so `a[data-active] { ... }` styling doesn't need `activeClassName` plumbed through at all), and to
the function-as-child form:

```tsx
<Link route={productRoute} to={{ productId: "123" }}>
{({ href, active, onClick }) => (
<CustomLink href={href} onClick={onClick} highlighted={active}>
Our Best Product Ever!
</CustomLink>
)}
</Link>
```

`onClick` here already calls `event.preventDefault()` before navigating - pass it straight to the
underlying element as-is.
Loading