@pyreon/router 0.14.0 → 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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +174 -51
- package/lib/types/index.d.ts +83 -8
- package/package.json +5 -4
- package/src/components.tsx +162 -27
- package/src/env.d.ts +6 -0
- package/src/index.ts +2 -0
- package/src/loader.ts +14 -4
- package/src/manifest.ts +63 -0
- package/src/match.ts +12 -1
- package/src/redirect.ts +63 -0
- package/src/router.ts +94 -34
- package/src/tests/loader.test.ts +149 -0
- package/src/tests/manifest-snapshot.test.ts +5 -1
- 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 +25 -3
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/components.tsx
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import type { ComponentFn, Props, VNodeChild } from '@pyreon/core'
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import type { ClassValue, ComponentFn, Props, VNodeChild } from '@pyreon/core'
|
|
2
|
+
import {
|
|
3
|
+
createRef,
|
|
4
|
+
cx,
|
|
5
|
+
ErrorBoundary,
|
|
6
|
+
h,
|
|
7
|
+
nativeCompat,
|
|
8
|
+
onUnmount,
|
|
9
|
+
provide,
|
|
10
|
+
useContext,
|
|
11
|
+
} from '@pyreon/core'
|
|
12
|
+
import { computed, signal } from '@pyreon/reactivity'
|
|
4
13
|
import { LoaderDataContext, prefetchLoaderData } from './loader'
|
|
5
14
|
import { isLazy, RouterContext, setActiveRouter } from './router'
|
|
6
15
|
import type { LazyComponent, ResolvedRoute, RouteRecord, Router, RouterInstance } from './types'
|
|
@@ -75,30 +84,108 @@ export const RouterView: ComponentFn<RouterViewProps> = (props) => {
|
|
|
75
84
|
router._viewDepth--
|
|
76
85
|
})
|
|
77
86
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
// ── Structure / data decoupling ───────────────────────────────────────────
|
|
88
|
+
//
|
|
89
|
+
// Pre-fix the reactive child accessor read `_loadingSignal` and the full
|
|
90
|
+
// `currentRoute` snapshot. The framework's `mountReactive` tears down and
|
|
91
|
+
// rebuilds the entire subtree on every accessor re-emission, so any
|
|
92
|
+
// unrelated route signal (loader writes, lazy resolution, navigation
|
|
93
|
+
// start/end counters, param changes that don't change the matched record)
|
|
94
|
+
// would tear down the layout, then the page, then everything below it.
|
|
95
|
+
// For a single page load with one cold-start `router.replace()`, that
|
|
96
|
+
// produced ~9 cascading remounts of the layout — confirmed empirically
|
|
97
|
+
// by instance counters.
|
|
98
|
+
//
|
|
99
|
+
// The fix decouples STRUCTURE (which RouteRecord is mounted at this depth
|
|
100
|
+
// + which component to render for it) from DATA (params / query / loader
|
|
101
|
+
// data flowing into the rendered component). One computed returns BOTH
|
|
102
|
+
// the record and its resolved component as an atomic pair — re-emits ONLY
|
|
103
|
+
// when either side changes (reference equality on both fields). Loader
|
|
104
|
+
// writes / param changes / navigation counters don't re-emit; the rendered
|
|
105
|
+
// component receives route data through reactive props + the
|
|
106
|
+
// `LoaderDataProvider` context, which subscribe per-component to the
|
|
107
|
+
// signals they actually care about, so a param change re-renders just the
|
|
108
|
+
// page leaf — not the layout chain above it.
|
|
109
|
+
//
|
|
110
|
+
// The structure is intentionally a SINGLE computed (not two layered ones):
|
|
111
|
+
// when `currentRoute` changes, the reactive child accessor must see a
|
|
112
|
+
// CONSISTENT (rec, comp) pair on its next re-run. With two layered
|
|
113
|
+
// computeds the child accessor subscribes to both, and the order in which
|
|
114
|
+
// those two notify the child is unspecified — if the child runs after rec
|
|
115
|
+
// is notified but before comp re-evaluates, it reads the new rec paired
|
|
116
|
+
// with the OLD comp. Empirically that produced rec=/button paired with
|
|
117
|
+
// comp=HomePage, leaving the previous page rendered after navigation.
|
|
118
|
+
// Combining them into one computed forces atomic emission.
|
|
119
|
+
interface DepthEntry {
|
|
120
|
+
rec: RouteRecord | null
|
|
121
|
+
comp: ComponentFn | null
|
|
122
|
+
/**
|
|
123
|
+
* True when lazy resolution exhausted retries and the chunk is in
|
|
124
|
+
* `_erroredChunks`. Tracked structurally so the entry re-emits when
|
|
125
|
+
* the error state flips on — otherwise `equals` would block the
|
|
126
|
+
* { rec, comp: null } → { rec, comp: null, errored: true } transition
|
|
127
|
+
* (`comp` and `rec` are unchanged) and the error component would
|
|
128
|
+
* never render.
|
|
129
|
+
*/
|
|
130
|
+
errored: boolean
|
|
131
|
+
/**
|
|
132
|
+
* The full ResolvedRoute reference at the time this entry was emitted.
|
|
133
|
+
* `currentRoute` is a `computed` keyed on `currentPath` — same path
|
|
134
|
+
* returns the same memoized reference, different path returns a new
|
|
135
|
+
* one. Tracking the reference in `equals` makes the depth re-emit on
|
|
136
|
+
* any real navigation (params change, query change, hash change) even
|
|
137
|
+
* when the matched record at this depth stays the same — required so
|
|
138
|
+
* `/user/42 → /user/99` re-renders the User component with new params
|
|
139
|
+
* — while NOT re-emitting on navigate-flow noise (`_loadingSignal`
|
|
140
|
+
* start/end ticks, lazy resolution writes that complete without
|
|
141
|
+
* changing currentPath). One emit per real navigation, not per
|
|
142
|
+
* within-navigation signal tick.
|
|
143
|
+
*/
|
|
144
|
+
route: ResolvedRoute
|
|
145
|
+
}
|
|
146
|
+
const depthEntry = computed<DepthEntry>(
|
|
147
|
+
() => {
|
|
148
|
+
const route = router.currentRoute()
|
|
149
|
+
const rec = route.matched[depth] ?? null
|
|
150
|
+
if (!rec) return { rec: null, comp: null, errored: false, route }
|
|
151
|
+
// Subscribe to `_loadingSignal` so lazy resolution wakes this
|
|
152
|
+
// computed up — when the cache fills, we re-emit with comp set.
|
|
153
|
+
router._loadingSignal()
|
|
154
|
+
const errored = router._erroredChunks.has(rec)
|
|
155
|
+
if (errored) return { rec, comp: null, errored: true, route }
|
|
156
|
+
const cached = router._componentCache.get(rec)
|
|
157
|
+
if (cached) return { rec, comp: cached, errored: false, route }
|
|
158
|
+
const raw = rec.component
|
|
159
|
+
if (!isLazy(raw)) {
|
|
160
|
+
cacheSet(router, rec, raw)
|
|
161
|
+
return { rec, comp: raw, errored: false, route }
|
|
162
|
+
}
|
|
163
|
+
// Lazy and not yet cached — `child()` below renders the lazy
|
|
164
|
+
// fallback and triggers the load; once the load completes,
|
|
165
|
+
// `_loadingSignal` ticks and this computed re-emits with `comp` set.
|
|
166
|
+
return { rec, comp: null, errored: false, route }
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
equals: (a, b) =>
|
|
170
|
+
a.rec === b.rec &&
|
|
171
|
+
a.comp === b.comp &&
|
|
172
|
+
a.errored === b.errored &&
|
|
173
|
+
a.route === b.route,
|
|
174
|
+
},
|
|
175
|
+
)
|
|
93
176
|
|
|
94
|
-
|
|
177
|
+
const child = (): VNodeChild => {
|
|
178
|
+
const { rec, comp, route } = depthEntry()
|
|
179
|
+
if (!rec) return null
|
|
95
180
|
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
return renderWithLoader(router, record, raw, route)
|
|
181
|
+
if (comp) {
|
|
182
|
+
return renderWithLoader(router, rec, comp, route)
|
|
99
183
|
}
|
|
100
184
|
|
|
101
|
-
|
|
185
|
+
// Component not yet cached — kick off the lazy load. `renderLazyRoute`
|
|
186
|
+
// mutates `_loadingSignal` and `_componentCache` on completion, which
|
|
187
|
+
// re-emits `depthEntry` and re-runs this accessor with `comp` set.
|
|
188
|
+
return renderLazyRoute(router, rec, rec.component as LazyComponent)
|
|
102
189
|
}
|
|
103
190
|
|
|
104
191
|
return h('div', { 'data-pyreon-router-view': true }, child as unknown as VNodeChild)
|
|
@@ -155,7 +242,14 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
|
|
|
155
242
|
}
|
|
156
243
|
|
|
157
244
|
const inst = router as RouterInstance | null
|
|
158
|
-
|
|
245
|
+
// `href` MUST be an accessor, not a string captured at setup. `props.to`
|
|
246
|
+
// is a getter when the parent passes a reactive expression (the JSX
|
|
247
|
+
// compiler wraps `<RouterLink to={someExpr}>` as `_rp(() => someExpr)`).
|
|
248
|
+
// Capturing into a string at setup time freezes the URL — passing the
|
|
249
|
+
// accessor lets `applyProp` wrap it in `renderEffect` so href tracks the
|
|
250
|
+
// underlying signal.
|
|
251
|
+
const href = (): string =>
|
|
252
|
+
inst?.mode === 'history' ? `${inst._base}${props.to}` : `#${props.to}`
|
|
159
253
|
|
|
160
254
|
const isExactMatch = (): boolean => {
|
|
161
255
|
if (!router) return false
|
|
@@ -199,12 +293,44 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
|
|
|
199
293
|
onUnmount(() => observer.disconnect())
|
|
200
294
|
}
|
|
201
295
|
|
|
202
|
-
// Forward all non-RouterLink props (style,
|
|
203
|
-
|
|
296
|
+
// Forward all non-RouterLink props (style, id, data-*, etc.) to the <a>.
|
|
297
|
+
// `class` is pulled out separately so it can be MERGED with the internal
|
|
298
|
+
// active-class accessor — overriding the user's class silently dropped any
|
|
299
|
+
// conditional class the consumer wanted (e.g. `class={() => cond ? 'on' : ''}`).
|
|
300
|
+
const {
|
|
301
|
+
to: _to,
|
|
302
|
+
replace: _replace,
|
|
303
|
+
activeClass: _ac,
|
|
304
|
+
exactActiveClass: _eac,
|
|
305
|
+
exact: _exact,
|
|
306
|
+
prefetch: _prefetch,
|
|
307
|
+
class: userClass,
|
|
308
|
+
children,
|
|
309
|
+
...rest
|
|
310
|
+
} = props as RouterLinkProps & { class?: ClassValue | (() => ClassValue) }
|
|
311
|
+
|
|
312
|
+
// Compose the user-provided `class` (string / array / object / function) with
|
|
313
|
+
// the internal `activeClass` accessor. Returning a function lets `applyProp`
|
|
314
|
+
// wrap it in `renderEffect` once — so navigation re-evaluates BOTH sides on
|
|
315
|
+
// every route change without rebuilding the link.
|
|
316
|
+
const mergedClass = (): string => {
|
|
317
|
+
const userResolved =
|
|
318
|
+
typeof userClass === 'function' ? (userClass as () => ClassValue)() : userClass
|
|
319
|
+
return cx([userResolved, activeClass()] as ClassValue)
|
|
320
|
+
}
|
|
204
321
|
|
|
205
322
|
return h(
|
|
206
323
|
'a',
|
|
207
|
-
{
|
|
324
|
+
{
|
|
325
|
+
...rest,
|
|
326
|
+
ref,
|
|
327
|
+
href,
|
|
328
|
+
class: mergedClass,
|
|
329
|
+
'aria-current': ariaCurrent,
|
|
330
|
+
onClick: handleClick,
|
|
331
|
+
onMouseEnter: handleMouseEnter,
|
|
332
|
+
onFocus: handleFocus,
|
|
333
|
+
},
|
|
208
334
|
children ?? props.to,
|
|
209
335
|
)
|
|
210
336
|
}
|
|
@@ -455,3 +581,12 @@ function isStaleChunk(err: unknown): boolean {
|
|
|
455
581
|
if (err instanceof SyntaxError) return true
|
|
456
582
|
return false
|
|
457
583
|
}
|
|
584
|
+
|
|
585
|
+
// Mark router framework components as native — compat-mode jsx() runtimes
|
|
586
|
+
// (react/preact/vue/solid-compat) skip wrapCompatComponent for these so their
|
|
587
|
+
// provide() / useContext() / onUnmount() / effect() / IntersectionObserver
|
|
588
|
+
// setup runs inside Pyreon's lifecycle frame instead of the compat wrapper's
|
|
589
|
+
// runUntracked accessor.
|
|
590
|
+
nativeCompat(RouterProvider)
|
|
591
|
+
nativeCompat(RouterView)
|
|
592
|
+
nativeCompat(RouterLink)
|
package/src/env.d.ts
ADDED
package/src/index.ts
CHANGED
|
@@ -46,6 +46,8 @@ export type { RouterLinkProps, RouterProviderProps, RouterViewProps } from './co
|
|
|
46
46
|
export { RouterLink, RouterProvider, RouterView } from './components'
|
|
47
47
|
export type { NotFoundBoundaryProps } from './not-found'
|
|
48
48
|
export { isNotFoundError, NotFoundBoundary, notFound } from './not-found'
|
|
49
|
+
export type { RedirectStatus } from './redirect'
|
|
50
|
+
export { getRedirectInfo, isRedirectError, redirect } from './redirect'
|
|
49
51
|
export { hydrateLoaderData, prefetchLoaderData, serializeLoaderData, useLoaderData } from './loader'
|
|
50
52
|
// Match utilities (useful for SSR route pre-fetching)
|
|
51
53
|
export {
|
package/src/loader.ts
CHANGED
|
@@ -3,8 +3,7 @@ import { createContext, useContext } from '@pyreon/core'
|
|
|
3
3
|
import type { RouterInstance } from './types'
|
|
4
4
|
|
|
5
5
|
// Dev-mode gate + counter sink. See packages/internals/perf-harness for contract.
|
|
6
|
-
|
|
7
|
-
const __DEV__ = import.meta.env?.DEV === true
|
|
6
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
8
7
|
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
9
8
|
|
|
10
9
|
/**
|
|
@@ -33,12 +32,22 @@ export function useLoaderData<T = unknown>(): T {
|
|
|
33
32
|
* SSR helper: pre-run all loaders for the given path before rendering.
|
|
34
33
|
* Call this before `renderToString` so route components can read data via `useLoaderData()`.
|
|
35
34
|
*
|
|
35
|
+
* The optional `request` is forwarded to each loader's `LoaderContext.request`,
|
|
36
|
+
* letting server-side loaders read cookies / auth headers and `throw redirect()`
|
|
37
|
+
* before the layout renders. A loader that throws `redirect()` propagates the
|
|
38
|
+
* thrown error here — the SSR handler's `catch` converts it into a 302/307
|
|
39
|
+
* `Location:` Response.
|
|
40
|
+
*
|
|
36
41
|
* @example
|
|
37
42
|
* const router = createRouter({ routes, url: req.url })
|
|
38
|
-
* await prefetchLoaderData(router, req.url)
|
|
43
|
+
* await prefetchLoaderData(router, req.url, request)
|
|
39
44
|
* const html = await renderToString(h(App, { router }))
|
|
40
45
|
*/
|
|
41
|
-
export async function prefetchLoaderData(
|
|
46
|
+
export async function prefetchLoaderData(
|
|
47
|
+
router: RouterInstance,
|
|
48
|
+
path: string,
|
|
49
|
+
request?: Request,
|
|
50
|
+
): Promise<void> {
|
|
42
51
|
if (__DEV__) _countSink.__pyreon_count__?.('router.prefetch')
|
|
43
52
|
const route = router._resolve(path)
|
|
44
53
|
// Use a local AbortController — prefetch is best-effort and must NOT
|
|
@@ -54,6 +63,7 @@ export async function prefetchLoaderData(router: RouterInstance, path: string):
|
|
|
54
63
|
params: route.params,
|
|
55
64
|
query: route.query,
|
|
56
65
|
signal: ac.signal,
|
|
66
|
+
...(request ? { request } : {}),
|
|
57
67
|
})
|
|
58
68
|
router._loaderData.set(r, data)
|
|
59
69
|
}),
|
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
|
@@ -293,7 +293,18 @@ function flattenOne(
|
|
|
293
293
|
return
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
|
|
296
|
+
// fs-router emits absolute paths for nested children (e.g. parent
|
|
297
|
+
// `/app` with child `/app/dashboard`, NOT child `dashboard`). Concating
|
|
298
|
+
// parent segments with the child's already-absolute segments would
|
|
299
|
+
// produce `/app/app/dashboard` — the staticMap then lookups the wrong
|
|
300
|
+
// key and resolveRoute returns `matched: []` for any such request.
|
|
301
|
+
// Detect "child path is absolute" (`path` starts with `/`) and skip the
|
|
302
|
+
// parent-segment prefix in that case — the child's own segments ARE
|
|
303
|
+
// the full intended path. Relative children (`dashboard`, `:id`)
|
|
304
|
+
// continue to inherit the parent's segments via concatenation.
|
|
305
|
+
const childPath = c.route.path
|
|
306
|
+
const isAbsoluteChild = typeof childPath === 'string' && childPath.startsWith('/')
|
|
307
|
+
const joined = isAbsoluteChild ? c.segments : [...parentSegments, ...c.segments]
|
|
297
308
|
if (c.children && c.children.length > 0) {
|
|
298
309
|
flattenWalk(result, c.children, joined, chain, meta)
|
|
299
310
|
}
|
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
|
+
}
|