@pyreon/router 0.13.1 → 0.14.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.
@@ -47,6 +47,15 @@ interface ResolvedRoute<P extends Record<string, string | undefined> = Record<st
47
47
  /** All matched records from root to leaf (one per nesting level) */
48
48
  matched: RouteRecord[];
49
49
  meta: RouteMeta;
50
+ /**
51
+ * Validated search params — populated when the matched route has `validateSearch`.
52
+ * Contains the typed result of `validateSearch(query)`. Use `useValidatedSearch()`
53
+ * to access this in components with full type inference.
54
+ * Empty object `{}` when no `validateSearch` is configured.
55
+ */
56
+ search?: Record<string, unknown> | undefined;
57
+ /** Middleware data attached during navigation (populated by middleware chain) */
58
+ _middlewareData?: Record<string, unknown> | undefined;
50
59
  }
51
60
  declare const LAZY_SYMBOL: unique symbol;
52
61
  interface LazyComponent {
@@ -144,8 +153,58 @@ interface RouteRecord<TPath extends string = string> {
144
153
  * Only applies when navigating to a route that already has cached loader data.
145
154
  */
146
155
  staleWhileRevalidate?: boolean;
156
+ /**
157
+ * Cache key function for loader data. Returns a string key derived from
158
+ * route params/query. When the key matches cached data, the loader is
159
+ * skipped (cache hit). Default: `path + JSON.stringify(params)`.
160
+ *
161
+ * @example
162
+ * ```ts
163
+ * loaderKey: ({ params }) => `user-${params.id}`
164
+ * ```
165
+ */
166
+ loaderKey?: (ctx: Pick<LoaderContext, 'params' | 'query'>) => string;
167
+ /**
168
+ * Time in ms to keep cached loader data before garbage collection.
169
+ * Default: 300000 (5 minutes). Set to 0 to disable caching.
170
+ * Stale data is still served immediately if `staleWhileRevalidate` is true.
171
+ */
172
+ gcTime?: number;
147
173
  /** Component rendered when this route's loader throws an error */
148
174
  errorComponent?: ComponentFn$1;
175
+ /**
176
+ * Component rendered while this route's loader is running.
177
+ * Only shown after `pendingMs` (default: 0) to avoid flash on fast loads.
178
+ * Once shown, displayed for at least `pendingMinMs` (default: 200) to avoid flicker.
179
+ */
180
+ pendingComponent?: ComponentFn$1;
181
+ /** Delay in ms before showing pendingComponent (default: 0). Prevents flash on fast loaders. */
182
+ pendingMs?: number;
183
+ /** Minimum display time in ms for pendingComponent once shown (default: 200). Prevents flicker. */
184
+ pendingMinMs?: number;
185
+ /**
186
+ * Validate and transform raw query string parameters into typed values.
187
+ * Receives the raw `Record<string, string>` from the URL and returns
188
+ * a typed object. The validated result is available via `useValidatedSearch()`.
189
+ *
190
+ * Accepts any function — use Zod `.parse`, Valibot, or a plain function:
191
+ *
192
+ * @example
193
+ * ```ts
194
+ * // Plain function:
195
+ * validateSearch: (raw) => ({
196
+ * page: Number(raw.page) || 1,
197
+ * q: raw.q ?? '',
198
+ * })
199
+ *
200
+ * // With Zod:
201
+ * validateSearch: z.object({
202
+ * page: z.coerce.number().default(1),
203
+ * q: z.string().default(''),
204
+ * }).parse
205
+ * ```
206
+ */
207
+ validateSearch?: (raw: Record<string, string>) => Record<string, unknown>;
149
208
  /** Per-route middleware — runs before guards, can accumulate context data. */
150
209
  middleware?: RouteMiddleware | RouteMiddleware[];
151
210
  }
@@ -255,6 +314,13 @@ interface Router<TNames extends string = string> {
255
314
  * call this for the same `url` you initialised the router with.
256
315
  */
257
316
  preload(path: string): Promise<void>;
317
+ /**
318
+ * Invalidate cached loader data. Forces loaders to re-run on next navigation.
319
+ * - No args: invalidate ALL cached loader data
320
+ * - String: invalidate by cache key (as returned by `loaderKey`)
321
+ * - Function: invalidate entries where the predicate returns true
322
+ */
323
+ invalidateLoader(keyOrPredicate?: string | ((key: string) => boolean)): void;
258
324
  /** Remove all event listeners, clear caches, and abort in-flight navigations. */
259
325
  destroy(): void;
260
326
  }
@@ -290,6 +356,15 @@ interface RouterInstance extends Router {
290
356
  _readyResolve: (() => void) | null;
291
357
  /** The isReady() promise instance */
292
358
  _readyPromise: Promise<void>;
359
+ /** Timestamp when the current navigation started — used for pendingMs timing */
360
+ _navigationStartTime: number;
361
+ /** Key-based loader cache: cacheKey → { data, timestamp } */
362
+ _loaderCache: Map<string, {
363
+ data: unknown;
364
+ timestamp: number;
365
+ }>;
366
+ /** In-flight loader dedup: cacheKey → Promise */
367
+ _loaderInflight: Map<string, Promise<unknown>>;
293
368
  }
294
369
  //#endregion
295
370
  //#region src/components.d.ts
@@ -338,15 +413,53 @@ interface RouterLinkProps extends Props {
338
413
  exact?: boolean;
339
414
  /**
340
415
  * Prefetch strategy for loader data:
341
- * - "hover" (default) — prefetch when the user hovers over the link
416
+ * - "intent" (default) — prefetch on hover AND focus (covers mouse + keyboard)
417
+ * - "hover" — prefetch on hover only
342
418
  * - "viewport" — prefetch when the link scrolls into the viewport
343
419
  * - "none" — no prefetching
344
420
  */
345
- prefetch?: 'hover' | 'viewport' | 'none';
421
+ prefetch?: 'intent' | 'hover' | 'viewport' | 'none';
346
422
  children?: VNodeChild | null;
347
423
  }
348
424
  declare const RouterLink: ComponentFn<RouterLinkProps>;
349
425
  //#endregion
426
+ //#region src/not-found.d.ts
427
+ /**
428
+ * Throw inside a route loader or component to trigger the nearest
429
+ * NotFoundBoundary. Inspired by Next.js's `notFound()`.
430
+ *
431
+ * @example
432
+ * ```ts
433
+ * // In a loader:
434
+ * loader: async ({ params }) => {
435
+ * const user = await fetchUser(params.id)
436
+ * if (!user) notFound()
437
+ * return user
438
+ * }
439
+ * ```
440
+ */
441
+ declare function notFound(message?: string): never;
442
+ /** Check if an error is a NotFoundError thrown by `notFound()`. */
443
+ declare function isNotFoundError(err: unknown): boolean;
444
+ interface NotFoundBoundaryProps extends Props {
445
+ /** Component or VNode to render when notFound() is thrown */
446
+ fallback: ComponentFn | VNodeChild;
447
+ children?: VNodeChild;
448
+ }
449
+ /**
450
+ * Catches `notFound()` errors from child route components or loaders
451
+ * and renders the fallback. Wraps Pyreon's ErrorBoundary with notFound
452
+ * detection — non-notFound errors propagate to parent error boundaries.
453
+ *
454
+ * @example
455
+ * ```tsx
456
+ * <NotFoundBoundary fallback={<NotFoundPage />}>
457
+ * <RouterView />
458
+ * </NotFoundBoundary>
459
+ * ```
460
+ */
461
+ declare const NotFoundBoundary: ComponentFn<NotFoundBoundaryProps>;
462
+ //#endregion
350
463
  //#region src/loader.d.ts
351
464
  /**
352
465
  * Returns the data resolved by the current route's `loader` function.
@@ -399,10 +512,6 @@ declare function serializeLoaderData(router: RouterInstance): Record<string, unk
399
512
  declare function hydrateLoaderData(router: RouterInstance, serialized: Record<string, unknown>): void;
400
513
  //#endregion
401
514
  //#region src/match.d.ts
402
- /**
403
- * Parse a query string into key-value pairs. Duplicate keys are overwritten
404
- * (last value wins). Use `parseQueryMulti` to preserve duplicates as arrays.
405
- */
406
515
  declare function parseQuery(qs: string): Record<string, string>;
407
516
  /**
408
517
  * Parse a query string preserving duplicate keys as arrays.
@@ -526,6 +635,27 @@ declare function useSearchParams<T extends Record<string, string>>(defaults?: T)
526
635
  * ```
527
636
  */
528
637
  declare function useTypedSearchParams<T extends SearchParamSchema>(schema: T): [get: () => InferSearchParams<T>, set: (updates: Partial<InferSearchParams<T>>) => Promise<void>];
638
+ /**
639
+ * Read the validated search params from the current route's `validateSearch`.
640
+ * Returns a reactive accessor that re-evaluates when the route changes.
641
+ *
642
+ * The generic `T` should match the return type of your `validateSearch` function.
643
+ *
644
+ * @example
645
+ * ```tsx
646
+ * // Route config:
647
+ * { path: '/search', validateSearch: (raw) => ({
648
+ * page: Number(raw.page) || 1,
649
+ * q: raw.q ?? '',
650
+ * }), component: SearchPage }
651
+ *
652
+ * // In SearchPage:
653
+ * const search = useValidatedSearch<{ page: number; q: string }>()
654
+ * // search().page — typed as number
655
+ * // search().q — typed as string
656
+ * ```
657
+ */
658
+ declare function useValidatedSearch<T extends Record<string, unknown> = Record<string, unknown>>(): () => T;
529
659
  /**
530
660
  * Returns true while a navigation is in progress (guards + loaders running).
531
661
  * Use this to show loading indicators during route transitions.
@@ -556,7 +686,7 @@ declare function useTransition(): () => boolean;
556
686
  * ```
557
687
  */
558
688
  declare function useMiddlewareData(): () => Record<string, unknown>;
559
- declare function createRouter(options: RouterOptions | RouteRecord[]): Router;
689
+ declare function createRouter<TNames extends string = string>(options: RouterOptions | RouteRecord[]): Router<TNames>;
560
690
  //#endregion
561
- export { type AfterEachHook, type Blocker, type BlockerFn, type ExtractParams, type LazyComponent, type LoaderContext, type NavigationGuard, type NavigationGuardResult, type ResolvedRoute, type RouteComponent, type RouteLoaderFn, type RouteMeta, type RouteMiddleware, type RouteMiddlewareContext, type RouteRecord, type Router, RouterContext, RouterLink, type RouterLinkProps, type RouterOptions, RouterProvider, type RouterProviderProps, RouterView, type RouterViewProps, type ScrollBehaviorFn, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useIsActive, useLoaderData, useMiddlewareData, useRoute, useRouter, useSearchParams, useTransition, useTypedSearchParams };
691
+ export { type AfterEachHook, type Blocker, type BlockerFn, type ExtractParams, type LazyComponent, type LoaderContext, type NavigationGuard, type NavigationGuardResult, NotFoundBoundary, type NotFoundBoundaryProps, type ResolvedRoute, type RouteComponent, type RouteLoaderFn, type RouteMeta, type RouteMiddleware, type RouteMiddlewareContext, type RouteRecord, type Router, RouterContext, RouterLink, type RouterLinkProps, type RouterOptions, RouterProvider, type RouterProviderProps, RouterView, type RouterViewProps, type ScrollBehaviorFn, buildPath, createRouter, findRouteByName, hydrateLoaderData, isNotFoundError, lazy, notFound, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useIsActive, useLoaderData, useMiddlewareData, useRoute, useRouter, useSearchParams, useTransition, useTypedSearchParams, useValidatedSearch };
562
692
  //# sourceMappingURL=index2.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/types.ts","../../../src/components.tsx","../../../src/loader.ts","../../../src/match.ts","../../../src/router.ts"],"mappings":"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"}
1
+ {"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/types.ts","../../../src/components.tsx","../../../src/not-found.ts","../../../src/loader.ts","../../../src/match.ts","../../../src/router.ts"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/router",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Official router for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/router#readme",
6
6
  "bugs": {
@@ -43,14 +43,14 @@
43
43
  "prepublishOnly": "bun run build"
44
44
  },
45
45
  "dependencies": {
46
- "@pyreon/core": "^0.13.1",
47
- "@pyreon/reactivity": "^0.13.1",
48
- "@pyreon/runtime-dom": "^0.13.1"
46
+ "@pyreon/core": "^0.14.0",
47
+ "@pyreon/reactivity": "^0.14.0",
48
+ "@pyreon/runtime-dom": "^0.14.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@happy-dom/global-registrator": "^20.8.9",
52
52
  "@pyreon/manifest": "0.13.1",
53
- "@pyreon/test-utils": "^0.13.1",
53
+ "@pyreon/test-utils": "^0.13.2",
54
54
  "@vitest/browser-playwright": "^4.1.4",
55
55
  "happy-dom": "^20.8.3"
56
56
  }
@@ -1,5 +1,6 @@
1
1
  import type { ComponentFn, Props, VNodeChild } from '@pyreon/core'
2
2
  import { createRef, ErrorBoundary, h, onUnmount, provide, useContext } from '@pyreon/core'
3
+ import { signal } from '@pyreon/reactivity'
3
4
  import { LoaderDataContext, prefetchLoaderData } from './loader'
4
5
  import { isLazy, RouterContext, setActiveRouter } from './router'
5
6
  import type { LazyComponent, ResolvedRoute, RouteRecord, Router, RouterInstance } from './types'
@@ -117,17 +118,18 @@ export interface RouterLinkProps extends Props {
117
118
  exact?: boolean
118
119
  /**
119
120
  * Prefetch strategy for loader data:
120
- * - "hover" (default) — prefetch when the user hovers over the link
121
+ * - "intent" (default) — prefetch on hover AND focus (covers mouse + keyboard)
122
+ * - "hover" — prefetch on hover only
121
123
  * - "viewport" — prefetch when the link scrolls into the viewport
122
124
  * - "none" — no prefetching
123
125
  */
124
- prefetch?: 'hover' | 'viewport' | 'none'
126
+ prefetch?: 'intent' | 'hover' | 'viewport' | 'none'
125
127
  children?: VNodeChild | null
126
128
  }
127
129
 
128
130
  export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
129
131
  const router = useContext(RouterContext)
130
- const prefetchMode = props.prefetch ?? 'hover'
132
+ const prefetchMode = props.prefetch ?? 'intent'
131
133
 
132
134
  const handleClick = (e: MouseEvent) => {
133
135
  e.preventDefault()
@@ -139,14 +141,29 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
139
141
  }
140
142
  }
141
143
 
142
- const handleMouseEnter = () => {
143
- if (prefetchMode !== 'hover' || !router) return
144
+ const triggerPrefetch = () => {
145
+ if (!router) return
144
146
  prefetchRoute(router as RouterInstance, props.to)
145
147
  }
146
148
 
149
+ const handleMouseEnter = () => {
150
+ if (prefetchMode === 'hover' || prefetchMode === 'intent') triggerPrefetch()
151
+ }
152
+
153
+ const handleFocus = () => {
154
+ if (prefetchMode === 'intent') triggerPrefetch()
155
+ }
156
+
147
157
  const inst = router as RouterInstance | null
148
158
  const href = inst?.mode === 'history' ? `${inst._base}${props.to}` : `#${props.to}`
149
159
 
160
+ const isExactMatch = (): boolean => {
161
+ if (!router) return false
162
+ const target = props.to
163
+ if (typeof target !== 'string') return false
164
+ return router.currentRoute().path === target
165
+ }
166
+
150
167
  const activeClass = (): string => {
151
168
  if (!router) return ''
152
169
  const current = router.currentRoute().path
@@ -161,6 +178,8 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
161
178
  return classes.join(' ').trim()
162
179
  }
163
180
 
181
+ const ariaCurrent = (): string | undefined => isExactMatch() ? 'page' : undefined
182
+
164
183
  // Viewport prefetching — observe link visibility with IntersectionObserver
165
184
  const ref = createRef<Element>()
166
185
  if (prefetchMode === 'viewport' && router && typeof IntersectionObserver !== 'undefined') {
@@ -185,12 +204,14 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
185
204
 
186
205
  return h(
187
206
  'a',
188
- { ...rest, ref, href, class: activeClass, onClick: handleClick, onMouseEnter: handleMouseEnter },
207
+ { ...rest, ref, href, class: activeClass, 'aria-current': ariaCurrent, onClick: handleClick, onMouseEnter: handleMouseEnter, onFocus: handleFocus },
189
208
  children ?? props.to,
190
209
  )
191
210
  }
192
211
 
193
212
  /** Prefetch loader data for a route (only once per router + path). */
213
+ const MAX_PREFETCH_CACHE = 50
214
+
194
215
  function prefetchRoute(router: RouterInstance, path: string): void {
195
216
  let set = _prefetched.get(router)
196
217
  if (!set) {
@@ -198,6 +219,11 @@ function prefetchRoute(router: RouterInstance, path: string): void {
198
219
  _prefetched.set(router, set)
199
220
  }
200
221
  if (set.has(path)) return
222
+ // Evict oldest entries when cache is full to prevent unbounded growth
223
+ if (set.size >= MAX_PREFETCH_CACHE) {
224
+ const first = set.values().next().value as string
225
+ set.delete(first)
226
+ }
201
227
  set.add(path)
202
228
  prefetchLoaderData(router, path).catch(() => {
203
229
  // Silently ignore — prefetch is best-effort
@@ -276,12 +302,118 @@ function renderLoaderContent(
276
302
  routeProps: Record<string, unknown>,
277
303
  ): VNodeChild {
278
304
  const data = router._loaderData.get(record)
279
- if (data === undefined && record.errorComponent) {
305
+
306
+ if (data !== undefined) {
307
+ return h(LoaderDataProvider, { data, children: h(Comp, routeProps) })
308
+ }
309
+
310
+ // Data not yet available — show pending component if configured
311
+ if (record.pendingComponent) {
312
+ return h(PendingLoader as unknown as ComponentFn, {
313
+ router,
314
+ record,
315
+ Comp,
316
+ routeProps,
317
+ })
318
+ }
319
+
320
+ if (record.errorComponent) {
280
321
  return h(record.errorComponent, routeProps)
281
322
  }
282
323
  return h(LoaderDataProvider, { data, children: h(Comp, routeProps) })
283
324
  }
284
325
 
326
+ /**
327
+ * Signal-based pending component with timing control.
328
+ *
329
+ * State machine: hidden → pending → ready
330
+ * - hidden: initial state, nothing shown (lasts pendingMs)
331
+ * - pending: pendingComponent shown (lasts at least pendingMinMs)
332
+ * - ready: real component shown (loader data arrived + minTime elapsed)
333
+ */
334
+ function PendingLoader(props: {
335
+ router: RouterInstance
336
+ record: RouteRecord
337
+ Comp: ComponentFn
338
+ routeProps: Record<string, unknown>
339
+ }): VNodeChild {
340
+ const { router, record, Comp, routeProps } = props
341
+ const pendingMs = record.pendingMs ?? 0
342
+ const pendingMinMs = record.pendingMinMs ?? 200
343
+
344
+ type Phase = 'hidden' | 'pending' | 'ready'
345
+ const phase = signal<Phase>(pendingMs === 0 ? 'pending' : 'hidden')
346
+
347
+ let pendingTimer: ReturnType<typeof setTimeout> | null = null
348
+ let minTimer: ReturnType<typeof setTimeout> | null = null
349
+ let minTimeElapsed = pendingMs === 0 ? false : true // if no delay, minTime matters
350
+ let dataReady = false
351
+
352
+ if (pendingMs === 0) {
353
+ // Show pending immediately, start minTime countdown
354
+ minTimeElapsed = false
355
+ minTimer = setTimeout(() => {
356
+ minTimeElapsed = true
357
+ minTimer = null
358
+ if (dataReady) phase.set('ready')
359
+ }, pendingMinMs)
360
+ } else {
361
+ // Delay before showing pending
362
+ pendingTimer = setTimeout(() => {
363
+ pendingTimer = null
364
+ if (dataReady) {
365
+ // Data arrived during delay — skip pending entirely
366
+ phase.set('ready')
367
+ } else {
368
+ phase.set('pending')
369
+ minTimeElapsed = false
370
+ minTimer = setTimeout(() => {
371
+ minTimeElapsed = true
372
+ minTimer = null
373
+ if (dataReady) phase.set('ready')
374
+ }, pendingMinMs)
375
+ }
376
+ }, pendingMs)
377
+ }
378
+
379
+ // Watch for loader data arrival
380
+ const checkData = () => {
381
+ const data = router._loaderData.get(record)
382
+ if (data !== undefined) {
383
+ dataReady = true
384
+ if (phase.peek() === 'hidden') {
385
+ // Data arrived before pendingMs — skip pending, go straight to ready
386
+ if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null }
387
+ phase.set('ready')
388
+ } else if (minTimeElapsed) {
389
+ phase.set('ready')
390
+ }
391
+ // else: pending is showing but minTime hasn't elapsed — wait for minTimer
392
+ }
393
+ }
394
+
395
+ // Poll via loadingSignal reactivity — re-checks when navigation completes
396
+ // This runs inside the reactive accessor below
397
+
398
+ onUnmount(() => {
399
+ if (pendingTimer) clearTimeout(pendingTimer)
400
+ if (minTimer) clearTimeout(minTimer)
401
+ })
402
+
403
+ return (() => {
404
+ // Track router's loading signal to re-run when loader completes
405
+ router._loadingSignal()
406
+ checkData()
407
+
408
+ const p = phase()
409
+ if (p === 'hidden') return null
410
+ if (p === 'pending') return h(record.pendingComponent!, routeProps)
411
+ // ready
412
+ const data = router._loaderData.get(record)
413
+ return h(LoaderDataProvider, { data, children: h(Comp, routeProps) })
414
+ }) as unknown as VNodeChild
415
+ }
416
+
285
417
  /**
286
418
  * Thin provider component that pushes LoaderDataContext before children mount.
287
419
  * Uses Pyreon's context stack so useLoaderData() reads it during child setup.
package/src/index.ts CHANGED
@@ -44,6 +44,8 @@
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'
47
49
  export { hydrateLoaderData, prefetchLoaderData, serializeLoaderData, useLoaderData } from './loader'
48
50
  // Match utilities (useful for SSR route pre-fetching)
49
51
  export {
@@ -68,6 +70,7 @@ export {
68
70
  useSearchParams,
69
71
  useTransition,
70
72
  useTypedSearchParams,
73
+ useValidatedSearch,
71
74
  } from './router'
72
75
  // Types
73
76
  // Data loaders
package/src/loader.ts CHANGED
@@ -2,6 +2,11 @@ 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
+ // @ts-ignore — `import.meta.env.DEV` is provided by Vite/Rolldown at build time
7
+ const __DEV__ = import.meta.env?.DEV === true
8
+ const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
9
+
5
10
  /**
6
11
  * Context frame that holds the loader data for the currently rendered route record.
7
12
  * Pushed by RouterView's withLoaderData wrapper before invoking the route component.
@@ -34,6 +39,7 @@ export function useLoaderData<T = unknown>(): T {
34
39
  * const html = await renderToString(h(App, { router }))
35
40
  */
36
41
  export async function prefetchLoaderData(router: RouterInstance, path: string): Promise<void> {
42
+ if (__DEV__) _countSink.__pyreon_count__?.('router.prefetch')
37
43
  const route = router._resolve(path)
38
44
  // Use a local AbortController — prefetch is best-effort and must NOT
39
45
  // clobber `router._abortController`, which belongs to the active
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]
@@ -558,6 +563,7 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
558
563
  hash,
559
564
  matched: staticMatch.matchedChain,
560
565
  meta: staticMatch.meta,
566
+ search: runValidateSearch(staticMatch.matchedChain, query),
561
567
  }
562
568
  }
563
569
 
@@ -579,6 +585,7 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
579
585
  hash,
580
586
  matched: match.matched,
581
587
  meta: mergeMeta(match.matched),
588
+ search: runValidateSearch(match.matched, query),
582
589
  }
583
590
  }
584
591
  }
@@ -594,6 +601,7 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
594
601
  hash,
595
602
  matched: dynMatch.matched,
596
603
  meta: mergeMeta(dynMatch.matched),
604
+ search: runValidateSearch(dynMatch.matched, query),
597
605
  }
598
606
  }
599
607
 
@@ -607,10 +615,31 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
607
615
  hash,
608
616
  matched: w.matchedChain,
609
617
  meta: w.meta,
618
+ search: runValidateSearch(w.matchedChain, query),
610
619
  }
611
620
  }
612
621
 
613
- return { path: cleanPath, params: {}, query, hash, matched: [], meta: {} }
622
+ return { path: cleanPath, params: {}, query, hash, matched: [], meta: {}, search: {} }
623
+ }
624
+
625
+ /** Run validateSearch from the deepest matched route that has one. */
626
+ function runValidateSearch(
627
+ matched: RouteRecord[],
628
+ query: Record<string, string>,
629
+ ): Record<string, unknown> {
630
+ // Walk from leaf to root — first validateSearch wins (most specific route)
631
+ for (let i = matched.length - 1; i >= 0; i--) {
632
+ const validate = matched[i]?.validateSearch
633
+ if (validate) {
634
+ try {
635
+ return validate(query)
636
+ } catch {
637
+ // Validation failed — return raw query as-is
638
+ return { ...query }
639
+ }
640
+ }
641
+ }
642
+ return {}
614
643
  }
615
644
 
616
645
  /** Merge meta from matched routes (leaf takes precedence) */