@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.
@@ -1,4 +1,4 @@
1
- import * as _pyreon_core0 from "@pyreon/core";
1
+ import * as _$_pyreon_core0 from "@pyreon/core";
2
2
  import { ComponentFn, ComponentFn as ComponentFn$1, Props, VNodeChild } from "@pyreon/core";
3
3
  import { Computed, Signal } from "@pyreon/reactivity";
4
4
 
@@ -56,6 +56,15 @@ interface ResolvedRoute<P extends Record<string, string | undefined> = Record<st
56
56
  search?: Record<string, unknown> | undefined;
57
57
  /** Middleware data attached during navigation (populated by middleware chain) */
58
58
  _middlewareData?: Record<string, unknown> | undefined;
59
+ /**
60
+ * `true` when the URL didn't match any route AND a parent record's
61
+ * `notFoundComponent` was used as a synthetic fallback leaf. The
62
+ * `matched` chain ends with a synthetic `RouteRecord` rendering the
63
+ * not-found component INSIDE all its ancestor layouts — so 404 pages
64
+ * carry the same chrome (headers, footers, navigation) as regular
65
+ * pages. SSR handlers read this to set HTTP status 404.
66
+ */
67
+ isNotFound?: boolean;
59
68
  }
60
69
  declare const LAZY_SYMBOL: unique symbol;
