@mindees/router 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,31 @@
1
+ # License
2
+
3
+ MindeesNative is dual-licensed under either of:
4
+
5
+ - **Apache License, Version 2.0** ([LICENSE-APACHE](./LICENSE-APACHE) or
6
+ <https://www.apache.org/licenses/LICENSE-2.0>)
7
+ - **MIT license** ([LICENSE-MIT](./LICENSE-MIT) or
8
+ <https://opensource.org/licenses/MIT>)
9
+
10
+ at your option.
11
+
12
+ This `MIT OR Apache-2.0` dual-license is the same model used by the Rust
13
+ ecosystem and many modern open-source projects. It gives downstream users
14
+ maximum flexibility: the MIT option is short and permissive, while the Apache
15
+ option adds an explicit patent grant.
16
+
17
+ ## SPDX identifier
18
+
19
+ ```
20
+ SPDX-License-Identifier: MIT OR Apache-2.0
21
+ ```
22
+
23
+ ## Contribution
24
+
25
+ Unless you explicitly state otherwise, any contribution intentionally
26
+ submitted for inclusion in the work by you, as defined in the Apache-2.0
27
+ license, shall be dual-licensed as above, without any additional terms or
28
+ conditions.
29
+
30
+ Contributions are accepted under the **Developer Certificate of Origin (DCO)**.
31
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for details on signing off your commits.
package/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # @mindees/router
2
+
3
+ **Quantum** โ€” the typed, signals-native router for MindeesNative. **Codegen-free
4
+ typed path params**, **typed + runtime-validated search params** (bring any
5
+ Zod / Valibot / ArkType schema), and **fine-grained reactive route state** with
6
+ selector-based re-render isolation. A modern, type-safe alternative to Expo
7
+ Router and React Router for TypeScript cross-platform apps.
8
+
9
+ > **Status: ๐Ÿงช Experimental (Phase 6 โ€” Router I + Phase 7 โ€” Router II).** The
10
+ > typed routing core (typed params, Standard Schema search validation, the
11
+ > signals-native router, typed + relative navigation, dynamic reconfiguration,
12
+ > memory + browser history), **render integration** (`createRouterView` โ€”
13
+ > fine-grained, layout-preserving nested rendering; typed `createLink`), and
14
+ > **data/guards/transitions** (SWR loaders + `preload`/`invalidate`, navigation
15
+ > guards, web view transitions) are implemented and tested. The global typed
16
+ > route registry and file-based route scanning are a later phase โ€” see
17
+ > [ROADMAP](../../ROADMAP.md).
18
+
19
+ ## Why Quantum?
20
+
21
+ | | **Quantum** | Expo Router v6 | React Router v7 |
22
+ | --- | --- | --- | --- |
23
+ | Typed path params | โœ… template-literal types, **no codegen** | โš ๏ธ codegen; required params typed as *optional* | โœ… codegen (`.react-router/types`) |
24
+ | Typed **search** params | โœ… via Standard Schema | โŒ not typed | โŒ raw `URLSearchParams` |
25
+ | Validation lock-in | โœ… none โ€” any Standard Schema (Zod/Valibot/ArkType) | โ€” | โ€” |
26
+ | Reactivity | โœ… fine-grained signals; select a slice, re-run on *that* change | โš ๏ธ global-vs-local re-render footgun | React renders |
27
+ | Build/dev-server required for types | โŒ pure TypeScript inference | โœ… dev server | โœ… typegen step |
28
+
29
+ Relative navigation (`navigate('./edit')`, `'../'`) and `#fragment` targets are
30
+ supported via href strings (resolved against the current location); the typed
31
+ structured form (`navigate({ to, params })`) builds absolute paths.
32
+
33
+ ## Quick start
34
+
35
+ ```ts
36
+ import { createRouter, createMemoryHistory } from '@mindees/router'
37
+ import { z } from 'zod' // or valibot, arktype โ€” any Standard Schema validator
38
+
39
+ const router = createRouter({
40
+ routes: [
41
+ { path: '/' },
42
+ { path: '/posts/:postId' },
43
+ { path: '/search', searchSchema: z.object({ q: z.string(), page: z.coerce.number() }) },
44
+ { path: '/files/:rest*' }, // catch-all
45
+ ],
46
+ history: createMemoryHistory({ initialEntries: ['/'] }), // or createBrowserHistory()
47
+ })
48
+
49
+ // Typed navigation โ€” params are required iff the pattern has them (inferred, no codegen):
50
+ router.navigate({ to: '/posts/:postId', params: { postId: '42' }, search: { ref: 'home' } })
51
+ // router.navigate({ to: '/posts/:postId' }) // โœ— type error: params required
52
+ // router.navigate({ to: '/', params: { x: '1' } }) // โœ— type error: no params allowed
53
+ router.navigate('./edit') // โœ“ relative navigation (href string)
54
+
55
+ // Re-render isolation โ€” re-runs ONLY when the selected slice changes:
56
+ const postId = router.select((s) => s.params.postId)
57
+ const matched = router.match() // { route, params, search, searchRaw, issues? }
58
+ ```
59
+
60
+ ## Typed, validated search params
61
+
62
+ Search params are first-class typed state. Reads are validated against the route's
63
+ schema and fully typed โ€” the capability Expo Router and React Router lack:
64
+
65
+ ```ts
66
+ import { validateSearch } from '@mindees/router'
67
+
68
+ const schema = z.object({ page: z.coerce.number(), q: z.string() })
69
+ const search = validateSearch(schema, { page: '2', q: 'router' })
70
+ // ^? { page: number; q: string }
71
+ ```
72
+
73
+ Invalid params never crash navigation โ€” the match exposes `issues` (Standard
74
+ Schema diagnostics) and falls back to the raw parse. Repeated keys
75
+ (`?tag=a&tag=b`) become arrays; coercion is delegated to your schema.
76
+
77
+ ## Render integration โ€” nested routes that actually render
78
+
79
+ `createRouterView` renders the matched route **chain**; a layout renders its
80
+ `children` (the outlet). Navigation is **fine-grained and layout-preserving** โ€”
81
+ switching between sibling pages keeps the parent layout (and its state) mounted,
82
+ and a same-route param change (`/posts/1` โ†’ `/posts/2`) re-mounts *nothing*; only
83
+ the bindings that read the changed param update.
84
+
85
+ ```ts
86
+ import { createElement } from '@mindees/core'
87
+ import { render, createDomBackend } from '@mindees/renderer'
88
+ import { createRouter, createRouterView, createLink, type RouteComponentProps } from '@mindees/router'
89
+
90
+ function DashLayout(props: RouteComponentProps) {
91
+ return createElement('view', null,
92
+ createElement('nav', null, 'sidebar'),
93
+ props.children, // โ† the outlet: the matched child route renders here
94
+ )
95
+ }
96
+ const Settings = () => createElement('text', null, 'settings')
97
+
98
+ const router = createRouter({
99
+ routes: [{ path: '/dash', component: DashLayout, children: [
100
+ { path: '', component: () => createElement('text', null, 'home') },
101
+ { path: 'settings', component: Settings },
102
+ ] }],
103
+ })
104
+
105
+ const Link = createLink(router)
106
+ render(createRouterView(router, { notFound: () => createElement('text', null, '404') }),
107
+ createDomBackend(), document.getElementById('app')!)
108
+
109
+ // Typed link โ€” params required iff the pattern has them:
110
+ Link({ to: '/dash/settings', children: 'Settings' })
111
+ ```
112
+
113
+ Components built on `@mindees/core`'s `createElement` โ€” the renderer just renders
114
+ the tree, so `@mindees/router` keeps **zero renderer runtime dependency**.
115
+
116
+ ## Codegen-free typed params
117
+
118
+ ```ts
119
+ import type { PathParams } from '@mindees/router'
120
+
121
+ type A = PathParams<'/posts/:postId'> // { postId: string }
122
+ type B = PathParams<'/u/:userId/p/:postId'> // { userId: string; postId: string }
123
+ type C = PathParams<'/files/:rest*'> // { rest: string }
124
+ type D = PathParams<'/about'> // {}
125
+ ```
126
+
127
+ No generated `.d.ts`, no dev server, no stale type files โ€” just TypeScript.
128
+
129
+ ## Data, guards & transitions
130
+
131
+ ```ts
132
+ const router = createRouter({
133
+ routes: [
134
+ {
135
+ path: '/posts/:postId',
136
+ // SWR loader: cached, revalidated, abortable; result flows to props.data()
137
+ loader: async ({ params, signal }) => fetch(`/api/posts/${params.postId}`, { signal }).then((r) => r.json()),
138
+ loaderDeps: ({ search }) => search.page, // re-load when ?page changes
139
+ staleTime: 30_000,
140
+ },
141
+ ],
142
+ // Guard: cancel (false) or redirect (string); idempotent nav is automatic
143
+ beforeNavigate: (to) => (isLoggedIn() ? undefined : '/login'),
144
+ viewTransitions: true, // wrap navigations in document.startViewTransition (web)
145
+ })
146
+
147
+ router.preload('/posts/42') // intent prefetch โ€” runs the loader, no navigation
148
+ router.invalidate() // re-run the current chain's loaders
149
+
150
+ function Post(props: RouteComponentProps) {
151
+ const d = props.data() // reactive: { status, data?, error? } โ€” updates in place
152
+ // ...
153
+ }
154
+ ```
155
+
156
+ Loaders run for every route in the matched chain, cache by route + params +
157
+ `loaderDeps` (stale-while-revalidate), abort superseded loads via `AbortSignal`,
158
+ and surface results reactively โ€” a resolved load updates the component's data
159
+ binding **without re-mounting** it. View transitions are feature-detected and a
160
+ transparent no-op outside a DOM (SSR / native).
161
+
162
+ ## API surface
163
+
164
+ - **Render (Router II)** โ€” `createRouterView`, `createLink`, `RouterViewOptions`,
165
+ `LinkProps`, `LinkComponent`, `LinkOptions`, `RouteComponentProps`.
166
+ - **Data / guards / transitions** โ€” route `loader` / `loaderDeps` / `staleTime`,
167
+ `router.loaderData` / `invalidate` / `preload`, `LoaderContext`, `LoaderData`,
168
+ `LoaderFn`, `LoaderStatus`; `BeforeNavigate`, `NavigateOptions` (`force`,
169
+ `viewTransition`), `CreateRouterOptions` (`beforeNavigate`, `viewTransitions`).
170
+ - **Router** โ€” `createRouter`, `Router`, `RouteRecord` (with `children` for
171
+ nesting), `RouteMatch`, `RouterState` (with `matches` chain), `NavTarget`,
172
+ `NavigateOptions`, `resolvePath`.
173
+ - **Patterns** โ€” `matchPattern`, `buildPath`, `parsePattern`,
174
+ `compareSpecificity`, `PathParams`, `HasPathParams`.
175
+ - **Search** โ€” `parseQuery`, `stringifyQuery`, `validateSearch`,
176
+ `safeValidateSearch`, `QueryValue`, `ValidationResult`.
177
+ - **History** โ€” `createMemoryHistory`, `createBrowserHistory`, `parseHref`,
178
+ `createHref`, `RouterHistory`, `RouterLocation`.
179
+ - **Validation** โ€” `StandardSchemaV1` (vendored, zero runtime dependency).
180
+ - **Errors** โ€” `RouterError`, `RouterErrorCode`.
181
+
182
+ ## Design
183
+
184
+ Route state (location, params, search, matched route) is modeled as the
185
+ fine-grained **signal graph** from [`@mindees/core`](../core) โ€” no monolithic
186
+ state object, no over-subscription. `select()` applies the same
187
+ selector-isolation technique as core's Phase 2 `createProvider` (a `computed`
188
+ memo over an `equals:false` source). History is an **injected capability**, so the
189
+ whole router is deterministically testable headless. Validation rides on
190
+ [**Standard Schema**](https://standardschema.dev) โ€” vendored as types only, so
191
+ Quantum adds **zero runtime dependencies** while accepting any compliant
192
+ validator. See [ADR-0003](../../docs/adr/0003-router-architecture.md).
193
+
194
+ ## License
195
+
196
+ Dual-licensed under **MIT OR Apache-2.0**.
@@ -0,0 +1,50 @@
1
+ import { NavTarget, Router } from "./router.js";
2
+ import { Component, MindeesElement, MindeesNode } from "@mindees/core";
3
+
4
+ //#region src/components.d.ts
5
+ /** Options for {@link createRouterView}. */
6
+ interface RouterViewOptions {
7
+ /** Rendered when no route matches the current location. */
8
+ notFound?: Component;
9
+ }
10
+ /**
11
+ * Create the router's view: a node that renders the matched route **chain**
12
+ * top-down, nesting each child into its parent's `children` (the outlet). Render
13
+ * it with the Helix renderer (`render(createRouterView(router), backend, root)`);
14
+ * it re-renders fine-grainedly as navigation changes the matched routes.
15
+ *
16
+ * @example
17
+ * const view = createRouterView(router, { notFound: NotFound })
18
+ * render(view, backend, root)
19
+ */
20
+ declare function createRouterView(router: Router, options?: RouterViewOptions): MindeesNode;
21
+ /** Extra (non-target) props accepted by a {@link LinkComponent}. */
22
+ interface LinkOptions {
23
+ /** Replace the current history entry instead of pushing. */
24
+ replace?: boolean;
25
+ /** Host tag to render. Defaults to `'a'` (web). Use e.g. `'view'` on native. */
26
+ as?: string;
27
+ /** Class applied (in addition to `class`) when the link's path is the current pathname. */
28
+ activeClass?: string;
29
+ /** Static class. */
30
+ class?: string;
31
+ /** Link content. */
32
+ children?: MindeesNode;
33
+ }
34
+ /** Props for a typed link: a {@link NavTarget} plus {@link LinkOptions}. */
35
+ type LinkProps<P extends string> = NavTarget<P> & LinkOptions;
36
+ /** A typed link component โ€” params are required iff the pattern has them. */
37
+ type LinkComponent = <P extends string>(props: LinkProps<P>) => MindeesElement;
38
+ /**
39
+ * Create a typed `Link` bound to `router`. Calling it returns an element that
40
+ * navigates on activation (default tag `'a'` with `href` + an `onClick` that
41
+ * honors modifier/middle clicks). Params are required iff the pattern has them.
42
+ *
43
+ * @example
44
+ * const Link = createLink(router)
45
+ * Link({ to: '/posts/:postId', params: { postId: '42' }, children: 'Open' })
46
+ */
47
+ declare function createLink(router: Router): LinkComponent;
48
+ //#endregion
49
+ export { LinkComponent, LinkOptions, LinkProps, RouterViewOptions, createLink, createRouterView };
50
+ //# sourceMappingURL=components.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"components.d.ts","names":[],"sources":["../src/components.ts"],"mappings":";;;;;UA6BiB,iBAAA;EAegB;EAb/B,QAAA,GAAW,SAAS;AAAA;;;AAawE;AA2C9F;;;;;;;iBA3CgB,gBAAA,CAAiB,MAAA,EAAQ,MAAA,EAAQ,OAAA,GAAS,iBAAA,GAAyB,WAAA;;UA2ClE,WAAA;EAUO;EARtB,OAAA;EAYU;EAVV,EAAA;EAUmB;EARnB,WAAA;EAQwC;EANxC,KAAA;EAMkE;EAJlE,QAAA,GAAW,WAAW;AAAA;;KAIZ,SAAA,qBAA8B,SAAA,CAAU,CAAA,IAAK,WAAA;;KAG7C,aAAA,sBAAmC,KAAA,EAAO,SAAA,CAAU,CAAA,MAAO,cAAA;AAHH;AAGpE;;;;;;;;AAHoE,iBA2DpD,UAAA,CAAW,MAAA,EAAQ,MAAA,GAAS,aAAa"}
@@ -0,0 +1,94 @@
1
+ import { buildPath } from "./pattern.js";
2
+ import { stringifyQuery } from "./search.js";
3
+ import { createElement } from "@mindees/core";
4
+ //#region src/components.ts
5
+ /**
6
+ * Render integration โ€” `createRouterView` (renders the matched route chain) and
7
+ * `createLink` (typed navigation links). Built on `@mindees/core`'s
8
+ * `createElement` + signals; the renderer turns the returned function nodes into
9
+ * fine-grained reactive regions. See ADR-0004.
10
+ *
11
+ * Nesting uses **explicit composition** (no ambient context): each matched route
12
+ * component receives the next route in the chain as `children` (the outlet), and
13
+ * the router exposes the chain as the reactive `matches` array. Each depth is a
14
+ * reactive region keyed on that depth's route identity, so navigating a leaf
15
+ * re-mounts only the leaf โ€” parent layouts (and their state) are preserved.
16
+ *
17
+ * @module
18
+ */
19
+ /** Shared idle loader state for routes without a loader. */
20
+ const IDLE_LOADER_DATA = Object.freeze({ status: "idle" });
21
+ /**
22
+ * Create the router's view: a node that renders the matched route **chain**
23
+ * top-down, nesting each child into its parent's `children` (the outlet). Render
24
+ * it with the Helix renderer (`render(createRouterView(router), backend, root)`);
25
+ * it re-renders fine-grainedly as navigation changes the matched routes.
26
+ *
27
+ * @example
28
+ * const view = createRouterView(router, { notFound: NotFound })
29
+ * render(view, backend, root)
30
+ */
31
+ function createRouterView(router, options = {}) {
32
+ const outletAt = (depth) => () => {
33
+ const route = router.select((s) => s.matches[depth]?.route ?? null)();
34
+ if (route === null) return depth === 0 && options.notFound ? createElement(options.notFound, {}) : null;
35
+ const child = outletAt(depth + 1);
36
+ const component = route.component;
37
+ if (component === void 0) return child;
38
+ return createElement(component, {
39
+ router,
40
+ params: router.params,
41
+ search: router.search,
42
+ data: () => {
43
+ const match = router.matches()[depth];
44
+ return match ? router.loaderData(match) : IDLE_LOADER_DATA;
45
+ },
46
+ children: child
47
+ });
48
+ };
49
+ return outletAt(0);
50
+ }
51
+ /** Should a click be left for the browser (modifier/middle-click, already handled)? */
52
+ function isModifiedClick(event) {
53
+ return event.defaultPrevented === true || event.button !== void 0 && event.button !== 0 || event.metaKey === true || event.ctrlKey === true || event.shiftKey === true || event.altKey === true;
54
+ }
55
+ /** Assemble an href (path + query + hash) from a link target. */
56
+ function hrefFor(props) {
57
+ const path = buildPath(props.to, props.params ?? {});
58
+ const query = props.search ? stringifyQuery(props.search) : "";
59
+ let hash = props.hash ?? "";
60
+ if (hash.length > 0 && !hash.startsWith("#")) hash = `#${hash}`;
61
+ return `${path}${query ? `?${query}` : ""}${hash}`;
62
+ }
63
+ /**
64
+ * Create a typed `Link` bound to `router`. Calling it returns an element that
65
+ * navigates on activation (default tag `'a'` with `href` + an `onClick` that
66
+ * honors modifier/middle clicks). Params are required iff the pattern has them.
67
+ *
68
+ * @example
69
+ * const Link = createLink(router)
70
+ * Link({ to: '/posts/:postId', params: { postId: '42' }, children: 'Open' })
71
+ */
72
+ function createLink(router) {
73
+ const Link = (props) => {
74
+ const tag = props.as ?? "a";
75
+ const href = hrefFor(props);
76
+ const onClick = (event) => {
77
+ if (event && isModifiedClick(event)) return;
78
+ event?.preventDefault?.();
79
+ router.navigate(href, { replace: props.replace === true });
80
+ };
81
+ const elementProps = { onClick };
82
+ if (tag === "a") elementProps.href = href;
83
+ if (props.activeClass !== void 0) {
84
+ const here = buildPath(props.to, props.params ?? {});
85
+ elementProps.class = () => [props.class, router.location().pathname === here ? props.activeClass : void 0].filter((c) => typeof c === "string" && c.length > 0).join(" ");
86
+ } else if (props.class !== void 0) elementProps.class = props.class;
87
+ return createElement(tag, elementProps, props.children);
88
+ };
89
+ return Link;
90
+ }
91
+ //#endregion
92
+ export { createLink, createRouterView };
93
+
94
+ //# sourceMappingURL=components.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"components.js","names":[],"sources":["../src/components.ts"],"sourcesContent":["/**\n * Render integration โ€” `createRouterView` (renders the matched route chain) and\n * `createLink` (typed navigation links). Built on `@mindees/core`'s\n * `createElement` + signals; the renderer turns the returned function nodes into\n * fine-grained reactive regions. See ADR-0004.\n *\n * Nesting uses **explicit composition** (no ambient context): each matched route\n * component receives the next route in the chain as `children` (the outlet), and\n * the router exposes the chain as the reactive `matches` array. Each depth is a\n * reactive region keyed on that depth's route identity, so navigating a leaf\n * re-mounts only the leaf โ€” parent layouts (and their state) are preserved.\n *\n * @module\n */\n\nimport { type Component, createElement, type MindeesElement, type MindeesNode } from '@mindees/core'\nimport type { LoaderData } from './data'\nimport { buildPath } from './pattern'\nimport type { NavTarget, Router } from './router'\nimport { type QueryValue, stringifyQuery } from './search'\n\n/** Shared idle loader state for routes without a loader. */\nconst IDLE_LOADER_DATA: LoaderData = Object.freeze({ status: 'idle' })\n\n// ---------------------------------------------------------------------------\n// RouterView โ€” render the matched route chain\n// ---------------------------------------------------------------------------\n\n/** Options for {@link createRouterView}. */\nexport interface RouterViewOptions {\n /** Rendered when no route matches the current location. */\n notFound?: Component\n}\n\n/**\n * Create the router's view: a node that renders the matched route **chain**\n * top-down, nesting each child into its parent's `children` (the outlet). Render\n * it with the Helix renderer (`render(createRouterView(router), backend, root)`);\n * it re-renders fine-grainedly as navigation changes the matched routes.\n *\n * @example\n * const view = createRouterView(router, { notFound: NotFound })\n * render(view, backend, root)\n */\nexport function createRouterView(router: Router, options: RouterViewOptions = {}): MindeesNode {\n // Each depth is its own reactive region (a function node). A region depends\n // only on a memo of its depth's matched route identity (`Object.is`), so a\n // navigation that doesn't change a given depth's route does NOT re-run that\n // region โ€” parent layouts (and their state) stay mounted.\n //\n // The route memo is created FRESH on every region run, on purpose: a memo\n // cached across runs would be owned by โ€” and disposed with โ€” this region's\n // effect when it re-runs, leaving a dead source (the region would react once\n // and then freeze). A fresh memo each run is always live; the `Object.is`\n // equality on the memo is what still gates re-runs (the memo only re-runs the\n // region when the route at this depth actually changes).\n const outletAt =\n (depth: number): (() => MindeesNode) =>\n (): MindeesNode => {\n const route = router.select((s) => s.matches[depth]?.route ?? null)()\n if (route === null) {\n return depth === 0 && options.notFound ? createElement(options.notFound, {}) : null\n }\n const child = outletAt(depth + 1)\n const component = route.component\n // A component-less (layout/pathless) route passes its child through.\n if (component === undefined) return child\n return createElement(component, {\n router,\n params: router.params,\n search: router.search,\n data: () => {\n const match = router.matches()[depth]\n return match ? router.loaderData(match) : IDLE_LOADER_DATA\n },\n children: child,\n })\n }\n\n return outletAt(0)\n}\n\n// ---------------------------------------------------------------------------\n// Link โ€” typed navigation links\n// ---------------------------------------------------------------------------\n\n/** Extra (non-target) props accepted by a {@link LinkComponent}. */\nexport interface LinkOptions {\n /** Replace the current history entry instead of pushing. */\n replace?: boolean\n /** Host tag to render. Defaults to `'a'` (web). Use e.g. `'view'` on native. */\n as?: string\n /** Class applied (in addition to `class`) when the link's path is the current pathname. */\n activeClass?: string\n /** Static class. */\n class?: string\n /** Link content. */\n children?: MindeesNode\n}\n\n/** Props for a typed link: a {@link NavTarget} plus {@link LinkOptions}. */\nexport type LinkProps<P extends string> = NavTarget<P> & LinkOptions\n\n/** A typed link component โ€” params are required iff the pattern has them. */\nexport type LinkComponent = <P extends string>(props: LinkProps<P>) => MindeesElement\n\n/** The broad runtime shape the Link impl accepts (typed surface is {@link LinkProps}). */\ninterface LinkInput {\n to: string\n params?: Record<string, string | number>\n search?: Record<string, QueryValue>\n hash?: string\n replace?: boolean\n as?: string\n activeClass?: string\n class?: string\n children?: MindeesNode\n}\n\n/** A minimal click-event shape (DOM `MouseEvent` satisfies it; tests can omit it). */\ninterface ClickEventLike {\n preventDefault?: () => void\n defaultPrevented?: boolean\n button?: number\n metaKey?: boolean\n ctrlKey?: boolean\n shiftKey?: boolean\n altKey?: boolean\n}\n\n/** Should a click be left for the browser (modifier/middle-click, already handled)? */\nfunction isModifiedClick(event: ClickEventLike): boolean {\n return (\n event.defaultPrevented === true ||\n (event.button !== undefined && event.button !== 0) ||\n event.metaKey === true ||\n event.ctrlKey === true ||\n event.shiftKey === true ||\n event.altKey === true\n )\n}\n\n/** Assemble an href (path + query + hash) from a link target. */\nfunction hrefFor(props: LinkInput): string {\n const path = buildPath(props.to, props.params ?? {})\n const query = props.search ? stringifyQuery(props.search) : ''\n let hash = props.hash ?? ''\n if (hash.length > 0 && !hash.startsWith('#')) hash = `#${hash}`\n return `${path}${query ? `?${query}` : ''}${hash}`\n}\n\n/**\n * Create a typed `Link` bound to `router`. Calling it returns an element that\n * navigates on activation (default tag `'a'` with `href` + an `onClick` that\n * honors modifier/middle clicks). Params are required iff the pattern has them.\n *\n * @example\n * const Link = createLink(router)\n * Link({ to: '/posts/:postId', params: { postId: '42' }, children: 'Open' })\n */\nexport function createLink(router: Router): LinkComponent {\n const Link = (props: LinkInput): MindeesElement => {\n const tag = props.as ?? 'a'\n const href = hrefFor(props)\n const onClick = (event?: ClickEventLike): void => {\n if (event && isModifiedClick(event)) return\n event?.preventDefault?.()\n // `href` is an absolute path; navigate by string (resolveHref no-ops on absolutes).\n router.navigate(href, { replace: props.replace === true })\n }\n\n const elementProps: Record<string, unknown> = { onClick }\n if (tag === 'a') elementProps.href = href\n\n if (props.activeClass !== undefined) {\n const here = buildPath(props.to, props.params ?? {})\n elementProps.class = (): string =>\n [props.class, router.location().pathname === here ? props.activeClass : undefined]\n .filter((c): c is string => typeof c === 'string' && c.length > 0)\n .join(' ')\n } else if (props.class !== undefined) {\n elementProps.class = props.class\n }\n\n return createElement(tag, elementProps, props.children)\n }\n return Link as LinkComponent\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAsBA,MAAM,mBAA+B,OAAO,OAAO,EAAE,QAAQ,OAAO,CAAC;;;;;;;;;;;AAsBrE,SAAgB,iBAAiB,QAAgB,UAA6B,CAAC,GAAgB;CAY7F,MAAM,YACH,gBACkB;EACjB,MAAM,QAAQ,OAAO,QAAQ,MAAM,EAAE,QAAQ,QAAQ,SAAS,IAAI,EAAE;EACpE,IAAI,UAAU,MACZ,OAAO,UAAU,KAAK,QAAQ,WAAW,cAAc,QAAQ,UAAU,CAAC,CAAC,IAAI;EAEjF,MAAM,QAAQ,SAAS,QAAQ,CAAC;EAChC,MAAM,YAAY,MAAM;EAExB,IAAI,cAAc,KAAA,GAAW,OAAO;EACpC,OAAO,cAAc,WAAW;GAC9B;GACA,QAAQ,OAAO;GACf,QAAQ,OAAO;GACf,YAAY;IACV,MAAM,QAAQ,OAAO,QAAQ,EAAE;IAC/B,OAAO,QAAQ,OAAO,WAAW,KAAK,IAAI;GAC5C;GACA,UAAU;EACZ,CAAC;CACH;CAEF,OAAO,SAAS,CAAC;AACnB;;AAmDA,SAAS,gBAAgB,OAAgC;CACvD,OACE,MAAM,qBAAqB,QAC1B,MAAM,WAAW,KAAA,KAAa,MAAM,WAAW,KAChD,MAAM,YAAY,QAClB,MAAM,YAAY,QAClB,MAAM,aAAa,QACnB,MAAM,WAAW;AAErB;;AAGA,SAAS,QAAQ,OAA0B;CACzC,MAAM,OAAO,UAAU,MAAM,IAAI,MAAM,UAAU,CAAC,CAAC;CACnD,MAAM,QAAQ,MAAM,SAAS,eAAe,MAAM,MAAM,IAAI;CAC5D,IAAI,OAAO,MAAM,QAAQ;CACzB,IAAI,KAAK,SAAS,KAAK,CAAC,KAAK,WAAW,GAAG,GAAG,OAAO,IAAI;CACzD,OAAO,GAAG,OAAO,QAAQ,IAAI,UAAU,KAAK;AAC9C;;;;;;;;;;AAWA,SAAgB,WAAW,QAA+B;CACxD,MAAM,QAAQ,UAAqC;EACjD,MAAM,MAAM,MAAM,MAAM;EACxB,MAAM,OAAO,QAAQ,KAAK;EAC1B,MAAM,WAAW,UAAiC;GAChD,IAAI,SAAS,gBAAgB,KAAK,GAAG;GACrC,OAAO,iBAAiB;GAExB,OAAO,SAAS,MAAM,EAAE,SAAS,MAAM,YAAY,KAAK,CAAC;EAC3D;EAEA,MAAM,eAAwC,EAAE,QAAQ;EACxD,IAAI,QAAQ,KAAK,aAAa,OAAO;EAErC,IAAI,MAAM,gBAAgB,KAAA,GAAW;GACnC,MAAM,OAAO,UAAU,MAAM,IAAI,MAAM,UAAU,CAAC,CAAC;GACnD,aAAa,cACX,CAAC,MAAM,OAAO,OAAO,SAAS,EAAE,aAAa,OAAO,MAAM,cAAc,KAAA,CAAS,EAC9E,QAAQ,MAAmB,OAAO,MAAM,YAAY,EAAE,SAAS,CAAC,EAChE,KAAK,GAAG;EACf,OAAO,IAAI,MAAM,UAAU,KAAA,GACzB,aAAa,QAAQ,MAAM;EAG7B,OAAO,cAAc,KAAK,cAAc,MAAM,QAAQ;CACxD;CACA,OAAO;AACT"}
package/dist/data.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { RouterLocation } from "./history.js";
2
+ //#region src/data.d.ts
3
+ /** Context passed to a route `loader`. */
4
+ interface LoaderContext {
5
+ /** The matched path params. */
6
+ params: Record<string, string>;
7
+ /** The (validated) search params. */
8
+ search: Record<string, unknown>;
9
+ /** The current location. */
10
+ location: RouterLocation;
11
+ /** Aborted when this load is superseded (navigated away, or re-keyed). */
12
+ signal: AbortSignal;
13
+ }
14
+ /** A route data loader. May be sync or async. */
15
+ type LoaderFn = (ctx: LoaderContext) => unknown | Promise<unknown>;
16
+ /**
17
+ * Declares which inputs key a route's loader cache (e.g. specific search params).
18
+ * The returned value keys the SWR cache via `JSON.stringify`, so it must be
19
+ * JSON-serializable (and ideally have stable property order). A non-serializable
20
+ * value degrades to a params-only cache key rather than throwing.
21
+ */
22
+ type LoaderDepsFn = (args: {
23
+ search: Record<string, unknown>;
24
+ }) => unknown;
25
+ /** Loader lifecycle status. */
26
+ type LoaderStatus = 'idle' | 'pending' | 'success' | 'error';
27
+ /** The reactive state of a route's loader. */
28
+ interface LoaderData<T = unknown> {
29
+ /** `idle` when the route has no loader or hasn't started. */
30
+ status: LoaderStatus;
31
+ /** The loaded value (also present while `pending` if a stale value exists). */
32
+ data?: T;
33
+ /** The error, when `status === 'error'`. */
34
+ error?: unknown;
35
+ }
36
+ //#endregion
37
+ export { LoaderContext, LoaderData, LoaderDepsFn, LoaderFn, LoaderStatus };
38
+ //# sourceMappingURL=data.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data.d.ts","names":[],"sources":["../src/data.ts"],"mappings":";;;UAiBiB,aAAA;EAIP;EAFR,MAAA,EAAQ,MAAA;EAIE;EAFV,MAAA,EAAQ,MAAA;EAIA;EAFR,QAAA,EAAU,cAAA;EAES;EAAnB,MAAA,EAAQ,WAAA;AAAA;;KAIE,QAAA,IAAY,GAAA,EAAK,aAAA,eAA4B,OAAO;;;;;AAAA;AAQhE;KAAY,YAAA,IAAgB,IAAA;EAAQ,MAAA,EAAQ,MAAM;AAAA;;KAGtC,YAAA;;UAGK,UAAA;EANoD;EAQnE,MAAA,EAAQ,YAAA;EALc;EAOtB,IAAA,GAAO,CAAC;EAPc;EAStB,KAAA;AAAA"}
package/dist/data.js ADDED
@@ -0,0 +1,151 @@
1
+ //#region src/data.ts
2
+ const IDLE = Object.freeze({ status: "idle" });
3
+ /** Create a loader manager. */
4
+ function createLoaderManager(options) {
5
+ const now = options.now ?? Date.now;
6
+ let cache = /* @__PURE__ */ new WeakMap();
7
+ const inFlight = /* @__PURE__ */ new Map();
8
+ const ids = /* @__PURE__ */ new WeakMap();
9
+ let nextId = 0;
10
+ let disposed = false;
11
+ const MAX_ENTRIES_PER_ROUTE = 64;
12
+ const idOf = (route) => {
13
+ let id = ids.get(route);
14
+ if (id === void 0) {
15
+ id = nextId++;
16
+ ids.set(route, id);
17
+ }
18
+ return id;
19
+ };
20
+ const innerKey = (route, match) => {
21
+ try {
22
+ const deps = route.loaderDeps ? route.loaderDeps({ search: match.search }) : null;
23
+ return JSON.stringify({
24
+ p: match.params,
25
+ d: deps
26
+ });
27
+ } catch {
28
+ return `${JSON.stringify({ p: match.params })}::unserializable-deps`;
29
+ }
30
+ };
31
+ const getEntry = (route, key) => cache.get(route)?.get(key);
32
+ const setEntry = (route, key, entry) => {
33
+ let m = cache.get(route);
34
+ if (!m) {
35
+ m = /* @__PURE__ */ new Map();
36
+ cache.set(route, m);
37
+ }
38
+ m.delete(key);
39
+ m.set(key, entry);
40
+ if (m.size > MAX_ENTRIES_PER_ROUTE) for (const [k, e] of m) {
41
+ if (m.size <= MAX_ENTRIES_PER_ROUTE) break;
42
+ if (e.status !== "pending") m.delete(k);
43
+ }
44
+ };
45
+ const ensure = (match) => {
46
+ if (disposed) return;
47
+ const route = match.route;
48
+ const loader = route.loader;
49
+ if (!loader) return;
50
+ const key = innerKey(route, match);
51
+ const gkey = `${idOf(route)}:${key}`;
52
+ const existing = getEntry(route, key);
53
+ const staleTime = route.staleTime ?? 0;
54
+ if (existing?.status === "success" && now() - existing.loadedAt < staleTime) return;
55
+ if (inFlight.has(gkey)) return;
56
+ const controller = new AbortController();
57
+ inFlight.set(gkey, controller);
58
+ const pendingEntry = {
59
+ status: "pending",
60
+ loadedAt: existing?.loadedAt ?? 0,
61
+ controller
62
+ };
63
+ if (existing?.data !== void 0) pendingEntry.data = existing.data;
64
+ setEntry(route, key, pendingEntry);
65
+ options.onChange();
66
+ const ctx = {
67
+ params: match.params,
68
+ search: match.search,
69
+ location: options.location(),
70
+ signal: controller.signal
71
+ };
72
+ const settle = (settled) => {
73
+ if (inFlight.get(gkey) === controller) inFlight.delete(gkey);
74
+ if (getEntry(route, key) !== pendingEntry) return;
75
+ setEntry(route, key, settled);
76
+ if (!controller.signal.aborted) options.onChange();
77
+ };
78
+ Promise.resolve().then(() => loader(ctx)).then((data) => settle({
79
+ status: "success",
80
+ data,
81
+ loadedAt: now()
82
+ }), (error) => {
83
+ const errored = {
84
+ status: "error",
85
+ error,
86
+ loadedAt: now()
87
+ };
88
+ if (existing?.data !== void 0) errored.data = existing.data;
89
+ settle(errored);
90
+ });
91
+ };
92
+ const currentGlobalKeys = (matches) => {
93
+ const keys = /* @__PURE__ */ new Set();
94
+ for (const m of matches) if (m.route.loader) keys.add(`${idOf(m.route)}:${innerKey(m.route, m)}`);
95
+ return keys;
96
+ };
97
+ const ensureSafe = (match) => {
98
+ try {
99
+ ensure(match);
100
+ } catch {}
101
+ };
102
+ return {
103
+ sync(matches) {
104
+ for (const m of matches) ensureSafe(m);
105
+ const keep = currentGlobalKeys(matches);
106
+ for (const [gkey, controller] of inFlight) if (!keep.has(gkey)) {
107
+ controller.abort();
108
+ inFlight.delete(gkey);
109
+ }
110
+ },
111
+ preload(matches) {
112
+ for (const m of matches) ensureSafe(m);
113
+ },
114
+ read(match) {
115
+ options.track();
116
+ const route = match.route;
117
+ if (!route.loader) return IDLE;
118
+ const entry = getEntry(route, innerKey(route, match));
119
+ if (!entry) return IDLE;
120
+ const out = { status: entry.status };
121
+ if (entry.data !== void 0) out.data = entry.data;
122
+ if (entry.error !== void 0) out.error = entry.error;
123
+ return out;
124
+ },
125
+ invalidate(matches) {
126
+ for (const m of matches) {
127
+ if (!m.route.loader) continue;
128
+ const key = innerKey(m.route, m);
129
+ const entry = getEntry(m.route, key);
130
+ if (entry) entry.loadedAt = 0;
131
+ const gkey = `${idOf(m.route)}:${key}`;
132
+ const controller = inFlight.get(gkey);
133
+ if (controller) {
134
+ controller.abort();
135
+ inFlight.delete(gkey);
136
+ }
137
+ }
138
+ this.sync(matches);
139
+ },
140
+ dispose() {
141
+ disposed = true;
142
+ for (const controller of inFlight.values()) controller.abort();
143
+ inFlight.clear();
144
+ cache = /* @__PURE__ */ new WeakMap();
145
+ }
146
+ };
147
+ }
148
+ //#endregion
149
+ export { createLoaderManager };
150
+
151
+ //# sourceMappingURL=data.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data.js","names":[],"sources":["../src/data.ts"],"sourcesContent":["/**\n * Loaders + data โ€” a stale-while-revalidate (SWR) cache for route loaders.\n *\n * A route may declare a `loader`. The manager runs loaders for the matched\n * chain, caches results (keyed by route identity + params + `loaderDeps`),\n * serves stale data while revalidating, aborts superseded loads via\n * `AbortSignal`, and exposes results **reactively** so a component's data binding\n * updates when its load resolves โ€” without re-mounting the component. See\n * ADR-0005.\n *\n * @module\n */\n\nimport type { RouterLocation } from './history'\nimport type { RouteMatch, RouteRecord } from './router'\n\n/** Context passed to a route `loader`. */\nexport interface LoaderContext {\n /** The matched path params. */\n params: Record<string, string>\n /** The (validated) search params. */\n search: Record<string, unknown>\n /** The current location. */\n location: RouterLocation\n /** Aborted when this load is superseded (navigated away, or re-keyed). */\n signal: AbortSignal\n}\n\n/** A route data loader. May be sync or async. */\nexport type LoaderFn = (ctx: LoaderContext) => unknown | Promise<unknown>\n\n/**\n * Declares which inputs key a route's loader cache (e.g. specific search params).\n * The returned value keys the SWR cache via `JSON.stringify`, so it must be\n * JSON-serializable (and ideally have stable property order). A non-serializable\n * value degrades to a params-only cache key rather than throwing.\n */\nexport type LoaderDepsFn = (args: { search: Record<string, unknown> }) => unknown\n\n/** Loader lifecycle status. */\nexport type LoaderStatus = 'idle' | 'pending' | 'success' | 'error'\n\n/** The reactive state of a route's loader. */\nexport interface LoaderData<T = unknown> {\n /** `idle` when the route has no loader or hasn't started. */\n status: LoaderStatus\n /** The loaded value (also present while `pending` if a stale value exists). */\n data?: T\n /** The error, when `status === 'error'`. */\n error?: unknown\n}\n\nconst IDLE: LoaderData = Object.freeze({ status: 'idle' })\n\ninterface CacheEntry {\n status: LoaderStatus\n data?: unknown\n error?: unknown\n loadedAt: number\n controller?: AbortController\n}\n\n/** Options for {@link createLoaderManager}. */\nexport interface LoaderManagerOptions {\n /** The current location (for the loader context). */\n location: () => RouterLocation\n /** Called whenever cached data changes (bump the reactive data version). */\n onChange: () => void\n /** Subscribe the current reactive scope to data changes (read the version signal). */\n track: () => void\n /** Wall-clock now (injectable for tests). Defaults to `Date.now`. */\n now?: () => number\n}\n\n/** Manages route loaders + their SWR cache. */\nexport interface LoaderManager {\n /** Ensure loads for the matched chain and abort loads for routes no longer matched. */\n sync(matches: readonly RouteMatch[]): void\n /** Ensure loads for `matches` WITHOUT aborting others (used by `preload`). */\n preload(matches: readonly RouteMatch[]): void\n /** Reactively read a match's loader state. */\n read(match: RouteMatch): LoaderData\n /** Mark the given chain's entries stale and reload them. */\n invalidate(matches: readonly RouteMatch[]): void\n /** Abort all in-flight loads. */\n dispose(): void\n}\n\n/** Create a loader manager. */\nexport function createLoaderManager(options: LoaderManagerOptions): LoaderManager {\n const now = options.now ?? Date.now\n // Per-route SWR cache. The outer WeakMap lets a route's entries be reclaimed\n // when its RouteRecord is dropped; the inner Map is bounded (see setEntry) so a\n // high-cardinality dynamic route (e.g. `/posts/:id` visited thousands of times)\n // can't grow it without limit. `let` so dispose() can drop it wholesale.\n let cache = new WeakMap<RouteRecord, Map<string, CacheEntry>>()\n const inFlight = new Map<string, AbortController>()\n const ids = new WeakMap<RouteRecord, number>()\n let nextId = 0\n let disposed = false\n\n // Max distinct (params, loaderDeps) entries retained per route before the oldest\n // non-pending entries are evicted (LRU by last write). Bounds memory growth.\n const MAX_ENTRIES_PER_ROUTE = 64\n\n const idOf = (route: RouteRecord): number => {\n let id = ids.get(route)\n if (id === undefined) {\n id = nextId++\n ids.set(route, id)\n }\n return id\n }\n\n const innerKey = (route: RouteRecord, match: RouteMatch): string => {\n try {\n const deps = route.loaderDeps ? route.loaderDeps({ search: match.search }) : null\n return JSON.stringify({ p: match.params, d: deps })\n } catch {\n // A throwing or non-serializable loaderDeps (BigInt/circular): degrade to a\n // params-only key rather than throwing out of navigation. `params` is\n // always a Record<string, string>, so this is total. (loaderDeps should be\n // pure and JSON-serializable โ€” see LoaderDepsFn.) innerKey is also called\n // from currentGlobalKeys/read/invalidate, so it must never throw.\n return `${JSON.stringify({ p: match.params })}::unserializable-deps`\n }\n }\n\n const getEntry = (route: RouteRecord, key: string): CacheEntry | undefined =>\n cache.get(route)?.get(key)\n\n const setEntry = (route: RouteRecord, key: string, entry: CacheEntry): void => {\n let m = cache.get(route)\n if (!m) {\n m = new Map()\n cache.set(route, m)\n }\n // Re-insert so Map iteration order is least-recently-written first.\n m.delete(key)\n m.set(key, entry)\n // Bound the cache: evict the oldest entries that aren't currently loading.\n if (m.size > MAX_ENTRIES_PER_ROUTE) {\n for (const [k, e] of m) {\n if (m.size <= MAX_ENTRIES_PER_ROUTE) break\n if (e.status !== 'pending') m.delete(k)\n }\n }\n }\n\n const ensure = (match: RouteMatch): void => {\n if (disposed) return\n const route = match.route\n const loader = route.loader\n if (!loader) return\n\n const key = innerKey(route, match)\n const gkey = `${idOf(route)}:${key}`\n const existing = getEntry(route, key)\n const staleTime = route.staleTime ?? 0\n\n if (existing?.status === 'success' && now() - existing.loadedAt < staleTime) return\n if (inFlight.has(gkey)) return // a load for this exact key is already running\n\n const controller = new AbortController()\n inFlight.set(gkey, controller)\n // Keep any prior data visible while revalidating (stale-while-revalidate).\n const pendingEntry: CacheEntry = {\n status: 'pending',\n loadedAt: existing?.loadedAt ?? 0,\n controller,\n }\n if (existing?.data !== undefined) pendingEntry.data = existing.data\n setEntry(route, key, pendingEntry)\n options.onChange()\n\n const ctx: LoaderContext = {\n params: match.params,\n search: match.search,\n location: options.location(),\n signal: controller.signal,\n }\n\n // Settle the load. Even an aborted load (e.g. a preload superseded by a\n // navigation) still warms the cache for the route's next visit โ€” but only if\n // our pending entry hasn't already been replaced by a newer load, and we\n // notify observers only when we weren't aborted. This also guarantees an\n // aborted preload never leaves a permanently `pending` cache entry.\n const settle = (settled: CacheEntry): void => {\n if (inFlight.get(gkey) === controller) inFlight.delete(gkey)\n if (getEntry(route, key) !== pendingEntry) return // a newer load superseded us\n setEntry(route, key, settled)\n if (!controller.signal.aborted) options.onChange()\n }\n\n Promise.resolve()\n .then(() => loader(ctx))\n .then(\n (data) => settle({ status: 'success', data, loadedAt: now() }),\n (error: unknown) => {\n const errored: CacheEntry = { status: 'error', error, loadedAt: now() }\n if (existing?.data !== undefined) errored.data = existing.data\n settle(errored)\n },\n )\n }\n\n const currentGlobalKeys = (matches: readonly RouteMatch[]): Set<string> => {\n const keys = new Set<string>()\n for (const m of matches) {\n if (m.route.loader) keys.add(`${idOf(m.route)}:${innerKey(m.route, m)}`)\n }\n return keys\n }\n\n // Run ensure() for one match, isolating failures so a single route can never\n // abort the whole pass (and so nothing escapes navigation).\n const ensureSafe = (match: RouteMatch): void => {\n try {\n ensure(match)\n } catch {\n // A route's key/loader-start failure must not break sibling/child loads.\n }\n }\n\n return {\n sync(matches) {\n for (const m of matches) ensureSafe(m)\n // Abort in-flight loads for routes no longer in the matched chain.\n const keep = currentGlobalKeys(matches)\n for (const [gkey, controller] of inFlight) {\n if (!keep.has(gkey)) {\n controller.abort()\n inFlight.delete(gkey)\n }\n }\n },\n preload(matches) {\n for (const m of matches) ensureSafe(m)\n },\n read(match) {\n options.track()\n const route = match.route\n if (!route.loader) return IDLE\n const entry = getEntry(route, innerKey(route, match))\n if (!entry) return IDLE\n const out: LoaderData = { status: entry.status }\n if (entry.data !== undefined) out.data = entry.data\n if (entry.error !== undefined) out.error = entry.error\n return out\n },\n invalidate(matches) {\n for (const m of matches) {\n if (!m.route.loader) continue\n const key = innerKey(m.route, m)\n const entry = getEntry(m.route, key)\n if (entry) entry.loadedAt = 0 // force stale\n // If a load is already in flight for this key, abort it so the sync below\n // starts a FRESH load โ€” otherwise ensure() bails on the in-flight guard\n // and the pre-invalidate (possibly stale) result is served, with the\n // staleness mark silently overwritten when it resolves.\n const gkey = `${idOf(m.route)}:${key}`\n const controller = inFlight.get(gkey)\n if (controller) {\n controller.abort()\n inFlight.delete(gkey)\n }\n }\n this.sync(matches)\n },\n dispose() {\n disposed = true\n for (const controller of inFlight.values()) controller.abort()\n inFlight.clear()\n // Drop all cached loader data so a disposed (but still-referenced) manager\n // doesn't retain payloads; a fresh WeakMap lets the inner Maps be GC'd.\n cache = new WeakMap()\n },\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,21 @@
1
+ import { StandardSchemaV1 } from "./standard-schema.js";
2
+
3
+ //#region src/errors.d.ts
4
+ /** Machine-readable router error codes. */
5
+ type RouterErrorCode = /** A path pattern was malformed (e.g. a catch-all that is not the last segment). */'INVALID_PATTERN' /** {@link buildPath} was called without a value for a required param. */ | 'MISSING_PARAM' /** Search-param validation failed against the route's schema. */ | 'VALIDATE_SEARCH'
6
+ /**
7
+ * A Standard Schema returned a `Promise`. Navigation-time parsing must be
8
+ * synchronous, so async schemas are rejected.
9
+ */
10
+ | 'ASYNC_SCHEMA';
11
+ /** An error thrown by the Quantum router. */
12
+ declare class RouterError extends Error {
13
+ /** Machine-readable error code. */
14
+ readonly code: RouterErrorCode;
15
+ /** Standard Schema issues, present only for `VALIDATE_SEARCH`. */
16
+ readonly issues?: ReadonlyArray<StandardSchemaV1.Issue>;
17
+ constructor(code: RouterErrorCode, message: string, issues?: ReadonlyArray<StandardSchemaV1.Issue>);
18
+ }
19
+ //#endregion
20
+ export { RouterError, RouterErrorCode };
21
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","names":[],"sources":["../src/errors.ts"],"mappings":";;;AAyBA;AAAA,KAdY,eAAA;;;;;;;cAcC,WAAA,SAAoB,KAAA;EAItB;EAAA,SAFA,IAAA,EAAM,eAAA;EAEiB;EAAA,SAAvB,MAAA,GAAS,aAAA,CAAc,gBAAA,CAAiB,KAAA;cAG/C,IAAA,EAAM,eAAA,EACN,OAAA,UACA,MAAA,GAAS,aAAA,CAAc,gBAAA,CAAiB,KAAA;AAAA"}