@pyreon/router 0.22.0 → 0.23.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/README.md +200 -111
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,185 +1,274 @@
|
|
|
1
1
|
# @pyreon/router
|
|
2
2
|
|
|
3
|
-
Type-safe client-side router
|
|
3
|
+
Type-safe client-side router with nested routes, loaders, View Transitions, middleware, and SSR.
|
|
4
|
+
|
|
5
|
+
`@pyreon/router` provides `createRouter`, `<RouterProvider>`, `<RouterView>`, `<RouterLink>`, and a suite of hooks (`useRoute`, `useRouter`, `useLoaderData`, `useTransition`, `useIsActive`, `useSearchParams`, `useTypedSearchParams`, `useValidatedSearch`, `useMiddlewareData`, `useBlocker`). Path params are TypeScript-inferred from path strings (`'/user/:id'` → `{ id: string }`); named routes enable typed programmatic navigation. Supports hash, history, and SSR modes; per-route data loaders with TTL cache + in-flight dedup + SWR; navigation guards + middleware; View Transitions integration; `notFound()` / `redirect()` thrown from loaders with discriminated-union helpers; validated typed search params; `lazy(loader)` for code-split route components.
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
7
9
|
```bash
|
|
8
|
-
bun add @pyreon/router
|
|
10
|
+
bun add @pyreon/router @pyreon/core @pyreon/reactivity
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
## Quick
|
|
13
|
+
## Quick start
|
|
12
14
|
|
|
13
15
|
```tsx
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createRouter, RouterProvider, RouterView, RouterLink,
|
|
18
|
+
useRoute, useRouter, useLoaderData, useTypedSearchParams, useTransition,
|
|
19
|
+
notFound, NotFoundBoundary, redirect, lazy,
|
|
20
|
+
} from '@pyreon/router'
|
|
21
|
+
import { mount } from '@pyreon/runtime-dom'
|
|
15
22
|
|
|
16
|
-
// Type-safe named navigation — route names are checked at compile time
|
|
17
23
|
const router = createRouter<'home' | 'user'>({
|
|
18
24
|
routes: [
|
|
19
25
|
{ path: '/', component: Home, name: 'home' },
|
|
20
|
-
{ path: '/user/:id', component:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
component:
|
|
24
|
-
children: [{ path: 'users', component: AdminUsers }],
|
|
25
|
-
},
|
|
26
|
+
{ path: '/user/:id', component: User, name: 'user',
|
|
27
|
+
loader: ({ params }) => fetchUser(params.id) },
|
|
28
|
+
{ path: '/admin', component: AdminLayout,
|
|
29
|
+
children: [{ path: 'users', component: AdminUsers }] },
|
|
26
30
|
{ path: '/old-path', redirect: '/new-path' },
|
|
27
31
|
{ path: '/dashboard', component: lazy(() => import('./Dashboard')) },
|
|
28
|
-
{ path: '(.*)', component:
|
|
32
|
+
{ path: '(.*)', component: NotFoundPage },
|
|
29
33
|
],
|
|
30
34
|
})
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
function App() {
|
|
37
|
+
return (
|
|
38
|
+
<RouterProvider router={router}>
|
|
39
|
+
<nav>
|
|
40
|
+
<RouterLink to="/" prefetch="intent">Home</RouterLink>
|
|
41
|
+
<RouterLink to={{ name: 'user', params: { id: '42' } }}>Profile</RouterLink>
|
|
42
|
+
</nav>
|
|
43
|
+
<NotFoundBoundary fallback={<p>404</p>}>
|
|
44
|
+
<RouterView />
|
|
45
|
+
</NotFoundBoundary>
|
|
46
|
+
</RouterProvider>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function User() {
|
|
51
|
+
const route = useRoute<'/user/:id'>()
|
|
52
|
+
const data = useLoaderData<{ name: string }>()
|
|
53
|
+
const params = useTypedSearchParams({ page: 'number', q: 'string' })
|
|
54
|
+
return <h1>{data.name} (id={route().params.id}, page={params().page})</h1>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
mount(<App />, document.getElementById('app')!)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Modes
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
createRouter({ routes, mode: 'history' }) // default; uses pushState
|
|
64
|
+
createRouter({ routes, mode: 'hash' }) // for static hosting; pushState w/ #
|
|
65
|
+
createRouter({ routes, url: '/some/path' }) // SSR — pin to a URL for one render
|
|
37
66
|
```
|
|
38
67
|
|
|
39
|
-
|
|
68
|
+
Hash mode uses `history.pushState` under the hood (not `window.location.hash`) to avoid the double-update jank.
|
|
40
69
|
|
|
41
|
-
|
|
70
|
+
## Typed params + named navigation
|
|
42
71
|
|
|
43
72
|
```tsx
|
|
73
|
+
// Params inferred from the path string
|
|
44
74
|
const route = useRoute<'/user/:id'>()
|
|
45
75
|
route().params.id // string
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
## Named Navigation
|
|
49
76
|
|
|
50
|
-
|
|
51
|
-
const router =
|
|
77
|
+
// Typed names — compile-time-checked
|
|
78
|
+
const router = createRouter<'home' | 'user' | 'admin'>({ routes })
|
|
52
79
|
router.push({ name: 'user', params: { id: '42' } })
|
|
80
|
+
router.push({ name: 'typo' }) // ❌ TypeScript error
|
|
53
81
|
```
|
|
54
82
|
|
|
55
83
|
## RouterLink
|
|
56
84
|
|
|
57
85
|
```tsx
|
|
58
86
|
<RouterLink to="/user/42">Profile</RouterLink>
|
|
59
|
-
<RouterLink to={{ name:
|
|
87
|
+
<RouterLink to={{ name: 'user', params: { id: '42' } }}>Profile</RouterLink>
|
|
88
|
+
<RouterLink to="/about" prefetch="hover">About</RouterLink>
|
|
89
|
+
<RouterLink to="/feed" prefetch="viewport">Feed</RouterLink>
|
|
90
|
+
<RouterLink to="/dashboard" activeClass="is-active">Dashboard</RouterLink>
|
|
60
91
|
```
|
|
61
92
|
|
|
62
|
-
|
|
93
|
+
**Prefetch strategies** (default: `"intent"`):
|
|
63
94
|
|
|
64
|
-
|
|
65
|
-
|
|
95
|
+
- `"intent"` — prefetches on hover AND focus (keyboard + mouse parity)
|
|
96
|
+
- `"hover"` — hover only
|
|
97
|
+
- `"viewport"` — once the link enters the viewport (`IntersectionObserver`, scheduled via `requestIdleCallback`)
|
|
98
|
+
- `"none"` — disabled
|
|
66
99
|
|
|
67
|
-
|
|
68
|
-
```
|
|
100
|
+
`activeClass` is **merged** with the user-provided `class` via `cx` (not overridden). `aria-current="page"` is set automatically on active links.
|
|
69
101
|
|
|
70
|
-
##
|
|
102
|
+
## Data loaders
|
|
71
103
|
|
|
72
|
-
|
|
104
|
+
```ts
|
|
105
|
+
{
|
|
106
|
+
path: '/user/:id',
|
|
107
|
+
component: User,
|
|
108
|
+
loader: async ({ params, request }) => {
|
|
109
|
+
const r = await fetch(`/api/users/${params.id}`)
|
|
110
|
+
if (!r.ok) notFound()
|
|
111
|
+
return r.json()
|
|
112
|
+
},
|
|
113
|
+
loaderKey: ({ params }) => `user-${params.id}`,
|
|
114
|
+
gcTime: 5 * 60_000, // default; cache expiry
|
|
115
|
+
staleWhileRevalidate: true, // serve cached, refetch in background
|
|
116
|
+
}
|
|
117
|
+
```
|
|
73
118
|
|
|
74
|
-
-
|
|
75
|
-
- `lazy(loader)` -- define a lazily loaded route component
|
|
119
|
+
Loaders run before the route renders. In-flight calls for the same `loaderKey` dedupe; cached results are served until `gcTime` expires. `router.invalidateLoader(key?)` clears entries.
|
|
76
120
|
|
|
77
|
-
|
|
121
|
+
Read loader data in the component:
|
|
78
122
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
123
|
+
```ts
|
|
124
|
+
const data = useLoaderData<{ name: string }>()
|
|
125
|
+
```
|
|
82
126
|
|
|
83
|
-
|
|
127
|
+
`LoaderContext.request?: Request` is populated only on SSR (via `prefetchLoaderData(router, path, request)`); `undefined` on CSR.
|
|
84
128
|
|
|
85
|
-
|
|
86
|
-
- `RouterView` -- renders the matched route component
|
|
87
|
-
- `RouterLink` -- anchor element with client-side navigation
|
|
129
|
+
## Guards + middleware
|
|
88
130
|
|
|
89
|
-
|
|
131
|
+
```ts
|
|
132
|
+
{
|
|
133
|
+
path: '/admin',
|
|
134
|
+
component: AdminLayout,
|
|
135
|
+
beforeEnter: (to, from) => isAdmin() || '/login',
|
|
136
|
+
children: [...],
|
|
137
|
+
}
|
|
90
138
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
139
|
+
createRouter({
|
|
140
|
+
routes,
|
|
141
|
+
middleware: [
|
|
142
|
+
async (to, from, ctx) => {
|
|
143
|
+
ctx.data.user = await fetchUserFromCookie(to.request)
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
})
|
|
98
147
|
|
|
99
|
-
|
|
148
|
+
// In components:
|
|
149
|
+
const data = useMiddlewareData()
|
|
150
|
+
data().user
|
|
151
|
+
```
|
|
100
152
|
|
|
101
|
-
|
|
153
|
+
## notFound() / redirect()
|
|
102
154
|
|
|
103
|
-
|
|
155
|
+
```ts
|
|
156
|
+
import { notFound, redirect } from '@pyreon/router'
|
|
104
157
|
|
|
105
|
-
|
|
158
|
+
// In a loader
|
|
159
|
+
loader: async ({ params, request }) => {
|
|
160
|
+
const user = await fetchUser(params.id)
|
|
161
|
+
if (!user) notFound() // → NotFoundBoundary fallback
|
|
162
|
+
if (!user.isVerified) redirect('/verify') // → router.replace, or HTTP 302/307 on SSR
|
|
163
|
+
return user
|
|
164
|
+
}
|
|
165
|
+
```
|
|
106
166
|
|
|
107
|
-
`
|
|
167
|
+
`notFound()` and `redirect()` throw discriminated-union errors. The router catches them; `@pyreon/server`'s SSR handler returns real HTTP `404` / `302` / `307` responses (no layout HTML leaks server-side). Error-boundary code can introspect via `isNotFoundError(err)` / `isRedirectError(err)` / `getRedirectInfo(err)`.
|
|
108
168
|
|
|
109
|
-
|
|
110
|
-
| --- | --- | --- |
|
|
111
|
-
| `updateCallbackDone` | Callback done; DOM swapped; state live | yes |
|
|
112
|
-
| `ready` | Snapshot captured, pseudo-elements ready | no -- `.catch()` only |
|
|
113
|
-
| `finished` | Full animation completed | no -- `.catch()` only |
|
|
169
|
+
Pair with `<NotFoundBoundary fallback={<NotFoundPage />}>...</NotFoundBoundary>` at your layout root.
|
|
114
170
|
|
|
115
|
-
|
|
171
|
+
## Pending components
|
|
116
172
|
|
|
117
|
-
|
|
173
|
+
```ts
|
|
174
|
+
{
|
|
175
|
+
path: '/dashboard',
|
|
176
|
+
component: Dashboard,
|
|
177
|
+
loader: fetchDashboard,
|
|
178
|
+
pendingComponent: DashboardSkeleton,
|
|
179
|
+
pendingMs: 200, // delay before showing skeleton (avoid flash)
|
|
180
|
+
pendingMinMs: 500, // minimum display time (avoid flicker)
|
|
181
|
+
}
|
|
182
|
+
```
|
|
118
183
|
|
|
119
|
-
|
|
184
|
+
Hidden → pending → ready state machine, signal-driven.
|
|
120
185
|
|
|
121
|
-
|
|
122
|
-
import { notFound, NotFoundBoundary, RouterView } from '@pyreon/router'
|
|
186
|
+
## Validated search params
|
|
123
187
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (!user) notFound()
|
|
128
|
-
return user
|
|
129
|
-
}}
|
|
188
|
+
```ts
|
|
189
|
+
// Plain function
|
|
190
|
+
{ path: '/search', validateSearch: (raw) => ({ page: Number(raw.page) || 1, q: raw.q ?? '' }) }
|
|
130
191
|
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
192
|
+
// Zod
|
|
193
|
+
{ path: '/search', validateSearch: z.object({ page: z.coerce.number().default(1), q: z.string() }).parse }
|
|
194
|
+
|
|
195
|
+
// In component
|
|
196
|
+
const search = useValidatedSearch<{ page: number; q: string }>()
|
|
197
|
+
search().page // number
|
|
135
198
|
```
|
|
136
199
|
|
|
137
|
-
|
|
200
|
+
Structural sharing — returns the same object reference when validated values haven't changed.
|
|
138
201
|
|
|
139
|
-
|
|
202
|
+
For untyped or single-shot reads:
|
|
140
203
|
|
|
141
204
|
```ts
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
loader: fetchDashboardData,
|
|
146
|
-
pendingComponent: DashboardSkeleton,
|
|
147
|
-
pendingMs: 200, // delay before showing skeleton (avoid flash)
|
|
148
|
-
pendingMinMs: 500, // minimum display time (avoid flicker)
|
|
149
|
-
}
|
|
205
|
+
const params = useSearchParams() // accessor → URLSearchParams
|
|
206
|
+
const typed = useTypedSearchParams({ page: 'number', q: 'string' })
|
|
207
|
+
typed().page // number (NaN coerced to 0)
|
|
150
208
|
```
|
|
151
209
|
|
|
152
|
-
##
|
|
210
|
+
## View Transitions
|
|
211
|
+
|
|
212
|
+
Route changes auto-wrap in `document.startViewTransition()` when supported. Opt out per-route with `meta: { viewTransition: false }`.
|
|
153
213
|
|
|
154
|
-
|
|
214
|
+
**`await router.push()` / `.replace()` resolves on `updateCallbackDone`** — the DOM commit, NOT the full animation:
|
|
215
|
+
|
|
216
|
+
| Promise | Resolves when | Router awaits? |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| `updateCallbackDone` | Callback done; DOM swapped; new state live | ✅ yes |
|
|
219
|
+
| `ready` | Snapshot captured, pseudo-elements ready | no — `.catch()` only |
|
|
220
|
+
| `finished` | Full animation completed (200-300ms) | no — `.catch()` only |
|
|
221
|
+
|
|
222
|
+
Blocking every navigation on a 200-300ms animation is unacceptable; `.ready` and `.finished` get `.catch()` handlers so their `AbortError` (when a newer navigation interrupts) doesn't leak as unhandled.
|
|
223
|
+
|
|
224
|
+
`afterEach` hooks + scroll restoration fire AFTER the VT callback completes — they observe the new route state.
|
|
225
|
+
|
|
226
|
+
## Component-level hooks
|
|
155
227
|
|
|
156
228
|
```ts
|
|
157
|
-
|
|
229
|
+
onBeforeRouteLeave((to, from) => confirm('Leave?') || false)
|
|
230
|
+
onBeforeRouteUpdate((to, from) => { /* same-route params changed */ })
|
|
158
231
|
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
component: SearchPage,
|
|
163
|
-
validateSearch: (raw) => ({
|
|
164
|
-
page: Number(raw.page) || 1,
|
|
165
|
-
q: raw.q ?? '',
|
|
166
|
-
}),
|
|
167
|
-
}
|
|
232
|
+
// Browser navigation guard for unsaved changes
|
|
233
|
+
useBlocker(() => isDirty)
|
|
234
|
+
```
|
|
168
235
|
|
|
169
|
-
|
|
170
|
-
{
|
|
171
|
-
path: '/search',
|
|
172
|
-
component: SearchPage,
|
|
173
|
-
validateSearch: z.object({
|
|
174
|
-
page: z.coerce.number().default(1),
|
|
175
|
-
q: z.string().default(''),
|
|
176
|
-
}).parse,
|
|
177
|
-
}
|
|
236
|
+
## SSR helpers
|
|
178
237
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
238
|
+
```ts
|
|
239
|
+
import {
|
|
240
|
+
prefetchLoaderData, hydrateLoaderData,
|
|
241
|
+
serializeLoaderData, stringifyLoaderData,
|
|
242
|
+
} from '@pyreon/router'
|
|
243
|
+
|
|
244
|
+
// Server: pre-fetch + serialize
|
|
245
|
+
await prefetchLoaderData(router, url.pathname, request)
|
|
246
|
+
const blob = serializeLoaderData(router)
|
|
247
|
+
const json = stringifyLoaderData(blob) // safe stringifier — drops fns, throws on cycles
|
|
248
|
+
|
|
249
|
+
// Client: hydrate
|
|
250
|
+
hydrateLoaderData(router, window.__PYREON_LOADER_DATA__)
|
|
183
251
|
```
|
|
184
252
|
|
|
185
|
-
|
|
253
|
+
`stringifyLoaderData` is the safe serializer: drops `function` / `symbol` values, throws `[Pyreon] Loader returned circular reference at "<path>"` on cycles, escapes `</script>` for inline embedding.
|
|
254
|
+
|
|
255
|
+
## Match utilities
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
import {
|
|
259
|
+
resolveRoute, buildPath, findRouteByName,
|
|
260
|
+
parseQuery, parseQueryMulti, stringifyQuery,
|
|
261
|
+
} from '@pyreon/router'
|
|
262
|
+
|
|
263
|
+
const resolved = resolveRoute(routes, '/user/42?tab=settings')
|
|
264
|
+
const url = buildPath('/user/:id', { id: '42' }) // '/user/42'
|
|
265
|
+
const url2 = buildPath('/blog/:rest*', { rest: 'a/b' }) // '/blog/a/b' — catch-all
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Documentation
|
|
269
|
+
|
|
270
|
+
Full docs: [docs.pyreon.dev/docs/router](https://docs.pyreon.dev/docs/router) (or `docs/docs/router.md` in this repo).
|
|
271
|
+
|
|
272
|
+
## License
|
|
273
|
+
|
|
274
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Official router for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/router#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -44,14 +44,14 @@
|
|
|
44
44
|
"prepublishOnly": "bun run build"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@pyreon/core": "^0.
|
|
48
|
-
"@pyreon/reactivity": "^0.
|
|
49
|
-
"@pyreon/runtime-dom": "^0.
|
|
47
|
+
"@pyreon/core": "^0.23.0",
|
|
48
|
+
"@pyreon/reactivity": "^0.23.0",
|
|
49
|
+
"@pyreon/runtime-dom": "^0.23.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
53
53
|
"@pyreon/manifest": "0.13.1",
|
|
54
|
-
"@pyreon/test-utils": "^0.13.
|
|
54
|
+
"@pyreon/test-utils": "^0.13.10",
|
|
55
55
|
"@vitest/browser-playwright": "^4.1.4",
|
|
56
56
|
"happy-dom": "^20.8.3"
|
|
57
57
|
}
|