@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.
@@ -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":"007eb862-1","name":"loader.ts"},{"uid":"007eb862-3","name":"match.ts"},{"uid":"007eb862-5","name":"scroll.ts"},{"uid":"007eb862-7","name":"types.ts"},{"uid":"007eb862-9","name":"router.ts"},{"uid":"007eb862-11","name":"components.tsx"},{"uid":"007eb862-13","name":"not-found.ts"},{"uid":"007eb862-15","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"007eb862-1":{"renderedLength":2974,"gzipLength":1310,"brotliLength":0,"metaUid":"007eb862-0"},"007eb862-3":{"renderedLength":12965,"gzipLength":3914,"brotliLength":0,"metaUid":"007eb862-2"},"007eb862-5":{"renderedLength":2194,"gzipLength":899,"brotliLength":0,"metaUid":"007eb862-4"},"007eb862-7":{"renderedLength":385,"gzipLength":246,"brotliLength":0,"metaUid":"007eb862-6"},"007eb862-9":{"renderedLength":27519,"gzipLength":7633,"brotliLength":0,"metaUid":"007eb862-8"},"007eb862-11":{"renderedLength":9851,"gzipLength":3302,"brotliLength":0,"metaUid":"007eb862-10"},"007eb862-13":{"renderedLength":1315,"gzipLength":682,"brotliLength":0,"metaUid":"007eb862-12"},"007eb862-15":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"007eb862-14"}},"nodeMetas":{"007eb862-0":{"id":"/src/loader.ts","moduleParts":{"index.js":"007eb862-1"},"imported":[{"uid":"007eb862-16"}],"importedBy":[{"uid":"007eb862-14"},{"uid":"007eb862-10"}]},"007eb862-2":{"id":"/src/match.ts","moduleParts":{"index.js":"007eb862-3"},"imported":[],"importedBy":[{"uid":"007eb862-14"},{"uid":"007eb862-8"}]},"007eb862-4":{"id":"/src/scroll.ts","moduleParts":{"index.js":"007eb862-5"},"imported":[],"importedBy":[{"uid":"007eb862-8"}]},"007eb862-6":{"id":"/src/types.ts","moduleParts":{"index.js":"007eb862-7"},"imported":[],"importedBy":[{"uid":"007eb862-14"},{"uid":"007eb862-8"}]},"007eb862-8":{"id":"/src/router.ts","moduleParts":{"index.js":"007eb862-9"},"imported":[{"uid":"007eb862-16"},{"uid":"007eb862-17"},{"uid":"007eb862-2"},{"uid":"007eb862-4"},{"uid":"007eb862-6"}],"importedBy":[{"uid":"007eb862-14"},{"uid":"007eb862-10"}]},"007eb862-10":{"id":"/src/components.tsx","moduleParts":{"index.js":"007eb862-11"},"imported":[{"uid":"007eb862-16"},{"uid":"007eb862-17"},{"uid":"007eb862-0"},{"uid":"007eb862-8"}],"importedBy":[{"uid":"007eb862-14"}]},"007eb862-12":{"id":"/src/not-found.ts","moduleParts":{"index.js":"007eb862-13"},"imported":[{"uid":"007eb862-16"}],"importedBy":[{"uid":"007eb862-14"}]},"007eb862-14":{"id":"/src/index.ts","moduleParts":{"index.js":"007eb862-15"},"imported":[{"uid":"007eb862-10"},{"uid":"007eb862-12"},{"uid":"007eb862-0"},{"uid":"007eb862-2"},{"uid":"007eb862-8"},{"uid":"007eb862-6"}],"importedBy":[],"isEntry":true},"007eb862-16":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"007eb862-10"},{"uid":"007eb862-12"},{"uid":"007eb862-0"},{"uid":"007eb862-8"}]},"007eb862-17":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"007eb862-10"},{"uid":"007eb862-8"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
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 = import.meta.env?.DEV === true;
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 joined = [...parentSegments, ...c.segments];
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__ = import.meta.env?.DEV === true;
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 true;
1203
+ return { action: "continue" };
996
1204
  }
997
- if (ac.signal.aborted) return true;
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 false;
1212
+ if (router._onError(result.reason, to) === false) return { action: "cancel" };
1000
1213
  }
1001
1214
  router._loaderData.set(record, void 0);
1002
- return true;
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
- router._loaderCache.set(key, {
1057
- data,
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, promise);
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 false;
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
- if (!processLoaderResult(result, record, ac, to)) return false;
1314
+ const outcome = processLoaderResult(result, record, ac, to);
1315
+ if (outcome.action !== "continue") return outcome;
1082
1316
  }
1083
- return true;
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
- const key = getCacheKey(r, loaderCtx);
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 true;
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
- if (!await runBlockingLoaders(blocking, to, gen, ac)) return false;
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 true;
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
- if (!await runLoaders(to, gen, ac)) {
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 child = () => {
1458
- router._loadingSignal();
1692
+ const depthEntry = computed(() => {
1459
1693
  const route = router.currentRoute();
1460
- if (route.matched.length === 0) return null;
1461
- const record = route.matched[depth];
1462
- if (!record) return null;
1463
- const cached = router._componentCache.get(record);
1464
- if (cached) return renderWithLoader(router, record, cached, route);
1465
- const raw = record.component;
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, record, raw);
1468
- return renderWithLoader(router, record, raw, route);
1717
+ cacheSet(router, rec, raw);
1718
+ return {
1719
+ rec,
1720
+ comp: raw,
1721
+ errored: false,
1722
+ route
1723
+ };
1469
1724
  }
1470
- return renderLazyRoute(router, record, raw);
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: activeClass,
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