61
70
  interface LazyComponent {
@@ -111,6 +120,21 @@ interface LoaderContext {
111
120
  query: Record<string, string>;
112
121
  /** Aborted when a newer navigation supersedes this one */
113
122
  signal: AbortSignal;
123
+ /**
124
+ * The incoming HTTP `Request` — populated only when the loader runs during
125
+ * SSR (via `prefetchLoaderData`); `undefined` on every CSR navigation.
126
+ * Lets server-side loaders read cookies / auth headers and decide whether
127
+ * to `throw redirect('/login')` BEFORE the layout renders.
128
+ *
129
+ * @example
130
+ * loader: ({ request }) => {
131
+ * const cookie = request?.headers.get('cookie') ?? ''
132
+ * const sid = cookie.match(/sid=([^;]+)/)?.[1]
133
+ * if (!sid) redirect('/login')
134
+ * return { sid }
135
+ * }
136
+ */
137
+ request?: Request;
114
138
  }
115
139
  type RouteLoaderFn = (ctx: LoaderContext) => Promise<unknown>;
116
140
  interface RouteRecord<TPath extends string = string> {
@@ -172,6 +196,14 @@ interface RouteRecord<TPath extends string = string> {
172
196
  gcTime?: number;
173
197
  /** Component rendered when this route's loader throws an error */
174
198
  errorComponent?: ComponentFn$1;
199
+ /**
200
+ * Component rendered when a URL doesn't match any descendant route under
201
+ * this record's path. Acts as a "404 within layout" — the matched chain
202
+ * is `[...ancestors, this, syntheticLeaf]` so the not-found component
203
+ * renders INSIDE this layout's chrome. fs-router attaches this when it
204
+ * detects a `_404.tsx` / `_not-found.tsx` file under this layout.
205
+ */
206
+ notFoundComponent?: ComponentFn$1;
175
207
  /**
176
208
  * Component rendered while this route's loader is running.
177
209
  * Only shown after `pendingMs` (default: 0) to avoid flash on fast loads.
@@ -313,7 +345,9 @@ interface Router<TNames extends string = string> {
313
345
  * separately when creating the router (`createRouter({ url, ... })`) or
314
346
  * call this for the same `url` you initialised the router with.
315
347
  */
316
- preload(path: string): Promise<void>;
348
+ preload(path: string, request?: Request, options?: {
349
+ skipLoaders?: boolean;
350
+ }): Promise<void>;
317
351
  /**
318
352
  * Invalidate cached loader data. Forces loaders to re-run on next navigation.
319
353
  * - No args: invalidate ALL cached loader data
@@ -363,8 +397,18 @@ interface RouterInstance extends Router {
363
397
  data: unknown;
364
398
  timestamp: number;
365
399
  }>;
366
- /** In-flight loader dedup: cacheKey → Promise */
367
- _loaderInflight: Map<string, Promise<unknown>>;
400
+ /**
401
+ * In-flight loader dedup: cacheKey → { promise, signal }.
402
+ * Tracking the signal lets dedup skip an in-flight entry whose signal is
403
+ * already aborted — otherwise nav-2 would inherit nav-1's aborted promise
404
+ * (`router.push` aborts the previous nav's controller before starting the
405
+ * next, so back-to-back nav to the same path could resolve nav-2 against
406
+ * nav-1's aborted fetch).
407
+ */
408
+ _loaderInflight: Map<string, {
409
+ promise: Promise<unknown>;
410
+ signal: AbortSignal;
411
+ }>;
368
412
  }
369
413
  //#endregion
370
414
  //#region src/components.d.ts
@@ -460,6 +504,50 @@ interface NotFoundBoundaryProps extends Props {
460
504
  */
461
505
  declare const NotFoundBoundary: ComponentFn<NotFoundBoundaryProps>;
462
506
  //#endregion
507
+ //#region src/redirect.d.ts
508
+ /** Standard redirect status codes. 307/308 preserve the request method, 302/303 don't. */
509
+ type RedirectStatus = 301 | 302 | 303 | 307 | 308;
510
+ interface RedirectInfo {
511
+ url: string;
512
+ status: RedirectStatus;
513
+ }
514
+ /**
515
+ * Throw inside a route loader to redirect the navigation server-side
516
+ * (during SSR returns a 302/307 `Location:` response) and client-side
517
+ * (during CSR triggers `router.replace()` before the layout renders).
518
+ *
519
+ * The auth-gate use case: replaces the fragile `onMount + router.push()`
520
+ * workaround. `onMount` doesn't fire reliably under nested-layout dev SSR +
521
+ * hydration — so the layout renders briefly before the push happens, leaking
522
+ * authenticated UI to unauthenticated users. `redirect()` runs in the loader
523
+ * BEFORE the layout's component is invoked, so the unauthenticated UI never
524
+ * mounts in the first place.
525
+ *
526
+ * @example
527
+ * ```ts
528
+ * // src/routes/app/_layout.tsx
529
+ * export const loader = async ({ request }) => {
530
+ * const session = await getSession(request)
531
+ * if (!session) redirect('/login')
532
+ * return { user: session.user }
533
+ * }
534
+ * ```
535
+ *
536
+ * @param url - Target URL (typically a path like `/login` or absolute URL for cross-origin).
537
+ * @param status - HTTP redirect status. Default `307` (Temporary Redirect, method-preserving).
538
+ * Use `301`/`308` for permanent moves, `302`/`303` to force GET on the target.
539
+ */
540
+ declare function redirect(url: string, status?: RedirectStatus): never;
541
+ /** Check if an error is a RedirectError thrown by `redirect()`. */
542
+ declare function isRedirectError(err: unknown): boolean;
543
+ /**
544
+ * Extract the redirect URL and status from a thrown RedirectError. Returns
545
+ * `null` if `err` isn't a RedirectError. Used by the router's loader-runner
546
+ * (CSR) and the SSR handler to convert the thrown error into the right kind
547
+ * of response (a `router.replace()` call or a `302`/`307` Response).
548
+ */
549
+ declare function getRedirectInfo(err: unknown): RedirectInfo | null;
550
+ //#endregion
463
551
  //#region src/loader.d.ts
464
552
  /**
465
553
  * Returns the data resolved by the current route's `loader` function.
@@ -478,12 +566,18 @@ declare function useLoaderData<T = unknown>(): T;
478
566
  * SSR helper: pre-run all loaders for the given path before rendering.
479
567
  * Call this before `renderToString` so route components can read data via `useLoaderData()`.
480
568
  *
569
+ * The optional `request` is forwarded to each loader's `LoaderContext.request`,
570
+ * letting server-side loaders read cookies / auth headers and `throw redirect()`
571
+ * before the layout renders. A loader that throws `redirect()` propagates the
572
+ * thrown error here — the SSR handler's `catch` converts it into a 302/307
573
+ * `Location:` Response.
574
+ *
481
575
  * @example
482
576
  * const router = createRouter({ routes, url: req.url })
483
- * await prefetchLoaderData(router, req.url)
577
+ * await prefetchLoaderData(router, req.url, request)
484
578
  * const html = await renderToString(h(App, { router }))
485
579
  */
486
- declare function prefetchLoaderData(router: RouterInstance, path: string): Promise<void>;
580
+ declare function prefetchLoaderData(router: RouterInstance, path: string, request?: Request): Promise<void>;
487
581
  /**
488
582
  * Serialize loader data to a JSON-safe plain object for embedding in SSR HTML.
489
583
  * Keys are route path patterns (stable across server and client).
@@ -496,6 +590,35 @@ declare function prefetchLoaderData(router: RouterInstance, path: string): Promi
496
590
  * ...${html}...`
497
591
  */
498
592
  declare function serializeLoaderData(router: RouterInstance): Record<string, unknown>;
593
+ /**
594
+ * Serialize loader data to JSON for embedding in an SSR `<script>` tag.
595
+ *
596
+ * M2.2 — Drop-in replacement for `JSON.stringify(serializeLoaderData(router))`
597
+ * with three correctness wins:
598
+ * 1. **Strips functions / symbols / undefined values silently** so a loader
599
+ * that accidentally returns `{ data, fn: () => {} }` doesn't crash
600
+ * hydration — `JSON.stringify` drops these by default for the value
601
+ * itself but THROWS on circular references containing them. The custom
602
+ * replacer drops them inline so the surrounding object survives.
603
+ * 2. **Detects circular references** with a WeakSet and emits a clear
604
+ * `[Pyreon] Loader returned circular reference at key "<path>"` error
605
+ * naming the offending key instead of `Converting circular structure
606
+ * to JSON` (which doesn't tell the user which loader is broken).
607
+ * 3. **Escapes `</`** so embedding the JSON inside `<script>` can't break
608
+ * out of the script tag — already done at every call site but now
609
+ * centralised so all four callers (handler string-mode, handler stream-
610
+ * mode, SSG entry, dev SSR) get the escape uniformly.
611
+ *
612
+ * Returns the safely-escaped JSON string ready to drop into a `<script>`
613
+ * tag's body. Throws (with the Pyreon-prefixed error) on circular refs so
614
+ * the caller's existing try/catch wraps it correctly — silent serialization
615
+ * failures were the pre-fix shape.
616
+ *
617
+ * @example
618
+ * const json = stringifyLoaderData(serializeLoaderData(router))
619
+ * const tag = `<script>window.__PYREON_LOADER_DATA__=${json}</script>`
620
+ */
621
+ declare function stringifyLoaderData(loaderData: Record<string, unknown>): string;
499
622
  /**
500
623
  * Hydrate loader data from a serialized object (e.g. `window.__PYREON_LOADER_DATA__`).
501
624
  * Populates the router's internal `_loaderData` map so the initial render uses
@@ -533,7 +656,7 @@ declare function buildPath(pattern: string, params: Record<string, string>): str
533
656
  declare function findRouteByName(name: string, routes: RouteRecord[]): RouteRecord | null;
534
657
  //#endregion
535
658
  //#region src/router.d.ts
536
- declare const RouterContext: _pyreon_core0.Context<RouterInstance | null>;
659
+ declare const RouterContext: _$_pyreon_core0.Context<RouterInstance | null>;
537
660
  declare function useRouter(): Router;
538
661
  declare function useRoute<TPath extends string = string>(): () => ResolvedRoute<ExtractParams<TPath> & Record<string, string>, Record<string, string>>;
539
662
  /**
@@ -688,5 +811,5 @@ declare function useTransition(): () => boolean;
688
811
  declare function useMiddlewareData(): () => Record<string, unknown>;
689
812
  declare function createRouter<TNames extends string = string>(options: RouterOptions | RouteRecord[]): Router<TNames>;
690
813
  //#endregion
691
- export { type AfterEachHook, type Blocker, type BlockerFn, type ExtractParams, type LazyComponent, type LoaderContext, type NavigationGuard, type NavigationGuardResult, NotFoundBoundary, type NotFoundBoundaryProps, type ResolvedRoute, type RouteComponent, type RouteLoaderFn, type RouteMeta, type RouteMiddleware, type RouteMiddlewareContext, type RouteRecord, type Router, RouterContext, RouterLink, type RouterLinkProps, type RouterOptions, RouterProvider, type RouterProviderProps, RouterView, type RouterViewProps, type ScrollBehaviorFn, buildPath, createRouter, findRouteByName, hydrateLoaderData, isNotFoundError, lazy, notFound, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useIsActive, useLoaderData, useMiddlewareData, useRoute, useRouter, useSearchParams, useTransition, useTypedSearchParams, useValidatedSearch };
814
+ export { type AfterEachHook, type Blocker, type BlockerFn, type ExtractParams, type LazyComponent, type LoaderContext, type NavigationGuard, type NavigationGuardResult, NotFoundBoundary, type NotFoundBoundaryProps, type RedirectStatus, type ResolvedRoute, type RouteComponent, type RouteLoaderFn, type RouteMeta, type RouteMiddleware, type RouteMiddlewareContext, type RouteRecord, type Router, RouterContext, RouterLink, type RouterLinkProps, type RouterOptions, RouterProvider, type RouterProviderProps, RouterView, type RouterViewProps, type ScrollBehaviorFn, buildPath, createRouter, findRouteByName, getRedirectInfo, hydrateLoaderData, isNotFoundError, isRedirectError, lazy, notFound, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, redirect, resolveRoute, serializeLoaderData, stringifyLoaderData, stringifyQuery, useBlocker, useIsActive, useLoaderData, useMiddlewareData, useRoute, useRouter, useSearchParams, useTransition, useTypedSearchParams, useValidatedSearch };
692
815
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/router",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Official router for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/router#readme",
6
6
  "bugs": {
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "files": [
16
16
  "lib",
17
+ "!lib/**/*.map",
17
18
  "src",
18
19
  "README.md",
19
20
  "LICENSE"
@@ -43,14 +44,14 @@
43
44
  "prepublishOnly": "bun run build"
44
45
  },
45
46
  "dependencies": {
46
- "@pyreon/core": "^0.14.0",
47
- "@pyreon/reactivity": "^0.14.0",
48
- "@pyreon/runtime-dom": "^0.14.0"
47
+ "@pyreon/core": "^0.16.0",
48
+ "@pyreon/reactivity": "^0.16.0",
49
+ "@pyreon/runtime-dom": "^0.16.0"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@happy-dom/global-registrator": "^20.8.9",
52
53
  "@pyreon/manifest": "0.13.1",
53
- "@pyreon/test-utils": "^0.13.2",
54
+ "@pyreon/test-utils": "^0.13.3",
54
55
  "@vitest/browser-playwright": "^4.1.4",
55
56
  "happy-dom": "^20.8.3"
56
57
  }
@@ -1,7 +1,17 @@
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'
14
+ import { _setDefaultChromeLayout } from './match'
5
15
  import { isLazy, RouterContext, setActiveRouter } from './router'
6
16
  import type { LazyComponent, ResolvedRoute, RouteRecord, Router, RouterInstance } from './types'
7
17
 
@@ -75,30 +85,108 @@ export const RouterView: ComponentFn<RouterViewProps> = (props) => {
75
85
  router._viewDepth--
76
86
  })
77
87
 
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
- }
88
+ // ── Structure / data decoupling ───────────────────────────────────────────
89
+ //
90
+ // Pre-fix the reactive child accessor read `_loadingSignal` and the full
91
+ // `currentRoute` snapshot. The framework's `mountReactive` tears down and
92
+ // rebuilds the entire subtree on every accessor re-emission, so any
93
+ // unrelated route signal (loader writes, lazy resolution, navigation
94
+ // start/end counters, param changes that don't change the matched record)
95
+ // would tear down the layout, then the page, then everything below it.
96
+ // For a single page load with one cold-start `router.replace()`, that
97
+ // produced ~9 cascading remounts of the layout confirmed empirically
98
+ // by instance counters.
99
+ //
100
+ // The fix decouples STRUCTURE (which RouteRecord is mounted at this depth
101
+ // + which component to render for it) from DATA (params / query / loader
102
+ // data flowing into the rendered component). One computed returns BOTH
103
+ // the record and its resolved component as an atomic pair — re-emits ONLY
104
+ // when either side changes (reference equality on both fields). Loader
105
+ // writes / param changes / navigation counters don't re-emit; the rendered
106
+ // component receives route data through reactive props + the
107
+ // `LoaderDataProvider` context, which subscribe per-component to the
108
+ // signals they actually care about, so a param change re-renders just the
109
+ // page leaf — not the layout chain above it.
110
+ //
111
+ // The structure is intentionally a SINGLE computed (not two layered ones):
112
+ // when `currentRoute` changes, the reactive child accessor must see a
113
+ // CONSISTENT (rec, comp) pair on its next re-run. With two layered
114
+ // computeds the child accessor subscribes to both, and the order in which
115
+ // those two notify the child is unspecified — if the child runs after rec
116
+ // is notified but before comp re-evaluates, it reads the new rec paired
117
+ // with the OLD comp. Empirically that produced rec=/button paired with
118
+ // comp=HomePage, leaving the previous page rendered after navigation.
119
+ // Combining them into one computed forces atomic emission.
120
+ interface DepthEntry {
121
+ rec: RouteRecord | null
122
+ comp: ComponentFn | null
123
+ /**
124
+ * True when lazy resolution exhausted retries and the chunk is in
125
+ * `_erroredChunks`. Tracked structurally so the entry re-emits when
126
+ * the error state flips on — otherwise `equals` would block the
127
+ * { rec, comp: null } → { rec, comp: null, errored: true } transition
128
+ * (`comp` and `rec` are unchanged) and the error component would
129
+ * never render.
130
+ */
131
+ errored: boolean
132
+ /**
133
+ * The full ResolvedRoute reference at the time this entry was emitted.
134
+ * `currentRoute` is a `computed` keyed on `currentPath` — same path
135
+ * returns the same memoized reference, different path returns a new
136
+ * one. Tracking the reference in `equals` makes the depth re-emit on
137
+ * any real navigation (params change, query change, hash change) even
138
+ * when the matched record at this depth stays the same — required so
139
+ * `/user/42 → /user/99` re-renders the User component with new params
140
+ * — while NOT re-emitting on navigate-flow noise (`_loadingSignal`
141
+ * start/end ticks, lazy resolution writes that complete without
142
+ * changing currentPath). One emit per real navigation, not per
143
+ * within-navigation signal tick.
144
+ */
145
+ route: ResolvedRoute
146
+ }
147
+ const depthEntry = computed<DepthEntry>(
148
+ () => {
149
+ const route = router.currentRoute()
150
+ const rec = route.matched[depth] ?? null
151
+ if (!rec) return { rec: null, comp: null, errored: false, route }
152
+ // Subscribe to `_loadingSignal` so lazy resolution wakes this
153
+ // computed up — when the cache fills, we re-emit with comp set.
154
+ router._loadingSignal()
155
+ const errored = router._erroredChunks.has(rec)
156
+ if (errored) return { rec, comp: null, errored: true, route }
157
+ const cached = router._componentCache.get(rec)
158
+ if (cached) return { rec, comp: cached, errored: false, route }
159
+ const raw = rec.component
160
+ if (!isLazy(raw)) {
161
+ cacheSet(router, rec, raw)
162
+ return { rec, comp: raw, errored: false, route }
163
+ }
164
+ // Lazy and not yet cached — `child()` below renders the lazy
165
+ // fallback and triggers the load; once the load completes,
166
+ // `_loadingSignal` ticks and this computed re-emits with `comp` set.
167
+ return { rec, comp: null, errored: false, route }
168
+ },
169
+ {
170
+ equals: (a, b) =>
171
+ a.rec === b.rec &&
172
+ a.comp === b.comp &&
173
+ a.errored === b.errored &&
174
+ a.route === b.route,
175
+ },
176
+ )
93
177
 
