@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 +31 -0
- package/README.md +196 -0
- package/dist/components.d.ts +50 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +94 -0
- package/dist/components.js.map +1 -0
- package/dist/data.d.ts +38 -0
- package/dist/data.d.ts.map +1 -0
- package/dist/data.js +151 -0
- package/dist/data.js.map +1 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +18 -0
- package/dist/errors.js.map +1 -0
- package/dist/history.d.ts +75 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +125 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/pattern.d.ts +81 -0
- package/dist/pattern.d.ts.map +1 -0
- package/dist/pattern.js +156 -0
- package/dist/pattern.js.map +1 -0
- package/dist/router.d.ts +217 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +263 -0
- package/dist/router.js.map +1 -0
- package/dist/search.d.ts +50 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +112 -0
- package/dist/search.js.map +1 -0
- package/dist/standard-schema.d.ts +90 -0
- package/dist/standard-schema.d.ts.map +1 -0
- package/package.json +50 -0
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
|
package/dist/data.js.map
ADDED
|
@@ -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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"}
|
package/dist/errors.d.ts
ADDED
|
@@ -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"}
|