@pyreon/router 0.21.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.
Files changed (2) hide show
  1. package/README.md +200 -111
  2. 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 for Pyreon with hash and history modes, nested routes, guards, loaders, and scroll restoration.
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 Start
13
+ ## Quick start
12
14
 
13
15
  ```tsx
14
- import { createRouter, RouterProvider, RouterView, RouterLink } from '@pyreon/router'
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: UserPage, name: 'user' },
21
- {
22
- path: '/admin',
23
- component: AdminLayout,
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: NotFound },
32
+ { path: '(.*)', component: NotFoundPage },
29
33
  ],
30
34
  })
31
35
 
32
- const App = () => (
33
- <RouterProvider router={router}>
34
- <RouterView />
35
- </RouterProvider>
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
- ## Typed Params
68
+ Hash mode uses `history.pushState` under the hood (not `window.location.hash`) to avoid the double-update jank.
40
69
 
41
- Route parameters are inferred from path strings:
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
- ```tsx
51
- const router = useRouter()
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: "user", params: { id: "42" } }}>Profile</RouterLink>
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
- ## Data Loaders
93
+ **Prefetch strategies** (default: `"intent"`):
63
94
 
64
- ```tsx
65
- import { useLoaderData, prefetchLoaderData } from '@pyreon/router'
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
- const data = useLoaderData<typeof loader>()
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
- ## API
102
+ ## Data loaders
71
103
 
72
- ### Router Creation
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
- - `createRouter(options: RouterOptions)` -- create a router instance
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
- ### Hooks
121
+ Read loader data in the component:
78
122
 
79
- - `useRouter()` -- access the router instance
80
- - `useRoute<Path>()` -- access the current resolved route with typed params
81
- - `useLoaderData<T>()` -- access data returned by a route loader
123
+ ```ts
124
+ const data = useLoaderData<{ name: string }>()
125
+ ```
82
126
 
83
- ### Components
127
+ `LoaderContext.request?: Request` is populated only on SSR (via `prefetchLoaderData(router, path, request)`); `undefined` on CSR.
84
128
 
85
- - `RouterProvider` -- provides router context to the tree
86
- - `RouterView` -- renders the matched route component
87
- - `RouterLink` -- anchor element with client-side navigation
129
+ ## Guards + middleware
88
130
 
89
- ### Utilities
131
+ ```ts
132
+ {
133
+ path: '/admin',
134
+ component: AdminLayout,
135
+ beforeEnter: (to, from) => isAdmin() || '/login',
136
+ children: [...],
137
+ }
90
138
 
91
- - `resolveRoute(routes, path)` -- match a path against route definitions
92
- - `parseQuery(search)` / `parseQueryMulti(search)` -- parse query strings
93
- - `stringifyQuery(params)` -- serialize query parameters
94
- - `buildPath(pattern, params)` -- build a path from a pattern and params
95
- - `findRouteByName(routes, name)` -- look up a named route
96
- - `prefetchLoaderData(router, path)` -- prefetch loader data for a path
97
- - `serializeLoaderData(router)` / `hydrateLoaderData(router, data)` -- SSR serialization
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
- ### Types
148
+ // In components:
149
+ const data = useMiddlewareData()
150
+ data().user
151
+ ```
100
152
 
101
- `ExtractParams`, `RouteMeta`, `ResolvedRoute`, `RouteRecord`, `RouterOptions`, `Router`, `NavigationGuard`, `AfterEachHook`, `ScrollBehaviorFn`, `LoaderContext`, `RouteLoaderFn`
153
+ ## notFound() / redirect()
102
154
 
103
- ## View Transitions
155
+ ```ts
156
+ import { notFound, redirect } from '@pyreon/router'
104
157
 
105
- Route changes are wrapped in `document.startViewTransition()` automatically when the browser supports it. Opt out per-route with `meta: { viewTransition: false }`.
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
- `await router.push()` / `.replace()` resolves once the DOM has committed to the new route -- specifically, when the ViewTransition's `updateCallbackDone` promise settles. It does NOT wait for the full animation (`.finished`, 200-300ms), because blocking every programmatic navigation on an animation is unacceptable.
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
- | Promise | Resolves when | Router awaits? |
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
- `afterEach` hooks and scroll restoration fire after the VT callback completes, so they observe the new route state when invoked.
171
+ ## Pending components
116
172
 
117
- ## notFound()
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
- Throw `notFound()` in a loader or component to render a 404 boundary:
184
+ Hidden pending ready state machine, signal-driven.
120
185
 
121
- ```tsx
122
- import { notFound, NotFoundBoundary, RouterView } from '@pyreon/router'
186
+ ## Validated search params
123
187
 
124
- // Route loader:
125
- { path: '/user/:id', component: UserPage, loader: async ({ params }) => {
126
- const user = await fetchUser(params.id)
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
- // App layout:
132
- <NotFoundBoundary fallback={<NotFoundPage />}>
133
- <RouterView />
134
- </NotFoundBoundary>
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
- ## Pending Components
200
+ Structural sharing — returns the same object reference when validated values haven't changed.
138
201
 
139
- Show a skeleton while route loaders run:
202
+ For untyped or single-shot reads:
140
203
 
141
204
  ```ts
142
- {
143
- path: '/dashboard',
144
- component: Dashboard,
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
- ## Validated Search Params
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
- Type-safe query string validation per routeworks with Zod, Valibot, or plain functions:
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
- import { useValidatedSearch } from '@pyreon/router'
229
+ onBeforeRouteLeave((to, from) => confirm('Leave?') || false)
230
+ onBeforeRouteUpdate((to, from) => { /* same-route params changed */ })
158
231
 
159
- // Route config:
160
- {
161
- path: '/search',
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
- // With Zod:
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
- // In component:
180
- const search = useValidatedSearch<{ page: number; q: string }>()
181
- search().page // number — typed + validated
182
- search().q // string — typed + validated
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
- Structural sharing: `useValidatedSearch()` returns the same object reference when the validated values haven't changed, preventing unnecessary downstream re-renders.
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.21.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.21.0",
48
- "@pyreon/reactivity": "^0.21.0",
49
- "@pyreon/runtime-dom": "^0.21.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.8",
54
+ "@pyreon/test-utils": "^0.13.10",
55
55
  "@vitest/browser-playwright": "^4.1.4",
56
56
  "happy-dom": "^20.8.3"
57
57
  }