@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.
@@ -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
+ }
package/src/router.ts CHANGED
@@ -29,6 +29,9 @@ const _isBrowser = typeof window !== 'undefined'
29
29
  // @ts-ignore — `import.meta.env.DEV` is provided by Vite/Rolldown at build time
30
30
  const __DEV__ = import.meta.env?.DEV === true
31
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 }
34
+
32
35
  // ─── Router context ───────────────────────────────────────────────────────────
33
36
  // Context-based access: isolated per request in SSR (ALS-backed via
34
37
  // @pyreon/runtime-server), isolated per component tree in CSR.
@@ -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,
@@ -558,6 +613,68 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
558
613
  return runGlobalGuards(guards, to, from, gen)
559
614
  }
560
615
 
616
+ /** Default cache key: path + serialized params */
617
+ function defaultLoaderKey(
618
+ record: RouteRecord,
619
+ ctx: Pick<LoaderContext, 'params' | 'query'>,
620
+ ): string {
621
+ return `${record.path}:${JSON.stringify(ctx.params)}`
622
+ }
623
+
624
+ /** Get cache key for a route record + context. */
625
+ function getCacheKey(record: RouteRecord, ctx: Pick<LoaderContext, 'params' | 'query'>): string {
626
+ return record.loaderKey ? record.loaderKey(ctx) : defaultLoaderKey(record, ctx)
627
+ }
628
+
629
+ /** Check if a cached entry is still fresh (not expired by gcTime). */
630
+ function isCacheFresh(entry: { timestamp: number }, record: RouteRecord): boolean {
631
+ const gcTime = record.gcTime ?? 300_000 // 5 min default
632
+ if (gcTime === 0) return false // caching disabled
633
+ return Date.now() - entry.timestamp < gcTime
634
+ }
635
+
636
+ /**
637
+ * Execute a loader with cache + dedup:
638
+ * 1. Cache hit + fresh → return cached data (skip loader entirely)
639
+ * 2. In-flight for same key → dedup (return existing promise)
640
+ * 3. Otherwise → run loader, cache result, clean up in-flight
641
+ */
642
+ function executeLoader(record: RouteRecord, loaderCtx: LoaderContext): Promise<unknown> {
643
+ if (!record.loader) return Promise.resolve(undefined)
644
+
645
+ const key = getCacheKey(record, loaderCtx)
646
+
647
+ // 1. Cache hit — skip for SWR routes (they always revalidate via the SWR path)
648
+ if (!record.staleWhileRevalidate) {
649
+ const cached = router._loaderCache.get(key)
650
+ if (cached && isCacheFresh(cached, record)) {
651
+ if (__DEV__) _countSink.__pyreon_count__?.('router.loaderCache.hit')
652
+ return Promise.resolve(cached.data)
653
+ }
654
+ }
655
+
656
+ // 2. Dedup in-flight
657
+ const inflight = router._loaderInflight.get(key)
658
+ if (inflight) return inflight
659
+
660
+ // 3. Execute
661
+ if (__DEV__) _countSink.__pyreon_count__?.('router.loaderRun')
662
+ const promise = record
663
+ .loader(loaderCtx)
664
+ .then((data) => {
665
+ router._loaderCache.set(key, { data, timestamp: Date.now() })
666
+ router._loaderInflight.delete(key)
667
+ return data
668
+ })
669
+ .catch((err) => {
670
+ router._loaderInflight.delete(key)
671
+ throw err
672
+ })
673
+
674
+ router._loaderInflight.set(key, promise)
675
+ return promise
676
+ }
677
+
561
678
  async function runBlockingLoaders(
562
679
  records: RouteRecord[],
563
680
  to: ResolvedRoute,
@@ -565,9 +682,7 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
565
682
  ac: AbortController,
566
683
  ): Promise<boolean> {
567
684
  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
- )
685
+ const results = await Promise.allSettled(records.map((r) => executeLoader(r, loaderCtx)))
571
686
  if (gen !== _navGen) return false
