@pyreon/router 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -2
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +467 -56
- package/lib/types/index.d.ts +218 -13
- package/package.json +6 -5
- package/src/components.tsx +299 -32
- package/src/env.d.ts +6 -0
- package/src/index.ts +5 -0
- package/src/loader.ts +18 -2
- package/src/manifest.ts +63 -0
- package/src/match.ts +48 -8
- package/src/not-found.ts +75 -0
- package/src/redirect.ts +63 -0
- package/src/router.ts +263 -45
- package/src/tests/loader.test.ts +149 -0
- package/src/tests/manifest-snapshot.test.ts +5 -1
- package/src/tests/match.test.ts +31 -0
- package/src/tests/native-markers.test.ts +18 -0
- package/src/tests/redirect.test.ts +96 -0
- package/src/tests/router.browser.test.tsx +68 -1
- package/src/tests/router.test.ts +686 -1
- package/src/tests/routerlink-reactive-to.browser.test.tsx +158 -0
- package/src/types.ts +95 -1
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
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/redirect.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ─── Redirect symbol + throw ────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
const REDIRECT = Symbol.for('pyreon.redirect')
|
|
4
|
+
|
|
5
|
+
/** Standard redirect status codes. 307/308 preserve the request method, 302/303 don't. */
|
|
6
|
+
export type RedirectStatus = 301 | 302 | 303 | 307 | 308
|
|
7
|
+
|
|
8
|
+
interface RedirectInfo {
|
|
9
|
+
url: string
|
|
10
|
+
status: RedirectStatus
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Throw inside a route loader to redirect the navigation server-side
|
|
15
|
+
* (during SSR returns a 302/307 `Location:` response) and client-side
|
|
16
|
+
* (during CSR triggers `router.replace()` before the layout renders).
|
|
17
|
+
*
|
|
18
|
+
* The auth-gate use case: replaces the fragile `onMount + router.push()`
|
|
19
|
+
* workaround. `onMount` doesn't fire reliably under nested-layout dev SSR +
|
|
20
|
+
* hydration — so the layout renders briefly before the push happens, leaking
|
|
21
|
+
* authenticated UI to unauthenticated users. `redirect()` runs in the loader
|
|
22
|
+
* BEFORE the layout's component is invoked, so the unauthenticated UI never
|
|
23
|
+
* mounts in the first place.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* // src/routes/app/_layout.tsx
|
|
28
|
+
* export const loader = async ({ request }) => {
|
|
29
|
+
* const session = await getSession(request)
|
|
30
|
+
* if (!session) redirect('/login')
|
|
31
|
+
* return { user: session.user }
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @param url - Target URL (typically a path like `/login` or absolute URL for cross-origin).
|
|
36
|
+
* @param status - HTTP redirect status. Default `307` (Temporary Redirect, method-preserving).
|
|
37
|
+
* Use `301`/`308` for permanent moves, `302`/`303` to force GET on the target.
|
|
38
|
+
*/
|
|
39
|
+
export function redirect(url: string, status: RedirectStatus = 307): never {
|
|
40
|
+
const err = new Error(`Redirect to ${url}`)
|
|
41
|
+
;(err as unknown as Record<symbol, RedirectInfo>)[REDIRECT] = { url, status }
|
|
42
|
+
throw err
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Check if an error is a RedirectError thrown by `redirect()`. */
|
|
46
|
+
export function isRedirectError(err: unknown): boolean {
|
|
47
|
+
return (
|
|
48
|
+
typeof err === 'object' &&
|
|
49
|
+
err !== null &&
|
|
50
|
+
typeof (err as Record<symbol, unknown>)[REDIRECT] === 'object'
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extract the redirect URL and status from a thrown RedirectError. Returns
|
|
56
|
+
* `null` if `err` isn't a RedirectError. Used by the router's loader-runner
|
|
57
|
+
* (CSR) and the SSR handler to convert the thrown error into the right kind
|
|
58
|
+
* of response (a `router.replace()` call or a `302`/`307` Response).
|
|
59
|
+
*/
|
|
60
|
+
export function getRedirectInfo(err: unknown): RedirectInfo | null {
|
|
61
|
+
if (!isRedirectError(err)) return null
|
|
62
|
+
return (err as Record<symbol, RedirectInfo>)[REDIRECT] ?? null
|
|
63
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createContext, onUnmount, useContext } from '@pyreon/core'
|
|
2
2
|
import { computed, signal } from '@pyreon/reactivity'
|
|
3
3
|
import { buildNameIndex, buildPath, resolveRoute, stringifyQuery } from './match'
|
|
4
|
+
import { getRedirectInfo } from './redirect'
|
|
4
5
|
import { ScrollManager } from './scroll'
|
|
5
6
|
import {
|
|
6
7
|
type AfterEachHook,
|
|
@@ -26,8 +27,10 @@ import {
|
|
|
26
27
|
const _isBrowser = typeof window !== 'undefined'
|
|
27
28
|
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
28
29
|
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
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 }
|
|
31
34
|
|
|
32
35
|
// ─── Router context ───────────────────────────────────────────────────────────
|
|
33
36
|
// Context-based access: isolated per request in SSR (ALS-backed via
|
|
@@ -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,
|
|
@@ -514,18 +569,24 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
514
569
|
record: RouteRecord,
|
|
515
570
|
ac: AbortController,
|
|
516
571
|
to: ResolvedRoute,
|
|
517
|
-
):
|
|
572
|
+
): GuardOutcome {
|
|
518
573
|
if (result.status === 'fulfilled') {
|
|
519
574
|
router._loaderData.set(record, result.value)
|
|
520
|
-
return
|
|
575
|
+
return { action: 'continue' }
|
|
521
576
|
}
|
|
522
|
-
if (ac.signal.aborted) return
|
|
577
|
+
if (ac.signal.aborted) return { action: 'continue' }
|
|
578
|
+
// `redirect()` from a loader: propagate as a router-level redirect so the
|
|
579
|
+
// navigate flow re-runs against the target path BEFORE the matched route's
|
|
580
|
+
// layout / page mounts. Bypasses the user-supplied `_onError` hook — a
|
|
581
|
+
// redirect is intentional flow control, not an error.
|
|
582
|
+
const info = getRedirectInfo(result.reason)
|
|
583
|
+
if (info) return { action: 'redirect', target: info.url }
|
|
523
584
|
if (router._onError) {
|
|
524
585
|
const cancel = router._onError(result.reason, to)
|
|
525
|
-
if (cancel === false) return
|
|
586
|
+
if (cancel === false) return { action: 'cancel' }
|
|
526
587
|
}
|
|
527
588
|
router._loaderData.set(record, undefined)
|
|
528
|
-
return
|
|
589
|
+
return { action: 'continue' }
|
|
529
590
|
}
|
|
530
591
|
|
|
531
592
|
function syncBrowserUrl(path: string, replace: boolean): void {
|
|
@@ -558,24 +619,122 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
558
619
|
return runGlobalGuards(guards, to, from, gen)
|
|
559
620
|
}
|
|
560
621
|
|
|
622
|
+
/** Default cache key: path + serialized params */
|
|
623
|
+
function defaultLoaderKey(
|
|
624
|
+
record: RouteRecord,
|
|
625
|
+
ctx: Pick<LoaderContext, 'params' | 'query'>,
|
|
626
|
+
): string {
|
|
627
|
+
return `${record.path}:${JSON.stringify(ctx.params)}`
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Get cache key for a route record + context. */
|
|
631
|
+
function getCacheKey(record: RouteRecord, ctx: Pick<LoaderContext, 'params' | 'query'>): string {
|
|
632
|
+
return record.loaderKey ? record.loaderKey(ctx) : defaultLoaderKey(record, ctx)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Check if a cached entry is still fresh (not expired by gcTime). */
|
|
636
|
+
function isCacheFresh(entry: { timestamp: number }, record: RouteRecord): boolean {
|
|
637
|
+
const gcTime = record.gcTime ?? 300_000 // 5 min default
|
|
638
|
+
if (gcTime === 0) return false // caching disabled
|
|
639
|
+
return Date.now() - entry.timestamp < gcTime
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Bounded set into `_loaderCache`: evicts the oldest entry (insertion-order
|
|
644
|
+
* FIFO) when the cap is exceeded. The `gcTime` TTL handles staleness, but
|
|
645
|
+
* without a size cap a long-running SPA navigating across many distinct
|
|
646
|
+
* loader keys (e.g. `/posts/:id` with hundreds of unique IDs) would
|
|
647
|
+
* accumulate cache entries indefinitely until manual `invalidateLoader()`
|
|
648
|
+
* — `_maxCacheSize` was wired through from `RouterOptions.maxCacheSize`
|
|
649
|
+
* (default 100) but the loader cache write paths never read it. Mirrors
|
|
650
|
+
* the same pattern used for `_componentCache` in `components.tsx`.
|
|
651
|
+
*/
|
|
652
|
+
function loaderCacheSet(key: string, data: unknown): void {
|
|
653
|
+
router._loaderCache.set(key, { data, timestamp: Date.now() })
|
|
654
|
+
if (router._loaderCache.size > router._maxCacheSize) {
|
|
655
|
+
// Map iterates in insertion order — first key is oldest
|
|
656
|
+
const oldest = router._loaderCache.keys().next().value as string | undefined
|
|
657
|
+
if (oldest !== undefined) router._loaderCache.delete(oldest)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Execute a loader with cache + dedup:
|
|
663
|
+
* 1. Cache hit + fresh → return cached data (skip loader entirely)
|
|
664
|
+
* 2. In-flight for same key → dedup (return existing promise)
|
|
665
|
+
* 3. Otherwise → run loader, cache result, clean up in-flight
|
|
666
|
+
*/
|
|
667
|
+
function executeLoader(record: RouteRecord, loaderCtx: LoaderContext): Promise<unknown> {
|
|
668
|
+
if (!record.loader) return Promise.resolve(undefined)
|
|
669
|
+
|
|
670
|
+
const key = getCacheKey(record, loaderCtx)
|
|
671
|
+
|
|
672
|
+
// 1. Cache hit — skip for SWR routes (they always revalidate via the SWR path)
|
|
673
|
+
if (!record.staleWhileRevalidate) {
|
|
674
|
+
const cached = router._loaderCache.get(key)
|
|
675
|
+
if (cached && isCacheFresh(cached, record)) {
|
|
676
|
+
if (__DEV__) _countSink.__pyreon_count__?.('router.loaderCache.hit')
|
|
677
|
+
return Promise.resolve(cached.data)
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// 2. Dedup in-flight — but only if the in-flight signal is still live.
|
|
682
|
+
// Pre-fix: nav-1 starts loader (signal=ac1.signal). User navigates again
|
|
683
|
+
// to the same path → nav-2's `router.push` first calls `_abortController?.abort()`
|
|
684
|
+
// (aborting ac1), then calls executeLoader. The Map still holds nav-1's
|
|
685
|
+
// promise (the .catch hasn't run yet); deduping returns it, but its
|
|
686
|
+
// signal is already aborted → nav-2 ends up with a rejected promise
|
|
687
|
+
// even though it has its own fresh ac2.signal. Now we check liveness.
|
|
688
|
+
const inflight = router._loaderInflight.get(key)
|
|
689
|
+
if (inflight && !inflight.signal.aborted) return inflight.promise
|
|
690
|
+
|
|
691
|
+
// 3. Execute. Wrap with `Promise.resolve().then(...)` so a SYNCHRONOUS
|
|
692
|
+
// throw from the loader (`redirect('/login')` / `notFound()` / a plain
|
|
693
|
+
// `throw new Error(...)`) becomes a rejected promise the `.catch` can
|
|
694
|
+
// handle — instead of escaping past the promise chain and surfacing as
|
|
695
|
+
// an unhandled exception in `runBlockingLoaders`'s `Promise.allSettled`.
|
|
696
|
+
if (__DEV__) _countSink.__pyreon_count__?.('router.loaderRun')
|
|
697
|
+
const promise = Promise.resolve()
|
|
698
|
+
.then(() => record.loader!(loaderCtx))
|
|
699
|
+
.then((data) => {
|
|
700
|
+
loaderCacheSet(key, data)
|
|
701
|
+
// Only delete if WE'RE still the registered in-flight (a later nav
|
|
702
|
+
// may have replaced the entry with a fresh promise).
|
|
703
|
+
if (router._loaderInflight.get(key)?.promise === promise) {
|
|
704
|
+
router._loaderInflight.delete(key)
|
|
705
|
+
}
|
|
706
|
+
return data
|
|
707
|
+
})
|
|
708
|
+
.catch((err) => {
|
|
709
|
+
if (router._loaderInflight.get(key)?.promise === promise) {
|
|
710
|
+
router._loaderInflight.delete(key)
|
|
711
|
+
}
|
|
712
|
+
throw err
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
router._loaderInflight.set(key, { promise, signal: loaderCtx.signal })
|
|
716
|
+
return promise
|
|
717
|
+
}
|
|
718
|
+
|
|
561
719
|
async function runBlockingLoaders(
|
|
562
720
|
records: RouteRecord[],
|
|
563
721
|
to: ResolvedRoute,
|
|
564
722
|
gen: number,
|
|
565
723
|
ac: AbortController,
|
|
566
|
-
): Promise<
|
|
724
|
+
): Promise<GuardOutcome> {
|
|
567
725
|
const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
|
|
568
|
-
const results = await Promise.allSettled(
|
|
569
|
-
|
|
570
|
-
)
|
|
571
|
-
if (gen !== _navGen) return false
|
|
726
|
+
const results = await Promise.allSettled(records.map((r) => executeLoader(r, loaderCtx)))
|
|
727
|
+
if (gen !== _navGen) return { action: 'cancel' }
|
|
572
728
|
for (let i = 0; i < records.length; i++) {
|
|
573
729
|
const result = results[i]
|
|
574
730
|
const record = records[i]
|
|
575
731
|
if (!result || !record) continue
|
|
576
|
-
|
|
732
|
+
const outcome = processLoaderResult(result, record, ac, to)
|
|
733
|
+
// Short-circuit on first redirect or cancel — later loaders' results
|
|
734
|
+
// are irrelevant once we know the navigation isn't committing here.
|
|
735
|
+
if (outcome.action !== 'continue') return outcome
|
|
577
736
|
}
|
|
578
|
-
return
|
|
737
|
+
return { action: 'continue' }
|
|
579
738
|
}
|
|
580
739
|
|
|
581
740
|
/** Fire-and-forget background revalidation for stale-while-revalidate routes. */
|
|
@@ -583,10 +742,14 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
583
742
|
const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
|
|
584
743
|
for (const r of records) {
|
|
585
744
|
if (!r.loader) continue
|
|
745
|
+
// Bypass cache for revalidation — always fetch fresh
|
|
586
746
|
r.loader(loaderCtx)
|
|
587
747
|
.then((data) => {
|
|
588
748
|
if (!ac.signal.aborted) {
|
|
589
749
|
router._loaderData.set(r, data)
|
|
750
|
+
// Update cache with fresh data
|
|
751
|
+
const key = getCacheKey(r, loaderCtx)
|
|
752
|
+
loaderCacheSet(key, data)
|
|
590
753
|
// Bump loadingSignal to trigger reactive re-render with fresh data
|
|
591
754
|
loadingSignal.update((n) => n + 1)
|
|
592
755
|
loadingSignal.update((n) => n - 1)
|
|
@@ -598,9 +761,13 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
598
761
|
}
|
|
599
762
|
}
|
|
600
763
|
|
|
601
|
-
async function runLoaders(
|
|
764
|
+
async function runLoaders(
|
|
765
|
+
to: ResolvedRoute,
|
|
766
|
+
gen: number,
|
|
767
|
+
ac: AbortController,
|
|
768
|
+
): Promise<GuardOutcome> {
|
|
602
769
|
const loadableRecords = to.matched.filter((r) => r.loader)
|
|
603
|
-
if (loadableRecords.length === 0) return
|
|
770
|
+
if (loadableRecords.length === 0) return { action: 'continue' }
|
|
604
771
|
|
|
605
772
|
const blocking: RouteRecord[] = []
|
|
606
773
|
const swr: RouteRecord[] = []
|
|
@@ -613,11 +780,11 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
613
780
|
}
|
|
614
781
|
|
|
615
782
|
if (blocking.length > 0) {
|
|
616
|
-
const
|
|
617
|
-
if (
|
|
783
|
+
const outcome = await runBlockingLoaders(blocking, to, gen, ac)
|
|
784
|
+
if (outcome.action !== 'continue') return outcome
|
|
618
785
|
}
|
|
619
786
|
if (swr.length > 0) revalidateSwrLoaders(swr, to, ac)
|
|
620
|
-
return
|
|
787
|
+
return { action: 'continue' }
|
|
621
788
|
}
|
|
622
789
|
|
|
623
790
|
async function commitNavigation(
|
|
@@ -645,9 +812,10 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
645
812
|
|
|
646
813
|
// Use View Transitions API when available and not explicitly disabled.
|
|
647
814
|
// Route meta can opt out: meta: { viewTransition: false }
|
|
648
|
-
const useVT =
|
|
649
|
-
&&
|
|
650
|
-
|
|
815
|
+
const useVT =
|
|
816
|
+
_isBrowser &&
|
|
817
|
+
to.meta.viewTransition !== false &&
|
|
818
|
+
typeof (document as any).startViewTransition === 'function'
|
|
651
819
|
|
|
652
820
|
if (useVT) {
|
|
653
821
|
// `startViewTransition(cb)` runs `cb` inside an async transition. Its
|
|
@@ -670,10 +838,11 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
670
838
|
ready?: Promise<void>
|
|
671
839
|
finished?: Promise<void>
|
|
672
840
|
}
|
|
673
|
-
const vt = (
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
841
|
+
const vt = (
|
|
842
|
+
document as { startViewTransition?: (cb: () => void) => ViewTransitionLike | undefined }
|
|
843
|
+
).startViewTransition!(() => {
|
|
844
|
+
doCommit()
|
|
845
|
+
})
|
|
677
846
|
// `startViewTransition` may return `undefined` in test doubles
|
|
678
847
|
// that shim it with a bare `(cb) => cb()`. Guard accordingly.
|
|
679
848
|
if (vt) {
|
|
@@ -734,7 +903,9 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
734
903
|
to: ResolvedRoute,
|
|
735
904
|
from: ResolvedRoute,
|
|
736
905
|
gen: number,
|
|
737
|
-
): Promise<
|
|
906
|
+
): Promise<
|
|
907
|
+
{ action: 'continue' } | { action: 'cancel' } | { action: 'redirect'; target: string }
|
|
908
|
+
> {
|
|
738
909
|
const ctx: RouteMiddlewareContext = { to, from, data: {} }
|
|
739
910
|
|
|
740
911
|
for (const record of to.matched) {
|
|
@@ -749,11 +920,13 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
749
920
|
}
|
|
750
921
|
|
|
751
922
|
// Store middleware data on the resolved route for component access
|
|
752
|
-
|
|
923
|
+
to._middlewareData = ctx.data
|
|
753
924
|
return { action: 'continue' }
|
|
754
925
|
}
|
|
755
926
|
|
|
756
927
|
async function navigate(rawPath: string, replace: boolean, redirectDepth = 0): Promise<void> {
|
|
928
|
+
if (__DEV__) _countSink.__pyreon_count__?.('router.navigate')
|
|
929
|
+
router._navigationStartTime = Date.now()
|
|
757
930
|
if (redirectDepth > 10) {
|
|
758
931
|
if (__DEV__) {
|
|
759
932
|
// oxlint-disable-next-line no-console
|
|
@@ -807,9 +980,12 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
807
980
|
const ac = new AbortController()
|
|
808
981
|
router._abortController = ac
|
|
809
982
|
|
|
810
|
-
const
|
|
811
|
-
if (
|
|
983
|
+
const loaderOutcome = await runLoaders(to, gen, ac)
|
|
984
|
+
if (loaderOutcome.action !== 'continue') {
|
|
812
985
|
loadingSignal.update((n) => n - 1)
|
|
986
|
+
if (loaderOutcome.action === 'redirect') {
|
|
987
|
+
return navigate(sanitizePath(loaderOutcome.target), replace, redirectDepth + 1)
|
|
988
|
+
}
|
|
813
989
|
return
|
|
814
990
|
}
|
|
815
991
|
|
|
@@ -847,6 +1023,9 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
847
1023
|
_readyPromise,
|
|
848
1024
|
_onError: onError,
|
|
849
1025
|
_maxCacheSize: maxCacheSize,
|
|
1026
|
+
_navigationStartTime: Date.now(),
|
|
1027
|
+
_loaderCache: new Map(),
|
|
1028
|
+
_loaderInflight: new Map(),
|
|
850
1029
|
|
|
851
1030
|
async push(
|
|
852
1031
|
location:
|
|
@@ -918,7 +1097,7 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
918
1097
|
return router._readyPromise
|
|
919
1098
|
},
|
|
920
1099
|
|
|
921
|
-
async preload(path: string) {
|
|
1100
|
+
async preload(path: string, request?: Request) {
|
|
922
1101
|
const resolved = resolveRoute(path, routes)
|
|
923
1102
|
// Load lazy components in parallel and populate the component cache so
|
|
924
1103
|
// the synchronous render pass finds ready components instead of kicking
|
|
@@ -948,16 +1127,46 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
948
1127
|
resolved.matched
|
|
949
1128
|
.filter((r) => r.loader)
|
|
950
1129
|
.map(async (r) => {
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1130
|
+
// Wrap with `Promise.resolve().then(...)` so a SYNCHRONOUS
|
|
1131
|
+
// throw — `redirect('/login')` from a sync loader, `notFound()`,
|
|
1132
|
+
// a plain `throw new Error(...)` — becomes a rejected promise
|
|
1133
|
+
// the surrounding Promise.all surfaces. Bare `await r.loader(...)`
|
|
1134
|
+
// would let synchronous throws escape past the `await` and
|
|
1135
|
+
// surface as an uncaught exception in the Vite dev SSR pipeline.
|
|
1136
|
+
const data = await Promise.resolve().then(() =>
|
|
1137
|
+
r.loader!({
|
|
1138
|
+
params: resolved.params,
|
|
1139
|
+
query: resolved.query,
|
|
1140
|
+
signal: ac.signal,
|
|
1141
|
+
...(request ? { request } : {}),
|
|
1142
|
+
}),
|
|
1143
|
+
)
|
|
956
1144
|
router._loaderData.set(r, data)
|
|
957
1145
|
}),
|
|
958
1146
|
)
|
|
959
1147
|
},
|
|
960
1148
|
|
|
1149
|
+
invalidateLoader(keyOrPredicate?: string | ((key: string) => boolean)) {
|
|
1150
|
+
if (!keyOrPredicate) {
|
|
1151
|
+
// Invalidate all
|
|
1152
|
+
router._loaderCache.clear()
|
|
1153
|
+
router._loaderInflight.clear()
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1156
|
+
if (typeof keyOrPredicate === 'string') {
|
|
1157
|
+
router._loaderCache.delete(keyOrPredicate)
|
|
1158
|
+
router._loaderInflight.delete(keyOrPredicate)
|
|
1159
|
+
return
|
|
1160
|
+
}
|
|
1161
|
+
// Predicate
|
|
1162
|
+
for (const key of [...router._loaderCache.keys()]) {
|
|
1163
|
+
if (keyOrPredicate(key)) {
|
|
1164
|
+
router._loaderCache.delete(key)
|
|
1165
|
+
router._loaderInflight.delete(key)
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
|
|
961
1170
|
destroy() {
|
|
962
1171
|
if (_popstateHandler) window.removeEventListener('popstate', _popstateHandler)
|
|
963
1172
|
if (_hashchangeHandler) window.removeEventListener('hashchange', _hashchangeHandler)
|
|
@@ -968,6 +1177,8 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
968
1177
|
router._blockers.clear()
|
|
969
1178
|
componentCache.clear()
|
|
970
1179
|
router._loaderData.clear()
|
|
1180
|
+
router._loaderCache.clear()
|
|
1181
|
+
router._loaderInflight.clear()
|
|
971
1182
|
router._abortController?.abort()
|
|
972
1183
|
router._abortController = null
|
|
973
1184
|
// Clear global ref so stale router doesn't survive in SSR or re-creation
|
|
@@ -986,7 +1197,7 @@ export function createRouter(options: RouterOptions | RouteRecord[]): Router {
|
|
|
986
1197
|
}
|
|
987
1198
|
})
|
|
988
1199
|
|
|
989
|
-
return router
|
|
1200
|
+
return router as unknown as Router<TNames>
|
|
990
1201
|
}
|
|
991
1202
|
|
|
992
1203
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -1014,6 +1225,13 @@ function resolveNamedPath(
|
|
|
1014
1225
|
): string {
|
|
1015
1226
|
const record = index.get(name)
|
|
1016
1227
|
if (!record) {
|
|
1228
|
+
if (__DEV__) {
|
|
1229
|
+
// oxlint-disable-next-line no-console
|
|
1230
|
+
console.warn(
|
|
1231
|
+
`[Pyreon Router] Unknown route name "${name}". ` +
|
|
1232
|
+
`Available names: ${[...index.keys()].join(', ') || '(none)'}. Falling back to "/".`,
|
|
1233
|
+
)
|
|
1234
|
+
}
|
|
1017
1235
|
return '/'
|
|
1018
1236
|
}
|
|
1019
1237
|
let path = buildPath(record.path, params)
|