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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ export default SearchForm;

That's all the basics! Hopefully this gave a flavour of the power and simplicity of this
routing system. See the [docs site](https://jarl.randomdev.co.uk) for query strings, redirects, and
data loading (resolving promises as part of a route match, `jarl-atoms`' `resolvedAtom`) in more
depth.
data loading (resolving promises as part of a route match, `jarl-atoms`' `asyncRouteAtom`) in
more depth.

## Documentation

Expand Down
12 changes: 6 additions & 6 deletions e2e/fixture-app/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
* can exercise realistic nested/param routes.
*
* NOTE: this file only *composes* the primitives jarl-atoms exports
* (rootAtom, staticRouteAtom, paramRouteAtom, redirectAtom, resolvedAtom). It
* (rootAtom, staticRouteAtom, paramRouteAtom, redirectAtom, asyncRouteAtom). It
* does not add routing features to the library.
*/
import { atom } from "jotai/vanilla";
import { loadable } from "jotai/utils";
import { rootAtom, staticRouteAtom, paramRouteAtom, redirectAtom, resolvedAtom, redirect } from "jarl-atoms";
import { rootAtom, staticRouteAtom, paramRouteAtom, redirectAtom, asyncRouteAtom, redirect } from "jarl-atoms";

// --- Shell (demo/cypress/integration/00DemosShell.js) ---
export { rootAtom };
Expand Down Expand Up @@ -77,20 +77,20 @@ const CONTENT: Record<string, string> = {
"about-us": "A jarl was a Norse or Danish chief, a rank of nobility above a freeman and below a king.",
};

export const redirectsAdminDataAtom = resolvedAtom(redirectsAdminAtom, async (_values, get) => {
export const redirectsAdminDataAtom = asyncRouteAtom(redirectsAdminAtom, "admin", async (_values, get) => {
if (!get(isAdminAuthenticatedAtom)) {
return redirect("/redirects");
}
return { body: "This is the super secret admin page." };
});
}).data;

export const redirectsContentDataAtom = resolvedAtom(redirectsContentSlugAtom, async ({ slug }) => {
export const redirectsContentDataAtom = asyncRouteAtom(redirectsContentSlugAtom, "content", async ({ slug }) => {
const body = CONTENT[slug];
if (!body) {
return redirect("/redirects");
}
return { body };
});
}).data;

// loadable() lets the pages read these without a Suspense boundary.
export const redirectsAdminDataLoadableAtom = loadable(redirectsAdminDataAtom);
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/api-jarl-react.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ re-export `jarl-atoms`: get your route atoms from `jarl-atoms` and these compone

Every hook here takes a route atom as its first argument. `jarl-react` also re-exports jotai's
own `useAtom`, `useAtomValue` and `useSetAtom`, so composing directly with a route atom (or with
`jarl-atoms` primitives like `resolvedAtom`) never needs a separate direct dependency on
`jarl-atoms` primitives like `asyncRouteAtom`) never needs a separate direct dependency on
`jotai`.

The reference below is generated from the doc comments on each export. Components list only
Expand Down
66 changes: 36 additions & 30 deletions packages/docs/src/content/guides/DataLoading.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,35 @@ page layout churn as everything resolves, JARL lets you attach a loader to a rou
resolve everything it needs before the route ever renders - jotai's own async-atom machinery,
behind a single `Suspense` boundary in React, handles the wait.

`resolvedAtom` (from `jarl-atoms`) takes a route atom and a loader function, and resolves once
that route matches:
`asyncRouteAtom` (from `jarl-atoms`) takes a route atom, a name for what the loader produces,
and the loader itself. Its `.data` is a plain jotai async atom that resolves once that route
matches:

routes.ts:

```ts
import { staticRouteAtom, paramRouteAtom, resolvedAtom } from "jarl-atoms";
import { staticRouteAtom, paramRouteAtom, asyncRouteAtom } from "jarl-atoms";

export const productsRoute = staticRouteAtom("products");
export const productRoute = paramRouteAtom("productId", { parent: productsRoute });

export const productDataRoute = resolvedAtom(productRoute, async ({ productId }) => {
export const productData = asyncRouteAtom(productRoute, "product", async ({ productId }) => {
const result = await fetch(`/api/products/${productId}`);
return result.json();
});
}).data;
```

`resolvedAtom` is a plain jotai async atom (`Atom<Promise<Data | Redirect | undefined>>`), so
any of jotai's usual ways of consuming one work - the most idiomatic in React is `useAtomValue`
under a `Suspense` boundary:
`.data` is `Atom<Promise<Data | Redirect | undefined>>`, so any of jotai's usual ways of
consuming an async atom work - the most idiomatic in React is `useAtomValue` under a `Suspense`
boundary:

```tsx
import { Suspense } from "react";
import { useAtomValue } from "jarl-react";
import { productDataRoute } from "./routes";
import { productData } from "./routes";

const ProductPage = () => {
const product = useAtomValue(productDataRoute);
const product = useAtomValue(productData);
return <ProductView product={product} />;
};

Expand All @@ -45,17 +46,20 @@ export default () => (
```

By loading data as part of the route atom itself, the data is guaranteed to exist (or the
resolver's `Promise` is still pending, transparently handled by `Suspense`) by the time
loader's `Promise` is still pending, transparently handled by `Suspense`) by the time
`ProductPage` renders - no separate loading flag to plumb through. If you'd rather not suspend,
jotai/utils' `loadable()` wraps any async atom into a synchronous `{ state: "hasData" | "loading"
| "hasError", ... }` value instead.

Read this way, `.data` has no bearing on whether the route matches and needs nothing else wired
up. The name you passed (`"product"` above) is only used by the next section.

## 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:
call. Drop the `.data` and keep the atom itself: the load then decides the match, and whatever it
found is bound to the route's own values under the name you gave it.

```ts
import { staticRouteAtom, paramRouteAtom, asyncRouteAtom } from "jarl-atoms";
Expand All @@ -70,7 +74,7 @@ export const postRoute = asyncRouteAtom(slugRoute, "post", ({ slug }) => db.find
export const asyncRoutes = [postRoute];
```

`undefined` back from the lookup means the route doesn't match, so a `Switch` fallback (or
`undefined` back from the loader 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
Expand All @@ -86,15 +90,15 @@ its own.

### Server rendering

Route matching is synchronous everywhere in JARL, so the lookup has to have settled before a
Route matching is synchronous everywhere in JARL, so the load 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 routeData = await preloadAsyncRoutes(store, asyncRoutes);
const html = renderToString(
<Provider store={store}>
<App />
Expand All @@ -106,57 +110,59 @@ 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:
`preloadAsyncRoutes` 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 load:

```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
`followAsyncRoutes` keeps the routes settled from then on, re-running each load 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.

This lifecycle is what gating costs. A route read only through `.data` never needs any of it.

## Redirecting

Sometimes a route shouldn't render at all, and should instead send the visitor somewhere else -
an auth gate, a canonical-URL redirect, or (as below) a resolver that didn't find what it was
an auth gate, a canonical-URL redirect, or (as below) a loader that didn't find what it was
looking for. `redirect(to)` marks that outcome:

```ts
import { staticRouteAtom, paramRouteAtom, resolvedAtom, redirect } from "jarl-atoms";
import { staticRouteAtom, paramRouteAtom, asyncRouteAtom, redirect } from "jarl-atoms";

export const productBySlugRoute = paramRouteAtom("productSlug", { parent: productsRoute });

export const productBySlugDataRoute = resolvedAtom(productBySlugRoute, async ({ productSlug }) => {
export const productBySlugData = asyncRouteAtom(productBySlugRoute, "product", async ({ productSlug }) => {
const response = await fetch(`/api/productsBySlug?slug=${productSlug}`);
if (!response.ok) {
return redirect("/products/not-found");
}
return response.json();
});
}).data;
```

A `Redirect` returned from a resolver doesn't navigate anywhere by itself - reading the atom
A `Redirect` returned from a loader doesn't navigate anywhere by itself - reading the atom
just tells you a redirect _would_ happen, which keeps it composable and testable like any other
value. To actually perform the navigation, wire `followResolvedRedirects` up once near the root
value. To actually perform the navigation, wire `followAsyncRedirects` up once near the root
of your app (typically alongside where you create your jotai store):

```ts
import { followResolvedRedirects } from "jarl-atoms";
import { productBySlugDataRoute } from "./routes";
import { followAsyncRedirects } from "jarl-atoms";
import { productBySlugData } from "./routes";

const unsubscribe = followResolvedRedirects(store, [productBySlugDataRoute]);
const unsubscribe = followAsyncRedirects(store, [productBySlugData]);
```

It subscribes to each resolved atom given and, the moment one produces a `Redirect`, replaces
It subscribes to each `.data` atom given and, the moment one produces a `Redirect`, replaces
the current location with its target (`history.replaceState`, so the abandoned URL doesn't
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 the `resolvedAtom` wrapper:
`redirectAtom`/`followRedirects` do the same job without a loader:

```ts
import { redirectAtom, followRedirects } from "jarl-atoms";
Expand Down
4 changes: 2 additions & 2 deletions packages/docs/src/entry-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { CacheProvider } from "@emotion/react";
import createCache from "@emotion/cache";
import createEmotionServer from "@emotion/server/create-instance";
import App from "./App";
import { locationAtom, preloadRoutes } from "jarl-atoms";
import { locationAtom, preloadAsyncRoutes } from "jarl-atoms";
import { asyncRoutes, notFoundAtom } from "./router/routes";

// Re-exported so the plain-Node prerender script (scripts/build.mjs) can drive the
Expand Down Expand Up @@ -39,7 +39,7 @@ export const render = async (path: string): Promise<RenderResult> => {
pathname: rawPathname || "/",
searchParams: new URLSearchParams(rawSearch),
});
const routeData = await preloadRoutes(store, asyncRoutes);
const routeData = await preloadAsyncRoutes(store, asyncRoutes);
// The default key is what the browser-side cache adopts server-rendered styles under.
const cache = createCache({ key: "css" });
const { extractCriticalToChunks, constructStyleTagsFromChunks } = createEmotionServer(cache);
Expand Down
8 changes: 4 additions & 4 deletions packages/jarl-atoms/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ const href = store.get(docAtom).reverse({ docName: "getting-started" });
```

Other exports: `queryAtom`/`queryParamAtom` (query-string state, composable
the same way as path atoms), `redirectAtom`, `resolvedAtom`, and `asyncRouteAtom`
(a route that exists only if an async lookup finds it, with what it found bound
to the route's values). See the full
docs and demos for the complete model:
the same way as path atoms), `redirectAtom`, and `asyncRouteAtom` (async data for
a route, read as `.data`; read as a route instead and the route exists only if
the load found something, with what it found bound to the route's values). See
the full docs and demos for the complete model:

[JARL demos and documentation](https://jarl.randomdev.co.uk)

Expand Down
Loading
Loading