94
- const raw = record.component
178
+ const child = (): VNodeChild => {
179
+ const { rec, comp, route } = depthEntry()
180
+ if (!rec) return null
95
181
 
96
- if (!isLazy(raw)) {
97
- cacheSet(router, record, raw)
98
- return renderWithLoader(router, record, raw, route)
182
+ if (comp) {
183
+ return renderWithLoader(router, rec, comp, route)
99
184
  }
100
185
 
101
- return renderLazyRoute(router, record, raw)
186
+ // Component not yet cached — kick off the lazy load. `renderLazyRoute`
187
+ // mutates `_loadingSignal` and `_componentCache` on completion, which
188
+ // re-emits `depthEntry` and re-runs this accessor with `comp` set.
189
+ return renderLazyRoute(router, rec, rec.component as LazyComponent)
102
190
  }
103
191
 
104
192
  return h('div', { 'data-pyreon-router-view': true }, child as unknown as VNodeChild)
@@ -155,7 +243,14 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
155
243
  }
156
244
 
157
245
  const inst = router as RouterInstance | null
158
- const href = inst?.mode === 'history' ? `${inst._base}${props.to}` : `#${props.to}`
246
+ // `href` MUST be an accessor, not a string captured at setup. `props.to`
247
+ // is a getter when the parent passes a reactive expression (the JSX
248
+ // compiler wraps `<RouterLink to={someExpr}>` as `_rp(() => someExpr)`).
249
+ // Capturing into a string at setup time freezes the URL — passing the
250
+ // accessor lets `applyProp` wrap it in `renderEffect` so href tracks the
251
+ // underlying signal.
252
+ const href = (): string =>
253
+ inst?.mode === 'history' ? `${inst._base}${props.to}` : `#${props.to}`
159
254
 
