@pyreon/router 0.14.0 → 0.16.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/manifest.ts CHANGED
@@ -254,6 +254,69 @@ const User = () => {
254
254
  }`,
255
255
  seeAlso: ['useMiddlewareData', 'useRoute'],
256
256
  },
257
+ {
258
+ name: 'redirect',
259
+ kind: 'function',
260
+ signature: 'redirect(url: string, status?: 301 | 302 | 303 | 307 | 308): never',
261
+ summary:
262
+ "Throw inside a route loader to redirect the navigation BEFORE the layout renders. On SSR (initial nav), the thrown error is converted by `@pyreon/server`'s handler into a real HTTP `302`/`307` `Location:` response — no layout HTML leaves the server. On CSR (subsequent nav), the redirect propagates through the navigate flow and triggers `router.replace()` before any matched route's component mounts. Replaces the fragile `onMount + router.push()` workaround for auth-gates under nested-layout dev SSR + hydration. Default status is `307` (Temporary Redirect, method-preserving).",
263
+ example: `// src/routes/app/_layout.tsx
264
+ import { redirect, type LoaderContext } from "@pyreon/router"
265
+
266
+ export async function loader(ctx: LoaderContext) {
267
+ // SSR: read from request headers; CSR: read from document.cookie
268
+ const cookie = ctx.request?.headers.get("cookie")
269
+ ?? (typeof document !== "undefined" ? document.cookie : "")
270
+ const sid = /(?:^|;\\s*)sid=([^;]+)/.exec(cookie)?.[1]
271
+ if (!sid) redirect("/login")
272
+ const session = await getSession(sid)
273
+ if (!session) redirect("/login")
274
+ return { session }
275
+ }`,
276
+ mistakes: [
277
+ 'Calling `redirect()` outside a loader (in a component body, an event handler, etc.) — the helper expects to be caught by the loader-runner. For imperative redirects from event handlers, use `router.replace(target)` instead.',
278
+ "Forgetting to make `LoaderContext.request` access optional. It's populated only on SSR; CSR loaders see `request: undefined`. Read both: `ctx.request?.headers.get('cookie') ?? document.cookie`.",
279
+ 'Using `redirect()` for control-flow that should be a `<Match>` / `<Show>` conditional — the helper is for redirecting the URL, not for branching the rendered output.',
280
+ 'Returning `redirect()` instead of throwing it. The helper has return type `never` and throws — `return redirect(...)` is misleading and may suppress the throw under TS strict-null checks.',
281
+ 'Picking the wrong status. Default `307` preserves the request method (POST stays POST after redirect). Use `302`/`303` to force GET on the target. Use `301`/`308` for PERMANENT moves (browsers cache them aggressively).',
282
+ 'Assuming `redirect()` cancels every loader in a sibling chain. The first loader to throw wins; later loaders in the same `Promise.allSettled` batch may have already started executing before the redirect short-circuits. Treat them as best-effort.',
283
+ ],
284
+ seeAlso: ['notFound', 'useLoaderData', 'isRedirectError'],
285
+ },
286
+ {
287
+ name: 'isRedirectError',
288
+ kind: 'function',
289
+ signature: 'isRedirectError(err: unknown): boolean',
290
+ summary:
291
+ 'Type guard for errors thrown by `redirect()`. Used internally by the router (CSR) and `@pyreon/server` (SSR) to distinguish redirect-control-flow errors from real failures. Useful in custom error boundaries that should let redirects pass through to the framework instead of catching them.',
292
+ example: `import { ErrorBoundary } from "@pyreon/core"
293
+ import { isRedirectError } from "@pyreon/router"
294
+
295
+ <ErrorBoundary fallback={(err, reset) => {
296
+ if (isRedirectError(err)) throw err // let the framework handle it
297
+ return <ErrorPage error={err} onReset={reset} />
298
+ }}>
299
+ <App />
300
+ </ErrorBoundary>`,
301
+ seeAlso: ['redirect', 'isNotFoundError', 'getRedirectInfo'],
302
+ },
303
+ {
304
+ name: 'getRedirectInfo',
305
+ kind: 'function',
306
+ signature: 'getRedirectInfo(err: unknown): { url: string; status: 301 | 302 | 303 | 307 | 308 } | null',
307
+ summary:
308
+ "Extract the redirect URL and status from a thrown RedirectError. Returns `null` for non-redirect errors. Used by `@pyreon/server`'s SSR handler to convert the thrown error into a 302/307 `Response`.",
309
+ example: `import { getRedirectInfo } from "@pyreon/router"
310
+
311
+ try {
312
+ await prefetchLoaderData(router, path, request)
313
+ } catch (err) {
314
+ const info = getRedirectInfo(err)
315
+ if (info) return new Response(null, { status: info.status, headers: { Location: info.url } })
316
+ throw err
317
+ }`,
318
+ seeAlso: ['redirect', 'isRedirectError'],
319
+ },
257
320
  {
258
321
  name: 'useSearchParams',
259
322
  kind: 'hook',
package/src/match.ts CHANGED
@@ -1,4 +1,38 @@
1
- import type { ResolvedRoute, RouteMeta, RouteRecord } from './types'
1
+ import type { ResolvedRoute, RouteComponent, RouteMeta, RouteRecord } from './types'
2
+
3
+ // ─── Default chrome layout registration ──────────────────────────────────────
4
+ //
5
+ // Late-bound registration for the synthetic layout used by the
6
+ // layout-less-app 404 fallback in `findNotFoundFallback` below. The
7
+ // component itself lives in `./components.tsx` (it needs JSX + the
8
+ // `RouterView` it imports), but `match.ts` is below `components.tsx` in
9
+ // the dependency graph (router.ts imports match.ts; components.tsx
10
+ // imports router.ts) — directly importing `components.tsx` from here
11
+ // would create a cycle. Instead, `components.tsx` calls
12
+ // `_setDefaultChromeLayout(DefaultChromeLayout)` at module load. As
13
+ // long as the consumer's app imports anything from `@pyreon/router`
14
+ // that touches `components.tsx` (which every app does via
15
+ // `RouterProvider` / `RouterView` / `RouterLink`), the registration
16
+ // runs before any `resolveRoute()` call.
17
+ //
18
+ // When the setter hasn't been called (e.g. unit tests that exercise
19
+ // `resolveRoute` in isolation without ever importing `components.tsx`),
20
+ // `findNotFoundFallback` returns `null` for the layout-less case — the
21
+ // standalone-render path in the SSG plugin / runtime handler picks up
22
+ // from there. So the fix degrades gracefully.
23
+ let _defaultChromeLayout: RouteComponent | null = null
24
+
25
+ /**
26
+ * Register the synthetic "default chrome" layout used when a page-level
27
+ * `notFoundComponent` is the closest fallback (layout-less single-page-
28
+ * app shape). Called once at module load from `./components.tsx`. Pyreon
29
+ * apps shouldn't need to call this themselves.
30
+ *
31
+ * @internal
32
+ */
33
+ export function _setDefaultChromeLayout(component: RouteComponent): void {
34
+ _defaultChromeLayout = component
35
+ }
2
36
 
3
37
  // ─── Query string ─────────────────────────────────────────────────────────────
4
38
 
@@ -293,7 +327,18 @@ function flattenOne(
293
327
  return
294
328
  }
295
329
 
296
- const joined = [...parentSegments, ...c.segments]
330
+ // fs-router emits absolute paths for nested children (e.g. parent
331
+ // `/app` with child `/app/dashboard`, NOT child `dashboard`). Concating
332
+ // parent segments with the child's already-absolute segments would
333
+ // produce `/app/app/dashboard` — the staticMap then lookups the wrong
334
+ // key and resolveRoute returns `matched: []` for any such request.
335
+ // Detect "child path is absolute" (`path` starts with `/`) and skip the
336
+ // parent-segment prefix in that case — the child's own segments ARE
337
+ // the full intended path. Relative children (`dashboard`, `:id`)
338
+ // continue to inherit the parent's segments via concatenation.
339
+ const childPath = c.route.path
340
+ const isAbsoluteChild = typeof childPath === 'string' && childPath.startsWith('/')
341
+ const joined = isAbsoluteChild ? c.segments : [...parentSegments, ...c.segments]
297
342
  if (c.children && c.children.length > 0) {
298
343
  flattenWalk(result, c.children, joined, chain, meta)
299
344
  }
@@ -619,9 +664,189 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
619
664
  }
620
665
  }
621
666
 
667
+ // Fallback: notFoundComponent walk. When the URL doesn't match any
668
+ // descendant route, look for the deepest parent `notFoundComponent`
669
+ // whose path is a prefix of this URL. Build a synthetic chain that
670
+ // renders the not-found component INSIDE its ancestor layouts so the
671
+ // 404 page carries the same chrome (headers, footers, navigation) as
672
+ // regular pages. Without this, SSG/SSR returns `matched: []` and the
673
+ // caller has to render the not-found component standalone, losing
674
+ // layout wrapping.
675
+ const nfb = findNotFoundFallback(routes, cleanPath)
676
+ if (nfb) {
677
+ return {
678
+ path: cleanPath,
679
+ params: {},
680
+ query,
681
+ hash,
682
+ matched: nfb,
683
+ meta: mergeMeta(nfb),
684
+ search: {},
685
+ isNotFound: true,
686
+ }
687
+ }
688
+
622
689
  return { path: cleanPath, params: {}, query, hash, matched: [], meta: {}, search: {} }
623
690
  }
624
691
 
692
+ // ─── notFoundComponent walking ───────────────────────────────────────────────
693
+
694
+ /** Synthetic leaf RouteRecord used by the 404 fallback. Carries no real
695
+ * path matching — the resolver inserts it at the end of the chain when
696
+ * a parent `notFoundComponent` is the closest fallback for the URL. */
697
+ const SYNTHETIC_NOT_FOUND_PATH = '__pyreon_not_found_leaf__'
698
+
699
+ /**
700
+ * Walk the route tree finding records with `notFoundComponent`. Return
701
+ * the chain `[...ancestors, parentWithNotFound, syntheticLeaf]` for the
702
+ * DEEPEST record whose URL path is a prefix of `urlPath`.
703
+ *
704
+ * The path-prefix check: a record at `'/de'` applies to `/de/unknown`
705
+ * and `/de` itself but NOT to `/about` or `/encyclopedia` (full-segment
706
+ * boundary required, not substring). A record at `'/'` (root layout)
707
+ * applies to every URL. Deeper matches win — `/de` layout takes
708
+ * precedence over root layout for URLs under `/de/...`.
709
+ *
710
+ * Returns `null` when no record has `notFoundComponent`.
711
+ */
712
+ function findNotFoundFallback(routes: RouteRecord[], urlPath: string): RouteRecord[] | null {
713
+ let best: { chain: RouteRecord[]; record: RouteRecord; depth: number; specificity: number } | null = null
714
+ // Second-pass fallback: collect the BEST page-level notFoundComponent
715
+ // (no children) in case the layout pass finds nothing. Applies to the
716
+ // layout-less single-page-app case where `_404.tsx` is emitted without
717
+ // a parent `_layout.tsx`. The layout pass intentionally skips this
718
+ // shape (page records have no `<RouterView />` to wrap the leaf); the
719
+ // synthetic default-chrome layout fills that gap below.
720
+ let pageBest: {
721
+ record: RouteRecord
722
+ depth: number
723
+ specificity: number
724
+ fullPath: string
725
+ } | null = null
726
+
727
+ function walk(records: RouteRecord[], parentChain: RouteRecord[], parentPath: string): void {
728
+ for (const r of records) {
729
+ const rawPath = typeof r.path === 'string' ? r.path : ''
730
+ // fs-router emits absolute paths for nested routes (e.g. `/de/about`);
731
+ // relative paths inherit parent's path via concat. Mirror flattenOne's
732
+ // logic so synthesised paths track real URL prefixes.
733
+ const fullPath = rawPath.startsWith('/')
734
+ ? rawPath
735
+ : `${parentPath}/${rawPath}`.replace(/\/+/g, '/')
736
+ const chain = [...parentChain, r]
737
+
738
+ // Filter to LAYOUT records (records with non-empty `children`).
739
+ // fs-router attaches `notFoundComponent` to BOTH the parent layout
740
+ // AND every page record under that layout. Page records have no
741
+ // `<RouterView />` to render the synthetic leaf at the next depth,
742
+ // so picking a page as the fallback parent produces a chain
743
+ // `[Layout, Page, syntheticLeaf]` where `Page` swallows the leaf.
744
+ // Filtering to records with children ensures the synthetic leaf
745
+ // lands at a depth a `<RouterView />` will actually render.
746
+ const isLayout = Array.isArray(r.children) && r.children.length > 0
747
+
748
+ if (typeof r.notFoundComponent === 'function') {
749
+ const applies = pathPrefixApplies(fullPath, urlPath)
750
+ if (applies) {
751
+ // Prefer (a) the deepest record (longest chain), then (b) the
752
+ // most specific path-prefix when chains tie. Specificity =
753
+ // number of path segments in `fullPath`. `/` has 0; `/de` has 1.
754
+ const specificity = countSegments(fullPath)
755
+ if (isLayout) {
756
+ if (
757
+ !best ||
758
+ chain.length > best.depth ||
759
+ (chain.length === best.depth && specificity > best.specificity)
760
+ ) {
761
+ best = { chain, record: r, depth: chain.length, specificity }
762
+ }
763
+ } else if (
764
+ !pageBest ||
765
+ chain.length > pageBest.depth ||
766
+ (chain.length === pageBest.depth && specificity > pageBest.specificity)
767
+ ) {
768
+ pageBest = { record: r, depth: chain.length, specificity, fullPath }
769
+ }
770
+ }
771
+ }
772
+
773
+ if (Array.isArray(r.children)) {
774
+ walk(r.children, chain, fullPath)
775
+ }
776
+ }
777
+ }
778
+
779
+ walk(routes, [], '')
780
+
781
+ if (best) {
782
+ // TypeScript widening: `best` is inferred as `null` inside the closure
783
+ // when not narrowed, even though we asserted it's non-null above.
784
+ const found: { chain: RouteRecord[]; record: RouteRecord; depth: number; specificity: number } =
785
+ best
786
+
787
+ const syntheticLeaf: RouteRecord = {
788
+ path: SYNTHETIC_NOT_FOUND_PATH,
789
+ component: found.record.notFoundComponent as RouteComponent,
790
+ }
791
+
792
+ return [...found.chain, syntheticLeaf]
793
+ }
794
+
795
+ // Layout-less fallback. The user has a page-level `notFoundComponent`
796
+ // (e.g. `_404.tsx` at the route root with no `_layout.tsx`). Without
797
+ // a parent layout to wrap the leaf, we synthesize ONE: a minimal
798
+ // "default chrome" layout that renders `<main data-pyreon-default-chrome>
799
+ // <RouterView /></main>`. This provides a semantic-HTML landmark for
800
+ // accessibility + a hook for users to target the wrapper via CSS, while
801
+ // routing the render through the normal `<RouterView />` pipeline (so
802
+ // `isNotFound` propagation and runtime SSR status-404 still work).
803
+ //
804
+ // The DefaultChromeLayout component is registered by `components.tsx`
805
+ // at module load time via `_setDefaultChromeLayout()` (setter pattern
806
+ // to avoid the components.tsx → match.ts circular import). If the
807
+ // setter hasn't been called yet (consumer never imported anything
808
+ // from `@pyreon/router` that triggers components.tsx's side effects),
809
+ // we fall back to returning null — the standalone-render path in the
810
+ // SSG plugin / runtime handler picks up from there.
811
+ if (pageBest && _defaultChromeLayout) {
812
+ const found: {
813
+ record: RouteRecord
814
+ depth: number
815
+ specificity: number
816
+ fullPath: string
817
+ } = pageBest
818
+
819
+ const syntheticChromeLayout: RouteRecord = {
820
+ path: found.fullPath,
821
+ component: _defaultChromeLayout,
822
+ }
823
+ const syntheticLeaf: RouteRecord = {
824
+ path: SYNTHETIC_NOT_FOUND_PATH,
825
+ component: found.record.notFoundComponent as RouteComponent,
826
+ }
827
+ return [syntheticChromeLayout, syntheticLeaf]
828
+ }
829
+
830
+ return null
831
+ }
832
+
833
+ /** Check whether `prefixPath` is a path-prefix of `urlPath` at segment boundaries. */
834
+ function pathPrefixApplies(prefixPath: string, urlPath: string): boolean {
835
+ if (prefixPath === '/' || prefixPath === '') return true
836
+ if (urlPath === prefixPath) return true
837
+ // Require a `/` boundary after the prefix to avoid `/de` matching `/encyclopedia`.
838
+ return urlPath.startsWith(`${prefixPath}/`)
839
+ }
840
+
841
+ /** Count `/`-separated path segments. `/` → 0; `/de` → 1; `/de/about` → 2. */
842
+ function countSegments(path: string): number {
843
+ let count = 0
844
+ for (let i = 0; i < path.length; i++) {
845
+ if (path.charCodeAt(i) === 47 /* / */ && i + 1 < path.length) count++
846
+ }
847
+ return count
848
+ }
849
+
625
850
  /** Run validateSearch from the deepest matched route that has one. */
