@pyreon/router 0.14.0 → 0.16.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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +326 -51
- package/lib/types/index.d.ts +131 -8
- package/package.json +6 -5
- package/src/components.tsx +192 -27
- package/src/env.d.ts +6 -0
- package/src/index.ts +9 -1
- package/src/loader.ts +72 -4
- package/src/manifest.ts +63 -0
- package/src/match.ts +227 -2
- package/src/redirect.ts +63 -0
- package/src/router.ts +105 -35
- package/src/tests/loader.test.ts +326 -1
- package/src/tests/manifest-snapshot.test.ts +5 -1
- package/src/tests/match.test.ts +284 -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 +149 -0
- package/src/tests/routerlink-reactive-to.browser.test.tsx +158 -0
- package/src/types.ts +46 -3
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/manifest.ts
CHANGED
|
@@ -254,6 +254,69 @@ const User = () => {
|
|
|
254
254
|
}`,
|
|
255
255
|
seeAlso: ['useMiddlewareData', 'useRoute'],
|
|
256
256
|
},
|
|
257
|
+
{
|
|
258
|
+
name: 'redirect',
|
|
259
|
+
kind: 'function',
|
|
260
|
+
signature: 'redirect(url: string, status?: 301 | 302 | 303 | 307 | 308): never',
|
|
261
|
+
summary:
|
|
262
|
+
"Throw inside a route loader to redirect the navigation BEFORE the layout renders. On SSR (initial nav), the thrown error is converted by `@pyreon/server`'s handler into a real HTTP `302`/`307` `Location:` response — no layout HTML leaves the server. On CSR (subsequent nav), the redirect propagates through the navigate flow and triggers `router.replace()` before any matched route's component mounts. Replaces the fragile `onMount + router.push()` workaround for auth-gates under nested-layout dev SSR + hydration. Default status is `307` (Temporary Redirect, method-preserving).",
|
|
263
|
+
example: `// src/routes/app/_layout.tsx
|
|
264
|
+
import { redirect, type LoaderContext } from "@pyreon/router"
|
|
265
|
+
|
|
266
|
+
export async function loader(ctx: LoaderContext) {
|
|
267
|
+
// SSR: read from request headers; CSR: read from document.cookie
|
|
268
|
+
const cookie = ctx.request?.headers.get("cookie")
|
|
269
|
+
?? (typeof document !== "undefined" ? document.cookie : "")
|
|
270
|
+
const sid = /(?:^|;\\s*)sid=([^;]+)/.exec(cookie)?.[1]
|
|
271
|
+
if (!sid) redirect("/login")
|
|
272
|
+
const session = await getSession(sid)
|
|
273
|
+
if (!session) redirect("/login")
|
|
274
|
+
return { session }
|
|
275
|
+
}`,
|
|
276
|
+
mistakes: [
|
|
277
|
+
'Calling `redirect()` outside a loader (in a component body, an event handler, etc.) — the helper expects to be caught by the loader-runner. For imperative redirects from event handlers, use `router.replace(target)` instead.',
|
|
278
|
+
"Forgetting to make `LoaderContext.request` access optional. It's populated only on SSR; CSR loaders see `request: undefined`. Read both: `ctx.request?.headers.get('cookie') ?? document.cookie`.",
|
|
279
|
+
'Using `redirect()` for control-flow that should be a `<Match>` / `<Show>` conditional — the helper is for redirecting the URL, not for branching the rendered output.',
|
|
280
|
+
'Returning `redirect()` instead of throwing it. The helper has return type `never` and throws — `return redirect(...)` is misleading and may suppress the throw under TS strict-null checks.',
|
|
281
|
+
'Picking the wrong status. Default `307` preserves the request method (POST stays POST after redirect). Use `302`/`303` to force GET on the target. Use `301`/`308` for PERMANENT moves (browsers cache them aggressively).',
|
|
282
|
+
'Assuming `redirect()` cancels every loader in a sibling chain. The first loader to throw wins; later loaders in the same `Promise.allSettled` batch may have already started executing before the redirect short-circuits. Treat them as best-effort.',
|
|
283
|
+
],
|
|
284
|
+
seeAlso: ['notFound', 'useLoaderData', 'isRedirectError'],
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: 'isRedirectError',
|
|
288
|
+
kind: 'function',
|
|
289
|
+
signature: 'isRedirectError(err: unknown): boolean',
|
|
290
|
+
summary:
|
|
291
|
+
'Type guard for errors thrown by `redirect()`. Used internally by the router (CSR) and `@pyreon/server` (SSR) to distinguish redirect-control-flow errors from real failures. Useful in custom error boundaries that should let redirects pass through to the framework instead of catching them.',
|
|
292
|
+
example: `import { ErrorBoundary } from "@pyreon/core"
|
|
293
|
+
import { isRedirectError } from "@pyreon/router"
|
|
294
|
+
|
|
295
|
+
<ErrorBoundary fallback={(err, reset) => {
|
|
296
|
+
if (isRedirectError(err)) throw err // let the framework handle it
|
|
297
|
+
return <ErrorPage error={err} onReset={reset} />
|
|
298
|
+
}}>
|
|
299
|
+
<App />
|
|
300
|
+
</ErrorBoundary>`,
|
|
301
|
+
seeAlso: ['redirect', 'isNotFoundError', 'getRedirectInfo'],
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: 'getRedirectInfo',
|
|
305
|
+
kind: 'function',
|
|
306
|
+
signature: 'getRedirectInfo(err: unknown): { url: string; status: 301 | 302 | 303 | 307 | 308 } | null',
|
|
307
|
+
summary:
|
|
308
|
+
"Extract the redirect URL and status from a thrown RedirectError. Returns `null` for non-redirect errors. Used by `@pyreon/server`'s SSR handler to convert the thrown error into a 302/307 `Response`.",
|
|
309
|
+
example: `import { getRedirectInfo } from "@pyreon/router"
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
await prefetchLoaderData(router, path, request)
|
|
313
|
+
} catch (err) {
|
|
314
|
+
const info = getRedirectInfo(err)
|
|
315
|
+
if (info) return new Response(null, { status: info.status, headers: { Location: info.url } })
|
|
316
|
+
throw err
|
|
317
|
+
}`,
|
|
318
|
+
seeAlso: ['redirect', 'isRedirectError'],
|
|
319
|
+
},
|
|
257
320
|
{
|
|
258
321
|
name: 'useSearchParams',
|
|
259
322
|
kind: 'hook',
|
package/src/match.ts
CHANGED
|
@@ -1,4 +1,38 @@
|
|
|
1
|
-
import type { ResolvedRoute, RouteMeta, RouteRecord } from './types'
|
|
1
|
+
import type { ResolvedRoute, RouteComponent, RouteMeta, RouteRecord } from './types'
|
|
2
|
+
|
|
3
|
+
// ─── Default chrome layout registration ──────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// Late-bound registration for the synthetic layout used by the
|
|
6
|
+
// layout-less-app 404 fallback in `findNotFoundFallback` below. The
|
|
7
|
+
// component itself lives in `./components.tsx` (it needs JSX + the
|
|
8
|
+
// `RouterView` it imports), but `match.ts` is below `components.tsx` in
|
|
9
|
+
// the dependency graph (router.ts imports match.ts; components.tsx
|
|
10
|
+
// imports router.ts) — directly importing `components.tsx` from here
|
|
11
|
+
// would create a cycle. Instead, `components.tsx` calls
|
|
12
|
+
// `_setDefaultChromeLayout(DefaultChromeLayout)` at module load. As
|
|
13
|
+
// long as the consumer's app imports anything from `@pyreon/router`
|
|
14
|
+
// that touches `components.tsx` (which every app does via
|
|
15
|
+
// `RouterProvider` / `RouterView` / `RouterLink`), the registration
|
|
16
|
+
// runs before any `resolveRoute()` call.
|
|
17
|
+
//
|
|
18
|
+
// When the setter hasn't been called (e.g. unit tests that exercise
|
|
19
|
+
// `resolveRoute` in isolation without ever importing `components.tsx`),
|
|
20
|
+
// `findNotFoundFallback` returns `null` for the layout-less case — the
|
|
21
|
+
// standalone-render path in the SSG plugin / runtime handler picks up
|
|
22
|
+
// from there. So the fix degrades gracefully.
|
|
23
|
+
let _defaultChromeLayout: RouteComponent | null = null
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Register the synthetic "default chrome" layout used when a page-level
|
|
27
|
+
* `notFoundComponent` is the closest fallback (layout-less single-page-
|
|
28
|
+
* app shape). Called once at module load from `./components.tsx`. Pyreon
|
|
29
|
+
* apps shouldn't need to call this themselves.
|
|
30
|
+
*
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
export function _setDefaultChromeLayout(component: RouteComponent): void {
|
|
34
|
+
_defaultChromeLayout = component
|
|
35
|
+
}
|
|
2
36
|
|
|
3
37
|
// ─── Query string ─────────────────────────────────────────────────────────────
|
|
4
38
|
|
|
@@ -293,7 +327,18 @@ function flattenOne(
|
|
|
293
327
|
return
|
|
294
328
|
}
|
|
295
329
|
|
|
296
|
-
|
|
330
|
+
// fs-router emits absolute paths for nested children (e.g. parent
|
|
331
|
+
// `/app` with child `/app/dashboard`, NOT child `dashboard`). Concating
|
|
332
|
+
// parent segments with the child's already-absolute segments would
|
|
333
|
+
// produce `/app/app/dashboard` — the staticMap then lookups the wrong
|
|
334
|
+
// key and resolveRoute returns `matched: []` for any such request.
|
|
335
|
+
// Detect "child path is absolute" (`path` starts with `/`) and skip the
|
|
336
|
+
// parent-segment prefix in that case — the child's own segments ARE
|
|
337
|
+
// the full intended path. Relative children (`dashboard`, `:id`)
|
|
338
|
+
// continue to inherit the parent's segments via concatenation.
|
|
339
|
+
const childPath = c.route.path
|
|
340
|
+
const isAbsoluteChild = typeof childPath === 'string' && childPath.startsWith('/')
|
|
341
|
+
const joined = isAbsoluteChild ? c.segments : [...parentSegments, ...c.segments]
|
|
297
342
|
if (c.children && c.children.length > 0) {
|
|
298
343
|
flattenWalk(result, c.children, joined, chain, meta)
|
|
299
344
|
}
|
|
@@ -619,9 +664,189 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
|
|
|
619
664
|
}
|
|
620
665
|
}
|
|
621
666
|
|
|
667
|
+
// Fallback: notFoundComponent walk. When the URL doesn't match any
|
|
668
|
+
// descendant route, look for the deepest parent `notFoundComponent`
|
|
669
|
+
// whose path is a prefix of this URL. Build a synthetic chain that
|
|
670
|
+
// renders the not-found component INSIDE its ancestor layouts so the
|
|
671
|
+
// 404 page carries the same chrome (headers, footers, navigation) as
|
|
672
|
+
// regular pages. Without this, SSG/SSR returns `matched: []` and the
|
|
673
|
+
// caller has to render the not-found component standalone, losing
|
|
674
|
+
// layout wrapping.
|
|
675
|
+
const nfb = findNotFoundFallback(routes, cleanPath)
|
|
676
|
+
if (nfb) {
|
|
677
|
+
return {
|
|
678
|
+
path: cleanPath,
|
|
679
|
+
params: {},
|
|
680
|
+
query,
|
|
681
|
+
hash,
|
|
682
|
+
matched: nfb,
|
|
683
|
+
meta: mergeMeta(nfb),
|
|
684
|
+
search: {},
|
|
685
|
+
isNotFound: true,
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
622
689
|
return { path: cleanPath, params: {}, query, hash, matched: [], meta: {}, search: {} }
|
|
623
690
|
}
|
|
624
691
|
|
|
692
|
+
// ─── notFoundComponent walking ───────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
/** Synthetic leaf RouteRecord used by the 404 fallback. Carries no real
|
|
695
|
+
* path matching — the resolver inserts it at the end of the chain when
|
|
696
|
+
* a parent `notFoundComponent` is the closest fallback for the URL. */
|
|
697
|
+
const SYNTHETIC_NOT_FOUND_PATH = '__pyreon_not_found_leaf__'
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Walk the route tree finding records with `notFoundComponent`. Return
|
|
701
|
+
* the chain `[...ancestors, parentWithNotFound, syntheticLeaf]` for the
|
|
702
|
+
* DEEPEST record whose URL path is a prefix of `urlPath`.
|
|
703
|
+
*
|
|
704
|
+
* The path-prefix check: a record at `'/de'` applies to `/de/unknown`
|
|
705
|
+
* and `/de` itself but NOT to `/about` or `/encyclopedia` (full-segment
|
|
706
|
+
* boundary required, not substring). A record at `'/'` (root layout)
|
|
707
|
+
* applies to every URL. Deeper matches win — `/de` layout takes
|
|
708
|
+
* precedence over root layout for URLs under `/de/...`.
|
|
709
|
+
*
|
|
710
|
+
* Returns `null` when no record has `notFoundComponent`.
|
|
711
|
+
*/
|
|
712
|
+
function findNotFoundFallback(routes: RouteRecord[], urlPath: string): RouteRecord[] | null {
|
|
713
|
+
let best: { chain: RouteRecord[]; record: RouteRecord; depth: number; specificity: number } | null = null
|
|
714
|
+
// Second-pass fallback: collect the BEST page-level notFoundComponent
|
|
715
|
+
// (no children) in case the layout pass finds nothing. Applies to the
|
|
716
|
+
// layout-less single-page-app case where `_404.tsx` is emitted without
|
|
717
|
+
// a parent `_layout.tsx`. The layout pass intentionally skips this
|
|
718
|
+
// shape (page records have no `<RouterView />` to wrap the leaf); the
|
|
719
|
+
// synthetic default-chrome layout fills that gap below.
|
|
720
|
+
let pageBest: {
|
|
721
|
+
record: RouteRecord
|
|
722
|
+
depth: number
|
|
723
|
+
specificity: number
|
|
724
|
+
fullPath: string
|
|
725
|
+
} | null = null
|
|
726
|
+
|
|
727
|
+
function walk(records: RouteRecord[], parentChain: RouteRecord[], parentPath: string): void {
|
|
728
|
+
for (const r of records) {
|
|
729
|
+
const rawPath = typeof r.path === 'string' ? r.path : ''
|
|
730
|
+
// fs-router emits absolute paths for nested routes (e.g. `/de/about`);
|
|
731
|
+
// relative paths inherit parent's path via concat. Mirror flattenOne's
|
|
732
|
+
// logic so synthesised paths track real URL prefixes.
|
|
733
|
+
const fullPath = rawPath.startsWith('/')
|
|
734
|
+
? rawPath
|
|
735
|
+
: `${parentPath}/${rawPath}`.replace(/\/+/g, '/')
|
|
736
|
+
const chain = [...parentChain, r]
|
|
737
|
+
|
|
738
|
+
// Filter to LAYOUT records (records with non-empty `children`).
|
|
739
|
+
// fs-router attaches `notFoundComponent` to BOTH the parent layout
|
|
740
|
+
// AND every page record under that layout. Page records have no
|
|
741
|
+
// `<RouterView />` to render the synthetic leaf at the next depth,
|
|
742
|
+
// so picking a page as the fallback parent produces a chain
|
|
743
|
+
// `[Layout, Page, syntheticLeaf]` where `Page` swallows the leaf.
|
|
744
|
+
// Filtering to records with children ensures the synthetic leaf
|
|
745
|
+
// lands at a depth a `<RouterView />` will actually render.
|
|
746
|
+
const isLayout = Array.isArray(r.children) && r.children.length > 0
|
|
747
|
+
|
|
748
|
+
if (typeof r.notFoundComponent === 'function') {
|
|
749
|
+
const applies = pathPrefixApplies(fullPath, urlPath)
|
|
750
|
+
if (applies) {
|
|
751
|
+
// Prefer (a) the deepest record (longest chain), then (b) the
|
|
752
|
+
// most specific path-prefix when chains tie. Specificity =
|
|
753
|
+
// number of path segments in `fullPath`. `/` has 0; `/de` has 1.
|
|
754
|
+
const specificity = countSegments(fullPath)
|
|
755
|
+
if (isLayout) {
|
|
756
|
+
if (
|
|
757
|
+
!best ||
|
|
758
|
+
chain.length > best.depth ||
|
|
759
|
+
(chain.length === best.depth && specificity > best.specificity)
|
|
760
|
+
) {
|
|
761
|
+
best = { chain, record: r, depth: chain.length, specificity }
|
|
762
|
+
}
|
|
763
|
+
} else if (
|
|
764
|
+
!pageBest ||
|
|
765
|
+
chain.length > pageBest.depth ||
|
|
766
|
+
(chain.length === pageBest.depth && specificity > pageBest.specificity)
|
|
767
|
+
) {
|
|
768
|
+
pageBest = { record: r, depth: chain.length, specificity, fullPath }
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (Array.isArray(r.children)) {
|
|
774
|
+
walk(r.children, chain, fullPath)
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
walk(routes, [], '')
|
|
780
|
+
|
|
781
|
+
if (best) {
|
|
782
|
+
// TypeScript widening: `best` is inferred as `null` inside the closure
|
|
783
|
+
// when not narrowed, even though we asserted it's non-null above.
|
|
784
|
+
const found: { chain: RouteRecord[]; record: RouteRecord; depth: number; specificity: number } =
|
|
785
|
+
best
|
|
786
|
+
|
|
787
|
+
const syntheticLeaf: RouteRecord = {
|
|
788
|
+
path: SYNTHETIC_NOT_FOUND_PATH,
|
|
789
|
+
component: found.record.notFoundComponent as RouteComponent,
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return [...found.chain, syntheticLeaf]
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Layout-less fallback. The user has a page-level `notFoundComponent`
|
|
796
|
+
// (e.g. `_404.tsx` at the route root with no `_layout.tsx`). Without
|
|
797
|
+
// a parent layout to wrap the leaf, we synthesize ONE: a minimal
|
|
798
|
+
// "default chrome" layout that renders `<main data-pyreon-default-chrome>
|
|
799
|
+
// <RouterView /></main>`. This provides a semantic-HTML landmark for
|
|
800
|
+
// accessibility + a hook for users to target the wrapper via CSS, while
|
|
801
|
+
// routing the render through the normal `<RouterView />` pipeline (so
|
|
802
|
+
// `isNotFound` propagation and runtime SSR status-404 still work).
|
|
803
|
+
//
|
|
804
|
+
// The DefaultChromeLayout component is registered by `components.tsx`
|
|
805
|
+
// at module load time via `_setDefaultChromeLayout()` (setter pattern
|
|
806
|
+
// to avoid the components.tsx → match.ts circular import). If the
|
|
807
|
+
// setter hasn't been called yet (consumer never imported anything
|
|
808
|
+
// from `@pyreon/router` that triggers components.tsx's side effects),
|
|
809
|
+
// we fall back to returning null — the standalone-render path in the
|
|
810
|
+
// SSG plugin / runtime handler picks up from there.
|
|
811
|
+
if (pageBest && _defaultChromeLayout) {
|
|
812
|
+
const found: {
|
|
813
|
+
record: RouteRecord
|
|
814
|
+
depth: number
|
|
815
|
+
specificity: number
|
|
816
|
+
fullPath: string
|
|
817
|
+
} = pageBest
|
|
818
|
+
|
|
819
|
+
const syntheticChromeLayout: RouteRecord = {
|
|
820
|
+
path: found.fullPath,
|
|
821
|
+
component: _defaultChromeLayout,
|
|
822
|
+
}
|
|
823
|
+
const syntheticLeaf: RouteRecord = {
|
|
824
|
+
path: SYNTHETIC_NOT_FOUND_PATH,
|
|
825
|
+
component: found.record.notFoundComponent as RouteComponent,
|
|
826
|
+
}
|
|
827
|
+
return [syntheticChromeLayout, syntheticLeaf]
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
return null
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Check whether `prefixPath` is a path-prefix of `urlPath` at segment boundaries. */
|
|
834
|
+
function pathPrefixApplies(prefixPath: string, urlPath: string): boolean {
|
|
835
|
+
if (prefixPath === '/' || prefixPath === '') return true
|
|
836
|
+
if (urlPath === prefixPath) return true
|
|
837
|
+
// Require a `/` boundary after the prefix to avoid `/de` matching `/encyclopedia`.
|
|
838
|
+
return urlPath.startsWith(`${prefixPath}/`)
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Count `/`-separated path segments. `/` → 0; `/de` → 1; `/de/about` → 2. */
|
|
842
|
+
function countSegments(path: string): number {
|
|
843
|
+
let count = 0
|
|
844
|
+
for (let i = 0; i < path.length; i++) {
|
|
845
|
+
if (path.charCodeAt(i) === 47 /* / */ && i + 1 < path.length) count++
|
|
846
|
+
}
|
|
847
|
+
return count
|
|
848
|
+
}
|
|
849
|
+
|
|
625
850
|
/** Run validateSearch from the deepest matched route that has one. */
|
|
626
851
|
function runValidateSearch(
|
|
627
852
|
matched: RouteRecord[],
|
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,
|
|
@@ -12,7 +13,6 @@ import {
|
|
|
12
13
|
type NavigationGuard,
|
|
13
14
|
type NavigationGuardResult,
|
|
14
15
|
type ResolvedRoute,
|
|
15
|
-
type RouteMiddleware,
|
|
16
16
|
type RouteMiddlewareContext,
|
|
17
17
|
type RouteRecord,
|
|
18
18
|
type Router,
|
|
@@ -26,8 +26,7 @@ import {
|
|
|
26
26
|
const _isBrowser = typeof window !== 'undefined'
|
|
27
27
|
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
28
28
|
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
29
|
-
|
|
30
|
-
const __DEV__ = import.meta.env?.DEV === true
|
|
29
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
31
30
|
|
|
32
31
|
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
33
32
|
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
@@ -569,18 +568,24 @@ export function createRouter<TNames extends string = string>(
|
|
|
569
568
|
record: RouteRecord,
|
|
570
569
|
ac: AbortController,
|
|
571
570
|
to: ResolvedRoute,
|
|
572
|
-
):
|
|
571
|
+
): GuardOutcome {
|
|
573
572
|
if (result.status === 'fulfilled') {
|
|
574
573
|
router._loaderData.set(record, result.value)
|
|
575
|
-
return
|
|
574
|
+
return { action: 'continue' }
|
|
576
575
|
}
|
|
577
|
-
if (ac.signal.aborted) return
|
|
576
|
+
if (ac.signal.aborted) return { action: 'continue' }
|
|
577
|
+
// `redirect()` from a loader: propagate as a router-level redirect so the
|
|
578
|
+
// navigate flow re-runs against the target path BEFORE the matched route's
|
|
579
|
+
// layout / page mounts. Bypasses the user-supplied `_onError` hook — a
|
|
580
|
+
// redirect is intentional flow control, not an error.
|
|
581
|
+
const info = getRedirectInfo(result.reason)
|
|
582
|
+
if (info) return { action: 'redirect', target: info.url }
|
|
578
583
|
if (router._onError) {
|
|
579
584
|
const cancel = router._onError(result.reason, to)
|
|
580
|
-
if (cancel === false) return
|
|
585
|
+
if (cancel === false) return { action: 'cancel' }
|
|
581
586
|
}
|
|
582
587
|
router._loaderData.set(record, undefined)
|
|
583
|
-
return
|
|
588
|
+
return { action: 'continue' }
|
|
584
589
|
}
|
|
585
590
|
|
|
586
591
|
function syncBrowserUrl(path: string, replace: boolean): void {
|
|
@@ -633,6 +638,25 @@ export function createRouter<TNames extends string = string>(
|
|
|
633
638
|
return Date.now() - entry.timestamp < gcTime
|
|
634
639
|
}
|
|
635
640
|
|
|
641
|
+
/**
|
|
642
|
+
* Bounded set into `_loaderCache`: evicts the oldest entry (insertion-order
|
|
643
|
+
* FIFO) when the cap is exceeded. The `gcTime` TTL handles staleness, but
|
|
644
|
+
* without a size cap a long-running SPA navigating across many distinct
|
|
645
|
+
* loader keys (e.g. `/posts/:id` with hundreds of unique IDs) would
|
|
646
|
+
* accumulate cache entries indefinitely until manual `invalidateLoader()`
|
|
647
|
+
* — `_maxCacheSize` was wired through from `RouterOptions.maxCacheSize`
|
|
648
|
+
* (default 100) but the loader cache write paths never read it. Mirrors
|
|
649
|
+
* the same pattern used for `_componentCache` in `components.tsx`.
|
|
650
|
+
*/
|
|
651
|
+
function loaderCacheSet(key: string, data: unknown): void {
|
|
652
|
+
router._loaderCache.set(key, { data, timestamp: Date.now() })
|
|
653
|
+
if (router._loaderCache.size > router._maxCacheSize) {
|
|
654
|
+
// Map iterates in insertion order — first key is oldest
|
|
655
|
+
const oldest = router._loaderCache.keys().next().value as string | undefined
|
|
656
|
+
if (oldest !== undefined) router._loaderCache.delete(oldest)
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
636
660
|
/**
|
|
637
661
|
* Execute a loader with cache + dedup:
|
|
638
662
|
* 1. Cache hit + fresh → return cached data (skip loader entirely)
|
|
@@ -653,25 +677,41 @@ export function createRouter<TNames extends string = string>(
|
|
|
653
677
|
}
|
|
654
678
|
}
|
|
655
679
|
|
|
656
|
-
// 2. Dedup in-flight
|
|
680
|
+
// 2. Dedup in-flight — but only if the in-flight signal is still live.
|
|
681
|
+
// Pre-fix: nav-1 starts loader (signal=ac1.signal). User navigates again
|
|
682
|
+
// to the same path → nav-2's `router.push` first calls `_abortController?.abort()`
|
|
683
|
+
// (aborting ac1), then calls executeLoader. The Map still holds nav-1's
|
|
684
|
+
// promise (the .catch hasn't run yet); deduping returns it, but its
|
|
685
|
+
// signal is already aborted → nav-2 ends up with a rejected promise
|
|
686
|
+
// even though it has its own fresh ac2.signal. Now we check liveness.
|
|
657
687
|
const inflight = router._loaderInflight.get(key)
|
|
658
|
-
if (inflight) return inflight
|
|
688
|
+
if (inflight && !inflight.signal.aborted) return inflight.promise
|
|
659
689
|
|
|
660
|
-
// 3. Execute
|
|
690
|
+
// 3. Execute. Wrap with `Promise.resolve().then(...)` so a SYNCHRONOUS
|
|
691
|
+
// throw from the loader (`redirect('/login')` / `notFound()` / a plain
|
|
692
|
+
// `throw new Error(...)`) becomes a rejected promise the `.catch` can
|
|
693
|
+
// handle — instead of escaping past the promise chain and surfacing as
|
|
694
|
+
// an unhandled exception in `runBlockingLoaders`'s `Promise.allSettled`.
|
|
661
695
|
if (__DEV__) _countSink.__pyreon_count__?.('router.loaderRun')
|
|
662
|
-
const promise =
|
|
663
|
-
.loader(loaderCtx)
|
|
696
|
+
const promise = Promise.resolve()
|
|
697
|
+
.then(() => record.loader!(loaderCtx))
|
|
664
698
|
.then((data) => {
|
|
665
|
-
|
|
666
|
-
|
|
699
|
+
loaderCacheSet(key, data)
|
|
700
|
+
// Only delete if WE'RE still the registered in-flight (a later nav
|
|
701
|
+
// may have replaced the entry with a fresh promise).
|
|
702
|
+
if (router._loaderInflight.get(key)?.promise === promise) {
|
|
703
|
+
router._loaderInflight.delete(key)
|
|
704
|
+
}
|
|
667
705
|
return data
|
|
668
706
|
})
|
|
669
707
|
.catch((err) => {
|
|
670
|
-
router._loaderInflight.
|
|
708
|
+
if (router._loaderInflight.get(key)?.promise === promise) {
|
|
709
|
+
router._loaderInflight.delete(key)
|
|
710
|
+
}
|
|
671
711
|
throw err
|
|
672
712
|
})
|
|
673
713
|
|
|
674
|
-
router._loaderInflight.set(key, promise)
|
|
714
|
+
router._loaderInflight.set(key, { promise, signal: loaderCtx.signal })
|
|
675
715
|
return promise
|
|
676
716
|
}
|
|
677
717
|
|
|
@@ -680,17 +720,20 @@ export function createRouter<TNames extends string = string>(
|
|
|
680
720
|
to: ResolvedRoute,
|
|
681
721
|
gen: number,
|
|
682
722
|
ac: AbortController,
|
|
683
|
-
): Promise<
|
|
723
|
+
): Promise<GuardOutcome> {
|
|
684
724
|
const loaderCtx: LoaderContext = { params: to.params, query: to.query, signal: ac.signal }
|
|
685
725
|
const results = await Promise.allSettled(records.map((r) => executeLoader(r, loaderCtx)))
|
|
686
|
-
if (gen !== _navGen) return
|
|
726
|
+
if (gen !== _navGen) return { action: 'cancel' }
|
|
687
727
|
for (let i = 0; i < records.length; i++) {
|
|
688
728
|
const result = results[i]
|
|
689
729
|
const record = records[i]
|
|
690
730
|
if (!result || !record) continue
|
|
691
|
-
|
|
731
|
+
const outcome = processLoaderResult(result, record, ac, to)
|
|
732
|
+
// Short-circuit on first redirect or cancel — later loaders' results
|
|
733
|
+
// are irrelevant once we know the navigation isn't committing here.
|
|
734
|
+
if (outcome.action !== 'continue') return outcome
|
|
692
735
|
}
|
|
693
|
-
return
|
|
736
|
+
return { action: 'continue' }
|
|
694
737
|
}
|
|
695
738
|
|
|
696
739
|
/** Fire-and-forget background revalidation for stale-while-revalidate routes. */
|
|
@@ -705,7 +748,7 @@ export function createRouter<TNames extends string = string>(
|
|
|
705
748
|
router._loaderData.set(r, data)
|
|
706
749
|
// Update cache with fresh data
|
|
707
750
|
const key = getCacheKey(r, loaderCtx)
|
|
708
|
-
|
|
751
|
+
loaderCacheSet(key, data)
|
|
709
752
|
// Bump loadingSignal to trigger reactive re-render with fresh data
|
|
710
753
|
loadingSignal.update((n) => n + 1)
|
|
711
754
|
loadingSignal.update((n) => n - 1)
|
|
@@ -717,9 +760,13 @@ export function createRouter<TNames extends string = string>(
|
|
|
717
760
|
}
|
|
718
761
|
}
|
|
719
762
|
|
|
720
|
-
async function runLoaders(
|
|
763
|
+
async function runLoaders(
|
|
764
|
+
to: ResolvedRoute,
|
|
765
|
+
gen: number,
|
|
766
|
+
ac: AbortController,
|
|
767
|
+
): Promise<GuardOutcome> {
|
|
721
768
|
const loadableRecords = to.matched.filter((r) => r.loader)
|
|
722
|
-
if (loadableRecords.length === 0) return
|
|
769
|
+
if (loadableRecords.length === 0) return { action: 'continue' }
|
|
723
770
|
|
|
724
771
|
const blocking: RouteRecord[] = []
|
|
725
772
|
const swr: RouteRecord[] = []
|
|
@@ -732,11 +779,11 @@ export function createRouter<TNames extends string = string>(
|
|
|
732
779
|
}
|
|
733
780
|
|
|
734
781
|
if (blocking.length > 0) {
|
|
735
|
-
const
|
|
736
|
-
if (
|
|
782
|
+
const outcome = await runBlockingLoaders(blocking, to, gen, ac)
|
|
783
|
+
if (outcome.action !== 'continue') return outcome
|
|
737
784
|
}
|
|
738
785
|
if (swr.length > 0) revalidateSwrLoaders(swr, to, ac)
|
|
739
|
-
return
|
|
786
|
+
return { action: 'continue' }
|
|
740
787
|
}
|
|
741
788
|
|
|
742
789
|
async function commitNavigation(
|
|
@@ -932,9 +979,12 @@ export function createRouter<TNames extends string = string>(
|
|
|
932
979
|
const ac = new AbortController()
|
|
933
980
|
router._abortController = ac
|
|
934
981
|
|
|
935
|
-
const
|
|
936
|
-
if (
|
|
982
|
+
const loaderOutcome = await runLoaders(to, gen, ac)
|
|
983
|
+
if (loaderOutcome.action !== 'continue') {
|
|
937
984
|
loadingSignal.update((n) => n - 1)
|
|
985
|
+
if (loaderOutcome.action === 'redirect') {
|
|
986
|
+
return navigate(sanitizePath(loaderOutcome.target), replace, redirectDepth + 1)
|
|
987
|
+
}
|
|
938
988
|
return
|
|
939
989
|
}
|
|
940
990
|
|
|
@@ -1046,7 +1096,11 @@ export function createRouter<TNames extends string = string>(
|
|
|
1046
1096
|
return router._readyPromise
|
|
1047
1097
|
},
|
|
1048
1098
|
|
|
1049
|
-
async preload(
|
|
1099
|
+
async preload(
|
|
1100
|
+
path: string,
|
|
1101
|
+
request?: Request,
|
|
1102
|
+
options?: { skipLoaders?: boolean },
|
|
1103
|
+
) {
|
|
1050
1104
|
const resolved = resolveRoute(path, routes)
|
|
1051
1105
|
// Load lazy components in parallel and populate the component cache so
|
|
1052
1106
|
// the synchronous render pass finds ready components instead of kicking
|
|
@@ -1064,6 +1118,13 @@ export function createRouter<TNames extends string = string>(
|
|
|
1064
1118
|
componentCache.set(record, comp)
|
|
1065
1119
|
}),
|
|
1066
1120
|
)
|
|
1121
|
+
// Skip the loader-running step when the caller explicitly opts out
|
|
1122
|
+
// (used by the SSG plugin's 404 build path — parent-layout loaders
|
|
1123
|
+
// that hit auth resources or external APIs shouldn't fire when
|
|
1124
|
+
// generating a static 404 page). Lazy components above DO still
|
|
1125
|
+
// resolve so the synthetic chain renders cleanly; only the
|
|
1126
|
+
// `r.loader()` invocations are skipped.
|
|
1127
|
+
if (options?.skipLoaders) return
|
|
1067
1128
|
// Run loaders for the matched path — uses the same code path SSR
|
|
1068
1129
|
// already relied on, so loader data ends up in `_loaderData` under the
|
|
1069
1130
|
// matched route records. Uses a LOCAL AbortController: `preload` is
|
|
@@ -1076,11 +1137,20 @@ export function createRouter<TNames extends string = string>(
|
|
|
1076
1137
|
resolved.matched
|
|
1077
1138
|
.filter((r) => r.loader)
|
|
1078
1139
|
.map(async (r) => {
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1140
|
+
// Wrap with `Promise.resolve().then(...)` so a SYNCHRONOUS
|
|
1141
|
+
// throw — `redirect('/login')` from a sync loader, `notFound()`,
|
|
1142
|
+
// a plain `throw new Error(...)` — becomes a rejected promise
|
|
1143
|
+
// the surrounding Promise.all surfaces. Bare `await r.loader(...)`
|
|
1144
|
+
// would let synchronous throws escape past the `await` and
|
|
1145
|
+
// surface as an uncaught exception in the Vite dev SSR pipeline.
|
|
1146
|
+
const data = await Promise.resolve().then(() =>
|
|
1147
|
+
r.loader!({
|
|
1148
|
+
params: resolved.params,
|
|
1149
|
+
query: resolved.query,
|
|
1150
|
+
signal: ac.signal,
|
|
1151
|
+
...(request ? { request } : {}),
|
|
1152
|
+
}),
|
|
1153
|
+
)
|
|
1084
1154
|
router._loaderData.set(r, data)
|
|
1085
1155
|
}),
|
|
1086
1156
|
)
|