@pyreon/router 0.18.0 → 0.19.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":"93e1055e-1","name":"loader.ts"},{"uid":"93e1055e-3","name":"match.ts"},{"uid":"93e1055e-5","name":"redirect.ts"},{"uid":"93e1055e-7","name":"scroll.ts"},{"uid":"93e1055e-9","name":"types.ts"},{"uid":"93e1055e-11","name":"router.ts"},{"uid":"93e1055e-13","name":"components.tsx"},{"uid":"93e1055e-15","name":"not-found.ts"},{"uid":"93e1055e-17","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"93e1055e-1":{"renderedLength":5692,"gzipLength":2467,"brotliLength":0,"metaUid":"93e1055e-0"},"93e1055e-3":{"renderedLength":16699,"gzipLength":5143,"brotliLength":0,"metaUid":"93e1055e-2"},"93e1055e-5":{"renderedLength":1966,"gzipLength":1043,"brotliLength":0,"metaUid":"93e1055e-4"},"93e1055e-7":{"renderedLength":2194,"gzipLength":899,"brotliLength":0,"metaUid":"93e1055e-6"},"93e1055e-9":{"renderedLength":385,"gzipLength":246,"brotliLength":0,"metaUid":"93e1055e-8"},"93e1055e-11":{"renderedLength":29175,"gzipLength":8093,"brotliLength":0,"metaUid":"93e1055e-10"},"93e1055e-13":{"renderedLength":10756,"gzipLength":3576,"brotliLength":0,"metaUid":"93e1055e-12"},"93e1055e-15":{"renderedLength":1315,"gzipLength":682,"brotliLength":0,"metaUid":"93e1055e-14"},"93e1055e-17":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"93e1055e-16"}},"nodeMetas":{"93e1055e-0":{"id":"/src/loader.ts","moduleParts":{"index.js":"93e1055e-1"},"imported":[{"uid":"93e1055e-18"}],"importedBy":[{"uid":"93e1055e-16"},{"uid":"93e1055e-12"}]},"93e1055e-2":{"id":"/src/match.ts","moduleParts":{"index.js":"93e1055e-3"},"imported":[],"importedBy":[{"uid":"93e1055e-16"},{"uid":"93e1055e-12"},{"uid":"93e1055e-10"}]},"93e1055e-4":{"id":"/src/redirect.ts","moduleParts":{"index.js":"93e1055e-5"},"imported":[],"importedBy":[{"uid":"93e1055e-16"},{"uid":"93e1055e-10"}]},"93e1055e-6":{"id":"/src/scroll.ts","moduleParts":{"index.js":"93e1055e-7"},"imported":[],"importedBy":[{"uid":"93e1055e-10"}]},"93e1055e-8":{"id":"/src/types.ts","moduleParts":{"index.js":"93e1055e-9"},"imported":[],"importedBy":[{"uid":"93e1055e-16"},{"uid":"93e1055e-10"}]},"93e1055e-10":{"id":"/src/router.ts","moduleParts":{"index.js":"93e1055e-11"},"imported":[{"uid":"93e1055e-18"},{"uid":"93e1055e-19"},{"uid":"93e1055e-2"},{"uid":"93e1055e-4"},{"uid":"93e1055e-6"},{"uid":"93e1055e-8"}],"importedBy":[{"uid":"93e1055e-16"},{"uid":"93e1055e-12"}]},"93e1055e-12":{"id":"/src/components.tsx","moduleParts":{"index.js":"93e1055e-13"},"imported":[{"uid":"93e1055e-18"},{"uid":"93e1055e-19"},{"uid":"93e1055e-0"},{"uid":"93e1055e-2"},{"uid":"93e1055e-10"}],"importedBy":[{"uid":"93e1055e-16"}]},"93e1055e-14":{"id":"/src/not-found.ts","moduleParts":{"index.js":"93e1055e-15"},"imported":[{"uid":"93e1055e-18"}],"importedBy":[{"uid":"93e1055e-16"}]},"93e1055e-16":{"id":"/src/index.ts","moduleParts":{"index.js":"93e1055e-17"},"imported":[{"uid":"93e1055e-12"},{"uid":"93e1055e-14"},{"uid":"93e1055e-4"},{"uid":"93e1055e-0"},{"uid":"93e1055e-2"},{"uid":"93e1055e-10"},{"uid":"93e1055e-8"}],"importedBy":[],"isEntry":true},"93e1055e-18":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"93e1055e-12"},{"uid":"93e1055e-14"},{"uid":"93e1055e-0"},{"uid":"93e1055e-10"}]},"93e1055e-19":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"93e1055e-12"},{"uid":"93e1055e-10"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"86e1f850-1","name":"loader.ts"},{"uid":"86e1f850-3","name":"match.ts"},{"uid":"86e1f850-5","name":"redirect.ts"},{"uid":"86e1f850-7","name":"scroll.ts"},{"uid":"86e1f850-9","name":"types.ts"},{"uid":"86e1f850-11","name":"router.ts"},{"uid":"86e1f850-13","name":"components.tsx"},{"uid":"86e1f850-15","name":"not-found.ts"},{"uid":"86e1f850-17","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"86e1f850-1":{"renderedLength":6146,"gzipLength":2628,"brotliLength":0,"metaUid":"86e1f850-0"},"86e1f850-3":{"renderedLength":16699,"gzipLength":5143,"brotliLength":0,"metaUid":"86e1f850-2"},"86e1f850-5":{"renderedLength":1966,"gzipLength":1043,"brotliLength":0,"metaUid":"86e1f850-4"},"86e1f850-7":{"renderedLength":2194,"gzipLength":899,"brotliLength":0,"metaUid":"86e1f850-6"},"86e1f850-9":{"renderedLength":439,"gzipLength":262,"brotliLength":0,"metaUid":"86e1f850-8"},"86e1f850-11":{"renderedLength":31150,"gzipLength":8783,"brotliLength":0,"metaUid":"86e1f850-10"},"86e1f850-13":{"renderedLength":10955,"gzipLength":3659,"brotliLength":0,"metaUid":"86e1f850-12"},"86e1f850-15":{"renderedLength":1315,"gzipLength":682,"brotliLength":0,"metaUid":"86e1f850-14"},"86e1f850-17":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"86e1f850-16"}},"nodeMetas":{"86e1f850-0":{"id":"/src/loader.ts","moduleParts":{"index.js":"86e1f850-1"},"imported":[{"uid":"86e1f850-18"}],"importedBy":[{"uid":"86e1f850-16"},{"uid":"86e1f850-12"}]},"86e1f850-2":{"id":"/src/match.ts","moduleParts":{"index.js":"86e1f850-3"},"imported":[],"importedBy":[{"uid":"86e1f850-16"},{"uid":"86e1f850-12"},{"uid":"86e1f850-10"}]},"86e1f850-4":{"id":"/src/redirect.ts","moduleParts":{"index.js":"86e1f850-5"},"imported":[],"importedBy":[{"uid":"86e1f850-16"},{"uid":"86e1f850-10"}]},"86e1f850-6":{"id":"/src/scroll.ts","moduleParts":{"index.js":"86e1f850-7"},"imported":[],"importedBy":[{"uid":"86e1f850-10"}]},"86e1f850-8":{"id":"/src/types.ts","moduleParts":{"index.js":"86e1f850-9"},"imported":[],"importedBy":[{"uid":"86e1f850-16"},{"uid":"86e1f850-10"}]},"86e1f850-10":{"id":"/src/router.ts","moduleParts":{"index.js":"86e1f850-11"},"imported":[{"uid":"86e1f850-18"},{"uid":"86e1f850-19"},{"uid":"86e1f850-2"},{"uid":"86e1f850-4"},{"uid":"86e1f850-6"},{"uid":"86e1f850-8"}],"importedBy":[{"uid":"86e1f850-16"},{"uid":"86e1f850-12"}]},"86e1f850-12":{"id":"/src/components.tsx","moduleParts":{"index.js":"86e1f850-13"},"imported":[{"uid":"86e1f850-18"},{"uid":"86e1f850-19"},{"uid":"86e1f850-0"},{"uid":"86e1f850-2"},{"uid":"86e1f850-10"}],"importedBy":[{"uid":"86e1f850-16"}]},"86e1f850-14":{"id":"/src/not-found.ts","moduleParts":{"index.js":"86e1f850-15"},"imported":[{"uid":"86e1f850-18"}],"importedBy":[{"uid":"86e1f850-16"}]},"86e1f850-16":{"id":"/src/index.ts","moduleParts":{"index.js":"86e1f850-17"},"imported":[{"uid":"86e1f850-12"},{"uid":"86e1f850-14"},{"uid":"86e1f850-4"},{"uid":"86e1f850-0"},{"uid":"86e1f850-2"},{"uid":"86e1f850-10"},{"uid":"86e1f850-8"}],"importedBy":[],"isEntry":true},"86e1f850-18":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"86e1f850-12"},{"uid":"86e1f850-14"},{"uid":"86e1f850-0"},{"uid":"86e1f850-10"}]},"86e1f850-19":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"86e1f850-12"},{"uid":"86e1f850-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
@@ -98,18 +98,25 @@ function serializeLoaderData(router) {
98
98
  * const tag = `<script>window.__PYREON_LOADER_DATA__=${json}<\/script>`
99
99
  */
100
100
  function stringifyLoaderData(loaderData) {
101
- const seen = /* @__PURE__ */ new WeakSet();
102
- const keyStack = [];
103
- const replacer = (key, value) => {
104
- if (key !== "") keyStack.push(key);
105
- if (typeof value === "function" || typeof value === "symbol") return;
106
- if (value && typeof value === "object") {
107
- if (seen.has(value)) {
108
- const path = keyStack.join(".") || "<root>";
109
- throw new Error(`[Pyreon] Loader returned circular reference at "${path}". Loaders must return JSON-serializable data (no cycles, no functions, no Date/Map/Set without a custom replacer). Common cause: returning a Mongo/Prisma model with back-references intact.`);
110
- }
111
- seen.add(value);
101
+ const ancestors = /* @__PURE__ */ new Set();
102
+ const detectCycle = (value, path) => {
103
+ if (value === null || typeof value !== "object") return;
104
+ const v = typeof value.toJSON === "function" ? value.toJSON() : value;
105
+ if (v === null || typeof v !== "object") return;
106
+ const obj = v;
107
+ if (ancestors.has(obj)) throw new Error(`[Pyreon] Loader returned circular reference at "${path || "<root>"}". Loaders must return JSON-serializable data (no cycles, no functions, no Date/Map/Set without a custom replacer). Common cause: returning a Mongo/Prisma model with back-references intact.`);
108
+ ancestors.add(obj);
109
+ if (Array.isArray(obj)) for (let i = 0; i < obj.length; i++) detectCycle(obj[i], `${path}[${i}]`);
110
+ else for (const k of Object.keys(obj)) {
111
+ const child = obj[k];
112
+ if (typeof child === "function" || typeof child === "symbol") continue;
113
+ detectCycle(child, path ? `${path}.${k}` : k);
112
114
  }
115
+ ancestors.delete(obj);
116
+ };
117
+ detectCycle(loaderData, "");
118
+ const replacer = (_key, value) => {
119
+ if (typeof value === "function" || typeof value === "symbol") return void 0;
113
120
  return value;
114
121
  };
115
122
  return JSON.stringify(loaderData, replacer).replace(/<\//g, "<\\/");
@@ -809,7 +816,8 @@ function lazy(loader, options) {
809
816
  [LAZY_SYMBOL]: true,
810
817
  loader,
811
818
  ...options?.loading ? { loadingComponent: options.loading } : {},
812
- ...options?.error ? { errorComponent: options.error } : {}
819
+ ...options?.error ? { errorComponent: options.error } : {},
820
+ ...options?.hmrId ? { _hmrId: options.hmrId } : {}
813
821
  };
814
822
  }
815
823
  function isLazy(c) {
@@ -1332,7 +1340,10 @@ function createRouter(options) {
1332
1340
  loadingSignal.update((n) => n + 1);
1333
1341
  loadingSignal.update((n) => n - 1);
1334
1342
  }
1335
- }).catch(() => {});
1343
+ }).catch((err) => {
1344
+ if (__DEV__) console.warn(`[Pyreon Router] SWR background revalidation failed for "${r.path}" — serving stale data:`, err);
1345
+ router._onError?.(err, to);
1346
+ });
1336
1347
  }
1337
1348
  }
