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
28 changes: 28 additions & 0 deletions packages/docs/src/content/guides/PathVariables.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,34 @@ Linking to a dynamic route works the same way as a static one - just pass the pa
</Link>
```

## Segments from a fixed set

A dynamic segment usually isn't dynamic in the sense of "anything at all" - it's one of a handful of
known values. `enumRouteAtom` matches exactly those and nothing else, and types the value it binds
as the union of them rather than as `string`:

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

const productTabs = ["overview", "reviews", "specs"] as const;

export const productsRoute = staticRouteAtom("products");
export const productRoute = paramRouteAtom("productId", { parent: productsRoute });
export const productTabRoute = enumRouteAtom("tab", productTabs, { parent: productRoute });
```

`/products/123/reviews` matches with `{ productId: "123", tab: "reviews" }`, where `tab` is
`"overview" | "reviews" | "specs"` - so a `switch` over it is exhaustive with no default case to
write, and `<Link route={productTabRoute} to={{ productId: "123", tab: "reveiws" }}>` is a typo the
compiler catches rather than a dead link. `/products/123/pricing` doesn't match at all, so it falls
through to whatever the app renders for an unknown URL instead of reaching the tab page with a value
it has nothing to show for.

`numericRouteAtom` does the same job for a segment that has to be a whole number, optionally within
a `min`/`max` range, binding it as a `number` rather than a string. For a constraint neither one
covers - a checksum, a slug in a database, a rule spanning several segments - see
`transformRouteAtom` and `validateRouteAtom` in the [API reference](/api/jarl-atoms).

## Query parameters

Dynamic _path_ segments aren't the only way to carry a value in a URL - `queryParamRouteAtom` (from
Expand Down
41 changes: 12 additions & 29 deletions packages/docs/src/pages/Api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,34 +48,17 @@ export const ApiIndex = () => (
</>
);

export const ApiPage = ({ apiName }: { apiName: string }) => {
const source = content[apiName as ApiName];
if (!source) {
return (
<>
<h1>Not found</h1>
<p>
No API reference named &ldquo;{apiName}&rdquo;. Back to{" "}
<Link route={apiPageRoute} to={{ apiName: apiPages[0].apiName }}>
API
</Link>
.
</p>
</>
);
}
return (
<>
<PackageTabs>
{apiPages.map(({ apiName: name, title }) => (
<Link key={name} route={apiPageRoute} to={{ apiName: name }} exact>
{title}
</Link>
))}
</PackageTabs>
<Markdown source={source} />
</>
);
};
export const ApiPage = ({ apiName }: { apiName: ApiName }) => (
<>
<PackageTabs>
{apiPages.map(({ apiName: name, title }) => (
<Link key={name} route={apiPageRoute} to={{ apiName: name }} exact>
{title}
</Link>
))}
</PackageTabs>
<Markdown source={content[apiName]} />
</>
);

export default ApiIndex;
19 changes: 1 addition & 18 deletions packages/docs/src/pages/Docs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,23 +31,6 @@ export const DocsIndex = () => (
</>
);

export const DocPage = ({ docName }: { docName: string }) => {
const source = guides[docName as DocName];
if (!source) {
return (
<>
<h1>Not found</h1>
<p>
No guide named &ldquo;{docName}&rdquo;. Back to{" "}
<Link route={docPageRoute} to={{ docName: docPages[0].docName }}>
Docs
</Link>
.
</p>
</>
);
}
return <Markdown source={source} />;
};
export const DocPage = ({ docName }: { docName: DocName }) => <Markdown source={guides[docName]} />;

