@pyreon/router 0.14.0 → 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.
@@ -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":"25bb3a92-1","name":"loader.ts"},{"uid":"25bb3a92-3","name":"match.ts"},{"uid":"25bb3a92-5","name":"redirect.ts"},{"uid":"25bb3a92-7","name":"scroll.ts"},{"uid":"25bb3a92-9","name":"types.ts"},{"uid":"25bb3a92-11","name":"router.ts"},{"uid":"25bb3a92-13","name":"components.tsx"},{"uid":"25bb3a92-15","name":"not-found.ts"},{"uid":"25bb3a92-17","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"25bb3a92-1":{"renderedLength":3376,"gzipLength":1507,"brotliLength":0,"metaUid":"25bb3a92-0"},"25bb3a92-3":{"renderedLength":13072,"gzipLength":3951,"brotliLength":0,"metaUid":"25bb3a92-2"},"25bb3a92-5":{"renderedLength":1966,"gzipLength":1043,"brotliLength":0,"metaUid":"25bb3a92-4"},"25bb3a92-7":{"renderedLength":2194,"gzipLength":899,"brotliLength":0,"metaUid":"25bb3a92-6"},"25bb3a92-9":{"renderedLength":385,"gzipLength":246,"brotliLength":0,"metaUid":"25bb3a92-8"},"25bb3a92-11":{"renderedLength":29129,"gzipLength":8078,"brotliLength":0,"metaUid":"25bb3a92-10"},"25bb3a92-13":{"renderedLength":10571,"gzipLength":3518,"brotliLength":0,"metaUid":"25bb3a92-12"},"25bb3a92-15":{"renderedLength":1315,"gzipLength":682,"brotliLength":0,"metaUid":"25bb3a92-14"},"25bb3a92-17":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"25bb3a92-16"}},"nodeMetas":{"25bb3a92-0":{"id":"/src/loader.ts","moduleParts":{"index.js":"25bb3a92-1"},"imported":[{"uid":"25bb3a92-18"}],"importedBy":[{"uid":"25bb3a92-16"},{"uid":"25bb3a92-12"}]},"25bb3a92-2":{"id":"/src/match.ts","moduleParts":{"index.js":"25bb3a92-3"},"imported":[],"importedBy":[{"uid":"25bb3a92-16"},{"uid":"25bb3a92-10"}]},"25bb3a92-4":{"id":"/src/redirect.ts","moduleParts":{"index.js":"25bb3a92-5"},"imported":[],"importedBy":[{"uid":"25bb3a92-16"},{"uid":"25bb3a92-10"}]},"25bb3a92-6":{"id":"/src/scroll.ts","moduleParts":{"index.js":"25bb3a92-7"},"imported":[],"importedBy":[{"uid":"25bb3a92-10"}]},"25bb3a92-8":{"id":"/src/types.ts","moduleParts":{"index.js":"25bb3a92-9"},"imported":[],"importedBy":[{"uid":"25bb3a92-16"},{"uid":"25bb3a92-10"}]},"25bb3a92-10":{"id":"/src/router.ts","moduleParts":{"index.js":"25bb3a92-11"},"imported":[{"uid":"25bb3a92-18"},{"uid":"25bb3a92-19"},{"uid":"25bb3a92-2"},{"uid":"25bb3a92-4"},{"uid":"25bb3a92-6"},{"uid":"25bb3a92-8"}],"importedBy":[{"uid":"25bb3a92-16"},{"uid":"25bb3a92-12"}]},"25bb3a92-12":{"id":"/src/components.tsx","moduleParts":{"index.js":"25bb3a92-13"},"imported":[{"uid":"25bb3a92-18"},{"uid":"25bb3a92-19"},{"uid":"25bb3a92-0"},{"uid":"25bb3a92-10"}],"importedBy":[{"uid":"25bb3a92-16"}]},"25bb3a92-14":{"id":"/src/not-found.ts","moduleParts":{"index.js":"25bb3a92-15"},"imported":[{"uid":"25bb3a92-18"}],"importedBy":[{"uid":"25bb3a92-16"}]},"25bb3a92-16":{"id":"/src/index.ts","moduleParts":{"index.js":"25bb3a92-17"},"imported":[{"uid":"25bb3a92-12"},{"uid":"25bb3a92-14"},{"uid":"25bb3a92-4"},{"uid":"25bb3a92-0"},{"uid":"25bb3a92-2"},{"uid":"25bb3a92-10"},{"uid":"25bb3a92-8"}],"importedBy":[],"isEntry":true},"25bb3a92-18":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"25bb3a92-12"},{"uid":"25bb3a92-14"},{"uid":"25bb3a92-0"},{"uid":"25bb3a92-10"}]},"25bb3a92-19":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"25bb3a92-12"},{"uid":"25bb3a92-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
  }));
