@pyreon/router 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +283 -30
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +285 -29
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index2.d.ts +118 -5
- package/lib/types/index2.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/components.tsx +2 -1
- package/src/index.ts +12 -1
- package/src/match.ts +94 -28
- package/src/router.ts +336 -25
- package/src/tests/router.test.ts +629 -2
- package/src/types.ts +79 -7
package/src/router.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { createContext, useContext } from "@pyreon/core"
|
|
1
|
+
import { createContext, onUnmount, useContext } from "@pyreon/core"
|
|
2
2
|
import { computed, signal } from "@pyreon/reactivity"
|
|
3
|
-
import { buildNameIndex, buildPath, resolveRoute } from "./match"
|
|
3
|
+
import { buildNameIndex, buildPath, resolveRoute, stringifyQuery } from "./match"
|
|
4
4
|
import { ScrollManager } from "./scroll"
|
|
5
5
|
import {
|
|
6
6
|
type AfterEachHook,
|
|
7
|
+
type Blocker,
|
|
8
|
+
type BlockerFn,
|
|
7
9
|
type ComponentFn,
|
|
8
10
|
isLazy,
|
|
9
11
|
type LoaderContext,
|
|
@@ -54,7 +56,7 @@ export function useRouter(): Router {
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
export function useRoute<TPath extends string = string>(): () => ResolvedRoute<
|
|
57
|
-
import("./types").ExtractParams<TPath>,
|
|
59
|
+
import("./types").ExtractParams<TPath> & Record<string, string>,
|
|
58
60
|
Record<string, string>
|
|
59
61
|
> {
|
|
60
62
|
const router = useContext(RouterContext) ?? _activeRouter
|
|
@@ -65,11 +67,155 @@ export function useRoute<TPath extends string = string>(): () => ResolvedRoute<
|
|
|
65
67
|
return router.currentRoute as never
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
/**
|
|
71
|
+
* In-component guard: called before the component's route is left.
|
|
72
|
+
* Return `false` to cancel, a string to redirect, or `undefined`/`true` to proceed.
|
|
73
|
+
* Automatically removed on component unmount.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* onBeforeRouteLeave((to, from) => {
|
|
77
|
+
* if (hasUnsavedChanges()) return false
|
|
78
|
+
* })
|
|
79
|
+
*/
|
|
80
|
+
export function onBeforeRouteLeave(guard: NavigationGuard): () => void {
|
|
81
|
+
const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
|
|
82
|
+
if (!router)
|
|
83
|
+
throw new Error(
|
|
84
|
+
"[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.",
|
|
85
|
+
)
|
|
86
|
+
// Register as a global guard that only fires when leaving the current route
|
|
87
|
+
const currentMatched = router.currentRoute().matched
|
|
88
|
+
const wrappedGuard: NavigationGuard = (to, from) => {
|
|
89
|
+
// Only fire if we're actually leaving one of the matched routes
|
|
90
|
+
const isLeaving = from.matched.some((r) => currentMatched.includes(r))
|
|
91
|
+
if (!isLeaving) return undefined
|
|
92
|
+
return guard(to, from)
|
|
93
|
+
}
|
|
94
|
+
const remove = router.beforeEach(wrappedGuard)
|
|
95
|
+
onUnmount(() => remove())
|
|
96
|
+
return remove
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* In-component guard: called when the route changes but the component is reused
|
|
101
|
+
* (e.g. `/user/1` → `/user/2`). Useful for reacting to param changes.
|
|
102
|
+
* Automatically removed on component unmount.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* onBeforeRouteUpdate((to, from) => {
|
|
106
|
+
* if (!isValidId(to.params.id)) return false
|
|
107
|
+
* })
|
|
108
|
+
*/
|
|
109
|
+
export function onBeforeRouteUpdate(guard: NavigationGuard): () => void {
|
|
110
|
+
const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
|
|
111
|
+
if (!router)
|
|
112
|
+
throw new Error(
|
|
113
|
+
"[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.",
|
|
114
|
+
)
|
|
115
|
+
const currentMatched = router.currentRoute().matched
|
|
116
|
+
const wrappedGuard: NavigationGuard = (to, from) => {
|
|
117
|
+
// Only fire when the same component is reused (matched routes overlap)
|
|
118
|
+
const isReused = to.matched.some((r) => currentMatched.includes(r))
|
|
119
|
+
if (!isReused) return undefined
|
|
120
|
+
return guard(to, from)
|
|
121
|
+
}
|
|
122
|
+
const remove = router.beforeEach(wrappedGuard)
|
|
123
|
+
onUnmount(() => remove())
|
|
124
|
+
return remove
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Register a navigation blocker. The `fn` callback is called before each
|
|
129
|
+
* navigation — return `true` (or resolve to `true`) to block it.
|
|
130
|
+
*
|
|
131
|
+
* Automatically removed on component unmount if called during component setup.
|
|
132
|
+
* Also installs a `beforeunload` handler so the browser shows a confirmation
|
|
133
|
+
* dialog when the user tries to close the tab while a blocker is active.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* const blocker = useBlocker((to, from) => {
|
|
137
|
+
* return hasUnsavedChanges() && !confirm("Discard changes?")
|
|
138
|
+
* })
|
|
139
|
+
* // later: blocker.remove()
|
|
140
|
+
*/
|
|
141
|
+
export function useBlocker(fn: BlockerFn): Blocker {
|
|
142
|
+
const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
|
|
143
|
+
if (!router)
|
|
144
|
+
throw new Error(
|
|
145
|
+
"[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.",
|
|
146
|
+
)
|
|
147
|
+
router._blockers.add(fn)
|
|
148
|
+
|
|
149
|
+
// Warn before tab/window close while this blocker is registered
|
|
150
|
+
const beforeUnloadHandler = _isBrowser
|
|
151
|
+
? (e: BeforeUnloadEvent) => {
|
|
152
|
+
e.preventDefault()
|
|
153
|
+
}
|
|
154
|
+
: null
|
|
155
|
+
if (beforeUnloadHandler) {
|
|
156
|
+
window.addEventListener("beforeunload", beforeUnloadHandler)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const remove = () => {
|
|
160
|
+
router._blockers.delete(fn)
|
|
161
|
+
if (beforeUnloadHandler) {
|
|
162
|
+
window.removeEventListener("beforeunload", beforeUnloadHandler)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Auto-remove when the component that called useBlocker unmounts
|
|
167
|
+
onUnmount(() => remove())
|
|
168
|
+
|
|
169
|
+
return { remove }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Reactive read/write access to the current route's query parameters.
|
|
174
|
+
*
|
|
175
|
+
* Returns `[get, set]` where `get` is a reactive signal producing the merged
|
|
176
|
+
* query object and `set` navigates to the current path with updated params.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* const [params, setParams] = useSearchParams({ page: "1", sort: "name" })
|
|
180
|
+
* params().page // "1" if not in URL
|
|
181
|
+
* setParams({ page: "2" }) // navigates to ?page=2&sort=name
|
|
182
|
+
*/
|
|
183
|
+
export function useSearchParams<T extends Record<string, string>>(
|
|
184
|
+
defaults?: T,
|
|
185
|
+
): [get: () => T, set: (updates: Partial<T>) => Promise<void>] {
|
|
186
|
+
const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
|
|
187
|
+
if (!router)
|
|
188
|
+
throw new Error(
|
|
189
|
+
"[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.",
|
|
190
|
+
)
|
|
191
|
+
const get = (): T => {
|
|
192
|
+
const query = router.currentRoute().query
|
|
193
|
+
if (!defaults) return query as T
|
|
194
|
+
return { ...defaults, ...query } as T
|
|
195
|
+
}
|
|
196
|
+
const set = (updates: Partial<T>): Promise<void> => {
|
|
197
|
+
const merged = { ...get(), ...updates }
|
|
198
|
+
const path = router.currentRoute().path + stringifyQuery(merged as Record<string, string>)
|
|
199
|
+
return router.replace(path)
|
|
200
|
+
}
|
|
201
|
+
return [get, set]
|
|
202
|
+
}
|
|
203
|
+
|
|
68
204
|
// ─── Factory ──────────────────────────────────────────────────────────────────
|
|
69
205
|
|
|
70
206
|
export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
71
207
|
const opts: RouterOptions = Array.isArray(options) ? { routes: options } : options
|
|
72
|
-
const {
|
|
208
|
+
const {
|
|
209
|
+
routes,
|
|
210
|
+
mode = "hash",
|
|
211
|
+
scrollBehavior,
|
|
212
|
+
onError,
|
|
213
|
+
maxCacheSize = 100,
|
|
214
|
+
trailingSlash = "strip",
|
|
215
|
+
} = opts
|
|
216
|
+
|
|
217
|
+
// Base path only applies to history mode — hash-based routing already namespaces via #
|
|
218
|
+
const base = mode === "history" ? normalizeBase(opts.base ?? "") : ""
|
|
73
219
|
|
|
74
220
|
// Pre-built O(1) name → record index. Computed once at startup.
|
|
75
221
|
const nameIndex = buildNameIndex(routes)
|
|
@@ -85,11 +231,11 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
85
231
|
// ── Initial location ──────────────────────────────────────────────────────
|
|
86
232
|
|
|
87
233
|
const getInitialLocation = (): string => {
|
|
88
|
-
// SSR: use explicitly provided url
|
|
89
|
-
if (opts.url) return opts.url
|
|
234
|
+
// SSR: use explicitly provided url (strip base if present)
|
|
235
|
+
if (opts.url) return stripBase(opts.url, base)
|
|
90
236
|
if (!_isBrowser) return "/"
|
|
91
237
|
if (mode === "history") {
|
|
92
|
-
return window.location.pathname + window.location.search
|
|
238
|
+
return stripBase(window.location.pathname, base) + window.location.search
|
|
93
239
|
}
|
|
94
240
|
const hash = window.location.hash
|
|
95
241
|
return hash.startsWith("#") ? hash.slice(1) || "/" : "/"
|
|
@@ -98,7 +244,7 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
98
244
|
const getCurrentLocation = (): string => {
|
|
99
245
|
if (!_isBrowser) return currentPath()
|
|
100
246
|
if (mode === "history") {
|
|
101
|
-
return window.location.pathname + window.location.search
|
|
247
|
+
return stripBase(window.location.pathname, base) + window.location.search
|
|
102
248
|
}
|
|
103
249
|
const hash = window.location.hash
|
|
104
250
|
return hash.startsWith("#") ? hash.slice(1) || "/" : "/"
|
|
@@ -106,7 +252,7 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
106
252
|
|
|
107
253
|
// ── Signals ───────────────────────────────────────────────────────────────
|
|
108
254
|
|
|
109
|
-
const currentPath = signal(getInitialLocation())
|
|
255
|
+
const currentPath = signal(normalizeTrailingSlash(getInitialLocation(), trailingSlash))
|
|
110
256
|
const currentRoute = computed<ResolvedRoute>(() => resolveRoute(currentPath(), routes))
|
|
111
257
|
|
|
112
258
|
// Browser event listeners — stored so destroy() can remove them
|
|
@@ -199,7 +345,7 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
199
345
|
|
|
200
346
|
function syncBrowserUrl(path: string, replace: boolean): void {
|
|
201
347
|
if (!_isBrowser) return
|
|
202
|
-
const url = mode === "history" ? path : `#${path}`
|
|
348
|
+
const url = mode === "history" ? `${base}${path}` : `#${path}`
|
|
203
349
|
if (replace) {
|
|
204
350
|
window.history.replaceState(null, "", url)
|
|
205
351
|
} else {
|
|
@@ -227,28 +373,68 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
227
373
|
return runGlobalGuards(guards, to, from, gen)
|
|
228
374
|
}
|
|
229
375
|
|
|
230
|
-
async function
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
376
|
+
async function runBlockingLoaders(
|
|
377
|
+
records: RouteRecord[],
|
|
378
|
+
to: ResolvedRoute,
|
|
379
|
+
gen: number,
|
|
380
|
+
ac: AbortController,
|
|
381
|
+
): Promise<boolean> {
|
|
234
382
|
const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
|
|
235
383
|
const results = await Promise.allSettled(
|
|
236
|
-
|
|
237
|
-
if (!r.loader) return Promise.resolve(undefined)
|
|
238
|
-
return r.loader(loaderCtx)
|
|
239
|
-
}),
|
|
384
|
+
records.map((r) => (r.loader ? r.loader(loaderCtx) : Promise.resolve(undefined))),
|
|
240
385
|
)
|
|
241
386
|
if (gen !== _navGen) return false
|
|
242
|
-
|
|
243
|
-
for (let i = 0; i < loadableRecords.length; i++) {
|
|
387
|
+
for (let i = 0; i < records.length; i++) {
|
|
244
388
|
const result = results[i]
|
|
245
|
-
const record =
|
|
389
|
+
const record = records[i]
|
|
246
390
|
if (!result || !record) continue
|
|
247
391
|
if (!processLoaderResult(result, record, ac, to)) return false
|
|
248
392
|
}
|
|
249
393
|
return true
|
|
250
394
|
}
|
|
251
395
|
|
|
396
|
+
/** Fire-and-forget background revalidation for stale-while-revalidate routes. */
|
|
397
|
+
function revalidateSwrLoaders(records: RouteRecord[], to: ResolvedRoute, ac: AbortController) {
|
|
398
|
+
const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
|
|
399
|
+
for (const r of records) {
|
|
400
|
+
if (!r.loader) continue
|
|
401
|
+
r.loader(loaderCtx)
|
|
402
|
+
.then((data) => {
|
|
403
|
+
if (!ac.signal.aborted) {
|
|
404
|
+
router._loaderData.set(r, data)
|
|
405
|
+
// Bump loadingSignal to trigger reactive re-render with fresh data
|
|
406
|
+
loadingSignal.update((n) => n + 1)
|
|
407
|
+
loadingSignal.update((n) => n - 1)
|
|
408
|
+
}
|
|
409
|
+
})
|
|
410
|
+
.catch(() => {
|
|
411
|
+
/* Background revalidation failure — stale data remains valid */
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function runLoaders(to: ResolvedRoute, gen: number, ac: AbortController): Promise<boolean> {
|
|
417
|
+
const loadableRecords = to.matched.filter((r) => r.loader)
|
|
418
|
+
if (loadableRecords.length === 0) return true
|
|
419
|
+
|
|
420
|
+
const blocking: RouteRecord[] = []
|
|
421
|
+
const swr: RouteRecord[] = []
|
|
422
|
+
for (const r of loadableRecords) {
|
|
423
|
+
if (r.staleWhileRevalidate && router._loaderData.has(r)) {
|
|
424
|
+
swr.push(r)
|
|
425
|
+
} else {
|
|
426
|
+
blocking.push(r)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (blocking.length > 0) {
|
|
431
|
+
const ok = await runBlockingLoaders(blocking, to, gen, ac)
|
|
432
|
+
if (!ok) return false
|
|
433
|
+
}
|
|
434
|
+
if (swr.length > 0) revalidateSwrLoaders(swr, to, ac)
|
|
435
|
+
return true
|
|
436
|
+
}
|
|
437
|
+
|
|
252
438
|
function commitNavigation(
|
|
253
439
|
path: string,
|
|
254
440
|
replace: boolean,
|
|
@@ -282,9 +468,22 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
282
468
|
}
|
|
283
469
|
}
|
|
284
470
|
|
|
285
|
-
async function
|
|
471
|
+
async function checkBlockers(
|
|
472
|
+
to: ResolvedRoute,
|
|
473
|
+
from: ResolvedRoute,
|
|
474
|
+
gen: number,
|
|
475
|
+
): Promise<"continue" | "cancel"> {
|
|
476
|
+
for (const blocker of router._blockers) {
|
|
477
|
+
const blocked = await blocker(to, from)
|
|
478
|
+
if (gen !== _navGen || blocked) return "cancel"
|
|
479
|
+
}
|
|
480
|
+
return "continue"
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function navigate(rawPath: string, replace: boolean, redirectDepth = 0): Promise<void> {
|
|
286
484
|
if (redirectDepth > 10) return
|
|
287
485
|
|
|
486
|
+
const path = normalizeTrailingSlash(rawPath, trailingSlash)
|
|
288
487
|
const gen = ++_navGen
|
|
289
488
|
loadingSignal.update((n) => n + 1)
|
|
290
489
|
|
|
@@ -297,6 +496,12 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
297
496
|
return navigate(redirectTarget, replace, redirectDepth + 1)
|
|
298
497
|
}
|
|
299
498
|
|
|
499
|
+
const blockerResult = await checkBlockers(to, from, gen)
|
|
500
|
+
if (blockerResult !== "continue") {
|
|
501
|
+
loadingSignal.update((n) => n - 1)
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
|
|
300
505
|
const guardOutcome = await runAllGuards(to, from, gen)
|
|
301
506
|
if (guardOutcome.action !== "continue") {
|
|
302
507
|
loadingSignal.update((n) => n - 1)
|
|
@@ -320,11 +525,20 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
320
525
|
loadingSignal.update((n) => n - 1)
|
|
321
526
|
}
|
|
322
527
|
|
|
528
|
+
// ── isReady promise ─────────────────────────────────────────────────────
|
|
529
|
+
// Resolves after the first navigation (including guards + loaders) completes.
|
|
530
|
+
|
|
531
|
+
let _readyResolve: (() => void) | null = null
|
|
532
|
+
const _readyPromise = new Promise<void>((resolve) => {
|
|
533
|
+
_readyResolve = resolve
|
|
534
|
+
})
|
|
535
|
+
|
|
323
536
|
// ── Public router object ──────────────────────────────────────────────────
|
|
324
537
|
|
|
325
538
|
const router: RouterInstance = {
|
|
326
539
|
routes,
|
|
327
540
|
mode,
|
|
541
|
+
_base: base,
|
|
328
542
|
currentRoute,
|
|
329
543
|
_currentPath: currentPath,
|
|
330
544
|
_currentRoute: currentRoute,
|
|
@@ -336,6 +550,9 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
336
550
|
_erroredChunks: new Set(),
|
|
337
551
|
_loaderData: new Map(),
|
|
338
552
|
_abortController: null,
|
|
553
|
+
_blockers: new Set(),
|
|
554
|
+
_readyResolve,
|
|
555
|
+
_readyPromise,
|
|
339
556
|
_onError: onError,
|
|
340
557
|
_maxCacheSize: maxCacheSize,
|
|
341
558
|
|
|
@@ -344,7 +561,10 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
344
561
|
| string
|
|
345
562
|
| { name: string; params?: Record<string, string>; query?: Record<string, string> },
|
|
346
563
|
) {
|
|
347
|
-
if (typeof location === "string")
|
|
564
|
+
if (typeof location === "string") {
|
|
565
|
+
const resolved = resolveRelativePath(location, currentPath())
|
|
566
|
+
return navigate(sanitizePath(resolved), false)
|
|
567
|
+
}
|
|
348
568
|
const path = resolveNamedPath(
|
|
349
569
|
location.name,
|
|
350
570
|
location.params ?? {},
|
|
@@ -354,14 +574,36 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
354
574
|
return navigate(path, false)
|
|
355
575
|
},
|
|
356
576
|
|
|
357
|
-
async replace(
|
|
358
|
-
|
|
577
|
+
async replace(
|
|
578
|
+
location:
|
|
579
|
+
| string
|
|
580
|
+
| { name: string; params?: Record<string, string>; query?: Record<string, string> },
|
|
581
|
+
) {
|
|
582
|
+
if (typeof location === "string") {
|
|
583
|
+
const resolved = resolveRelativePath(location, currentPath())
|
|
584
|
+
return navigate(sanitizePath(resolved), true)
|
|
585
|
+
}
|
|
586
|
+
const path = resolveNamedPath(
|
|
587
|
+
location.name,
|
|
588
|
+
location.params ?? {},
|
|
589
|
+
location.query ?? {},
|
|
590
|
+
nameIndex,
|
|
591
|
+
)
|
|
592
|
+
return navigate(path, true)
|
|
359
593
|
},
|
|
360
594
|
|
|
361
595
|
back() {
|
|
362
596
|
if (_isBrowser) window.history.back()
|
|
363
597
|
},
|
|
364
598
|
|
|
599
|
+
forward() {
|
|
600
|
+
if (_isBrowser) window.history.forward()
|
|
601
|
+
},
|
|
602
|
+
|
|
603
|
+
go(delta: number) {
|
|
604
|
+
if (_isBrowser) window.history.go(delta)
|
|
605
|
+
},
|
|
606
|
+
|
|
365
607
|
beforeEach(guard: NavigationGuard) {
|
|
366
608
|
guards.push(guard)
|
|
367
609
|
return () => {
|
|
@@ -380,6 +622,10 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
380
622
|
|
|
381
623
|
loading: () => loadingSignal() > 0,
|
|
382
624
|
|
|
625
|
+
isReady() {
|
|
626
|
+
return router._readyPromise
|
|
627
|
+
},
|
|
628
|
+
|
|
383
629
|
destroy() {
|
|
384
630
|
if (_popstateHandler) {
|
|
385
631
|
window.removeEventListener("popstate", _popstateHandler)
|
|
@@ -391,6 +637,7 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
391
637
|
}
|
|
392
638
|
guards.length = 0
|
|
393
639
|
afterHooks.length = 0
|
|
640
|
+
router._blockers.clear()
|
|
394
641
|
componentCache.clear()
|
|
395
642
|
router._loaderData.clear()
|
|
396
643
|
router._abortController?.abort()
|
|
@@ -400,6 +647,15 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
400
647
|
_resolve: (rawPath: string) => resolveRoute(rawPath, routes),
|
|
401
648
|
}
|
|
402
649
|
|
|
650
|
+
// Initial route is resolved synchronously — mark ready on next microtask
|
|
651
|
+
// so consumers can await isReady() before the first render.
|
|
652
|
+
queueMicrotask(() => {
|
|
653
|
+
if (router._readyResolve) {
|
|
654
|
+
router._readyResolve()
|
|
655
|
+
router._readyResolve = null
|
|
656
|
+
}
|
|
657
|
+
})
|
|
658
|
+
|
|
403
659
|
return router
|
|
404
660
|
}
|
|
405
661
|
|
|
@@ -435,6 +691,61 @@ function resolveNamedPath(
|
|
|
435
691
|
return path
|
|
436
692
|
}
|
|
437
693
|
|
|
694
|
+
/** Normalize a base path: ensure leading `/`, strip trailing `/`. */
|
|
695
|
+
function normalizeBase(raw: string): string {
|
|
696
|
+
if (!raw) return ""
|
|
697
|
+
let b = raw
|
|
698
|
+
if (!b.startsWith("/")) b = `/${b}`
|
|
699
|
+
if (b.endsWith("/")) b = b.slice(0, -1)
|
|
700
|
+
return b
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Strip the base prefix from a full URL path. Returns the app-relative path. */
|
|
704
|
+
function stripBase(path: string, base: string): string {
|
|
705
|
+
if (!base) return path
|
|
706
|
+
if (path === base || path === `${base}/`) return "/"
|
|
707
|
+
if (path.startsWith(`${base}/`)) return path.slice(base.length)
|
|
708
|
+
return path
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Normalize trailing slash on a path according to the configured strategy. */
|
|
712
|
+
function normalizeTrailingSlash(path: string, strategy: "strip" | "add" | "ignore"): string {
|
|
713
|
+
if (strategy === "ignore" || path === "/") return path
|
|
714
|
+
// Split off query string + hash so we only touch the path portion
|
|
715
|
+
const qIdx = path.indexOf("?")
|
|
716
|
+
const hIdx = path.indexOf("#")
|
|
717
|
+
const endIdx = qIdx >= 0 ? qIdx : hIdx >= 0 ? hIdx : path.length
|
|
718
|
+
const pathPart = path.slice(0, endIdx)
|
|
719
|
+
const suffix = path.slice(endIdx)
|
|
720
|
+
if (strategy === "strip") {
|
|
721
|
+
return pathPart.length > 1 && pathPart.endsWith("/") ? pathPart.slice(0, -1) + suffix : path
|
|
722
|
+
}
|
|
723
|
+
// strategy === "add"
|
|
724
|
+
return !pathPart.endsWith("/") ? `${pathPart}/${suffix}` : path
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Resolve a relative path (starting with `.` or `..`) against the current path.
|
|
729
|
+
* Non-relative paths are returned as-is.
|
|
730
|
+
*/
|
|
731
|
+
function resolveRelativePath(to: string, from: string): string {
|
|
732
|
+
if (!to.startsWith("./") && !to.startsWith("../") && to !== "." && to !== "..") return to
|
|
733
|
+
|
|
734
|
+
// Split current path into segments, drop the last segment (file-like resolution)
|
|
735
|
+
const fromSegments = from.split("/").filter(Boolean)
|
|
736
|
+
fromSegments.pop()
|
|
737
|
+
|
|
738
|
+
const toSegments = to.split("/").filter(Boolean)
|
|
739
|
+
for (const seg of toSegments) {
|
|
740
|
+
if (seg === "..") {
|
|
741
|
+
fromSegments.pop()
|
|
742
|
+
} else if (seg !== ".") {
|
|
743
|
+
fromSegments.push(seg)
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return `/${fromSegments.join("/")}`
|
|
747
|
+
}
|
|
748
|
+
|
|
438
749
|
/** Block unsafe navigation targets: javascript/data/vbscript URIs and absolute URLs. */
|
|
439
750
|
function sanitizePath(path: string): string {
|
|
440
751
|
const trimmed = path.trim()
|