export default DocsIndex;
12 changes: 7 additions & 5 deletions packages/docs/src/router/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@
* `jarl-atoms`' server-seedable `locationAtom`.
*/
import { atom } from "jotai";
import { asyncRouteAtom, notAtom, rootRoute, staticRouteAtom, paramRouteAtom } from "jarl-atoms";
import { asyncRouteAtom, enumRouteAtom, 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 = rootRoute;

export const docsSectionRoute = staticRouteAtom("docs");
export const docPageRoute = paramRouteAtom("docName", { parent: docsSectionRoute });
const docNames = ["getting-started", "data-loading", "path-variables"] as const;
export const docPageRoute = enumRouteAtom("docName", docNames, { parent: docsSectionRoute });

export const apiSectionRoute = staticRouteAtom("api");
export const apiPageRoute = paramRouteAtom("apiName", { parent: apiSectionRoute });
const apiNames = ["jarl-atoms", "jarl-react"] as const;
export const apiPageRoute = enumRouteAtom("apiName", apiNames, { parent: apiSectionRoute });

// Changelog: static mount point. The per-version tree lives inside the Changelog component.
export const changelogRoute = staticRouteAtom("changelog");
Expand Down Expand Up @@ -78,15 +80,15 @@ export const notFoundAtom = atom(
(get) => get(exactRouteMissedAtom) && !get(changelogRoute).match && !get(blogRoutingDemoRoute).match,
);

export type DocName = "getting-started" | "data-loading" | "path-variables";
export type DocName = (typeof docNames)[number];

export const docPages: { docName: DocName; title: string }[] = [
{ docName: "getting-started", title: "Getting Started" },
{ docName: "data-loading", title: "Data Loading" },
{ docName: "path-variables", title: "Path Variables" },
];

export type ApiName = "jarl-atoms" | "jarl-react";
export type ApiName = (typeof apiNames)[number];

export const apiPages: { apiName: ApiName; title: string }[] = [
{ apiName: "jarl-atoms", title: "jarl-atoms" },
Expand Down
25 changes: 25 additions & 0 deletions packages/jarl-atoms/DESIGN-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,28 @@ Dropping `Route` from every name instead was rejected: `routeAtom` would collide
No deprecated aliases were kept for the old names. Two names per export would make the surface
less consistent rather than more, which is the opposite of the point, and the alias would then
need its own removal later.

## Naming a fixed-value segment: `enumRouteAtom`

`enum` is the ecosystem's word for a value drawn from a fixed set of strings — JSON Schema's and
OpenAPI's `enum`, and zod's `z.enum([...])`, which takes the same non-empty literal tuple and
yields the same string-literal union. It is not TypeScript's `enum` keyword, which this package
uses nowhere; what the route binds is a union of literals. It also keeps the segment constructors
named for the kind of segment they match — static, param, numeric, enum — which is what makes the
family scannable.

Two other names were rejected. `setRouteAtom` satisfies the return-type rule above but collides
with jotai's write vocabulary, where `set` means "write to an atom": `useSetAtom(setRouteAtom(...))`
is a sentence fighting itself. `oneOfRouteAtom` reads well in isolation, but "one of" names a choice
between whole alternatives — JSON Schema's `oneOf` is exactly a union of schemas — which is what a
primitive combining several _routes_ wants, not one constraining a single segment to a value set.
The two do different jobs and neither subsumes the other, so two near-synonymous names would only
invite reaching for the wrong one.

## A fixed-value segment needs no precedence rule

A path segment is one string, so at most one member of the set can match it: matching is a
membership test rather than an ordered scan, and the order the values are listed in decides
nothing. What can still overlap is a route and its _siblings_ — a `staticRouteAtom("about")` and an
`enumRouteAtom` that accepts `"about"` under the same parent both match `/about` — but that is the
ordinary ambiguity of declaring two routes for one URL, which no single route atom can see.
89 changes: 89 additions & 0 deletions packages/jarl-atoms/src/__tests__/enumRouteAtom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { createStore } from "jotai/vanilla";
import { describe, expect, expectTypeOf, it } from "vitest";
import { enumRouteAtom } from "../enumRouteAtom";
import { locationAtom } from "../locationAtom";
import { paramRouteAtom } from "../paramRouteAtom";
import { requireMatch } from "../requireMatch";
import { staticRouteAtom } from "../staticRouteAtom";

const seed = (store: ReturnType<typeof createStore>, pathname: string) => {
store.set(locationAtom, { pathname, searchParams: new URLSearchParams() });
};

const docs = staticRouteAtom("docs");
const guideRoute = enumRouteAtom("guide", ["getting-started", "data-loading"], { parent: docs });

describe("enumRouteAtom", () => {
it("matches a segment in the set and binds it", () => {
const store = createStore();
seed(store, "/docs/data-loading");

const route = store.get(guideRoute);

expect(route.match).toBe(true);
expect(route.exact).toBe(true);
expect(route.values).toEqual({ guide: "data-loading" });
});

it("does not match a segment outside the set", () => {
const store = createStore();
seed(store, "/docs/nonsense");

const route = store.get(guideRoute);

expect(route.match).toBe(false);
expect(route.values).toBeUndefined();
});

it("does not match its parent's own path, where there is no segment to bind", () => {
const store = createStore();
seed(store, "/docs");

expect(store.get(guideRoute).match).toBe(false);
});

it("binds the value whatever order the set is written in", () => {
const store = createStore();
const reversed = enumRouteAtom("guide", ["data-loading", "getting-started"], { parent: docs });
seed(store, "/docs/data-loading");

expect(store.get(reversed).values).toEqual({ guide: "data-loading" });
});

it("types the bound value as the union of the set, not as a string", () => {
const store = createStore();
seed(store, "/docs/getting-started");

const route = requireMatch(store.get(guideRoute), "guideRoute");

expectTypeOf(route.values.guide).toEqualTypeOf<"getting-started" | "data-loading">();
// @ts-expect-error - only the segments the route was given can be reversed, written or linked
route.reverse({ guide: "no-such-guide" });
});

it("builds hrefs through reverse()", () => {
const store = createStore();

expect(store.get(guideRoute).reverse({ guide: "getting-started" })).toBe("/docs/getting-started");
});

it("navigates when written to", () => {
const store = createStore();

store.set(guideRoute, { guide: "data-loading" });

expect(store.get(locationAtom).pathname).toBe("/docs/data-loading");
});

it("parents another route, consuming only its own segment", () => {
const store = createStore();
const section = paramRouteAtom("section", { parent: guideRoute });
seed(store, "/docs/data-loading/suspense");

const route = store.get(section);

expect(route.match).toBe(true);
expect(route.exact).toBe(true);
expect(route.values).toEqual({ guide: "data-loading", section: "suspense" });
});
});
30 changes: 30 additions & 0 deletions packages/jarl-atoms/src/enumRouteAtom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { routeAtom } from "./routeAtom";
import { DefaultParams, RouteAtom, RouteOptions } from "./types";

/** The segment values an `enumRouteAtom` accepts: a non-empty tuple of string literals. */
export type EnumValues = readonly [string, ...string[]];

/**
* Binds one dynamic path segment to a named value drawn from a fixed set: `enumRouteAtom("page",
* ["home", "about", "contact"], { parent: site })` matches `/:page` on those three segments and no
* others, and types `values.page` as the union of them rather than as `string` - so a `switch` over
* it is exhaustive, and a value that isn't one of them is a compile error wherever the route is
* written to, reversed or linked. Any other segment leaves the route unmatched, which is what makes
* an unknown one a genuine miss rather than a page rendering its own "not found".
*
* Pass the values as a literal array, or as an `as const` tuple to share the list with the code
* that consumes it; a plain `string[]` has no literals left to bind and won't type.
*/
export const enumRouteAtom = <Name extends string, const Values extends EnumValues, Parent extends DefaultParams>(
name: Name,
allowed: Values,
options?: RouteOptions<Parent>,
): RouteAtom<{ [key in Name]: Values[number] } & Parent> => {
type Bound = { [key in Name]: Values[number] };
const accepted = new Set<string>(allowed);
return routeAtom<Bound, Parent>(
(path) => (accepted.has(path) ? ({ [name]: path } as Bound) : undefined),
(values) => values[name],
options,
);
};
1 change: 1 addition & 0 deletions packages/jarl-atoms/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from "./rootRouteAtom";
export * from "./staticRouteAtom";
export * from "./paramRouteAtom";
export * from "./numericRouteAtom";
export * from "./enumRouteAtom";
export * from "./transformRouteAtom";
export * from "./validateRouteAtom";
export * from "./notAtom";
Expand Down
Loading