@pyreon/router 0.13.1 → 0.14.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/README.md CHANGED
@@ -13,9 +13,10 @@ bun add @pyreon/router
13
13
  ```tsx
14
14
  import { createRouter, RouterProvider, RouterView, RouterLink } from '@pyreon/router'
15
15
 
16
- const router = createRouter({
16
+ // Type-safe named navigation — route names are checked at compile time
17
+ const router = createRouter<'home' | 'user'>({
17
18
  routes: [
18
- { path: '/', component: Home },
19
+ { path: '/', component: Home, name: 'home' },
19
20
  { path: '/user/:id', component: UserPage, name: 'user' },
20
21
  {
21
22
  path: '/admin',
@@ -112,3 +113,73 @@ Route changes are wrapped in `document.startViewTransition()` automatically when
112
113
  | `finished` | Full animation completed | no -- `.catch()` only |
113
114
 
114
115
  `afterEach` hooks and scroll restoration fire after the VT callback completes, so they observe the new route state when invoked.
116
+
117
+ ## notFound()
118
+
119
+ Throw `notFound()` in a loader or component to render a 404 boundary:
120
+
121
+ ```tsx
122
+ import { notFound, NotFoundBoundary, RouterView } from '@pyreon/router'
123
+
124
+ // Route loader:
125
+ { path: '/user/:id', component: UserPage, loader: async ({ params }) => {
126
+ const user = await fetchUser(params.id)
127
+ if (!user) notFound()
128
+ return user
129
+ }}
130
+
131
+ // App layout:
132
+ <NotFoundBoundary fallback={<NotFoundPage />}>
133
+ <RouterView />
134
+ </NotFoundBoundary>
135
+ ```
136
+
137
+ ## Pending Components
138
+
139
+ Show a skeleton while route loaders run:
140
+
141
+ ```ts
142
+ {
143
+ path: '/dashboard',
144
+ component: Dashboard,
145
+ loader: fetchDashboardData,
146
+ pendingComponent: DashboardSkeleton,
147
+ pendingMs: 200, // delay before showing skeleton (avoid flash)
148
+ pendingMinMs: 500, // minimum display time (avoid flicker)
149
+ }
150
+ ```
151
+
152
+ ## Validated Search Params
153
+
154
+ Type-safe query string validation per route — works with Zod, Valibot, or plain functions:
155
+
156
+ ```ts
157
+ import { useValidatedSearch } from '@pyreon/router'
158
+
159
+ // Route config:
160
+ {
161
+ path: '/search',
162
+ component: SearchPage,
163
+ validateSearch: (raw) => ({
164
+ page: Number(raw.page) || 1,
165
+ q: raw.q ?? '',
166
+ }),
167
+ }
168
+
169
+ // With Zod:
170
+ {
171
+ path: '/search',
172
+ component: SearchPage,
173
+ validateSearch: z.object({
174
+ page: z.coerce.number().default(1),
175
+ q: z.string().default(''),
176
+ }).parse,
177
+ }
178
+
179
+ // In component:
180
+ const search = useValidatedSearch<{ page: number; q: string }>()
181
+ search().page // number — typed + validated
182
+ search().q // string — typed + validated
183
+ ```
184
+
185
+ Structural sharing: `useValidatedSearch()` returns the same object reference when the validated values haven't changed, preventing unnecessary downstream re-renders.
@@ -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":"aa1c31e3-1","name":"loader.ts"},{"uid":"aa1c31e3-3","name":"match.ts"},{"uid":"aa1c31e3-5","name":"scroll.ts"},{"uid":"aa1c31e3-7","name":"types.ts"},{"uid":"aa1c31e3-9","name":"router.ts"},{"uid":"aa1c31e3-11","name":"components.tsx"},{"uid":"aa1c31e3-13","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"aa1c31e3-1":{"renderedLength":2824,"gzipLength":1233,"brotliLength":0,"metaUid":"aa1c31e3-0"},"aa1c31e3-3":{"renderedLength":12203,"gzipLength":3691,"brotliLength":0,"metaUid":"aa1c31e3-2"},"aa1c31e3-5":{"renderedLength":2194,"gzipLength":899,"brotliLength":0,"metaUid":"aa1c31e3-4"},"aa1c31e3-7":{"renderedLength":385,"gzipLength":246,"brotliLength":0,"metaUid":"aa1c31e3-6"},"aa1c31e3-9":{"renderedLength":23568,"gzipLength":6469,"brotliLength":0,"metaUid":"aa1c31e3-8"},"aa1c31e3-11":{"renderedLength":7151,"gzipLength":2635,"brotliLength":0,"metaUid":"aa1c31e3-10"},"aa1c31e3-13":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"aa1c31e3-12"}},"nodeMetas":{"aa1c31e3-0":{"id":"/src/loader.ts","moduleParts":{"index.js":"aa1c31e3-1"},"imported":[{"uid":"aa1c31e3-14"}],"importedBy":[{"uid":"aa1c31e3-12"},{"uid":"aa1c31e3-10"}]},"aa1c31e3-2":{"id":"/src/match.ts","moduleParts":{"index.js":"aa1c31e3-3"},"imported":[],"importedBy":[{"uid":"aa1c31e3-12"},{"uid":"aa1c31e3-8"}]},"aa1c31e3-4":{"id":"/src/scroll.ts","moduleParts":{"index.js":"aa1c31e3-5"},"imported":[],"importedBy":[{"uid":"aa1c31e3-8"}]},"aa1c31e3-6":{"id":"/src/types.ts","moduleParts":{"index.js":"aa1c31e3-7"},"imported":[],"importedBy":[{"uid":"aa1c31e3-12"},{"uid":"aa1c31e3-8"}]},"aa1c31e3-8":{"id":"/src/router.ts","moduleParts":{"index.js":"aa1c31e3-9"},"imported":[{"uid":"aa1c31e3-14"},{"uid":"aa1c31e3-15"},{"uid":"aa1c31e3-2"},{"uid":"aa1c31e3-4"},{"uid":"aa1c31e3-6"}],"importedBy":[{"uid":"aa1c31e3-12"},{"uid":"aa1c31e3-10"}]},"aa1c31e3-10":{"id":"/src/components.tsx","moduleParts":{"index.js":"aa1c31e3-11"},"imported":[{"uid":"aa1c31e3-14"},{"uid":"aa1c31e3-0"},{"uid":"aa1c31e3-8"}],"importedBy":[{"uid":"aa1c31e3-12"}]},"aa1c31e3-12":{"id":"/src/index.ts","moduleParts":{"index.js":"aa1c31e3-13"},"imported":[{"uid":"aa1c31e3-10"},{"uid":"aa1c31e3-0"},{"uid":"aa1c31e3-2"},{"uid":"aa1c31e3-8"},{"uid":"aa1c31e3-6"}],"importedBy":[],"isEntry":true},"aa1c31e3-14":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"aa1c31e3-10"},{"uid":"aa1c31e3-0"},{"uid":"aa1c31e3-8"}]},"aa1c31e3-15":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"aa1c31e3-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":"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}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
package/lib/index.js CHANGED
@@ -2,6 +2,8 @@ import { ErrorBoundary, createContext, createRef, h, onUnmount, provide, useCont
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;
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.
@@ -32,6 +34,7 @@ function useLoaderData() {
32
34
  * const html = await renderToString(h(App, { router }))
33
35
  */
34
36
  async function prefetchLoaderData(router, path) {
37
+ if (__DEV__$1) _countSink$1.__pyreon_count__?.("router.prefetch");
35
38
  const route = router._resolve(path);
36
39
  const ac = new AbortController();
37
40
  await Promise.all(route.matched.filter((r) => r.loader).map(async (r) => {
@@ -84,17 +87,21 @@ function hydrateLoaderData(router, serialized) {
84
87
  * Parse a query string into key-value pairs. Duplicate keys are overwritten
85
88
  * (last value wins). Use `parseQueryMulti` to preserve duplicates as arrays.
86
89
  */
90
+ /** Decode a query component: `+` → space (per application/x-www-form-urlencoded), then URI-decode. */
91
+ function decodeQueryComponent(raw) {
92
+ return decodeURIComponent(raw.replace(/\+/g, " "));
93
+ }
87
94
  function parseQuery(qs) {
88
95
  if (!qs) return {};
89
96
  const result = {};
90
97
  for (const part of qs.split("&")) {
91
98
  const eqIdx = part.indexOf("=");
92
99
  if (eqIdx < 0) {
93
- const key = decodeURIComponent(part);
100
+ const key = decodeQueryComponent(part);
94
101
  if (key) result[key] = "";
95
102
  } else {
96
- const key = decodeURIComponent(part.slice(0, eqIdx));
97
- const val = decodeURIComponent(part.slice(eqIdx + 1));
103
+ const key = decodeQueryComponent(part.slice(0, eqIdx));
104
+ const val = decodeQueryComponent(part.slice(eqIdx + 1));
98
105
  if (key) result[key] = val;
99
106
  }
100
107
  }
@@ -115,11 +122,11 @@ function parseQueryMulti(qs) {
115
122
  let key;
116
123
  let val;
117
124
  if (eqIdx < 0) {
118
- key = decodeURIComponent(part);
125
+ key = decodeQueryComponent(part);
119
126
  val = "";
120
127
  } else {
121
- key = decodeURIComponent(part.slice(0, eqIdx));
122
- val = decodeURIComponent(part.slice(eqIdx + 1));
128
+ key = decodeQueryComponent(part.slice(0, eqIdx));
129
+ val = decodeQueryComponent(part.slice(eqIdx + 1));
123
130
  }
124
131
  if (!key) continue;
125
132
  const existing = result[key];
@@ -398,7 +405,8 @@ function resolveRoute(rawPath, routes) {
398
405
  query,
399
406
  hash,
400
407
  matched: staticMatch.matchedChain,
401
- meta: staticMatch.meta
408
+ meta: staticMatch.meta,
409
+ search: runValidateSearch(staticMatch.matchedChain, query)
402
410
  };
403
411
  const pathParts = splitPath(cleanPath);
404
412
  const pathLen = pathParts.length;
@@ -413,7 +421,8 @@ function resolveRoute(rawPath, routes) {
413
421
  query,
414
422
  hash,
415
423
  matched: match.matched,
416
- meta: mergeMeta(match.matched)
424
+ meta: mergeMeta(match.matched),
425
+ search: runValidateSearch(match.matched, query)
417
426
  };
418
427
  }
419
428
  }
@@ -424,7 +433,8 @@ function resolveRoute(rawPath, routes) {
424
433
  query,
425
434
  hash,
426
435
  matched: dynMatch.matched,
427
- meta: mergeMeta(dynMatch.matched)
436
+ meta: mergeMeta(dynMatch.matched),
437
+ search: runValidateSearch(dynMatch.matched, query)
428
438
  };
429
439
  const w = index.wildcards[0];
430
440
  if (w) return {
@@ -433,7 +443,8 @@ function resolveRoute(rawPath, routes) {
433
443
  query,
434
444
  hash,
435
445
  matched: w.matchedChain,
436
- meta: w.meta
446
+ meta: w.meta,
447
+ search: runValidateSearch(w.matchedChain, query)
437
448
  };
438
449
  return {
439
450
  path: cleanPath,
@@ -441,9 +452,22 @@ function resolveRoute(rawPath, routes) {
441
452
  query,
442
453
  hash,
443
454
  matched: [],
444
- meta: {}
455
+ meta: {},
456
+ search: {}
445
457
  };
446
458
  }
459
+ /** Run validateSearch from the deepest matched route that has one. */
460
+ function runValidateSearch(matched, query) {
461
+ for (let i = matched.length - 1; i >= 0; i--) {
462
+ const validate = matched[i]?.validateSearch;
463
+ if (validate) try {
464
+ return validate(query);
465
+ } catch {
466
+ return { ...query };
467
+ }
468
+ }
469
+ return {};
470
+ }
447
471
  /** Merge meta from matched routes (leaf takes precedence) */
448
472
  function mergeMeta(matched) {
449
473
  const meta = {};
@@ -588,6 +612,7 @@ function isLazy(c) {
588
612
  //#region src/router.ts
589
613
  const _isBrowser = typeof window !== "undefined";
590
614
  const __DEV__ = import.meta.env?.DEV === true;
615
+ const _countSink = globalThis;
591
616
  const RouterContext = createContext(null);
592
617
  let _activeRouter = null;
593
618
  function setActiveRouter(router) {
@@ -806,8 +831,10 @@ function useTypedSearchParams(schema) {
806
831
  const result = {};
807
832
  for (const [key, type] of Object.entries(schema)) {
808
833
  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";
834
+ if (type === "number") {
835
+ const n = raw !== void 0 ? Number(raw) : 0;
836
+ result[key] = Number.isNaN(n) ? 0 : n;
837
+ } else if (type === "boolean") result[key] = raw === "true" || raw === "1";
811
838
  else result[key] = raw ?? "";
812
839
  }
813
840
  return result;
@@ -824,6 +851,44 @@ function useTypedSearchParams(schema) {
824
851
  };
825
852
  return [get, set];
826
853
  }
854
+ /**
855
+ * Read the validated search params from the current route's `validateSearch`.
856
+ * Returns a reactive accessor that re-evaluates when the route changes.
857
+ *
858
+ * The generic `T` should match the return type of your `validateSearch` function.
859
+ *
860
+ * @example
861
+ * ```tsx
862
+ * // Route config:
863
+ * { path: '/search', validateSearch: (raw) => ({
864
+ * page: Number(raw.page) || 1,
865
+ * q: raw.q ?? '',
866
+ * }), component: SearchPage }
867
+ *
868
+ * // In SearchPage:
869
+ * const search = useValidatedSearch<{ page: number; q: string }>()
870
+ * // search().page — typed as number
871
+ * // search().q — typed as string
872
+ * ```
873
+ */
874
+ function useValidatedSearch() {
875
+ const router = _getRouter();
876
+ let prev = null;
877
+ return () => {
878
+ const next = router.currentRoute().search;
879
+ if (prev && shallowEqual(prev, next)) return prev;
880
+ prev = next;
881
+ return next;
882
+ };
883
+ }
884
+ /** Shallow equality check for plain objects — keys + strict value comparison. */
885
+ function shallowEqual(a, b) {
886
+ const keysA = Object.keys(a);
887
+ const keysB = Object.keys(b);
888
+ if (keysA.length !== keysB.length) return false;
889
+ for (const key of keysA) if (a[key] !== b[key]) return false;
890
+ return true;
891
+ }
827
892
  function _getRouter() {
828
893
  const router = useContext(RouterContext) ?? _activeRouter;
829
894
  if (!router) throw new Error("[Pyreon] No router installed. Wrap your app in <RouterProvider router={router}>.");
@@ -954,13 +1019,60 @@ function createRouter(options) {
954
1019
  if (enterOutcome.action !== "continue") return enterOutcome;
955
1020
  return runGlobalGuards(guards, to, from, gen);
956
1021
  }
1022
+ /** Default cache key: path + serialized params */
1023
+ function defaultLoaderKey(record, ctx) {
1024
+ return `${record.path}:${JSON.stringify(ctx.params)}`;
1025
+ }
1026
+ /** Get cache key for a route record + context. */
1027
+ function getCacheKey(record, ctx) {
1028
+ return record.loaderKey ? record.loaderKey(ctx) : defaultLoaderKey(record, ctx);
1029
+ }
1030
+ /** Check if a cached entry is still fresh (not expired by gcTime). */
1031
+ function isCacheFresh(entry, record) {
1032
+ const gcTime = record.gcTime ?? 3e5;
1033
+ if (gcTime === 0) return false;
1034
+ return Date.now() - entry.timestamp < gcTime;
1035
+ }
1036
+ /**
1037
+ * Execute a loader with cache + dedup:
1038
+ * 1. Cache hit + fresh → return cached data (skip loader entirely)
1039
+ * 2. In-flight for same key → dedup (return existing promise)
1040
+ * 3. Otherwise → run loader, cache result, clean up in-flight
1041
+ */
1042
+ function executeLoader(record, loaderCtx) {
1043
+ if (!record.loader) return Promise.resolve(void 0);
1044
+ const key = getCacheKey(record, loaderCtx);
1045
+ if (!record.staleWhileRevalidate) {
1046
+ const cached = router._loaderCache.get(key);
1047
+ if (cached && isCacheFresh(cached, record)) {
1048
+ if (__DEV__) _countSink.__pyreon_count__?.("router.loaderCache.hit");
1049
+ return Promise.resolve(cached.data);
1050
+ }
1051
+ }
1052
+ const inflight = router._loaderInflight.get(key);
1053
+ if (inflight) return inflight;
1054
+ 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);
1061
+ return data;
1062
+ }).catch((err) => {
1063
+ router._loaderInflight.delete(key);
1064
+ throw err;
1065
+ });
1066
+ router._loaderInflight.set(key, promise);
1067
+ return promise;
1068
+ }
957
1069
  async function runBlockingLoaders(records, to, gen, ac) {
958
1070
  const loaderCtx = {
959
1071
  params: to.params,
960
1072
  query: to.query,
961
1073
  signal: ac.signal
962
1074
  };
963
- const results = await Promise.allSettled(records.map((r) => r.loader ? r.loader(loaderCtx) : Promise.resolve(void 0)));
1075
+ const results = await Promise.allSettled(records.map((r) => executeLoader(r, loaderCtx)));
964
1076
  if (gen !== _navGen) return false;
965
1077
  for (let i = 0; i < records.length; i++) {
966
1078
  const result = results[i];
@@ -982,6 +1094,11 @@ function createRouter(options) {
982
1094
  r.loader(loaderCtx).then((data) => {
983
1095
  if (!ac.signal.aborted) {
984
1096
  router._loaderData.set(r, data);
1097
+ const key = getCacheKey(r, loaderCtx);
1098
+ router._loaderCache.set(key, {
1099
+ data,
1100
+ timestamp: Date.now()
1101
+ });
985
1102
  loadingSignal.update((n) => n + 1);
986
1103
  loadingSignal.update((n) => n - 1);
987
1104
  }
@@ -1059,6 +1176,8 @@ function createRouter(options) {
1059
1176
  return { action: "continue" };
1060
1177
  }
1061
1178
  async function navigate(rawPath, replace, redirectDepth = 0) {
1179
+ if (__DEV__) _countSink.__pyreon_count__?.("router.navigate");
1180
+ router._navigationStartTime = Date.now();
1062
1181
  if (redirectDepth > 10) {
1063
1182
  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
1183
  return;
@@ -1123,6 +1242,9 @@ function createRouter(options) {
1123
1242
  _readyPromise,
1124
1243
  _onError: onError,
1125
1244
  _maxCacheSize: maxCacheSize,
1245
+ _navigationStartTime: Date.now(),
1246
+ _loaderCache: /* @__PURE__ */ new Map(),
1247
+ _loaderInflight: /* @__PURE__ */ new Map(),
1126
1248
  async push(location) {
1127
1249
  if (typeof location === "string") return navigate(sanitizePath(resolveRelativePath(location, currentPath())), false);
1128
1250
  return navigate(resolveNamedPath(location.name, location.params ?? {}, location.query ?? {}, nameIndex), false);
@@ -1181,6 +1303,22 @@ function createRouter(options) {
1181
1303
  router._loaderData.set(r, data);
1182
1304
  }));
1183
1305
  },
1306
+ invalidateLoader(keyOrPredicate) {
1307
+ if (!keyOrPredicate) {
1308
+ router._loaderCache.clear();
1309
+ router._loaderInflight.clear();
1310
+ return;
1311
+ }
1312
+ if (typeof keyOrPredicate === "string") {
1313
+ router._loaderCache.delete(keyOrPredicate);
1314
+ router._loaderInflight.delete(keyOrPredicate);
1315
+ return;
1316
+ }
1317
+ for (const key of [...router._loaderCache.keys()]) if (keyOrPredicate(key)) {
1318
+ router._loaderCache.delete(key);
1319
+ router._loaderInflight.delete(key);
1320
+ }
1321
+ },
1184
1322
  destroy() {
1185
1323
  if (_popstateHandler) window.removeEventListener("popstate", _popstateHandler);
1186
1324
  if (_hashchangeHandler) window.removeEventListener("hashchange", _hashchangeHandler);
@@ -1190,6 +1328,8 @@ function createRouter(options) {
1190
1328
  router._blockers.clear();
1191
1329
  componentCache.clear();
1192
1330
  router._loaderData.clear();
1331
+ router._loaderCache.clear();
1332
+ router._loaderInflight.clear();
1193
1333
  router._abortController?.abort();
1194
1334
  router._abortController = null;
1195
1335
  if (_activeRouter === router) _activeRouter = null;
@@ -1214,7 +1354,10 @@ async function runGuard(guard, to, from) {
1214
1354
  }
1215
1355
  function resolveNamedPath(name, params, query, index) {
1216
1356
  const record = index.get(name);
1217
- if (!record) return "/";
1357
+ if (!record) {
1358
+ if (__DEV__) console.warn(`[Pyreon Router] Unknown route name "${name}". Available names: ${[...index.keys()].join(", ") || "(none)"}. Falling back to "/".`);
1359
+ return "/";
1360
+ }
1218
1361
  let path = buildPath(record.path, params);
1219
1362
  const qs = Object.entries(query).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
1220
1363
  if (qs) path += `?${qs}`;
@@ -1330,19 +1473,31 @@ const RouterView = (props) => {
1330
1473
  };
1331
1474
  const RouterLink = (props) => {
1332
1475
  const router = useContext(RouterContext);
1333
- const prefetchMode = props.prefetch ?? "hover";
1476
+ const prefetchMode = props.prefetch ?? "intent";
1334
1477
  const handleClick = (e) => {
1335
1478
  e.preventDefault();
1336
1479
  if (!router) return;
1337
1480
  if (props.replace) router.replace(props.to);
1338
1481
  else router.push(props.to);
1339
1482
  };
1340
- const handleMouseEnter = () => {
1341
- if (prefetchMode !== "hover" || !router) return;
1483
+ const triggerPrefetch = () => {
1484
+ if (!router) return;
1342
1485
  prefetchRoute(router, props.to);
1343
1486
  };
1487
+ const handleMouseEnter = () => {
1488
+ if (prefetchMode === "hover" || prefetchMode === "intent") triggerPrefetch();
1489
+ };
1490
+ const handleFocus = () => {
1491
+ if (prefetchMode === "intent") triggerPrefetch();
1492
+ };
1344
1493
  const inst = router;
1345
1494
  const href = inst?.mode === "history" ? `${inst._base}${props.to}` : `#${props.to}`;
