@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/package.json +4 -6
- package/src/components.tsx +0 -650
- package/src/env.d.ts +0 -6
- package/src/index.ts +0 -106
- package/src/loader.ts +0 -200
- package/src/manifest.ts +0 -399
- package/src/match.ts +0 -921
- package/src/not-found.ts +0 -75
- package/src/redirect.ts +0 -63
- package/src/router.ts +0 -1424
- package/src/scroll.ts +0 -93
- package/src/tests/integration.test.tsx +0 -298
- package/src/tests/loader.test.ts +0 -1024
- package/src/tests/manifest-snapshot.test.ts +0 -101
- package/src/tests/match.test.ts +0 -782
- package/src/tests/native-markers.test.ts +0 -18
- package/src/tests/redirect.test.ts +0 -96
- package/src/tests/router.browser.test.tsx +0 -509
- package/src/tests/router.test.ts +0 -5498
- package/src/tests/routerlink-reactive-to.browser.test.tsx +0 -158
- package/src/tests/scroll.test.ts +0 -31
- package/src/tests/setup.ts +0 -3
- package/src/types.ts +0 -517
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 }
|