@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/router.ts DELETED
@@ -1,1424 +0,0 @@
1
- import { createContext, onUnmount, useContext } from '@pyreon/core'
2
- import { computed, signal } from '@pyreon/reactivity'
3
- import { buildNameIndex, buildPath, resolveRoute, stringifyQuery } from './match'
4
- import { getRedirectInfo } from './redirect'
5
- import { ScrollManager } from './scroll'
6
- import {
7
- type AfterEachHook,
8
- type Blocker,
9
- type BlockerFn,
10
- type ComponentFn,
11
- isLazy,
12
- type LoaderContext,
13
- type NavigationGuard,
14
- type NavigationGuardResult,
15
- type ResolvedRoute,
16
- type RouteMiddlewareContext,
17
- type RouteRecord,
18
- type Router,
19
- type RouterInstance,
20
- type RouterOptions,
21
- } from './types'
22
-
23
- // Evaluated once at module load — collapses to `true` in browser / happy-dom,
24
- // `false` on the server. Using a constant avoids per-call `typeof` branches
25
- // that are uncoverable in test environments.
26
- const _isBrowser = typeof window !== 'undefined'
27
- // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
28
- // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
29
- const __DEV__ = process.env.NODE_ENV !== 'production'
30
-
31
- // Dev-time counter sink — see packages/internals/perf-harness for contract.
32
- const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
33
-
34
- // ─── Router context ───────────────────────────────────────────────────────────
35
- // Context-based access: isolated per request in SSR (ALS-backed via
36
- // @pyreon/runtime-server), isolated per component tree in CSR.
37
- // Falls back to the module-level singleton for code running outside a component
38
- // tree (e.g. programmatic navigation from event handlers).
39
-
40
- export const RouterContext = createContext<RouterInstance | null>(null)
41
-
42
- // Module-level fallback — safe for CSR (single-threaded), not for concurrent SSR.
43
- // RouterProvider also sets this so legacy useRouter() calls outside the tree work.
44
- let _activeRouter: RouterInstance | null = null
45
-
46
- export function getActiveRouter(): RouterInstance | null {
47
- return useContext(RouterContext) ?? _activeRouter
48
- }
49
-
50
- export function setActiveRouter(router: RouterInstance | null): void {
51
- if (router) router._viewDepth = 0
52
- _activeRouter = router
53
- }
54
-
55
- // ─── Hooks ────────────────────────────────────────────────────────────────────
56
-
57
- export function useRouter(): Router {
58
- const router = useContext(RouterContext) ?? _activeRouter
59
- if (!router)
60
- throw new Error(
61
- '[Pyreon] No router installed. Wrap your app in <RouterProvider router={router}>.',
62
- )
63
- return router
64
- }
65
-
66
- export function useRoute<TPath extends string = string>(): () => ResolvedRoute<
67
- import('./types').ExtractParams<TPath> & Record<string, string>,
68
- Record<string, string>
69
- > {
70
- const router = useContext(RouterContext) ?? _activeRouter
71
- if (!router)
72
- throw new Error(
73
- '[Pyreon] No router installed. Wrap your app in <RouterProvider router={router}>.',
74
- )
75
- return router.currentRoute as never
76
- }
77
-
78
- /**
79
- * In-component guard: called before the component's route is left.
80
- * Return `false` to cancel, a string to redirect, or `undefined`/`true` to proceed.
81
- * Automatically removed on component unmount.
82
- *
83
- * @example
84
- * onBeforeRouteLeave((to, from) => {
85
- * if (hasUnsavedChanges()) return false
86
- * })
87
- */
88
- export function onBeforeRouteLeave(guard: NavigationGuard): () => void {
89
- const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
90
- if (!router)
91
- throw new Error(
92
- '[Pyreon] No router installed. Wrap your app in <RouterProvider router={router}>.',
93
- )
94
- // Register as a global guard that only fires when leaving the current route
95
- const currentMatched = router.currentRoute().matched
96
- const wrappedGuard: NavigationGuard = (to, from) => {
97
- // Only fire if we're actually leaving one of the matched routes
98
- const isLeaving = from.matched.some((r) => currentMatched.includes(r))
99
- if (!isLeaving) return undefined
100
- return guard(to, from)
101
- }
102
- const remove = router.beforeEach(wrappedGuard)
103
- onUnmount(() => remove())
104
- return remove
105
- }
106
-
107
- /**
108
- * In-component guard: called when the route changes but the component is reused
109
- * (e.g. `/user/1` → `/user/2`). Useful for reacting to param changes.
110
- * Automatically removed on component unmount.
111
- *
112
- * @example
113
- * onBeforeRouteUpdate((to, from) => {
114
- * if (!isValidId(to.params.id)) return false
115
- * })
116
- */
117
- export function onBeforeRouteUpdate(guard: NavigationGuard): () => void {
118
- const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
119
- if (!router)
120
- throw new Error(
121
- '[Pyreon] No router installed. Wrap your app in <RouterProvider router={router}>.',
122
- )
123
- const currentMatched = router.currentRoute().matched
124
- const wrappedGuard: NavigationGuard = (to, from) => {
125
- // Only fire when the same component is reused (matched routes overlap)
126
- const isReused = to.matched.some((r) => currentMatched.includes(r))
127
- if (!isReused) return undefined
128
- return guard(to, from)
129
- }
130
- const remove = router.beforeEach(wrappedGuard)
131
- onUnmount(() => remove())
132
- return remove
133
- }
134
-
135
- /**
136
- * Register a navigation blocker. The `fn` callback is called before each
137
- * navigation — return `true` (or resolve to `true`) to block it.
138
- *
139
- * Automatically removed on component unmount if called during component setup.
140
- * Also installs a `beforeunload` handler so the browser shows a confirmation
141
- * dialog when the user tries to close the tab while a blocker is active.
142
- *
143
- * @example
144
- * const blocker = useBlocker((to, from) => {
145
- * return hasUnsavedChanges() && !confirm("Discard changes?")
146
- * })
147
- * // later: blocker.remove()
148
- */
149
- // Shared beforeunload handler — single listener for all active blockers.
150
- // Attached when the first blocker registers, detached when the last one is
151
- // removed. Avoids listener accumulation from multiple useBlocker() calls.
152
- let _beforeUnloadRefCount = 0
153
- const _beforeUnloadHandler = (e: BeforeUnloadEvent) => {
154
- e.preventDefault()
155
- }
156
-
157
- function retainBeforeUnload(): void {
158
- if (!_isBrowser) return
159
- if (_beforeUnloadRefCount === 0) {
160
- window.addEventListener('beforeunload', _beforeUnloadHandler)
161
- }
162
- _beforeUnloadRefCount++
163
- }
164
-
165
- function releaseBeforeUnload(): void {
166
- if (!_isBrowser) return
167
- _beforeUnloadRefCount--
168
- if (_beforeUnloadRefCount <= 0) {
169
- _beforeUnloadRefCount = 0
170
- window.removeEventListener('beforeunload', _beforeUnloadHandler)
171
- }
172
- }
173
-
174
- export function useBlocker(fn: BlockerFn): Blocker {
175
- const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
176
- if (!router)
177
- throw new Error(
178
- '[Pyreon] No router installed. Wrap your app in <RouterProvider router={router}>.',
179
- )
180
- router._blockers.add(fn)
181
- retainBeforeUnload()
182
-
183
- const remove = () => {
184
- router._blockers.delete(fn)
185
- releaseBeforeUnload()
186
- }
187
-
188
- // Auto-remove when the component that called useBlocker unmounts
189
- onUnmount(() => remove())
190
-
191
- return { remove }
192
- }
193
-
194
- /**
195
- * Reactive read/write access to the current route's query parameters.
196
- *
197
- * Returns `[get, set]` where `get` is a reactive signal producing the merged
198
- * query object and `set` navigates to the current path with updated params.
199
- *
200
- * @example
201
- * const [params, setParams] = useSearchParams({ page: "1", sort: "name" })
202
- * params().page // "1" if not in URL
203
- * setParams({ page: "2" }) // navigates to ?page=2&sort=name
204
- */
205
- /**
206
- * Check if a path is active (matches the current route).
207
- * Returns a reactive boolean signal.
208
- *
209
- * - Exact mode: `/admin` matches only `/admin`
210
- * - Partial mode (default): `/admin` matches `/admin`, `/admin/users`, `/admin/settings`
211
- * Uses segment-aware prefix matching — `/admin` does NOT match `/admin-panel`
212
- *
213
- * @example
214
- * ```tsx
215
- * const isAdmin = useIsActive("/admin") // partial — matches /admin/*
216
- * const isExact = useIsActive("/admin", true) // exact — only /admin
217
- *
218
- * <div class={isAdmin() ? "active" : ""}>Admin</div>
219
- * <Show when={isAdmin()}><Badge>Active</Badge></Show>
220
- * ```
221
- */
222
- export function useIsActive(path: string, exact = false): () => boolean {
223
- const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
224
- if (!router)
225
- throw new Error(
226
- '[Pyreon] No router installed. Wrap your app in <RouterProvider router={router}>.',
227
- )
228
- return () => {
229
- const current = router.currentRoute().path
230
- if (exact) {
231
- return matchSegments(current, path, true)
232
- }
233
- if (path === '/') return current === '/'
234
- // Segment-aware prefix: /admin matches /admin/users but NOT /admin-panel
235
- return matchSegments(current, path, false)
236
- }
237
- }
238
-
239
- /** Match current path segments against a pattern that may contain `:param` segments. */
240
- function matchSegments(current: string, pattern: string, exact: boolean): boolean {
241
- const cs = current.split('/').filter(Boolean)
242
- const ps = pattern.split('/').filter(Boolean)
243
- if (exact) {
244
- if (cs.length !== ps.length) return false
245
- return ps.every((seg, i) => seg.startsWith(':') || seg === cs[i])
246
- }
247
- if (ps.length > cs.length) return false
248
- return ps.every((seg, i) => seg.startsWith(':') || seg === cs[i])
249
- }
250
-
251
- /** Schema entry for typed search params. */
252
- export type SearchParamSchema = {
253
- [key: string]: 'string' | 'number' | 'boolean'
254
- }
255
-
256
- /** Infer the typed result from a search param schema. */
257
- type InferSearchParams<T extends SearchParamSchema> = {
258
- [K in keyof T]: T[K] extends 'number' ? number : T[K] extends 'boolean' ? boolean : string
259
- }
260
-
261
- /**
262
- * Read and write URL search params reactively.
263
- *
264
- * @example Basic (untyped)
265
- * ```ts
266
- * const [params, setParams] = useSearchParams({ page: "1" })
267
- * params().page // "1"
268
- * setParams({ page: "2" }) // updates URL
269
- * ```
270
- *
271
- * @example Typed with schema
272
- * ```ts
273
- * const [params, setParams] = useSearchParams({
274
- * page: 'number',
275
- * sort: 'string',
276
- * desc: 'boolean',
277
- * })
278
- * params().page // number (auto-coerced)
279
- * params().desc // boolean
280
- * ```
281
- */
282
- export function useSearchParams<T extends Record<string, string>>(
283
- defaults?: T,
284
- ): [get: () => T, set: (updates: Partial<T>) => Promise<void>] {
285
- const router = _getRouter()
286
- const get = (): T => {
287
- const query = router.currentRoute().query
288
- if (!defaults) return query as T
289
- return { ...defaults, ...query } as T
290
- }
291
- const set = (updates: Partial<T>): Promise<void> => {
292
- const merged = { ...get(), ...updates }
293
- const path = router.currentRoute().path + stringifyQuery(merged as Record<string, string>)
294
- return router.replace(path)
295
- }
296
- return [get, set]
297
- }
298
-
299
- /**
300
- * Typed search params with auto-coercion.
301
- *
302
- * Schema values define the type: `'string'`, `'number'`, or `'boolean'`.
303
- * Query string values are automatically coerced to the declared type.
304
- *
305
- * @example
306
- * ```ts
307
- * const [params, setParams] = useTypedSearchParams({
308
- * page: 'number',
309
- * sort: 'string',
310
- * desc: 'boolean',
311
- * })
312
- * params().page // number (coerced from "3" → 3)
313
- * params().desc // boolean (coerced from "true" → true)
314
- * setParams({ page: 2 }) // updates URL with ?page=2
315
- * ```
316
- */
317
- export function useTypedSearchParams<T extends SearchParamSchema>(
318
- schema: T,
319
- ): [
320
- get: () => InferSearchParams<T>,
321
- set: (updates: Partial<InferSearchParams<T>>) => Promise<void>,
322
- ] {
323
- const router = _getRouter()
324
- const get = (): InferSearchParams<T> => {
325
- const query = router.currentRoute().query
326
- const result: Record<string, unknown> = {}
327
- for (const [key, type] of Object.entries(schema)) {
328
- const raw = query[key]
329
- if (type === 'number') {
330
- const n = raw !== undefined ? Number(raw) : 0
331
- result[key] = Number.isNaN(n) ? 0 : n
332
- } else if (type === 'boolean') result[key] = raw === 'true' || raw === '1'
333
- else result[key] = raw ?? ''
334
- }
335
- return result as InferSearchParams<T>
336
- }
337
- const set = (updates: Partial<InferSearchParams<T>>): Promise<void> => {
338
- const current = get()
339
- const merged: Record<string, string> = {}
340
- for (const [k, v] of Object.entries({ ...current, ...updates })) {
341
- merged[k] = String(v)
342
- }
343
- const path = router.currentRoute().path + stringifyQuery(merged)
344
- return router.replace(path)
345
- }
346
- return [get, set]
347
- }
348
-
349
- /**
350
- * Read the validated search params from the current route's `validateSearch`.
351
- * Returns a reactive accessor that re-evaluates when the route changes.
352
- *
353
- * The generic `T` should match the return type of your `validateSearch` function.
354
- *
355
- * @example
356
- * ```tsx
357
- * // Route config:
358
- * { path: '/search', validateSearch: (raw) => ({
359
- * page: Number(raw.page) || 1,
360
- * q: raw.q ?? '',
361
- * }), component: SearchPage }
362
- *
363
- * // In SearchPage:
364
- * const search = useValidatedSearch<{ page: number; q: string }>()
365
- * // search().page — typed as number
366
- * // search().q — typed as string
367
- * ```
368
- */
369
- export function useValidatedSearch<
370
- T extends Record<string, unknown> = Record<string, unknown>,
371
- >(): () => T {
372
- const router = _getRouter()
373
- // Structural sharing: cache the previous result and return it if
374
- // shallow-equal to the new one. Prevents downstream re-renders when
375
- // unrelated query params change but the validated subset didn't.
376
- let prev: T | null = null
377
- return () => {
378
- const next = router.currentRoute().search as T
379
- if (prev && shallowEqual(prev, next)) return prev
380
- prev = next
381
- return next
382
- }
383
- }
384
-
385
- /** Shallow equality check for plain objects — keys + strict value comparison. */
386
- function shallowEqual<T extends Record<string, unknown>>(a: T, b: T): boolean {
387
- const keysA = Object.keys(a)
388
- const keysB = Object.keys(b)
389
- if (keysA.length !== keysB.length) return false
390
- for (const key of keysA) {
391
- if (a[key] !== b[key]) return false
392
- }
393
- return true
394
- }
395
-
396
- function _getRouter(): RouterInstance {
397
- const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
398
- if (!router)
399
- throw new Error(
400
- '[Pyreon] No router installed. Wrap your app in <RouterProvider router={router}>.',
401
- )
402
- return router
403
- }
404
-
405
- /**
406
- * Returns true while a navigation is in progress (guards + loaders running).
407
- * Use this to show loading indicators during route transitions.
408
- *
409
- * @example
410
- * ```tsx
411
- * const isNavigating = useTransition()
412
- * <Show when={isNavigating}>
413
- * <LoadingBar />
414
- * </Show>
415
- * ```
416
- */
417
- export function useTransition(): () => boolean {
418
- const router = _getRouter()
419
- return () => router._loadingSignal() > 0
420
- }
421
-
422
- /**
423
- * Read data accumulated by route middleware.
424
- *
425
- * @example
426
- * ```ts
427
- * // In middleware:
428
- * const authMiddleware: RouteMiddleware = async (ctx) => {
429
- * ctx.data.user = await getUser(ctx.to)
430
- * if (!ctx.data.user) return '/login'
431
- * }
432
- *
433
- * // In component:
434
- * const data = useMiddlewareData()
435
- * const user = () => data().user as User
436
- * ```
437
- */
438
- export function useMiddlewareData(): () => Record<string, unknown> {
439
- const router = _getRouter()
440
- return () => router.currentRoute()._middlewareData ?? {}
441
- }
442
-
443
- // ─── Factory ──────────────────────────────────────────────────────────────────
444
-
445
- export function createRouter<TNames extends string = string>(
446
- options: RouterOptions | RouteRecord[],
447
- ): Router<TNames> {
448
- const opts: RouterOptions = Array.isArray(options) ? { routes: options } : options
449
- const {
450
- routes,
451
- mode = 'hash',
452
- scrollBehavior,
453
- onError,
454
- maxCacheSize = 100,
455
- trailingSlash = 'strip',
456
- } = opts
457
-
458
- // Base path only applies to history mode — hash-based routing already namespaces via #
459
- const base = mode === 'history' ? normalizeBase(opts.base ?? '') : ''
460
-
461
- // Pre-built O(1) name → record index. Computed once at startup.
462
- const nameIndex = buildNameIndex(routes)
463
-
464
- const guards: NavigationGuard[] = []
465
- const afterHooks: AfterEachHook[] = []
466
- const scrollManager = new ScrollManager(scrollBehavior)
467
-
468
- // Navigation generation counter — cancels in-flight navigations when a newer
469
- // one starts. Prevents out-of-order completion from stale async guards.
470
- let _navGen = 0
471
-
472
- // ── Initial location ──────────────────────────────────────────────────────
473
-
474
- const getInitialLocation = (): string => {
475
- // SSR: use explicitly provided url (strip base if present)
476
- if (opts.url) return stripBase(opts.url, base)
477
- if (!_isBrowser) return '/'
478
- if (mode === 'history') {
479
- return stripBase(window.location.pathname, base) + window.location.search
480
- }
481
- const hash = window.location.hash
482
- return hash.startsWith('#') ? hash.slice(1) || '/' : '/'
483
- }
484
-
485
- const getCurrentLocation = (): string => {
486
- if (!_isBrowser) return currentPath()
487
- if (mode === 'history') {
488
- return stripBase(window.location.pathname, base) + window.location.search
489
- }
490
- const hash = window.location.hash
491
- return hash.startsWith('#') ? hash.slice(1) || '/' : '/'
492
- }
493
-
494
- // ── Signals ───────────────────────────────────────────────────────────────
495
-
496
- const currentPath = signal(normalizeTrailingSlash(getInitialLocation(), trailingSlash))
497
- const currentRoute = computed<ResolvedRoute>(() => resolveRoute(currentPath(), routes))
498
-
499
- // Browser event listeners — stored so destroy() can remove them.
500
- // Ternary-bound on `_isBrowser` (a typeof-derived const) so the lint rule
501
- // can trace these to an SSR-safe shape without needing `if (_isBrowser &&
502
- // handler)` contortions at every use site.
503
- const _popstateHandler: (() => void) | null =
504
- _isBrowser && mode === 'history' ? () => currentPath.set(getCurrentLocation()) : null
505
- const _hashchangeHandler: (() => void) | null =
506
- _isBrowser && mode !== 'history' ? () => currentPath.set(getCurrentLocation()) : null
507
-
508
- if (_popstateHandler) window.addEventListener('popstate', _popstateHandler)
509
- if (_hashchangeHandler) window.addEventListener('hashchange', _hashchangeHandler)
510
-
511
- const componentCache = new Map<RouteRecord, ComponentFn>()
512
- const loadingSignal = signal(0)
513
-
514
- // ── Navigation ────────────────────────────────────────────────────────────
515
-
516
- type GuardOutcome =
517
- | { action: 'continue' }
518
- | { action: 'cancel' }
519
- | { action: 'redirect'; target: string }
520
-
521
- async function evaluateGuard(
522
- guard: NavigationGuard,
523
- to: ResolvedRoute,
524
- from: ResolvedRoute,
525
- gen: number,
526
- ): Promise<GuardOutcome> {
527
- const result = await runGuard(guard, to, from)
528
- if (gen !== _navGen) return { action: 'cancel' }
529
- if (result === false) return { action: 'cancel' }
530
- if (typeof result === 'string') return { action: 'redirect', target: result }
531
- return { action: 'continue' }
532
- }
533
-
534
- async function runRouteGuards(
535
- records: RouteRecord[],
536
- guardKey: 'beforeLeave' | 'beforeEnter',
537
- to: ResolvedRoute,
538
- from: ResolvedRoute,
539
- gen: number,
540
- ): Promise<GuardOutcome> {
541
- for (const record of records) {
542
- const raw = record[guardKey]
543
- if (!raw) continue
544
- const routeGuards = Array.isArray(raw) ? raw : [raw]
545
- for (const guard of routeGuards) {
546
- const outcome = await evaluateGuard(guard, to, from, gen)
547
- if (outcome.action !== 'continue') return outcome
548
- }
549
- }
550
- return { action: 'continue' }
551
- }
552
-
553
- async function runGlobalGuards(
554
- globalGuards: NavigationGuard[],
555
- to: ResolvedRoute,
556
- from: ResolvedRoute,
557
- gen: number,
558
- ): Promise<GuardOutcome> {
559
- for (const guard of globalGuards) {
560
- const outcome = await evaluateGuard(guard, to, from, gen)
561
- if (outcome.action !== 'continue') return outcome
562
- }
563
- return { action: 'continue' }
564
- }
565
-
566
- function processLoaderResult(
567
- result: PromiseSettledResult<unknown>,
568
- record: RouteRecord,
569
- ac: AbortController,
570
- to: ResolvedRoute,
571
- ): GuardOutcome {
572
- if (result.status === 'fulfilled') {
573
- router._loaderData.set(record, result.value)
574
- return { action: 'continue' }
575
- }
576
- if (ac.signal.aborted) return { action: 'continue' }
577
- // `redirect()` from a loader: propagate as a router-level redirect so the
578
- // navigate flow re-runs against the target path BEFORE the matched route's
579
- // layout / page mounts. Bypasses the user-supplied `_onError` hook — a
580
- // redirect is intentional flow control, not an error.
581
- const info = getRedirectInfo(result.reason)
582
- if (info) return { action: 'redirect', target: info.url }
583
- if (router._onError) {
584
- const cancel = router._onError(result.reason, to)
585
- if (cancel === false) return { action: 'cancel' }
586
- }
587
- router._loaderData.set(record, undefined)
588
- return { action: 'continue' }
589
- }
590
-
591
- function syncBrowserUrl(path: string, replace: boolean): void {
592
- if (!_isBrowser) return
593
- const url = mode === 'history' ? `${base}${path}` : `#${path}`
594
- if (replace) {
595
- window.history.replaceState(null, '', url)
596
- } else {
597
- window.history.pushState(null, '', url)
598
- }
599
- }
600
-
601
- function resolveRedirect(to: ResolvedRoute): string | null {
602
- const leaf = to.matched[to.matched.length - 1]
603
- if (!leaf?.redirect) return null
604
- return sanitizePath(typeof leaf.redirect === 'function' ? leaf.redirect(to) : leaf.redirect)
605
- }
606
-
607
- async function runAllGuards(
608
- to: ResolvedRoute,
609
- from: ResolvedRoute,
610
- gen: number,
611
- ): Promise<GuardOutcome> {
612
- const leaveOutcome = await runRouteGuards(from.matched, 'beforeLeave', to, from, gen)
613
- if (leaveOutcome.action !== 'continue') return leaveOutcome
614
-
615
- const enterOutcome = await runRouteGuards(to.matched, 'beforeEnter', to, from, gen)
616
- if (enterOutcome.action !== 'continue') return enterOutcome
617
-
618
- return runGlobalGuards(guards, to, from, gen)
619
- }
620
-
621
- /** Default cache key: path + serialized params */
622
- function defaultLoaderKey(
623
- record: RouteRecord,
624
- ctx: Pick<LoaderContext, 'params' | 'query'>,
625
- ): string {
626
- return `${record.path}:${JSON.stringify(ctx.params)}`
627
- }
628
-
629
- /** Get cache key for a route record + context. */
630
- function getCacheKey(record: RouteRecord, ctx: Pick<LoaderContext, 'params' | 'query'>): string {
631
- return record.loaderKey ? record.loaderKey(ctx) : defaultLoaderKey(record, ctx)
632
- }
633
-
634
- /** Check if a cached entry is still fresh (not expired by gcTime). */
635
- function isCacheFresh(entry: { timestamp: number }, record: RouteRecord): boolean {
636
- const gcTime = record.gcTime ?? 300_000 // 5 min default
637
- if (gcTime === 0) return false // caching disabled
638
- return Date.now() - entry.timestamp < gcTime
639
- }
640
-
641
- /**
642
- * Bounded set into `_loaderCache`: evicts the oldest entry (insertion-order
643
- * FIFO) when the cap is exceeded. The `gcTime` TTL handles staleness, but
644
- * without a size cap a long-running SPA navigating across many distinct
645
- * loader keys (e.g. `/posts/:id` with hundreds of unique IDs) would
646
- * accumulate cache entries indefinitely until manual `invalidateLoader()`
647
- * — `_maxCacheSize` was wired through from `RouterOptions.maxCacheSize`
648
- * (default 100) but the loader cache write paths never read it. Mirrors
649
- * the same pattern used for `_componentCache` in `components.tsx`.
650
- */
651
- function loaderCacheSet(key: string, data: unknown): void {
652
- router._loaderCache.set(key, { data, timestamp: Date.now() })
653
- if (router._loaderCache.size > router._maxCacheSize) {
654
- // Map iterates in insertion order — first key is oldest
655
- const oldest = router._loaderCache.keys().next().value as string | undefined
656
- if (oldest !== undefined) router._loaderCache.delete(oldest)
657
- }
658
- }
659
-
660
- /**
661
- * Execute a loader with cache + dedup:
662
- * 1. Cache hit + fresh → return cached data (skip loader entirely)
663
- * 2. In-flight for same key → dedup (return existing promise)
664
- * 3. Otherwise → run loader, cache result, clean up in-flight
665
- */
666
- function executeLoader(record: RouteRecord, loaderCtx: LoaderContext): Promise<unknown> {
667
- if (!record.loader) return Promise.resolve(undefined)
668
-
669
- const key = getCacheKey(record, loaderCtx)
670
-
671
- // 1. Cache hit — skip for SWR routes (they always revalidate via the SWR path)
672
- if (!record.staleWhileRevalidate) {
673
- const cached = router._loaderCache.get(key)
674
- if (cached && isCacheFresh(cached, record)) {
675
- if (__DEV__) _countSink.__pyreon_count__?.('router.loaderCache.hit')
676
- return Promise.resolve(cached.data)
677
- }
678
- }
679
-
680
- // 2. Dedup in-flight — but only if the in-flight signal is still live.
681
- // Pre-fix: nav-1 starts loader (signal=ac1.signal). User navigates again
682
- // to the same path → nav-2's `router.push` first calls `_abortController?.abort()`
683
- // (aborting ac1), then calls executeLoader. The Map still holds nav-1's
684
- // promise (the .catch hasn't run yet); deduping returns it, but its
685
- // signal is already aborted → nav-2 ends up with a rejected promise
686
- // even though it has its own fresh ac2.signal. Now we check liveness.
687
- const inflight = router._loaderInflight.get(key)
688
- if (inflight && !inflight.signal.aborted) return inflight.promise
689
-
690
- // 3. Execute. Wrap with `Promise.resolve().then(...)` so a SYNCHRONOUS
691
- // throw from the loader (`redirect('/login')` / `notFound()` / a plain
692
- // `throw new Error(...)`) becomes a rejected promise the `.catch` can
693
- // handle — instead of escaping past the promise chain and surfacing as
694
- // an unhandled exception in `runBlockingLoaders`'s `Promise.allSettled`.
695
- if (__DEV__) _countSink.__pyreon_count__?.('router.loaderRun')
696
- const promise = Promise.resolve()
697
- .then(() => record.loader!(loaderCtx))
698
- .then((data) => {
699
- loaderCacheSet(key, data)
700
- // Only delete if WE'RE still the registered in-flight (a later nav
701
- // may have replaced the entry with a fresh promise).
702
- if (router._loaderInflight.get(key)?.promise === promise) {
703
- router._loaderInflight.delete(key)
704
- }
705
- return data
706
- })
707
- .catch((err) => {
708
- if (router._loaderInflight.get(key)?.promise === promise) {
709
- router._loaderInflight.delete(key)
710
- }
711
- throw err
712
- })
713
-
714
- router._loaderInflight.set(key, { promise, signal: loaderCtx.signal })
715
- return promise
716
- }
717
-
718
- async function runBlockingLoaders(
719
- records: RouteRecord[],
720
- to: ResolvedRoute,
721
- gen: number,
722
- ac: AbortController,
723
- ): Promise<GuardOutcome> {
724
- const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
725
- const results = await Promise.allSettled(records.map((r) => executeLoader(r, loaderCtx)))
726
- if (gen !== _navGen) return { action: 'cancel' }
727
- for (let i = 0; i < records.length; i++) {
728
- const result = results[i]
729
- const record = records[i]
730
- if (!result || !record) continue
731
- const outcome = processLoaderResult(result, record, ac, to)
732
- // Short-circuit on first redirect or cancel — later loaders' results
733
- // are irrelevant once we know the navigation isn't committing here.
734
- if (outcome.action !== 'continue') return outcome
735
- }
736
- return { action: 'continue' }
737
- }
738
-
739
- /** Fire-and-forget background revalidation for stale-while-revalidate routes. */
740
- function revalidateSwrLoaders(records: RouteRecord[], to: ResolvedRoute, ac: AbortController) {
741
- const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
742
- for (const r of records) {
743
- if (!r.loader) continue
744
- // Bypass cache for revalidation — always fetch fresh
745
- r.loader(loaderCtx)
746
- .then((data) => {
747
- if (!ac.signal.aborted) {
748
- router._loaderData.set(r, data)
749
- // Update cache with fresh data
750
- const key = getCacheKey(r, loaderCtx)
751
- loaderCacheSet(key, data)
752
- // Bump loadingSignal to trigger reactive re-render with fresh data
753
- loadingSignal.update((n) => n + 1)
754
- loadingSignal.update((n) => n - 1)
755
- }
756
- })
757
- .catch((err: unknown) => {
758
- // Background revalidation failed — the stale data remains valid
759
- // and on screen, so this MUST NOT cancel/redirect the (already
760
- // settled) navigation. But an empty catch is the silent-failure
761
- // anti-pattern the project forbids: a persistently-failing
762
- // revalidation loader (auth expiry, API outage, a bug thrown in
763
- // the loader) produces ZERO signal — the developer sees
764
- // permanently-stale data with nothing pointing at the cause.
765
- // Surface it like every other loader error (dev warn + the
766
- // user-supplied onError hook) WITHOUT acting on the return
767
- // value. This path was dead code until the SWR prune fix
768
- // (#617) made `revalidateSwrLoaders` actually run for the
769
- // nav-away/back case.
770
- if (__DEV__) {
771
- // oxlint-disable-next-line no-console
772
- console.warn(
773
- `[Pyreon Router] SWR background revalidation failed for "${r.path}" — serving stale data:`,
774
- err,
775
- )
776
- }
777
- router._onError?.(err, to)
778
- })
779
- }
780
- }
781
-
782
- async function runLoaders(
783
- to: ResolvedRoute,
784
- gen: number,
785
- ac: AbortController,
786
- ): Promise<GuardOutcome> {
787
- const loadableRecords = to.matched.filter((r) => r.loader)
788
- if (loadableRecords.length === 0) return { action: 'continue' }
789
-
790
- const blocking: RouteRecord[] = []
791
- const swr: RouteRecord[] = []
792
- for (const r of loadableRecords) {
793
- if (r.staleWhileRevalidate && router._loaderData.has(r)) {
794
- swr.push(r)
795
- } else {
796
- blocking.push(r)
797
- }
798
- }
799
-
800
- if (blocking.length > 0) {
801
- const outcome = await runBlockingLoaders(blocking, to, gen, ac)
802
- if (outcome.action !== 'continue') return outcome
803
- }
804
- if (swr.length > 0) revalidateSwrLoaders(swr, to, ac)
805
- return { action: 'continue' }
806
- }
807
-
808
- async function commitNavigation(
809
- path: string,
810
- replace: boolean,
811
- to: ResolvedRoute,
812
- from: ResolvedRoute,
813
- ): Promise<void> {
814
- scrollManager.save(from.path)
815
-
816
- const doCommit = () => {
817
- currentPath.set(path)
818
- syncBrowserUrl(path, replace)
819
-
820
- if (_isBrowser && to.meta.title) {
821
- document.title = to.meta.title
822
- }
823
-
824
- // Drop loader data for routes no longer matched — EXCEPT
825
- // `staleWhileRevalidate` routes. SWR's entire contract is "on
826
- // return to this route, serve the previously-loaded data stale
827
- // while revalidating in the background"; that requires the data to
828
- // SURVIVE navigating away. Pruning it here (the pre-fix behaviour)
829
- // meant `runLoaders`' `_loaderData.has(r)` gate was always false on
830
- // return, so `revalidateSwrLoaders` never ran and every visit went
831
- // through the blocking path — `staleWhileRevalidate` was a no-op
832
- // for the realistic nav-away/back case. Retained SWR data is
833
- // bounded by the number of SWR route RECORDS (a developer-declared
834
- // set; param routes share one record), and per-key freshness/LRU
835
- // is still handled by `_loaderCache`.
836
- for (const record of router._loaderData.keys()) {
837
- if (!to.matched.includes(record) && !record.staleWhileRevalidate) {
838
- router._loaderData.delete(record)
839
- }
840
- }
841
- }
842
-
843
- // Use View Transitions API when available and not explicitly disabled.
844
- // Route meta can opt out: meta: { viewTransition: false }
845
- const useVT =
846
- _isBrowser &&
847
- to.meta.viewTransition !== false &&
848
- typeof (document as any).startViewTransition === 'function'
849
-
850
- if (useVT) {
851
- // `startViewTransition(cb)` runs `cb` inside an async transition. Its
852
- // `.updateCallbackDone` promise resolves as soon as the callback
853
- // finishes — DOM has swapped, state is live, but the fade/slide
854
- // animation is still running. That's what `await router.push()`
855
- // should wait for: callers need the new route live before they act
856
- // (e.g. focus an element, inspect `location`, query a new DOM node);
857
- // they don't want to block on the full animation (`.finished`),
858
- // which would add 200-300ms to every programmatic navigation.
859
- //
860
- // Before this await, `commitNavigation` was sync: the transition
861
- // callback ran in a later microtask, so `await router.push()`
862
- // resolved BEFORE the DOM swap. Browser smoke tests had to opt out
863
- // of View Transitions per-route via `meta: { viewTransition: false }`
864
- // to stay deterministic — a flag whose only purpose was to paper
865
- // over this bug.
866
- type ViewTransitionLike = {
867
- updateCallbackDone?: Promise<void>
868
- ready?: Promise<void>
869
- finished?: Promise<void>
870
- }
871
- const vt = (
872
- document as { startViewTransition?: (cb: () => void) => ViewTransitionLike | undefined }
873
- ).startViewTransition!(() => {
874
- doCommit()
875
- })
876
- // `startViewTransition` may return `undefined` in test doubles
877
- // that shim it with a bare `(cb) => cb()`. Guard accordingly.
878
- if (vt) {
879
- // The ViewTransition object exposes THREE promises —
880
- // `updateCallbackDone`, `ready`, `finished`. When a newer
881
- // `startViewTransition()` starts while this one is in flight,
882
- // `ready` and `finished` reject with `AbortError: Transition
883
- // was skipped`. We only need to wait on `updateCallbackDone`
884
- // (the DOM-commit signal), but the other two MUST still be
885
- // handled or the rejection surfaces as an unhandled promise
886
- // rejection that breaks test runners and CI dashboards.
887
- vt.ready?.catch(() => {})
888
- vt.finished?.catch(() => {})
889
- if (vt.updateCallbackDone) {
890
- try {
891
- await vt.updateCallbackDone
892
- } catch {
893
- // `updateCallbackDone` rejects if the callback itself throws.
894
- // The DOM may be in a partial-commit state; the newer
895
- // navigation (if any) will re-commit. Swallow so the
896
- // navigation chain never hangs on a transition error.
897
- }
898
- }
899
- }
900
- } else {
901
- doCommit()
902
- }
903
-
904
- for (const hook of afterHooks) {
905
- try {
906
- hook(to, from)
907
- } catch (err) {
908
- if (__DEV__) {
909
- console.warn(`[Pyreon Router] afterEach hook threw an error:`, err)
910
- }
911
- }
912
- }
913
-
914
- if (_isBrowser) {
915
- queueMicrotask(() => scrollManager.restore(to, from))
916
- }
917
- }
918
-
919
- async function checkBlockers(
920
- to: ResolvedRoute,
921
- from: ResolvedRoute,
922
- gen: number,
923
- ): Promise<'continue' | 'cancel'> {
924
- for (const blocker of router._blockers) {
925
- const blocked = await blocker(to, from)
926
- if (gen !== _navGen || blocked) return 'cancel'
927
- }
928
- return 'continue'
929
- }
930
-
931
- /** Run per-route middleware chain. Middleware from all matched routes execute in order. */
932
- async function runMiddleware(
933
- to: ResolvedRoute,
934
- from: ResolvedRoute,
935
- gen: number,
936
- ): Promise<
937
- { action: 'continue' } | { action: 'cancel' } | { action: 'redirect'; target: string }
938
- > {
939
- const ctx: RouteMiddlewareContext = { to, from, data: {} }
940
-
941
- for (const record of to.matched) {
942
- if (!record.middleware) continue
943
- const mws = Array.isArray(record.middleware) ? record.middleware : [record.middleware]
944
- for (const mw of mws) {
945
- if (gen !== _navGen) return { action: 'cancel' }
946
- const result = await mw(ctx)
947
- if (result === false) return { action: 'cancel' }
948
- if (typeof result === 'string') return { action: 'redirect', target: result }
949
- }
950
- }
951
-
952
- // Store middleware data on the resolved route for component access
953
- to._middlewareData = ctx.data
954
- return { action: 'continue' }
955
- }
956
-
957
- async function navigate(rawPath: string, replace: boolean, redirectDepth = 0): Promise<void> {
958
- if (__DEV__) _countSink.__pyreon_count__?.('router.navigate')
959
- router._navigationStartTime = Date.now()
960
- if (redirectDepth > 10) {
961
- if (__DEV__) {
962
- // oxlint-disable-next-line no-console
963
- console.warn(
964
- `[Pyreon] Navigation to "${rawPath}" aborted: redirect depth exceeded 10 levels. ` +
965
- 'This likely indicates a redirect loop in your route configuration.',
966
- )
967
- }
968
- return
969
- }
970
-
971
- const path = normalizeTrailingSlash(rawPath, trailingSlash)
972
- const gen = ++_navGen
973
- loadingSignal.update((n) => n + 1)
974
-
975
- const to = resolveRoute(path, routes)
976
- const from = currentRoute()
977
-
978
- const redirectTarget = resolveRedirect(to)
979
- if (redirectTarget !== null) {
980
- loadingSignal.update((n) => n - 1)
981
- return navigate(redirectTarget, replace, redirectDepth + 1)
982
- }
983
-
984
- const blockerResult = await checkBlockers(to, from, gen)
985
- if (blockerResult !== 'continue') {
986
- loadingSignal.update((n) => n - 1)
987
- return
988
- }
989
-
990
- // Run per-route middleware chain (before guards)
991
- const mwResult = await runMiddleware(to, from, gen)
992
- if (mwResult.action !== 'continue') {
993
- loadingSignal.update((n) => n - 1)
994
- if (mwResult.action === 'redirect') {
995
- return navigate(sanitizePath(mwResult.target), replace, redirectDepth + 1)
996
- }
997
- return
998
- }
999
-
1000
- const guardOutcome = await runAllGuards(to, from, gen)
1001
- if (guardOutcome.action !== 'continue') {
1002
- loadingSignal.update((n) => n - 1)
1003
- if (guardOutcome.action === 'redirect') {
1004
- return navigate(sanitizePath(guardOutcome.target), replace, redirectDepth + 1)
1005
- }
1006
- return
1007
- }
1008
-
1009
- router._abortController?.abort()
1010
- const ac = new AbortController()
1011
- router._abortController = ac
1012
-
1013
- const loaderOutcome = await runLoaders(to, gen, ac)
1014
- if (loaderOutcome.action !== 'continue') {
1015
- loadingSignal.update((n) => n - 1)
1016
- if (loaderOutcome.action === 'redirect') {
1017
- return navigate(sanitizePath(loaderOutcome.target), replace, redirectDepth + 1)
1018
- }
1019
- return
1020
- }
1021
-
1022
- await commitNavigation(path, replace, to, from)
1023
- loadingSignal.update((n) => n - 1)
1024
- }
1025
-
1026
- // ── isReady promise ─────────────────────────────────────────────────────
1027
- // Resolves after the first navigation (including guards + loaders) completes.
1028
-
1029
- let _readyResolve: (() => void) | null = null
1030
- const _readyPromise = new Promise<void>((resolve) => {
1031
- _readyResolve = resolve
1032
- })
1033
-
1034
- // ── Public router object ──────────────────────────────────────────────────
1035
-
1036
- const router: RouterInstance = {
1037
- routes,
1038
- mode,
1039
- _base: base,
1040
- currentRoute,
1041
- _currentPath: currentPath,
1042
- _currentRoute: currentRoute,
1043
- _componentCache: componentCache,
1044
- _loadingSignal: loadingSignal,
1045
- _scrollPositions: new Map(),
1046
- _scrollBehavior: scrollBehavior,
1047
- _viewDepth: 0,
1048
- _erroredChunks: new Set(),
1049
- _loaderData: new Map(),
1050
- _abortController: null,
1051
- _blockers: new Set(),
1052
- _readyResolve,
1053
- _readyPromise,
1054
- _onError: onError,
1055
- _maxCacheSize: maxCacheSize,
1056
- _navigationStartTime: Date.now(),
1057
- _loaderCache: new Map(),
1058
- _loaderInflight: new Map(),
1059
-
1060
- async push(
1061
- location:
1062
- | string
1063
- | { name: string; params?: Record<string, string>; query?: Record<string, string> },
1064
- ) {
1065
- if (typeof location === 'string') {
1066
- const resolved = resolveRelativePath(location, currentPath())
1067
- return navigate(sanitizePath(resolved), false)
1068
- }
1069
- const path = resolveNamedPath(
1070
- location.name,
1071
- location.params ?? {},
1072
- location.query ?? {},
1073
- nameIndex,
1074
- )
1075
- return navigate(path, false)
1076
- },
1077
-
1078
- async replace(
1079
- location:
1080
- | string
1081
- | { name: string; params?: Record<string, string>; query?: Record<string, string> },
1082
- ) {
1083
- if (typeof location === 'string') {
1084
- const resolved = resolveRelativePath(location, currentPath())
1085
- return navigate(sanitizePath(resolved), true)
1086
- }
1087
- const path = resolveNamedPath(
1088
- location.name,
1089
- location.params ?? {},
1090
- location.query ?? {},
1091
- nameIndex,
1092
- )
1093
- return navigate(path, true)
1094
- },
1095
-
1096
- back() {
1097
- if (_isBrowser) window.history.back()
1098
- },
1099
-
1100
- forward() {
1101
- if (_isBrowser) window.history.forward()
1102
- },
1103
-
1104
- go(delta: number) {
1105
- if (_isBrowser) window.history.go(delta)
1106
- },
1107
-
1108
- beforeEach(guard: NavigationGuard) {
1109
- guards.push(guard)
1110
- return () => {
1111
- const idx = guards.indexOf(guard)
1112
- if (idx >= 0) guards.splice(idx, 1)
1113
- }
1114
- },
1115
-
1116
- afterEach(hook: AfterEachHook) {
1117
- afterHooks.push(hook)
1118
- return () => {
1119
- const idx = afterHooks.indexOf(hook)
1120
- if (idx >= 0) afterHooks.splice(idx, 1)
1121
- }
1122
- },
1123
-
1124
- loading: () => loadingSignal() > 0,
1125
-
1126
- isReady() {
1127
- return router._readyPromise
1128
- },
1129
-
1130
- async preload(
1131
- path: string,
1132
- request?: Request,
1133
- options?: { skipLoaders?: boolean },
1134
- ) {
1135
- const resolved = resolveRoute(path, routes)
1136
- // Load lazy components in parallel and populate the component cache so
1137
- // the synchronous render pass finds ready components instead of kicking
1138
- // off async imports (which would fall back to loadingComponent).
1139
- await Promise.all(
1140
- resolved.matched.map(async (record) => {
1141
- if (componentCache.has(record)) return
1142
- const raw = record.component
1143
- if (!isLazy(raw)) {
1144
- componentCache.set(record, raw)
1145
- return
1146
- }
1147
- const mod = await raw.loader()
1148
- const comp = typeof mod === 'function' ? mod : mod.default
1149
- componentCache.set(record, comp)
1150
- }),
1151
- )
1152
- // Skip the loader-running step when the caller explicitly opts out
1153
- // (used by the SSG plugin's 404 build path — parent-layout loaders
1154
- // that hit auth resources or external APIs shouldn't fire when
1155
- // generating a static 404 page). Lazy components above DO still
1156
- // resolve so the synthetic chain renders cleanly; only the
1157
- // `r.loader()` invocations are skipped.
1158
- if (options?.skipLoaders) return
1159
- // Run loaders for the matched path — uses the same code path SSR
1160
- // already relied on, so loader data ends up in `_loaderData` under the
1161
- // matched route records. Uses a LOCAL AbortController: `preload` is
1162
- // a prefetch operation and must NOT clobber `router._abortController`,
1163
- // which belongs to the active navigation. Without this, calling
1164
- // `router.preload(...)` during a navigation destroyed the nav's
1165
- // abort capability.
1166
- const ac = new AbortController()
1167
- await Promise.all(
1168
- resolved.matched
1169
- .filter((r) => r.loader)
1170
- .map(async (r) => {
1171
- // Wrap with `Promise.resolve().then(...)` so a SYNCHRONOUS
1172
- // throw — `redirect('/login')` from a sync loader, `notFound()`,
1173
- // a plain `throw new Error(...)` — becomes a rejected promise
1174
- // the surrounding Promise.all surfaces. Bare `await r.loader(...)`
1175
- // would let synchronous throws escape past the `await` and
1176
- // surface as an uncaught exception in the Vite dev SSR pipeline.
1177
- const data = await Promise.resolve().then(() =>
1178
- r.loader!({
1179
- params: resolved.params,
1180
- query: resolved.query,
1181
- signal: ac.signal,
1182
- ...(request ? { request } : {}),
1183
- }),
1184
- )
1185
- router._loaderData.set(r, data)
1186
- }),
1187
- )
1188
- },
1189
-
1190
- invalidateLoader(keyOrPredicate?: string | ((key: string) => boolean)) {
1191
- if (!keyOrPredicate) {
1192
- // Invalidate all
1193
- router._loaderCache.clear()
1194
- router._loaderInflight.clear()
1195
- return
1196
- }
1197
- if (typeof keyOrPredicate === 'string') {
1198
- router._loaderCache.delete(keyOrPredicate)
1199
- router._loaderInflight.delete(keyOrPredicate)
1200
- return
1201
- }
1202
- // Predicate
1203
- for (const key of [...router._loaderCache.keys()]) {
1204
- if (keyOrPredicate(key)) {
1205
- router._loaderCache.delete(key)
1206
- router._loaderInflight.delete(key)
1207
- }
1208
- }
1209
- },
1210
-
1211
- destroy() {
1212
- if (_popstateHandler) window.removeEventListener('popstate', _popstateHandler)
1213
- if (_hashchangeHandler) window.removeEventListener('hashchange', _hashchangeHandler)
1214
- guards.length = 0
1215
- afterHooks.length = 0
1216
- // Release beforeunload for any remaining blockers
1217
- for (let i = router._blockers.size; i > 0; i--) releaseBeforeUnload()
1218
- router._blockers.clear()
1219
- componentCache.clear()
1220
- router._loaderData.clear()
1221
- router._loaderCache.clear()
1222
- router._loaderInflight.clear()
1223
- router._abortController?.abort()
1224
- router._abortController = null
1225
- // Clear global ref so stale router doesn't survive in SSR or re-creation
1226
- if (_activeRouter === router) _activeRouter = null
1227
- if (__DEV__ && _isBrowser) {
1228
- const g = globalThis as Record<string, unknown>
1229
- if (g.__pyreon_hmr_swap__ === router._hmrSwap) {
1230
- delete g.__pyreon_hmr_swap__
1231
- }
1232
- }
1233
- },
1234
-
1235
- _resolve: (rawPath: string) => resolveRoute(rawPath, routes),
1236
-
1237
- // Dev-only HMR coordinator — see RouterInstance._hmrSwap JSDoc.
1238
- // Gated to dev+browser so it's tree-shaken from production bundles.
1239
- ...(__DEV__ && _isBrowser
1240
- ? {
1241
- _hmrSwap(id: string, mod: unknown): boolean {
1242
- const m = mod as { default?: ComponentFn } | ComponentFn | null
1243
- const next: ComponentFn | undefined =
1244
- typeof m === 'function' ? m : (m?.default ?? undefined)
1245
- // No default export in the fresh namespace (named-only edit, or
1246
- // the module no longer exports a component) — let the plugin
1247
- // fall back to an automatic reload rather than blank the route.
1248
- if (typeof next !== 'function') return false
1249
-
1250
- const matched = currentRoute().matched
1251
- let changed = false
1252
- for (const record of matched) {
1253
- const raw = record.component
1254
- if (!isLazy(raw) || !raw._hmrId) continue
1255
- if (!_hmrIdMatches(raw._hmrId, id)) continue
1256
- componentCache.set(record, next)
1257
- router._erroredChunks.delete(record)
1258
- changed = true
1259
- }
1260
- // Bump `_loadingSignal` so `RouterView`'s `depthEntry` computed
1261
- // re-emits; its `equals` compares `comp` identity, so only the
1262
- // depth whose component actually changed re-renders — every
1263
- // other depth (layout, siblings) stays mounted, signals intact.
1264
- if (changed) loadingSignal.update((n) => n + 1)
1265
- return changed
1266
- },
1267
- }
1268
- : {}),
1269
- }
1270
-
1271
- // Initial route is resolved synchronously — mark ready on next microtask
1272
- // so consumers can await isReady() before the first render.
1273
- queueMicrotask(() => {
1274
- if (router._readyResolve) {
1275
- router._readyResolve()
1276
- router._readyResolve = null
1277
- }
1278
- })
1279
-
1280
- // Expose the HMR coordinator on globalThis so `@pyreon/vite-plugin`'s
1281
- // injected `import.meta.hot.accept` handler can reach it WITHOUT importing
1282
- // `@pyreon/router` (zero import coupling — same pattern as the perf-harness
1283
- // counter sink). Last router wins; single-router apps (the norm, every
1284
- // `@pyreon/zero` app) are unaffected. Dev+browser only.
1285
- if (__DEV__ && _isBrowser && router._hmrSwap) {
1286
- // `_hmrSwap` closes over `currentRoute`/`componentCache`/`loadingSignal`
1287
- // (not `this`), so the raw reference is safe to expose and to compare by
1288
- // identity on `destroy()`.
1289
- ;(globalThis as Record<string, unknown>).__pyreon_hmr_swap__ =
1290
- router._hmrSwap
1291
- }
1292
-
1293
- return router as unknown as Router<TNames>
1294
- }
1295
-
1296
- // ─── Helpers ──────────────────────────────────────────────────────────────────
1297
-
1298
- /**
1299
- * Match a lazy route's `_hmrId` (emitted by `@pyreon/zero`'s fs-router as the
1300
- * absolute route-file path) against the module id `@pyreon/vite-plugin`'s
1301
- * accept handler reports. Both are absolute paths to the same file but may
1302
- * differ in query suffix (`?t=…`, `?v=…`) or, in some Vite setups, a `/@fs`
1303
- * prefix. Strip queries, then accept exact equality OR a suffix match on the
1304
- * longer path — route-file paths are unique within an app so suffix matching
1305
- * can't cross-fire. A miss makes `_hmrSwap` return false → the plugin falls
1306
- * back to an automatic reload (correct, just not in-place), so a too-strict
1307
- * match degrades safely rather than swapping the wrong component.
1308
- */
1309
- function _hmrIdMatches(recordId: string, incomingId: string): boolean {
1310
- const a = recordId.split('?')[0] ?? recordId
1311
- const b = incomingId.split('?')[0] ?? incomingId
1312
- if (a === b) return true
1313
- return a.length >= b.length ? a.endsWith(b) : b.endsWith(a)
1314
- }
1315
-
1316
- async function runGuard(
1317
- guard: NavigationGuard,
1318
- to: ResolvedRoute,
1319
- from: ResolvedRoute,
1320
- ): Promise<NavigationGuardResult> {
1321
- try {
1322
- return await guard(to, from)
1323
- } catch (err) {
1324
- if (__DEV__) {
1325
- console.warn(`[Pyreon Router] Navigation guard threw an error — navigation cancelled:`, err)
1326
- }
1327
- return false
1328
- }
1329
- }
1330
-
1331
- function resolveNamedPath(
1332
- name: string,
1333
- params: Record<string, string>,
1334
- query: Record<string, string>,
1335
- index: Map<string, RouteRecord>,
1336
- ): string {
1337
- const record = index.get(name)
1338
- if (!record) {
1339
- if (__DEV__) {
1340
- // oxlint-disable-next-line no-console
1341
- console.warn(
1342
- `[Pyreon Router] Unknown route name "${name}". ` +
1343
- `Available names: ${[...index.keys()].join(', ') || '(none)'}. Falling back to "/".`,
1344
- )
1345
- }
1346
- return '/'
1347
- }
1348
- let path = buildPath(record.path, params)
1349
- const qs = Object.entries(query)
1350
- .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
1351
- .join('&')
1352
- if (qs) path += `?${qs}`
1353
- return path
1354
- }
1355
-
1356
- /** Normalize a base path: ensure leading `/`, strip trailing `/`. */
1357
- function normalizeBase(raw: string): string {
1358
- if (!raw) return ''
1359
- let b = raw
1360
- if (!b.startsWith('/')) b = `/${b}`
1361
- if (b.endsWith('/')) b = b.slice(0, -1)
1362
- return b
1363
- }
1364
-
1365
- /** Strip the base prefix from a full URL path. Returns the app-relative path. */
1366
- function stripBase(path: string, base: string): string {
1367
- if (!base) return path
1368
- if (path === base || path === `${base}/`) return '/'
1369
- if (path.startsWith(`${base}/`)) return path.slice(base.length)
1370
- return path
1371
- }
1372
-
1373
- /** Normalize trailing slash on a path according to the configured strategy. */
1374
- function normalizeTrailingSlash(path: string, strategy: 'strip' | 'add' | 'ignore'): string {
1375
- if (strategy === 'ignore' || path === '/') return path
1376
- // Split off query string + hash so we only touch the path portion
1377
- const qIdx = path.indexOf('?')
1378
- const hIdx = path.indexOf('#')
1379
- const endIdx = qIdx >= 0 ? qIdx : hIdx >= 0 ? hIdx : path.length
1380
- const pathPart = path.slice(0, endIdx)
1381
- const suffix = path.slice(endIdx)
1382
- if (strategy === 'strip') {
1383
- return pathPart.length > 1 && pathPart.endsWith('/') ? pathPart.slice(0, -1) + suffix : path
1384
- }
1385
- // strategy === "add"
1386
- return !pathPart.endsWith('/') ? `${pathPart}/${suffix}` : path
1387
- }
1388
-
1389
- /**
1390
- * Resolve a relative path (starting with `.` or `..`) against the current path.
1391
- * Non-relative paths are returned as-is.
1392
- */
1393
- function resolveRelativePath(to: string, from: string): string {
1394
- if (!to.startsWith('./') && !to.startsWith('../') && to !== '.' && to !== '..') return to
1395
-
1396
- // Split current path into segments, drop the last segment (file-like resolution)
1397
- const fromSegments = from.split('/').filter(Boolean)
1398
- fromSegments.pop()
1399
-
1400
- const toSegments = to.split('/').filter(Boolean)
1401
- for (const seg of toSegments) {
1402
- if (seg === '..') {
1403
- fromSegments.pop()
1404
- } else if (seg !== '.') {
1405
- fromSegments.push(seg)
1406
- }
1407
- }
1408
- return `/${fromSegments.join('/')}`
1409
- }
1410
-
1411
- /** Block unsafe navigation targets: javascript/data/vbscript URIs and absolute URLs. */
1412
- function sanitizePath(path: string): string {
1413
- const trimmed = path.trim()
1414
- if (/^(?:javascript|data|vbscript):/i.test(trimmed)) {
1415
- return '/'
1416
- }
1417
- // Block absolute URLs and protocol-relative URLs — router only handles same-origin paths
1418
- if (/^\/\/|^https?:/i.test(trimmed)) {
1419
- return '/'
1420
- }
1421
- return path
1422
- }
1423
-
1424
- export { isLazy }