@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.
@@ -1,5 +1,15 @@
1
- import type { ComponentFn, Props, VNodeChild } from '@pyreon/core'
2
- import { createRef, ErrorBoundary, h, onUnmount, provide, useContext } from '@pyreon/core'
1
+ import type { ClassValue, ComponentFn, Props, VNodeChild } from '@pyreon/core'
2
+ import {
3
+ createRef,
4
+ cx,
5
+ ErrorBoundary,
6
+ h,
7
+ nativeCompat,
8
+ onUnmount,
9
+ provide,
10
+ useContext,
11
+ } from '@pyreon/core'
12
+ import { computed, signal } from '@pyreon/reactivity'
3
13
  import { LoaderDataContext, prefetchLoaderData } from './loader'
4
14
  import { isLazy, RouterContext, setActiveRouter } from './router'
5
15
  import type { LazyComponent, ResolvedRoute, RouteRecord, Router, RouterInstance } from './types'
@@ -74,30 +84,108 @@ export const RouterView: ComponentFn<RouterViewProps> = (props) => {
74
84
  router._viewDepth--
75
85
  })
76
86
 
77
- const child = (): VNodeChild => {
78
- router._loadingSignal() // reactive — re-renders after lazy load completes
79
-
80
- const route = router.currentRoute()
81
-
82
- if (route.matched.length === 0) return null
83
-
84
- // Render the matched record at this view's depth level
85
- const record = route.matched[depth]
86
- if (!record) return null // no component at this nesting level
87
-
88
- const cached = router._componentCache.get(record)
89
- if (cached) {
90
- return renderWithLoader(router, record, cached, route)
91
- }
87
+ // ── Structure / data decoupling ───────────────────────────────────────────
88
+ //
89
+ // Pre-fix the reactive child accessor read `_loadingSignal` and the full
90
+ // `currentRoute` snapshot. The framework's `mountReactive` tears down and
91
+ // rebuilds the entire subtree on every accessor re-emission, so any
92
+ // unrelated route signal (loader writes, lazy resolution, navigation
93
+ // start/end counters, param changes that don't change the matched record)
94
+ // would tear down the layout, then the page, then everything below it.
95
+ // For a single page load with one cold-start `router.replace()`, that
96
+ // produced ~9 cascading remounts of the layout confirmed empirically
97
+ // by instance counters.
98
+ //
99
+ // The fix decouples STRUCTURE (which RouteRecord is mounted at this depth
100
+ // + which component to render for it) from DATA (params / query / loader
101
+ // data flowing into the rendered component). One computed returns BOTH
102
+ // the record and its resolved component as an atomic pair — re-emits ONLY
103
+ // when either side changes (reference equality on both fields). Loader
104
+ // writes / param changes / navigation counters don't re-emit; the rendered
105
+ // component receives route data through reactive props + the
106
+ // `LoaderDataProvider` context, which subscribe per-component to the
107
+ // signals they actually care about, so a param change re-renders just the
108
+ // page leaf — not the layout chain above it.
109
+ //
110
+ // The structure is intentionally a SINGLE computed (not two layered ones):
111
+ // when `currentRoute` changes, the reactive child accessor must see a
112
+ // CONSISTENT (rec, comp) pair on its next re-run. With two layered
113
+ // computeds the child accessor subscribes to both, and the order in which
114
+ // those two notify the child is unspecified — if the child runs after rec
115
+ // is notified but before comp re-evaluates, it reads the new rec paired
116
+ // with the OLD comp. Empirically that produced rec=/button paired with
117
+ // comp=HomePage, leaving the previous page rendered after navigation.
118
+ // Combining them into one computed forces atomic emission.
119
+ interface DepthEntry {
120
+ rec: RouteRecord | null
121
+ comp: ComponentFn | null
122
+ /**
123
+ * True when lazy resolution exhausted retries and the chunk is in
124
+ * `_erroredChunks`. Tracked structurally so the entry re-emits when
125
+ * the error state flips on — otherwise `equals` would block the
126
+ * { rec, comp: null } → { rec, comp: null, errored: true } transition
127
+ * (`comp` and `rec` are unchanged) and the error component would
128
+ * never render.
129
+ */
130
+ errored: boolean
131
+ /**
132
+ * The full ResolvedRoute reference at the time this entry was emitted.
133
+ * `currentRoute` is a `computed` keyed on `currentPath` — same path
134
+ * returns the same memoized reference, different path returns a new
135
+ * one. Tracking the reference in `equals` makes the depth re-emit on
136
+ * any real navigation (params change, query change, hash change) even
137
+ * when the matched record at this depth stays the same — required so
138
+ * `/user/42 → /user/99` re-renders the User component with new params
139
+ * — while NOT re-emitting on navigate-flow noise (`_loadingSignal`
140
+ * start/end ticks, lazy resolution writes that complete without
141
+ * changing currentPath). One emit per real navigation, not per
142
+ * within-navigation signal tick.
143
+ */
144
+ route: ResolvedRoute
145
+ }
146
+ const depthEntry = computed<DepthEntry>(
147
+ () => {
148
+ const route = router.currentRoute()
149
+ const rec = route.matched[depth] ?? null
150
+ if (!rec) return { rec: null, comp: null, errored: false, route }
151
+ // Subscribe to `_loadingSignal` so lazy resolution wakes this
152
+ // computed up — when the cache fills, we re-emit with comp set.
153
+ router._loadingSignal()
154
+ const errored = router._erroredChunks.has(rec)
155
+ if (errored) return { rec, comp: null, errored: true, route }
156
+ const cached = router._componentCache.get(rec)
157
+ if (cached) return { rec, comp: cached, errored: false, route }
158
+ const raw = rec.component
159
+ if (!isLazy(raw)) {
160
+ cacheSet(router, rec, raw)
161
+ return { rec, comp: raw, errored: false, route }
162
+ }
163
+ // Lazy and not yet cached — `child()` below renders the lazy
164
+ // fallback and triggers the load; once the load completes,
165
+ // `_loadingSignal` ticks and this computed re-emits with `comp` set.
166
+ return { rec, comp: null, errored: false, route }
167
+ },
168
+ {
169
+ equals: (a, b) =>
170
+ a.rec === b.rec &&
171
+ a.comp === b.comp &&
172
+ a.errored === b.errored &&
173
+ a.route === b.route,
174
+ },
175
+ )
92
176
 
93
- const raw = record.component
177
+ const child = (): VNodeChild => {
178
+ const { rec, comp, route } = depthEntry()
179
+ if (!rec) return null
94
180
 
95
- if (!isLazy(raw)) {
96
- cacheSet(router, record, raw)
97
- return renderWithLoader(router, record, raw, route)
181
+ if (comp) {
182
+ return renderWithLoader(router, rec, comp, route)
98
183
  }
99
184
 
100
- return renderLazyRoute(router, record, raw)
185
+ // Component not yet cached — kick off the lazy load. `renderLazyRoute`
186
+ // mutates `_loadingSignal` and `_componentCache` on completion, which
187
+ // re-emits `depthEntry` and re-runs this accessor with `comp` set.
188
+ return renderLazyRoute(router, rec, rec.component as LazyComponent)
101
189
  }
102
190
 
103
191
  return h('div', { 'data-pyreon-router-view': true }, child as unknown as VNodeChild)
@@ -117,17 +205,18 @@ export interface RouterLinkProps extends Props {
117
205
  exact?: boolean
118
206
  /**
119
207
  * Prefetch strategy for loader data:
120
- * - "hover" (default) — prefetch when the user hovers over the link
208
+ * - "intent" (default) — prefetch on hover AND focus (covers mouse + keyboard)
209
+ * - "hover" — prefetch on hover only
121
210
  * - "viewport" — prefetch when the link scrolls into the viewport
122
211
  * - "none" — no prefetching
123
212
  */
124
- prefetch?: 'hover' | 'viewport' | 'none'
213
+ prefetch?: 'intent' | 'hover' | 'viewport' | 'none'
125
214
  children?: VNodeChild | null
126
215
  }
127
216
 
128
217
  export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
129
218
  const router = useContext(RouterContext)
130
- const prefetchMode = props.prefetch ?? 'hover'
219
+ const prefetchMode = props.prefetch ?? 'intent'
131
220
 
132
221
  const handleClick = (e: MouseEvent) => {
133
222
  e.preventDefault()
@@ -139,13 +228,35 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
139
228
  }
140
229
  }
141
230
 
142
- const handleMouseEnter = () => {
143
- if (prefetchMode !== 'hover' || !router) return
231
+ const triggerPrefetch = () => {
232
+ if (!router) return
144
233
  prefetchRoute(router as RouterInstance, props.to)
145
234
  }
146
235
 
236
+ const handleMouseEnter = () => {
237
+ if (prefetchMode === 'hover' || prefetchMode === 'intent') triggerPrefetch()
238
+ }
239
+
240
+ const handleFocus = () => {
241
+ if (prefetchMode === 'intent') triggerPrefetch()
242
+ }
243
+
147
244
  const inst = router as RouterInstance | null
148
- const href = inst?.mode === 'history' ? `${inst._base}${props.to}` : `#${props.to}`
245
+ // `href` MUST be an accessor, not a string captured at setup. `props.to`
246
+ // is a getter when the parent passes a reactive expression (the JSX
247
+ // compiler wraps `<RouterLink to={someExpr}>` as `_rp(() => someExpr)`).
248
+ // Capturing into a string at setup time freezes the URL — passing the
249
+ // accessor lets `applyProp` wrap it in `renderEffect` so href tracks the
250
+ // underlying signal.
251
+ const href = (): string =>
252
+ inst?.mode === 'history' ? `${inst._base}${props.to}` : `#${props.to}`
253
+
254
+ const isExactMatch = (): boolean => {
255
+ if (!router) return false
256
+ const target = props.to
257
+ if (typeof target !== 'string') return false
258
+ return router.currentRoute().path === target
259
+ }
149
260
 
150
261
  const activeClass = (): string => {
151
262
  if (!router) return ''
@@ -161,6 +272,8 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
161
272
  return classes.join(' ').trim()
162
273
  }
163
274
 
275
+ const ariaCurrent = (): string | undefined => isExactMatch() ? 'page' : undefined
276
+
164
277
  // Viewport prefetching — observe link visibility with IntersectionObserver
165
278
  const ref = createRef<Element>()
166
279
  if (prefetchMode === 'viewport' && router && typeof IntersectionObserver !== 'undefined') {
@@ -180,17 +293,51 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
180
293
  onUnmount(() => observer.disconnect())
181
294
  }
182
295
 
183
- // Forward all non-RouterLink props (style, class, id, data-*, etc.) to the <a>.
184
- const { to: _to, replace: _replace, activeClass: _ac, exactActiveClass: _eac, exact: _exact, prefetch: _prefetch, children, ...rest } = props
296
+ // Forward all non-RouterLink props (style, id, data-*, etc.) to the <a>.
297
+ // `class` is pulled out separately so it can be MERGED with the internal
298
+ // active-class accessor — overriding the user's class silently dropped any
299
+ // conditional class the consumer wanted (e.g. `class={() => cond ? 'on' : ''}`).
300
+ const {
301
+ to: _to,
302
+ replace: _replace,
303
+ activeClass: _ac,
304
+ exactActiveClass: _eac,
305
+ exact: _exact,
306
+ prefetch: _prefetch,
307
+ class: userClass,
308
+ children,
309
+ ...rest
310
+ } = props as RouterLinkProps & { class?: ClassValue | (() => ClassValue) }
311
+
312
+ // Compose the user-provided `class` (string / array / object / function) with
313
+ // the internal `activeClass` accessor. Returning a function lets `applyProp`
314
+ // wrap it in `renderEffect` once — so navigation re-evaluates BOTH sides on
315
+ // every route change without rebuilding the link.
316
+ const mergedClass = (): string => {
317
+ const userResolved =
318
+ typeof userClass === 'function' ? (userClass as () => ClassValue)() : userClass
319
+ return cx([userResolved, activeClass()] as ClassValue)
320
+ }
185
321
 
186
322
  return h(
187
323
  'a',
188
- { ...rest, ref, href, class: activeClass, onClick: handleClick, onMouseEnter: handleMouseEnter },
324
+ {
325
+ ...rest,
326
+ ref,
327
+ href,
328
+ class: mergedClass,
329
+ 'aria-current': ariaCurrent,
330
+ onClick: handleClick,
331
+ onMouseEnter: handleMouseEnter,
332
+ onFocus: handleFocus,
333
+ },
189
334
  children ?? props.to,
190
335
  )
191
336
  }
192
337
 
193
338
  /** Prefetch loader data for a route (only once per router + path). */
339
+ const MAX_PREFETCH_CACHE = 50
340
+
194
341
  function prefetchRoute(router: RouterInstance, path: string): void {
195
342
  let set = _prefetched.get(router)
196
343
  if (!set) {
@@ -198,6 +345,11 @@ function prefetchRoute(router: RouterInstance, path: string): void {
198
345
  _prefetched.set(router, set)
199
346
  }
200
347
  if (set.has(path)) return
348
+ // Evict oldest entries when cache is full to prevent unbounded growth
349
+ if (set.size >= MAX_PREFETCH_CACHE) {
350
+ const first = set.values().next().value as string
351
+ set.delete(first)
352
+ }
201
353
  set.add(path)
202
354
  prefetchLoaderData(router, path).catch(() => {
203
355
  // Silently ignore — prefetch is best-effort
@@ -276,12 +428,118 @@ function renderLoaderContent(
276
428
  routeProps: Record<string, unknown>,
277
429
  ): VNodeChild {
278
430
  const data = router._loaderData.get(record)
279
- if (data === undefined && record.errorComponent) {
431
+
432
+ if (data !== undefined) {
433
+ return h(LoaderDataProvider, { data, children: h(Comp, routeProps) })
434
+ }
435
+
436
+ // Data not yet available — show pending component if configured
437
+ if (record.pendingComponent) {
438
+ return h(PendingLoader as unknown as ComponentFn, {
439
+ router,
440
+ record,
441
+ Comp,
442
+ routeProps,
443
+ })
444
+ }
445
+
446
+ if (record.errorComponent) {
280
447
  return h(record.errorComponent, routeProps)
281
448
  }
282
449
  return h(LoaderDataProvider, { data, children: h(Comp, routeProps) })
283
450
  }
284
451
 
452
+ /**
453
+ * Signal-based pending component with timing control.
454
+ *
455
+ * State machine: hidden → pending → ready
456
+ * - hidden: initial state, nothing shown (lasts pendingMs)
457
+ * - pending: pendingComponent shown (lasts at least pendingMinMs)
458
+ * - ready: real component shown (loader data arrived + minTime elapsed)
459
+ */
460
+ function PendingLoader(props: {
461
+ router: RouterInstance
462
+ record: RouteRecord
463
+ Comp: ComponentFn
464
+ routeProps: Record<string, unknown>
465
+ }): VNodeChild {
466
+ const { router, record, Comp, routeProps } = props
467
+ const pendingMs = record.pendingMs ?? 0
468
+ const pendingMinMs = record.pendingMinMs ?? 200
469
+
470
+ type Phase = 'hidden' | 'pending' | 'ready'
471
+ const phase = signal<Phase>(pendingMs === 0 ? 'pending' : 'hidden')
472
+
473
+ let pendingTimer: ReturnType<typeof setTimeout> | null = null
474
+ let minTimer: ReturnType<typeof setTimeout> | null = null
475
+ let minTimeElapsed = pendingMs === 0 ? false : true // if no delay, minTime matters
476
+ let dataReady = false
477
+
478
+ if (pendingMs === 0) {
479
+ // Show pending immediately, start minTime countdown
480
+ minTimeElapsed = false
481
+ minTimer = setTimeout(() => {
482
+ minTimeElapsed = true
483
+ minTimer = null
484
+ if (dataReady) phase.set('ready')
485
+ }, pendingMinMs)
486
+ } else {
487
+ // Delay before showing pending
488
+ pendingTimer = setTimeout(() => {
489
+ pendingTimer = null
490
+ if (dataReady) {
491
+ // Data arrived during delay — skip pending entirely
492
+ phase.set('ready')
493
+ } else {
494
+ phase.set('pending')
495
+ minTimeElapsed = false
496
+ minTimer = setTimeout(() => {
497
+ minTimeElapsed = true
498
+ minTimer = null
499
+ if (dataReady) phase.set('ready')
500
+ }, pendingMinMs)
501
+ }
502
+ }, pendingMs)
503
+ }
504
+
505
+ // Watch for loader data arrival
506
+ const checkData = () => {
507
+ const data = router._loaderData.get(record)
508
+ if (data !== undefined) {
509
+ dataReady = true
510
+ if (phase.peek() === 'hidden') {
511
+ // Data arrived before pendingMs — skip pending, go straight to ready
512
+ if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null }
513
+ phase.set('ready')
514
+ } else if (minTimeElapsed) {
515
+ phase.set('ready')
516
+ }
517
+ // else: pending is showing but minTime hasn't elapsed — wait for minTimer
518
+ }
519
+ }
520
+
521
+ // Poll via loadingSignal reactivity — re-checks when navigation completes
522
+ // This runs inside the reactive accessor below
523
+
524
+ onUnmount(() => {
525
+ if (pendingTimer) clearTimeout(pendingTimer)
526
+ if (minTimer) clearTimeout(minTimer)
527
+ })
528
+
529
+ return (() => {
530
+ // Track router's loading signal to re-run when loader completes
531
+ router._loadingSignal()
532
+ checkData()
533
+
534
+ const p = phase()
535
+ if (p === 'hidden') return null
536
+ if (p === 'pending') return h(record.pendingComponent!, routeProps)
537
+ // ready
538
+ const data = router._loaderData.get(record)
539
+ return h(LoaderDataProvider, { data, children: h(Comp, routeProps) })
540
+ }) as unknown as VNodeChild
541
+ }
542
+
285
543
  /**
286
544
  * Thin provider component that pushes LoaderDataContext before children mount.
287
545
  * Uses Pyreon's context stack so useLoaderData() reads it during child setup.
@@ -323,3 +581,12 @@ function isStaleChunk(err: unknown): boolean {
323
581
  if (err instanceof SyntaxError) return true
324
582
  return false
325
583
  }
584
+
585
+ // Mark router framework components as native — compat-mode jsx() runtimes
586
+ // (react/preact/vue/solid-compat) skip wrapCompatComponent for these so their
587
+ // provide() / useContext() / onUnmount() / effect() / IntersectionObserver
588
+ // setup runs inside Pyreon's lifecycle frame instead of the compat wrapper's
589
+ // runUntracked accessor.
590
+ nativeCompat(RouterProvider)
591
+ nativeCompat(RouterView)
592
+ nativeCompat(RouterLink)
package/src/env.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Minimal process type — just enough for `process.env.NODE_ENV` checks.
3
+ * Avoids requiring @types/node in consumers that import pyreon source
4
+ * via the `"bun"` export condition.
5
+ */
6
+ declare var process: { env: { NODE_ENV?: string } }
package/src/index.ts CHANGED
@@ -44,6 +44,10 @@
44
44
  export type { RouterLinkProps, RouterProviderProps, RouterViewProps } from './components'
45
45
  // Components
46
46
  export { RouterLink, RouterProvider, RouterView } from './components'
47
+ export type { NotFoundBoundaryProps } from './not-found'
48
+ export { isNotFoundError, NotFoundBoundary, notFound } from './not-found'
49
+ export type { RedirectStatus } from './redirect'
50
+ export { getRedirectInfo, isRedirectError, redirect } from './redirect'
47
51
  export { hydrateLoaderData, prefetchLoaderData, serializeLoaderData, useLoaderData } from './loader'
48
52
  // Match utilities (useful for SSR route pre-fetching)
49
53
  export {
@@ -68,6 +72,7 @@ export {
68
72
  useSearchParams,
69
73
  useTransition,
70
74
  useTypedSearchParams,
75
+ useValidatedSearch,
71
76
  } from './router'
72
77
  // Types
73
78
  // Data loaders
package/src/loader.ts CHANGED
@@ -2,6 +2,10 @@ import type { Context } from '@pyreon/core'
2
2
  import { createContext, useContext } from '@pyreon/core'
3
3
  import type { RouterInstance } from './types'
4
4
 
5
+ // Dev-mode gate + counter sink. See packages/internals/perf-harness for contract.
6
+ const __DEV__ = process.env.NODE_ENV !== 'production'
7
+ const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
8
+
5
9
  /**
6
10
  * Context frame that holds the loader data for the currently rendered route record.
7
11
  * Pushed by RouterView's withLoaderData wrapper before invoking the route component.
@@ -28,12 +32,23 @@ export function useLoaderData<T = unknown>(): T {
28
32
  * SSR helper: pre-run all loaders for the given path before rendering.
29
33
  * Call this before `renderToString` so route components can read data via `useLoaderData()`.
30
34
  *
35
+ * The optional `request` is forwarded to each loader's `LoaderContext.request`,
36
+ * letting server-side loaders read cookies / auth headers and `throw redirect()`
37
+ * before the layout renders. A loader that throws `redirect()` propagates the
38
+ * thrown error here — the SSR handler's `catch` converts it into a 302/307
39
+ * `Location:` Response.
40
+ *
31
41
  * @example
32
42
  * const router = createRouter({ routes, url: req.url })
33
- * await prefetchLoaderData(router, req.url)
43
+ * await prefetchLoaderData(router, req.url, request)
34
44
  * const html = await renderToString(h(App, { router }))
35
45
  */
36
- export async function prefetchLoaderData(router: RouterInstance, path: string): Promise<void> {
46
+ export async function prefetchLoaderData(
47
+ router: RouterInstance,
48
+ path: string,
49
+ request?: Request,
50
+ ): Promise<void> {
51
+ if (__DEV__) _countSink.__pyreon_count__?.('router.prefetch')
37
52
  const route = router._resolve(path)
38
53
  // Use a local AbortController — prefetch is best-effort and must NOT
39
54
  // clobber `router._abortController`, which belongs to the active
@@ -48,6 +63,7 @@ export async function prefetchLoaderData(router: RouterInstance, path: string):
48
63
  params: route.params,
49
64
  query: route.query,
50
65
  signal: ac.signal,
66
+ ...(request ? { request } : {}),
51
67
  })
52
68
  router._loaderData.set(r, data)
53
69
  }),
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
@@ -6,17 +6,22 @@ import type { ResolvedRoute, RouteMeta, RouteRecord } from './types'
6
6
  * Parse a query string into key-value pairs. Duplicate keys are overwritten
7
7
  * (last value wins). Use `parseQueryMulti` to preserve duplicates as arrays.
8
8
  */
9
+ /** Decode a query component: `+` → space (per application/x-www-form-urlencoded), then URI-decode. */
10
+ function decodeQueryComponent(raw: string): string {
11
+ return decodeURIComponent(raw.replace(/\+/g, ' '))
12
+ }
13
+
9
14
  export function parseQuery(qs: string): Record<string, string> {
10
15
  if (!qs) return {}
11
16
  const result: Record<string, string> = {}
12
17
  for (const part of qs.split('&')) {
13
18
  const eqIdx = part.indexOf('=')
14
19
  if (eqIdx < 0) {
15
- const key = decodeURIComponent(part)
20
+ const key = decodeQueryComponent(part)
16
21
  if (key) result[key] = ''
17
22
  } else {
18
- const key = decodeURIComponent(part.slice(0, eqIdx))
19
- const val = decodeURIComponent(part.slice(eqIdx + 1))
23
+ const key = decodeQueryComponent(part.slice(0, eqIdx))
24
+ const val = decodeQueryComponent(part.slice(eqIdx + 1))
20
25
  if (key) result[key] = val
21
26
  }
22
27
  }
@@ -38,11 +43,11 @@ export function parseQueryMulti(qs: string): Record<string, string | string[]> {
38
43
  let key: string
39
44
  let val: string
40
45
  if (eqIdx < 0) {
41
- key = decodeURIComponent(part)
46
+ key = decodeQueryComponent(part)
42
47
  val = ''
43
48
  } else {
44
- key = decodeURIComponent(part.slice(0, eqIdx))
45
- val = decodeURIComponent(part.slice(eqIdx + 1))
49
+ key = decodeQueryComponent(part.slice(0, eqIdx))
50
+ val = decodeQueryComponent(part.slice(eqIdx + 1))
46
51
  }
47
52
  if (!key) continue
48
53
  const existing = result[key]
@@ -288,7 +293,18 @@ function flattenOne(
288
293
  return
289
294
  }
290
295
 
291
- const joined = [...parentSegments, ...c.segments]
296
+ // fs-router emits absolute paths for nested children (e.g. parent
297
+ // `/app` with child `/app/dashboard`, NOT child `dashboard`). Concating
298
+ // parent segments with the child's already-absolute segments would
299
+ // produce `/app/app/dashboard` — the staticMap then lookups the wrong
300
+ // key and resolveRoute returns `matched: []` for any such request.
301
+ // Detect "child path is absolute" (`path` starts with `/`) and skip the
302
+ // parent-segment prefix in that case — the child's own segments ARE
303
+ // the full intended path. Relative children (`dashboard`, `:id`)
304
+ // continue to inherit the parent's segments via concatenation.
305
+ const childPath = c.route.path
306
+ const isAbsoluteChild = typeof childPath === 'string' && childPath.startsWith('/')
307
+ const joined = isAbsoluteChild ? c.segments : [...parentSegments, ...c.segments]
292
308
  if (c.children && c.children.length > 0) {
293
309
  flattenWalk(result, c.children, joined, chain, meta)
294
310
  }
@@ -558,6 +574,7 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
558
574
  hash,
559
575
  matched: staticMatch.matchedChain,
560
576
  meta: staticMatch.meta,
577
+ search: runValidateSearch(staticMatch.matchedChain, query),
561
578
  }
562
579
  }
563
580
 
@@ -579,6 +596,7 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
579
596
  hash,
580
597
  matched: match.matched,
581
598
  meta: mergeMeta(match.matched),
599
+ search: runValidateSearch(match.matched, query),
582
600
  }
583
601
  }
584
602
  }
@@ -594,6 +612,7 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
594
612
  hash,
595
613
  matched: dynMatch.matched,
596
614
  meta: mergeMeta(dynMatch.matched),
615
+ search: runValidateSearch(dynMatch.matched, query),
597
616
  }
598
617
  }
599
618
 
@@ -607,10 +626,31 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
607
626
  hash,
608
627
  matched: w.matchedChain,
609
628
  meta: w.meta,
629
+ search: runValidateSearch(w.matchedChain, query),
610
630
  }
611
631
  }
612
632
 
613
- return { path: cleanPath, params: {}, query, hash, matched: [], meta: {} }
633
+ return { path: cleanPath, params: {}, query, hash, matched: [], meta: {}, search: {} }
634
+ }
635
+
636
+ /** Run validateSearch from the deepest matched route that has one. */
637
+ function runValidateSearch(
638
+ matched: RouteRecord[],
639
+ query: Record<string, string>,
640
+ ): Record<string, unknown> {
641
+ // Walk from leaf to root — first validateSearch wins (most specific route)
642
+ for (let i = matched.length - 1; i >= 0; i--) {
643
+ const validate = matched[i]?.validateSearch
644
+ if (validate) {
645
+ try {
646
+ return validate(query)
647
+ } catch {
648
+ // Validation failed — return raw query as-is
649
+ return { ...query }
650
+ }
651
+ }
652
+ }
653
+ return {}
614
654
  }
615
655
 
616
656
  /** Merge meta from matched routes (leaf takes precedence) */