@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/lib/types/index.d.ts
CHANGED
|
@@ -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
|
-
* - "
|
|
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
|
package/lib/types/index.d.ts.map
CHANGED
|
@@ -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":";;;;;;;;AAiBA;;;;;;;;KAAY,aAAA,qBAAkC,CAAA,6DAClC,KAAA,cAAmB,aAAA,KAAkB,IAAA,MAC7C,CAAA,+CACU,KAAA,cACR,CAAA,6DACU,KAAA,2BAAgC,aAAA,KAAkB,IAAA,MAC1D,CAAA,+CACU,KAAA,2BACR,CAAA,4DACU,KAAA,cAAmB,aAAA,KAAkB,IAAA,MAC7C,CAAA,8CACU,KAAA,cACR,MAAA;;;;;;;;;;;;;UAgBG,SAAA;EAzBR;EA2BP,KAAA;EA1BI;EA4BJ,WAAA;EA5BiD;EA8BjD,YAAA;EA7Bc;EA+Bd,cAAA;EA/BgE;EAiChE,cAAA;AAAA;AAAA,UAKe,aAAA,WACL,MAAA,+BAAqC,MAAA,4BACrC,MAAA,mBAAyB,MAAA;EAEnC,IAAA;EACA,MAAA,EAAQ,CAAA;EACR,KAAA,EAAO,CAAA;EACP,IAAA;EAzCa;EA2Cb,OAAA,EAAS,WAAA;EACT,IAAA,EAAM,SAAA;AAAA;AAAA,
|
|
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.
|
|
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.
|
|
47
|
-
"@pyreon/reactivity": "^0.
|
|
48
|
-
"@pyreon/runtime-dom": "^0.
|
|
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.
|
|
53
|
+
"@pyreon/test-utils": "^0.13.2",
|
|
54
54
|
"@vitest/browser-playwright": "^4.1.4",
|
|
55
55
|
"happy-dom": "^20.8.3"
|
|
56
56
|
}
|
package/src/components.tsx
CHANGED
|
@@ -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
|
-
* - "
|
|
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 ?? '
|
|
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
|
|
143
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
20
|
+
const key = decodeQueryComponent(part)
|
|
16
21
|
if (key) result[key] = ''
|
|
17
22
|
} else {
|
|
18
|
-
const key =
|
|
19
|
-
const val =
|
|
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 =
|
|
46
|
+
key = decodeQueryComponent(part)
|
|
42
47
|
val = ''
|
|
43
48
|
} else {
|
|
44
|
-
key =
|
|
45
|
-
val =
|
|
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) */
|