shelving
Version:
Toolkit for using data in JavaScript.
187 lines (139 loc) • 6.98 kB
Markdown
# Router
Client-side routing for shelving apps.
Two pieces:
| Component | Job |
| --------------- | ---------------------------------------------------------------------------------------------- |
| `<Navigation>` | One per app. Owns URL state, intercepts link clicks, listens for `popstate`. |
| `<Router>` | Pure matcher. Reads URL from `<Meta>`, matches `routes`, renders the matched element. |
Plus `requireNavigation()` for imperative `forward()` / `redirect()` calls from anywhere in the tree.
`<Router>` reads URL state from `<Meta>`, so it works with no `<Navigation>` at all (SSR, static rendering, tests). `<Navigation>` is what publishes a *live* URL into `<Meta>` on the client.
## Basic setup
```tsx
<HTML url={initialUrl} root="https://example.com/">
<Navigation>
<Router routes={{
"/": HomePage,
"/users/{id}": UserPage,
"/about": AboutPage,
}}/>
</Navigation>
</HTML>
```
- Route keys are `AbsolutePath` strings starting with `/`.
- Placeholders (`{id}`, `:id`, `[id]`, `${id}`, `{{id}}`) are passed to function/component routes as props (merged with URL `?query` params; placeholders win on conflict).
- A string value (e.g. `"/users/123"`) is a redirect — visiting the key path navigates to the target.
- `<Router>` itself accepts `PossibleMeta` props (`url`, `base`, etc.) to override the surrounding context.
## Route value types
| Value | Behaviour |
| --------------------- | ---------------------------------------------------------------------- |
| `RouteComponent` | Rendered as `<Component {...params}/>` with merged placeholder + query. |
| `AbsolutePath` string | Redirects to that path (placeholders resolved against the source). |
| `ReactElement` | Rendered as-is — use for layout wrapping or composing inner routers. |
## Placeholder syntax
All forms produce the same matched value — pick whichever reads best:
| Form | Single segment | Catchall (one+ segments, also matches empty) |
| ----------------- | ---------------------- | -------------------------------------------- |
| Anonymous | `*` (named `"0"`) | `**` / `***` / `****` (named `"0"`) |
| Colon | `:name` | `:name*` / `:name**` |
| Single brace | `{name}` | `{...name}` / `{name*}` / `{....name}` |
| Square bracket | `[name]` | `[...name]` / `[name*]` |
| Dollar brace | `${name}` | `${...name}` / `${name*}` |
| Double brace | `{{name}}` | `{{...name}}` / `{{name*}}` |
Modifier chars are tolerant: one-or-more stars and three-or-more dots are all equivalent. So `{path*}`, `{path**}`, `{...path}`, and `{....path}` all behave the same.
Catchall placeholders allow empty values, so a trailing catchall matches the trailing-slash-absent variant too — `/files/{...path}` matches both `/files`, `/files/`, and `/files/a/b/c`.
## Layout wrapping
Put layout JSX as the route value with another `<Router>` inside.
```tsx
const SIDEBARRED_ROUTES = {
"/users": <UsersPage/>,
"/users/{id}": <UserPage/>,
"/settings": <SettingsPage/>,
};
<Router routes={{
"/": <HomePage/>,
"/{...path}": (
<SidebarLayout>
<Router routes={SIDEBARRED_ROUTES}/>
</SidebarLayout>
),
}}/>
```
The outer router matches everything, wraps in `<SidebarLayout>`, and hands off to the inner router. Since `<Router>` reads its URL from `<Meta>`, the inner router sees the same URL.
## Section / microsite pattern
A self-contained "section" of the app — its own URL prefix, its own routes — composes via a catchall + a function route value that hands the captured sub-path to a nested router.
```tsx
<Router routes={{
"/": <HomePage/>,
"/users/{...path}": ({ path = "/" }) => (
<Router routes={{
"/": UsersPage,
"/{id}": UserPage,
}} url={path}/>
),
}}/>
```
The outer router captures everything under `/users` into the `path` placeholder, then the inner router treats `path` as its starting URL. The inner router's `"/"` matches the bare `/users`; `/{id}` matches `/users/123`.
Pull that out into a dedicated component for readability:
```tsx
const USER_ROUTES = {
"/": UsersPage,
"/{id}": UserPage,
"/{id}/edit": UserEditPage,
};
export function UserRouter({ path = "/" }: { path?: AbsolutePath }) {
return <Router routes={USER_ROUTES} url={path}/>;
}
// then at the call site:
<Router routes={{
"/": <HomePage/>,
"/users/{...path}": ({ path }) => <UserRouter path={path}/>,
"/blog/{...path}": ({ path }) => <BlogRouter path={path}/>,
}}/>
```
Each section module owns its routes and exposes one component. The top-level router stays a flat list of section prefixes.
## Stacking layouts and sections
The two patterns compose. Wrap a bunch of routes in a layout, then route further inside it:
```tsx
const SIDEBARRED_ROUTES = {
"/": <Dashboard/>,
"/users/{...path}": ({ path }) => <UserRouter path={path}/>,
"/blog/{...path}": ({ path }) => <BlogRouter path={path}/>,
"/settings": <SettingsPage/>,
};
<Router routes={{
"/login": <LoginPage/>, // no sidebar
"/{...path}": ( // everything else wrapped
<SidebarLayout>
<Router routes={SIDEBARRED_ROUTES}/>
</SidebarLayout>
),
}}/>
```
`/login` skips the sidebar. Every other path goes through `<SidebarLayout>` and then the inner router decides what to render — including handing off to section routers via the catchall pattern.
## Navigation
Inside a component, get the navigation store for imperative URL changes:
```tsx
const nav = requireNavigation();
nav.forward("/users/123"); // push history
nav.redirect("/login"); // replace history
```
Same-origin anchor clicks are intercepted automatically and turned into `forward()` calls. Add a `download` attribute to opt out.
## `<NavigationIsolate>`
Force a full remount of children whenever the URL changes:
```tsx
<NavigationIsolate>
<ExpensiveStatefulThing/>
</NavigationIsolate>
```
## SSR / static rendering
`<Router>` has no client requirements — it reads from `<Meta>` and re-renders when context changes. For static rendering, set `url` and `root` on the outer wrapper and skip `<Navigation>`:
```tsx
renderToString(
<HTML url={path} root="https://example.com/">
<Router routes={…}/>
</HTML>
);
```
For client-side SPAs, wrap the same tree in `<Navigation>` for live URL updates.
## Base paths
`root="https://example.com/app/"` is supported. The base path prefix is stripped from the URL before route matching via `matchURLPrefix`. URLs that fall outside the base render as `null` from the router.