shelving 1.244.1 → 1.246.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,6 +32,7 @@ export interface PackageExtractorOptions {
32
32
  * - Static export keys (e.g. `"./api"`, `"./firestore/client"`) become one module each.
33
33
  * - Wildcard export keys (e.g. `"./util/*"`) expand against the source tree — one module per matching child file or subdirectory.
34
34
  * - Each export's *target* extension (e.g. the `.js` in `"./util/*.js"`) is mapped to source extensions via `extensions`, so built `.js` paths resolve to their `.ts` sources.
35
+ * - Each module's `title` is prefixed with the package `name` (e.g. `ui` → `shelving/ui`) so listings read as package subpaths.
35
36
  * - The `"."` root export is skipped — its content is the root tree element itself.
36
37
  * - Throws if a static export key has no matching source element in the tree.
37
38
  *
@@ -16,6 +16,7 @@ const DEFAULT_EXTENSIONS = {
16
16
  * - Static export keys (e.g. `"./api"`, `"./firestore/client"`) become one module each.
17
17
  * - Wildcard export keys (e.g. `"./util/*"`) expand against the source tree — one module per matching child file or subdirectory.
18
18
  * - Each export's *target* extension (e.g. the `.js` in `"./util/*.js"`) is mapped to source extensions via `extensions`, so built `.js` paths resolve to their `.ts` sources.
19
+ * - Each module's `title` is prefixed with the package `name` (e.g. `ui` → `shelving/ui`) so listings read as package subpaths.
19
20
  * - The `"."` root export is skipped — its content is the root tree element itself.
20
21
  * - Throws if a static export key has no matching source element in the tree.
21
22
  *
@@ -56,7 +57,9 @@ export class PackageExtractor extends Extractor {
56
57
  async extract(packageJson) {
57
58
  const pkgPath = requirePath(packageJson, this._base, this.extract);
58
59
  const pkg = (await Bun.file(pkgPath).json());
59
- const exports = pkg.exports ?? {};
60
+ const tree = this._tree;
61
+ // Read the package name alongside its exports — the name prefixes each module title (e.g. `ui` → `shelving/ui`).
62
+ const { name, exports = {} } = pkg;
60
63
  const modules = [];
61
64
  for (const [key, value] of Object.entries(exports)) {
62
65
  if (key === ".")
@@ -76,18 +79,21 @@ export class PackageExtractor extends Extractor {
76
79
  modules.push(this._module.extract({ name: subpath, source }));
77
80
  }
78
81
  }
79
- const tree = this._tree;
82
+ // Prefix the package name onto each module's title so listings read `shelving/ui` rather than a bare `ui`, making modules glanceable as package subpaths.
83
+ const children = name
84
+ ? modules.map(module => ({ ...module, props: { ...module.props, title: `${name}/${module.props.title ?? module.props.name}` } }))
85
+ : modules;
80
86
  // Canonical URL `path`s aren't stamped here — they're derived from tree structure when the tree is flattened (`flattenTree()`) in the UI layer.
81
87
  return {
82
88
  type: "tree-element",
83
- key: pkg.name ?? tree.key,
89
+ key: name ?? tree.key,
84
90
  props: {
85
91
  source: tree.props.source,
86
- name: pkg.name ?? tree.props.name,
87
- title: pkg.name ?? tree.props.title,
92
+ name: name ?? tree.props.name,
93
+ title: name ?? tree.props.title,
88
94
  description: pkg.description ?? tree.props.description,
89
95
  content: tree.props.content,
90
- children: modules,
96
+ children,
91
97
  },
92
98
  };
93
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.244.1",
3
+ "version": "1.246.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
package/ui/README.md CHANGED
@@ -11,7 +11,7 @@ The `ui` module exists so an app never hand-rolls the same form field, card, or
11
11
  A few conventions run through every component:
12
12
 
13
13
  - **Styling props are for one-off overrides.** Visual options are props on the component — enumerated props for the scales (`color="red"`, `size="large"`, `space="none"`, `width="narrow"`) and boolean props for on/off variants (`<Button strong>`, `<Title center>`, `<Flex wrap>`). Each maps to a class in a CSS Module. Reach for them when a component needs to look different in *one place* — the way the docs site tints its accents purple — not as the way to dress a whole app. You never pass `style` or raw `className`.
14
- - **Composition.** Higher-level components — a `*Page`, a `*Card` — take their identity from library components like [`Card`](/ui/Card), [`Section`](/ui/Section), [`Button`](/ui/Button), and [`Tag`](/ui/Tag) rather than shipping their own styling.
14
+ - **Composition.** Higher-level components — a `*Page`, a `*Card` — take their identity from library components like [`<Card>`](/ui/Card), [`<Section>`](/ui/Section), [`<Button>`](/ui/Button), and [`<Tag>`](/ui/Tag) rather than shipping their own styling.
15
15
  - **Sentence case.** Titles, headings, and button labels capitalise only the first word.
16
16
  - **Theme with CSS.** An app-wide custom look is a CSS file, not a wall of props. Write a `theme.css` that overrides the base design-token variables (and, where needed, per-component hooks) at `:root`, and import it after the library styles. The recommended workflow is to spend time tuning those variables to match your design — see [Theming](#theming) below.
17
17
 
@@ -19,15 +19,15 @@ A few conventions run through every component:
19
19
 
20
20
  The styling system lives in `style/` and has four moving parts: design tokens, the tint scale, cascade layers, and the styling props. Components compose them in a predictable shape; consumers theme by overriding CSS custom properties at `:root`.
21
21
 
22
- **Design tokens.** Every design-token constant is defined at `:root`, split across the themed token modules in `style/` — each module owns one domain, documents the variables it defines, and is the page a theme author overrides. `style/layers.css` is the cascade-layer anchor; every `*.module.css` `@import`s it plus the specific token modules it references, so the tokens and the layer order reach every component regardless of bundle order. The domains are: colours ([`getColorClass`](/ui/getColorClass)), font sizes ([`getSizeClass`](/ui/getSizeClass)), font weights ([`getWeightClass`](/ui/getWeightClass)), font faces ([`getFontClass`](/ui/getFontClass)), spacing ([`getSpaceClass`](/ui/getSpaceClass)), widths ([`getWidthClass`](/ui/getWidthClass)), radii ([`getRadiusClass`](/ui/getRadiusClass)), strokes ([`getStrokeClass`](/ui/getStrokeClass)), shadows ([`getShadowClass`](/ui/getShadowClass)), and durations ([`getDurationClass`](/ui/getDurationClass)). Each also defines the semantic aliases a theme usually targets (`--color-primary`, `--color-link`, `--space-paragraph`, …). Components read tokens via `var(--token)`.
22
+ **Design tokens.** Every design-token constant is defined at `:root`, split across the themed token modules in `style/` — each module owns one domain, documents the variables it defines, and is the page a theme author overrides. `style/layers.css` is the cascade-layer anchor; every `*.module.css` `@import`s it plus the specific token modules it references, so the tokens and the layer order reach every component regardless of bundle order. The domains are: colours ([`getColorClass()`](/ui/getColorClass)), font sizes ([`getSizeClass()`](/ui/getSizeClass)), font weights ([`getWeightClass()`](/ui/getWeightClass)), font faces ([`getFontClass()`](/ui/getFontClass)), spacing ([`getSpaceClass()`](/ui/getSpaceClass)), widths ([`getWidthClass()`](/ui/getWidthClass)), radii ([`getRadiusClass()`](/ui/getRadiusClass)), strokes ([`getStrokeClass()`](/ui/getStrokeClass)), shadows ([`getShadowClass()`](/ui/getShadowClass)), and durations ([`getDurationClass()`](/ui/getDurationClass)). Each also defines the semantic aliases a theme usually targets (`--color-primary`, `--color-link`, `--space-paragraph`, …). Components read tokens via `var(--token)`.
23
23
 
24
- **The tint scale.** All colour flows from one anchor variable, `--tint-50`, from which a 21-step ladder is computed and *recomputed* under [`TINT_CLASS`](/ui/TINT_CLASS) — the heart of how `color=` and `status=` retint a whole subtree. The ladder, the recompute trick, the painting conventions, and the theming guide all live on the [`TINT_CLASS`](/ui/TINT_CLASS) page.
24
+ **The tint scale.** All colour flows from one anchor variable, `--tint-50`, from which a 21-step ladder is computed and *recomputed* under [`TINT_CLASS`](/ui/TINT_CLASS) — the heart of how `color=` and `status=` retint a whole subtree. The ladder, the recompute trick, the painting conventions, and the theming guide all live on the `TINT_CLASS` page.
25
25
 
26
26
  **Cascade layers.** Styles are ordered by `@layer`, lowest to highest priority: `defaults` (`:root` tokens, the tint ladder, body baseline) → `components` (the bulk of the CSS: `.card`, `.button`, …) → `variants` (cross-cutting opt-in modifiers, which always beat components) → `overrides` (top-priority structural fixes like `:first-child` / `:last-child` margin collapses). Unlayered rules beat all layered rules, so a theme should set tokens at `:root` or wrap its rules in `@layer`.
27
27
 
28
- **Styling props.** The cross-cutting visual options are props, each backed by a helper in `style/` that maps the prop to a class. Colour and status move the tint anchor — [`getColorClass`](/ui/getColorClass) and [`getStatusClass`](/ui/getStatusClass); font size, weight, and family come from [`getSizeClass`](/ui/getSizeClass), [`getWeightClass`](/ui/getWeightClass), and [`getFontClass`](/ui/getFontClass), which [`getTypographyClass`](/ui/getTypographyClass) combines with text alignment and tint; spacing, padding, and gap from [`getSpaceClass`](/ui/getSpaceClass), [`getPaddingClass`](/ui/getPaddingClass), and [`getGapClass`](/ui/getGapClass); width constraints from [`getWidthClass`](/ui/getWidthClass); flex layout from [`getFlexClass`](/ui/getFlexClass); and opt-in scrolling from [`getScrollClass`](/ui/getScrollClass). Each helper's page lists its exact prop values and what they set. A component opts into the props it wants by extending the matching `*Props` interfaces and composing the `getXxxClass(props)` calls.
28
+ **Styling props.** The cross-cutting visual options are props, each backed by a helper in `style/` that maps the prop to a class. Colour and status move the tint anchor — `getColorClass()` and [`getStatusClass()`](/ui/getStatusClass); font size, weight, and family come from `getSizeClass()`, `getWeightClass()`, and `getFontClass()`, which [`getTypographyClass()`](/ui/getTypographyClass) combines with text alignment and tint; spacing, padding, and gap from `getSpaceClass()`, [`getPaddingClass()`](/ui/getPaddingClass), and [`getGapClass()`](/ui/getGapClass); width constraints from `getWidthClass()`; flex layout from [`getFlexClass()`](/ui/getFlexClass); and opt-in scrolling from [`getScrollClass()`](/ui/getScrollClass). Each helper's page lists its exact prop values and what they set. A component opts into the props it wants by extending the matching `*Props` interfaces and composing the `getXxxClass(props)` calls.
29
29
 
30
- Each painting component also exposes its own theme hooks — a single tint hook (`--card-tint`) to recolour the whole component, plus per-property hooks (`--card-background`, `--card-radius`, …) for surgical overrides. Those are documented in each component's own **Styling** section (see [`Card`](/ui/Card) for the precedent).
30
+ Each painting component also exposes its own theme hooks — a single tint hook (`--card-tint`) to recolour the whole component, plus per-property hooks (`--card-background`, `--card-radius`, …) for surgical overrides. Those are documented in each component's own **Styling** section (see [`<Card>`](/ui/Card) for the precedent).
31
31
 
32
32
  ## Theming
33
33
 
@@ -45,10 +45,10 @@ The recommended way to give an app its own look is a **theme stylesheet**, not s
45
45
 
46
46
  Each base token lives in a themed module that documents the variables it defines and which ones a theme usually overrides. Work from broadest (a palette colour or scale root) to narrowest (a single semantic alias):
47
47
 
48
- - [weight](/ui/getWeightClass) · [size](/ui/getSizeClass) · [font](/ui/getFontClass) — typography (`--weight-*`, `--size-*`, `--font-*`, `--case-label`).
49
- - [color](/ui/getColorClass) — palette, semantic, and brand colours (`--color-*`).
50
- - [space](/ui/getSpaceClass) · [width](/ui/getWidthClass) — layout spacing and widths (`--space-*`, `--width-*`).
51
- - [radius](/ui/getRadiusClass) · [stroke](/ui/getStrokeClass) · [shadow](/ui/getShadowClass) · [duration](/ui/getDurationClass) — surface tokens (`--radius-*`, `--stroke-*`, `--shadow-*`, `--duration-*`).
48
+ - [`weight`](/ui/getWeightClass) · [`size`](/ui/getSizeClass) · [`font`](/ui/getFontClass) — typography (`--weight-*`, `--size-*`, `--font-*`, `--case-label`).
49
+ - [`color`](/ui/getColorClass) — palette, semantic, and brand colours (`--color-*`).
50
+ - [`space`](/ui/getSpaceClass) · [`width`](/ui/getWidthClass) — layout spacing and widths (`--space-*`, `--width-*`).
51
+ - [`radius`](/ui/getRadiusClass) · [`stroke`](/ui/getStrokeClass) · [`shadow`](/ui/getShadowClass) · [`duration`](/ui/getDurationClass) — surface tokens (`--radius-*`, `--stroke-*`, `--shadow-*`, `--duration-*`).
52
52
 
53
53
  The **tint ladder** is the one exception that doesn't follow the override-a-variable pattern: its 21 steps are *recomputed* from a single anchor inside every tinted scope, so you move the anchor rather than overriding individual steps. See [`TINT_CLASS`](/ui/TINT_CLASS) for the full theming guide, and each component's **Styling** section for its per-component hooks.
54
54
 
@@ -56,19 +56,19 @@ The **tint ladder** is the one exception that doesn't follow the override-a-vari
56
56
 
57
57
  The components below are listed in the index following this page; this is the short version of where to start reading.
58
58
 
59
- **Content.** Block-level structure starts with [`Card`](/ui/Card), [`Section`](/ui/Section), and the [`Heading`](/ui/Heading) / [`Title`](/ui/Title) family, with [`Table`](/ui/Table), [`List`](/ui/List), and [`Figure`](/ui/Figure) for specific shapes; wrap longform copy in [`Prose`](/ui/Prose). Inline pieces — [`Link`](/ui/Link), [`Code`](/ui/Code), [`Strong`](/ui/Strong), [`Mark`](/ui/Mark) — live inside that block content. To render a Markdown string as components, use [`Markup`](/ui/Markup).
59
+ **Content.** Block-level structure starts with [`<Card>`](/ui/Card), [`<Section>`](/ui/Section), and the [`<Heading>`](/ui/Heading) / [`<Title>`](/ui/Title) family, with [`<Table>`](/ui/Table), [`<List>`](/ui/List), and [`<Figure>`](/ui/Figure) for specific shapes; wrap longform copy in [`<Prose>`](/ui/Prose). Inline pieces — [`<Link>`](/ui/Link), [`<Code>`](/ui/Code), [`<Strong>`](/ui/Strong), [`<Mark>`](/ui/Mark) — live inside that block content. To render a Markdown string as components, use [`<Markup>`](/ui/Markup).
60
60
 
61
- **Structure.** Mount a client app with [`App`](/ui/App), or render a full server document with [`HTML`](/ui/HTML) and [`Page`](/ui/Page). Arrange the screen with [`CenteredLayout`](/ui/CenteredLayout) or [`SidebarLayout`](/ui/SidebarLayout), and drive URLs with [`Navigation`](/ui/Navigation) and [`Router`](/ui/Router).
61
+ **Structure.** Mount a client app with [`<App>`](/ui/App), or render a full server document with [`<HTML>`](/ui/HTML) and [`<Page>`](/ui/Page). Arrange the screen with [`<CenteredLayout>`](/ui/CenteredLayout) or [`<SidebarLayout>`](/ui/SidebarLayout), and drive URLs with [`<Navigation>`](/ui/Navigation) and [`<Router>`](/ui/Router).
62
62
 
63
- **Interaction.** Forms start at [`Form`](/ui/Form), which wires [`Field`](/ui/Field) and the typed inputs to a [`FormStore`](/ui/FormStore); [`Button`](/ui/Button) is the standalone action. Overlays are [`Dialog`](/ui/Dialog) and [`Modal`](/ui/Modal); navigation menus are [`Menu`](/ui/Menu) and [`MenuItem`](/ui/MenuItem); transient feedback is [`Notice`](/ui/Notice) (and the global [`Notices`](/ui/Notices) list); animate enter/leave with [`Transition`](/ui/Transition).
63
+ **Interaction.** Forms start at [`<Form>`](/ui/Form), which wires [`<Field>`](/ui/Field) and the typed inputs to a [`FormStore`](/ui/FormStore); [`<Button>`](/ui/Button) is the standalone action. Overlays are [`<Dialog>`](/ui/Dialog) and [`<Modal>`](/ui/Modal); navigation menus are [`<Menu>`](/ui/Menu) and [`<MenuItem>`](/ui/MenuItem); transient feedback is [`<Notice>`](/ui/Notice) (and the global [`<Notices>`](/ui/Notices) list); animate enter/leave with [`<Transition>`](/ui/Transition).
64
64
 
65
- **Documentation site.** Hand an extracted tree to [`TreeApp`](/ui/TreeApp) and you get a complete site — sidebar, routing, and a rendered page per element — using the renderers in `docs/`.
65
+ **Documentation site.** Hand an extracted tree to [`<TreeApp>`](/ui/TreeApp) and you get a complete site — sidebar, routing, and a rendered page per element — using the renderers in `docs/`.
66
66
 
67
67
  ## See also
68
68
 
69
- - [extract](/extract) — builds the tree that the documentation components render
70
- - [markup](/markup) — Markdown rendering used by [`Markup`](/ui/Markup) and [`Prose`](/ui/Prose)
71
- - [store](/store) — reactive state behind [`FormStore`](/ui/FormStore), `NavigationStore`, and notices
72
- - [react](/react) — store and provider hooks used alongside these components
69
+ - [`extract`](/extract) — builds the tree that the documentation components render
70
+ - [`markup`](/markup) — Markdown rendering used by [`<Markup>`](/ui/Markup) and [`<Prose>`](/ui/Prose)
71
+ - [`store`](/store) — reactive state behind [`FormStore`](/ui/FormStore), [`NavigationStore`](/ui/NavigationStore), and notices
72
+ - [`react`](/react) — store and provider hooks used alongside these components
73
73
 
74
74
  > Building or extending a component? The contributor walkthrough (file layout, the tint-anchor + per-property-hook pattern, `:first-child` / `:last-child` overrides, and the checklist) lives in the **React Components** section of `AGENTS.md`.
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { requireMetaURL } from "../misc/MetaContext.js";
2
+ import { RouteCache } from "../router/RouteCache.js";
3
3
  import { getClass, getModuleClass } from "../util/css.js";
4
4
  import CENTERED_LAYOUT_CSS from "./CenteredLayout.module.css";
5
5
  import { LAYOUT_CLASS } from "./Layout.js";
@@ -15,6 +15,7 @@ import { LAYOUT_CLASS } from "./Layout.js";
15
15
  * @see https://dhoulb.github.io/shelving/ui/layout/CenteredLayout/CenteredLayout
16
16
  */
17
17
  export function CenteredLayout({ children, fullWidth = false }) {
18
- const { path } = requireMetaURL();
19
- return (_jsx("main", { className: getClass(getModuleClass(CENTERED_LAYOUT_CSS, "main"), LAYOUT_CLASS), children: _jsx("div", { className: getModuleClass(CENTERED_LAYOUT_CSS, "mainInner"), style: fullWidth ? { maxWidth: "none" } : undefined, children: children }) }, path));
18
+ // Wrap the scrolling `<main>` in `<RouteCache>` so recently-visited pages stay mounted but hidden,
19
+ // keeping their scroll position and state intact across back/forward navigation.
20
+ return (_jsx(RouteCache, { children: _jsx("main", { className: getClass(getModuleClass(CENTERED_LAYOUT_CSS, "main"), LAYOUT_CLASS), children: _jsx("div", { className: getModuleClass(CENTERED_LAYOUT_CSS, "mainInner"), style: fullWidth ? { maxWidth: "none" } : undefined, children: children }) }) }));
20
21
  }
@@ -1,5 +1,5 @@
1
1
  import type { ReactElement } from "react";
2
- import { requireMetaURL } from "../misc/MetaContext.js";
2
+ import { RouteCache } from "../router/RouteCache.js";
3
3
  import { getClass, getModuleClass } from "../util/css.js";
4
4
  import type { OptionalChildProps } from "../util/props.js";
5
5
  import CENTERED_LAYOUT_CSS from "./CenteredLayout.module.css";
@@ -26,12 +26,15 @@ export interface CenteredLayoutProps extends OptionalChildProps {
26
26
  * @see https://dhoulb.github.io/shelving/ui/layout/CenteredLayout/CenteredLayout
27
27
  */
28
28
  export function CenteredLayout({ children, fullWidth = false }: CenteredLayoutProps): ReactElement {
29
- const { path } = requireMetaURL();
29
+ // Wrap the scrolling `<main>` in `<RouteCache>` so recently-visited pages stay mounted but hidden,
30
+ // keeping their scroll position and state intact across back/forward navigation.
30
31
  return (
31
- <main key={path} className={getClass(getModuleClass(CENTERED_LAYOUT_CSS, "main"), LAYOUT_CLASS)}>
32
- <div className={getModuleClass(CENTERED_LAYOUT_CSS, "mainInner")} style={fullWidth ? { maxWidth: "none" } : undefined}>
33
- {children}
34
- </div>
35
- </main>
32
+ <RouteCache>
33
+ <main className={getClass(getModuleClass(CENTERED_LAYOUT_CSS, "main"), LAYOUT_CLASS)}>
34
+ <div className={getModuleClass(CENTERED_LAYOUT_CSS, "mainInner")} style={fullWidth ? { maxWidth: "none" } : undefined}>
35
+ {children}
36
+ </div>
37
+ </main>
38
+ </RouteCache>
36
39
  );
37
40
  }
@@ -17,6 +17,7 @@ export interface SidebarLayoutProps extends OptionalChildProps {
17
17
  * - On narrow viewports the sidebar becomes an off-canvas drawer toggled by a single menu button that switches between a burger and a close icon.
18
18
  * - While the drawer is open an overlay dims the rest of the page; clicking the overlay closes the drawer.
19
19
  * - Inside a `<Navigation>` the drawer closes itself whenever the route changes (e.g. tapping a sidebar link).
20
+ * - The scrollable content column is kept alive across navigation via `<RouteCache>`, so returning to a recently-visited page restores its scroll position and state; the sidebar stays mounted throughout.
20
21
  * - Use the `--sidebar-layout-width`, `--sidebar-layout-bg`, `--sidebar-layout-border`, and `--sidebar-layout-color-border` custom properties to override defaults.
21
22
  *
22
23
  * @kind component
@@ -3,6 +3,7 @@ import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid";
3
3
  import { useEffect, useState } from "react";
4
4
  import { Button } from "../form/Button.js";
5
5
  import { requireMetaURL } from "../misc/MetaContext.js";
6
+ import { RouteCache } from "../router/RouteCache.js";
6
7
  import { getClass, getModuleClass } from "../util/css.js";
7
8
  import { LAYOUT_CLASS } from "./Layout.js";
8
9
  import SIDEBAR_LAYOUT_CSS from "./SidebarLayout.module.css";
@@ -12,6 +13,7 @@ import SIDEBAR_LAYOUT_CSS from "./SidebarLayout.module.css";
12
13
  * - On narrow viewports the sidebar becomes an off-canvas drawer toggled by a single menu button that switches between a burger and a close icon.
13
14
  * - While the drawer is open an overlay dims the rest of the page; clicking the overlay closes the drawer.
14
15
  * - Inside a `<Navigation>` the drawer closes itself whenever the route changes (e.g. tapping a sidebar link).
16
+ * - The scrollable content column is kept alive across navigation via `<RouteCache>`, so returning to a recently-visited page restores its scroll position and state; the sidebar stays mounted throughout.
15
17
  * - Use the `--sidebar-layout-width`, `--sidebar-layout-bg`, `--sidebar-layout-border`, and `--sidebar-layout-color-border` custom properties to override defaults.
16
18
  *
17
19
  * @kind component
@@ -31,7 +33,11 @@ export function SidebarLayout({ sidebar, children, right = false }) {
31
33
  setOpen(false);
32
34
  }, [path]);
33
35
  const sidebarEl = (_jsx("nav", { className: getClass(getModuleClass(SIDEBAR_LAYOUT_CSS, "sidebar"), open && getModuleClass(SIDEBAR_LAYOUT_CSS, "open")), children: sidebar }, "sidebar"));
34
- const contentEl = (_jsxs("div", { className: getClass(LAYOUT_CLASS, getModuleClass(SIDEBAR_LAYOUT_CSS, "content")), children: [_jsx("div", { className: getModuleClass(SIDEBAR_LAYOUT_CSS, "toggle"), children: _jsx(Button, { title: open ? "Close menu" : "Show menu", onClick: () => setOpen(o => !o), children: open ? _jsx(XMarkIcon, {}) : _jsx(Bars3Icon, {}) }) }), _jsx("div", { className: getModuleClass(SIDEBAR_LAYOUT_CSS, "contentInner"), children: children })] }, path));
36
+ // Wrap the scrolling content column in `<RouteCache>` so recently-visited pages stay mounted but hidden
37
+ // — keeping the scroll position of this `.content` container (and all page state) intact across
38
+ // back/forward navigation. The sidebar and drawer state stay outside the cache, so they are neither
39
+ // duplicated nor remounted as the URL changes.
40
+ const contentEl = (_jsx(RouteCache, { children: _jsxs("div", { className: getClass(LAYOUT_CLASS, getModuleClass(SIDEBAR_LAYOUT_CSS, "content")), children: [_jsx("div", { className: getModuleClass(SIDEBAR_LAYOUT_CSS, "toggle"), children: _jsx(Button, { title: open ? "Close menu" : "Show menu", onClick: () => setOpen(o => !o), children: open ? _jsx(XMarkIcon, {}) : _jsx(Bars3Icon, {}) }) }), _jsx("div", { className: getModuleClass(SIDEBAR_LAYOUT_CSS, "contentInner"), children: children })] }) }, "content"));
35
41
  const overlayEl = open && (_jsx("button", { type: "button", className: getModuleClass(SIDEBAR_LAYOUT_CSS, "overlay"), "aria-label": "Close menu", onClick: () => setOpen(false) }, "overlay"));
36
42
  return (_jsx("main", { className: getClass(getModuleClass(SIDEBAR_LAYOUT_CSS, "main"), LAYOUT_CLASS), children: right ? [contentEl, sidebarEl, overlayEl] : [sidebarEl, contentEl, overlayEl] }));
37
43
  }
@@ -2,6 +2,7 @@ import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid";
2
2
  import { type ReactElement, type ReactNode, useEffect, useState } from "react";
3
3
  import { Button } from "../form/Button.js";
4
4
  import { requireMetaURL } from "../misc/MetaContext.js";
5
+ import { RouteCache } from "../router/RouteCache.js";
5
6
  import { getClass, getModuleClass } from "../util/css.js";
6
7
  import type { OptionalChildProps } from "../util/props.js";
7
8
  import { LAYOUT_CLASS } from "./Layout.js";
@@ -25,6 +26,7 @@ export interface SidebarLayoutProps extends OptionalChildProps {
25
26
  * - On narrow viewports the sidebar becomes an off-canvas drawer toggled by a single menu button that switches between a burger and a close icon.
26
27
  * - While the drawer is open an overlay dims the rest of the page; clicking the overlay closes the drawer.
27
28
  * - Inside a `<Navigation>` the drawer closes itself whenever the route changes (e.g. tapping a sidebar link).
29
+ * - The scrollable content column is kept alive across navigation via `<RouteCache>`, so returning to a recently-visited page restores its scroll position and state; the sidebar stays mounted throughout.
28
30
  * - Use the `--sidebar-layout-width`, `--sidebar-layout-bg`, `--sidebar-layout-border`, and `--sidebar-layout-color-border` custom properties to override defaults.
29
31
  *
30
32
  * @kind component
@@ -52,15 +54,21 @@ export function SidebarLayout({ sidebar, children, right = false }: SidebarLayou
52
54
  {sidebar}
53
55
  </nav>
54
56
  );
57
+ // Wrap the scrolling content column in `<RouteCache>` so recently-visited pages stay mounted but hidden
58
+ // — keeping the scroll position of this `.content` container (and all page state) intact across
59
+ // back/forward navigation. The sidebar and drawer state stay outside the cache, so they are neither
60
+ // duplicated nor remounted as the URL changes.
55
61
  const contentEl = (
56
- <div key={path} className={getClass(LAYOUT_CLASS, getModuleClass(SIDEBAR_LAYOUT_CSS, "content"))}>
57
- <div className={getModuleClass(SIDEBAR_LAYOUT_CSS, "toggle")}>
58
- <Button title={open ? "Close menu" : "Show menu"} onClick={() => setOpen(o => !o)}>
59
- {open ? <XMarkIcon /> : <Bars3Icon />}
60
- </Button>
62
+ <RouteCache key="content">
63
+ <div className={getClass(LAYOUT_CLASS, getModuleClass(SIDEBAR_LAYOUT_CSS, "content"))}>
64
+ <div className={getModuleClass(SIDEBAR_LAYOUT_CSS, "toggle")}>
65
+ <Button title={open ? "Close menu" : "Show menu"} onClick={() => setOpen(o => !o)}>
66
+ {open ? <XMarkIcon /> : <Bars3Icon />}
67
+ </Button>
68
+ </div>
69
+ <div className={getModuleClass(SIDEBAR_LAYOUT_CSS, "contentInner")}>{children}</div>
61
70
  </div>
62
- <div className={getModuleClass(SIDEBAR_LAYOUT_CSS, "contentInner")}>{children}</div>
63
- </div>
71
+ </RouteCache>
64
72
  );
65
73
  const overlayEl = open && (
66
74
  <button
@@ -0,0 +1,42 @@
1
+ import { type ReactNode } from "react";
2
+ /**
3
+ * Props for `<RouteCache>` — the `maxCached` size and the content `children` to keep alive per URL.
4
+ *
5
+ * @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCacheProps
6
+ */
7
+ export interface RouteCacheProps {
8
+ /**
9
+ * Number of recently-visited URLs to keep mounted (but hidden) so the entire state of their subtree —
10
+ * scroll position of every scroll container, open/closed toggles, in-progress searches, form inputs,
11
+ * focus — is restored intact when navigating back or forward to them.
12
+ * - Defaults to `10`. Once the limit is reached the least-recently-visited entry is unmounted.
13
+ * - Set to `0` (or less) to disable caching and unmount the subtree as you leave each URL.
14
+ */
15
+ readonly maxCached?: number | undefined;
16
+ /** The content to render for the current URL and keep alive (hidden) for recently-visited URLs. */
17
+ readonly children: ReactNode;
18
+ }
19
+ /**
20
+ * Keep-alive page cache keyed by the current URL — drop it into a layout around its scrolling content region.
21
+ *
22
+ * - Reads the live URL from the surrounding `<Meta>` context and keeps up to `maxCached` recently-visited
23
+ * pages mounted but hidden (via React's `<Activity>`), so navigating back/forward to a page restores its
24
+ * entire DOM and component state — scroll position, toggles, searches, inputs, focus — untouched.
25
+ * - Pages are kept in a least-recently-used map keyed by `path`; the oldest is unmounted past the limit.
26
+ * - Each snapshot is frozen under its own `<MetaContext>`, so the same single `children` element resolves a
27
+ * different page per path and a hidden page never re-renders for someone else's URL.
28
+ * - `<Activity mode="hidden">` preserves a hidden page's state while unmounting its effects, so its
29
+ * subscriptions/observers/timers pause and resume cleanly as it is hidden and shown.
30
+ * - Because it wraps the scroll container itself (rather than sitting below it inside the router), the
31
+ * scroll position of every cached page is preserved — surrounding chrome (sidebar, drawer state) stays
32
+ * outside the cache, so it is neither duplicated nor remounted on navigation.
33
+ * - When `maxCached <= 0` the page is rendered directly with no caching (it unmounts as you leave it).
34
+ *
35
+ * @kind component
36
+ * @param maxCached Number of recently-visited URLs to keep alive (defaults to `10`; `0` disables caching).
37
+ * @param children The content region to render and cache (typically a layout's scrollable column).
38
+ * @returns The visible current page plus any cached pages kept alive but hidden.
39
+ * @example <SidebarLayout sidebar={<Menu />}><RouteCache><Router … /></RouteCache></SidebarLayout>
40
+ * @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCache
41
+ */
42
+ export declare function RouteCache({ maxCached, children }: RouteCacheProps): ReactNode;
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Activity, useRef } from "react";
3
+ import { MetaContext, requireMetaURL } from "../misc/MetaContext.js";
4
+ /**
5
+ * Keep-alive page cache keyed by the current URL — drop it into a layout around its scrolling content region.
6
+ *
7
+ * - Reads the live URL from the surrounding `<Meta>` context and keeps up to `maxCached` recently-visited
8
+ * pages mounted but hidden (via React's `<Activity>`), so navigating back/forward to a page restores its
9
+ * entire DOM and component state — scroll position, toggles, searches, inputs, focus — untouched.
10
+ * - Pages are kept in a least-recently-used map keyed by `path`; the oldest is unmounted past the limit.
11
+ * - Each snapshot is frozen under its own `<MetaContext>`, so the same single `children` element resolves a
12
+ * different page per path and a hidden page never re-renders for someone else's URL.
13
+ * - `<Activity mode="hidden">` preserves a hidden page's state while unmounting its effects, so its
14
+ * subscriptions/observers/timers pause and resume cleanly as it is hidden and shown.
15
+ * - Because it wraps the scroll container itself (rather than sitting below it inside the router), the
16
+ * scroll position of every cached page is preserved — surrounding chrome (sidebar, drawer state) stays
17
+ * outside the cache, so it is neither duplicated nor remounted on navigation.
18
+ * - When `maxCached <= 0` the page is rendered directly with no caching (it unmounts as you leave it).
19
+ *
20
+ * @kind component
21
+ * @param maxCached Number of recently-visited URLs to keep alive (defaults to `10`; `0` disables caching).
22
+ * @param children The content region to render and cache (typically a layout's scrollable column).
23
+ * @returns The visible current page plus any cached pages kept alive but hidden.
24
+ * @example <SidebarLayout sidebar={<Menu />}><RouteCache><Router … /></RouteCache></SidebarLayout>
25
+ * @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCache
26
+ */
27
+ export function RouteCache({ maxCached = 10, children }) {
28
+ // Read the live URL from context; each navigation re-renders this with the new path.
29
+ const meta = requireMetaURL();
30
+ const mapRef = useRef(undefined);
31
+ const usedRef = useRef(0);
32
+ // Snapshot the children under the live URL (frozen) so each kept-alive copy keeps rendering for the URL
33
+ // it was captured at — the same `children` element resolves a different page per path.
34
+ const node = _jsx(MetaContext, { value: meta, children: children });
35
+ // Caching disabled — render the page directly so it unmounts as soon as you leave it.
36
+ if (maxCached <= 0)
37
+ return node;
38
+ // Insert or refresh the current page, then evict the least-recently-used pages beyond the limit.
39
+ const { path } = meta;
40
+ const map = (mapRef.current ??= new Map());
41
+ const used = ++usedRef.current;
42
+ const entry = map.get(path);
43
+ if (entry) {
44
+ entry.node = node;
45
+ entry.used = used;
46
+ }
47
+ else {
48
+ map.set(path, { node, used });
49
+ }
50
+ while (map.size > maxCached)
51
+ map.delete(_findLeastRecentlyUsed(map));
52
+ // Render every cached page; only the current `path` is visible, the rest are kept alive but hidden.
53
+ return Array.from(map, ([key, cached]) => (_jsx(Activity, { mode: key === path ? "visible" : "hidden", children: cached.node }, key)));
54
+ }
55
+ /** Find the key of the least-recently-used (lowest `used` tick) entry in the cache map. */
56
+ function _findLeastRecentlyUsed(map) {
57
+ let lruKey;
58
+ let lruUsed = Number.POSITIVE_INFINITY;
59
+ for (const [key, entry] of map) {
60
+ if (entry.used < lruUsed) {
61
+ lruUsed = entry.used;
62
+ lruKey = key;
63
+ }
64
+ }
65
+ return lruKey;
66
+ }
@@ -0,0 +1,31 @@
1
+ # RouteCache
2
+
3
+ A keep-alive page cache. Drop it into a layout around its scrolling content region: it reads the current URL from the surrounding [`<Meta>`](/ui/MetaContext) context and keeps a handful of recently-visited pages mounted but *hidden* (using React's [`<Activity>`](https://react.dev/reference/react/Activity)), so navigating back or forward to a page restores its entire DOM and component state — scroll position of every scroll container, open/closed toggles, in-progress searches, form inputs, focus — instead of remounting it fresh at the top.
4
+
5
+ Shelving's [`SidebarLayout`](/ui/SidebarLayout) and [`CenteredLayout`](/ui/CenteredLayout) already wrap their scrollable content column in one for you, so pages rendered inside them keep their state across navigation automatically. Reach for it by hand only when building a custom layout.
6
+
7
+ **Things to know:**
8
+
9
+ - Pages are kept in a least-recently-used map keyed by `path`. Once `maxCached` pages are retained the least-recently-visited one is unmounted (and loses its state). A never-seen or evicted page mounts fresh at the top.
10
+ - Pass `maxCached={0}` (or less) to disable caching entirely — the page renders directly and unmounts as soon as you leave it.
11
+ - `<Activity mode="hidden">` preserves a hidden page's *state* while unmounting its *effects*, so subscriptions, observers (e.g. infinite-scroll), and timers pause and resume cleanly as the page is hidden and shown.
12
+ - Each snapshot is frozen under its own `<Meta>` context, so the same single `children` element resolves a different page per path and a hidden page never re-renders for someone else's URL.
13
+ - For per-page **scroll** to be preserved the cache has to wrap the scroll container itself — which is why it lives in the layout (around the scrollable column) rather than below it inside the router. Surrounding chrome (sidebar, drawer state) stays outside the cache, so it is neither duplicated nor remounted on navigation.
14
+
15
+ ## Usage
16
+
17
+ ```tsx
18
+ import { RouteCache } from "shelving/ui";
19
+
20
+ <SidebarLayout sidebar={<Menu/>}>
21
+ <RouteCache>
22
+ <Router routes={routes}/>
23
+ </RouteCache>
24
+ </SidebarLayout>
25
+ ```
26
+
27
+ ## See also
28
+
29
+ - [`SidebarLayout`](/ui/SidebarLayout) / [`CenteredLayout`](/ui/CenteredLayout) — wrap their scrollable content column in a `<RouteCache>` for you
30
+ - [`Router`](/ui/Router) — matches a URL to a route; render it inside a `<RouteCache>` to keep pages alive
31
+ - [`Navigation`](/ui/Navigation) — drives the URL changes that move between cached pages
@@ -0,0 +1,97 @@
1
+ import { Activity, type ReactNode, useRef } from "react";
2
+ import type { AbsolutePath } from "../../util/path.js";
3
+ import { MetaContext, requireMetaURL } from "../misc/MetaContext.js";
4
+
5
+ /** A cached page: the rendered node plus a monotonic `used` tick for least-recently-used eviction. */
6
+ interface CacheEntry {
7
+ node: ReactNode;
8
+ used: number;
9
+ }
10
+
11
+ /**
12
+ * Props for `<RouteCache>` — the `maxCached` size and the content `children` to keep alive per URL.
13
+ *
14
+ * @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCacheProps
15
+ */
16
+ export interface RouteCacheProps {
17
+ /**
18
+ * Number of recently-visited URLs to keep mounted (but hidden) so the entire state of their subtree —
19
+ * scroll position of every scroll container, open/closed toggles, in-progress searches, form inputs,
20
+ * focus — is restored intact when navigating back or forward to them.
21
+ * - Defaults to `10`. Once the limit is reached the least-recently-visited entry is unmounted.
22
+ * - Set to `0` (or less) to disable caching and unmount the subtree as you leave each URL.
23
+ */
24
+ readonly maxCached?: number | undefined;
25
+ /** The content to render for the current URL and keep alive (hidden) for recently-visited URLs. */
26
+ readonly children: ReactNode;
27
+ }
28
+
29
+ /**
30
+ * Keep-alive page cache keyed by the current URL — drop it into a layout around its scrolling content region.
31
+ *
32
+ * - Reads the live URL from the surrounding `<Meta>` context and keeps up to `maxCached` recently-visited
33
+ * pages mounted but hidden (via React's `<Activity>`), so navigating back/forward to a page restores its
34
+ * entire DOM and component state — scroll position, toggles, searches, inputs, focus — untouched.
35
+ * - Pages are kept in a least-recently-used map keyed by `path`; the oldest is unmounted past the limit.
36
+ * - Each snapshot is frozen under its own `<MetaContext>`, so the same single `children` element resolves a
37
+ * different page per path and a hidden page never re-renders for someone else's URL.
38
+ * - `<Activity mode="hidden">` preserves a hidden page's state while unmounting its effects, so its
39
+ * subscriptions/observers/timers pause and resume cleanly as it is hidden and shown.
40
+ * - Because it wraps the scroll container itself (rather than sitting below it inside the router), the
41
+ * scroll position of every cached page is preserved — surrounding chrome (sidebar, drawer state) stays
42
+ * outside the cache, so it is neither duplicated nor remounted on navigation.
43
+ * - When `maxCached <= 0` the page is rendered directly with no caching (it unmounts as you leave it).
44
+ *
45
+ * @kind component
46
+ * @param maxCached Number of recently-visited URLs to keep alive (defaults to `10`; `0` disables caching).
47
+ * @param children The content region to render and cache (typically a layout's scrollable column).
48
+ * @returns The visible current page plus any cached pages kept alive but hidden.
49
+ * @example <SidebarLayout sidebar={<Menu />}><RouteCache><Router … /></RouteCache></SidebarLayout>
50
+ * @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCache
51
+ */
52
+ export function RouteCache({ maxCached = 10, children }: RouteCacheProps): ReactNode {
53
+ // Read the live URL from context; each navigation re-renders this with the new path.
54
+ const meta = requireMetaURL();
55
+ const mapRef = useRef<Map<AbsolutePath, CacheEntry>>(undefined);
56
+ const usedRef = useRef(0);
57
+
58
+ // Snapshot the children under the live URL (frozen) so each kept-alive copy keeps rendering for the URL
59
+ // it was captured at — the same `children` element resolves a different page per path.
60
+ const node = <MetaContext value={meta}>{children}</MetaContext>;
61
+
62
+ // Caching disabled — render the page directly so it unmounts as soon as you leave it.
63
+ if (maxCached <= 0) return node;
64
+
65
+ // Insert or refresh the current page, then evict the least-recently-used pages beyond the limit.
66
+ const { path } = meta;
67
+ const map = (mapRef.current ??= new Map());
68
+ const used = ++usedRef.current;
69
+ const entry = map.get(path);
70
+ if (entry) {
71
+ entry.node = node;
72
+ entry.used = used;
73
+ } else {
74
+ map.set(path, { node, used });
75
+ }
76
+ while (map.size > maxCached) map.delete(_findLeastRecentlyUsed(map));
77
+
78
+ // Render every cached page; only the current `path` is visible, the rest are kept alive but hidden.
79
+ return Array.from(map, ([key, cached]) => (
80
+ <Activity key={key} mode={key === path ? "visible" : "hidden"}>
81
+ {cached.node}
82
+ </Activity>
83
+ ));
84
+ }
85
+
86
+ /** Find the key of the least-recently-used (lowest `used` tick) entry in the cache map. */
87
+ function _findLeastRecentlyUsed(map: Map<AbsolutePath, CacheEntry>): AbsolutePath {
88
+ let lruKey!: AbsolutePath;
89
+ let lruUsed = Number.POSITIVE_INFINITY;
90
+ for (const [key, entry] of map) {
91
+ if (entry.used < lruUsed) {
92
+ lruUsed = entry.used;
93
+ lruKey = key;
94
+ }
95
+ }
96
+ return lruKey;
97
+ }
@@ -8,6 +8,7 @@ A pure URL matcher: it reads the current URL from the surrounding `<Meta>` conte
8
8
  - `<Router>` accepts `PossibleMeta` props (`url`, `base`, etc.) to override the surrounding context — this is how nested routers scope themselves.
9
9
  - With a `base` set, the path used for matching is the URL after `matchURLPrefix` strips the base prefix; URLs outside the base render as `null`.
10
10
  - Pass `fallback` to control no-match behaviour. An explicit `null` renders nothing; leaving it `undefined` throws a `NotFoundError`.
11
+ - `cache` (default `10`) keeps recently-visited pages mounted but hidden so back/forward navigation restores their state — see [Keeping page state](#keeping-page-state).
11
12
 
12
13
  ## Usage
13
14
 
@@ -119,6 +120,30 @@ const SIDEBARRED_ROUTES = {
119
120
  }}/>
120
121
  ```
121
122
 
123
+ ### Keeping page state
124
+
125
+ By default `<Router>` unmounts a page when you navigate away and mounts a fresh one when you return — so scroll position, open/closed toggles, in-progress searches, form inputs, and focus are all lost, and you land back at the top.
126
+
127
+ The `cache` prop keeps recently-visited pages mounted but hidden (using React's [`<Activity>`](https://react.dev/reference/react/Activity)), so navigating back or forward to a page restores its entire DOM and component state untouched — no per-feature scroll-capturing or state serialisation required.
128
+
129
+ ```tsx
130
+ // Keep the last 10 visited pages alive (the default).
131
+ <Router routes={ROUTES}/>
132
+
133
+ // Keep more pages, at the cost of more retained DOM/memory.
134
+ <Router routes={ROUTES} cache={25}/>
135
+
136
+ // Opt out — unmount each page as you leave it (original behaviour).
137
+ <Router routes={ROUTES} cache={0}/>
138
+ ```
139
+
140
+ **Things to know:**
141
+
142
+ - Pages are keyed by their matched `path`; once `cache` pages are retained the least-recently-visited one is unmounted (and so loses its state). Visiting a never-seen or evicted page mounts it fresh at the top.
143
+ - Each cached page is wrapped in its own frozen `<Meta>` context, so hidden pages never re-render for the current URL.
144
+ - `<Activity mode="hidden">` unmounts a hidden page's _effects_ while preserving its state, so subscriptions, observers (e.g. infinite-scroll), and timers pause politely and resume when the page is shown again.
145
+ - For per-page scroll to be preserved, each page must own its scroll container (the scrollable element must live _inside_ the route, not in a shared layout wrapper that all routes render into).
146
+
122
147
  ### SSR / static rendering
123
148
 
124
149
  `<Router>` re-renders when context changes and needs no client. For static rendering set `url` and `root` on the outer wrapper and skip `<Navigation>`:
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { MetaContext } from "../misc/MetaContext.js";
4
+ import { createMeta } from "../util/meta.js";
5
+ import { Router } from "./Router.js";
6
+
7
+ const ROUTES = {
8
+ "/": () => <main>Home</main>,
9
+ "/about": () => <main>About</main>,
10
+ } as const;
11
+
12
+ function render(url: string) {
13
+ return renderToStaticMarkup(
14
+ <MetaContext value={createMeta({ root: "http://x.com/", url })}>
15
+ <Router routes={ROUTES} />
16
+ </MetaContext>,
17
+ );
18
+ }
19
+
20
+ describe("Router", () => {
21
+ test("renders the matched route", () => {
22
+ expect(render("./about")).toContain("About");
23
+ });
24
+
25
+ test("throws when no route matches and no fallback is given", () => {
26
+ expect(() => render("./missing")).toThrow();
27
+ });
28
+ });
@@ -1,5 +1,6 @@
1
1
  export * from "./Navigation.js";
2
2
  export * from "./NavigationContext.js";
3
3
  export * from "./NavigationStore.js";
4
+ export * from "./RouteCache.js";
4
5
  export * from "./Router.js";
5
6
  export * from "./Routes.js";
@@ -1,5 +1,6 @@
1
1
  export * from "./Navigation.js";
2
2
  export * from "./NavigationContext.js";
3
3
  export * from "./NavigationStore.js";
4
+ export * from "./RouteCache.js";
4
5
  export * from "./Router.js";
5
6
  export * from "./Routes.js";
@@ -1,5 +1,6 @@
1
1
  export * from "./Navigation.js";
2
2
  export * from "./NavigationContext.js";
3
3
  export * from "./NavigationStore.js";
4
+ export * from "./RouteCache.js";
4
5
  export * from "./Router.js";
5
6
  export * from "./Routes.js";