@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.
- package/README.md +73 -2
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +309 -21
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +138 -8
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/components.tsx +139 -7
- package/src/index.ts +3 -0
- package/src/loader.ts +6 -0
- package/src/match.ts +36 -7
- package/src/not-found.ts +75 -0
- package/src/router.ts +179 -21
- package/src/tests/match.test.ts +31 -0
- package/src/tests/router.test.ts +537 -1
- package/src/types.ts +72 -0
package/src/not-found.ts
ADDED
|
@@ -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
|
-
): [
|
|
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')
|
|
327
|
-
|
|
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 () =>
|
|
441
|
+
return () => router.currentRoute()._middlewareData ?? {}
|
|
389
442
|
}
|
|
390
443
|
|
|
391
444
|
// ─── Factory ──────────────────────────────────────────────────────────────────
|
|
392
445
|
|
|
393
|
-
export function createRouter
|
|
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 =
|
|
649
|
-
&&
|
|
650
|
-
|
|
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 = (
|
|
674
|
-
|
|
675
|
-
|
|
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<
|
|
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
|
-
|
|
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)
|
package/src/tests/match.test.ts
CHANGED
|
@@ -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
|
+
})
|