160
255
  const isExactMatch = (): boolean => {
161
256
  if (!router) return false
@@ -199,12 +294,44 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
199
294
  onUnmount(() => observer.disconnect())
200
295
  }
201
296
 
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
297
+ // Forward all non-RouterLink props (style, id, data-*, etc.) to the <a>.
298
+ // `class` is pulled out separately so it can be MERGED with the internal
299
+ // active-class accessor — overriding the user's class silently dropped any
300
+ // conditional class the consumer wanted (e.g. `class={() => cond ? 'on' : ''}`).
301
+ const {
302
+ to: _to,
303
+ replace: _replace,
304
+ activeClass: _ac,
305
+ exactActiveClass: _eac,
306
+ exact: _exact,
307
+ prefetch: _prefetch,
308
+ class: userClass,
309
+ children,
310
+ ...rest
311
+ } = props as RouterLinkProps & { class?: ClassValue | (() => ClassValue) }
312
+
313
+ // Compose the user-provided `class` (string / array / object / function) with
314
+ // the internal `activeClass` accessor. Returning a function lets `applyProp`
315
+ // wrap it in `renderEffect` once — so navigation re-evaluates BOTH sides on
316
+ // every route change without rebuilding the link.
317
+ const mergedClass = (): string => {
318
+ const userResolved =
319
+ typeof userClass === 'function' ? (userClass as () => ClassValue)() : userClass
320
+ return cx([userResolved, activeClass()] as ClassValue)
321
+ }
204
322
 