626
851
  function runValidateSearch(
627
852
  matched: RouteRecord[],
@@ -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,
@@ -12,7 +13,6 @@ import {
12
13
  type NavigationGuard,
13
14
  type NavigationGuardResult,
14
15
  type ResolvedRoute,
15
- type RouteMiddleware,
16
16
  type RouteMiddlewareContext,
17
17
  type RouteRecord,
18
18
  type Router,
@@ -26,8 +26,7 @@ import {
26
26
  const _isBrowser = typeof window !== 'undefined'
27
27
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
28
28
  // 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
29
+ const __DEV__ = process.env.NODE_ENV !== 'production'
31
30
 
32
31
  // Dev-time counter sink — see packages/internals/perf-harness for contract.
33
32
  const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
@@ -569,18 +568,24 @@ export function createRouter<TNames extends string = string>(
569
568
  record: RouteRecord,
570
569
  ac: AbortController,
571
570
  to: ResolvedRoute,
572
- ): boolean {
571
+ ): GuardOutcome {
573
572
  if (result.status === 'fulfilled') {
574
573
  router._loaderData.set(record, result.value)
575
- return true
574
+ return { action: 'continue' }
576
575
  }
577
- if (ac.signal.aborted) return true
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 }
578
583
  if (router._onError) {
579
584
  const cancel = router._onError(result.reason, to)
580
- if (cancel === false) return false
585
+ if (cancel === false) return { action: 'cancel' }
581
586
  }
582
587
  router._loaderData.set(record, undefined)
583
- return true
588
+ return { action: 'continue' }
584
589
  }
585
590
 
586
591
  function syncBrowserUrl(path: string, replace: boolean): void {
@@ -633,6 +638,25 @@ export function createRouter<TNames extends string = string>(
633
638
  return Date.now() - entry.timestamp < gcTime
634
639
  }
635
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
+
636
660
  /**
637
661
  * Execute a loader with cache + dedup:
638
662
  * 1. Cache hit + fresh → return cached data (skip loader entirely)
@@ -653,25 +677,41 @@ export function createRouter<TNames extends string = string>(
653
677
  }
654
678
  }
655
679
 
656
- // 2. Dedup in-flight
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.
657
687
  const inflight = router._loaderInflight.get(key)
658
- if (inflight) return inflight
688
+ if (inflight && !inflight.signal.aborted) return inflight.promise
659
689
 
660
- // 3. Execute
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`.
661
695
  if (__DEV__) _countSink.__pyreon_count__?.('router.loaderRun')
662
- const promise = record
663
- .loader(loaderCtx)
696
+ const promise = Promise.resolve()
697
+ .then(() => record.loader!(loaderCtx))
664
698
  .then((data) => {
665
- router._loaderCache.set(key, { data, timestamp: Date.now() })
666
- router._loaderInflight.delete(key)
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
+ }
667
705
  return data
668
706
  })
669
707
  .catch((err) => {
670
- router._loaderInflight.delete(key)
708
+ if (router._loaderInflight.get(key)?.promise === promise) {
709
+ router._loaderInflight.delete(key)
710
+ }
671
711
  throw err
672
712
  })
673
713
 
674
- router._loaderInflight.set(key, promise)
714
+ router._loaderInflight.set(key, { promise, signal: loaderCtx.signal })
675
715
  return promise
676
716
  }
677
717
 
@@ -680,17 +720,20 @@ export function createRouter<TNames extends string = string>(
680
720
  to: ResolvedRoute,
681
721
  gen: number,
682
722
  ac: AbortController,
683
- ): Promise<boolean> {
723
+ ): Promise<GuardOutcome> {
684
724
  const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
685
725
  const results = await Promise.allSettled(records.map((r) => executeLoader(r, loaderCtx)))
686
- if (gen !== _navGen) return false
726
+ if (gen !== _navGen) return { action: 'cancel' }
687
727
  for (let i = 0; i < records.length; i++) {
688
728
  const result = results[i]
689
729
  const record = records[i]
690
730
  if (!result || !record) continue
691
- if (!processLoaderResult(result, record, ac, to)) return false
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
692
735
  }
693
- return true
736
+ return { action: 'continue' }
694
737
  }
695
738
 
696
739
  /** Fire-and-forget background revalidation for stale-while-revalidate routes. */
@@ -705,7 +748,7 @@ export function createRouter<TNames extends string = string>(
705
748
  router._loaderData.set(r, data)
706
749
  // Update cache with fresh data
707
750
  const key = getCacheKey(r, loaderCtx)
708
- router._loaderCache.set(key, { data, timestamp: Date.now() })
751
+ loaderCacheSet(key, data)
709
752
  // Bump loadingSignal to trigger reactive re-render with fresh data
710
753
  loadingSignal.update((n) => n + 1)
711
754
  loadingSignal.update((n) => n - 1)
@@ -717,9 +760,13 @@ export function createRouter<TNames extends string = string>(
717
760
  }
718
761
  }
719
762
 
720
- async function runLoaders(to: ResolvedRoute, gen: number, ac: AbortController): Promise<boolean> {
763
+ async function runLoaders(
764
+ to: ResolvedRoute,
765
+ gen: number,
766
+ ac: AbortController,
767
+ ): Promise<GuardOutcome> {
721
768
  const loadableRecords = to.matched.filter((r) => r.loader)
722
- if (loadableRecords.length === 0) return true
769
+ if (loadableRecords.length === 0) return { action: 'continue' }
723
770
 
724
771
  const blocking: RouteRecord[] = []
725
772
  const swr: RouteRecord[] = []
@@ -732,11 +779,11 @@ export function createRouter<TNames extends string = string>(
732
779
  }
733
780
 
734
781
  if (blocking.length > 0) {
735
- const ok = await runBlockingLoaders(blocking, to, gen, ac)
736
- if (!ok) return false
782
+ const outcome = await runBlockingLoaders(blocking, to, gen, ac)
783
+ if (outcome.action !== 'continue') return outcome
737
784
  }
738
785
  if (swr.length > 0) revalidateSwrLoaders(swr, to, ac)
739
- return true
786
+ return { action: 'continue' }
740
787
  }
741
788
 
742
789
  async function commitNavigation(
@@ -932,9 +979,12 @@ export function createRouter<TNames extends string = string>(
932
979
  const ac = new AbortController()
933
980
  router._abortController = ac
934
981
 
935
- const loadersOk = await runLoaders(to, gen, ac)
936
- if (!loadersOk) {
982
+ const loaderOutcome = await runLoaders(to, gen, ac)
983
+ if (loaderOutcome.action !== 'continue') {
937
984
  loadingSignal.update((n) => n - 1)
985
+ if (loaderOutcome.action === 'redirect') {
986
+ return navigate(sanitizePath(loaderOutcome.target), replace, redirectDepth + 1)
987
+ }
938
988
  return
939
989
  }
940
990
 
@@ -1046,7 +1096,11 @@ export function createRouter<TNames extends string = string>(
1046
1096
  return router._readyPromise
1047
1097
  },
1048
1098
 
1049
- async preload(path: string) {
1099
+ async preload(
1100
+ path: string,
1101
+ request?: Request,
1102
+ options?: { skipLoaders?: boolean },
1103
+ ) {
1050
1104
  const resolved = resolveRoute(path, routes)
1051
1105
  // Load lazy components in parallel and populate the component cache so
1052
1106
  // the synchronous render pass finds ready components instead of kicking
@@ -1064,6 +1118,13 @@ export function createRouter<TNames extends string = string>(
1064
1118
  componentCache.set(record, comp)
1065
1119
  }),
1066
1120
  )
1121
+ // Skip the loader-running step when the caller explicitly opts out
1122
+ // (used by the SSG plugin's 404 build path — parent-layout loaders
1123
+ // that hit auth resources or external APIs shouldn't fire when
1124
+ // generating a static 404 page). Lazy components above DO still
1125
+ // resolve so the synthetic chain renders cleanly; only the
1126
+ // `r.loader()` invocations are skipped.
1127
+ if (options?.skipLoaders) return
1067
1128
  // Run loaders for the matched path — uses the same code path SSR
1068
1129
  // already relied on, so loader data ends up in `_loaderData` under the
1069
1130
  // matched route records. Uses a LOCAL AbortController: `preload` is
@@ -1076,11 +1137,20 @@ export function createRouter<TNames extends string = string>(
1076
1137
  resolved.matched
1077
1138
  .filter((r) => r.loader)
1078
1139
  .map(async (r) => {
1079
- const data = await r.loader?.({
1080
- params: resolved.params,
1081
- query: resolved.query,
1082
- signal: ac.signal,
1083
- })
1140
+ // Wrap with `Promise.resolve().then(...)` so a SYNCHRONOUS
1141
+ // throw — `redirect('/login')` from a sync loader, `notFound()`,
1142
+ // a plain `throw new Error(...)` — becomes a rejected promise
1143
+ // the surrounding Promise.all surfaces. Bare `await r.loader(...)`
1144
+ // would let synchronous throws escape past the `await` and
1145
+ // surface as an uncaught exception in the Vite dev SSR pipeline.
1146
+ const data = await Promise.resolve().then(() =>
1147
+ r.loader!({
1148
+ params: resolved.params,
1149
+ query: resolved.query,
1150
+ signal: ac.signal,
1151
+ ...(request ? { request } : {}),
1152
+ }),
1153
+ )
1084
1154
  router._loaderData.set(r, data)
1085
1155
  }),
1086
1156
  )