@pyreon/router 0.24.4 → 0.24.6

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/src/manifest.ts DELETED
@@ -1,399 +0,0 @@
1
- import { defineManifest } from '@pyreon/manifest'
2
-
3
- export default defineManifest({
4
- name: '@pyreon/router',
5
- title: 'Router',
6
- tagline:
7
- 'hash+history+SSR, context-based, prefetching, guards, loaders, useIsActive, View Transitions, middleware, typed search params',
8
- description:
9
- 'Type-safe client-side router for Pyreon with nested routes, per-route and global navigation guards, data loaders, middleware chain, View Transitions API integration, and typed search params. Context-based (`RouterContext`) with hash and history mode support. Route params are inferred from path strings (`"/user/:id"` yields `{ id: string }`). Named routes enable typed programmatic navigation. SSR-compatible with server-side route resolution. Hash mode uses `history.pushState` (not `window.location.hash`) to avoid double-update. `await router.push()` resolves after the View Transition `updateCallbackDone` (DOM commit), not after animation completion.',
10
- category: 'browser',
11
- longExample: `import { createRouter, RouterProvider, RouterView, RouterLink, useRouter, useRoute, useIsActive, useTypedSearchParams, useTransition, useLoaderData, useMiddlewareData } from "@pyreon/router"
12
- import { mount } from "@pyreon/runtime-dom"
13
-
14
- // Define routes with typed params, guards, loaders, and middleware
15
- const router = createRouter({
16
- routes: [
17
- { path: "/", component: Home, name: "home" },
18
- { path: "/user/:id", component: User, name: "user",
19
- loader: ({ params }) => fetchUser(params.id),
20
- meta: { title: "User Profile" } },
21
- { path: "/admin", component: AdminLayout,
22
- beforeEnter: (to, from) => isAdmin() || "/login",
23
- children: [
24
- { path: "users", component: AdminUsers },
25
- { path: "settings", component: AdminSettings },
26
- ] },
27
- { path: "/settings", redirect: "/admin/settings" },
28
- { path: "(.*)", component: NotFound },
29
- ],
30
- middleware: [authMiddleware, loggerMiddleware],
31
- })
32
-
33
- // Mount with RouterProvider
34
- mount(
35
- <RouterProvider router={router}>
36
- <nav>
37
- <RouterLink to="/" activeClass="nav-active">Home</RouterLink>
38
- <RouterLink to={{ name: "user", params: { id: "42" } }}>Profile</RouterLink>
39
- </nav>
40
- <RouterView />
41
- </RouterProvider>,
42
- document.getElementById("app")!
43
- )
44
-
45
- // Inside a component — hooks
46
- const User = () => {
47
- const route = useRoute<"/user/:id">()
48
- const data = useLoaderData<UserData>()
49
- const router = useRouter()
50
- const isAdmin = useIsActive("/admin")
51
- const isTransitioning = useTransition()
52
- const params = useTypedSearchParams({ tab: "string", page: "number" })
53
-
54
- return (
55
- <div>
56
- <h1>{data.name} (ID: {route().params.id})</h1>
57
- <Show when={isTransitioning()}>
58
- <ProgressBar />
59
- </Show>
60
- <button onClick={() => router.push("/")}>Go Home</button>
61
- </div>
62
- )
63
- }`,
64
- features: [
65
- 'createRouter() — factory with routes, guards, middleware, loaders, hash/history mode',
66
- 'RouterProvider / RouterView / RouterLink — context-based rendering components',
67
- 'useRouter / useRoute — programmatic navigation and typed route access',
68
- 'useIsActive — reactive boolean for path matching (segment-aware prefix)',
69
- 'useTypedSearchParams — typed search params with auto-coercion',
70
- 'useTransition — reactive signal for route transition state',
71
- 'useMiddlewareData — read data set by route middleware chain',
72
- 'useLoaderData — access route loader results',
73
- 'View Transitions API — auto-enabled, awaits updateCallbackDone',
74
- 'Named routes — typed navigation via { name, params }',
75
- 'Nested routes — recursive matching with child RouterView',
76
- 'Navigation guards — per-route and global beforeEnter/afterEach hooks',
77
- ],
78
- api: [
79
- {
80
- name: 'createRouter',
81
- kind: 'function',
82
- signature: 'createRouter(options: RouterOptions | RouteRecord[]): Router',
83
- summary:
84
- 'Create a router instance with route records, guards, middleware, and mode configuration. Accepts either an array of route records (shorthand) or a full `RouterOptions` object with `routes`, `mode` (`"history"` | `"hash"`), `scrollBehavior`, `beforeEach`, `afterEach`, and `middleware`. The returned `Router` is generic over route names for typed programmatic navigation.',
85
- example: `const router = createRouter([
86
- { path: "/", component: Home },
87
- { path: "/user/:id", component: User, loader: ({ params }) => fetchUser(params.id) },
88
- { path: "/admin", component: Admin, beforeEnter: requireAuth, children: [
89
- { path: "settings", component: Settings },
90
- ]},
91
- ])`,
92
- mistakes: [
93
- '`createRouter({ routes: [...], mode: "hash" })` and using `window.location.hash` elsewhere — hash mode uses `history.pushState`, not `location.hash`. Reading `location.hash` directly will not reflect router state',
94
- 'Defining route paths without leading `/` in root routes — all root-level paths must start with `/`',
95
- 'Using `redirect: "/target"` with a guard on the same route — redirects bypass guards. Use `beforeEnter` to conditionally redirect instead',
96
- 'Forgetting the catch-all route — `{ path: "(.*)", component: NotFound }` should be the last route to handle 404s',
97
- ],
98
- seeAlso: ['RouterProvider', 'useRouter', 'useRoute'],
99
- },
100
- {
101
- name: 'RouterProvider',
102
- kind: 'component',
103
- signature: '<RouterProvider router={router}>{children}</RouterProvider>',
104
- summary:
105
- 'Provide the router instance to the component tree via `RouterContext`. Must wrap the entire app (or the routed section). Sets up the context stack so `useRouter()`, `useRoute()`, and other hooks can access the router.',
106
- example: `const App = () => (
107
- <RouterProvider router={router}>
108
- <nav><RouterLink to="/">Home</RouterLink></nav>
109
- <RouterView />
110
- </RouterProvider>
111
- )`,
112
- seeAlso: ['createRouter', 'RouterView', 'RouterLink'],
113
- },
114
- {
115
- name: 'RouterView',
116
- kind: 'component',
117
- signature: '<RouterView />',
118
- summary:
119
- 'Render the matched route\'s component. For nested routes, the parent route component includes a `<RouterView />` that renders the matched child. Each `<RouterView>` renders one level of the route tree.',
120
- example: `// Renders the matched route's component
121
- <RouterView />
122
-
123
- // Nested routes: parent component includes <RouterView /> for children
124
- const Admin = () => (
125
- <div>
126
- <h1>Admin</h1>
127
- <RouterView /> {/* renders Settings, Users, etc. */}
128
- </div>
129
- )`,
130
- seeAlso: ['RouterProvider', 'createRouter'],
131
- },
132
- {
133
- name: 'RouterLink',
134
- kind: 'component',
135
- signature: '<RouterLink to={path} activeClass={cls} exactActiveClass={cls}>{children}</RouterLink>',
136
- summary:
137
- 'Declarative navigation link that renders an `<a>` element. Supports string paths or named route objects (`{ name, params }`). Applies `activeClass` when the current route matches the link path (prefix), and `exactActiveClass` for exact matches. Click handler calls `router.push()` and prevents default.',
138
- example: `<RouterLink to="/" activeClass="nav-active">Home</RouterLink>
139
- <RouterLink to={{ name: "user", params: { id: "42" } }}>Profile</RouterLink>`,
140
- mistakes: [
141
- '`<a href="/about" onClick={() => router.push("/about")}>` — use `<RouterLink to="/about">` instead; it handles the anchor element, active class, and click interception',
142
- '`<RouterLink to="/about" target="_blank">` — external navigation bypasses the router; use a plain `<a>` for external links',
143
- '`<RouterLink to={dynamicPath}>` without calling the signal — must call: `<RouterLink to={dynamicPath()}>` (or let the compiler handle it via `_rp()`)',
144
- ],
145
- seeAlso: ['useRouter', 'useIsActive'],
146
- },
147
- {
148
- name: 'useRouter',
149
- kind: 'hook',
150
- signature: 'useRouter(): Router',
151
- summary:
152
- 'Access the router instance for programmatic navigation. Returns the `Router` object with `push()`, `replace()`, `back()`, `forward()`, `go()`. `await router.push()` resolves after the View Transition `updateCallbackDone` (DOM commit is complete, new route state is live), NOT after the animation finishes.',
153
- example: `const router = useRouter()
154
-
155
- router.push("/settings")
156
- router.push({ name: "user", params: { id: "42" } })
157
- router.replace("/login")
158
- router.back()
159
- router.forward()
160
- router.go(-2)`,
161
- mistakes: [
162
- '`router.push("/path")` at the top level of a component body — this is synchronous imperative navigation during render, causing an infinite loop. Wrap in `onMount`, event handler, or `effect`',
163
- '`await router.push("/path")` expecting animation completion — `push` resolves after DOM commit (`updateCallbackDone`), not after View Transition animation finishes. Use the returned transition object\'s `.finished` if you need to wait for animation',
164
- 'Calling `useRouter()` outside a `<RouterProvider>` — throws because no router context exists',
165
- ],
166
- seeAlso: ['useRoute', 'RouterLink', 'createRouter'],
167
- },
168
- {
169
- name: 'useRoute',
170
- kind: 'hook',
171
- signature: 'useRoute<TPath extends string>(): () => ResolvedRoute<ExtractParams<TPath>>',
172
- summary:
173
- 'Access the current resolved route as a reactive accessor. Generic over the path string for typed params — `useRoute<"/user/:id">()` yields `route().params.id: string`. Returns a function (accessor) that must be called to read the current route — reads inside reactive scopes track route changes.',
174
- example: `// Type-safe params:
175
- const route = useRoute<"/user/:id">()
176
- const userId = route().params.id // string
177
-
178
- // Access query, meta, etc:
179
- route().query
180
- route().meta`,
181
- seeAlso: ['useRouter', 'useSearchParams', 'useLoaderData'],
182
- },
183
- {
184
- name: 'useIsActive',
185
- kind: 'hook',
186
- signature: 'useIsActive(path: string, exact?: boolean): () => boolean',
187
- summary:
188
- 'Returns a reactive boolean for whether a path matches the current route. Segment-aware prefix matching: `/admin` matches `/admin/users` but NOT `/admin-panel`. Pass `exact=true` for exact-only matching. Updates reactively when the route changes.',
189
- example: `const isHome = useIsActive("/")
190
- const isAdmin = useIsActive("/admin") // prefix match
191
- const isExactAdmin = useIsActive("/admin", true) // exact only
192
-
193
- // Reactive — updates when route changes:
194
- <a class={{ active: isAdmin() }} href="/admin">Admin</a>`,
195
- mistakes: [
196
- '`useIsActive("/admin")` matching `/admin-panel` — this does NOT happen. Matching is segment-aware: `/admin` only matches paths starting with `/admin/` or exactly `/admin`',
197
- '`if (useIsActive("/settings")())` at component top level — the outer call returns an accessor; make sure to read it inside a reactive scope for updates',
198
- 'Using `useIsActive` for complex route matching — it only does path prefix/exact matching. For query-param-aware or meta-aware checks, use `useRoute()` directly',
199
- ],
200
- seeAlso: ['useRoute', 'RouterLink'],
201
- },
202
- {
203
- name: 'useTypedSearchParams',
204
- kind: 'hook',
205
- signature: 'useTypedSearchParams<T>(schema: T): TypedSearchParams<T>',
206
- summary:
207
- 'Type-safe search params with auto-coercion from URL strings. Schema keys define parameter names, values define types (`"string"`, `"number"`, `"boolean"`). Returns an object where each key is a reactive accessor and `.set()` updates the URL.',
208
- example: `const params = useTypedSearchParams({ page: "number", q: "string", active: "boolean" })
209
- params.page() // number (auto-coerced)
210
- params.q() // string
211
- params.set({ page: 2 }) // updates URL`,
212
- seeAlso: ['useSearchParams', 'useRoute'],
213
- },
214
- {
215
- name: 'useTransition',
216
- kind: 'hook',
217
- signature: 'useTransition(): () => boolean',
218
- summary:
219
- 'Returns a reactive accessor for route transition state. The accessor is true during navigation (while guards run + loaders resolve), false when the new route is mounted. Call it inside a reactive scope. Useful for progress bars and global loading indicators.',
220
- example: `const isTransitioning = useTransition()
221
-
222
- <Show when={isTransitioning()}>
223
- <ProgressBar />
224
- </Show>`,
225
- seeAlso: ['useRouter', 'useRoute'],
226
- },
227
- {
228
- name: 'useMiddlewareData',
229
- kind: 'hook',
230
- signature: 'useMiddlewareData(): () => Record<string, unknown>',
231
- summary:
232
- 'Returns a reactive accessor for data set by `RouteMiddleware` in the middleware chain. Middleware functions receive `ctx` with a mutable `ctx.data` object — properties set there are read by calling the returned accessor inside a reactive scope.',
233
- example: `// Middleware:
234
- const authMiddleware: RouteMiddleware = async (ctx) => {
235
- ctx.data.user = await getUser(ctx.to)
236
- }
237
-
238
- // Component:
239
- const data = useMiddlewareData()
240
- // data().user is available`,
241
- seeAlso: ['createRouter', 'useLoaderData'],
242
- },
243
- {
244
- name: 'useLoaderData',
245
- kind: 'hook',
246
- signature: 'useLoaderData<T>(): T',
247
- summary:
248
- 'Access the data returned by the current route\'s `loader` function. The loader runs before the route component mounts; its return value is cached and available synchronously via this hook. Generic over the loader return type.',
249
- example: `// Route: { path: "/user/:id", component: User, loader: ({ params }) => fetchUser(params.id) }
250
-
251
- const User = () => {
252
- const data = useLoaderData<UserData>()
253
- return <div>{data.name}</div>
254
- }`,
255
- seeAlso: ['useMiddlewareData', 'useRoute'],
256
- },
257
- {
258
- name: 'redirect',
259
- kind: 'function',
260
- signature: 'redirect(url: string, status?: 301 | 302 | 303 | 307 | 308): never',
261
- summary:
262
- "Throw inside a route loader to redirect the navigation BEFORE the layout renders. On SSR (initial nav), the thrown error is converted by `@pyreon/server`'s handler into a real HTTP `302`/`307` `Location:` response — no layout HTML leaves the server. On CSR (subsequent nav), the redirect propagates through the navigate flow and triggers `router.replace()` before any matched route's component mounts. Replaces the fragile `onMount + router.push()` workaround for auth-gates under nested-layout dev SSR + hydration. Default status is `307` (Temporary Redirect, method-preserving).",
263
- example: `// src/routes/app/_layout.tsx
264
- import { redirect, type LoaderContext } from "@pyreon/router"
265
-
266
- export async function loader(ctx: LoaderContext) {
267
- // SSR: read from request headers; CSR: read from document.cookie
268
- const cookie = ctx.request?.headers.get("cookie")
269
- ?? (typeof document !== "undefined" ? document.cookie : "")
270
- const sid = /(?:^|;\\s*)sid=([^;]+)/.exec(cookie)?.[1]
271
- if (!sid) redirect("/login")
272
- const session = await getSession(sid)
273
- if (!session) redirect("/login")
274
- return { session }
275
- }`,
276
- mistakes: [
277
- 'Calling `redirect()` outside a loader (in a component body, an event handler, etc.) — the helper expects to be caught by the loader-runner. For imperative redirects from event handlers, use `router.replace(target)` instead.',
278
- "Forgetting to make `LoaderContext.request` access optional. It's populated only on SSR; CSR loaders see `request: undefined`. Read both: `ctx.request?.headers.get('cookie') ?? document.cookie`.",
279
- 'Using `redirect()` for control-flow that should be a `<Match>` / `<Show>` conditional — the helper is for redirecting the URL, not for branching the rendered output.',
280
- 'Returning `redirect()` instead of throwing it. The helper has return type `never` and throws — `return redirect(...)` is misleading and may suppress the throw under TS strict-null checks.',
281
- 'Picking the wrong status. Default `307` preserves the request method (POST stays POST after redirect). Use `302`/`303` to force GET on the target. Use `301`/`308` for PERMANENT moves (browsers cache them aggressively).',
282
- 'Assuming `redirect()` cancels every loader in a sibling chain. The first loader to throw wins; later loaders in the same `Promise.allSettled` batch may have already started executing before the redirect short-circuits. Treat them as best-effort.',
283
- ],
284
- seeAlso: ['notFound', 'useLoaderData', 'isRedirectError'],
285
- },
286
- {
287
- name: 'isRedirectError',
288
- kind: 'function',
289
- signature: 'isRedirectError(err: unknown): boolean',
290
- summary:
291
- 'Type guard for errors thrown by `redirect()`. Used internally by the router (CSR) and `@pyreon/server` (SSR) to distinguish redirect-control-flow errors from real failures. Useful in custom error boundaries that should let redirects pass through to the framework instead of catching them.',
292
- example: `import { ErrorBoundary } from "@pyreon/core"
293
- import { isRedirectError } from "@pyreon/router"
294
-
295
- <ErrorBoundary fallback={(err, reset) => {
296
- if (isRedirectError(err)) throw err // let the framework handle it
297
- return <ErrorPage error={err} onReset={reset} />
298
- }}>
299
- <App />
300
- </ErrorBoundary>`,
301
- seeAlso: ['redirect', 'isNotFoundError', 'getRedirectInfo'],
302
- },
303
- {
304
- name: 'getRedirectInfo',
305
- kind: 'function',
306
- signature: 'getRedirectInfo(err: unknown): { url: string; status: 301 | 302 | 303 | 307 | 308 } | null',
307
- summary:
308
- "Extract the redirect URL and status from a thrown RedirectError. Returns `null` for non-redirect errors. Used by `@pyreon/server`'s SSR handler to convert the thrown error into a 302/307 `Response`.",
309
- example: `import { getRedirectInfo } from "@pyreon/router"
310
-
311
- try {
312
- await prefetchLoaderData(router, path, request)
313
- } catch (err) {
314
- const info = getRedirectInfo(err)
315
- if (info) return new Response(null, { status: info.status, headers: { Location: info.url } })
316
- throw err
317
- }`,
318
- seeAlso: ['redirect', 'isRedirectError'],
319
- },
320
- {
321
- name: 'useSearchParams',
322
- kind: 'hook',
323
- signature:
324
- 'useSearchParams<T>(defaults?: T): [get: () => T, set: (updates: Partial<T>) => Promise<void>]',
325
- summary:
326
- 'Access and update URL search params as a reactive tuple. Returns `[get, set]` where `get()` reads the current params and `set()` updates them via `replaceState`. For typed params with auto-coercion, prefer `useTypedSearchParams`.',
327
- example: `const [search, setSearch] = useSearchParams({ page: "1", sort: "name" })
328
-
329
- // Read:
330
- search().page // "1"
331
-
332
- // Write:
333
- setSearch({ page: "2" })`,
334
- seeAlso: ['useTypedSearchParams', 'useRoute'],
335
- },
336
- {
337
- name: 'useBlocker',
338
- kind: 'hook',
339
- signature: 'useBlocker(shouldBlock: () => boolean): Blocker',
340
- summary:
341
- 'Block navigation when a condition is true (e.g., unsaved form changes). Returns a `Blocker` object with `proceed()` and `reset()` methods. Also hooks into the browser\'s `beforeunload` event to warn on tab close. Uses a shared ref-counted listener for `beforeunload` — N blockers share one event handler.',
342
- example: `const blocker = useBlocker(() => form.isDirty())
343
-
344
- <Show when={blocker.isBlocked()}>
345
- <Dialog>
346
- <p>Unsaved changes. Leave anyway?</p>
347
- <button onClick={blocker.proceed}>Leave</button>
348
- <button onClick={blocker.reset}>Stay</button>
349
- </Dialog>
350
- </Show>`,
351
- seeAlso: ['useRouter'],
352
- },
353
- {
354
- name: 'onBeforeRouteLeave',
355
- kind: 'function',
356
- signature: 'onBeforeRouteLeave(guard: NavigationGuard): void',
357
- summary:
358
- 'Register a per-component navigation guard that fires when leaving the current route. Return `false` to cancel, a string path to redirect, or `undefined` to allow. Must be called during component setup.',
359
- example: `onBeforeRouteLeave((to, from) => {
360
- if (hasUnsavedChanges()) return false // cancel navigation
361
- })`,
362
- seeAlso: ['onBeforeRouteUpdate', 'useBlocker'],
363
- },
364
- {
365
- name: 'onBeforeRouteUpdate',
366
- kind: 'function',
367
- signature: 'onBeforeRouteUpdate(guard: NavigationGuard): void',
368
- summary:
369
- 'Register a per-component navigation guard that fires when the route updates but the same component stays mounted (e.g., param change `/user/1` to `/user/2`). Same return semantics as `onBeforeRouteLeave`.',
370
- example: `onBeforeRouteUpdate((to, from) => {
371
- if (to.params.id === from.params.id) return // no change
372
- // reload data for new ID...
373
- })`,
374
- seeAlso: ['onBeforeRouteLeave', 'useRoute'],
375
- },
376
- ],
377
- gotchas: [
378
- {
379
- label: 'View Transitions — what push() awaits',
380
- note: '`await router.push()` resolves after `updateCallbackDone` (DOM commit), NOT after animation finishes. It does NOT wait for `.finished` (~200-300ms). `.ready` and `.finished` get empty `.catch()` handlers so `AbortError: Transition was skipped` rejections (from interrupted transitions) do not leak as unhandled promise rejections.',
381
- },
382
- {
383
- label: 'Hash mode uses pushState',
384
- note: 'Hash mode uses `history.pushState` — NOT `window.location.hash` assignment — to avoid double-update from the hashchange event. Reading `location.hash` directly will not reflect router state; use `useRoute()` instead.',
385
- },
386
- {
387
- label: 'Imperative navigation in render body',
388
- note: '`router.push()` or `navigate()` called synchronously in the component function body causes an infinite render loop. Wrap in `onMount`, event handlers, `effect`, or any deferred execution context. The `pyreon/no-imperative-navigate-in-render` lint rule catches this.',
389
- },
390
- {
391
- label: 'Hook ordering with View Transitions',
392
- note: '`afterEach` hooks and scroll restoration fire AFTER the View Transition callback completes — not before. This means hooks see the NEW route state, which is the correct per-spec behavior but a subtle change from pre-VT versions.',
393
- },
394
- {
395
- label: 'For uses by, not key',
396
- note: '`<For>` in route lists uses `by` not `key`. `<For each={routes()} key={r => r.path}>` silently passes the key to VNode reconciliation instead of the list reconciler. Use `by={r => r.path}`.',
397
- },
398
- ],
399
- })