205
323
  return h(
206
324
  'a',
207
- { ...rest, ref, href, class: activeClass, 'aria-current': ariaCurrent, onClick: handleClick, onMouseEnter: handleMouseEnter, onFocus: handleFocus },
325
+ {
326
+ ...rest,
327
+ ref,
328
+ href,
329
+ class: mergedClass,
330
+ 'aria-current': ariaCurrent,
331
+ onClick: handleClick,
332
+ onMouseEnter: handleMouseEnter,
333
+ onFocus: handleFocus,
334
+ },
208
335
  children ?? props.to,
209
336
  )
210
337
  }
@@ -455,3 +582,41 @@ function isStaleChunk(err: unknown): boolean {
455
582
  if (err instanceof SyntaxError) return true
456
583
  return false
457
584
  }
585
+
586
+ // Mark router framework components as native — compat-mode jsx() runtimes
587
+ // (react/preact/vue/solid-compat) skip wrapCompatComponent for these so their
588
+ // provide() / useContext() / onUnmount() / effect() / IntersectionObserver
589
+ // setup runs inside Pyreon's lifecycle frame instead of the compat wrapper's
590
+ // runUntracked accessor.
591
+ nativeCompat(RouterProvider)
592
+ nativeCompat(RouterView)
593
+ nativeCompat(RouterLink)
594
+
595
+ // ─── DefaultChromeLayout ─────────────────────────────────────────────────────
596
+ //
597
+ // Synthetic layout used by the layout-less-app 404 fallback. When the user
598
+ // has a page-level `notFoundComponent` (`_404.tsx` at the route root without
599
+ // a wrapping `_layout.tsx`), `findNotFoundFallback` in match.ts synthesizes
600
+ // a chain `[DefaultChromeLayout, syntheticLeaf]` and the render pipeline
601
+ // produces 404 HTML wrapped in `<main data-pyreon-default-chrome>` instead
602
+ // of the bare component output.
603
+ //
604
+ // The wrapper is intentionally minimal:
605
+ // - `<main>` provides a semantic landmark for accessibility and SEO.
606
+ // - The `data-pyreon-default-chrome` attribute lets users target the
607
+ // wrapper from CSS if they want to customize spacing / centering.
608
+ // - No prescribed visual styling — the framework can't know the user's
609
+ // design system, so we ship semantics only.
610
+ //
611
+ // Registered via the setter pattern (`_setDefaultChromeLayout`) instead of
612
+ // directly imported into match.ts to avoid a circular dependency: components.tsx
613
+ // depends transitively on match.ts (via router.ts), so match.ts can't import
614
+ // components.tsx without a cycle. The setter call runs at module load —
615
+ // every Pyreon app imports something from `./components.tsx` (RouterProvider,
616
+ // RouterView, RouterLink), which triggers the setter before any resolveRoute
617
+ // call can fire.
618
+ export const DefaultChromeLayout: ComponentFn = () =>
619
+ h('main', { 'data-pyreon-default-chrome': '' }, h(RouterView, null))
620
+
621
+ nativeCompat(DefaultChromeLayout)
622
+ _setDefaultChromeLayout(DefaultChromeLayout)
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,7 +46,15 @@ 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 { hydrateLoaderData, prefetchLoaderData, serializeLoaderData, useLoaderData } from './loader'
49
+ export type { RedirectStatus } from './redirect'
50
+ export { getRedirectInfo, isRedirectError, redirect } from './redirect'
51
+ export {
52
+ hydrateLoaderData,
53
+ prefetchLoaderData,
54
+ serializeLoaderData,
55
+ stringifyLoaderData,
56
+ useLoaderData,
57
+ } from './loader'
50
58
  // Match utilities (useful for SSR route pre-fetching)
