@pyreon/router 0.13.1 → 0.15.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.
@@ -0,0 +1,75 @@
1
+ import type { ComponentFn, Props, VNodeChild } from '@pyreon/core'
2
+ import { ErrorBoundary, h } from '@pyreon/core'
3
+
4
+ // ─── NotFound symbol + throw ────────────────────────────────────────────────
5
+
6
+ const NOT_FOUND = Symbol.for('pyreon.notFound')
7
+
8
+ /**
9
+ * Throw inside a route loader or component to trigger the nearest
10
+ * NotFoundBoundary. Inspired by Next.js's `notFound()`.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * // In a loader:
15
+ * loader: async ({ params }) => {
16
+ * const user = await fetchUser(params.id)
17
+ * if (!user) notFound()
18
+ * return user
19
+ * }
20
+ * ```
21
+ */
22
+ export function notFound(message?: string): never {
23
+ const err = new Error(message ?? 'Not Found')
24
+ ;(err as unknown as Record<symbol, unknown>)[NOT_FOUND] = true
25
+ throw err
26
+ }
27
+
28
+ /** Check if an error is a NotFoundError thrown by `notFound()`. */
29
+ export function isNotFoundError(err: unknown): boolean {
30
+ return (
31
+ typeof err === 'object' &&
32
+ err !== null &&
33
+ (err as Record<string | symbol, unknown>)[NOT_FOUND] === true
34
+ )
35
+ }
36
+
37
+ // ─── NotFoundBoundary ──────────────────────────────────────────────────────
38
+
39
+ export interface NotFoundBoundaryProps extends Props {
40
+ /** Component or VNode to render when notFound() is thrown */
41
+ fallback: ComponentFn | VNodeChild
42
+ children?: VNodeChild
43
+ }
44
+
45
+ /**
46
+ * Catches `notFound()` errors from child route components or loaders
47
+ * and renders the fallback. Wraps Pyreon's ErrorBoundary with notFound
48
+ * detection — non-notFound errors propagate to parent error boundaries.
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * <NotFoundBoundary fallback={<NotFoundPage />}>
53
+ * <RouterView />
54
+ * </NotFoundBoundary>
55
+ * ```
56
+ */
57
+ export const NotFoundBoundary: ComponentFn<NotFoundBoundaryProps> = (props) => {
58
+ return h(
59
+ ErrorBoundary,
60
+ {
61
+ fallback: (err: unknown, reset: () => void) => {
62
+ if (!isNotFoundError(err)) {
63
+ // Re-throw non-notFound errors so they propagate
64
+ throw err
65
+ }
66
+ const fb = props.fallback
67
+ if (typeof fb === 'function' && fb.length <= 1) {
68
+ return h(fb as ComponentFn, { error: err, reset })
69
+ }
70
+ return fb as VNodeChild
71
+ },
72
+ },
73
+ props.children,
74
+ )
75
+ }
@@ -0,0 +1,63 @@
1
+ // ─── Redirect symbol + throw ────────────────────────────────────────────────
2
+
3
+ const REDIRECT = Symbol.for('pyreon.redirect')
4
+
5
+ /** Standard redirect status codes. 307/308 preserve the request method, 302/303 don't. */
6
+ export type RedirectStatus = 301 | 302 | 303 | 307 | 308
7
+
8
+ interface RedirectInfo {
9
+ url: string
10
+ status: RedirectStatus
11
+ }
12
+
13
+ /**
14
+ * Throw inside a route loader to redirect the navigation server-side
15
+ * (during SSR returns a 302/307 `Location:` response) and client-side
16
+ * (during CSR triggers `router.replace()` before the layout renders).
17
+ *
18
+ * The auth-gate use case: replaces the fragile `onMount + router.push()`
19
+ * workaround. `onMount` doesn't fire reliably under nested-layout dev SSR +
20
+ * hydration — so the layout renders briefly before the push happens, leaking
21
+ * authenticated UI to unauthenticated users. `redirect()` runs in the loader
22
+ * BEFORE the layout's component is invoked, so the unauthenticated UI never
23
+ * mounts in the first place.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * // src/routes/app/_layout.tsx
28
+ * export const loader = async ({ request }) => {
29
+ * const session = await getSession(request)
30
+ * if (!session) redirect('/login')
31
+ * return { user: session.user }
32
+ * }
33
+ * ```
34
+ *
35
+ * @param url - Target URL (typically a path like `/login` or absolute URL for cross-origin).
36
+ * @param status - HTTP redirect status. Default `307` (Temporary Redirect, method-preserving).
37
+ * Use `301`/`308` for permanent moves, `302`/`303` to force GET on the target.
38
+ */
39
+ export function redirect(url: string, status: RedirectStatus = 307): never {
40
+ const err = new Error(`Redirect to ${url}`)
41
+ ;(err as unknown as Record<symbol, RedirectInfo>)[REDIRECT] = { url, status }
42
+ throw err
43
+ }
44
+
45
+ /** Check if an error is a RedirectError thrown by `redirect()`. */
46
+ export function isRedirectError(err: unknown): boolean {
47
+ return (
48
+ typeof err === 'object' &&
49
+ err !== null &&
50
+ typeof (err as Record<symbol, unknown>)[REDIRECT] === 'object'
51
+ )
52
+ }
53
+
54
+ /**
55
+ * Extract the redirect URL and status from a thrown RedirectError. Returns
56
+ * `null` if `err` isn't a RedirectError. Used by the router's loader-runner
57
+ * (CSR) and the SSR handler to convert the thrown error into the right kind
58
+ * of response (a `router.replace()` call or a `302`/`307` Response).
59
+ */
60
+ export function getRedirectInfo(err: unknown): RedirectInfo | null {
61
+ if (!isRedirectError(err)) return null
62
+ return (err as Record<symbol, RedirectInfo>)[REDIRECT] ?? null
63
+ }
package/src/router.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createContext, onUnmount, useContext } from '@pyreon/core'
2
2
  import { computed, signal } from '@pyreon/reactivity'