@@ -275,7 +282,8 @@ function flattenOne(result, c, parentSegments, chain, meta) {
275
282
  if (c.children && c.children.length > 0) flattenWalk(result, c.children, parentSegments, chain, meta);
276
283
  return;
277
284
  }
278
- const joined = [...parentSegments, ...c.segments];
285
+ const childPath = c.route.path;
286
+ const joined = typeof childPath === "string" && childPath.startsWith("/") ? c.segments : [...parentSegments, ...c.segments];
279
287
  if (c.children && c.children.length > 0) flattenWalk(result, c.children, joined, chain, meta);
280
288
  result.push(makeFlatEntry(joined, chain, meta, false));
281
289
  }
@@ -513,6 +521,58 @@ function buildNameIndex(routes) {
513
521
  return index;
514
522
  }
515
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
+
516
576
  //#endregion
517
577
  //#region src/scroll.ts
518
578
  /**
@@ -611,7 +671,7 @@ function isLazy(c) {
611
671
  //#endregion
612
672
  //#region src/router.ts
613
673
  const _isBrowser = typeof window !== "undefined";
614
- const __DEV__ = import.meta.env?.DEV === true;
674
+ const __DEV__ = process.env.NODE_ENV !== "production";
615
675
  const _countSink = globalThis;
616
676
  const RouterContext = createContext(null);
617
677
  let _activeRouter = null;
@@ -992,14 +1052,19 @@ function createRouter(options) {
992
1052
  function processLoaderResult(result, record, ac, to) {
993
1053
  if (result.status === "fulfilled") {
994
1054
  router._loaderData.set(record, result.value);
995
- return true;
1055
+ return { action: "continue" };
996
1056
  }
997
- 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
+ };
998
1063
  if (router._onError) {
999
- if (router._onError(result.reason, to) === false) return false;
1064
+ if (router._onError(result.reason, to) === false) return { action: "cancel" };
1000
1065
  }
1001
1066
  router._loaderData.set(record, void 0);
1002
- return true;
1067
+ return { action: "continue" };
1003
1068
  }
1004
1069
  function syncBrowserUrl(path, replace) {
1005
1070
  if (!_isBrowser) return;
@@ -1034,6 +1099,26 @@ function createRouter(options) {
1034
1099
  return Date.now() - entry.timestamp < gcTime;
1035
1100
  }
1036
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
+ /**
1037
1122
  * Execute a loader with cache + dedup:
1038
1123
  * 1. Cache hit + fresh → return cached data (skip loader entirely)
1039
1124
  * 2. In-flight for same key → dedup (return existing promise)
@@ -1050,20 +1135,20 @@ function createRouter(options) {
1050
1135
  }
1051
1136
  }
1052
1137
  const inflight = router._loaderInflight.get(key);
1053
- if (inflight) return inflight;
1138
+ if (inflight && !inflight.signal.aborted) return inflight.promise;
1054
1139
  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);
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);
1061
1143
  return data;
1062
1144
  }).catch((err) => {
1063
- router._loaderInflight.delete(key);
1145
+ if (router._loaderInflight.get(key)?.promise === promise) router._loaderInflight.delete(key);
1064
1146
  throw err;
1065
1147
  });
1066
- router._loaderInflight.set(key, promise);
1148
+ router._loaderInflight.set(key, {
1149
+ promise,
1150
+ signal: loaderCtx.signal
1151
+ });
1067
1152
  return promise;
1068
1153
  }
1069
1154
  async function runBlockingLoaders(records, to, gen, ac) {
@@ -1073,14 +1158,15 @@ function createRouter(options) {
1073
1158
  signal: ac.signal
1074
1159
  };
1075
1160
  const results = await Promise.allSettled(records.map((r) => executeLoader(r, loaderCtx)));
1076
- if (gen !== _navGen) return false;
1161
+ if (gen !== _navGen) return { action: "cancel" };
1077
1162
  for (let i = 0; i < records.length; i++) {
1078
1163
  const result = results[i];
1079
1164
  const record = records[i];
1080
1165
  if (!result || !record) continue;
1081
- if (!processLoaderResult(result, record, ac, to)) return false;
1166
+ const outcome = processLoaderResult(result, record, ac, to);
1167
+ if (outcome.action !== "continue") return outcome;
1082
1168
  }
1083
- return true;
1169
+ return { action: "continue" };
1084
1170
  }
1085
1171
  /** Fire-and-forget background revalidation for stale-while-revalidate routes. */
