@pyreon/router 0.13.1 → 0.15.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/index.js CHANGED
@@ -1,7 +1,9 @@
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 = process.env.NODE_ENV !== "production";
6
+ const _countSink$1 = globalThis;
5
7
  /**
6
8
  * Context frame that holds the loader data for the currently rendered route record.
7
9
  * Pushed by RouterView's withLoaderData wrapper before invoking the route component.
@@ -26,19 +28,27 @@ function useLoaderData() {
26
28
  * SSR helper: pre-run all loaders for the given path before rendering.
27
29
  * Call this before `renderToString` so route components can read data via `useLoaderData()`.
28
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
+ *
29
37
  * @example
30
38
  * const router = createRouter({ routes, url: req.url })
31
- * await prefetchLoaderData(router, req.url)
39
+ * await prefetchLoaderData(router, req.url, request)
32
40
  * const html = await renderToString(h(App, { router }))
33
41
  */
34
- async function prefetchLoaderData(router, path) {
42
+ async function prefetchLoaderData(router, path, request) {
43
+ if (__DEV__$1) _countSink$1.__pyreon_count__?.("router.prefetch");
35
44
  const route = router._resolve(path);
36
45
  const ac = new AbortController();
37
46
  await Promise.all(route.matched.filter((r) => r.loader).map(async (r) => {
38
47
  const data = await r.loader?.({
39
48
  params: route.params,
40
49
  query: route.query,
41
- signal: ac.signal
50
+ signal: ac.signal,
51
+ ...request ? { request } : {}
42
52
  });
43
53
  router._loaderData.set(r, data);
44
54
  }));
@@ -84,17 +94,21 @@ function hydrateLoaderData(router, serialized) {
84
94
  * Parse a query string into key-value pairs. Duplicate keys are overwritten
85
95
  * (last value wins). Use `parseQueryMulti` to preserve duplicates as arrays.
86
96
  */
97
+ /** Decode a query component: `+` → space (per application/x-www-form-urlencoded), then URI-decode. */
98
+ function decodeQueryComponent(raw) {
99
+ return decodeURIComponent(raw.replace(/\+/g, " "));
100
+ }
87
101
  function parseQuery(qs) {
88
102
  if (!qs) return {};
89
103
  const result = {};
90
104
  for (const part of qs.split("&")) {
91
105
  const eqIdx = part.indexOf("=");
92
106
  if (eqIdx < 0) {
93
- const key = decodeURIComponent(part);
107
+ const key = decodeQueryComponent(part);
94
108
  if (key) result[key] = "";
95
109
  } else {
96
- const key = decodeURIComponent(part.slice(0, eqIdx));
97
- const val = decodeURIComponent(part.slice(eqIdx + 1));
110
+ const key = decodeQueryComponent(part.slice(0, eqIdx));
111
+ const val = decodeQueryComponent(part.slice(eqIdx + 1));
98
112
  if (key) result[key] = val;
99
113
  }
100
114
  }
@@ -115,11 +129,11 @@ function parseQueryMulti(qs) {
115
129
  let key;
116
130
  let val;
117
131
  if (eqIdx < 0) {
118
- key = decodeURIComponent(part);
132
+ key = decodeQueryComponent(part);
119
133
  val = "";
120
134
  } else {
121
- key = decodeURIComponent(part.slice(0, eqIdx));
122
- val = decodeURIComponent(part.slice(eqIdx + 1));
135
+ key = decodeQueryComponent(part.slice(0, eqIdx));
136
+ val = decodeQueryComponent(part.slice(eqIdx + 1));
123
137
  }
124
138
  if (!key) continue;
125
139
  const existing = result[key];
@@ -268,7 +282,8 @@ function flattenOne(result, c, parentSegments, chain, meta) {
268
282
  if (c.children && c.children.length > 0) flattenWalk(result, c.children, parentSegments, chain, meta);
269
283
  return;
270
284
  }
271
- const joined = [...parentSegments, ...c.segments];
285
+ const childPath = c.route.path;
286
+ const joined = typeof childPath === "string" && childPath.startsWith("/") ? c.segments : [...parentSegments, ...c.segments];
272
287
  if (c.children && c.children.length > 0) flattenWalk(result, c.children, joined, chain, meta);
273
288
  result.push(makeFlatEntry(joined, chain, meta, false));
274
289
  }
@@ -398,7 +413,8 @@ function resolveRoute(rawPath, routes) {
398
413
  query,
399
414
  hash,
400
415
  matched: staticMatch.matchedChain,
401
- meta: staticMatch.meta
416
+ meta: staticMatch.meta,
417
+ search: runValidateSearch(staticMatch.matchedChain, query)
402
418
  };
403
419
  const pathParts = splitPath(cleanPath);
404
420
  const pathLen = pathParts.length;
@@ -413,7 +429,8 @@ function resolveRoute(rawPath, routes) {
413
429
  query,
414
430
  hash,
415
431
  matched: match.matched,
416
- meta: mergeMeta(match.matched)
432
+ meta: mergeMeta(match.matched),
433
+ search: runValidateSearch(match.matched, query)
417
434
  };
418
435
  }
419
436
  }
@@ -424,7 +441,8 @@ function resolveRoute(rawPath, routes) {
424
441
  query,
425
442
  hash,
426
443
  matched: dynMatch.matched,
427
- meta: mergeMeta(dynMatch.matched)
444
+ meta: mergeMeta(dynMatch.matched),
445
+ search: runValidateSearch(dynMatch.matched, query)
428
446
  };
429
447
  const w = index.wildcards[0];
430
448
  if (w) return {
@@ -433,7 +451,8 @@ function resolveRoute(rawPath, routes) {
433
451
  query,
434
452
  hash,
435
453
  matched: w.matchedChain,
436
- meta: w.meta
454
+ meta: w.meta,
455
+ search: runValidateSearch(w.matchedChain, query)
437
456
  };
438
457
  return {
439
458
  path: cleanPath,
@@ -441,9 +460,22 @@ function resolveRoute(rawPath, routes) {
441
460
  query,
442
461
  hash,
443
462
  matched: [],
444
- meta: {}
463
+ meta: {},
464
+ search: {}
445
465
  };
446
466
  }
467
+ /** Run validateSearch from the deepest matched route that has one. */
468
+ function runValidateSearch(matched, query) {
469
+ for (let i = matched.length - 1; i >= 0; i--) {
470
+ const validate = matched[i]?.validateSearch;
471
+ if (validate) try {
472
+ return validate(query);
473
+ } catch {
474
+ return { ...query };
475
+ }
476
+ }
477
+ return {};
478
+ }
447
479
  /** Merge meta from matched routes (leaf takes precedence) */
448
480
  function mergeMeta(matched) {
449
481
  const meta = {};
@@ -489,6 +521,58 @@ function buildNameIndex(routes) {
489
521
  return index;
490
522
  }
491
523
 
524
+ //#endregion
525
+ //#region src/redirect.ts
526
+ const REDIRECT = Symbol.for("pyreon.redirect");
527
+ /**
528
+ * Throw inside a route loader to redirect the navigation server-side
529
+ * (during SSR returns a 302/307 `Location:` response) and client-side
530
+ * (during CSR triggers `router.replace()` before the layout renders).
531
+ *
532
+ * The auth-gate use case: replaces the fragile `onMount + router.push()`
533
+ * workaround. `onMount` doesn't fire reliably under nested-layout dev SSR +
534
+ * hydration — so the layout renders briefly before the push happens, leaking
535
+ * authenticated UI to unauthenticated users. `redirect()` runs in the loader
536
+ * BEFORE the layout's component is invoked, so the unauthenticated UI never
537
+ * mounts in the first place.
538
+ *
539
+ * @example
540
+ * ```ts
541
+ * // src/routes/app/_layout.tsx
542
+ * export const loader = async ({ request }) => {
543
+ * const session = await getSession(request)
544
+ * if (!session) redirect('/login')
545
+ * return { user: session.user }
546
+ * }
547
+ * ```
548
+ *
549
+ * @param url - Target URL (typically a path like `/login` or absolute URL for cross-origin).
550
+ * @param status - HTTP redirect status. Default `307` (Temporary Redirect, method-preserving).
551
+ * Use `301`/`308` for permanent moves, `302`/`303` to force GET on the target.
552
+ */
553
+ function redirect(url, status = 307) {
554
+ const err = /* @__PURE__ */ new Error(`Redirect to ${url}`);
555
+ err[REDIRECT] = {
556
+ url,
557
+ status
558
+ };
559
+ throw err;
560
+ }
561
+ /** Check if an error is a RedirectError thrown by `redirect()`. */
562
+ function isRedirectError(err) {
563
+ return typeof err === "object" && err !== null && typeof err[REDIRECT] === "object";
564
+ }
565
+ /**
566
+ * Extract the redirect URL and status from a thrown RedirectError. Returns
567
+ * `null` if `err` isn't a RedirectError. Used by the router's loader-runner
568
+ * (CSR) and the SSR handler to convert the thrown error into the right kind
569
+ * of response (a `router.replace()` call or a `302`/`307` Response).
570
+ */
571
+ function getRedirectInfo(err) {
572
+ if (!isRedirectError(err)) return null;
573
+ return err[REDIRECT] ?? null;
574
+ }
575
+
492
576
  //#endregion
493
577
  //#region src/scroll.ts
494
578
  /**
@@ -587,7 +671,8 @@ function isLazy(c) {
587
671
  //#endregion
588
672
  //#region src/router.ts
589
673
  const _isBrowser = typeof window !== "undefined";
590
- const __DEV__ = import.meta.env?.DEV === true;
674
+ const __DEV__ = process.env.NODE_ENV !== "production";
675
+ const _countSink = globalThis;
591
676
  const RouterContext = createContext(null);
592
677
  let _activeRouter = null;
593
678
  function setActiveRouter(router) {
@@ -806,8 +891,10 @@ function useTypedSearchParams(schema) {
806
891
  const result = {};
807
892
  for (const [key, type] of Object.entries(schema)) {
808
893
  const raw = query[key];
809
- if (type === "number") result[key] = raw !== void 0 ? Number(raw) : 0;
810
- else if (type === "boolean") result[key] = raw === "true" || raw === "1";
894
+ if (type === "number") {
895
+ const n = raw !== void 0 ? Number(raw) : 0;
896
+ result[key] = Number.isNaN(n) ? 0 : n;
897
+ } else if (type === "boolean") result[key] = raw === "true" || raw === "1";
811
898
  else result[key] = raw ?? "";
812
899
  }
813
900
  return result;
@@ -824,6 +911,44 @@ function useTypedSearchParams(schema) {
824
911
  };
825
912
  return [get, set];
826
913
  }
914
+ /**
915
+ * Read the validated search params from the current route's `validateSearch`.
916
+ * Returns a reactive accessor that re-evaluates when the route changes.
917
+ *
918
+ * The generic `T` should match the return type of your `validateSearch` function.
919
+ *
920
+ * @example
921
+ * ```tsx
922
+ * // Route config:
923
+ * { path: '/search', validateSearch: (raw) => ({
924
+ * page: Number(raw.page) || 1,
925
+ * q: raw.q ?? '',
926
+ * }), component: SearchPage }
927
+ *
928
+ * // In SearchPage:
929
+ * const search = useValidatedSearch<{ page: number; q: string }>()
930
+ * // search().page — typed as number
931
+ * // search().q — typed as string
932
+ * ```
933
+ */
934
+ function useValidatedSearch() {
935
+ const router = _getRouter();
936
+ let prev = null;
937
+ return () => {
938
+ const next = router.currentRoute().search;
939
+ if (prev && shallowEqual(prev, next)) return prev;
940
+ prev = next;
941
+ return next;
942
+ };
943
+ }
944
+ /** Shallow equality check for plain objects — keys + strict value comparison. */
945
+ function shallowEqual(a, b) {
946
+ const keysA = Object.keys(a);
947
+ const keysB = Object.keys(b);
948
+ if (keysA.length !== keysB.length) return false;
949
+ for (const key of keysA) if (a[key] !== b[key]) return false;
950
+ return true;
951
+ }
827
952
  function _getRouter() {
828
953
  const router = useContext(RouterContext) ?? _activeRouter;
829
954
  if (!router) throw new Error("[Pyreon] No router installed. Wrap your app in <RouterProvider router={router}>.");
@@ -927,14 +1052,19 @@ function createRouter(options) {
927
1052
  function processLoaderResult(result, record, ac, to) {
928
1053
  if (result.status === "fulfilled") {
929
1054
  router._loaderData.set(record, result.value);
930
- return true;
1055
+ return { action: "continue" };
931
1056
  }
932
- if (ac.signal.aborted) return true;
1057
+ if (ac.signal.aborted) return { action: "continue" };
1058
+ const info = getRedirectInfo(result.reason);
1059
+ if (info) return {
1060
+ action: "redirect",
1061
+ target: info.url
1062
+ };
933
1063
  if (router._onError) {
934
- if (router._onError(result.reason, to) === false) return false;
1064
+ if (router._onError(result.reason, to) === false) return { action: "cancel" };
935
1065
  }
936
1066
  router._loaderData.set(record, void 0);
937
- return true;
1067
+ return { action: "continue" };
938
1068
  }
939
1069
  function syncBrowserUrl(path, replace) {
940
1070
  if (!_isBrowser) return;
@@ -954,21 +1084,89 @@ function createRouter(options) {
954
1084
  if (enterOutcome.action !== "continue") return enterOutcome;
955
1085
  return runGlobalGuards(guards, to, from, gen);
956
1086
  }
1087
+ /** Default cache key: path + serialized params */
1088
+ function defaultLoaderKey(record, ctx) {
1089
+ return `${record.path}:${JSON.stringify(ctx.params)}`;
1090
+ }
1091
+ /** Get cache key for a route record + context. */
1092
+ function getCacheKey(record, ctx) {
1093
+ return record.loaderKey ? record.loaderKey(ctx) : defaultLoaderKey(record, ctx);
1094
+ }
1095
+ /** Check if a cached entry is still fresh (not expired by gcTime). */
1096
+ function isCacheFresh(entry, record) {
1097
+ const gcTime = record.gcTime ?? 3e5;
1098
+ if (gcTime === 0) return false;
1099
+ return Date.now() - entry.timestamp < gcTime;
1100
+ }
1101
+ /**
1102
+ * Bounded set into `_loaderCache`: evicts the oldest entry (insertion-order
1103
+ * FIFO) when the cap is exceeded. The `gcTime` TTL handles staleness, but
1104
+ * without a size cap a long-running SPA navigating across many distinct
1105
+ * loader keys (e.g. `/posts/:id` with hundreds of unique IDs) would
1106
+ * accumulate cache entries indefinitely until manual `invalidateLoader()`
1107
+ * — `_maxCacheSize` was wired through from `RouterOptions.maxCacheSize`
1108
+ * (default 100) but the loader cache write paths never read it. Mirrors
1109
+ * the same pattern used for `_componentCache` in `components.tsx`.
1110
+ */
1111
+ function loaderCacheSet(key, data) {
1112
+ router._loaderCache.set(key, {
1113
+ data,
1114
+ timestamp: Date.now()
1115
+ });
1116
+ if (router._loaderCache.size > router._maxCacheSize) {
1117
+ const oldest = router._loaderCache.keys().next().value;
1118
+ if (oldest !== void 0) router._loaderCache.delete(oldest);
1119
+ }
1120
+ }
1121
+ /**
1122
+ * Execute a loader with cache + dedup:
1123
+ * 1. Cache hit + fresh → return cached data (skip loader entirely)
1124
+ * 2. In-flight for same key → dedup (return existing promise)
1125
+ * 3. Otherwise → run loader, cache result, clean up in-flight
1126
+ */
1127
+ function executeLoader(record, loaderCtx) {
1128
+ if (!record.loader) return Promise.resolve(void 0);
1129
+ const key = getCacheKey(record, loaderCtx);
1130
+ if (!record.staleWhileRevalidate) {
1131
+ const cached = router._loaderCache.get(key);
1132
+ if (cached && isCacheFresh(cached, record)) {
1133
+ if (__DEV__) _countSink.__pyreon_count__?.("router.loaderCache.hit");
1134
+ return Promise.resolve(cached.data);
1135
+ }
1136
+ }
1137
+ const inflight = router._loaderInflight.get(key);
1138
+ if (inflight && !inflight.signal.aborted) return inflight.promise;
1139
+ if (__DEV__) _countSink.__pyreon_count__?.("router.loaderRun");
1140
+ const promise = Promise.resolve().then(() => record.loader(loaderCtx)).then((data) => {
1141
+ loaderCacheSet(key, data);
1142
+ if (router._loaderInflight.get(key)?.promise === promise) router._loaderInflight.delete(key);
1143
+ return data;
1144
+ }).catch((err) => {
1145
+ if (router._loaderInflight.get(key)?.promise === promise) router._loaderInflight.delete(key);
1146
+ throw err;
1147
+ });
1148
+ router._loaderInflight.set(key, {
1149
+ promise,
1150
+ signal: loaderCtx.signal
1151
+ });
1152
+ return promise;
1153
+ }
957
1154
  async function runBlockingLoaders(records, to, gen, ac) {
958
1155
  const loaderCtx = {
959
1156
  params: to.params,
960
1157
  query: to.query,
961
1158
  signal: ac.signal
962
1159
  };
963
- const results = await Promise.allSettled(records.map((r) => r.loader ? r.loader(loaderCtx) : Promise.resolve(void 0)));
964
- if (gen !== _navGen) return false;
1160
+ const results = await Promise.allSettled(records.map((r) => executeLoader(r, loaderCtx)));
1161
+ if (gen !== _navGen) return { action: "cancel" };
965
1162
  for (let i = 0; i < records.length; i++) {
966
1163
  const result = results[i];
967
1164
  const record = records[i];
968
1165
  if (!result || !record) continue;
969
- if (!processLoaderResult(result, record, ac, to)) return false;
1166
+ const outcome = processLoaderResult(result, record, ac, to);
1167
+ if (outcome.action !== "continue") return outcome;
970
1168
  }
971
- return true;
1169
+ return { action: "continue" };
972
1170
  }
973
1171
  /** Fire-and-forget background revalidation for stale-while-revalidate routes. */
974
1172
  function revalidateSwrLoaders(records, to, ac) {
@@ -982,6 +1180,7 @@ function createRouter(options) {
982
1180
  r.loader(loaderCtx).then((data) => {
983
1181
  if (!ac.signal.aborted) {
984
1182
  router._loaderData.set(r, data);
1183
+ loaderCacheSet(getCacheKey(r, loaderCtx), data);
985
1184
  loadingSignal.update((n) => n + 1);
986
1185
  loadingSignal.update((n) => n - 1);
987
1186
  }
@@ -990,16 +1189,17 @@ function createRouter(options) {
990
1189
  }
991
1190
  async function runLoaders(to, gen, ac) {
992
1191
  const loadableRecords = to.matched.filter((r) => r.loader);
993
- if (loadableRecords.length === 0) return true;
1192
+ if (loadableRecords.length === 0) return { action: "continue" };
994
1193
  const blocking = [];
995
1194
  const swr = [];
996
1195
  for (const r of loadableRecords) if (r.staleWhileRevalidate && router._loaderData.has(r)) swr.push(r);
997
1196
  else blocking.push(r);
998
1197
  if (blocking.length > 0) {
999
- if (!await runBlockingLoaders(blocking, to, gen, ac)) return false;
1198
+ const outcome = await runBlockingLoaders(blocking, to, gen, ac);
1199
+ if (outcome.action !== "continue") return outcome;
1000
1200
  }
1001
1201
  if (swr.length > 0) revalidateSwrLoaders(swr, to, ac);
1002
- return true;
1202
+ return { action: "continue" };
1003
1203
  }
1004
1204
  async function commitNavigation(path, replace, to, from) {
1005
1205
  scrollManager.save(from.path);
@@ -1059,6 +1259,8 @@ function createRouter(options) {
1059
1259
  return { action: "continue" };
1060
1260
  }
1061
1261
  async function navigate(rawPath, replace, redirectDepth = 0) {
1262
+ if (__DEV__) _countSink.__pyreon_count__?.("router.navigate");
1263
+ router._navigationStartTime = Date.now();
1062
1264
  if (redirectDepth > 10) {
1063
1265
  if (__DEV__) console.warn(`[Pyreon] Navigation to "${rawPath}" aborted: redirect depth exceeded 10 levels. This likely indicates a redirect loop in your route configuration.`);
1064
1266
  return;
@@ -1092,8 +1294,10 @@ function createRouter(options) {
1092
1294
  router._abortController?.abort();
1093
1295
  const ac = new AbortController();
1094
1296
  router._abortController = ac;
1095
- if (!await runLoaders(to, gen, ac)) {
1297
+ const loaderOutcome = await runLoaders(to, gen, ac);
1298
+ if (loaderOutcome.action !== "continue") {
1096
1299
  loadingSignal.update((n) => n - 1);
1300
+ if (loaderOutcome.action === "redirect") return navigate(sanitizePath(loaderOutcome.target), replace, redirectDepth + 1);
1097
1301
  return;
1098
1302
  }
1099
1303
  await commitNavigation(path, replace, to, from);
@@ -1123,6 +1327,9 @@ function createRouter(options) {
1123
1327
  _readyPromise,
1124
1328
  _onError: onError,
1125
1329
  _maxCacheSize: maxCacheSize,
1330
+ _navigationStartTime: Date.now(),
1331
+ _loaderCache: /* @__PURE__ */ new Map(),
1332
+ _loaderInflight: /* @__PURE__ */ new Map(),
1126
1333
  async push(location) {
1127
1334
  if (typeof location === "string") return navigate(sanitizePath(resolveRelativePath(location, currentPath())), false);
1128
1335
  return navigate(resolveNamedPath(location.name, location.params ?? {}, location.query ?? {}, nameIndex), false);
@@ -1158,7 +1365,7 @@ function createRouter(options) {
1158
1365
  isReady() {
1159
1366
  return router._readyPromise;
1160
1367
  },
1161
- async preload(path) {
1368
+ async preload(path, request) {
1162
1369
  const resolved = resolveRoute(path, routes);
1163
1370
  await Promise.all(resolved.matched.map(async (record) => {
1164
1371
  if (componentCache.has(record)) return;
@@ -1173,14 +1380,31 @@ function createRouter(options) {
1173
1380
  }));
1174
1381
  const ac = new AbortController();
1175
1382
  await Promise.all(resolved.matched.filter((r) => r.loader).map(async (r) => {
1176
- const data = await r.loader?.({
1383
+ const data = await Promise.resolve().then(() => r.loader({
1177
1384
  params: resolved.params,
1178
1385
  query: resolved.query,
1179
- signal: ac.signal
1180
- });
1386
+ signal: ac.signal,
1387
+ ...request ? { request } : {}
1388
+ }));
1181
1389
  router._loaderData.set(r, data);
1182
1390
  }));
1183
1391
  },
1392
+ invalidateLoader(keyOrPredicate) {
1393
+ if (!keyOrPredicate) {
1394
+ router._loaderCache.clear();
1395
+ router._loaderInflight.clear();
1396
+ return;
1397
+ }
1398
+ if (typeof keyOrPredicate === "string") {
1399
+ router._loaderCache.delete(keyOrPredicate);
1400
+ router._loaderInflight.delete(keyOrPredicate);
1401
+ return;
1402
+ }
1403
+ for (const key of [...router._loaderCache.keys()]) if (keyOrPredicate(key)) {
1404
+ router._loaderCache.delete(key);
1405
+ router._loaderInflight.delete(key);
1406
+ }
1407
+ },
1184
1408
  destroy() {
1185
1409
  if (_popstateHandler) window.removeEventListener("popstate", _popstateHandler);
1186
1410
  if (_hashchangeHandler) window.removeEventListener("hashchange", _hashchangeHandler);
@@ -1190,6 +1414,8 @@ function createRouter(options) {
1190
1414
  router._blockers.clear();
1191
1415
  componentCache.clear();
1192
1416
  router._loaderData.clear();
1417
+ router._loaderCache.clear();
1418
+ router._loaderInflight.clear();
1193
1419
  router._abortController?.abort();
1194
1420
  router._abortController = null;
1195
1421
  if (_activeRouter === router) _activeRouter = null;
@@ -1214,7 +1440,10 @@ async function runGuard(guard, to, from) {
1214
1440
  }
1215
1441
  function resolveNamedPath(name, params, query, index) {
1216
1442
  const record = index.get(name);
1217
- if (!record) return "/";
1443
+ if (!record) {
1444
+ if (__DEV__) console.warn(`[Pyreon Router] Unknown route name "${name}". Available names: ${[...index.keys()].join(", ") || "(none)"}. Falling back to "/".`);
1445
+ return "/";
1446
+ }
1218
1447
  let path = buildPath(record.path, params);
1219
1448
  const qs = Object.entries(query).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
1220
1449
  if (qs) path += `?${qs}`;
@@ -1311,38 +1540,81 @@ const RouterView = (props) => {
1311
1540
  onUnmount(() => {
1312
1541
  router._viewDepth--;
1313
1542
  });
1314
- const child = () => {
1315
- router._loadingSignal();
1543
+ const depthEntry = computed(() => {
1316
1544
  const route = router.currentRoute();
1317
- if (route.matched.length === 0) return null;
1318
- const record = route.matched[depth];
1319
- if (!record) return null;
1320
- const cached = router._componentCache.get(record);
1321
- if (cached) return renderWithLoader(router, record, cached, route);
1322
- const raw = record.component;
1545
+ const rec = route.matched[depth] ?? null;
1546
+ if (!rec) return {
1547
+ rec: null,
1548
+ comp: null,
1549
+ errored: false,
1550
+ route
1551
+ };
1552
+ router._loadingSignal();
1553
+ if (router._erroredChunks.has(rec)) return {
1554
+ rec,
1555
+ comp: null,
1556
+ errored: true,
1557
+ route
1558
+ };
1559
+ const cached = router._componentCache.get(rec);
1560
+ if (cached) return {
1561
+ rec,
1562
+ comp: cached,
1563
+ errored: false,
1564
+ route
1565
+ };
1566
+ const raw = rec.component;
1323
1567
  if (!isLazy(raw)) {
1324
- cacheSet(router, record, raw);
1325
- return renderWithLoader(router, record, raw, route);
1568
+ cacheSet(router, rec, raw);
1569
+ return {
1570
+ rec,
1571
+ comp: raw,
1572
+ errored: false,
1573
+ route
1574
+ };
1326
1575
  }
1327
- return renderLazyRoute(router, record, raw);
1576
+ return {
1577
+ rec,
1578
+ comp: null,
1579
+ errored: false,
1580
+ route
1581
+ };
1582
+ }, { equals: (a, b) => a.rec === b.rec && a.comp === b.comp && a.errored === b.errored && a.route === b.route });
1583
+ const child = () => {
1584
+ const { rec, comp, route } = depthEntry();
1585
+ if (!rec) return null;
1586
+ if (comp) return renderWithLoader(router, rec, comp, route);
1587
+ return renderLazyRoute(router, rec, rec.component);
1328
1588
  };
1329
1589
  return h("div", { "data-pyreon-router-view": true }, child);
1330
1590
  };
1331
1591
  const RouterLink = (props) => {
1332
1592
  const router = useContext(RouterContext);
1333
- const prefetchMode = props.prefetch ?? "hover";
1593
+ const prefetchMode = props.prefetch ?? "intent";
1334
1594
  const handleClick = (e) => {
1335
1595
  e.preventDefault();
1336
1596
  if (!router) return;
1337
1597
  if (props.replace) router.replace(props.to);
1338
1598
  else router.push(props.to);
1339
1599
  };
1340
- const handleMouseEnter = () => {
1341
- if (prefetchMode !== "hover" || !router) return;
1600
+ const triggerPrefetch = () => {
1601
+ if (!router) return;
1342
1602
  prefetchRoute(router, props.to);
1343
1603
  };
1604
+ const handleMouseEnter = () => {
1605
+ if (prefetchMode === "hover" || prefetchMode === "intent") triggerPrefetch();
1606
+ };
1607
+ const handleFocus = () => {
1608
+ if (prefetchMode === "intent") triggerPrefetch();
1609
+ };
1344
1610
  const inst = router;
1345
- const href = inst?.mode === "history" ? `${inst._base}${props.to}` : `#${props.to}`;
1611
+ const href = () => inst?.mode === "history" ? `${inst._base}${props.to}` : `#${props.to}`;
1612
+ const isExactMatch = () => {
1613
+ if (!router) return false;
1614
+ const target = props.to;
1615
+ if (typeof target !== "string") return false;
1616
+ return router.currentRoute().path === target;
1617
+ };
1346
1618
  const activeClass = () => {
1347
1619
  if (!router) return "";
1348
1620
  const current = router.currentRoute().path;
@@ -1355,6 +1627,7 @@ const RouterLink = (props) => {
1355
1627
  if (isExact) classes.push(props.exactActiveClass ?? "router-link-exact-active");
1356
1628
  return classes.join(" ").trim();
1357
1629
  };
1630
+ const ariaCurrent = () => isExactMatch() ? "page" : void 0;
1358
1631
  const ref = createRef();
1359
1632
  if (prefetchMode === "viewport" && router && typeof IntersectionObserver !== "undefined") {
1360
1633
  const observer = new IntersectionObserver((entries) => {
@@ -1369,17 +1642,23 @@ const RouterLink = (props) => {
1369
1642
  });
1370
1643
  onUnmount(() => observer.disconnect());
1371
1644
  }
1372
- const { to: _to, replace: _replace, activeClass: _ac, exactActiveClass: _eac, exact: _exact, prefetch: _prefetch, children, ...rest } = props;
1645
+ const { to: _to, replace: _replace, activeClass: _ac, exactActiveClass: _eac, exact: _exact, prefetch: _prefetch, class: userClass, children, ...rest } = props;
1646
+ const mergedClass = () => {
1647
+ return cx([typeof userClass === "function" ? userClass() : userClass, activeClass()]);
1648
+ };
1373
1649
  return h("a", {
1374
1650
  ...rest,
1375
1651
  ref,
1376
1652
  href,
1377
- class: activeClass,
1653
+ class: mergedClass,
1654
+ "aria-current": ariaCurrent,
1378
1655
  onClick: handleClick,
1379
- onMouseEnter: handleMouseEnter
1656
+ onMouseEnter: handleMouseEnter,
1657
+ onFocus: handleFocus
1380
1658
  }, children ?? props.to);
1381
1659
  };
1382
1660
  /** Prefetch loader data for a route (only once per router + path). */
1661
+ const MAX_PREFETCH_CACHE = 50;
1383
1662
  function prefetchRoute(router, path) {
1384
1663
  let set = _prefetched.get(router);
1385
1664
  if (!set) {
@@ -1387,6 +1666,10 @@ function prefetchRoute(router, path) {
1387
1666
  _prefetched.set(router, set);
1388
1667
  }
1389
1668
  if (set.has(path)) return;
1669
+ if (set.size >= MAX_PREFETCH_CACHE) {
1670
+ const first = set.values().next().value;
1671
+ set.delete(first);
1672
+ }
1390
1673
  set.add(path);
1391
1674
  prefetchLoaderData(router, path).catch(() => {
1392
1675
  set?.delete(path);
@@ -1431,13 +1714,88 @@ function renderWithLoader(router, record, Comp, route) {
1431
1714
  }
1432
1715
  function renderLoaderContent(router, record, Comp, routeProps) {
1433
1716
  const data = router._loaderData.get(record);
1434
- if (data === void 0 && record.errorComponent) return h(record.errorComponent, routeProps);
1717
+ if (data !== void 0) return h(LoaderDataProvider, {
1718
+ data,
1719
+ children: h(Comp, routeProps)
1720
+ });
1721
+ if (record.pendingComponent) return h(PendingLoader, {
1722
+ router,
1723
+ record,
1724
+ Comp,
1725
+ routeProps
1726
+ });
1727
+ if (record.errorComponent) return h(record.errorComponent, routeProps);
1435
1728
  return h(LoaderDataProvider, {
1436
1729
  data,
1437
1730
  children: h(Comp, routeProps)
1438
1731
  });
1439
1732
  }
1440
1733
  /**
1734
+ * Signal-based pending component with timing control.
1735
+ *
1736
+ * State machine: hidden → pending → ready
1737
+ * - hidden: initial state, nothing shown (lasts pendingMs)
1738
+ * - pending: pendingComponent shown (lasts at least pendingMinMs)
1739
+ * - ready: real component shown (loader data arrived + minTime elapsed)
1740
+ */
1741
+ function PendingLoader(props) {
1742
+ const { router, record, Comp, routeProps } = props;
1743
+ const pendingMs = record.pendingMs ?? 0;
1744
+ const pendingMinMs = record.pendingMinMs ?? 200;
1745
+ const phase = signal(pendingMs === 0 ? "pending" : "hidden");
1746
+ let pendingTimer = null;
1747
+ let minTimer = null;
1748
+ let minTimeElapsed = pendingMs === 0 ? false : true;
1749
+ let dataReady = false;
1750
+ if (pendingMs === 0) {
1751
+ minTimeElapsed = false;
1752
+ minTimer = setTimeout(() => {
1753
+ minTimeElapsed = true;
1754
+ minTimer = null;
1755
+ if (dataReady) phase.set("ready");
1756
+ }, pendingMinMs);
1757
+ } else pendingTimer = setTimeout(() => {
1758
+ pendingTimer = null;
1759
+ if (dataReady) phase.set("ready");
1760
+ else {
1761
+ phase.set("pending");
1762
+ minTimeElapsed = false;
1763
+ minTimer = setTimeout(() => {
1764
+ minTimeElapsed = true;
1765
+ minTimer = null;
1766
+ if (dataReady) phase.set("ready");
1767
+ }, pendingMinMs);
1768
+ }
1769
+ }, pendingMs);
1770
+ const checkData = () => {
1771
+ if (router._loaderData.get(record) !== void 0) {
1772
+ dataReady = true;
1773
+ if (phase.peek() === "hidden") {
1774
+ if (pendingTimer) {
1775
+ clearTimeout(pendingTimer);
1776
+ pendingTimer = null;
1777
+ }
1778
+ phase.set("ready");
1779
+ } else if (minTimeElapsed) phase.set("ready");
1780
+ }
1781
+ };
1782
+ onUnmount(() => {
1783
+ if (pendingTimer) clearTimeout(pendingTimer);
1784
+ if (minTimer) clearTimeout(minTimer);
1785
+ });
1786
+ return (() => {
1787
+ router._loadingSignal();
1788
+ checkData();
1789
+ const p = phase();
1790
+ if (p === "hidden") return null;
1791
+ if (p === "pending") return h(record.pendingComponent, routeProps);
1792
+ return h(LoaderDataProvider, {
1793
+ data: router._loaderData.get(record),
1794
+ children: h(Comp, routeProps)
1795
+ });
1796
+ });
1797
+ }
1798
+ /**
1441
1799
  * Thin provider component that pushes LoaderDataContext before children mount.
1442
1800
  * Uses Pyreon's context stack so useLoaderData() reads it during child setup.
1443
1801
  */
@@ -1474,7 +1832,60 @@ function isStaleChunk(err) {
1474
1832
  if (err instanceof SyntaxError) return true;
1475
1833
  return false;
1476
1834
  }
1835
+ nativeCompat(RouterProvider);
1836
+ nativeCompat(RouterView);
1837
+ nativeCompat(RouterLink);
1838
+
1839
+ //#endregion
1840
+ //#region src/not-found.ts
1841
+ const NOT_FOUND = Symbol.for("pyreon.notFound");
1842
+ /**
1843
+ * Throw inside a route loader or component to trigger the nearest
1844
+ * NotFoundBoundary. Inspired by Next.js's `notFound()`.
1845
+ *
1846
+ * @example
1847
+ * ```ts
1848
+ * // In a loader:
1849
+ * loader: async ({ params }) => {
1850
+ * const user = await fetchUser(params.id)
1851
+ * if (!user) notFound()
1852
+ * return user
1853
+ * }
1854
+ * ```
1855
+ */
1856
+ function notFound(message) {
1857
+ const err = new Error(message ?? "Not Found");
1858
+ err[NOT_FOUND] = true;
1859
+ throw err;
1860
+ }
1861
+ /** Check if an error is a NotFoundError thrown by `notFound()`. */
1862
+ function isNotFoundError(err) {
1863
+ return typeof err === "object" && err !== null && err[NOT_FOUND] === true;
1864
+ }
1865
+ /**
1866
+ * Catches `notFound()` errors from child route components or loaders
1867
+ * and renders the fallback. Wraps Pyreon's ErrorBoundary with notFound
1868
+ * detection — non-notFound errors propagate to parent error boundaries.
1869
+ *
1870
+ * @example
1871
+ * ```tsx
1872
+ * <NotFoundBoundary fallback={<NotFoundPage />}>
1873
+ * <RouterView />
1874
+ * </NotFoundBoundary>
1875
+ * ```
1876
+ */
1877
+ const NotFoundBoundary = (props) => {
1878
+ return h(ErrorBoundary, { fallback: (err, reset) => {
1879
+ if (!isNotFoundError(err)) throw err;
1880
+ const fb = props.fallback;
1881
+ if (typeof fb === "function" && fb.length <= 1) return h(fb, {
1882
+ error: err,
1883
+ reset
1884
+ });
1885
+ return fb;
1886
+ } }, props.children);
1887
+ };
1477
1888
 
1478
1889
  //#endregion
1479
- export { RouterContext, RouterLink, RouterProvider, RouterView, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useIsActive, useLoaderData, useMiddlewareData, useRoute, useRouter, useSearchParams, useTransition, useTypedSearchParams };
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 };
1480
1891
  //# sourceMappingURL=index.js.map