572
687
  for (let i = 0; i < records.length; i++) {
573
688
  const result = results[i]
@@ -583,10 +698,14 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
583
698
  const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
584
699
  for (const r of records) {
585
700
  if (!r.loader) continue
701
+ // Bypass cache for revalidation — always fetch fresh
586
702
  r.loader(loaderCtx)
587
703
  .then((data) => {
588
704
  if (!ac.signal.aborted) {
589
705
  router._loaderData.set(r, data)
706
+ // Update cache with fresh data
707
+ const key = getCacheKey(r, loaderCtx)
708
+ router._loaderCache.set(key, { data, timestamp: Date.now() })
590
709
  // Bump loadingSignal to trigger reactive re-render with fresh data
591
710
  loadingSignal.update((n) => n + 1)
592
711
  loadingSignal.update((n) => n - 1)
@@ -645,9 +764,10 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
645
764
 
646
765
  // Use View Transitions API when available and not explicitly disabled.
647
766
  // Route meta can opt out: meta: { viewTransition: false }
648
- const useVT = _isBrowser
649
- && to.meta.viewTransition !== false
650
- && typeof (document as any).startViewTransition === 'function'
767
+ const useVT =
768
+ _isBrowser &&
769
+ to.meta.viewTransition !== false &&
770
+ typeof (document as any).startViewTransition === 'function'
651
771
 
652
772
  if (useVT) {
653
773
  // `startViewTransition(cb)` runs `cb` inside an async transition. Its
@@ -670,10 +790,11 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
670
790
  ready?: Promise<void>
671
791
  finished?: Promise<void>
672
792
  }
673
- const vt = (document as { startViewTransition?: (cb: () => void) => ViewTransitionLike | undefined })
674
- .startViewTransition!(() => {
675
- doCommit()
676
- })
793
+ const vt = (
794
+ document as { startViewTransition?: (cb: () => void) => ViewTransitionLike | undefined }
795
+ ).startViewTransition!(() => {
796
+ doCommit()
797
+ })
677
798
  // `startViewTransition` may return `undefined` in test doubles
678
799
  // that shim it with a bare `(cb) => cb()`. Guard accordingly.
679
800
  if (vt) {
@@ -734,7 +855,9 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
734
855
  to: ResolvedRoute,
735
856
  from: ResolvedRoute,
736
857
  gen: number,
737
- ): Promise<{ action: 'continue' } | { action: 'cancel' } | { action: 'redirect'; target: string }> {
858
+ ): Promise<
859
+ { action: 'continue' } | { action: 'cancel' } | { action: 'redirect'; target: string }
860
+ > {
738
861
  const ctx: RouteMiddlewareContext = { to, from, data: {} }
739
862
 
740
863
  for (const record of to.matched) {
@@ -749,11 +872,13 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
749
872
  }
750
873
 
751
874
  // Store middleware data on the resolved route for component access
752
- ;(to as any)._middlewareData = ctx.data
875
+ to._middlewareData = ctx.data
753
876
  return { action: 'continue' }
754
877
  }
755
878
 
756
879
  async function navigate(rawPath: string, replace: boolean, redirectDepth = 0): Promise<void> {
880
+ if (__DEV__) _countSink.__pyreon_count__?.('router.navigate')
881
+ router._navigationStartTime = Date.now()
757
882
  if (redirectDepth > 10) {
758
883
  if (__DEV__) {
759
884
  // oxlint-disable-next-line no-console
@@ -847,6 +972,9 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
847
972
  _readyPromise,
848
973
  _onError: onError,
849
974
  _maxCacheSize: maxCacheSize,
975
+ _navigationStartTime: Date.now(),
976
+ _loaderCache: new Map(),
977
+ _loaderInflight: new Map(),
850
978
 
851
979
  async push(
852
980
  location:
@@ -958,6 +1086,27 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
958
1086
  )
959
1087
  },
960
1088
 
1089
+ invalidateLoader(keyOrPredicate?: string | ((key: string) => boolean)) {
1090
+ if (!keyOrPredicate) {
1091
+ // Invalidate all
1092
+ router._loaderCache.clear()
1093
+ router._loaderInflight.clear()
1094
+ return
1095
+ }
1096
+ if (typeof keyOrPredicate === 'string') {
1097
+ router._loaderCache.delete(keyOrPredicate)
1098
+ router._loaderInflight.delete(keyOrPredicate)
1099
+ return
1100
+ }
1101
+ // Predicate
1102
+ for (const key of [...router._loaderCache.keys()]) {
1103
+ if (keyOrPredicate(key)) {
1104
+ router._loaderCache.delete(key)
1105
+ router._loaderInflight.delete(key)
1106
+ }
1107
+ }
1108
+ },
1109
+
961
1110
  destroy() {
962
1111
  if (_popstateHandler) window.removeEventListener('popstate', _popstateHandler)
963
1112
  if (_hashchangeHandler) window.removeEventListener('hashchange', _hashchangeHandler)
@@ -968,6 +1117,8 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
968
1117
  router._blockers.clear()
969
1118
  componentCache.clear()
970
1119
  router._loaderData.clear()
1120
+ router._loaderCache.clear()
1121
+ router._loaderInflight.clear()
971
1122
  router._abortController?.abort()
972
1123
  router._abortController = null
973
1124
  // Clear global ref so stale router doesn't survive in SSR or re-creation
@@ -986,7 +1137,7 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
986
1137
  }
987
1138
  })
988
1139
 
989
- return router
1140
+ return router as unknown as Router<TNames>
990
1141
  }
991
1142
 
992
1143
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -1014,6 +1165,13 @@ function resolveNamedPath(
1014
1165
  ): string {
1015
1166
  const record = index.get(name)
1016
1167
  if (!record) {
1168
+ if (__DEV__) {
1169
+ // oxlint-disable-next-line no-console
1170
+ console.warn(
1171
+ `[Pyreon Router] Unknown route name "${name}". ` +
1172
+ `Available names: ${[...index.keys()].join(', ') || '(none)'}. Falling back to "/".`,
1173
+ )
1174
+ }
1017
1175
  return '/'
1018
1176
  }
1019
1177
  let path = buildPath(record.path, params)
@@ -465,3 +465,34 @@ describe('resolveRoute — wildcard patterns', () => {
465
465
  expect(r.matched[r.matched.length - 1]?.component).toBe(NotFound)
466
466
  })
467
467
  })
468
+
469
+ // ─── + as space in query parsing (application/x-www-form-urlencoded) ────────
470
+
471
+ describe('parseQuery — + as space', () => {
472
+ it('decodes + as space in values', () => {
473
+ expect(parseQuery('name=john+doe')).toEqual({ name: 'john doe' })
474
+ })
475
+
476
+ it('decodes + as space in keys', () => {
477
+ expect(parseQuery('first+name=Alice')).toEqual({ 'first name': 'Alice' })
478
+ })
479
+
480
+ it('handles mixed + and %20', () => {
481
+ expect(parseQuery('a=hello+world&b=foo%20bar')).toEqual({
482
+ a: 'hello world',
483
+ b: 'foo bar',
484
+ })
485
+ })
486
+
487
+ it('handles multiple + in a value', () => {
488
+ expect(parseQuery('q=one+two+three')).toEqual({ q: 'one two three' })
489
+ })
490
+ })
491
+
492
+ describe('parseQueryMulti — + as space', () => {
493
+ it('decodes + as space in values', () => {
494
+ expect(parseQueryMulti('tag=hello+world&tag=foo+bar')).toEqual({
495
+ tag: ['hello world', 'foo bar'],
496
+ })
497
+ })
498
+ })