@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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +72 -17
- package/lib/types/index.d.ts +34 -0
- package/package.json +5 -5
- package/src/components.tsx +37 -9
- package/src/loader.ts +47 -21
- package/src/manifest.ts +8 -8
- package/src/router.ts +104 -3
- package/src/tests/loader.test.ts +134 -18
- package/src/tests/manifest-snapshot.test.ts +1 -1
- package/src/tests/router.test.ts +90 -0
- package/src/types.ts +35 -1
|
@@ -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":"
|
|
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
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
});
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
48
|
-
"@pyreon/reactivity": "^0.
|
|
49
|
-
"@pyreon/runtime-dom": "^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.
|
|
54
|
+
"@pyreon/test-utils": "^0.13.6",
|
|
55
55
|
"@vitest/browser-playwright": "^4.1.4",
|
|
56
56
|
"happy-dom": "^20.8.3"
|
|
57
57
|
}
|
package/src/components.tsx
CHANGED
|
@@ -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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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():
|
|
217
|
+
signature: 'useTransition(): () => boolean',
|
|
218
218
|
summary:
|
|
219
|
-
'
|
|
220
|
-
example: `const
|
|
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
|
|
230
|
+
signature: 'useMiddlewareData(): () => Record<string, unknown>',
|
|
231
231
|
summary:
|
|
232
|
-
'
|
|
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
|
|
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
|
-
|
|
757
|
+
.catch((err: unknown) => {
|
|
758
|
+
// Background revalidation failed — the 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,
|
package/src/tests/loader.test.ts
CHANGED
|
@@ -184,20 +184,44 @@ describe('stringifyLoaderData (M2.2)', () => {
|
|
|
184
184
|
})
|
|
185
185
|
})
|
|
186
186
|
|
|
187
|
-
test('
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
// post-visit drop
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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(
|
|
629
|
+
expect(router._loaderData.get(swrRecord)).toBe('data-v1')
|
|
600
630
|
|
|
601
|
-
// Navigate
|
|
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
|
-
//
|
|
606
|
-
|
|
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
|
|
60
|
+
const isTransitioning = useTransition()
|
|
61
61
|
const params = useTypedSearchParams({ tab: "string", page: "number" })
|
|
62
62
|
|
|
63
63
|
return (
|
package/src/tests/router.test.ts
CHANGED
|
@@ -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
|
}
|