1086
1172
  function revalidateSwrLoaders(records, to, ac) {
@@ -1094,11 +1180,7 @@ function createRouter(options) {
1094
1180
  r.loader(loaderCtx).then((data) => {
1095
1181
  if (!ac.signal.aborted) {
1096
1182
  router._loaderData.set(r, data);
1097
- const key = getCacheKey(r, loaderCtx);
1098
- router._loaderCache.set(key, {
1099
- data,
1100
- timestamp: Date.now()
1101
- });
1183
+ loaderCacheSet(getCacheKey(r, loaderCtx), data);
1102
1184
  loadingSignal.update((n) => n + 1);
1103
1185
  loadingSignal.update((n) => n - 1);
1104
1186
  }
@@ -1107,16 +1189,17 @@ function createRouter(options) {
1107
1189
  }
1108
1190
  async function runLoaders(to, gen, ac) {
1109
1191
  const loadableRecords = to.matched.filter((r) => r.loader);
1110
- if (loadableRecords.length === 0) return true;
1192
+ if (loadableRecords.length === 0) return { action: "continue" };
1111
1193
  const blocking = [];
1112
1194
  const swr = [];
1113
1195
  for (const r of loadableRecords) if (r.staleWhileRevalidate && router._loaderData.has(r)) swr.push(r);
1114
1196
  else blocking.push(r);
1115
1197
  if (blocking.length > 0) {
1116
- 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;
1117
1200
  }
1118
1201
  if (swr.length > 0) revalidateSwrLoaders(swr, to, ac);
1119
- return true;
1202
+ return { action: "continue" };
1120
1203
  }
1121
1204
  async function commitNavigation(path, replace, to, from) {
1122
1205
  scrollManager.save(from.path);
@@ -1211,8 +1294,10 @@ function createRouter(options) {
1211
1294
  router._abortController?.abort();
1212
1295
  const ac = new AbortController();
1213
1296
  router._abortController = ac;
1214
- if (!await runLoaders(to, gen, ac)) {
1297
+ const loaderOutcome = await runLoaders(to, gen, ac);
1298
+ if (loaderOutcome.action !== "continue") {
1215
1299
  loadingSignal.update((n) => n - 1);
1300
+ if (loaderOutcome.action === "redirect") return navigate(sanitizePath(loaderOutcome.target), replace, redirectDepth + 1);
1216
1301
  return;
1217
1302
  }
1218
1303
  await commitNavigation(path, replace, to, from);
@@ -1280,7 +1365,7 @@ function createRouter(options) {
1280
1365
  isReady() {
1281
1366
  return router._readyPromise;
1282
1367
  },
1283
- async preload(path) {
1368
+ async preload(path, request) {
1284
1369
  const resolved = resolveRoute(path, routes);
1285
1370
  await Promise.all(resolved.matched.map(async (record) => {
1286
1371
  if (componentCache.has(record)) return;
@@ -1295,11 +1380,12 @@ function createRouter(options) {
1295
1380
  }));
1296
1381
  const ac = new AbortController();
1297
1382
  await Promise.all(resolved.matched.filter((r) => r.loader).map(async (r) => {
1298
- const data = await r.loader?.({
1383
+ const data = await Promise.resolve().then(() => r.loader({
1299
1384
  params: resolved.params,
1300
1385
  query: resolved.query,
1301
- signal: ac.signal
1302
- });
1386
+ signal: ac.signal,
1387
+ ...request ? { request } : {}
1388
+ }));
1303
1389
  router._loaderData.set(r, data);
1304
1390
  }));
1305
1391
  },
@@ -1454,20 +1540,51 @@ const RouterView = (props) => {
1454
1540
  onUnmount(() => {
1455
1541
  router._viewDepth--;
1456
1542
  });
1457
- const child = () => {
1458
- router._loadingSignal();
1543
+ const depthEntry = computed(() => {
1459
1544
  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;
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;
1466
1567
  if (!isLazy(raw)) {
1467
- cacheSet(router, record, raw);
1468
- return renderWithLoader(router, record, raw, route);
1568
+ cacheSet(router, rec, raw);
1569
+ return {
1570
+ rec,
1571
+ comp: raw,
1572
+ errored: false,
1573
+ route
1574
+ };
1469
1575
  }
1470
- 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);
1471
1588
  };
1472
1589
  return h("div", { "data-pyreon-router-view": true }, child);
1473
1590
  };
@@ -1491,7 +1608,7 @@ const RouterLink = (props) => {
1491
1608
  if (prefetchMode === "intent") triggerPrefetch();
1492
1609
  };
1493
1610
  const inst = router;
1494
- const href = inst?.mode === "history" ? `${inst._base}${props.to}` : `#${props.to}`;
1611
+ const href = () => inst?.mode === "history" ? `${inst._base}${props.to}` : `#${props.to}`;
1495
1612
  const isExactMatch = () => {
1496
1613
  if (!router) return false;
1497
1614
  const target = props.to;
@@ -1525,12 +1642,15 @@ const RouterLink = (props) => {
1525
1642
  });
1526
1643
  onUnmount(() => observer.disconnect());
1527
1644
  }
1528
- 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
+ };
1529
1649
  return h("a", {
1530
1650
  ...rest,
1531
1651
  ref,
1532
1652
  href,
1533
- class: activeClass,
1653
+ class: mergedClass,
1534
1654
  "aria-current": ariaCurrent,
1535
1655
  onClick: handleClick,
1536
1656
  onMouseEnter: handleMouseEnter,
@@ -1712,6 +1832,9 @@ function isStaleChunk(err) {
1712
1832
  if (err instanceof SyntaxError) return true;
1713
1833
  return false;
1714
1834
  }
1835
+ nativeCompat(RouterProvider);
1836
+ nativeCompat(RouterView);
1837
+ nativeCompat(RouterLink);
1715
1838
 
1716
1839
  //#endregion
1717
1840
  //#region src/not-found.ts
@@ -1764,5 +1887,5 @@ const NotFoundBoundary = (props) => {
1764
1887
  };
1765
1888
 
1766
1889
  //#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 };
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 };
1768
1891
  //# sourceMappingURL=index.js.map
@@ -1,4 +1,4 @@
1
- import * as _pyreon_core0 from "@pyreon/core";
1
+ import * as _$_pyreon_core0 from "@pyreon/core";
2
2
  import { ComponentFn, ComponentFn as ComponentFn$1, Props, VNodeChild } from "@pyreon/core";
3
3
  import { Computed, Signal } from "@pyreon/reactivity";
4
4
 
@@ -111,6 +111,21 @@ interface LoaderContext {
111
111
  query: Record<string, string>;
112
112
  /** Aborted when a newer navigation supersedes this one */
113
113
  signal: AbortSignal;
114
+ /**
115
+ * The incoming HTTP `Request` — populated only when the loader runs during
116
+ * SSR (via `prefetchLoaderData`); `undefined` on every CSR navigation.
117
+ * Lets server-side loaders read cookies / auth headers and decide whether
118
+ * to `throw redirect('/login')` BEFORE the layout renders.
119
+ *
120
+ * @example
121
+ * loader: ({ request }) => {
122
+ * const cookie = request?.headers.get('cookie') ?? ''
123
+ * const sid = cookie.match(/sid=([^;]+)/)?.[1]
124
+ * if (!sid) redirect('/login')
125
+ * return { sid }
126
+ * }
127
+ */
128
+ request?: Request;
114
129
  }
115
130
  type RouteLoaderFn = (ctx: LoaderContext) => Promise<unknown>;
116
131
  interface RouteRecord<TPath extends string = string> {
@@ -313,7 +328,7 @@ interface Router<TNames extends string = string> {
313
328
  * separately when creating the router (`createRouter({ url, ... })`) or
314
329
  * call this for the same `url` you initialised the router with.
315
330
  */
316
- preload(path: string): Promise<void>;
331
+ preload(path: string, request?: Request): Promise<void>;
317
332
  /**
318
333
  * Invalidate cached loader data. Forces loaders to re-run on next navigation.
319
334
  * - No args: invalidate ALL cached loader data
@@ -363,8 +378,18 @@ interface RouterInstance extends Router {
363
378
  data: unknown;
364
379
  timestamp: number;
365
380
  }>;
366
- /** In-flight loader dedup: cacheKey → Promise */
367
- _loaderInflight: Map<string, Promise<unknown>>;
381
+ /**
382
+ * In-flight loader dedup: cacheKey → { promise, signal }.
383
+ * Tracking the signal lets dedup skip an in-flight entry whose signal is
384
+ * already aborted — otherwise nav-2 would inherit nav-1's aborted promise
385
+ * (`router.push` aborts the previous nav's controller before starting the
386
+ * next, so back-to-back nav to the same path could resolve nav-2 against
387
+ * nav-1's aborted fetch).
388
+ */
389
+ _loaderInflight: Map<string, {
390
+ promise: Promise<unknown>;
391
+ signal: AbortSignal;
392
+ }>;
368
393
  }
369
394
  //#endregion
370
395
  //#region src/components.d.ts
@@ -460,6 +485,50 @@ interface NotFoundBoundaryProps extends Props {
460
485
  */
461
486
  declare const NotFoundBoundary: ComponentFn<NotFoundBoundaryProps>;
462
487
  //#endregion
488
+ //#region src/redirect.d.ts
489
+ /** Standard redirect status codes. 307/308 preserve the request method, 302/303 don't. */
490
+ type RedirectStatus = 301 | 302 | 303 | 307 | 308;
491
+ interface RedirectInfo {
492
+ url: string;
493
+ status: RedirectStatus;
494
+ }
495
+ /**
496
+ * Throw inside a route loader to redirect the navigation server-side
497
+ * (during SSR returns a 302/307 `Location:` response) and client-side
498
+ * (during CSR triggers `router.replace()` before the layout renders).
499
+ *
500
+ * The auth-gate use case: replaces the fragile `onMount + router.push()`
501
+ * workaround. `onMount` doesn't fire reliably under nested-layout dev SSR +
502
+ * hydration — so the layout renders briefly before the push happens, leaking
503
+ * authenticated UI to unauthenticated users. `redirect()` runs in the loader
504
+ * BEFORE the layout's component is invoked, so the unauthenticated UI never
505
+ * mounts in the first place.
506
+ *
507
+ * @example
508
+ * ```ts
509
+ * // src/routes/app/_layout.tsx
510
+ * export const loader = async ({ request }) => {
511
+ * const session = await getSession(request)
512
+ * if (!session) redirect('/login')
513
+ * return { user: session.user }
514
+ * }
515
+ * ```
516
+ *
517
+ * @param url - Target URL (typically a path like `/login` or absolute URL for cross-origin).
518
+ * @param status - HTTP redirect status. Default `307` (Temporary Redirect, method-preserving).
519
+ * Use `301`/`308` for permanent moves, `302`/`303` to force GET on the target.
520
+ */
521
+ declare function redirect(url: string, status?: RedirectStatus): never;
522
+ /** Check if an error is a RedirectError thrown by `redirect()`. */
523
+ declare function isRedirectError(err: unknown): boolean;
524
+ /**
525
+ * Extract the redirect URL and status from a thrown RedirectError. Returns
526
+ * `null` if `err` isn't a RedirectError. Used by the router's loader-runner
527
+ * (CSR) and the SSR handler to convert the thrown error into the right kind
528
+ * of response (a `router.replace()` call or a `302`/`307` Response).
529
+ */
530
+ declare function getRedirectInfo(err: unknown): RedirectInfo | null;
531
+ //#endregion
463
532
  //#region src/loader.d.ts
464
533
  /**
465
534
  * Returns the data resolved by the current route's `loader` function.
@@ -478,12 +547,18 @@ declare function useLoaderData<T = unknown>(): T;
478
547
  * SSR helper: pre-run all loaders for the given path before rendering.
479
548
  * Call this before `renderToString` so route components can read data via `useLoaderData()`.
480
549
  *
550
+ * The optional `request` is forwarded to each loader's `LoaderContext.request`,
551
+ * letting server-side loaders read cookies / auth headers and `throw redirect()`
552
+ * before the layout renders. A loader that throws `redirect()` propagates the
553
+ * thrown error here — the SSR handler's `catch` converts it into a 302/307
554
+ * `Location:` Response.
555
+ *
481
556
  * @example
482
557
  * const router = createRouter({ routes, url: req.url })
483
- * await prefetchLoaderData(router, req.url)
558
+ * await prefetchLoaderData(router, req.url, request)
484
559
  * const html = await renderToString(h(App, { router }))
485
560
  */
486
- declare function prefetchLoaderData(router: RouterInstance, path: string): Promise<void>;
561
+ declare function prefetchLoaderData(router: RouterInstance, path: string, request?: Request): Promise<void>;
487
562
  /**
488
563
  * Serialize loader data to a JSON-safe plain object for embedding in SSR HTML.
489
564
  * Keys are route path patterns (stable across server and client).
@@ -533,7 +608,7 @@ declare function buildPath(pattern: string, params: Record<string, string>): str
533
608
  declare function findRouteByName(name: string, routes: RouteRecord[]): RouteRecord | null;
534
609
  //#endregion
535
610
  //#region src/router.d.ts
536
- declare const RouterContext: _pyreon_core0.Context<RouterInstance | null>;
611
+ declare const RouterContext: _$_pyreon_core0.Context<RouterInstance | null>;
537
612
  declare function useRouter(): Router;
538
613
  declare function useRoute<TPath extends string = string>(): () => ResolvedRoute<ExtractParams<TPath> & Record<string, string>, Record<string, string>>;
539
614
  /**
@@ -688,5 +763,5 @@ declare function useTransition(): () => boolean;
688
763
  declare function useMiddlewareData(): () => Record<string, unknown>;
689
764
  declare function createRouter<TNames extends string = string>(options: RouterOptions | RouteRecord[]): Router<TNames>;
690
765
  //#endregion
691
- export { type AfterEachHook, type Blocker, type BlockerFn, type ExtractParams, type LazyComponent, type LoaderContext, type NavigationGuard, type NavigationGuardResult, NotFoundBoundary, type NotFoundBoundaryProps, type ResolvedRoute, type RouteComponent, type RouteLoaderFn, type RouteMeta, type RouteMiddleware, type RouteMiddlewareContext, type RouteRecord, type Router, RouterContext, RouterLink, type RouterLinkProps, type RouterOptions, RouterProvider, type RouterProviderProps, RouterView, type RouterViewProps, type ScrollBehaviorFn, buildPath, createRouter, findRouteByName, hydrateLoaderData, isNotFoundError, lazy, notFound, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useIsActive, useLoaderData, useMiddlewareData, useRoute, useRouter, useSearchParams, useTransition, useTypedSearchParams, useValidatedSearch };
766
+ export { type AfterEachHook, type Blocker, type BlockerFn, type ExtractParams, type LazyComponent, type LoaderContext, type NavigationGuard, type NavigationGuardResult, NotFoundBoundary, type NotFoundBoundaryProps, type RedirectStatus, type ResolvedRoute, type RouteComponent, type RouteLoaderFn, type RouteMeta, type RouteMiddleware, type RouteMiddlewareContext, type RouteRecord, type Router, RouterContext, RouterLink, type RouterLinkProps, type RouterOptions, RouterProvider, type RouterProviderProps, RouterView, type RouterViewProps, type ScrollBehaviorFn, buildPath, createRouter, findRouteByName, getRedirectInfo, hydrateLoaderData, isNotFoundError, isRedirectError, lazy, notFound, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, redirect, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useIsActive, useLoaderData, useMiddlewareData, useRoute, useRouter, useSearchParams, useTransition, useTypedSearchParams, useValidatedSearch };
692
767
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/router",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Official router for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/router#readme",
6
6
  "bugs": {
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "files": [
16
16
  "lib",
17
+ "!lib/**/*.map",
17
18
  "src",
18
19
  "README.md",
19
20
  "LICENSE"
@@ -43,9 +44,9 @@
43
44
  "prepublishOnly": "bun run build"
44
45
  },
45
46
  "dependencies": {
46
- "@pyreon/core": "^0.14.0",
47
- "@pyreon/reactivity": "^0.14.0",
48
- "@pyreon/runtime-dom": "^0.14.0"
47
+ "@pyreon/core": "^0.15.0",
48
+ "@pyreon/reactivity": "^0.15.0",
49
+ "@pyreon/runtime-dom": "^0.15.0"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@happy-dom/global-registrator": "^20.8.9",