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
4 changes: 2 additions & 2 deletions packages/docs/src/content/guides/DataLoading.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ const html = renderToString(
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.
`notFoundAtom` there is `notAtom(unionRouteAtom(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:
Expand Down
32 changes: 17 additions & 15 deletions packages/docs/src/router/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* `jarl-atoms`' server-seedable `locationAtom`.
*/
import { atom } from "jotai";
import { asyncRouteAtom, notAtom, rootAtom, staticRouteAtom, paramRouteAtom } from "jarl-atoms";
import { asyncRouteAtom, notAtom, rootAtom, staticRouteAtom, paramRouteAtom, unionRouteAtom } from "jarl-atoms";
import { blogStaticPaths } from "../demos/blogPosts";
import { articleSlugs, findArticle } from "../demos/asyncArticles";
import { changelogStaticPaths } from "../pages/changelogEntries";
Expand Down Expand Up @@ -51,20 +51,22 @@ export const asyncArticleRoute = asyncRouteAtom(asyncLookupSlugRoute, "article",
export const asyncRoutes = [asyncArticleRoute];

const exactRouteMissedAtom = notAtom(
homeRoute,
docsSectionRoute,
docPageRoute,
apiSectionRoute,
apiPageRoute,
changelogRoute,
historyRoute,
demosIndexRoute,
basicRoutingDemoRoute,
basicRoutingDemoPageRoute,
blogRoutingDemoRoute,
dataGridDemoRoute,
asyncLookupDemoRoute,
asyncArticleRoute,
unionRouteAtom([
homeRoute,
docsSectionRoute,
docPageRoute,
apiSectionRoute,
apiPageRoute,
changelogRoute,
historyRoute,
demosIndexRoute,
basicRoutingDemoRoute,
basicRoutingDemoPageRoute,
blogRoutingDemoRoute,
dataGridDemoRoute,
asyncLookupDemoRoute,
asyncArticleRoute,
]),
);

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/jarl-atoms/src/__tests__/asyncRouteAtom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { notAtom } from "../notAtom";
import { paramRouteAtom } from "../paramRouteAtom";
import { redirect } from "../redirectAtom";
import { staticRouteAtom } from "../staticRouteAtom";
import { unionRouteAtom } from "../unionRouteAtom";

type Post = { slug: string; title: string };

Expand Down Expand Up @@ -52,7 +53,7 @@ describe("asyncRouteAtom", () => {

it("doesn't match when the lookup finds nothing, so notAtom reports a not-found", async () => {
const postRoute = asyncRouteAtom(slugRoute, "post", findPost);
const notFound = notAtom(blogRoute, postRoute);
const notFound = notAtom(unionRouteAtom([blogRoute, postRoute]));
seed(store, "/blog/no-such-post");
await preloadRoutes(store, [postRoute]);

Expand Down
16 changes: 13 additions & 3 deletions packages/jarl-atoms/src/__tests__/notAtom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { locationAtom } from "../locationAtom";
import { notAtom } from "../notAtom";
import { staticRouteAtom } from "../staticRouteAtom";
import { unionRouteAtom } from "../unionRouteAtom";

const seed = (store: ReturnType<typeof createStore>, pathname: string) => {
store.set(locationAtom, { pathname, searchParams: new URLSearchParams() });
Expand All @@ -15,7 +16,7 @@ describe("notAtom", () => {
const users = staticRouteAtom("users");
seed(store, "/nowhere");

expect(store.get(notAtom(about, users))).toBe(true);
expect(store.get(notAtom(unionRouteAtom([about, users])))).toBe(true);
});

it("does not match when the only given route matches", () => {
Expand All @@ -33,7 +34,7 @@ describe("notAtom", () => {
const contact = staticRouteAtom("contact");
seed(store, "/users");

expect(store.get(notAtom(about, users, contact))).toBe(false);
expect(store.get(notAtom(unionRouteAtom([about, users, contact])))).toBe(false);
});

it("checks exactness rather than a bare match", () => {
Expand All @@ -47,6 +48,15 @@ describe("notAtom", () => {
expect(store.get(about).match).toBe(true);
expect(store.get(about).exact).toBe(false);
expect(store.get(notAtom(about))).toBe(true);
expect(store.get(notAtom(about, team))).toBe(false);
expect(store.get(notAtom(unionRouteAtom([about, team])))).toBe(false);
});

it("counts an ancestor match too when exactness is switched off", () => {
const store = createStore();
const about = staticRouteAtom("about");
staticRouteAtom("team", { parent: about });
seed(store, "/about/team");

expect(store.get(notAtom(about, { exact: false }))).toBe(false);
});
});
102 changes: 102 additions & 0 deletions packages/jarl-atoms/src/__tests__/unionRouteAtom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { createStore } from "jotai/vanilla";
import { describe, expect, expectTypeOf, it } from "vitest";
import { locationAtom } from "../locationAtom";
import { numericRouteAtom } from "../numericRouteAtom";
import { paramRouteAtom } from "../paramRouteAtom";
import { staticRouteAtom } from "../staticRouteAtom";
import { unionRouteAtom } from "../unionRouteAtom";

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

const blog = staticRouteAtom("blog");
const year = numericRouteAtom("year", { parent: blog });
const month = numericRouteAtom("month", { parent: year, min: 1, max: 12 });
const day = numericRouteAtom("day", { parent: month, min: 1, max: 31 });
const anyDate = unionRouteAtom([year, month, day]);

describe("unionRouteAtom", () => {
it("matches wherever any of its members does", () => {
const store = createStore();

for (const pathname of ["/blog/2024", "/blog/2024/06", "/blog/2024/06/12"]) {
seed(store, pathname);
expect(store.get(anyDate).exact).toBe(true);
}
});

it("does not match where no member does", () => {
const store = createStore();
seed(store, "/elsewhere");

const route = store.get(anyDate);

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

it("binds the values of the member that matched", () => {
const store = createStore();
seed(store, "/blog/2024/06");

expect(store.get(anyDate).values).toEqual({ year: 2024, month: 6 });
});

it("takes the exactly matching member whatever order the members are in", () => {
const store = createStore();
seed(store, "/blog/2024/06/12");

// `year` and `month` match this location too, as non-exact ancestors.
for (const route of [anyDate, unionRouteAtom([day, month, year])]) {
expect(store.get(route).values).toEqual({ year: 2024, month: 6, day: 12 });
}
});

it("falls back to the first member that matches when none matches exactly", () => {
const store = createStore();
seed(store, "/blog/2024/06/12/deeper");

expect(store.get(anyDate).values).toEqual({ year: 2024 });
expect(store.get(unionRouteAtom([day, month, year])).values).toEqual({ year: 2024, month: 6, day: 12 });
});

it("binds a union of its members' param types", () => {
const store = createStore();
seed(store, "/blog/2024");

const { values } = store.get(anyDate);

expectTypeOf(values).toEqualTypeOf<
| { year: number }
| ({ month: number } & { year: number })
| ({ day: number } & { month: number } & { year: number })
| undefined
>();
});

it("composes as a parent, nesting under whichever member matched", () => {
const store = createStore();
const slug = paramRouteAtom("slug", { parent: unionRouteAtom([month, year]) });
seed(store, "/blog/2024/06/some-post");

expect(store.get(slug).values).toEqual({ year: 2024, month: 6, slug: "some-post" });
});

it("reverses and navigates through the member that matches", () => {
const store = createStore();
seed(store, "/blog/2024/06");

expect(store.get(anyDate).reverse({ year: 2025, month: 3 })).toBe("/blog/2025/3");

store.set(anyDate, { year: 2025, month: 3 });
expect(store.get(locationAtom).pathname).toBe("/blog/2025/3");
});

it("reverses through the first member when nothing matches", () => {
const store = createStore();
seed(store, "/elsewhere");

expect(store.get(anyDate).reverse({ year: 2024 })).toBe("/blog/2024");
});
});
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 "./staticRouteAtom";
export * from "./paramRouteAtom";
export * from "./numericRouteAtom";
export * from "./transformRouteAtom";
export * from "./unionRouteAtom";
export * from "./notAtom";
export * from "./href";
export * from "./queryAtom";
Expand Down
29 changes: 22 additions & 7 deletions packages/jarl-atoms/src/notAtom.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
import { Atom, atom } from "jotai/vanilla";
import { RouteAtom } from "./types";

/** Options for `notAtom`. */
export type NotOptions = {
/**
* Whether only an exact (leaf) match counts as matching, rather than an ancestor match too.
* Defaults to `true`.
*/
exact?: boolean;
};

/**
* Matches when none of the given route atoms are an exact match - the
* inverse of a router's full route list, for a catch-all/not-found case.
* Checks `exact` rather than `match`: an ancestor route (or `rootAtom`
* itself) can be `match: true` without being the leaf that actually
* rendered, and only the leaf's exactness should count.
* Matches when `route` doesn't - the inverse of a router's full route list, for a
* catch-all/not-found case. Combine every route the app renders into one `unionRouteAtom` to
* negate the lot: `notAtom(unionRouteAtom([homeRoute, postRoute, ...]))`.
*
* Exactness is what counts by default: an ancestor route (or `rootAtom` itself) can be
* `match: true` without being the leaf that actually rendered, and only the leaf's exactness
* should decide whether anything was found. Pass `{ exact: false }` where an ancestor match
* should count too.
*/
export const notAtom = (...routes: RouteAtom<any>[]): Atom<boolean> =>
atom((get) => !routes.some((route) => get(route).exact));
export const notAtom = (route: RouteAtom<any>, { exact = true }: NotOptions = {}): Atom<boolean> =>
atom((get) => {
const matched = get(route);
return !(exact ? matched.exact : matched.match);
});
3 changes: 3 additions & 0 deletions packages/jarl-atoms/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export type RouteReturn<T extends DefaultParams = DefaultParams> = {
/** A route: read it for its `RouteReturn` match state, write param values to it to navigate. */
export type RouteAtom<T extends DefaultParams> = WritableAtom<RouteReturn<T>, [T, NavOptions?], void>;

/** The param values a route atom binds: `RouteValues<typeof postRoute>` is that route's `values`. */
export type RouteValues<Route extends RouteAtom<any>> = Route extends RouteAtom<infer T> ? T : never;

/** Common options for every route atom constructor. */
export type RouteOptions<Parent extends DefaultParams> = {
/** Route this one nests under, matching the segment after its parent's. Defaults to `rootAtom`. */
Expand Down
39 changes: 39 additions & 0 deletions packages/jarl-atoms/src/unionRouteAtom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Getter, atom } from "jotai/vanilla";
import { RouteAtom, RouteReturn, RouteValues } from "./types";

/** One or more route atoms, in the precedence order `unionRouteAtom` falls back through. */
export type UnionRoutes = readonly [RouteAtom<any>, ...RouteAtom<any>[]];

/**
* Combines several routes into the one route that matches wherever any of them does: it matches
* when any member matches, and exactly when any member matches exactly. `values`, `rest` and
* `reverse` come from the member that actually matched - the exact one where there is one,
* otherwise the first to match, in the order given - so a chain of increasingly specific routes
* reads as one route bound to a union of their param types.
*
* It composes like any other route: as a `parent`, in `<Route on={...}>`, or as the whole route
* list handed to `notAtom`. A union has no URL shape of its own, though, so `reverse` and writes
* go to whichever member matches, and to the first listed when none does - navigate through the
* specific member you mean rather than through the union.
*/
export const unionRouteAtom = <Routes extends UnionRoutes>(routes: Routes): RouteAtom<RouteValues<Routes[number]>> => {
type Values = RouteValues<Routes[number]>;
// An ancestor is `match: true` for every location beneath it, so the leaf that actually
// rendered is the honest source of values wherever one of the members is it.
const matched = (get: Getter) => routes.find((route) => get(route).exact) ?? routes.find((route) => get(route).match);
return atom(
(get) => {
const route = matched(get);
if (route) {
return get(route) as RouteReturn<Values>;
}
// Nothing matched, so nothing bound values or consumed path - but the first member is
// still the union's only answer to "where would this point".
const { reverse } = get(routes[0]);
return { match: false, exact: false, values: undefined, reverse };
},
(get, set, values, navOptions) => {
set(matched(get) ?? routes[0], values, navOptions);
},
);
};
4 changes: 2 additions & 2 deletions packages/jarl-react/src/__tests__/notAtom.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { useAtomValue } from "jotai";
import { notAtom } from "jarl-atoms";
import { notAtom, unionRouteAtom } from "jarl-atoms";
import { Route } from "../Route";
import { aboutAtom, usersAtom } from "./fixtures";

Expand All @@ -11,7 +11,7 @@ beforeEach(() => {
goTo("/");
});

const notFoundAtom = notAtom(aboutAtom, usersAtom);
const notFoundAtom = notAtom(unionRouteAtom([aboutAtom, usersAtom]));

const NotFound = () => (useAtomValue(notFoundAtom) ? <div>Not found</div> : null);

Expand Down
Loading