1338
1349
  async function runLoaders(to, gen, ac) {
@@ -1355,7 +1366,7 @@ function createRouter(options) {
1355
1366
  currentPath.set(path);
1356
1367
  syncBrowserUrl(path, replace);
1357
1368
  if (_isBrowser && to.meta.title) document.title = to.meta.title;
1358
- for (const record of router._loaderData.keys()) if (!to.matched.includes(record)) router._loaderData.delete(record);
1369
+ for (const record of router._loaderData.keys()) if (!to.matched.includes(record) && !record.staleWhileRevalidate) router._loaderData.delete(record);
1359
1370
  };
1360
1371
  if (_isBrowser && to.meta.viewTransition !== false && typeof document.startViewTransition === "function") {
1361
1372
  const vt = document.startViewTransition(() => {
@@ -1568,8 +1579,29 @@ function createRouter(options) {
1568
1579
  router._abortController?.abort();
1569
1580
  router._abortController = null;
1570
1581
  if (_activeRouter === router) _activeRouter = null;
1582
+ if (__DEV__ && _isBrowser) {
1583
+ const g = globalThis;
1584
+ if (g.__pyreon_hmr_swap__ === router._hmrSwap) delete g.__pyreon_hmr_swap__;
1585
+ }
1571
1586
  },
1572
- _resolve: (rawPath) => resolveRoute(rawPath, routes)
1587
+ _resolve: (rawPath) => resolveRoute(rawPath, routes),
1588
+ ...__DEV__ && _isBrowser ? { _hmrSwap(id, mod) {
1589
+ const m = mod;
1590
+ const next = typeof m === "function" ? m : m?.default ?? void 0;
1591
+ if (typeof next !== "function") return false;
1592
+ const matched = currentRoute().matched;
1593
+ let changed = false;
1594
+ for (const record of matched) {
1595
+ const raw = record.component;
1596
+ if (!isLazy(raw) || !raw._hmrId) continue;
1597
+ if (!_hmrIdMatches(raw._hmrId, id)) continue;
1598
+ componentCache.set(record, next);
1599
+ router._erroredChunks.delete(record);
1600
+ changed = true;
1601
+ }
1602
+ if (changed) loadingSignal.update((n) => n + 1);
1603
+ return changed;
1604
+ } } : {}
1573
1605
  };
1574
1606
  queueMicrotask(() => {
1575
1607
  if (router._readyResolve) {
@@ -1577,8 +1609,26 @@ function createRouter(options) {
1577
1609
  router._readyResolve = null;
1578
1610
  }
1579
1611
  });
1612
+ if (__DEV__ && _isBrowser && router._hmrSwap) globalThis.__pyreon_hmr_swap__ = router._hmrSwap;
1580
1613
  return router;
1581
1614
  }
1615
+ /**
1616
+ * Match a lazy route's `_hmrId` (emitted by `@pyreon/zero`'s fs-router as the
1617
+ * absolute route-file path) against the module id `@pyreon/vite-plugin`'s
1618
+ * accept handler reports. Both are absolute paths to the same file but may
1619
+ * differ in query suffix (`?t=…`, `?v=…`) or, in some Vite setups, a `/@fs`
1620
+ * prefix. Strip queries, then accept exact equality OR a suffix match on the
1621
+ * longer path — route-file paths are unique within an app so suffix matching
1622
+ * can't cross-fire. A miss makes `_hmrSwap` return false → the plugin falls
1623
+ * back to an automatic reload (correct, just not in-place), so a too-strict
1624
+ * match degrades safely rather than swapping the wrong component.
1625
+ */
1626
+ function _hmrIdMatches(recordId, incomingId) {
1627
+ const a = recordId.split("?")[0] ?? recordId;
1628
+ const b = incomingId.split("?")[0] ?? incomingId;
1629
+ if (a === b) return true;
1630
+ return a.length >= b.length ? a.endsWith(b) : b.endsWith(a);
1631
+ }
1582
1632
  async function runGuard(guard, to, from) {
1583
1633
  try {
1584
1634
  return await guard(to, from);
@@ -1779,13 +1829,18 @@ const RouterLink = (props) => {
1779
1829
  const ariaCurrent = () => isExactMatch() ? "page" : void 0;
1780
1830
  const ref = createRef();
1781
1831
  if (prefetchMode === "viewport" && router && typeof IntersectionObserver !== "undefined") {
1832
+ const ric = globalThis.requestIdleCallback;
1833
+ const scheduleIdle = (fn) => {
1834
+ if (typeof ric === "function") ric(fn);
1835
+ else setTimeout(fn, 1);
1836
+ };
1782
1837
  const observer = new IntersectionObserver((entries) => {
1783
1838
  for (const entry of entries) if (entry.isIntersecting) {
1784
- prefetchRoute(router, props.to);
1785
1839
  observer.disconnect();
1840
+ scheduleIdle(() => prefetchRoute(router, props.to));
1786
1841
  break;
1787
1842
  }
1788
- });
1843
+ }, { rootMargin: "200px" });
1789
1844
  queueMicrotask(() => {
1790
1845
  observer.observe(ref.current);
1791
1846
  });
@@ -76,12 +76,24 @@ interface LazyComponent {
76
76
  readonly loadingComponent?: ComponentFn$1;
77
77
  /** Optional component shown after all retries have failed */
78
78
  readonly errorComponent?: ComponentFn$1;
79
+ /**
80
+ * Dev-only module id, emitted by `@pyreon/zero`'s fs-router codegen as
81
+ * `lazy(() => import("/abs/X"), { hmrId: "/abs/X" })`. The HMR coordinator
82
+ * keys the active route's matched records by this id so a hot-updated
83
+ * module can be swapped IN PLACE (no page reload) using the fresh module
84
+ * Vite hands the `import.meta.hot.accept` callback — sidestepping the
85
+ * stale-`?t=` problem where re-running the dynamic-import thunk inside a
86
+ * non-invalidated virtual routes module would return the OLD module.
87
+ * Inert in production (no coordinator is registered when not in dev).
88
+ */
89
+ readonly _hmrId?: string;
79
90
  }
80
91
  declare function lazy(loader: () => Promise<ComponentFn$1 | {
81
92
  default: ComponentFn$1;
82
93
  }>, options?: {
83
94
  loading?: ComponentFn$1;
84
95
  error?: ComponentFn$1;
96
+ hmrId?: string;
85
97
  }): LazyComponent;
86
98
  type RouteComponent = ComponentFn$1 | LazyComponent;
87
99
  type NavigationGuardResult = boolean | string | undefined;
@@ -409,6 +421,28 @@ interface RouterInstance extends Router {
409
421
  promise: Promise<unknown>;
410
422
  signal: AbortSignal;
411
423
  }>;
424
+ /**
425
+ * Dev-only HMR coordinator. Given a hot-updated module's id and the FRESH
426
+ * module namespace Vite handed `import.meta.hot.accept`, swaps the new
427
+ * component into every matched record whose lazy `_hmrId` equals `id`,
428
+ * then bumps `_loadingSignal` so `RouterView` re-renders ONLY that subtree
429
+ * in place — no page reload, so `__pyreon_hmr_registry__` (module-scope
430
+ * signal values) survives and `__hmr_signal` restores them.
431
+ *
432
+ * Using the namespace Vite passed (not a re-run of the lazy thunk)
433
+ * sidesteps the stale-`?t=` trap: the dynamic-import thunk lives in the
434
+ * virtual routes module, which is NOT invalidated when a leaf route
435
+ * self-accepts, so re-importing it would return the OLD module.
436
+ *
437
+ * Returns `true` when at least one matched component was swapped. `false`
438
+ * tells `@pyreon/vite-plugin`'s accept handler the edit was outside the
439
+ * active route tree (a nested non-route component, an unrelated route, a
440
+ * signal-only module) so it falls back to `import.meta.hot.invalidate()`
441
+ * → an automatic full reload (no manual refresh either way).
442
+ *
443
+ * Present only when the router is created in a dev browser context.
444
+ */
445
+ _hmrSwap?: (id: string, mod: unknown) => boolean;
412
446
  }
413
447
  //#endregion
414
448
  //#region src/components.d.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/router",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Official router for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/router#readme",
6
6
  "bugs": {
@@ -44,14 +44,14 @@
44
44
  "prepublishOnly": "bun run build"
45
45
  },
46
46
  "dependencies": {
47
- "@pyreon/core": "^0.18.0",
48
- "@pyreon/reactivity": "^0.18.0",
49
- "@pyreon/runtime-dom": "^0.18.0"
47
+ "@pyreon/core": "^0.19.0",
48
+ "@pyreon/reactivity": "^0.19.0",
49
+ "@pyreon/runtime-dom": "^0.19.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@happy-dom/global-registrator": "^20.8.9",
53
53
  "@pyreon/manifest": "0.13.1",
54
- "@pyreon/test-utils": "^0.13.5",
54
+ "@pyreon/test-utils": "^0.13.6",
55
55
  "@vitest/browser-playwright": "^4.1.4",
56
56
  "happy-dom": "^20.8.3"
57
57
  }
@@ -275,18 +275,46 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
275
275
 
276
276
  const ariaCurrent = (): string | undefined => isExactMatch() ? 'page' : undefined
277
277
 
278
- // Viewport prefetching — observe link visibility with IntersectionObserver
278
+ // Viewport prefetching — observe link visibility with IntersectionObserver.
279
+ //
280
+ // Two refinements over the naive "fire prefetch the instant the link
281
+ // intersects" shape:
282
+ //
283
+ // 1. `rootMargin: '200px'` — start the prefetch BEFORE the link is
284
+ // fully on screen. By the time the user scrolls to it and clicks,
285
+ // the loader data is typically already resolved. Matches the
286
+ // margin instant.page / Astro use; 0px (the previous default)
287
+ // only started the fetch once the link was already visible,
288
+ // leaving a window where a fast scroll-then-click still waited.
289
+ // 2. Schedule the prefetch via `requestIdleCallback` so it never
290
+ // contends with active scrolling / paint. Prefetch is best-effort
291
+ // background work — running it in an idle slice keeps the main
292
+ // thread free for the scroll the user is actively performing.
293
+ // Falls back to a 1ms `setTimeout` where rIC is unavailable
294
+ // (Safari < 16.4, jsdom) so the behaviour degrades, not breaks.
279
295
  const ref = createRef<Element>()
280
296
  if (prefetchMode === 'viewport' && router && typeof IntersectionObserver !== 'undefined') {
281
- const observer = new IntersectionObserver((entries) => {
282
- for (const entry of entries) {
283
- if (entry.isIntersecting) {
284
- prefetchRoute(router as RouterInstance, props.to)
285
- observer.disconnect()
286
- break
297
+ const ric = (
298
+ globalThis as { requestIdleCallback?: (cb: () => void) => number }
299
+ ).requestIdleCallback
300
+ const scheduleIdle = (fn: () => void): void => {
301
+ if (typeof ric === 'function') ric(fn)
302
+ else setTimeout(fn, 1)
303
+ }
304
+ const observer = new IntersectionObserver(
305
+ (entries) => {
306
+ for (const entry of entries) {
307
+ if (entry.isIntersecting) {
308
+ // Disconnect synchronously so a re-intersection (scroll
309
+ // jitter) before the idle callback runs can't double-schedule.
310
+ observer.disconnect()
311
+ scheduleIdle(() => prefetchRoute(router as RouterInstance, props.to))
312
+ break
313
+ }
287
314
  }
288
- }
289
- })
315
+ },
316
+ { rootMargin: '200px' },
317
+ )
290
318
  // Observe after mount — the ref will be populated once the element is in the DOM
291
319
  queueMicrotask(() => {
292
320
  observer.observe(ref.current as Element)
package/src/loader.ts CHANGED
@@ -118,30 +118,56 @@ export function serializeLoaderData(router: RouterInstance): Record<string, unkn
118
118
  * const tag = `<script>window.__PYREON_LOADER_DATA__=${json}</script>`
119
119
  */
120
120
  export function stringifyLoaderData(loaderData: Record<string, unknown>): string {
121
- const seen = new WeakSet<object>()
122
- const keyStack: string[] = []
123
- const replacer = (key: string, value: unknown): unknown => {
124
- // JSON.stringify calls the replacer with key = '' for the root, then
125
- // the property name for each subsequent member. Track the path so the
126
- // circular-ref error message names the offending route key.
127
- if (key !== '') keyStack.push(key)
128
- if (typeof value === 'function' || typeof value === 'symbol') {
129
- // Drop silently. JSON.stringify already drops these as VALUES, but
130
- // an explicit drop also handles array entries (where it'd convert
131
- // to null otherwise undesirable for downstream typed hydration).
132
- return undefined
121
+ // True cycle detection: track the ANCESTOR PATH only (add on descend,
122
+ // remove on ascend), NOT every object ever visited. The prior
123
+ // implementation kept an all-seen WeakSet that was never pruned, so any
124
+ // object referenced more than once a DAG, not a cycle — falsely threw
125
+ // "circular reference" and 500'd the SSR response. Shared references are
126
+ // extremely common in loader payloads (`{ author: user, lastEditor: user }`
127
+ // where both are the same ORM instance; a list whose rows share a lookup
128
+ // object). `JSON.stringify` serializes those fine; only a real cycle must
129
+ // throw. A `JSON.stringify` replacer has no "leave" hook, so cycle
130
+ // detection runs as a single recursive pre-pass that maintains the
131
+ // ancestor set, then `JSON.stringify` does the (now cycle-free) encode.
132
+ const ancestors = new Set<object>()
133
+ const detectCycle = (value: unknown, path: string): void => {
134
+ if (value === null || typeof value !== 'object') return
135
+ // Respect `toJSON` so detection matches what JSON.stringify actually
136
+ // serializes (Date/etc. become primitives — no cycle through them).
137
+ const v =
138
+ typeof (value as { toJSON?: unknown }).toJSON === 'function'
139
+ ? (value as { toJSON: () => unknown }).toJSON()
140
+ : value
141
+ if (v === null || typeof v !== 'object') return
142
+ const obj = v as object
143
+ if (ancestors.has(obj)) {
144
+ throw new Error(
145
+ `[Pyreon] Loader returned circular reference at "${path || '<root>'}". ` +
146
+ `Loaders must return JSON-serializable data (no cycles, no functions, no Date/Map/Set without a custom replacer). ` +
147
+ `Common cause: returning a Mongo/Prisma model with back-references intact.`,
148
+ )
133
149
  }
134
- if (value && typeof value === 'object') {
135
- if (seen.has(value as object)) {
136
- const path = keyStack.join('.') || '<root>'
137
- throw new Error(
138
- `[Pyreon] Loader returned circular reference at "${path}". ` +
139
- `Loaders must return JSON-serializable data (no cycles, no functions, no Date/Map/Set without a custom replacer). ` +
140
- `Common cause: returning a Mongo/Prisma model with back-references intact.`,
141
- )
150
+ ancestors.add(obj)
151
+ if (Array.isArray(obj)) {
152
+ for (let i = 0; i < obj.length; i++) detectCycle(obj[i], `${path}[${i}]`)
153
+ } else {
154
+ for (const k of Object.keys(obj)) {
155
+ const child = (obj as Record<string, unknown>)[k]
156
+ // Mirror the encode-time drop: function/symbol values are not
157
+ // serialized, so a cycle reachable only THROUGH one can't occur.
158
+ if (typeof child === 'function' || typeof child === 'symbol') continue
159
+ detectCycle(child, path ? `${path}.${k}` : k)
142
160
  }
143
- seen.add(value as object)
144
161
  }
162
+ ancestors.delete(obj) // ascend — siblings / shared refs are NOT cycles
163
+ }
164
+ detectCycle(loaderData, '')
165
+
166
+ const replacer = (_key: string, value: unknown): unknown => {
167
+ // Drop silently. JSON.stringify already drops these as VALUES, but an
168
+ // explicit drop also handles array entries (where it'd convert to null
169
+ // otherwise — undesirable for downstream typed hydration).
170
+ if (typeof value === 'function' || typeof value === 'symbol') return undefined
145
171
  return value
146
172
  }
147
173
  return JSON.stringify(loaderData, replacer).replace(/<\//g, '<\\/')
package/src/manifest.ts CHANGED
@@ -48,7 +48,7 @@ const User = () => {
48
48
  const data = useLoaderData<UserData>()
49
49
  const router = useRouter()
50
50
  const isAdmin = useIsActive("/admin")
51
- const { isTransitioning } = useTransition()
51
+ const isTransitioning = useTransition()
52
52
  const params = useTypedSearchParams({ tab: "string", page: "number" })
53
53
 
54
54
  return (
@@ -214,10 +214,10 @@ params.set({ page: 2 }) // updates URL`,
214
214
  {
215
215
  name: 'useTransition',
216
216
  kind: 'hook',
217
- signature: 'useTransition(): { isTransitioning: () => boolean }',
217
+ signature: 'useTransition(): () => boolean',
218
218
  summary:
219
- 'Reactive signal for route transition state. `isTransitioning()` is true during navigation (while guards run + loaders resolve), false when the new route is mounted. Useful for progress bars and global loading indicators.',
220
- example: `const { isTransitioning } = useTransition()
219
+ 'Returns a reactive accessor for route transition state. The accessor is true during navigation (while guards run + loaders resolve), false when the new route is mounted. Call it inside a reactive scope. Useful for progress bars and global loading indicators.',
220
+ example: `const isTransitioning = useTransition()
221
221
 
222
222
  <Show when={isTransitioning()}>
223
223
  <ProgressBar />
@@ -227,17 +227,17 @@ params.set({ page: 2 }) // updates URL`,
227
227
  {
228
228
  name: 'useMiddlewareData',
229
229
  kind: 'hook',
230
- signature: 'useMiddlewareData<T>(): T',
230
+ signature: 'useMiddlewareData(): () => Record<string, unknown>',
231
231
  summary:
232
- 'Read data set by `RouteMiddleware` in the middleware chain. Middleware functions receive `ctx` with a mutable `ctx.data` object — properties set there are available to all downstream components via this hook.',
232
+ 'Returns a reactive accessor for data set by `RouteMiddleware` in the middleware chain. Middleware functions receive `ctx` with a mutable `ctx.data` object — properties set there are read by calling the returned accessor inside a reactive scope.',
233
233
  example: `// Middleware:
234
234
  const authMiddleware: RouteMiddleware = async (ctx) => {
235
235
  ctx.data.user = await getUser(ctx.to)
236
236
  }
237
237
 
238
238
  // Component:
239
- const data = useMiddlewareData<{ user: User }>()
240
- // data.user is available`,
239
+ const data = useMiddlewareData()
240
+ // data().user is available`,
241
241
  seeAlso: ['createRouter', 'useLoaderData'],
242
242
  },
243
243
  {
package/src/router.ts CHANGED
@@ -754,8 +754,27 @@ export function createRouter<TNames extends string = string>(
754
754
  loadingSignal.update((n) => n - 1)
755
755
  }
756
756
  })
757
- .catch(() => {
758
- /* Background revalidation failure — stale data remains valid */
757
+ .catch((err: unknown) => {
758
+ // Background revalidation failedthe stale data remains valid
759
+ // and on screen, so this MUST NOT cancel/redirect the (already
760
+ // settled) navigation. But an empty catch is the silent-failure
761
+ // anti-pattern the project forbids: a persistently-failing
762
+ // revalidation loader (auth expiry, API outage, a bug thrown in
763
+ // the loader) produces ZERO signal — the developer sees
764
+ // permanently-stale data with nothing pointing at the cause.
765
+ // Surface it like every other loader error (dev warn + the
766
+ // user-supplied onError hook) WITHOUT acting on the return
767
+ // value. This path was dead code until the SWR prune fix
768
+ // (#617) made `revalidateSwrLoaders` actually run for the
769
+ // nav-away/back case.
770
+ if (__DEV__) {
771
+ // oxlint-disable-next-line no-console
772
+ console.warn(
773
+ `[Pyreon Router] SWR background revalidation failed for "${r.path}" — serving stale data:`,
774
+ err,
775
+ )
776
+ }
777
+ router._onError?.(err, to)
759
778
  })
760
779
  }
761
780
  }
@@ -802,8 +821,20 @@ export function createRouter<TNames extends string = string>(
802
821
  document.title = to.meta.title
803
822
  }
804
823
 
824
+ // Drop loader data for routes no longer matched — EXCEPT
825
+ // `staleWhileRevalidate` routes. SWR's entire contract is "on
826
+ // return to this route, serve the previously-loaded data stale
827
+ // while revalidating in the background"; that requires the data to
828
+ // SURVIVE navigating away. Pruning it here (the pre-fix behaviour)
829
+ // meant `runLoaders`' `_loaderData.has(r)` gate was always false on
830
+ // return, so `revalidateSwrLoaders` never ran and every visit went
831
+ // through the blocking path — `staleWhileRevalidate` was a no-op
832
+ // for the realistic nav-away/back case. Retained SWR data is
833
+ // bounded by the number of SWR route RECORDS (a developer-declared
834
+ // set; param routes share one record), and per-key freshness/LRU
835
+ // is still handled by `_loaderCache`.
805
836
  for (const record of router._loaderData.keys()) {
806
- if (!to.matched.includes(record)) {
837
+ if (!to.matched.includes(record) && !record.staleWhileRevalidate) {
807
838
  router._loaderData.delete(record)
808
839
  }
809
840
  }
@@ -1193,9 +1224,48 @@ export function createRouter<TNames extends string = string>(
1193
1224
  router._abortController = null
1194
1225
  // Clear global ref so stale router doesn't survive in SSR or re-creation
1195
1226
  if (_activeRouter === router) _activeRouter = null
1227
+ if (__DEV__ && _isBrowser) {
1228
+ const g = globalThis as Record<string, unknown>
1229
+ if (g.__pyreon_hmr_swap__ === router._hmrSwap) {
1230
+ delete g.__pyreon_hmr_swap__
1231
+ }
1232
+ }
1196
1233
  },
1197
1234
 
1198
1235
  _resolve: (rawPath: string) => resolveRoute(rawPath, routes),
1236
+
1237
+ // Dev-only HMR coordinator — see RouterInstance._hmrSwap JSDoc.
1238
+ // Gated to dev+browser so it's tree-shaken from production bundles.
1239
+ ...(__DEV__ && _isBrowser
1240
+ ? {
1241
+ _hmrSwap(id: string, mod: unknown): boolean {
1242
+ const m = mod as { default?: ComponentFn } | ComponentFn | null
1243
+ const next: ComponentFn | undefined =
1244
+ typeof m === 'function' ? m : (m?.default ?? undefined)
1245
+ // No default export in the fresh namespace (named-only edit, or
1246
+ // the module no longer exports a component) — let the plugin
1247
+ // fall back to an automatic reload rather than blank the route.
1248
+ if (typeof next !== 'function') return false
1249
+
1250
+ const matched = currentRoute().matched
1251
+ let changed = false
1252
+ for (const record of matched) {
1253
+ const raw = record.component
1254
+ if (!isLazy(raw) || !raw._hmrId) continue
1255
+ if (!_hmrIdMatches(raw._hmrId, id)) continue
1256
+ componentCache.set(record, next)
1257
+ router._erroredChunks.delete(record)
1258
+ changed = true
1259
+ }
1260
+ // Bump `_loadingSignal` so `RouterView`'s `depthEntry` computed
1261
+ // re-emits; its `equals` compares `comp` identity, so only the
1262
+ // depth whose component actually changed re-renders — every
1263
+ // other depth (layout, siblings) stays mounted, signals intact.
1264
+ if (changed) loadingSignal.update((n) => n + 1)
1265
+ return changed
1266
+ },
1267
+ }
1268
+ : {}),
1199
1269
  }
1200
1270
 
1201
1271
  // Initial route is resolved synchronously — mark ready on next microtask
@@ -1207,11 +1277,42 @@ export function createRouter<TNames extends string = string>(
1207
1277
  }
1208
1278
  })
1209
1279
 
1280
+ // Expose the HMR coordinator on globalThis so `@pyreon/vite-plugin`'s
1281
+ // injected `import.meta.hot.accept` handler can reach it WITHOUT importing
1282
+ // `@pyreon/router` (zero import coupling — same pattern as the perf-harness
1283
+ // counter sink). Last router wins; single-router apps (the norm, every
1284
+ // `@pyreon/zero` app) are unaffected. Dev+browser only.
1285
+ if (__DEV__ && _isBrowser && router._hmrSwap) {
1286
+ // `_hmrSwap` closes over `currentRoute`/`componentCache`/`loadingSignal`
1287
+ // (not `this`), so the raw reference is safe to expose and to compare by
1288
+ // identity on `destroy()`.
1289
+ ;(globalThis as Record<string, unknown>).__pyreon_hmr_swap__ =
1290
+ router._hmrSwap
1291
+ }
1292
+
1210
1293
  return router as unknown as Router<TNames>
1211
1294
  }
1212
1295
 
1213
1296
  // ─── Helpers ──────────────────────────────────────────────────────────────────
1214
1297
 
1298
+ /**
1299
+ * Match a lazy route's `_hmrId` (emitted by `@pyreon/zero`'s fs-router as the
1300
+ * absolute route-file path) against the module id `@pyreon/vite-plugin`'s
1301
+ * accept handler reports. Both are absolute paths to the same file but may
1302
+ * differ in query suffix (`?t=…`, `?v=…`) or, in some Vite setups, a `/@fs`
1303
+ * prefix. Strip queries, then accept exact equality OR a suffix match on the
1304
+ * longer path — route-file paths are unique within an app so suffix matching
1305
+ * can't cross-fire. A miss makes `_hmrSwap` return false → the plugin falls
1306
+ * back to an automatic reload (correct, just not in-place), so a too-strict
1307
+ * match degrades safely rather than swapping the wrong component.
1308
+ */
1309
+ function _hmrIdMatches(recordId: string, incomingId: string): boolean {
1310
+ const a = recordId.split('?')[0] ?? recordId
1311
+ const b = incomingId.split('?')[0] ?? incomingId
1312
+ if (a === b) return true
1313
+ return a.length >= b.length ? a.endsWith(b) : b.endsWith(a)
1314
+ }
1315
+
1215
1316
  async function runGuard(
1216
1317
  guard: NavigationGuard,
1217
1318
  to: ResolvedRoute,
@@ -184,20 +184,44 @@ describe('stringifyLoaderData (M2.2)', () => {
184
184
  })
185
185
  })
186
186
 
187
- test('handles deeply-nested data without falsely flagging shared references as cycles', () => {
188
- // A non-cyclic shared reference (two keys pointing at the same array)
189
- // SHOULD throw JSON serialization can't represent shared identity
190
- // without `references`, and a runtime cycle-detector treating shared
191
- // refs as cycles is the safe default for hydration semantics. Verify
192
- // the throw shape if this becomes too aggressive, relax with a
193
- // post-visit drop instead of WeakSet.
187
+ test('shared (DAG) references serialize only true cycles throw', () => {
188
+ // BEHAVIOUR CHANGE (intentional, bug fix). Previously the all-seen
189
+ // WeakSet threw "circular reference" on ANY object visited twice — a
190
+ // shared reference (DAG), not a cycle. That 500'd the SSR response for
191
+ // extremely common loader payloads (an ORM returning the same instance
192
+ // twice). The original code's own comment anticipated this remedy:
193
+ // "if this becomes too aggressive, relax with a post-visit drop
194
+ // instead of WeakSet" — which is exactly what ancestor-path detection
195
+ // does. Strictly more permissive: inputs that throw before now succeed;
196
+ // inputs that worked are unchanged; real cycles still throw.
194
197
  const shared = [1, 2, 3]
195
- expect(() =>
196
- stringifyLoaderData({
197
- '/a': shared,
198
- '/b': shared,
199
- }),
200
- ).toThrow(/circular reference/)
198
+ const json = stringifyLoaderData({ '/a': shared, '/b': shared })
199
+ expect(JSON.parse(json)).toEqual({ '/a': [1, 2, 3], '/b': [1, 2, 3] })
200
+
201
+ // Canonical real-world shape: one user object referenced twice.
202
+ const user = { id: 7, name: 'Ada' }
203
+ const post = stringifyLoaderData({ '/posts/1': { author: user, lastEditor: user } })
204
+ expect(JSON.parse(post)).toEqual({
205
+ '/posts/1': { author: { id: 7, name: 'Ada' }, lastEditor: { id: 7, name: 'Ada' } },
206
+ })
207
+
208
+ // Diamond: same node reachable via two paths, no cycle.
209
+ const leaf = { v: 1 }
210
+ const diamond = stringifyLoaderData({ '/d': { left: { leaf }, right: { leaf } } })
211
+ expect(JSON.parse(diamond)).toEqual({ '/d': { left: { leaf: { v: 1 } }, right: { leaf: { v: 1 } } } })
212
+ })
213
+
214
+ test('still throws on a true cycle through a shared-looking path', () => {
215
+ // A shared ref that ALSO closes a cycle must still throw.
216
+ interface N {
217
+ id: number
218
+ next?: N
219
+ }
220
+ const a: N = { id: 1 }
221
+ const b: N = { id: 2 }
222
+ a.next = b
223
+ b.next = a // real cycle
224
+ expect(() => stringifyLoaderData({ '/x': { a, b } })).toThrow(/\[Pyreon\] Loader returned circular reference/)
201
225
  })
202
226
 
203
227
  test('empty record produces empty object JSON', () => {
@@ -587,25 +611,117 @@ describe('router — staleWhileRevalidate', () => {
587
611
  staleWhileRevalidate: true,
588
612
  loader: async () => {
589
613
  loaderCallCount++
590
- return `data-v${loaderCallCount}`
614
+ const v = loaderCallCount
615
+ // The revalidation call (2nd) takes REAL async time so the
616
+ // stale window is deterministic, not microtask-races. The
617
+ // first (blocking) call resolves immediately.
618
+ if (v >= 2) await new Promise<void>((r) => setTimeout(r, 40))
619
+ return `data-v${v}`
591
620
  },
592
621
  },
593
622
  ]
623
+ const swrRecord = routes[1] as RouteRecord
594
624
  const router = createRouter({ routes, url: '/' }) as RouterInstance
595
625
 
596
626
  // First navigation — loader runs as blocking
597
627
  await router.push('/data')
598
628
  expect(loaderCallCount).toBe(1)
599
- expect(router._loaderData.get(routes[1] as RouteRecord)).toBe('data-v1')
629
+ expect(router._loaderData.get(swrRecord)).toBe('data-v1')
600
630
 
601
- // Navigate away and back should show stale data and revalidate
631
+ // Navigate AWAY, then BACK. The away nav must NOT prune the SWR
632
+ // route's loader data (that was the bug — see the regression note
633
+ // below); on return the SWR fast-path must serve the STALE value
634
+ // immediately and revalidate in the BACKGROUND.
602
635
  await router.push('/')
603
636
  await router.push('/data')
604
637
 
605
- // Give background revalidation time
606
- await new Promise<void>((r) => setTimeout(r, 50))
638
+ // SWR discriminator (load-bearing): the return navigation resolves
639
+ // WITHOUT blocking on the (40ms) loader the served data is still
640
+ // STALE `data-v1`, revalidation pending. Pre-fix this navigation
641
+ // went through the BLOCKING path (the prune wiped the entry), so
642
+ // `await push('/data')` would have awaited the loader and the data
643
+ // here would already be `data-v2`.
644
+ expect(router._loaderData.get(swrRecord)).toBe('data-v1')
645
+
646
+ // Background revalidation then completes and swaps in fresh data.
647
+ await new Promise<void>((r) => setTimeout(r, 80))
648
+ expect(loaderCallCount).toBe(2)
649
+ expect(router._loaderData.get(swrRecord)).toBe('data-v2')
650
+ })
651
+
652
+ test('a failing background revalidation surfaces via onError without cancelling or clobbering', async () => {
653
+ // This path only became testable after #617 made the SWR branch
654
+ // actually run (the prune previously made `revalidateSwrLoaders`
655
+ // dead code — the reason an earlier attempt at this fix shipped
656
+ // WITHOUT a test and was reverted). With SWR live, an empty `.catch`
657
+ // is the silent-failure anti-pattern: a persistently-failing
658
+ // revalidation gives the developer zero signal. Contract: the error
659
+ // is surfaced via `onError` exactly once; the (already-settled)
660
+ // navigation is NOT cancelled; the failed revalidation does NOT
661
+ // clobber the stale data (it stays valid + on screen).
662
+ let loaderCallCount = 0
663
+ const onError = vi.fn<(err: unknown, route: unknown) => undefined | false>(() => undefined)
664
+ const routes: RouteRecord[] = [
665
+ { path: '/', component: Home },
666
+ {
667
+ path: '/data',
668
+ component: About,
669
+ staleWhileRevalidate: true,
670
+ loader: async () => {
671
+ loaderCallCount++
672
+ const v = loaderCallCount
673
+ if (v >= 2) {
674
+ // Real async delay so the rejection is genuinely a
675
+ // background failure (deterministic, not a microtask race).
676
+ await new Promise<void>((r) => setTimeout(r, 40))
677
+ throw new Error('revalidation upstream 503')
678
+ }
679
+ return `data-v${v}`
680
+ },
681
+ },
682
+ ]
683
+ const swrRecord = routes[1] as RouteRecord
684
+ const router = createRouter({ routes, url: '/', onError }) as RouterInstance
685
+
686
+ await router.push('/data') // blocking — primes stale data-v1
687
+ expect(router._loaderData.get(swrRecord)).toBe('data-v1')
688
+
689
+ await router.push('/') // nav away (SWR data survives — #617)
690
+ await router.push('/data') // SWR: stale served, revalidate in bg
691
+
692
+ // Returned on STALE data without blocking; revalidation not failed yet.
693
+ expect(router.currentRoute().path).toBe('/data')
694
+ expect(router._loaderData.get(swrRecord)).toBe('data-v1')
695
+ expect(onError).not.toHaveBeenCalled()
696
+
697
+ await new Promise<void>((r) => setTimeout(r, 90)) // revalidation rejects
698
+
607
699
  expect(loaderCallCount).toBe(2)
700
+ // The previously-empty `.catch` swallowed this entirely. Contract:
701
+ expect(onError).toHaveBeenCalledTimes(1)
702
+ expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error)
703
+ expect((onError.mock.calls[0]?.[0] as Error).message).toBe('revalidation upstream 503')
704
+ // Navigation was NOT cancelled, and the stale value was NOT clobbered
705
+ // by the failed revalidation — it stays valid and on screen.
706
+ expect(router.currentRoute().path).toBe('/data')
707
+ expect(router._loaderData.get(swrRecord)).toBe('data-v1')
608
708
  })
709
+
710
+ // REGRESSION NOTE (fixed in this PR). `staleWhileRevalidate` was
711
+ // effectively a no-op for the realistic nav-away/back case:
712
+ // `commitNavigation`'s `doCommit` pruned `_loaderData` for every
713
+ // record not in the NEW matched chain on every navigation, so
714
+ // navigating away deleted the SWR route's data and `runLoaders`'
715
+ // `r.staleWhileRevalidate && _loaderData.has(r)` gate was always false
716
+ // on return — `revalidateSwrLoaders` never ran; every visit went
717
+ // through the blocking path. (NB: the earlier hypothesis that
718
+ // `resolveRoute` returns fresh `RouteRecord` objects was WRONG —
719
+ // identity is stable; the prune was the sole cause, proven by an
720
+ // instrumented probe showing SWR fires correctly for `/data → /data`
721
+ // with no nav-away but not for `/data → / → /data`.) Fix: the prune
722
+ // skips `staleWhileRevalidate` records so their data survives
723
+ // navigating away. The strengthened test above is the regression
724
+ // guard — its stale-window assertion fails on the pre-fix prune.
609
725
  })
610
726
 
611
727
  describe('router.preload', () => {
@@ -57,7 +57,7 @@ describe('gen-docs — router snapshot', () => {
57
57
  const data = useLoaderData<UserData>()
58
58
  const router = useRouter()
59
59
  const isAdmin = useIsActive("/admin")
60
- const { isTransitioning } = useTransition()
60
+ const isTransitioning = useTransition()
61
61
  const params = useTypedSearchParams({ tab: "string", page: "number" })
62
62
 
63
63
  return (
@@ -2531,6 +2531,96 @@ describe('RouterLink viewport prefetch', () => {
2531
2531
 
2532
2532
  globalThis.IntersectionObserver = origIO
2533
2533
  })
2534
+
2535
+ // Regression — the viewport-prefetch polish (PR: prefetch DX):
2536
+ // (a) IntersectionObserver is constructed with rootMargin '200px' so
2537
+ // the prefetch starts BEFORE the link is fully on screen.
2538
+ // (b) The prefetch is scheduled via requestIdleCallback, NOT called
2539
+ // synchronously inside the observer callback — so it never
2540
+ // contends with the scroll the user is actively performing.
2541
+ test('viewport prefetch uses 200px rootMargin + idle scheduling', async () => {
2542
+ const el = container()
2543
+ let loaderCalled = false
2544
+ const prefetchRoutes: RouteRecord[] = [
2545
+ { path: '/', component: Home },
2546
+ {
2547
+ path: '/idle',
2548
+ component: About,
2549
+ loader: async () => {
2550
+ loaderCalled = true
2551
+ return 'data'
2552
+ },
2553
+ },
2554
+ ]
2555
+ const router = createRouter({ routes: prefetchRoutes, url: '/' })
2556
+
2557
+ const origIO = globalThis.IntersectionObserver
2558
+ const origRic = (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback
2559
+ let capturedRootMargin: string | undefined
2560
+ let idleCb: (() => void) | null = null
2561
+ ;(globalThis as { requestIdleCallback?: unknown }).requestIdleCallback = (
2562
+ cb: () => void,
2563
+ ): number => {
2564
+ idleCb = cb
2565
+ return 1
2566
+ }
2567
+ globalThis.IntersectionObserver = class {
2568
+ constructor(
2569
+ private cb: IntersectionObserverCallback,
2570
+ opts?: IntersectionObserverInit,
2571
+ ) {
2572
+ capturedRootMargin = opts?.rootMargin
2573
+ }
2574
+ observe(observedEl: Element) {
2575
+ this.cb(
2576
+ [{ isIntersecting: true, target: observedEl } as IntersectionObserverEntry],
2577
+ this as unknown as IntersectionObserver,
2578
+ )
2579
+ }
2580
+ disconnect() {}
2581
+ unobserve() {}
2582
+ takeRecords() {
2583
+ return []
2584
+ }
2585
+ get root() {
2586
+ return null
2587
+ }
2588
+ get rootMargin() {
2589
+ return ''
2590
+ }
2591
+ get thresholds() {
2592
+ return []
2593
+ }
2594
+ } as unknown as typeof IntersectionObserver
2595
+
2596
+ mount(
2597
+ h(
2598
+ RouterProvider,
2599
+ { router },
2600
+ h(RouterLink, { to: '/idle', prefetch: 'viewport' }, 'Idle'),
2601
+ ),
2602
+ el,
2603
+ )
2604
+
2605
+ await new Promise<void>((r) => setTimeout(r, 50))
2606
+
2607
+ // (a) Constructed with the 200px margin.
2608
+ expect(capturedRootMargin).toBe('200px')
2609
+ // (b) The prefetch was DEFERRED to the idle callback — it must NOT
2610
+ // have run synchronously inside the observer callback.
2611
+ expect(idleCb).not.toBeNull()
2612
+ expect(loaderCalled).toBe(false)
2613
+
2614
+ // Fire the idle slice — now the prefetch runs.
2615
+ idleCb!()
2616
+ await new Promise<void>((r) => setTimeout(r, 50))
2617
+ expect(loaderCalled).toBe(true)
2618
+
2619
+ globalThis.IntersectionObserver = origIO
2620
+ if (origRic === undefined)
2621
+ delete (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback
2622
+ else (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback = origRic
2623
+ })
2534
2624
  })
2535
2625
 
2536
2626
  // ─── matchPath additional branches (match.ts) ────────────────────────────────
package/src/types.ts CHANGED
@@ -100,17 +100,29 @@ export interface LazyComponent {
100
100
  readonly loadingComponent?: ComponentFn
101
101
  /** Optional component shown after all retries have failed */
102
102
  readonly errorComponent?: ComponentFn
103
+ /**
104
+ * Dev-only module id, emitted by `@pyreon/zero`'s fs-router codegen as
105
+ * `lazy(() => import("/abs/X"), { hmrId: "/abs/X" })`. The HMR coordinator
106
+ * keys the active route's matched records by this id so a hot-updated
107
+ * module can be swapped IN PLACE (no page reload) using the fresh module
108
+ * Vite hands the `import.meta.hot.accept` callback — sidestepping the
109
+ * stale-`?t=` problem where re-running the dynamic-import thunk inside a
110
+ * non-invalidated virtual routes module would return the OLD module.
111
+ * Inert in production (no coordinator is registered when not in dev).
112
+ */
113
+ readonly _hmrId?: string
103
114
  }
104
115
 
105
116
  export function lazy(
106
117
  loader: () => Promise<ComponentFn | { default: ComponentFn }>,
107
- options?: { loading?: ComponentFn; error?: ComponentFn },
118
+ options?: { loading?: ComponentFn; error?: ComponentFn; hmrId?: string },
108
119
  ): LazyComponent {
109
120
  return {
110
121
  [LAZY_SYMBOL]: true,
111
122
  loader,
112
123
  ...(options?.loading ? { loadingComponent: options.loading } : {}),
113
124
  ...(options?.error ? { errorComponent: options.error } : {}),
125
+ ...(options?.hmrId ? { _hmrId: options.hmrId } : {}),
114
126
  }
115
127
  }
116
128
 
@@ -480,4 +492,26 @@ export interface RouterInstance extends Router {
480
492
  * nav-1's aborted fetch).
481
493
  */
482
494
  _loaderInflight: Map<string, { promise: Promise<unknown>; signal: AbortSignal }>
495
+ /**
496
+ * Dev-only HMR coordinator. Given a hot-updated module's id and the FRESH
497
+ * module namespace Vite handed `import.meta.hot.accept`, swaps the new
498
+ * component into every matched record whose lazy `_hmrId` equals `id`,
499
+ * then bumps `_loadingSignal` so `RouterView` re-renders ONLY that subtree
500
+ * in place — no page reload, so `__pyreon_hmr_registry__` (module-scope
501
+ * signal values) survives and `__hmr_signal` restores them.
502
+ *
503
+ * Using the namespace Vite passed (not a re-run of the lazy thunk)
504
+ * sidesteps the stale-`?t=` trap: the dynamic-import thunk lives in the
505
+ * virtual routes module, which is NOT invalidated when a leaf route
506
+ * self-accepts, so re-importing it would return the OLD module.
507
+ *
508
+ * Returns `true` when at least one matched component was swapped. `false`
509
+ * tells `@pyreon/vite-plugin`'s accept handler the edit was outside the
510
+ * active route tree (a nested non-route component, an unrelated route, a
511
+ * signal-only module) so it falls back to `import.meta.hot.invalidate()`
512
+ * → an automatic full reload (no manual refresh either way).
513
+ *
514
+ * Present only when the router is created in a dev browser context.
515
+ */
516
+ _hmrSwap?: (id: string, mod: unknown) => boolean
483
517
  }