@pyreon/router 0.15.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 +154 -2
- package/lib/types/index.d.ts +50 -2
- package/package.json +5 -5
- package/src/components.tsx +30 -0
- package/src/index.ts +7 -1
- package/src/loader.ts +58 -0
- package/src/match.ts +215 -1
- package/src/router.ts +12 -2
- package/src/tests/loader.test.ts +177 -1
- package/src/tests/match.test.ts +284 -0
- package/src/types.ts +22 -1
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"93e1055e-1","name":"loader.ts"},{"uid":"93e1055e-3","name":"match.ts"},{"uid":"93e1055e-5","name":"redirect.ts"},{"uid":"93e1055e-7","name":"scroll.ts"},{"uid":"93e1055e-9","name":"types.ts"},{"uid":"93e1055e-11","name":"router.ts"},{"uid":"93e1055e-13","name":"components.tsx"},{"uid":"93e1055e-15","name":"not-found.ts"},{"uid":"93e1055e-17","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"93e1055e-1":{"renderedLength":5692,"gzipLength":2467,"brotliLength":0,"metaUid":"93e1055e-0"},"93e1055e-3":{"renderedLength":16699,"gzipLength":5143,"brotliLength":0,"metaUid":"93e1055e-2"},"93e1055e-5":{"renderedLength":1966,"gzipLength":1043,"brotliLength":0,"metaUid":"93e1055e-4"},"93e1055e-7":{"renderedLength":2194,"gzipLength":899,"brotliLength":0,"metaUid":"93e1055e-6"},"93e1055e-9":{"renderedLength":385,"gzipLength":246,"brotliLength":0,"metaUid":"93e1055e-8"},"93e1055e-11":{"renderedLength":29175,"gzipLength":8093,"brotliLength":0,"metaUid":"93e1055e-10"},"93e1055e-13":{"renderedLength":10756,"gzipLength":3576,"brotliLength":0,"metaUid":"93e1055e-12"},"93e1055e-15":{"renderedLength":1315,"gzipLength":682,"brotliLength":0,"metaUid":"93e1055e-14"},"93e1055e-17":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"93e1055e-16"}},"nodeMetas":{"93e1055e-0":{"id":"/src/loader.ts","moduleParts":{"index.js":"93e1055e-1"},"imported":[{"uid":"93e1055e-18"}],"importedBy":[{"uid":"93e1055e-16"},{"uid":"93e1055e-12"}]},"93e1055e-2":{"id":"/src/match.ts","moduleParts":{"index.js":"93e1055e-3"},"imported":[],"importedBy":[{"uid":"93e1055e-16"},{"uid":"93e1055e-12"},{"uid":"93e1055e-10"}]},"93e1055e-4":{"id":"/src/redirect.ts","moduleParts":{"index.js":"93e1055e-5"},"imported":[],"importedBy":[{"uid":"93e1055e-16"},{"uid":"93e1055e-10"}]},"93e1055e-6":{"id":"/src/scroll.ts","moduleParts":{"index.js":"93e1055e-7"},"imported":[],"importedBy":[{"uid":"93e1055e-10"}]},"93e1055e-8":{"id":"/src/types.ts","moduleParts":{"index.js":"93e1055e-9"},"imported":[],"importedBy":[{"uid":"93e1055e-16"},{"uid":"93e1055e-10"}]},"93e1055e-10":{"id":"/src/router.ts","moduleParts":{"index.js":"93e1055e-11"},"imported":[{"uid":"93e1055e-18"},{"uid":"93e1055e-19"},{"uid":"93e1055e-2"},{"uid":"93e1055e-4"},{"uid":"93e1055e-6"},{"uid":"93e1055e-8"}],"importedBy":[{"uid":"93e1055e-16"},{"uid":"93e1055e-12"}]},"93e1055e-12":{"id":"/src/components.tsx","moduleParts":{"index.js":"93e1055e-13"},"imported":[{"uid":"93e1055e-18"},{"uid":"93e1055e-19"},{"uid":"93e1055e-0"},{"uid":"93e1055e-2"},{"uid":"93e1055e-10"}],"importedBy":[{"uid":"93e1055e-16"}]},"93e1055e-14":{"id":"/src/not-found.ts","moduleParts":{"index.js":"93e1055e-15"},"imported":[{"uid":"93e1055e-18"}],"importedBy":[{"uid":"93e1055e-16"}]},"93e1055e-16":{"id":"/src/index.ts","moduleParts":{"index.js":"93e1055e-17"},"imported":[{"uid":"93e1055e-12"},{"uid":"93e1055e-14"},{"uid":"93e1055e-4"},{"uid":"93e1055e-0"},{"uid":"93e1055e-2"},{"uid":"93e1055e-10"},{"uid":"93e1055e-8"}],"importedBy":[],"isEntry":true},"93e1055e-18":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"93e1055e-12"},{"uid":"93e1055e-14"},{"uid":"93e1055e-0"},{"uid":"93e1055e-10"}]},"93e1055e-19":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"93e1055e-12"},{"uid":"93e1055e-10"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -70,6 +70,51 @@ function serializeLoaderData(router) {
|
|
|
70
70
|
return result;
|
|
71
71
|
}
|
|
72
72
|
/**
|
|
73
|
+
* Serialize loader data to JSON for embedding in an SSR `<script>` tag.
|
|
74
|
+
*
|
|
75
|
+
* M2.2 — Drop-in replacement for `JSON.stringify(serializeLoaderData(router))`
|
|
76
|
+
* with three correctness wins:
|
|
77
|
+
* 1. **Strips functions / symbols / undefined values silently** so a loader
|
|
78
|
+
* that accidentally returns `{ data, fn: () => {} }` doesn't crash
|
|
79
|
+
* hydration — `JSON.stringify` drops these by default for the value
|
|
80
|
+
* itself but THROWS on circular references containing them. The custom
|
|
81
|
+
* replacer drops them inline so the surrounding object survives.
|
|
82
|
+
* 2. **Detects circular references** with a WeakSet and emits a clear
|
|
83
|
+
* `[Pyreon] Loader returned circular reference at key "<path>"` error
|
|
84
|
+
* naming the offending key instead of `Converting circular structure
|
|
85
|
+
* to JSON` (which doesn't tell the user which loader is broken).
|
|
86
|
+
* 3. **Escapes `</`** so embedding the JSON inside `<script>` can't break
|
|
87
|
+
* out of the script tag — already done at every call site but now
|
|
88
|
+
* centralised so all four callers (handler string-mode, handler stream-
|
|
89
|
+
* mode, SSG entry, dev SSR) get the escape uniformly.
|
|
90
|
+
*
|
|
91
|
+
* Returns the safely-escaped JSON string ready to drop into a `<script>`
|
|
92
|
+
* tag's body. Throws (with the Pyreon-prefixed error) on circular refs so
|
|
93
|
+
* the caller's existing try/catch wraps it correctly — silent serialization
|
|
94
|
+
* failures were the pre-fix shape.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* const json = stringifyLoaderData(serializeLoaderData(router))
|
|
98
|
+
* const tag = `<script>window.__PYREON_LOADER_DATA__=${json}<\/script>`
|
|
99
|
+
*/
|
|
100
|
+
function stringifyLoaderData(loaderData) {
|
|
101
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
102
|
+
const keyStack = [];
|
|
103
|
+
const replacer = (key, value) => {
|
|
104
|
+
if (key !== "") keyStack.push(key);
|
|
105
|
+
if (typeof value === "function" || typeof value === "symbol") return;
|
|
106
|
+
if (value && typeof value === "object") {
|
|
107
|
+
if (seen.has(value)) {
|
|
108
|
+
const path = keyStack.join(".") || "<root>";
|
|
109
|
+
throw new Error(`[Pyreon] Loader returned circular reference at "${path}". Loaders must return JSON-serializable data (no cycles, no functions, no Date/Map/Set without a custom replacer). Common cause: returning a Mongo/Prisma model with back-references intact.`);
|
|
110
|
+
}
|
|
111
|
+
seen.add(value);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
};
|
|
115
|
+
return JSON.stringify(loaderData, replacer).replace(/<\//g, "<\\/");
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
73
118
|
* Hydrate loader data from a serialized object (e.g. `window.__PYREON_LOADER_DATA__`).
|
|
74
119
|
* Populates the router's internal `_loaderData` map so the initial render uses
|
|
75
120
|
* server-fetched data without re-running loaders on the client.
|
|
@@ -90,6 +135,18 @@ function hydrateLoaderData(router, serialized) {
|
|
|
90
135
|
|
|
91
136
|
//#endregion
|
|
92
137
|
//#region src/match.ts
|
|
138
|
+
let _defaultChromeLayout = null;
|
|
139
|
+
/**
|
|
140
|
+
* Register the synthetic "default chrome" layout used when a page-level
|
|
141
|
+
* `notFoundComponent` is the closest fallback (layout-less single-page-
|
|
142
|
+
* app shape). Called once at module load from `./components.tsx`. Pyreon
|
|
143
|
+
* apps shouldn't need to call this themselves.
|
|
144
|
+
*
|
|
145
|
+
* @internal
|
|
146
|
+
*/
|
|
147
|
+
function _setDefaultChromeLayout(component) {
|
|
148
|
+
_defaultChromeLayout = component;
|
|
149
|
+
}
|
|
93
150
|
/**
|
|
94
151
|
* Parse a query string into key-value pairs. Duplicate keys are overwritten
|
|
95
152
|
* (last value wins). Use `parseQueryMulti` to preserve duplicates as arrays.
|
|
@@ -454,6 +511,17 @@ function resolveRoute(rawPath, routes) {
|
|
|
454
511
|
meta: w.meta,
|
|
455
512
|
search: runValidateSearch(w.matchedChain, query)
|
|
456
513
|
};
|
|
514
|
+
const nfb = findNotFoundFallback(routes, cleanPath);
|
|
515
|
+
if (nfb) return {
|
|
516
|
+
path: cleanPath,
|
|
517
|
+
params: {},
|
|
518
|
+
query,
|
|
519
|
+
hash,
|
|
520
|
+
matched: nfb,
|
|
521
|
+
meta: mergeMeta(nfb),
|
|
522
|
+
search: {},
|
|
523
|
+
isNotFound: true
|
|
524
|
+
};
|
|
457
525
|
return {
|
|
458
526
|
path: cleanPath,
|
|
459
527
|
params: {},
|
|
@@ -464,6 +532,86 @@ function resolveRoute(rawPath, routes) {
|
|
|
464
532
|
search: {}
|
|
465
533
|
};
|
|
466
534
|
}
|
|
535
|
+
/** Synthetic leaf RouteRecord used by the 404 fallback. Carries no real
|
|
536
|
+
* path matching — the resolver inserts it at the end of the chain when
|
|
537
|
+
* a parent `notFoundComponent` is the closest fallback for the URL. */
|
|
538
|
+
const SYNTHETIC_NOT_FOUND_PATH = "__pyreon_not_found_leaf__";
|
|
539
|
+
/**
|
|
540
|
+
* Walk the route tree finding records with `notFoundComponent`. Return
|
|
541
|
+
* the chain `[...ancestors, parentWithNotFound, syntheticLeaf]` for the
|
|
542
|
+
* DEEPEST record whose URL path is a prefix of `urlPath`.
|
|
543
|
+
*
|
|
544
|
+
* The path-prefix check: a record at `'/de'` applies to `/de/unknown`
|
|
545
|
+
* and `/de` itself but NOT to `/about` or `/encyclopedia` (full-segment
|
|
546
|
+
* boundary required, not substring). A record at `'/'` (root layout)
|
|
547
|
+
* applies to every URL. Deeper matches win — `/de` layout takes
|
|
548
|
+
* precedence over root layout for URLs under `/de/...`.
|
|
549
|
+
*
|
|
550
|
+
* Returns `null` when no record has `notFoundComponent`.
|
|
551
|
+
*/
|
|
552
|
+
function findNotFoundFallback(routes, urlPath) {
|
|
553
|
+
let best = null;
|
|
554
|
+
let pageBest = null;
|
|
555
|
+
function walk(records, parentChain, parentPath) {
|
|
556
|
+
for (const r of records) {
|
|
557
|
+
const rawPath = typeof r.path === "string" ? r.path : "";
|
|
558
|
+
const fullPath = rawPath.startsWith("/") ? rawPath : `${parentPath}/${rawPath}`.replace(/\/+/g, "/");
|
|
559
|
+
const chain = [...parentChain, r];
|
|
560
|
+
const isLayout = Array.isArray(r.children) && r.children.length > 0;
|
|
561
|
+
if (typeof r.notFoundComponent === "function") {
|
|
562
|
+
if (pathPrefixApplies(fullPath, urlPath)) {
|
|
563
|
+
const specificity = countSegments(fullPath);
|
|
564
|
+
if (isLayout) {
|
|
565
|
+
if (!best || chain.length > best.depth || chain.length === best.depth && specificity > best.specificity) best = {
|
|
566
|
+
chain,
|
|
567
|
+
record: r,
|
|
568
|
+
depth: chain.length,
|
|
569
|
+
specificity
|
|
570
|
+
};
|
|
571
|
+
} else if (!pageBest || chain.length > pageBest.depth || chain.length === pageBest.depth && specificity > pageBest.specificity) pageBest = {
|
|
572
|
+
record: r,
|
|
573
|
+
depth: chain.length,
|
|
574
|
+
specificity,
|
|
575
|
+
fullPath
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (Array.isArray(r.children)) walk(r.children, chain, fullPath);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
walk(routes, [], "");
|
|
583
|
+
if (best) {
|
|
584
|
+
const found = best;
|
|
585
|
+
const syntheticLeaf = {
|
|
586
|
+
path: SYNTHETIC_NOT_FOUND_PATH,
|
|
587
|
+
component: found.record.notFoundComponent
|
|
588
|
+
};
|
|
589
|
+
return [...found.chain, syntheticLeaf];
|
|
590
|
+
}
|
|
591
|
+
if (pageBest && _defaultChromeLayout) {
|
|
592
|
+
const found = pageBest;
|
|
593
|
+
return [{
|
|
594
|
+
path: found.fullPath,
|
|
595
|
+
component: _defaultChromeLayout
|
|
596
|
+
}, {
|
|
597
|
+
path: SYNTHETIC_NOT_FOUND_PATH,
|
|
598
|
+
component: found.record.notFoundComponent
|
|
599
|
+
}];
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
/** Check whether `prefixPath` is a path-prefix of `urlPath` at segment boundaries. */
|
|
604
|
+
function pathPrefixApplies(prefixPath, urlPath) {
|
|
605
|
+
if (prefixPath === "/" || prefixPath === "") return true;
|
|
606
|
+
if (urlPath === prefixPath) return true;
|
|
607
|
+
return urlPath.startsWith(`${prefixPath}/`);
|
|
608
|
+
}
|
|
609
|
+
/** Count `/`-separated path segments. `/` → 0; `/de` → 1; `/de/about` → 2. */
|
|
610
|
+
function countSegments(path) {
|
|
611
|
+
let count = 0;
|
|
612
|
+
for (let i = 0; i < path.length; i++) if (path.charCodeAt(i) === 47 && i + 1 < path.length) count++;
|
|
613
|
+
return count;
|
|
614
|
+
}
|
|
467
615
|
/** Run validateSearch from the deepest matched route that has one. */
|
|
468
616
|
function runValidateSearch(matched, query) {
|
|
469
617
|
for (let i = matched.length - 1; i >= 0; i--) {
|
|
@@ -1365,7 +1513,7 @@ function createRouter(options) {
|
|
|
1365
1513
|
isReady() {
|
|
1366
1514
|
return router._readyPromise;
|
|
1367
1515
|
},
|
|
1368
|
-
async preload(path, request) {
|
|
1516
|
+
async preload(path, request, options) {
|
|
1369
1517
|
const resolved = resolveRoute(path, routes);
|
|
1370
1518
|
await Promise.all(resolved.matched.map(async (record) => {
|
|
1371
1519
|
if (componentCache.has(record)) return;
|
|
@@ -1378,6 +1526,7 @@ function createRouter(options) {
|
|
|
1378
1526
|
const comp = typeof mod === "function" ? mod : mod.default;
|
|
1379
1527
|
componentCache.set(record, comp);
|
|
1380
1528
|
}));
|
|
1529
|
+
if (options?.skipLoaders) return;
|
|
1381
1530
|
const ac = new AbortController();
|
|
1382
1531
|
await Promise.all(resolved.matched.filter((r) => r.loader).map(async (r) => {
|
|
1383
1532
|
const data = await Promise.resolve().then(() => r.loader({
|
|
@@ -1835,6 +1984,9 @@ function isStaleChunk(err) {
|
|
|
1835
1984
|
nativeCompat(RouterProvider);
|
|
1836
1985
|
nativeCompat(RouterView);
|
|
1837
1986
|
nativeCompat(RouterLink);
|
|
1987
|
+
const DefaultChromeLayout = () => h("main", { "data-pyreon-default-chrome": "" }, h(RouterView, null));
|
|
1988
|
+
nativeCompat(DefaultChromeLayout);
|
|
1989
|
+
_setDefaultChromeLayout(DefaultChromeLayout);
|
|
1838
1990
|
|
|
1839
1991
|
//#endregion
|
|
1840
1992
|
//#region src/not-found.ts
|
|
@@ -1887,5 +2039,5 @@ const NotFoundBoundary = (props) => {
|
|
|
1887
2039
|
};
|
|
1888
2040
|
|
|
1889
2041
|
//#endregion
|
|
1890
|
-
export { NotFoundBoundary, RouterContext, RouterLink, RouterProvider, RouterView, buildPath, createRouter, findRouteByName, getRedirectInfo, hydrateLoaderData, isNotFoundError, isRedirectError, lazy, notFound, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, redirect, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useIsActive, useLoaderData, useMiddlewareData, useRoute, useRouter, useSearchParams, useTransition, useTypedSearchParams, useValidatedSearch };
|
|
2042
|
+
export { NotFoundBoundary, RouterContext, RouterLink, RouterProvider, RouterView, 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 };
|
|
1891
2043
|
//# sourceMappingURL=index.js.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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 {
|
|
@@ -187,6 +196,14 @@ interface RouteRecord<TPath extends string = string> {
|
|
|
187
196
|
gcTime?: number;
|
|
188
197
|
/** Component rendered when this route's loader throws an error */
|
|
189
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;
|
|
190
207
|
/**
|
|
191
208
|
* Component rendered while this route's loader is running.
|
|
192
209
|
* Only shown after `pendingMs` (default: 0) to avoid flash on fast loads.
|
|
@@ -328,7 +345,9 @@ interface Router<TNames extends string = string> {
|
|
|
328
345
|
* separately when creating the router (`createRouter({ url, ... })`) or
|
|
329
346
|
* call this for the same `url` you initialised the router with.
|
|
330
347
|
*/
|
|
331
|
-
preload(path: string, request?: Request
|
|
348
|
+
preload(path: string, request?: Request, options?: {
|
|
349
|
+
skipLoaders?: boolean;
|
|
350
|
+
}): Promise<void>;
|
|
332
351
|
/**
|
|
333
352
|
* Invalidate cached loader data. Forces loaders to re-run on next navigation.
|
|
334
353
|
* - No args: invalidate ALL cached loader data
|
|
@@ -571,6 +590,35 @@ declare function prefetchLoaderData(router: RouterInstance, path: string, reques
|
|
|
571
590
|
* ...${html}...`
|
|
572
591
|
*/
|
|
573
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;
|
|
574
622
|
/**
|
|
575
623
|
* Hydrate loader data from a serialized object (e.g. `window.__PYREON_LOADER_DATA__`).
|
|
576
624
|
* Populates the router's internal `_loaderData` map so the initial render uses
|
|
@@ -763,5 +811,5 @@ declare function useTransition(): () => boolean;
|
|
|
763
811
|
declare function useMiddlewareData(): () => Record<string, unknown>;
|
|
764
812
|
declare function createRouter<TNames extends string = string>(options: RouterOptions | RouteRecord[]): Router<TNames>;
|
|
765
813
|
//#endregion
|
|
766
|
-
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, 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 };
|
|
767
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": {
|
|
@@ -44,14 +44,14 @@
|
|
|
44
44
|
"prepublishOnly": "bun run build"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@pyreon/core": "^0.
|
|
48
|
-
"@pyreon/reactivity": "^0.
|
|
49
|
-
"@pyreon/runtime-dom": "^0.
|
|
47
|
+
"@pyreon/core": "^0.16.0",
|
|
48
|
+
"@pyreon/reactivity": "^0.16.0",
|
|
49
|
+
"@pyreon/runtime-dom": "^0.16.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
53
53
|
"@pyreon/manifest": "0.13.1",
|
|
54
|
-
"@pyreon/test-utils": "^0.13.
|
|
54
|
+
"@pyreon/test-utils": "^0.13.3",
|
|
55
55
|
"@vitest/browser-playwright": "^4.1.4",
|
|
56
56
|
"happy-dom": "^20.8.3"
|
|
57
57
|
}
|
package/src/components.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '@pyreon/core'
|
|
12
12
|
import { computed, signal } from '@pyreon/reactivity'
|
|
13
13
|
import { LoaderDataContext, prefetchLoaderData } from './loader'
|
|
14
|
+
import { _setDefaultChromeLayout } from './match'
|
|
14
15
|
import { isLazy, RouterContext, setActiveRouter } from './router'
|
|
15
16
|
import type { LazyComponent, ResolvedRoute, RouteRecord, Router, RouterInstance } from './types'
|
|
16
17
|
|
|
@@ -590,3 +591,32 @@ function isStaleChunk(err: unknown): boolean {
|
|
|
590
591
|
nativeCompat(RouterProvider)
|
|
591
592
|
nativeCompat(RouterView)
|
|
592
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/index.ts
CHANGED
|
@@ -48,7 +48,13 @@ export type { NotFoundBoundaryProps } from './not-found'
|
|
|
48
48
|
export { isNotFoundError, NotFoundBoundary, notFound } from './not-found'
|
|
49
49
|
export type { RedirectStatus } from './redirect'
|
|
50
50
|
export { getRedirectInfo, isRedirectError, redirect } from './redirect'
|
|
51
|
-
export {
|
|
51
|
+
export {
|
|
52
|
+
hydrateLoaderData,
|
|
53
|
+
prefetchLoaderData,
|
|
54
|
+
serializeLoaderData,
|
|
55
|
+
stringifyLoaderData,
|
|
56
|
+
useLoaderData,
|
|
57
|
+
} from './loader'
|
|
52
58
|
// Match utilities (useful for SSR route pre-fetching)
|
|
53
59
|
export {
|
|
54
60
|
buildPath,
|
package/src/loader.ts
CHANGED
|
@@ -89,6 +89,64 @@ export function serializeLoaderData(router: RouterInstance): Record<string, unkn
|
|
|
89
89
|
return result
|
|
90
90
|
}
|
|
91
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
|
+
|
|
92
150
|
/**
|
|
93
151
|
* Hydrate loader data from a serialized object (e.g. `window.__PYREON_LOADER_DATA__`).
|
|
94
152
|
* Populates the router's internal `_loaderData` map so the initial render uses
|
package/src/match.ts
CHANGED
|
@@ -1,4 +1,38 @@
|
|
|
1
|
-
import type { ResolvedRoute, RouteMeta, RouteRecord } from './types'
|
|
1
|
+
import type { ResolvedRoute, RouteComponent, RouteMeta, RouteRecord } from './types'
|
|
2
|
+
|
|
3
|
+
// ─── Default chrome layout registration ──────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// Late-bound registration for the synthetic layout used by the
|
|
6
|
+
// layout-less-app 404 fallback in `findNotFoundFallback` below. The
|
|
7
|
+
// component itself lives in `./components.tsx` (it needs JSX + the
|
|
8
|
+
// `RouterView` it imports), but `match.ts` is below `components.tsx` in
|
|
9
|
+
// the dependency graph (router.ts imports match.ts; components.tsx
|
|
10
|
+
// imports router.ts) — directly importing `components.tsx` from here
|
|
11
|
+
// would create a cycle. Instead, `components.tsx` calls
|
|
12
|
+
// `_setDefaultChromeLayout(DefaultChromeLayout)` at module load. As
|
|
13
|
+
// long as the consumer's app imports anything from `@pyreon/router`
|
|
14
|
+
// that touches `components.tsx` (which every app does via
|
|
15
|
+
// `RouterProvider` / `RouterView` / `RouterLink`), the registration
|
|
16
|
+
// runs before any `resolveRoute()` call.
|
|
17
|
+
//
|
|
18
|
+
// When the setter hasn't been called (e.g. unit tests that exercise
|
|
19
|
+
// `resolveRoute` in isolation without ever importing `components.tsx`),
|
|
20
|
+
// `findNotFoundFallback` returns `null` for the layout-less case — the
|
|
21
|
+
// standalone-render path in the SSG plugin / runtime handler picks up
|
|
22
|
+
// from there. So the fix degrades gracefully.
|
|
23
|
+
let _defaultChromeLayout: RouteComponent | null = null
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Register the synthetic "default chrome" layout used when a page-level
|
|
27
|
+
* `notFoundComponent` is the closest fallback (layout-less single-page-
|
|
28
|
+
* app shape). Called once at module load from `./components.tsx`. Pyreon
|
|
29
|
+
* apps shouldn't need to call this themselves.
|
|
30
|
+
*
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
export function _setDefaultChromeLayout(component: RouteComponent): void {
|
|
34
|
+
_defaultChromeLayout = component
|
|
35
|
+
}
|
|
2
36
|
|
|
3
37
|
// ─── Query string ─────────────────────────────────────────────────────────────
|
|
4
38
|
|
|
@@ -630,9 +664,189 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
|
|
|
630
664
|
}
|
|
631
665
|
}
|
|
632
666
|
|
|
667
|
+
// Fallback: notFoundComponent walk. When the URL doesn't match any
|
|
668
|
+
// descendant route, look for the deepest parent `notFoundComponent`
|
|
669
|
+
// whose path is a prefix of this URL. Build a synthetic chain that
|
|
670
|
+
// renders the not-found component INSIDE its ancestor layouts so the
|
|
671
|
+
// 404 page carries the same chrome (headers, footers, navigation) as
|
|
672
|
+
// regular pages. Without this, SSG/SSR returns `matched: []` and the
|
|
673
|
+
// caller has to render the not-found component standalone, losing
|
|
674
|
+
// layout wrapping.
|
|
675
|
+
const nfb = findNotFoundFallback(routes, cleanPath)
|
|
676
|
+
if (nfb) {
|
|
677
|
+
return {
|
|
678
|
+
path: cleanPath,
|
|
679
|
+
params: {},
|
|
680
|
+
query,
|
|
681
|
+
hash,
|
|
682
|
+
matched: nfb,
|
|
683
|
+
meta: mergeMeta(nfb),
|
|
684
|
+
search: {},
|
|
685
|
+
isNotFound: true,
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
633
689
|
return { path: cleanPath, params: {}, query, hash, matched: [], meta: {}, search: {} }
|
|
634
690
|
}
|
|
635
691
|
|
|
692
|
+
// ─── notFoundComponent walking ───────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
/** Synthetic leaf RouteRecord used by the 404 fallback. Carries no real
|
|
695
|
+
* path matching — the resolver inserts it at the end of the chain when
|
|
696
|
+
* a parent `notFoundComponent` is the closest fallback for the URL. */
|
|
697
|
+
const SYNTHETIC_NOT_FOUND_PATH = '__pyreon_not_found_leaf__'
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Walk the route tree finding records with `notFoundComponent`. Return
|
|
701
|
+
* the chain `[...ancestors, parentWithNotFound, syntheticLeaf]` for the
|
|
702
|
+
* DEEPEST record whose URL path is a prefix of `urlPath`.
|
|
703
|
+
*
|
|
704
|
+
* The path-prefix check: a record at `'/de'` applies to `/de/unknown`
|
|
705
|
+
* and `/de` itself but NOT to `/about` or `/encyclopedia` (full-segment
|
|
706
|
+
* boundary required, not substring). A record at `'/'` (root layout)
|
|
707
|
+
* applies to every URL. Deeper matches win — `/de` layout takes
|
|
708
|
+
* precedence over root layout for URLs under `/de/...`.
|
|
709
|
+
*
|
|
710
|
+
* Returns `null` when no record has `notFoundComponent`.
|
|
711
|
+
*/
|
|
712
|
+
function findNotFoundFallback(routes: RouteRecord[], urlPath: string): RouteRecord[] | null {
|
|
713
|
+
let best: { chain: RouteRecord[]; record: RouteRecord; depth: number; specificity: number } | null = null
|
|
714
|
+
// Second-pass fallback: collect the BEST page-level notFoundComponent
|
|
715
|
+
// (no children) in case the layout pass finds nothing. Applies to the
|
|
716
|
+
// layout-less single-page-app case where `_404.tsx` is emitted without
|
|
717
|
+
// a parent `_layout.tsx`. The layout pass intentionally skips this
|
|
718
|
+
// shape (page records have no `<RouterView />` to wrap the leaf); the
|
|
719
|
+
// synthetic default-chrome layout fills that gap below.
|
|
720
|
+
let pageBest: {
|
|
721
|
+
record: RouteRecord
|
|
722
|
+
depth: number
|
|
723
|
+
specificity: number
|
|
724
|
+
fullPath: string
|
|
725
|
+
} | null = null
|
|
726
|
+
|
|
727
|
+
function walk(records: RouteRecord[], parentChain: RouteRecord[], parentPath: string): void {
|
|
728
|
+
for (const r of records) {
|
|
729
|
+
const rawPath = typeof r.path === 'string' ? r.path : ''
|
|
730
|
+
// fs-router emits absolute paths for nested routes (e.g. `/de/about`);
|
|
731
|
+
// relative paths inherit parent's path via concat. Mirror flattenOne's
|
|
732
|
+
// logic so synthesised paths track real URL prefixes.
|
|
733
|
+
const fullPath = rawPath.startsWith('/')
|
|
734
|
+
? rawPath
|
|
735
|
+
: `${parentPath}/${rawPath}`.replace(/\/+/g, '/')
|
|
736
|
+
const chain = [...parentChain, r]
|
|
737
|
+
|
|
738
|
+
// Filter to LAYOUT records (records with non-empty `children`).
|
|
739
|
+
// fs-router attaches `notFoundComponent` to BOTH the parent layout
|
|
740
|
+
// AND every page record under that layout. Page records have no
|
|
741
|
+
// `<RouterView />` to render the synthetic leaf at the next depth,
|
|
742
|
+
// so picking a page as the fallback parent produces a chain
|
|
743
|
+
// `[Layout, Page, syntheticLeaf]` where `Page` swallows the leaf.
|
|
744
|
+
// Filtering to records with children ensures the synthetic leaf
|
|
745
|
+
// lands at a depth a `<RouterView />` will actually render.
|
|
746
|
+
const isLayout = Array.isArray(r.children) && r.children.length > 0
|
|
747
|
+
|
|
748
|
+
if (typeof r.notFoundComponent === 'function') {
|
|
749
|
+
const applies = pathPrefixApplies(fullPath, urlPath)
|
|
750
|
+
if (applies) {
|
|
751
|
+
// Prefer (a) the deepest record (longest chain), then (b) the
|
|
752
|
+
// most specific path-prefix when chains tie. Specificity =
|
|
753
|
+
// number of path segments in `fullPath`. `/` has 0; `/de` has 1.
|
|
754
|
+
const specificity = countSegments(fullPath)
|
|
755
|
+
if (isLayout) {
|
|
756
|
+
if (
|
|
757
|
+
!best ||
|
|
758
|
+
chain.length > best.depth ||
|
|
759
|
+
(chain.length === best.depth && specificity > best.specificity)
|
|
760
|
+
) {
|
|
761
|
+
best = { chain, record: r, depth: chain.length, specificity }
|
|
762
|
+
}
|
|
763
|
+
} else if (
|
|
764
|
+
!pageBest ||
|
|
765
|
+
chain.length > pageBest.depth ||
|
|
766
|
+
(chain.length === pageBest.depth && specificity > pageBest.specificity)
|
|
767
|
+
) {
|
|
768
|
+
pageBest = { record: r, depth: chain.length, specificity, fullPath }
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (Array.isArray(r.children)) {
|
|
774
|
+
walk(r.children, chain, fullPath)
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
walk(routes, [], '')
|
|
780
|
+
|
|
781
|
+
if (best) {
|
|
782
|
+
// TypeScript widening: `best` is inferred as `null` inside the closure
|
|
783
|
+
// when not narrowed, even though we asserted it's non-null above.
|
|
784
|
+
const found: { chain: RouteRecord[]; record: RouteRecord; depth: number; specificity: number } =
|
|
785
|
+
best
|
|
786
|
+
|
|
787
|
+
const syntheticLeaf: RouteRecord = {
|
|
788
|
+
path: SYNTHETIC_NOT_FOUND_PATH,
|
|
789
|
+
component: found.record.notFoundComponent as RouteComponent,
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return [...found.chain, syntheticLeaf]
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Layout-less fallback. The user has a page-level `notFoundComponent`
|
|
796
|
+
// (e.g. `_404.tsx` at the route root with no `_layout.tsx`). Without
|
|
797
|
+
// a parent layout to wrap the leaf, we synthesize ONE: a minimal
|
|
798
|
+
// "default chrome" layout that renders `<main data-pyreon-default-chrome>
|
|
799
|
+
// <RouterView /></main>`. This provides a semantic-HTML landmark for
|
|
800
|
+
// accessibility + a hook for users to target the wrapper via CSS, while
|
|
801
|
+
// routing the render through the normal `<RouterView />` pipeline (so
|
|
802
|
+
// `isNotFound` propagation and runtime SSR status-404 still work).
|
|
803
|
+
//
|
|
804
|
+
// The DefaultChromeLayout component is registered by `components.tsx`
|
|
805
|
+
// at module load time via `_setDefaultChromeLayout()` (setter pattern
|
|
806
|
+
// to avoid the components.tsx → match.ts circular import). If the
|
|
807
|
+
// setter hasn't been called yet (consumer never imported anything
|
|
808
|
+
// from `@pyreon/router` that triggers components.tsx's side effects),
|
|
809
|
+
// we fall back to returning null — the standalone-render path in the
|
|
810
|
+
// SSG plugin / runtime handler picks up from there.
|
|
811
|
+
if (pageBest && _defaultChromeLayout) {
|
|
812
|
+
const found: {
|
|
813
|
+
record: RouteRecord
|
|
814
|
+
depth: number
|
|
815
|
+
specificity: number
|
|
816
|
+
fullPath: string
|
|
817
|
+
} = pageBest
|
|
818
|
+
|
|
819
|
+
const syntheticChromeLayout: RouteRecord = {
|
|
820
|
+
path: found.fullPath,
|
|
821
|
+
component: _defaultChromeLayout,
|
|
822
|
+
}
|
|
823
|
+
const syntheticLeaf: RouteRecord = {
|
|
824
|
+
path: SYNTHETIC_NOT_FOUND_PATH,
|
|
825
|
+
component: found.record.notFoundComponent as RouteComponent,
|
|
826
|
+
}
|
|
827
|
+
return [syntheticChromeLayout, syntheticLeaf]
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
return null
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Check whether `prefixPath` is a path-prefix of `urlPath` at segment boundaries. */
|
|
834
|
+
function pathPrefixApplies(prefixPath: string, urlPath: string): boolean {
|
|
835
|
+
if (prefixPath === '/' || prefixPath === '') return true
|
|
836
|
+
if (urlPath === prefixPath) return true
|
|
837
|
+
// Require a `/` boundary after the prefix to avoid `/de` matching `/encyclopedia`.
|
|
838
|
+
return urlPath.startsWith(`${prefixPath}/`)
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Count `/`-separated path segments. `/` → 0; `/de` → 1; `/de/about` → 2. */
|
|
842
|
+
function countSegments(path: string): number {
|
|
843
|
+
let count = 0
|
|
844
|
+
for (let i = 0; i < path.length; i++) {
|
|
845
|
+
if (path.charCodeAt(i) === 47 /* / */ && i + 1 < path.length) count++
|
|
846
|
+
}
|
|
847
|
+
return count
|
|
848
|
+
}
|
|
849
|
+
|
|
636
850
|
/** Run validateSearch from the deepest matched route that has one. */
|
|
637
851
|
function runValidateSearch(
|
|
638
852
|
matched: RouteRecord[],
|
package/src/router.ts
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
type NavigationGuard,
|
|
14
14
|
type NavigationGuardResult,
|
|
15
15
|
type ResolvedRoute,
|
|
16
|
-
type RouteMiddleware,
|
|
17
16
|
type RouteMiddlewareContext,
|
|
18
17
|
type RouteRecord,
|
|
19
18
|
type Router,
|
|
@@ -1097,7 +1096,11 @@ export function createRouter<TNames extends string = string>(
|
|
|
1097
1096
|
return router._readyPromise
|
|
1098
1097
|
},
|
|
1099
1098
|
|
|
1100
|
-
async preload(
|
|
1099
|
+
async preload(
|
|
1100
|
+
path: string,
|
|
1101
|
+
request?: Request,
|
|
1102
|
+
options?: { skipLoaders?: boolean },
|
|
1103
|
+
) {
|
|
1101
1104
|
const resolved = resolveRoute(path, routes)
|
|
1102
1105
|
// Load lazy components in parallel and populate the component cache so
|
|
1103
1106
|
// the synchronous render pass finds ready components instead of kicking
|
|
@@ -1115,6 +1118,13 @@ export function createRouter<TNames extends string = string>(
|
|
|
1115
1118
|
componentCache.set(record, comp)
|
|
1116
1119
|
}),
|
|
1117
1120
|
)
|
|
1121
|
+
// Skip the loader-running step when the caller explicitly opts out
|
|
1122
|
+
// (used by the SSG plugin's 404 build path — parent-layout loaders
|
|
1123
|
+
// that hit auth resources or external APIs shouldn't fire when
|
|
1124
|
+
// generating a static 404 page). Lazy components above DO still
|
|
1125
|
+
// resolve so the synthetic chain renders cleanly; only the
|
|
1126
|
+
// `r.loader()` invocations are skipped.
|
|
1127
|
+
if (options?.skipLoaders) return
|
|
1118
1128
|
// Run loaders for the matched path — uses the same code path SSR
|
|
1119
1129
|
// already relied on, so loader data ends up in `_loaderData` under the
|
|
1120
1130
|
// matched route records. Uses a LOCAL AbortController: `preload` is
|
package/src/tests/loader.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hydrateLoaderData, prefetchLoaderData, serializeLoaderData } from '../loader'
|
|
1
|
+
import { hydrateLoaderData, prefetchLoaderData, serializeLoaderData, stringifyLoaderData } from '../loader'
|
|
2
2
|
import { createRouter, setActiveRouter, useIsActive, useSearchParams } from '../router'
|
|
3
3
|
import { lazy } from '../types'
|
|
4
4
|
import type { RouteRecord, RouterInstance } from '../types'
|
|
@@ -130,6 +130,81 @@ describe('loader data serialization — edge cases', () => {
|
|
|
130
130
|
})
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
+
// ─── M2.2 — stringifyLoaderData ────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
describe('stringifyLoaderData (M2.2)', () => {
|
|
136
|
+
// Bisect-load-bearing: revert the replacer (use bare `JSON.stringify(d).replace(/<\//g, '<\\/')`)
|
|
137
|
+
// → the function-strip + circular-error specs fail. The bare-strings-only spec
|
|
138
|
+
// would still pass since JSON.stringify also drops function values for objects.
|
|
139
|
+
|
|
140
|
+
test('strips function values silently', () => {
|
|
141
|
+
const json = stringifyLoaderData({
|
|
142
|
+
'/home': { data: 1, fn: () => {} },
|
|
143
|
+
})
|
|
144
|
+
expect(json).not.toContain('fn')
|
|
145
|
+
expect(json).toContain('"data":1')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('strips symbol values silently', () => {
|
|
149
|
+
const json = stringifyLoaderData({
|
|
150
|
+
'/home': { data: 1, sym: Symbol('x') as unknown as string },
|
|
151
|
+
})
|
|
152
|
+
expect(json).not.toContain('sym')
|
|
153
|
+
expect(json).toContain('"data":1')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('throws Pyreon-prefixed error on circular reference naming the offending key', () => {
|
|
157
|
+
interface Cyclic {
|
|
158
|
+
data: number
|
|
159
|
+
self?: Cyclic
|
|
160
|
+
}
|
|
161
|
+
const cyclic: Cyclic = { data: 1 }
|
|
162
|
+
cyclic.self = cyclic
|
|
163
|
+
expect(() => stringifyLoaderData({ '/posts/1': cyclic })).toThrow(/\[Pyreon\] Loader returned circular reference/)
|
|
164
|
+
// The error names the path: `/posts/1.self` (or similar).
|
|
165
|
+
expect(() => stringifyLoaderData({ '/posts/1': cyclic })).toThrow(/\/posts\/1/)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('escapes </script> to prevent script-tag escape', () => {
|
|
169
|
+
const json = stringifyLoaderData({
|
|
170
|
+
'/home': { html: '</script><script>alert(1)' },
|
|
171
|
+
})
|
|
172
|
+
expect(json).not.toContain('</script>')
|
|
173
|
+
expect(json).toContain('<\\/script>')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('passes plain data through unchanged', () => {
|
|
177
|
+
const json = stringifyLoaderData({
|
|
178
|
+
'/posts': [{ id: 1, title: 'A' }],
|
|
179
|
+
'/about': { count: 42 },
|
|
180
|
+
})
|
|
181
|
+
expect(JSON.parse(json)).toEqual({
|
|
182
|
+
'/posts': [{ id: 1, title: 'A' }],
|
|
183
|
+
'/about': { count: 42 },
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('handles deeply-nested data without falsely flagging shared references as cycles', () => {
|
|
188
|
+
// A non-cyclic shared reference (two keys pointing at the same array)
|
|
189
|
+
// SHOULD throw — JSON serialization can't represent shared identity
|
|
190
|
+
// without `references`, and a runtime cycle-detector treating shared
|
|
191
|
+
// refs as cycles is the safe default for hydration semantics. Verify
|
|
192
|
+
// the throw shape — if this becomes too aggressive, relax with a
|
|
193
|
+
// post-visit drop instead of WeakSet.
|
|
194
|
+
const shared = [1, 2, 3]
|
|
195
|
+
expect(() =>
|
|
196
|
+
stringifyLoaderData({
|
|
197
|
+
'/a': shared,
|
|
198
|
+
'/b': shared,
|
|
199
|
+
}),
|
|
200
|
+
).toThrow(/circular reference/)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('empty record produces empty object JSON', () => {
|
|
204
|
+
expect(stringifyLoaderData({})).toBe('{}')
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
133
208
|
// ─── useIsActive — edge cases ────────────────────────────────────────────────
|
|
134
209
|
|
|
135
210
|
describe('useIsActive — edge cases', () => {
|
|
@@ -580,6 +655,107 @@ describe('router.preload', () => {
|
|
|
580
655
|
expect(lazyLoadCalls).toBe(1)
|
|
581
656
|
expect(router._componentCache.get(routes[1] as RouteRecord)).toBe(Lazy)
|
|
582
657
|
})
|
|
658
|
+
|
|
659
|
+
// ─── PR C — skipLoaders option for 404 build paths ──────────────────────
|
|
660
|
+
//
|
|
661
|
+
// The SSG plugin's `__renderNotFound` opts out of loader execution
|
|
662
|
+
// when generating a static 404 page — parent-layout loaders that hit
|
|
663
|
+
// auth resources / external APIs shouldn't fire when there's no real
|
|
664
|
+
// request context to drive them. `skipLoaders: true` skips the loader
|
|
665
|
+
// step entirely while keeping the lazy-component resolution intact
|
|
666
|
+
// (so the synthetic chain still renders cleanly).
|
|
667
|
+
test('skipLoaders: true skips loader execution', async () => {
|
|
668
|
+
let calls = 0
|
|
669
|
+
const routes: RouteRecord[] = [
|
|
670
|
+
{ path: '/', component: Home },
|
|
671
|
+
{
|
|
672
|
+
path: '/u/:id',
|
|
673
|
+
component: User,
|
|
674
|
+
loader: async ({ params }) => {
|
|
675
|
+
calls++
|
|
676
|
+
return { id: params.id }
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
]
|
|
680
|
+
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
681
|
+
|
|
682
|
+
await router.preload('/u/7', undefined, { skipLoaders: true })
|
|
683
|
+
|
|
684
|
+
expect(calls).toBe(0)
|
|
685
|
+
expect(router._loaderData.get(routes[1] as RouteRecord)).toBeUndefined()
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
test('skipLoaders: false (default) still runs loaders', async () => {
|
|
689
|
+
let calls = 0
|
|
690
|
+
const routes: RouteRecord[] = [
|
|
691
|
+
{ path: '/', component: Home },
|
|
692
|
+
{
|
|
693
|
+
path: '/u/:id',
|
|
694
|
+
component: User,
|
|
695
|
+
loader: async () => {
|
|
696
|
+
calls++
|
|
697
|
+
return null
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
]
|
|
701
|
+
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
702
|
+
|
|
703
|
+
// No options arg
|
|
704
|
+
await router.preload('/u/7')
|
|
705
|
+
expect(calls).toBe(1)
|
|
706
|
+
|
|
707
|
+
// Explicit false
|
|
708
|
+
await router.preload('/u/7', undefined, { skipLoaders: false })
|
|
709
|
+
expect(calls).toBe(2)
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
test('skipLoaders: true still loads lazy components (preserves render readiness)', async () => {
|
|
713
|
+
// The 404 build path needs the synthetic-chain components resolved
|
|
714
|
+
// so the render pass doesn't fall back to loadingComponent. Only
|
|
715
|
+
// the data-fetching `r.loader()` calls are skipped.
|
|
716
|
+
let lazyLoadCalls = 0
|
|
717
|
+
let loaderCalls = 0
|
|
718
|
+
const Lazy = () => null
|
|
719
|
+
const routes: RouteRecord[] = [
|
|
720
|
+
{ path: '/', component: Home },
|
|
721
|
+
{
|
|
722
|
+
path: '/lazy',
|
|
723
|
+
component: lazy(async () => {
|
|
724
|
+
lazyLoadCalls++
|
|
725
|
+
return Lazy
|
|
726
|
+
}),
|
|
727
|
+
loader: async () => {
|
|
728
|
+
loaderCalls++
|
|
729
|
+
return null
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
]
|
|
733
|
+
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
734
|
+
|
|
735
|
+
await router.preload('/lazy', undefined, { skipLoaders: true })
|
|
736
|
+
|
|
737
|
+
// Lazy component IS resolved (needed for render readiness).
|
|
738
|
+
expect(lazyLoadCalls).toBe(1)
|
|
739
|
+
expect(router._componentCache.get(routes[1] as RouteRecord)).toBe(Lazy)
|
|
740
|
+
// Loader was NOT called.
|
|
741
|
+
expect(loaderCalls).toBe(0)
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
test('skipLoaders: true preserves currentRoute (preload is non-navigational)', async () => {
|
|
745
|
+
const routes: RouteRecord[] = [
|
|
746
|
+
{ path: '/', component: Home },
|
|
747
|
+
{
|
|
748
|
+
path: '/u/:id',
|
|
749
|
+
component: User,
|
|
750
|
+
loader: async () => null,
|
|
751
|
+
},
|
|
752
|
+
]
|
|
753
|
+
const router = createRouter({ routes, url: '/' }) as RouterInstance
|
|
754
|
+
|
|
755
|
+
await router.preload('/u/7', undefined, { skipLoaders: true })
|
|
756
|
+
|
|
757
|
+
expect(router.currentRoute().path).toBe('/')
|
|
758
|
+
})
|
|
583
759
|
})
|
|
584
760
|
|
|
585
761
|
// ─── _loaderCache LRU cap (regression for missing _maxCacheSize wiring) ────
|
package/src/tests/match.test.ts
CHANGED
|
@@ -8,6 +8,12 @@ import {
|
|
|
8
8
|
resolveRoute,
|
|
9
9
|
stringifyQuery,
|
|
10
10
|
} from '../match'
|
|
11
|
+
// Importing from components.tsx triggers the module-load side-effect that
|
|
12
|
+
// registers DefaultChromeLayout with match.ts (via _setDefaultChromeLayout).
|
|
13
|
+
// Without this import, the layout-less fallback in findNotFoundFallback
|
|
14
|
+
// returns null because no chrome layout is registered. Tests below verify
|
|
15
|
+
// the registered layout is used as the synthetic chain's first entry.
|
|
16
|
+
import { DefaultChromeLayout } from '../components'
|
|
11
17
|
import type { RouteRecord } from '../types'
|
|
12
18
|
|
|
13
19
|
const Home = () => null
|
|
@@ -496,3 +502,281 @@ describe('parseQueryMulti — + as space', () => {
|
|
|
496
502
|
})
|
|
497
503
|
})
|
|
498
504
|
})
|
|
505
|
+
|
|
506
|
+
// ─── resolveRoute — notFoundComponent fallback (PR L5) ───────────────────────
|
|
507
|
+
//
|
|
508
|
+
// When a URL doesn't match any route AND a parent record has a
|
|
509
|
+
// `notFoundComponent`, resolveRoute builds a synthetic matched chain
|
|
510
|
+
// `[...ancestors, parentLayout, syntheticLeaf]` so the not-found
|
|
511
|
+
// component renders INSIDE its ancestor layouts' chrome.
|
|
512
|
+
|
|
513
|
+
describe('resolveRoute — notFoundComponent fallback', () => {
|
|
514
|
+
const Layout = () => null
|
|
515
|
+
const NotFoundPage = () => null
|
|
516
|
+
|
|
517
|
+
it('synthesises chain through root layout when URL is unmatched', () => {
|
|
518
|
+
const routes: RouteRecord[] = [
|
|
519
|
+
{
|
|
520
|
+
path: '/',
|
|
521
|
+
component: Layout,
|
|
522
|
+
notFoundComponent: NotFoundPage,
|
|
523
|
+
children: [
|
|
524
|
+
{ path: '/', component: Home },
|
|
525
|
+
{ path: '/about', component: About },
|
|
526
|
+
],
|
|
527
|
+
},
|
|
528
|
+
]
|
|
529
|
+
|
|
530
|
+
const r = resolveRoute('/this-does-not-exist', routes)
|
|
531
|
+
expect(r.isNotFound).toBe(true)
|
|
532
|
+
// Chain: [rootLayout, syntheticLeaf]. The synthetic leaf carries
|
|
533
|
+
// NotFoundPage as its component so the deepest RouterView resolves it.
|
|
534
|
+
expect(r.matched.length).toBe(2)
|
|
535
|
+
expect(r.matched[0]?.component).toBe(Layout)
|
|
536
|
+
expect(r.matched[1]?.component).toBe(NotFoundPage)
|
|
537
|
+
expect(r.matched[1]?.path).toBe('__pyreon_not_found_leaf__')
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
it('returns empty matched when no notFoundComponent anywhere', () => {
|
|
541
|
+
const routes: RouteRecord[] = [
|
|
542
|
+
{ path: '/', component: Home },
|
|
543
|
+
{ path: '/about', component: About },
|
|
544
|
+
]
|
|
545
|
+
|
|
546
|
+
const r = resolveRoute('/unknown', routes)
|
|
547
|
+
expect(r.isNotFound).toBeUndefined()
|
|
548
|
+
expect(r.matched.length).toBe(0)
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it('does not trigger fallback for matched routes', () => {
|
|
552
|
+
const routes: RouteRecord[] = [
|
|
553
|
+
{
|
|
554
|
+
path: '/',
|
|
555
|
+
component: Layout,
|
|
556
|
+
notFoundComponent: NotFoundPage,
|
|
557
|
+
children: [{ path: '/about', component: About }],
|
|
558
|
+
},
|
|
559
|
+
]
|
|
560
|
+
|
|
561
|
+
const r = resolveRoute('/about', routes)
|
|
562
|
+
expect(r.isNotFound).toBeUndefined()
|
|
563
|
+
expect(r.matched).not.toContain(NotFoundPage)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('picks the DEEPEST matching parent when nested layouts have notFoundComponent', () => {
|
|
567
|
+
const DeNotFound = () => null
|
|
568
|
+
const RootNotFound = () => null
|
|
569
|
+
const DeLayout = () => null
|
|
570
|
+
const routes: RouteRecord[] = [
|
|
571
|
+
{
|
|
572
|
+
path: '/',
|
|
573
|
+
component: Layout,
|
|
574
|
+
notFoundComponent: RootNotFound,
|
|
575
|
+
children: [
|
|
576
|
+
{
|
|
577
|
+
path: '/de',
|
|
578
|
+
component: DeLayout,
|
|
579
|
+
notFoundComponent: DeNotFound,
|
|
580
|
+
children: [{ path: '/de/about', component: About }],
|
|
581
|
+
},
|
|
582
|
+
],
|
|
583
|
+
},
|
|
584
|
+
]
|
|
585
|
+
|
|
586
|
+
// URL under /de prefix — should pick the DEEPER /de layout's notFound,
|
|
587
|
+
// not the root's
|
|
588
|
+
const r = resolveRoute('/de/unknown', routes)
|
|
589
|
+
expect(r.isNotFound).toBe(true)
|
|
590
|
+
expect(r.matched[r.matched.length - 1]?.component).toBe(DeNotFound)
|
|
591
|
+
// URL under root only — should fall back to root layout's notFound
|
|
592
|
+
const r2 = resolveRoute('/about-typo', routes)
|
|
593
|
+
expect(r2.isNotFound).toBe(true)
|
|
594
|
+
expect(r2.matched[r2.matched.length - 1]?.component).toBe(RootNotFound)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
it('respects segment boundary in path-prefix match (no substring confusion)', () => {
|
|
598
|
+
const EnNotFound = () => null
|
|
599
|
+
const routes: RouteRecord[] = [
|
|
600
|
+
{
|
|
601
|
+
path: '/en',
|
|
602
|
+
component: Layout,
|
|
603
|
+
notFoundComponent: EnNotFound,
|
|
604
|
+
children: [],
|
|
605
|
+
},
|
|
606
|
+
]
|
|
607
|
+
|
|
608
|
+
// `/encyclopedia` MUST NOT match `/en` as a prefix — full segment boundary required.
|
|
609
|
+
const r = resolveRoute('/encyclopedia', routes)
|
|
610
|
+
expect(r.isNotFound).toBeUndefined()
|
|
611
|
+
expect(r.matched.length).toBe(0)
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
it('non-matching URL under a layout prefix triggers fallback (deeper than root)', () => {
|
|
615
|
+
const routes: RouteRecord[] = [
|
|
616
|
+
{
|
|
617
|
+
path: '/admin',
|
|
618
|
+
component: Layout,
|
|
619
|
+
notFoundComponent: NotFoundPage,
|
|
620
|
+
children: [{ path: '/admin/users', component: User }],
|
|
621
|
+
},
|
|
622
|
+
]
|
|
623
|
+
|
|
624
|
+
// `/admin/missing` doesn't match `/admin` (layout itself) OR `/admin/users`
|
|
625
|
+
// → notFoundComponent fallback applies, chain wraps the admin layout
|
|
626
|
+
const r = resolveRoute('/admin/missing', routes)
|
|
627
|
+
expect(r.isNotFound).toBe(true)
|
|
628
|
+
expect(r.matched[0]?.component).toBe(Layout)
|
|
629
|
+
expect(r.matched[r.matched.length - 1]?.component).toBe(NotFoundPage)
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it('synthetic leaf has the right path marker (for runtime identification)', () => {
|
|
633
|
+
const routes: RouteRecord[] = [
|
|
634
|
+
{
|
|
635
|
+
path: '/',
|
|
636
|
+
component: Layout,
|
|
637
|
+
notFoundComponent: NotFoundPage,
|
|
638
|
+
children: [{ path: '/', component: Home }],
|
|
639
|
+
},
|
|
640
|
+
]
|
|
641
|
+
const r = resolveRoute('/unknown', routes)
|
|
642
|
+
expect(r.matched[r.matched.length - 1]?.path).toBe('__pyreon_not_found_leaf__')
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
it('preserves query string on the synthetic 404 resolution', () => {
|
|
646
|
+
const routes: RouteRecord[] = [
|
|
647
|
+
{
|
|
648
|
+
path: '/',
|
|
649
|
+
component: Layout,
|
|
650
|
+
notFoundComponent: NotFoundPage,
|
|
651
|
+
children: [{ path: '/', component: Home }],
|
|
652
|
+
},
|
|
653
|
+
]
|
|
654
|
+
const r = resolveRoute('/unknown?foo=bar', routes)
|
|
655
|
+
expect(r.isNotFound).toBe(true)
|
|
656
|
+
expect(r.query).toEqual({ foo: 'bar' })
|
|
657
|
+
expect(r.path).toBe('/unknown')
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it('fires fallback via DefaultChromeLayout when the only notFoundComponent is on a page record without children', () => {
|
|
661
|
+
// PR B (layout-less app fallback): page-level `notFoundComponent` now
|
|
662
|
+
// gets wrapped in a synthetic `DefaultChromeLayout` (`<main data-
|
|
663
|
+
// pyreon-default-chrome>`) so the render pipeline produces semantic-
|
|
664
|
+
// HTML output instead of bare component markup. Pre-PR-B the resolver
|
|
665
|
+
// returned an empty chain here — the standalone-render path in the
|
|
666
|
+
// SSG plugin / runtime handler would render the component bare with
|
|
667
|
+
// no wrapping (the documented "no chrome" limitation).
|
|
668
|
+
//
|
|
669
|
+
// Tests in the `layout-less app fallback (PR B)` describe block
|
|
670
|
+
// below cover the synthetic chain shape in detail.
|
|
671
|
+
const PageOnly = () => null
|
|
672
|
+
const routes: RouteRecord[] = [
|
|
673
|
+
{ path: '/', component: PageOnly, notFoundComponent: NotFoundPage },
|
|
674
|
+
]
|
|
675
|
+
const r = resolveRoute('/unknown', routes)
|
|
676
|
+
expect(r.isNotFound).toBe(true)
|
|
677
|
+
// Synthetic chain: [DefaultChromeLayout, syntheticLeaf]
|
|
678
|
+
expect(r.matched).toHaveLength(2)
|
|
679
|
+
expect(r.matched[0]?.component).toBe(DefaultChromeLayout)
|
|
680
|
+
expect(r.matched[1]?.component).toBe(NotFoundPage)
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it('does NOT fire when wildcard catch-all is configured', () => {
|
|
684
|
+
const Catchall = () => null
|
|
685
|
+
const routes: RouteRecord[] = [
|
|
686
|
+
{ path: '/', component: Home, notFoundComponent: NotFoundPage },
|
|
687
|
+
{ path: '(.*)', component: Catchall },
|
|
688
|
+
]
|
|
689
|
+
|
|
690
|
+
// Wildcard catches everything first — notFoundComponent fallback never runs.
|
|
691
|
+
const r = resolveRoute('/unknown', routes)
|
|
692
|
+
expect(r.isNotFound).toBeUndefined()
|
|
693
|
+
expect(r.matched[0]?.component).toBe(Catchall)
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
// ─── Layout-less app fallback (PR B) ───────────────────────────────────────
|
|
697
|
+
//
|
|
698
|
+
// When the user has a page-level `notFoundComponent` (`_404.tsx` at the
|
|
699
|
+
// route root without a wrapping `_layout.tsx`), the resolver synthesizes
|
|
700
|
+
// a chain `[DefaultChromeLayout, syntheticLeaf]` so the render pipeline
|
|
701
|
+
// produces 404 HTML wrapped in `<main data-pyreon-default-chrome>`.
|
|
702
|
+
//
|
|
703
|
+
// These tests import `./components` so the setter call at the bottom of
|
|
704
|
+
// components.tsx runs and registers `DefaultChromeLayout` with match.ts.
|
|
705
|
+
// Without that import, `_defaultChromeLayout` would be null and the
|
|
706
|
+
// fallback returns null (graceful degradation to the standalone-render
|
|
707
|
+
// path). The import happens at the top of the test file via the
|
|
708
|
+
// top-level `import` chain — describe block doesn't need to do anything.
|
|
709
|
+
describe('layout-less app fallback (PR B)', () => {
|
|
710
|
+
it('synthesizes a [DefaultChromeLayout, syntheticLeaf] chain when only a page record has notFoundComponent', () => {
|
|
711
|
+
const Index = () => null
|
|
712
|
+
const NotFound = () => null
|
|
713
|
+
const routes: RouteRecord[] = [
|
|
714
|
+
{ path: '/', component: Index, notFoundComponent: NotFound },
|
|
715
|
+
]
|
|
716
|
+
const r = resolveRoute('/missing', routes)
|
|
717
|
+
expect(r.isNotFound).toBe(true)
|
|
718
|
+
// Chain shape: [synthetic chrome layout, synthetic leaf]
|
|
719
|
+
expect(r.matched).toHaveLength(2)
|
|
720
|
+
// First entry is the synthetic chrome layout (with the
|
|
721
|
+
// page's `fullPath` carried for downstream identification).
|
|
722
|
+
expect(r.matched[0]?.path).toBe('/')
|
|
723
|
+
expect(typeof r.matched[0]?.component).toBe('function')
|
|
724
|
+
// Second entry is the synthetic leaf with the user's notFoundComponent.
|
|
725
|
+
expect(r.matched[1]?.component).toBe(NotFound)
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it('the synthetic chrome layout wraps the leaf in <main data-pyreon-default-chrome>', () => {
|
|
729
|
+
// Render the chain through the actual default chrome component to
|
|
730
|
+
// confirm the `<main>` wrapper materializes. The component reads
|
|
731
|
+
// RouterContext to render its inner RouterView, so we need a
|
|
732
|
+
// minimal harness — easiest path is to verify it's the DefaultChromeLayout
|
|
733
|
+
// we exported from components.tsx (identity check).
|
|
734
|
+
const NotFound = () => null
|
|
735
|
+
const routes: RouteRecord[] = [
|
|
736
|
+
{ path: '/', component: () => null, notFoundComponent: NotFound },
|
|
737
|
+
]
|
|
738
|
+
const r = resolveRoute('/missing', routes)
|
|
739
|
+
// Identity-check: the synthetic layout's component IS the registered
|
|
740
|
+
// DefaultChromeLayout. Avoids re-rendering — the runtime render path
|
|
741
|
+
// is covered by the verify-modes / e2e cells.
|
|
742
|
+
expect(r.matched[0]?.component).toBe(DefaultChromeLayout)
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
it('layout-with-notFoundComponent still wins over a page-level one (same urlPath)', () => {
|
|
746
|
+
// Both layout AND page have notFoundComponent. The layout-first
|
|
747
|
+
// logic from PR L5 still applies — page-level is ONLY the fallback.
|
|
748
|
+
const PageNotFound = () => null
|
|
749
|
+
const LayoutNotFound = () => null
|
|
750
|
+
const routes: RouteRecord[] = [
|
|
751
|
+
{
|
|
752
|
+
path: '/',
|
|
753
|
+
component: () => null,
|
|
754
|
+
notFoundComponent: LayoutNotFound,
|
|
755
|
+
children: [
|
|
756
|
+
{ path: '/page', component: () => null, notFoundComponent: PageNotFound },
|
|
757
|
+
],
|
|
758
|
+
},
|
|
759
|
+
]
|
|
760
|
+
const r = resolveRoute('/missing', routes)
|
|
761
|
+
expect(r.isNotFound).toBe(true)
|
|
762
|
+
// Should pick the layout, not the page — layout has children so
|
|
763
|
+
// the layout pass matches and wins.
|
|
764
|
+
const leaf = r.matched[r.matched.length - 1]
|
|
765
|
+
expect(leaf?.component).toBe(LayoutNotFound)
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
it('does NOT wrap when there is a wildcard catch-all (wildcard always wins)', () => {
|
|
769
|
+
// The wildcard route matches the URL directly, so the fallback never
|
|
770
|
+
// fires. Same precedence as the existing wildcard test above.
|
|
771
|
+
const Catchall = () => null
|
|
772
|
+
const NotFound = () => null
|
|
773
|
+
const routes: RouteRecord[] = [
|
|
774
|
+
{ path: '/', component: () => null, notFoundComponent: NotFound },
|
|
775
|
+
{ path: '(.*)', component: Catchall },
|
|
776
|
+
]
|
|
777
|
+
const r = resolveRoute('/missing', routes)
|
|
778
|
+
expect(r.isNotFound).toBeUndefined()
|
|
779
|
+
expect(r.matched[0]?.component).toBe(Catchall)
|
|
780
|
+
})
|
|
781
|
+
})
|
|
782
|
+
})
|
package/src/types.ts
CHANGED
|
@@ -78,6 +78,15 @@ export interface ResolvedRoute<
|
|
|
78
78
|
search?: Record<string, unknown> | undefined
|
|
79
79
|
/** Middleware data attached during navigation (populated by middleware chain) */
|
|
80
80
|
_middlewareData?: Record<string, unknown> | undefined
|
|
81
|
+
/**
|
|
82
|
+
* `true` when the URL didn't match any route AND a parent record's
|
|
83
|
+
* `notFoundComponent` was used as a synthetic fallback leaf. The
|
|
84
|
+
* `matched` chain ends with a synthetic `RouteRecord` rendering the
|
|
85
|
+
* not-found component INSIDE all its ancestor layouts — so 404 pages
|
|
86
|
+
* carry the same chrome (headers, footers, navigation) as regular
|
|
87
|
+
* pages. SSR handlers read this to set HTTP status 404.
|
|
88
|
+
*/
|
|
89
|
+
isNotFound?: boolean
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
// ─── Lazy component ───────────────────────────────────────────────────────────
|
|
@@ -246,6 +255,14 @@ export interface RouteRecord<TPath extends string = string> {
|
|
|
246
255
|
gcTime?: number
|
|
247
256
|
/** Component rendered when this route's loader throws an error */
|
|
248
257
|
errorComponent?: ComponentFn
|
|
258
|
+
/**
|
|
259
|
+
* Component rendered when a URL doesn't match any descendant route under
|
|
260
|
+
* this record's path. Acts as a "404 within layout" — the matched chain
|
|
261
|
+
* is `[...ancestors, this, syntheticLeaf]` so the not-found component
|
|
262
|
+
* renders INSIDE this layout's chrome. fs-router attaches this when it
|
|
263
|
+
* detects a `_404.tsx` / `_not-found.tsx` file under this layout.
|
|
264
|
+
*/
|
|
265
|
+
notFoundComponent?: ComponentFn
|
|
249
266
|
/**
|
|
250
267
|
* Component rendered while this route's loader is running.
|
|
251
268
|
* Only shown after `pendingMs` (default: 0) to avoid flash on fast loads.
|
|
@@ -398,7 +415,11 @@ export interface Router<TNames extends string = string> {
|
|
|
398
415
|
* separately when creating the router (`createRouter({ url, ... })`) or
|
|
399
416
|
* call this for the same `url` you initialised the router with.
|
|
400
417
|
*/
|
|
401
|
-
preload(
|
|
418
|
+
preload(
|
|
419
|
+
path: string,
|
|
420
|
+
request?: Request,
|
|
421
|
+
options?: { skipLoaders?: boolean },
|
|
422
|
+
): Promise<void>
|
|
402
423
|
/**
|
|
403
424
|
* Invalidate cached loader data. Forces loaders to re-run on next navigation.
|
|
404
425
|
* - No args: invalidate ALL cached loader data
|