1495
+ const isExactMatch = () => {
1496
+ if (!router) return false;
1497
+ const target = props.to;
1498
+ if (typeof target !== "string") return false;
1499
+ return router.currentRoute().path === target;
1500
+ };
1346
1501
  const activeClass = () => {
1347
1502
  if (!router) return "";
1348
1503
  const current = router.currentRoute().path;
@@ -1355,6 +1510,7 @@ const RouterLink = (props) => {
1355
1510
  if (isExact) classes.push(props.exactActiveClass ?? "router-link-exact-active");
1356
1511
  return classes.join(" ").trim();
1357
1512
  };
1513
+ const ariaCurrent = () => isExactMatch() ? "page" : void 0;
1358
1514
  const ref = createRef();
1359
1515
  if (prefetchMode === "viewport" && router && typeof IntersectionObserver !== "undefined") {
1360
1516
  const observer = new IntersectionObserver((entries) => {
@@ -1375,11 +1531,14 @@ const RouterLink = (props) => {
1375
1531
  ref,
1376
1532
  href,
1377
1533
  class: activeClass,
1534
+ "aria-current": ariaCurrent,
1378
1535
  onClick: handleClick,
1379
- onMouseEnter: handleMouseEnter
1536
+ onMouseEnter: handleMouseEnter,
1537
+ onFocus: handleFocus
1380
1538
  }, children ?? props.to);
1381
1539
  };
1382
1540
  /** Prefetch loader data for a route (only once per router + path). */
1541
+ const MAX_PREFETCH_CACHE = 50;
1383
1542
  function prefetchRoute(router, path) {
1384
1543
  let set = _prefetched.get(router);
1385
1544
  if (!set) {
@@ -1387,6 +1546,10 @@ function prefetchRoute(router, path) {
1387
1546
  _prefetched.set(router, set);
1388
1547
  }
1389
1548
  if (set.has(path)) return;
1549
+ if (set.size >= MAX_PREFETCH_CACHE) {
1550
+ const first = set.values().next().value;
1551
+ set.delete(first);
1552
+ }
1390
1553
  set.add(path);
1391
1554
  prefetchLoaderData(router, path).catch(() => {
1392
1555
  set?.delete(path);
@@ -1431,13 +1594,88 @@ function renderWithLoader(router, record, Comp, route) {
1431
1594
  }
1432
1595
  function renderLoaderContent(router, record, Comp, routeProps) {
1433
1596
  const data = router._loaderData.get(record);
1434
- if (data === void 0 && record.errorComponent) return h(record.errorComponent, routeProps);
1597
+ if (data !== void 0) return h(LoaderDataProvider, {
1598
+ data,
1599
+ children: h(Comp, routeProps)
1600
+ });
1601
+ if (record.pendingComponent) return h(PendingLoader, {
1602
+ router,
1603
+ record,
1604
+ Comp,
1605
+ routeProps
1606
+ });
1607
+ if (record.errorComponent) return h(record.errorComponent, routeProps);
1435
1608
  return h(LoaderDataProvider, {
1436
1609
  data,
1437
1610
  children: h(Comp, routeProps)
1438
1611
  });
1439
1612
  }
1440
1613
  /**
1614
+ * Signal-based pending component with timing control.
1615
+ *
1616
+ * State machine: hidden → pending → ready
1617
+ * - hidden: initial state, nothing shown (lasts pendingMs)
1618
+ * - pending: pendingComponent shown (lasts at least pendingMinMs)
1619
+ * - ready: real component shown (loader data arrived + minTime elapsed)
1620
+ */
1621
+ function PendingLoader(props) {
1622
+ const { router, record, Comp, routeProps } = props;
1623
+ const pendingMs = record.pendingMs ?? 0;
1624
+ const pendingMinMs = record.pendingMinMs ?? 200;
1625
+ const phase = signal(pendingMs === 0 ? "pending" : "hidden");
1626
+ let pendingTimer = null;
1627
+ let minTimer = null;
1628
+ let minTimeElapsed = pendingMs === 0 ? false : true;
1629
+ let dataReady = false;
1630
+ if (pendingMs === 0) {
1631
+ minTimeElapsed = false;
1632
+ minTimer = setTimeout(() => {
1633
+ minTimeElapsed = true;
1634
+ minTimer = null;
1635
+ if (dataReady) phase.set("ready");
1636
+ }, pendingMinMs);
1637
+ } else pendingTimer = setTimeout(() => {
1638
+ pendingTimer = null;
1639
+ if (dataReady) phase.set("ready");
1640
+ else {
1641
+ phase.set("pending");
1642
+ minTimeElapsed = false;
1643
+ minTimer = setTimeout(() => {
1644
+ minTimeElapsed = true;
1645
+ minTimer = null;
1646
+ if (dataReady) phase.set("ready");
1647
+ }, pendingMinMs);
1648
+ }
1649
+ }, pendingMs);
1650
+ const checkData = () => {
1651
+ if (router._loaderData.get(record) !== void 0) {
1652
+ dataReady = true;
1653
+ if (phase.peek() === "hidden") {
1654
+ if (pendingTimer) {
1655
+ clearTimeout(pendingTimer);
1656
+ pendingTimer = null;
1657
+ }
1658
+ phase.set("ready");
1659
+ } else if (minTimeElapsed) phase.set("ready");
1660
+ }
1661
+ };
1662
+ onUnmount(() => {
1663
+ if (pendingTimer) clearTimeout(pendingTimer);
1664
+ if (minTimer) clearTimeout(minTimer);
1665
+ });
1666
+ return (() => {
1667
+ router._loadingSignal();
1668
+ checkData();
1669
+ const p = phase();
1670
+ if (p === "hidden") return null;
1671
+ if (p === "pending") return h(record.pendingComponent, routeProps);
1672
+ return h(LoaderDataProvider, {
1673
+ data: router._loaderData.get(record),
1674
+ children: h(Comp, routeProps)
1675
+ });
1676
+ });
1677
+ }
1678
+ /**
1441
1679
  * Thin provider component that pushes LoaderDataContext before children mount.
1442
1680
  * Uses Pyreon's context stack so useLoaderData() reads it during child setup.
1443
1681
  */
@@ -1476,5 +1714,55 @@ function isStaleChunk(err) {
1476
1714
  }
1477
1715
 
1478
1716
  //#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 };
1717
+ //#region src/not-found.ts
1718
+ const NOT_FOUND = Symbol.for("pyreon.notFound");
1719
+ /**
1720
+ * Throw inside a route loader or component to trigger the nearest
1721
+ * NotFoundBoundary. Inspired by Next.js's `notFound()`.
1722
+ *
1723
+ * @example
1724
+ * ```ts
1725
+ * // In a loader:
1726
+ * loader: async ({ params }) => {
1727
+ * const user = await fetchUser(params.id)
1728
+ * if (!user) notFound()
1729
+ * return user
1730
+ * }
1731
+ * ```
1732
+ */
1733
+ function notFound(message) {
1734
+ const err = new Error(message ?? "Not Found");
1735
+ err[NOT_FOUND] = true;
1736
+ throw err;
1737
+ }
1738
+ /** Check if an error is a NotFoundError thrown by `notFound()`. */
1739
+ function isNotFoundError(err) {
1740
+ return typeof err === "object" && err !== null && err[NOT_FOUND] === true;
1741
+ }
1742
+ /**
1743
+ * Catches `notFound()` errors from child route components or loaders
1744
+ * and renders the fallback. Wraps Pyreon's ErrorBoundary with notFound
1745
+ * detection — non-notFound errors propagate to parent error boundaries.
1746
+ *
1747
+ * @example
1748
+ * ```tsx
1749
+ * <NotFoundBoundary fallback={<NotFoundPage />}>
1750
+ * <RouterView />
1751
+ * </NotFoundBoundary>
1752
+ * ```
1753
+ */
1754
+ const NotFoundBoundary = (props) => {
1755
+ return h(ErrorBoundary, { fallback: (err, reset) => {
1756
+ if (!isNotFoundError(err)) throw err;
1757
+ const fb = props.fallback;
1758
+ if (typeof fb === "function" && fb.length <= 1) return h(fb, {
1759
+ error: err,
1760
+ reset
1761
+ });
1762
+ return fb;
1763
+ } }, props.children);
1764
+ };
1765
+
1766
+ //#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 };
1480
1768
  //# sourceMappingURL=index.js.map