@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/lib/types/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
367
|
-
|
|
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.
|
|
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.
|
|
47
|
-
"@pyreon/reactivity": "^0.
|
|
48
|
-
"@pyreon/runtime-dom": "^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.
|
|
54
|
+
"@pyreon/test-utils": "^0.13.3",
|
|
54
55
|
"@vitest/browser-playwright": "^4.1.4",
|
|
55
56
|
"happy-dom": "^20.8.3"
|
|
56
57
|
}
|
package/src/components.tsx
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
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'
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
178
|
+
const child = (): VNodeChild => {
|
|
179
|
+
const { rec, comp, route } = depthEntry()
|
|
180
|
+
if (!rec) return null
|
|
95
181
|
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
return renderWithLoader(router, record, raw, route)
|
|
182
|
+
if (comp) {
|
|
183
|
+
return renderWithLoader(router, rec, comp, route)
|
|
99
184
|
}
|
|
100
185
|
|
|
101
|
-
|
|
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
|
-
|
|
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,
|
|
203
|
-
|
|
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
|
-
{
|
|
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
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 {
|
|
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
|
-
|
|
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
|
}),
|
|
@@ -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
|