@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.
@@ -1,6 +1,15 @@
1
- import type { ComponentFn, Props, VNodeChild } from '@pyreon/core'
2
- import { createRef, ErrorBoundary, h, onUnmount, provide, useContext } from '@pyreon/core'
3
- import { signal } from '@pyreon/reactivity'
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
- const child = (): VNodeChild => {
79
- router._loadingSignal() // reactive — re-renders after lazy load completes
80
-
81
- const route = router.currentRoute()
82
-
83
- if (route.matched.length === 0) return null
84
-
85
- // Render the matched record at this view's depth level
86
- const record = route.matched[depth]
87
- if (!record) return null // no component at this nesting level
88
-
89
- const cached = router._componentCache.get(record)
90
- if (cached) {
91
- return renderWithLoader(router, record, cached, route)
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
- const raw = record.component
177
+ const child = (): VNodeChild => {
178
+ const { rec, comp, route } = depthEntry()
179
+ if (!rec) return null
95
180
 
96
- if (!isLazy(raw)) {
97
- cacheSet(router, record, raw)
98
- return renderWithLoader(router, record, raw, route)
181
+ if (comp) {
182
+ return renderWithLoader(router, rec, comp, route)
99
183
  }
100
184
 
101
- return renderLazyRoute(router, record, raw)
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
- const href = inst?.mode === 'history' ? `${inst._base}${props.to}` : `#${props.to}`
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, class, id, data-*, etc.) to the <a>.
203
- const { to: _to, replace: _replace, activeClass: _ac, exactActiveClass: _eac, exact: _exact, prefetch: _prefetch, children, ...rest } = props
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
- { ...rest, ref, href, class: activeClass, 'aria-current': ariaCurrent, onClick: handleClick, onMouseEnter: handleMouseEnter, onFocus: handleFocus },
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
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Minimal process type — just enough for `process.env.NODE_ENV` checks.
3
+ * Avoids requiring @types/node in consumers that import pyreon source
4
+ * via the `"bun"` export condition.
5
+ */
6
+ declare var process: { env: { NODE_ENV?: string } }
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
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
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(router: RouterInstance, path: string): Promise<void> {
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
- const joined = [...parentSegments, ...c.segments]
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
  }
@@ -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
+ }