3
3
  import { buildNameIndex, buildPath, resolveRoute, stringifyQuery } from './match'
4
+ import { getRedirectInfo } from './redirect'
4
5
  import { ScrollManager } from './scroll'
5
6
  import {
6
7
  type AfterEachHook,
@@ -26,8 +27,10 @@ import {
26
27
  const _isBrowser = typeof window !== 'undefined'
27
28
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
28
29
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
29
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
30
- const __DEV__ = import.meta.env?.DEV === true
30
+ const __DEV__ = process.env.NODE_ENV !== 'production'
31
+
32
+ // Dev-time counter sink — see packages/internals/perf-harness for contract.
33
+ const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
31
34
 
32
35
  // ─── Router context ───────────────────────────────────────────────────────────
33
36
  // Context-based access: isolated per request in SSR (ALS-backed via
@@ -253,9 +256,7 @@ export type SearchParamSchema = {
253
256
 
254
257
  /** Infer the typed result from a search param schema. */
255
258
  type InferSearchParams<T extends SearchParamSchema> = {
256
- [K in keyof T]: T[K] extends 'number' ? number
257
- : T[K] extends 'boolean' ? boolean
258
- : string
259
+ [K in keyof T]: T[K] extends 'number' ? number : T[K] extends 'boolean' ? boolean : string
259
260
  }
260
261
 
261
262
  /**
@@ -316,15 +317,20 @@ export function useSearchParams<T extends Record<string, string>>(
316
317
  */
317
318
  export function useTypedSearchParams<T extends SearchParamSchema>(
318
319
  schema: T,
319
- ): [get: () => InferSearchParams<T>, set: (updates: Partial<InferSearchParams<T>>) => Promise<void>] {
320
+ ): [
321
+ get: () => InferSearchParams<T>,
322
+ set: (updates: Partial<InferSearchParams<T>>) => Promise<void>,
323
+ ] {
320
324
  const router = _getRouter()
321
325
  const get = (): InferSearchParams<T> => {
322
326
  const query = router.currentRoute().query
323
327
  const result: Record<string, unknown> = {}
324
328
  for (const [key, type] of Object.entries(schema)) {
325
329
  const raw = query[key]
326
- if (type === 'number') result[key] = raw !== undefined ? Number(raw) : 0
327
- else if (type === 'boolean') result[key] = raw === 'true' || raw === '1'
330
+ if (type === 'number') {
331
+ const n = raw !== undefined ? Number(raw) : 0
332
+ result[key] = Number.isNaN(n) ? 0 : n
333
+ } else if (type === 'boolean') result[key] = raw === 'true' || raw === '1'
328
334
  else result[key] = raw ?? ''
329
335
  }
330
336
  return result as InferSearchParams<T>
@@ -341,6 +347,53 @@ export function useTypedSearchParams<T extends SearchParamSchema>(
341
347
  return [get, set]
342
348
  }
343
349
 
350
+ /**
351
+ * Read the validated search params from the current route's `validateSearch`.
352
+ * Returns a reactive accessor that re-evaluates when the route changes.
353
+ *
354
+ * The generic `T` should match the return type of your `validateSearch` function.
355
+ *
356
+ * @example
357
+ * ```tsx
358
+ * // Route config:
359
+ * { path: '/search', validateSearch: (raw) => ({
360
+ * page: Number(raw.page) || 1,
361
+ * q: raw.q ?? '',
362
+ * }), component: SearchPage }
363
+ *
364
+ * // In SearchPage:
365
+ * const search = useValidatedSearch<{ page: number; q: string }>()
366
+ * // search().page — typed as number
367
+ * // search().q — typed as string
368
+ * ```
369
+ */
370
+ export function useValidatedSearch<
371
+ T extends Record<string, unknown> = Record<string, unknown>,
372
+ >(): () => T {
373
+ const router = _getRouter()
374
+ // Structural sharing: cache the previous result and return it if
375
+ // shallow-equal to the new one. Prevents downstream re-renders when
376
+ // unrelated query params change but the validated subset didn't.
377
+ let prev: T | null = null
378
+ return () => {
379
+ const next = router.currentRoute().search as T
380
+ if (prev && shallowEqual(prev, next)) return prev
381
+ prev = next
382
+ return next
383
+ }
384
+ }
385
+
386
+ /** Shallow equality check for plain objects — keys + strict value comparison. */
387
+ function shallowEqual<T extends Record<string, unknown>>(a: T, b: T): boolean {
388
+ const keysA = Object.keys(a)
389
+ const keysB = Object.keys(b)
390
+ if (keysA.length !== keysB.length) return false
391
+ for (const key of keysA) {
392
+ if (a[key] !== b[key]) return false
393
+ }
394
+ return true
395
+ }
396
+
344
397
  function _getRouter(): RouterInstance {
345
398
  const router = (useContext(RouterContext) ?? _activeRouter) as RouterInstance | null
346
399
  if (!router)
@@ -385,12 +438,14 @@ export function useTransition(): () => boolean {
385
438
  */
386
439
  export function useMiddlewareData(): () => Record<string, unknown> {
387
440
  const router = _getRouter()
388
- return () => (router.currentRoute() as any)._middlewareData ?? {}
441
+ return () => router.currentRoute()._middlewareData ?? {}
389
442
  }
390
443
 
391
444
  // ─── Factory ──────────────────────────────────────────────────────────────────
392
445
 
393
- export function createRouter(options: RouterOptions | RouteRecord[]): Router {
446
+ export function createRouter<TNames extends string = string>(
447
+ options: RouterOptions | RouteRecord[],
448
+ ): Router<TNames> {
394
449
  const opts: RouterOptions = Array.isArray(options) ? { routes: options } : options
395
450
  const {
396
451
  routes,
@@ -514,18 +569,24 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
514
569
  record: RouteRecord,
515
570
  ac: AbortController,
516
571
  to: ResolvedRoute,
517
- ): boolean {
572
+ ): GuardOutcome {
518
573
  if (result.status === 'fulfilled') {
519
574
  router._loaderData.set(record, result.value)
520
- return true
575
+ return { action: 'continue' }
521
576
  }
522
- if (ac.signal.aborted) return true
577
+ if (ac.signal.aborted) return { action: 'continue' }
578
+ // `redirect()` from a loader: propagate as a router-level redirect so the
579
+ // navigate flow re-runs against the target path BEFORE the matched route's
580
+ // layout / page mounts. Bypasses the user-supplied `_onError` hook — a
581
+ // redirect is intentional flow control, not an error.
582
+ const info = getRedirectInfo(result.reason)
583
+ if (info) return { action: 'redirect', target: info.url }
523
584
  if (router._onError) {
524
585
  const cancel = router._onError(result.reason, to)
525
- if (cancel === false) return false
586
+ if (cancel === false) return { action: 'cancel' }
526
587
  }
527
588
  router._loaderData.set(record, undefined)
528
- return true
589
+ return { action: 'continue' }
529
590
  }
530
591
 
531
592
  function syncBrowserUrl(path: string, replace: boolean): void {
@@ -558,24 +619,122 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
558
619
  return runGlobalGuards(guards, to, from, gen)
559
620
  }
560
621
 
622
+ /** Default cache key: path + serialized params */
623
+ function defaultLoaderKey(
624
+ record: RouteRecord,
625
+ ctx: Pick<LoaderContext, 'params' | 'query'>,
626
+ ): string {
627
+ return `${record.path}:${JSON.stringify(ctx.params)}`
628
+ }
629
+
630
+ /** Get cache key for a route record + context. */
631
+ function getCacheKey(record: RouteRecord, ctx: Pick<LoaderContext, 'params' | 'query'>): string {
632
+ return record.loaderKey ? record.loaderKey(ctx) : defaultLoaderKey(record, ctx)
633
+ }
634
+
635
+ /** Check if a cached entry is still fresh (not expired by gcTime). */
636
+ function isCacheFresh(entry: { timestamp: number }, record: RouteRecord): boolean {
637
+ const gcTime = record.gcTime ?? 300_000 // 5 min default
638
+ if (gcTime === 0) return false // caching disabled
639
+ return Date.now() - entry.timestamp < gcTime
640
+ }
641
+
642
+ /**
643
+ * Bounded set into `_loaderCache`: evicts the oldest entry (insertion-order
644
+ * FIFO) when the cap is exceeded. The `gcTime` TTL handles staleness, but
645
+ * without a size cap a long-running SPA navigating across many distinct
646
+ * loader keys (e.g. `/posts/:id` with hundreds of unique IDs) would
647
+ * accumulate cache entries indefinitely until manual `invalidateLoader()`
648
+ * — `_maxCacheSize` was wired through from `RouterOptions.maxCacheSize`
649
+ * (default 100) but the loader cache write paths never read it. Mirrors
650
+ * the same pattern used for `_componentCache` in `components.tsx`.
651
+ */
652
+ function loaderCacheSet(key: string, data: unknown): void {
653
+ router._loaderCache.set(key, { data, timestamp: Date.now() })
654
+ if (router._loaderCache.size > router._maxCacheSize) {
655
+ // Map iterates in insertion order — first key is oldest
656
+ const oldest = router._loaderCache.keys().next().value as string | undefined
657
+ if (oldest !== undefined) router._loaderCache.delete(oldest)
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Execute a loader with cache + dedup:
663
+ * 1. Cache hit + fresh → return cached data (skip loader entirely)
664
+ * 2. In-flight for same key → dedup (return existing promise)
665
+ * 3. Otherwise → run loader, cache result, clean up in-flight
666
+ */
667
+ function executeLoader(record: RouteRecord, loaderCtx: LoaderContext): Promise<unknown> {
668
+ if (!record.loader) return Promise.resolve(undefined)
669
+
670
+ const key = getCacheKey(record, loaderCtx)
671
+
672
+ // 1. Cache hit — skip for SWR routes (they always revalidate via the SWR path)
673
+ if (!record.staleWhileRevalidate) {
674
+ const cached = router._loaderCache.get(key)
675
+ if (cached && isCacheFresh(cached, record)) {
676
+ if (__DEV__) _countSink.__pyreon_count__?.('router.loaderCache.hit')
677
+ return Promise.resolve(cached.data)
678
+ }
679
+ }
680
+
681
+ // 2. Dedup in-flight — but only if the in-flight signal is still live.
682
+ // Pre-fix: nav-1 starts loader (signal=ac1.signal). User navigates again
683
+ // to the same path → nav-2's `router.push` first calls `_abortController?.abort()`
684
+ // (aborting ac1), then calls executeLoader. The Map still holds nav-1's
685
+ // promise (the .catch hasn't run yet); deduping returns it, but its
686
+ // signal is already aborted → nav-2 ends up with a rejected promise
687
+ // even though it has its own fresh ac2.signal. Now we check liveness.
688
+ const inflight = router._loaderInflight.get(key)
689
+ if (inflight && !inflight.signal.aborted) return inflight.promise
690
+
691
+ // 3. Execute. Wrap with `Promise.resolve().then(...)` so a SYNCHRONOUS
692
+ // throw from the loader (`redirect('/login')` / `notFound()` / a plain
693
+ // `throw new Error(...)`) becomes a rejected promise the `.catch` can
694
+ // handle — instead of escaping past the promise chain and surfacing as
695
+ // an unhandled exception in `runBlockingLoaders`'s `Promise.allSettled`.
696
+ if (__DEV__) _countSink.__pyreon_count__?.('router.loaderRun')
697
+ const promise = Promise.resolve()
698
+ .then(() => record.loader!(loaderCtx))
699
+ .then((data) => {
700
+ loaderCacheSet(key, data)
701
+ // Only delete if WE'RE still the registered in-flight (a later nav
702
+ // may have replaced the entry with a fresh promise).
703
+ if (router._loaderInflight.get(key)?.promise === promise) {
704
+ router._loaderInflight.delete(key)
705
+ }
706
+ return data
707
+ })
708
+ .catch((err) => {
709
+ if (router._loaderInflight.get(key)?.promise === promise) {
710
+ router._loaderInflight.delete(key)
711
+ }
712
+ throw err
713
+ })
714
+
715
+ router._loaderInflight.set(key, { promise, signal: loaderCtx.signal })
716
+ return promise
717
+ }
718
+
561
719
  async function runBlockingLoaders(
562
720
  records: RouteRecord[],
563
721
  to: ResolvedRoute,
564
722
  gen: number,
565
723
  ac: AbortController,
566
- ): Promise<boolean> {
724
+ ): Promise<GuardOutcome> {
567
725
  const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
568
- const results = await Promise.allSettled(
569
- records.map((r) => (r.loader ? r.loader(loaderCtx) : Promise.resolve(undefined))),
570
- )
571
- if (gen !== _navGen) return false
726
+ const results = await Promise.allSettled(records.map((r) => executeLoader(r, loaderCtx)))
727
+ if (gen !== _navGen) return { action: 'cancel' }
572
728
  for (let i = 0; i < records.length; i++) {
573
729
  const result = results[i]
574
730
  const record = records[i]
575
731
  if (!result || !record) continue
576
- if (!processLoaderResult(result, record, ac, to)) return false
732
+ const outcome = processLoaderResult(result, record, ac, to)
733
+ // Short-circuit on first redirect or cancel — later loaders' results
734
+ // are irrelevant once we know the navigation isn't committing here.
735
+ if (outcome.action !== 'continue') return outcome
577
736
  }
578
- return true
737
+ return { action: 'continue' }
579
738
  }
580
739
 
581
740
  /** Fire-and-forget background revalidation for stale-while-revalidate routes. */
@@ -583,10 +742,14 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
583
742
  const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
584
743
  for (const r of records) {
585
744
  if (!r.loader) continue
745
+ // Bypass cache for revalidation — always fetch fresh
586
746
  r.loader(loaderCtx)
587
747
  .then((data) => {
588
748
  if (!ac.signal.aborted) {
589
749
  router._loaderData.set(r, data)
750
+ // Update cache with fresh data
751
+ const key = getCacheKey(r, loaderCtx)
752
+ loaderCacheSet(key, data)
590
753
  // Bump loadingSignal to trigger reactive re-render with fresh data
591
754
  loadingSignal.update((n) => n + 1)
592
755
  loadingSignal.update((n) => n - 1)
@@ -598,9 +761,13 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
598
761
  }
599
762
  }
600
763
 
601
- async function runLoaders(to: ResolvedRoute, gen: number, ac: AbortController): Promise<boolean> {
764
+ async function runLoaders(
765
+ to: ResolvedRoute,
766
+ gen: number,
767
+ ac: AbortController,
768
+ ): Promise<GuardOutcome> {
602
769
  const loadableRecords = to.matched.filter((r) => r.loader)
603
- if (loadableRecords.length === 0) return true
770
+ if (loadableRecords.length === 0) return { action: 'continue' }
604
771
 
605
772
  const blocking: RouteRecord[] = []
606
773
  const swr: RouteRecord[] = []
@@ -613,11 +780,11 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
613
780
  }
614
781
 
615
782
  if (blocking.length > 0) {
616
- const ok = await runBlockingLoaders(blocking, to, gen, ac)
617
- if (!ok) return false
783
+ const outcome = await runBlockingLoaders(blocking, to, gen, ac)
784
+ if (outcome.action !== 'continue') return outcome
618
785
  }
619
786
  if (swr.length > 0) revalidateSwrLoaders(swr, to, ac)
620
- return true
787
+ return { action: 'continue' }
621
788
  }
622
789
 
623
790
  async function commitNavigation(
@@ -645,9 +812,10 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
645
812
 
646
813
  // Use View Transitions API when available and not explicitly disabled.
647
814
  // Route meta can opt out: meta: { viewTransition: false }
648
- const useVT = _isBrowser
649
- && to.meta.viewTransition !== false
650
- && typeof (document as any).startViewTransition === 'function'
815
+ const useVT =
816
+ _isBrowser &&
817
+ to.meta.viewTransition !== false &&
818
+ typeof (document as any).startViewTransition === 'function'
651
819
 
652
820
  if (useVT) {
653
821
  // `startViewTransition(cb)` runs `cb` inside an async transition. Its
@@ -670,10 +838,11 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
670
838
  ready?: Promise<void>
671
839
  finished?: Promise<void>
672
840
  }
673
- const vt = (document as { startViewTransition?: (cb: () => void) => ViewTransitionLike | undefined })
674
- .startViewTransition!(() => {
675
- doCommit()
676
- })
841
+ const vt = (
842
+ document as { startViewTransition?: (cb: () => void) => ViewTransitionLike | undefined }
843
+ ).startViewTransition!(() => {
844
+ doCommit()
845
+ })
677
846
  // `startViewTransition` may return `undefined` in test doubles
678
847
  // that shim it with a bare `(cb) => cb()`. Guard accordingly.
679
848
  if (vt) {
@@ -734,7 +903,9 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
734
903
  to: ResolvedRoute,
735
904
  from: ResolvedRoute,
736
905
  gen: number,
737
- ): Promise<{ action: 'continue' } | { action: 'cancel' } | { action: 'redirect'; target: string }> {
906
+ ): Promise<
907
+ { action: 'continue' } | { action: 'cancel' } | { action: 'redirect'; target: string }
908
+ > {
738
909
  const ctx: RouteMiddlewareContext = { to, from, data: {} }
739
910
 
740
911
  for (const record of to.matched) {
@@ -749,11 +920,13 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
749
920
  }
750
921
 
751
922
  // Store middleware data on the resolved route for component access
752
- ;(to as any)._middlewareData = ctx.data
923
+ to._middlewareData = ctx.data
753
924
  return { action: 'continue' }
754
925
  }
755
926
 
756
927
  async function navigate(rawPath: string, replace: boolean, redirectDepth = 0): Promise<void> {
928
+ if (__DEV__) _countSink.__pyreon_count__?.('router.navigate')
929
+ router._navigationStartTime = Date.now()
757
930
  if (redirectDepth > 10) {
758
931
  if (__DEV__) {
759
932
  // oxlint-disable-next-line no-console
@@ -807,9 +980,12 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
807
980
  const ac = new AbortController()
808
981
  router._abortController = ac
809
982
 
810
- const loadersOk = await runLoaders(to, gen, ac)
811
- if (!loadersOk) {
983
+ const loaderOutcome = await runLoaders(to, gen, ac)
984
+ if (loaderOutcome.action !== 'continue') {
812
985
  loadingSignal.update((n) => n - 1)
986
+ if (loaderOutcome.action === 'redirect') {
987
+ return navigate(sanitizePath(loaderOutcome.target), replace, redirectDepth + 1)
988
+ }
813
989
  return
814
990
  }
815
991
 
@@ -847,6 +1023,9 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
847
1023
  _readyPromise,
848
1024
  _onError: onError,
849
1025
  _maxCacheSize: maxCacheSize,
1026
+ _navigationStartTime: Date.now(),
1027
+ _loaderCache: new Map(),
1028
+ _loaderInflight: new Map(),
850
1029
 
851
1030
  async push(
852
1031
  location:
@@ -918,7 +1097,7 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
918
1097
  return router._readyPromise
919
1098
  },
920
1099
 
921
- async preload(path: string) {
1100
+ async preload(path: string, request?: Request) {
922
1101
  const resolved = resolveRoute(path, routes)
923
1102
  // Load lazy components in parallel and populate the component cache so
924
1103
  // the synchronous render pass finds ready components instead of kicking
@@ -948,16 +1127,46 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
948
1127
  resolved.matched
949
1128
  .filter((r) => r.loader)
950
1129
  .map(async (r) => {
951
- const data = await r.loader?.({
952
- params: resolved.params,
953
- query: resolved.query,
954
- signal: ac.signal,
955
- })
1130
+ // Wrap with `Promise.resolve().then(...)` so a SYNCHRONOUS
1131
+ // throw — `redirect('/login')` from a sync loader, `notFound()`,
1132
+ // a plain `throw new Error(...)` — becomes a rejected promise
1133
+ // the surrounding Promise.all surfaces. Bare `await r.loader(...)`
1134
+ // would let synchronous throws escape past the `await` and
1135
+ // surface as an uncaught exception in the Vite dev SSR pipeline.
1136
+ const data = await Promise.resolve().then(() =>
1137
+ r.loader!({
1138
+ params: resolved.params,
1139
+ query: resolved.query,
1140
+ signal: ac.signal,
1141
+ ...(request ? { request } : {}),
1142
+ }),
1143
+ )
956
1144
  router._loaderData.set(r, data)
957
1145
  }),
958
1146
  )
959
1147
  },
960
1148
 
1149
+ invalidateLoader(keyOrPredicate?: string | ((key: string) => boolean)) {
1150
+ if (!keyOrPredicate) {
1151
+ // Invalidate all
1152
+ router._loaderCache.clear()
1153
+ router._loaderInflight.clear()
1154
+ return
1155
+ }
1156
+ if (typeof keyOrPredicate === 'string') {
1157
+ router._loaderCache.delete(keyOrPredicate)
1158
+ router._loaderInflight.delete(keyOrPredicate)
1159
+ return
1160
+ }
1161
+ // Predicate
1162
+ for (const key of [...router._loaderCache.keys()]) {
1163
+ if (keyOrPredicate(key)) {
1164
+ router._loaderCache.delete(key)
1165
+ router._loaderInflight.delete(key)
1166
+ }
1167
+ }
1168
+ },
1169
+
961
1170
  destroy() {
962
1171
  if (_popstateHandler) window.removeEventListener('popstate', _popstateHandler)
963
1172
  if (_hashchangeHandler) window.removeEventListener('hashchange', _hashchangeHandler)
@@ -968,6 +1177,8 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
968
1177
  router._blockers.clear()
969
1178
  componentCache.clear()
970
1179
  router._loaderData.clear()
1180
+ router._loaderCache.clear()
1181
+ router._loaderInflight.clear()
971
1182
  router._abortController?.abort()
972
1183
  router._abortController = null
973
1184
  // Clear global ref so stale router doesn't survive in SSR or re-creation
@@ -986,7 +1197,7 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
986
1197
  }
987
1198
  })
988
1199
 
989
- return router
1200
+ return router as unknown as Router<TNames>
990
1201
  }
991
1202
 
992
1203
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -1014,6 +1225,13 @@ function resolveNamedPath(
1014
1225
  ): string {
1015
1226
  const record = index.get(name)
1016
1227
  if (!record) {
1228
+ if (__DEV__) {
1229
+ // oxlint-disable-next-line no-console
1230
+ console.warn(
1231
+ `[Pyreon Router] Unknown route name "${name}". ` +
1232
+ `Available names: ${[...index.keys()].join(', ') || '(none)'}. Falling back to "/".`,
1233
+ )
1234
+ }
1017
1235
  return '/'
1018
1236
  }
1019
1237
  let path = buildPath(record.path, params)