@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
|
@@ -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
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { ErrorBoundary, createContext, createRef, h, onUnmount, provide, useContext } from "@pyreon/core";
|
|
1
|
+
import { ErrorBoundary, createContext, createRef, cx, h, nativeCompat, onUnmount, provide, useContext } from "@pyreon/core";
|
|
2
2
|
import { computed, signal } from "@pyreon/reactivity";
|
|
3
3
|
|
|
4
4
|
//#region src/loader.ts
|
|
5
|
-
const __DEV__$1 =
|
|
5
|
+
const __DEV__$1 = process.env.NODE_ENV !== "production";
|
|
6
6
|
const _countSink$1 = globalThis;
|
|
7
7
|
/**
|
|
8
8
|
* Context frame that holds the loader data for the currently rendered route record.
|
|
@@ -28,12 +28,18 @@ function useLoaderData() {
|
|
|
28
28
|
* SSR helper: pre-run all loaders for the given path before rendering.
|
|
29
29
|
* Call this before `renderToString` so route components can read data via `useLoaderData()`.
|
|
30
30
|
*
|
|
31
|
+
* The optional `request` is forwarded to each loader's `LoaderContext.request`,
|
|
32
|
+
* letting server-side loaders read cookies / auth headers and `throw redirect()`
|
|
33
|
+
* before the layout renders. A loader that throws `redirect()` propagates the
|
|
34
|
+
* thrown error here — the SSR handler's `catch` converts it into a 302/307
|
|
35
|
+
* `Location:` Response.
|
|
36
|
+
*
|
|
31
37
|
* @example
|
|
32
38
|
* const router = createRouter({ routes, url: req.url })
|
|
33
|
-
* await prefetchLoaderData(router, req.url)
|
|
39
|
+
* await prefetchLoaderData(router, req.url, request)
|
|
34
40
|
* const html = await renderToString(h(App, { router }))
|
|
35
41
|
*/
|
|
36
|
-
async function prefetchLoaderData(router, path) {
|
|
42
|
+
async function prefetchLoaderData(router, path, request) {
|
|
37
43
|
if (__DEV__$1) _countSink$1.__pyreon_count__?.("router.prefetch");
|
|
38
44
|
const route = router._resolve(path);
|
|
39
45
|
const ac = new AbortController();
|
|
@@ -41,7 +47,8 @@ async function prefetchLoaderData(router, path) {
|
|
|
41
47
|
const data = await r.loader?.({
|
|
42
48
|
params: route.params,
|
|
43
49
|
query: route.query,
|
|
44
|
-
signal: ac.signal
|
|
50
|
+
signal: ac.signal,
|
|
51
|
+
...request ? { request } : {}
|
|
45
52
|
});
|
|
46
53
|
router._loaderData.set(r, data);
|
|
47
54
|
}));
|
|
@@ -63,6 +70,51 @@ function serializeLoaderData(router) {
|
|
|
63
70
|
return result;
|
|
64
71
|
}
|
|
65
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
|
+
/**
|
|
66
118
|
* Hydrate loader data from a serialized object (e.g. `window.__PYREON_LOADER_DATA__`).
|
|
67
119
|
* Populates the router's internal `_loaderData` map so the initial render uses
|
|
68
120
|
* server-fetched data without re-running loaders on the client.
|
|
@@ -83,6 +135,18 @@ function hydrateLoaderData(router, serialized) {
|
|
|
83
135
|
|
|
84
136
|
//#endregion
|
|
85
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
|
+
}
|
|
86
150
|
/**
|
|
87
151
|
* Parse a query string into key-value pairs. Duplicate keys are overwritten
|
|
88
152
|
* (last value wins). Use `parseQueryMulti` to preserve duplicates as arrays.
|
|
@@ -275,7 +339,8 @@ function flattenOne(result, c, parentSegments, chain, meta) {
|
|
|
275
339
|
if (c.children && c.children.length > 0) flattenWalk(result, c.children, parentSegments, chain, meta);
|
|
276
340
|
return;
|
|
277
341
|
}
|
|
278
|
-
const
|
|
342
|
+
const childPath = c.route.path;
|
|
343
|
+
const joined = typeof childPath === "string" && childPath.startsWith("/") ? c.segments : [...parentSegments, ...c.segments];
|
|
279
344
|
if (c.children && c.children.length > 0) flattenWalk(result, c.children, joined, chain, meta);
|
|
280
345
|
result.push(makeFlatEntry(joined, chain, meta, false));
|
|
281
346
|
}
|
|
@@ -446,6 +511,17 @@ function resolveRoute(rawPath, routes) {
|
|
|
446
511
|
meta: w.meta,
|
|
447
512
|
search: runValidateSearch(w.matchedChain, query)
|
|
448
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
|
+
};
|
|
449
525
|
return {
|
|
450
526
|
path: cleanPath,
|
|
451
527
|
params: {},
|
|
@@ -456,6 +532,86 @@ function resolveRoute(rawPath, routes) {
|
|
|
456
532
|
search: {}
|
|
457
533
|
};
|
|
458
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
|
+
}
|
|
459
615
|
/** Run validateSearch from the deepest matched route that has one. */
|
|
460
616
|
function runValidateSearch(matched, query) {
|
|
461
617
|
for (let i = matched.length - 1; i >= 0; i--) {
|
|
@@ -513,6 +669,58 @@ function buildNameIndex(routes) {
|
|
|
513
669
|
return index;
|
|
514
670
|
}
|
|
515
671
|
|
|
672
|
+
//#endregion
|
|
673
|
+
//#region src/redirect.ts
|
|
674
|
+
const REDIRECT = Symbol.for("pyreon.redirect");
|
|
675
|
+
/**
|
|
676
|
+
* Throw inside a route loader to redirect the navigation server-side
|
|
677
|
+
* (during SSR returns a 302/307 `Location:` response) and client-side
|
|
678
|
+
* (during CSR triggers `router.replace()` before the layout renders).
|
|
679
|
+
*
|
|
680
|
+
* The auth-gate use case: replaces the fragile `onMount + router.push()`
|
|
681
|
+
* workaround. `onMount` doesn't fire reliably under nested-layout dev SSR +
|
|
682
|
+
* hydration — so the layout renders briefly before the push happens, leaking
|
|
683
|
+
* authenticated UI to unauthenticated users. `redirect()` runs in the loader
|
|
684
|
+
* BEFORE the layout's component is invoked, so the unauthenticated UI never
|
|
685
|
+
* mounts in the first place.
|
|
686
|
+
*
|
|
687
|
+
* @example
|
|
688
|
+
* ```ts
|
|
689
|
+
* // src/routes/app/_layout.tsx
|
|
690
|
+
* export const loader = async ({ request }) => {
|
|
691
|
+
* const session = await getSession(request)
|
|
692
|
+
* if (!session) redirect('/login')
|
|
693
|
+
* return { user: session.user }
|
|
694
|
+
* }
|
|
695
|
+
* ```
|
|
696
|
+
*
|
|
697
|
+
* @param url - Target URL (typically a path like `/login` or absolute URL for cross-origin).
|
|
698
|
+
* @param status - HTTP redirect status. Default `307` (Temporary Redirect, method-preserving).
|
|
699
|
+
* Use `301`/`308` for permanent moves, `302`/`303` to force GET on the target.
|
|
700
|
+
*/
|
|
701
|
+
function redirect(url, status = 307) {
|
|
702
|
+
const err = /* @__PURE__ */ new Error(`Redirect to ${url}`);
|
|
703
|
+
err[REDIRECT] = {
|
|
704
|
+
url,
|
|
705
|
+
status
|
|
706
|
+
};
|
|
707
|
+
throw err;
|
|
708
|
+
}
|
|
709
|
+
/** Check if an error is a RedirectError thrown by `redirect()`. */
|
|
710
|
+
function isRedirectError(err) {
|
|
711
|
+
return typeof err === "object" && err !== null && typeof err[REDIRECT] === "object";
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Extract the redirect URL and status from a thrown RedirectError. Returns
|
|
715
|
+
* `null` if `err` isn't a RedirectError. Used by the router's loader-runner
|
|
716
|
+
* (CSR) and the SSR handler to convert the thrown error into the right kind
|
|
717
|
+
* of response (a `router.replace()` call or a `302`/`307` Response).
|
|
718
|
+
*/
|
|
719
|
+
function getRedirectInfo(err) {
|
|
720
|
+
if (!isRedirectError(err)) return null;
|
|
721
|
+
return err[REDIRECT] ?? null;
|
|
722
|
+
}
|
|
723
|
+
|
|
516
724
|
//#endregion
|
|
517
725
|
//#region src/scroll.ts
|
|
518
726
|
/**
|
|
@@ -611,7 +819,7 @@ function isLazy(c) {
|
|
|
611
819
|
//#endregion
|
|
612
820
|
//#region src/router.ts
|
|
613
821
|
const _isBrowser = typeof window !== "undefined";
|
|
614
|
-
const __DEV__ =
|
|
822
|
+
const __DEV__ = process.env.NODE_ENV !== "production";
|
|
615
823
|
const _countSink = globalThis;
|
|
616
824
|
const RouterContext = createContext(null);
|
|
617
825
|
let _activeRouter = null;
|
|
@@ -992,14 +1200,19 @@ function createRouter(options) {
|
|
|
992
1200
|
function processLoaderResult(result, record, ac, to) {
|
|
993
1201
|
if (result.status === "fulfilled") {
|
|
994
1202
|
router._loaderData.set(record, result.value);
|
|
995
|
-
return
|
|
1203
|
+
return { action: "continue" };
|
|
996
1204
|
}
|
|
997
|
-
if (ac.signal.aborted) return
|
|
1205
|
+
if (ac.signal.aborted) return { action: "continue" };
|
|
1206
|
+
const info = getRedirectInfo(result.reason);
|
|
1207
|
+
if (info) return {
|
|
1208
|
+
action: "redirect",
|
|
1209
|
+
target: info.url
|
|
1210
|
+
};
|
|
998
1211
|
if (router._onError) {
|
|
999
|
-
if (router._onError(result.reason, to) === false) return
|
|
1212
|
+
if (router._onError(result.reason, to) === false) return { action: "cancel" };
|
|
1000
1213
|
}
|
|
1001
1214
|
router._loaderData.set(record, void 0);
|
|
1002
|
-
return
|
|
1215
|
+
return { action: "continue" };
|
|
1003
1216
|
}
|
|
1004
1217
|
function syncBrowserUrl(path, replace) {
|
|
1005
1218
|
if (!_isBrowser) return;
|
|
@@ -1034,6 +1247,26 @@ function createRouter(options) {
|
|
|
1034
1247
|
return Date.now() - entry.timestamp < gcTime;
|
|
1035
1248
|
}
|
|
1036
1249
|
/**
|
|
1250
|
+
* Bounded set into `_loaderCache`: evicts the oldest entry (insertion-order
|
|
1251
|
+
* FIFO) when the cap is exceeded. The `gcTime` TTL handles staleness, but
|
|
1252
|
+
* without a size cap a long-running SPA navigating across many distinct
|
|
1253
|
+
* loader keys (e.g. `/posts/:id` with hundreds of unique IDs) would
|
|
1254
|
+
* accumulate cache entries indefinitely until manual `invalidateLoader()`
|
|
1255
|
+
* — `_maxCacheSize` was wired through from `RouterOptions.maxCacheSize`
|
|
1256
|
+
* (default 100) but the loader cache write paths never read it. Mirrors
|
|
1257
|
+
* the same pattern used for `_componentCache` in `components.tsx`.
|
|
1258
|
+
*/
|
|
1259
|
+
function loaderCacheSet(key, data) {
|
|
1260
|
+
router._loaderCache.set(key, {
|
|
1261
|
+
data,
|
|
1262
|
+
timestamp: Date.now()
|
|
1263
|
+
});
|
|
1264
|
+
if (router._loaderCache.size > router._maxCacheSize) {
|
|
1265
|
+
const oldest = router._loaderCache.keys().next().value;
|
|
1266
|
+
if (oldest !== void 0) router._loaderCache.delete(oldest);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1037
1270
|
* Execute a loader with cache + dedup:
|
|
1038
1271
|
* 1. Cache hit + fresh → return cached data (skip loader entirely)
|
|
1039
1272
|
* 2. In-flight for same key → dedup (return existing promise)
|
|
@@ -1050,20 +1283,20 @@ function createRouter(options) {
|
|
|
1050
1283
|
}
|
|
1051
1284
|
}
|
|
1052
1285
|
const inflight = router._loaderInflight.get(key);
|
|
1053
|
-
if (inflight) return inflight;
|
|
1286
|
+
if (inflight && !inflight.signal.aborted) return inflight.promise;
|
|
1054
1287
|
if (__DEV__) _countSink.__pyreon_count__?.("router.loaderRun");
|
|
1055
|
-
const promise = record.loader(loaderCtx).then((data) => {
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
timestamp: Date.now()
|
|
1059
|
-
});
|
|
1060
|
-
router._loaderInflight.delete(key);
|
|
1288
|
+
const promise = Promise.resolve().then(() => record.loader(loaderCtx)).then((data) => {
|
|
1289
|
+
loaderCacheSet(key, data);
|
|
1290
|
+
if (router._loaderInflight.get(key)?.promise === promise) router._loaderInflight.delete(key);
|
|
1061
1291
|
return data;
|
|
1062
1292
|
}).catch((err) => {
|
|
1063
|
-
router._loaderInflight.delete(key);
|
|
1293
|
+
if (router._loaderInflight.get(key)?.promise === promise) router._loaderInflight.delete(key);
|
|
1064
1294
|
throw err;
|
|
1065
1295
|
});
|
|
1066
|
-
router._loaderInflight.set(key,
|
|
1296
|
+
router._loaderInflight.set(key, {
|
|
1297
|
+
promise,
|
|
1298
|
+
signal: loaderCtx.signal
|
|
1299
|
+
});
|
|
1067
1300
|
return promise;
|
|
1068
1301
|
}
|
|
1069
1302
|
async function runBlockingLoaders(records, to, gen, ac) {
|
|
@@ -1073,14 +1306,15 @@ function createRouter(options) {
|
|
|
1073
1306
|
signal: ac.signal
|
|
1074
1307
|
};
|
|
1075
1308
|
const results = await Promise.allSettled(records.map((r) => executeLoader(r, loaderCtx)));
|
|
1076
|
-
if (gen !== _navGen) return
|
|
1309
|
+
if (gen !== _navGen) return { action: "cancel" };
|
|
1077
1310
|
for (let i = 0; i < records.length; i++) {
|
|
1078
1311
|
const result = results[i];
|
|
1079
1312
|
const record = records[i];
|
|
1080
1313
|
if (!result || !record) continue;
|
|
1081
|
-
|
|
1314
|
+
const outcome = processLoaderResult(result, record, ac, to);
|
|
1315
|
+
if (outcome.action !== "continue") return outcome;
|
|
1082
1316
|
}
|
|
1083
|
-
return
|
|
1317
|
+
return { action: "continue" };
|
|
1084
1318
|
}
|
|
1085
1319
|
/** Fire-and-forget background revalidation for stale-while-revalidate routes. */
|
|
1086
1320
|
function revalidateSwrLoaders(records, to, ac) {
|
|
@@ -1094,11 +1328,7 @@ function createRouter(options) {
|
|
|
1094
1328
|
r.loader(loaderCtx).then((data) => {
|
|
1095
1329
|
if (!ac.signal.aborted) {
|
|
1096
1330
|
router._loaderData.set(r, data);
|
|
1097
|
-
|
|
1098
|
-
router._loaderCache.set(key, {
|
|
1099
|
-
data,
|
|
1100
|
-
timestamp: Date.now()
|
|
1101
|
-
});
|
|
1331
|
+
loaderCacheSet(getCacheKey(r, loaderCtx), data);
|
|
1102
1332
|
loadingSignal.update((n) => n + 1);
|
|
1103
1333
|
loadingSignal.update((n) => n - 1);
|
|
1104
1334
|
}
|
|
@@ -1107,16 +1337,17 @@ function createRouter(options) {
|
|
|
1107
1337
|
}
|
|
1108
1338
|
async function runLoaders(to, gen, ac) {
|
|
1109
1339
|
const loadableRecords = to.matched.filter((r) => r.loader);
|
|
1110
|
-
if (loadableRecords.length === 0) return
|
|
1340
|
+
if (loadableRecords.length === 0) return { action: "continue" };
|
|
1111
1341
|
const blocking = [];
|
|
1112
1342
|
const swr = [];
|
|
1113
1343
|
for (const r of loadableRecords) if (r.staleWhileRevalidate && router._loaderData.has(r)) swr.push(r);
|
|
1114
1344
|
else blocking.push(r);
|
|
1115
1345
|
if (blocking.length > 0) {
|
|
1116
|
-
|
|
1346
|
+
const outcome = await runBlockingLoaders(blocking, to, gen, ac);
|
|
1347
|
+
if (outcome.action !== "continue") return outcome;
|
|
1117
1348
|
}
|
|
1118
1349
|
if (swr.length > 0) revalidateSwrLoaders(swr, to, ac);
|
|
1119
|
-
return
|
|
1350
|
+
return { action: "continue" };
|
|
1120
1351
|
}
|
|
1121
1352
|
async function commitNavigation(path, replace, to, from) {
|
|
1122
1353
|
scrollManager.save(from.path);
|
|
@@ -1211,8 +1442,10 @@ function createRouter(options) {
|
|
|
1211
1442
|
router._abortController?.abort();
|
|
1212
1443
|
const ac = new AbortController();
|
|
1213
1444
|
router._abortController = ac;
|
|
1214
|
-
|
|
1445
|
+
const loaderOutcome = await runLoaders(to, gen, ac);
|
|
1446
|
+
if (loaderOutcome.action !== "continue") {
|
|
1215
1447
|
loadingSignal.update((n) => n - 1);
|
|
1448
|
+
if (loaderOutcome.action === "redirect") return navigate(sanitizePath(loaderOutcome.target), replace, redirectDepth + 1);
|
|
1216
1449
|
return;
|
|
1217
1450
|
}
|
|
1218
1451
|
await commitNavigation(path, replace, to, from);
|
|
@@ -1280,7 +1513,7 @@ function createRouter(options) {
|
|
|
1280
1513
|
isReady() {
|
|
1281
1514
|
return router._readyPromise;
|
|
1282
1515
|
},
|
|
1283
|
-
async preload(path) {
|
|
1516
|
+
async preload(path, request, options) {
|
|
1284
1517
|
const resolved = resolveRoute(path, routes);
|
|
1285
1518
|
await Promise.all(resolved.matched.map(async (record) => {
|
|
1286
1519
|
if (componentCache.has(record)) return;
|
|
@@ -1293,13 +1526,15 @@ function createRouter(options) {
|
|
|
1293
1526
|
const comp = typeof mod === "function" ? mod : mod.default;
|
|
1294
1527
|
componentCache.set(record, comp);
|
|
1295
1528
|
}));
|
|
1529
|
+
if (options?.skipLoaders) return;
|
|
1296
1530
|
const ac = new AbortController();
|
|
1297
1531
|
await Promise.all(resolved.matched.filter((r) => r.loader).map(async (r) => {
|
|
1298
|
-
const data = await r.loader
|
|
1532
|
+
const data = await Promise.resolve().then(() => r.loader({
|
|
1299
1533
|
params: resolved.params,
|
|
1300
1534
|
query: resolved.query,
|
|
1301
|
-
signal: ac.signal
|
|
1302
|
-
|
|
1535
|
+
signal: ac.signal,
|
|
1536
|
+
...request ? { request } : {}
|
|
1537
|
+
}));
|
|
1303
1538
|
router._loaderData.set(r, data);
|
|
1304
1539
|
}));
|
|
1305
1540
|
},
|
|
@@ -1454,20 +1689,51 @@ const RouterView = (props) => {
|
|
|
1454
1689
|
onUnmount(() => {
|
|
1455
1690
|
router._viewDepth--;
|
|
1456
1691
|
});
|
|
1457
|
-
const
|
|
1458
|
-
router._loadingSignal();
|
|
1692
|
+
const depthEntry = computed(() => {
|
|
1459
1693
|
const route = router.currentRoute();
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1694
|
+
const rec = route.matched[depth] ?? null;
|
|
1695
|
+
if (!rec) return {
|
|
1696
|
+
rec: null,
|
|
1697
|
+
comp: null,
|
|
1698
|
+
errored: false,
|
|
1699
|
+
route
|
|
1700
|
+
};
|
|
1701
|
+
router._loadingSignal();
|
|
1702
|
+
if (router._erroredChunks.has(rec)) return {
|
|
1703
|
+
rec,
|
|
1704
|
+
comp: null,
|
|
1705
|
+
errored: true,
|
|
1706
|
+
route
|
|
1707
|
+
};
|
|
1708
|
+
const cached = router._componentCache.get(rec);
|
|
1709
|
+
if (cached) return {
|
|
1710
|
+
rec,
|
|
1711
|
+
comp: cached,
|
|
1712
|
+
errored: false,
|
|
1713
|
+
route
|
|
1714
|
+
};
|
|
1715
|
+
const raw = rec.component;
|
|
1466
1716
|
if (!isLazy(raw)) {
|
|
1467
|
-
cacheSet(router,
|
|
1468
|
-
return
|
|
1717
|
+
cacheSet(router, rec, raw);
|
|
1718
|
+
return {
|
|
1719
|
+
rec,
|
|
1720
|
+
comp: raw,
|
|
1721
|
+
errored: false,
|
|
1722
|
+
route
|
|
1723
|
+
};
|
|
1469
1724
|
}
|
|
1470
|
-
return
|
|
1725
|
+
return {
|
|
1726
|
+
rec,
|
|
1727
|
+
comp: null,
|
|
1728
|
+
errored: false,
|
|
1729
|
+
route
|
|
1730
|
+
};
|
|
1731
|
+
}, { equals: (a, b) => a.rec === b.rec && a.comp === b.comp && a.errored === b.errored && a.route === b.route });
|
|
1732
|
+
const child = () => {
|
|
1733
|
+
const { rec, comp, route } = depthEntry();
|
|
1734
|
+
if (!rec) return null;
|
|
1735
|
+
if (comp) return renderWithLoader(router, rec, comp, route);
|
|
1736
|
+
return renderLazyRoute(router, rec, rec.component);
|
|
1471
1737
|
};
|
|
1472
1738
|
return h("div", { "data-pyreon-router-view": true }, child);
|
|
1473
1739
|
};
|
|
@@ -1491,7 +1757,7 @@ const RouterLink = (props) => {
|
|
|
1491
1757
|
if (prefetchMode === "intent") triggerPrefetch();
|
|
1492
1758
|
};
|
|
1493
1759
|
const inst = router;
|
|
1494
|
-
const href = inst?.mode === "history" ? `${inst._base}${props.to}` : `#${props.to}`;
|
|
1760
|
+
const href = () => inst?.mode === "history" ? `${inst._base}${props.to}` : `#${props.to}`;
|
|
1495
1761
|
const isExactMatch = () => {
|
|
1496
1762
|
if (!router) return false;
|
|
1497
1763
|
const target = props.to;
|
|
@@ -1525,12 +1791,15 @@ const RouterLink = (props) => {
|
|
|
1525
1791
|
});
|
|
1526
1792
|
onUnmount(() => observer.disconnect());
|
|
1527
1793
|
}
|
|
1528
|
-
const { to: _to, replace: _replace, activeClass: _ac, exactActiveClass: _eac, exact: _exact, prefetch: _prefetch, children, ...rest } = props;
|
|
1794
|
+
const { to: _to, replace: _replace, activeClass: _ac, exactActiveClass: _eac, exact: _exact, prefetch: _prefetch, class: userClass, children, ...rest } = props;
|
|
1795
|
+
const mergedClass = () => {
|
|
1796
|
+
return cx([typeof userClass === "function" ? userClass() : userClass, activeClass()]);
|
|
1797
|
+
};
|
|
1529
1798
|
return h("a", {
|
|
1530
1799
|
...rest,
|
|
1531
1800
|
ref,
|
|
1532
1801
|
href,
|
|
1533
|
-
class:
|
|
1802
|
+
class: mergedClass,
|
|
1534
1803
|
"aria-current": ariaCurrent,
|
|
1535
1804
|
onClick: handleClick,
|
|
1536
1805
|
onMouseEnter: handleMouseEnter,
|
|
@@ -1712,6 +1981,12 @@ function isStaleChunk(err) {
|
|
|
1712
1981
|
if (err instanceof SyntaxError) return true;
|
|
1713
1982
|
return false;
|
|
1714
1983
|
}
|
|
1984
|
+
nativeCompat(RouterProvider);
|
|
1985
|
+
nativeCompat(RouterView);
|
|
1986
|
+
nativeCompat(RouterLink);
|
|
1987
|
+
const DefaultChromeLayout = () => h("main", { "data-pyreon-default-chrome": "" }, h(RouterView, null));
|
|
1988
|
+
nativeCompat(DefaultChromeLayout);
|
|
1989
|
+
_setDefaultChromeLayout(DefaultChromeLayout);
|
|
1715
1990
|
|
|
1716
1991
|
//#endregion
|
|
1717
1992
|
//#region src/not-found.ts
|
|
@@ -1764,5 +2039,5 @@ const NotFoundBoundary = (props) => {
|
|
|
1764
2039
|
};
|
|
1765
2040
|
|
|
1766
2041
|
//#endregion
|
|
1767
|
-
export { NotFoundBoundary, RouterContext, RouterLink, RouterProvider, RouterView, 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 };
|
|
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 };
|
|
1768
2043
|
//# sourceMappingURL=index.js.map
|