51
59
  export {
52
60
  buildPath,
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
  }),
@@ -79,6 +89,64 @@ export function serializeLoaderData(router: RouterInstance): Record<string, unkn
79
89
  return result
80
90
  }
81
91
 
92
+ /**
93
+ * Serialize loader data to JSON for embedding in an SSR `<script>` tag.
94
+ *
95
+ * M2.2 — Drop-in replacement for `JSON.stringify(serializeLoaderData(router))`
96
+ * with three correctness wins:
97
+ * 1. **Strips functions / symbols / undefined values silently** so a loader
98
+ * that accidentally returns `{ data, fn: () => {} }` doesn't crash
99
+ * hydration — `JSON.stringify` drops these by default for the value
100
+ * itself but THROWS on circular references containing them. The custom
101
+ * replacer drops them inline so the surrounding object survives.
102
+ * 2. **Detects circular references** with a WeakSet and emits a clear
103
+ * `[Pyreon] Loader returned circular reference at key "<path>"` error
104
+ * naming the offending key instead of `Converting circular structure
105
+ * to JSON` (which doesn't tell the user which loader is broken).
106
+ * 3. **Escapes `</`** so embedding the JSON inside `<script>` can't break
107
+ * out of the script tag — already done at every call site but now
108
+ * centralised so all four callers (handler string-mode, handler stream-
109
+ * mode, SSG entry, dev SSR) get the escape uniformly.
110
+ *
111
+ * Returns the safely-escaped JSON string ready to drop into a `<script>`
112
+ * tag's body. Throws (with the Pyreon-prefixed error) on circular refs so
113
+ * the caller's existing try/catch wraps it correctly — silent serialization
114
+ * failures were the pre-fix shape.
115
+ *
116
+ * @example
117
+ * const json = stringifyLoaderData(serializeLoaderData(router))
118
+ * const tag = `<script>window.__PYREON_LOADER_DATA__=${json}</script>`
119
+ */
120
+ export function stringifyLoaderData(loaderData: Record<string, unknown>): string {
121
+ const seen = new WeakSet<object>()
122
+ const keyStack: string[] = []
123
+ const replacer = (key: string, value: unknown): unknown => {
124
+ // JSON.stringify calls the replacer with key = '' for the root, then
125
+ // the property name for each subsequent member. Track the path so the
126
+ // circular-ref error message names the offending route key.
127
+ if (key !== '') keyStack.push(key)
128
+ if (typeof value === 'function' || typeof value === 'symbol') {
129
+ // Drop silently. JSON.stringify already drops these as VALUES, but
130
+ // an explicit drop also handles array entries (where it'd convert
131
+ // to null otherwise — undesirable for downstream typed hydration).
132
+ return undefined
133
+ }
134
+ if (value && typeof value === 'object') {
135
+ if (seen.has(value as object)) {
136
+ const path = keyStack.join('.') || '<root>'
137
+ throw new Error(
138
+ `[Pyreon] Loader returned circular reference at "${path}". ` +
139
+ `Loaders must return JSON-serializable data (no cycles, no functions, no Date/Map/Set without a custom replacer). ` +
140
+ `Common cause: returning a Mongo/Prisma model with back-references intact.`,
141
+ )
142
+ }
143
+ seen.add(value as object)
144
+ }
145
+ return value
146
+ }
147
+ return JSON.stringify(loaderData, replacer).replace(/<\//g, '<\\/')
148
+ }
149
+
82
150
  /**
83
151
  * Hydrate loader data from a serialized object (e.g. `window.__PYREON_LOADER_DATA__`).
84
152
  * Populates the router's internal `_loaderData` map so the initial render uses