@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/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 { routes, mode = "hash", scrollBehavior, onError, maxCacheSize = 100 } = opts
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 runLoaders(to: ResolvedRoute, gen: number, ac: AbortController): Promise<boolean> {
231
- const loadableRecords = to.matched.filter((r) => r.loader)
232
- if (loadableRecords.length === 0) return true
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
- loadableRecords.map((r) => {
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 = loadableRecords[i]
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 navigate(path: string, replace: boolean, redirectDepth = 0): Promise<void> {
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") return navigate(sanitizePath(location), false)
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(path: string) {
358
- return navigate(sanitizePath(path), true)
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()