@pyreon/router 0.1.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.
@@ -0,0 +1,690 @@
1
+ import { createContext, createRef, h, onUnmount, popContext, pushContext, useContext } from "@pyreon/core";
2
+ import { computed, signal } from "@pyreon/reactivity";
3
+
4
+ //#region src/loader.ts
5
+ /**
6
+ * Context frame that holds the loader data for the currently rendered route record.
7
+ * Pushed by RouterView's withLoaderData wrapper before invoking the route component.
8
+ */
9
+
10
+ /**
11
+ * Returns the data resolved by the current route's `loader` function.
12
+ * Must be called inside a route component rendered by <RouterView />.
13
+ *
14
+ * @example
15
+ * const routes = [{ path: "/users", component: Users, loader: fetchUsers }]
16
+ *
17
+ * function Users() {
18
+ * const users = useLoaderData<User[]>()
19
+ * return h("ul", null, users.map(u => h("li", null, u.name)))
20
+ * }
21
+ */
22
+ function useLoaderData() {
23
+ return useContext(LoaderDataContext);
24
+ }
25
+ /**
26
+ * SSR helper: pre-run all loaders for the given path before rendering.
27
+ * Call this before `renderToString` so route components can read data via `useLoaderData()`.
28
+ *
29
+ * @example
30
+ * const router = createRouter({ routes, url: req.url })
31
+ * await prefetchLoaderData(router, req.url)
32
+ * const html = await renderToString(h(App, { router }))
33
+ */
34
+ async function prefetchLoaderData(router, path) {
35
+ const route = router._resolve(path);
36
+ const ac = new AbortController();
37
+ router._abortController = ac;
38
+ await Promise.all(route.matched.filter(r => r.loader).map(async r => {
39
+ const data = await r.loader?.({
40
+ params: route.params,
41
+ query: route.query,
42
+ signal: ac.signal
43
+ });
44
+ router._loaderData.set(r, data);
45
+ }));
46
+ }
47
+ /**
48
+ * Serialize loader data to a JSON-safe plain object for embedding in SSR HTML.
49
+ * Keys are route path patterns (stable across server and client).
50
+ *
51
+ * @example — SSR handler:
52
+ * await prefetchLoaderData(router, req.url)
53
+ * const { html, head } = await renderWithHead(h(App, null))
54
+ * const page = `...${head}
55
+ * <script>window.__PYREON_LOADER_DATA__=${JSON.stringify(serializeLoaderData(router))}<\/script>
56
+ * ...${html}...`
57
+ */
58
+ function serializeLoaderData(router) {
59
+ const result = {};
60
+ for (const [record, data] of router._loaderData) result[record.path] = data;
61
+ return result;
62
+ }
63
+ /**
64
+ * Hydrate loader data from a serialized object (e.g. `window.__PYREON_LOADER_DATA__`).
65
+ * Populates the router's internal `_loaderData` map so the initial render uses
66
+ * server-fetched data without re-running loaders on the client.
67
+ *
68
+ * Call this before `mount()`, after `createRouter()`.
69
+ *
70
+ * @example — client entry:
71
+ * import { hydrateLoaderData } from "@pyreon/router"
72
+ * const router = createRouter({ routes })
73
+ * hydrateLoaderData(router, window.__PYREON_LOADER_DATA__ ?? {})
74
+ * mount(h(App, null), document.getElementById("app")!)
75
+ */
76
+ function hydrateLoaderData(router, serialized) {
77
+ if (!serialized || typeof serialized !== "object") return;
78
+ const route = router._resolve(router.currentRoute().path);
79
+ for (const record of route.matched) if (Object.hasOwn(serialized, record.path)) router._loaderData.set(record, serialized[record.path]);
80
+ }
81
+
82
+ //#endregion
83
+ //#region src/match.ts
84
+ /**
85
+ * Parse a query string into key-value pairs. Duplicate keys are overwritten
86
+ * (last value wins). Use `parseQueryMulti` to preserve duplicates as arrays.
87
+ */
88
+ function parseQuery(qs) {
89
+ if (!qs) return {};
90
+ const result = {};
91
+ for (const part of qs.split("&")) {
92
+ const eqIdx = part.indexOf("=");
93
+ if (eqIdx < 0) {
94
+ const key = decodeURIComponent(part);
95
+ if (key) result[key] = "";
96
+ } else {
97
+ const key = decodeURIComponent(part.slice(0, eqIdx));
98
+ const val = decodeURIComponent(part.slice(eqIdx + 1));
99
+ if (key) result[key] = val;
100
+ }
101
+ }
102
+ return result;
103
+ }
104
+ /**
105
+ * Parse a query string preserving duplicate keys as arrays.
106
+ *
107
+ * @example
108
+ * parseQueryMulti("color=red&color=blue&size=lg")
109
+ * // → { color: ["red", "blue"], size: "lg" }
110
+ */
111
+ function parseQueryMulti(qs) {
112
+ if (!qs) return {};
113
+ const result = {};
114
+ for (const part of qs.split("&")) {
115
+ const eqIdx = part.indexOf("=");
116
+ let key;
117
+ let val;
118
+ if (eqIdx < 0) {
119
+ key = decodeURIComponent(part);
120
+ val = "";
121
+ } else {
122
+ key = decodeURIComponent(part.slice(0, eqIdx));
123
+ val = decodeURIComponent(part.slice(eqIdx + 1));
124
+ }
125
+ if (!key) continue;
126
+ const existing = result[key];
127
+ if (existing === void 0) result[key] = val;else if (Array.isArray(existing)) existing.push(val);else result[key] = [existing, val];
128
+ }
129
+ return result;
130
+ }
131
+ function stringifyQuery(query) {
132
+ const parts = [];
133
+ for (const [k, v] of Object.entries(query)) parts.push(v ? `${encodeURIComponent(k)}=${encodeURIComponent(v)}` : encodeURIComponent(k));
134
+ return parts.length ? `?${parts.join("&")}` : "";
135
+ }
136
+ /**
137
+ * Match a single route pattern against a path segment.
138
+ * Returns extracted params or null if no match.
139
+ *
140
+ * Supports:
141
+ * - Exact segments: "/about"
142
+ * - Param segments: "/user/:id"
143
+ * - Wildcard: "(.*)" matches everything
144
+ */
145
+ function matchPath(pattern, path) {
146
+ if (pattern === "(.*)" || pattern === "*") return {};
147
+ const patternParts = pattern.split("/").filter(Boolean);
148
+ const pathParts = path.split("/").filter(Boolean);
149
+ const params = {};
150
+ for (let i = 0; i < patternParts.length; i++) {
151
+ const pp = patternParts[i];
152
+ const pt = pathParts[i];
153
+ if (pp.endsWith("*") && pp.startsWith(":")) {
154
+ const paramName = pp.slice(1, -1);
155
+ params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/");
156
+ return params;
157
+ }
158
+ if (pp.startsWith(":")) params[pp.slice(1)] = decodeURIComponent(pt);else if (pp !== pt) return null;
159
+ }
160
+ if (patternParts.length !== pathParts.length) return null;
161
+ return params;
162
+ }
163
+ /**
164
+ * Check if a path starts with a route's prefix (for nested route matching).
165
+ * Returns the remaining path suffix, or null if no match.
166
+ */
167
+ function matchPrefix(pattern, path) {
168
+ if (pattern === "(.*)" || pattern === "*") return {
169
+ params: {},
170
+ rest: path
171
+ };
172
+ const patternParts = pattern.split("/").filter(Boolean);
173
+ const pathParts = path.split("/").filter(Boolean);
174
+ if (pathParts.length < patternParts.length) return null;
175
+ const params = {};
176
+ for (let i = 0; i < patternParts.length; i++) {
177
+ const pp = patternParts[i];
178
+ const pt = pathParts[i];
179
+ if (pp.endsWith("*") && pp.startsWith(":")) {
180
+ const paramName = pp.slice(1, -1);
181
+ params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/");
182
+ return {
183
+ params,
184
+ rest: "/"
185
+ };
186
+ }
187
+ if (pp.startsWith(":")) params[pp.slice(1)] = decodeURIComponent(pt);else if (pp !== pt) return null;
188
+ }
189
+ return {
190
+ params,
191
+ rest: `/${pathParts.slice(patternParts.length).join("/")}`
192
+ };
193
+ }
194
+ /**
195
+ * Resolve a raw path (including query string and hash) against the route tree.
196
+ * Handles nested routes recursively.
197
+ */
198
+ function resolveRoute(rawPath, routes) {
199
+ const qIdx = rawPath.indexOf("?");
200
+ const pathAndHash = qIdx >= 0 ? rawPath.slice(0, qIdx) : rawPath;
201
+ const queryPart = qIdx >= 0 ? rawPath.slice(qIdx + 1) : "";
202
+ const hIdx = pathAndHash.indexOf("#");
203
+ const cleanPath = hIdx >= 0 ? pathAndHash.slice(0, hIdx) : pathAndHash;
204
+ const hash = hIdx >= 0 ? pathAndHash.slice(hIdx + 1) : "";
205
+ const query = parseQuery(queryPart);
206
+ const match = matchRoutes(cleanPath, routes, []);
207
+ if (match) return {
208
+ path: cleanPath,
209
+ params: match.params,
210
+ query,
211
+ hash,
212
+ matched: match.matched,
213
+ meta: mergeMeta(match.matched)
214
+ };
215
+ return {
216
+ path: cleanPath,
217
+ params: {},
218
+ query,
219
+ hash,
220
+ matched: [],
221
+ meta: {}
222
+ };
223
+ }
224
+ function matchRoutes(path, routes, parentMatched, parentParams = {}) {
225
+ for (const route of routes) {
226
+ const result = matchSingleRoute(path, route, parentMatched, parentParams);
227
+ if (result) return result;
228
+ }
229
+ return null;
230
+ }
231
+ function matchSingleRoute(path, route, parentMatched, parentParams) {
232
+ if (!route.children || route.children.length === 0) {
233
+ const params = matchPath(route.path, path);
234
+ if (params === null) return null;
235
+ return {
236
+ params: {
237
+ ...parentParams,
238
+ ...params
239
+ },
240
+ matched: [...parentMatched, route]
241
+ };
242
+ }
243
+ const prefix = matchPrefix(route.path, path);
244
+ if (prefix === null) return null;
245
+ const allParams = {
246
+ ...parentParams,
247
+ ...prefix.params
248
+ };
249
+ const matched = [...parentMatched, route];
250
+ const childMatch = matchRoutes(prefix.rest, route.children, matched, allParams);
251
+ if (childMatch) return childMatch;
252
+ const exactParams = matchPath(route.path, path);
253
+ if (exactParams === null) return null;
254
+ return {
255
+ params: {
256
+ ...parentParams,
257
+ ...exactParams
258
+ },
259
+ matched
260
+ };
261
+ }
262
+ /** Merge meta from matched routes (leaf takes precedence) */
263
+ function mergeMeta(matched) {
264
+ const meta = {};
265
+ for (const record of matched) if (record.meta) Object.assign(meta, record.meta);
266
+ return meta;
267
+ }
268
+ /** Build a path string from a named route's pattern and params */
269
+ function buildPath(pattern, params) {
270
+ return pattern.replace(/:([^/]+)\*?/g, (match, key) => {
271
+ const val = params[key] ?? "";
272
+ if (match.endsWith("*")) return val.split("/").map(encodeURIComponent).join("/");
273
+ return encodeURIComponent(val);
274
+ });
275
+ }
276
+ /** Find a route record by name (recursive, O(n)). Prefer buildNameIndex for repeated lookups. */
277
+ function findRouteByName(name, routes) {
278
+ for (const route of routes) {
279
+ if (route.name === name) return route;
280
+ if (route.children) {
281
+ const found = findRouteByName(name, route.children);
282
+ if (found) return found;
283
+ }
284
+ }
285
+ return null;
286
+ }
287
+ /**
288
+ * Pre-build a name → RouteRecord index from a route tree for O(1) named navigation.
289
+ * Called once at router creation time; avoids O(n) depth-first search per push({ name }).
290
+ */
291
+ function buildNameIndex(routes) {
292
+ const index = /* @__PURE__ */new Map();
293
+ function walk(list) {
294
+ for (const route of list) {
295
+ if (route.name) index.set(route.name, route);
296
+ if (route.children) walk(route.children);
297
+ }
298
+ }
299
+ walk(routes);
300
+ return index;
301
+ }
302
+
303
+ //#endregion
304
+ //#region src/scroll.ts
305
+ /**
306
+ * Scroll restoration manager.
307
+ *
308
+ * Saves scroll position before each navigation and restores it when
309
+ * navigating back to a previously visited path.
310
+ */
311
+
312
+ function lazy(loader, options) {
313
+ return {
314
+ [LAZY_SYMBOL]: true,
315
+ loader,
316
+ ...(options?.loading ? {
317
+ loadingComponent: options.loading
318
+ } : {}),
319
+ ...(options?.error ? {
320
+ errorComponent: options.error
321
+ } : {})
322
+ };
323
+ }
324
+ function isLazy(c) {
325
+ return typeof c === "object" && c !== null && c[LAZY_SYMBOL] === true;
326
+ }
327
+
328
+ //#endregion
329
+ //#region src/router.ts
330
+
331
+ function setActiveRouter(router) {
332
+ if (router) router._viewDepth = 0;
333
+ _activeRouter = router;
334
+ }
335
+ function useRouter() {
336
+ const router = useContext(RouterContext) ?? _activeRouter;
337
+ if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
338
+ return router;
339
+ }
340
+ function useRoute() {
341
+ const router = useContext(RouterContext) ?? _activeRouter;
342
+ if (!router) throw new Error("[pyreon-router] No router installed. Wrap your app in <RouterProvider router={router}>.");
343
+ return router.currentRoute;
344
+ }
345
+ function createRouter(options) {
346
+ const opts = Array.isArray(options) ? {
347
+ routes: options
348
+ } : options;
349
+ const {
350
+ routes,
351
+ mode = "hash",
352
+ scrollBehavior,
353
+ onError,
354
+ maxCacheSize = 100
355
+ } = opts;
356
+ const nameIndex = buildNameIndex(routes);
357
+ const guards = [];
358
+ const afterHooks = [];
359
+ const scrollManager = new ScrollManager(scrollBehavior);
360
+ let _navGen = 0;
361
+ const getInitialLocation = () => {
362
+ if (opts.url) return opts.url;
363
+ if (!_isBrowser) return "/";
364
+ if (mode === "history") return window.location.pathname + window.location.search;
365
+ const hash = window.location.hash;
366
+ return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
367
+ };
368
+ const getCurrentLocation = () => {
369
+ if (!_isBrowser) return currentPath();
370
+ if (mode === "history") return window.location.pathname + window.location.search;
371
+ const hash = window.location.hash;
372
+ return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
373
+ };
374
+ const currentPath = signal(getInitialLocation());
375
+ const currentRoute = computed(() => resolveRoute(currentPath(), routes));
376
+ let _popstateHandler = null;
377
+ let _hashchangeHandler = null;
378
+ if (_isBrowser) if (mode === "history") {
379
+ _popstateHandler = () => currentPath.set(getCurrentLocation());
380
+ window.addEventListener("popstate", _popstateHandler);
381
+ } else {
382
+ _hashchangeHandler = () => currentPath.set(getCurrentLocation());
383
+ window.addEventListener("hashchange", _hashchangeHandler);
384
+ }
385
+ const componentCache = /* @__PURE__ */new Map();
386
+ const loadingSignal = signal(0);
387
+ async function evaluateGuard(guard, to, from, gen) {
388
+ const result = await runGuard(guard, to, from);
389
+ if (gen !== _navGen) return {
390
+ action: "cancel"
391
+ };
392
+ if (result === false) return {
393
+ action: "cancel"
394
+ };
395
+ if (typeof result === "string") return {
396
+ action: "redirect",
397
+ target: result
398
+ };
399
+ return {
400
+ action: "continue"
401
+ };
402
+ }
403
+ async function runRouteGuards(records, guardKey, to, from, gen) {
404
+ for (const record of records) {
405
+ const raw = record[guardKey];
406
+ if (!raw) continue;
407
+ const routeGuards = Array.isArray(raw) ? raw : [raw];
408
+ for (const guard of routeGuards) {
409
+ const outcome = await evaluateGuard(guard, to, from, gen);
410
+ if (outcome.action !== "continue") return outcome;
411
+ }
412
+ }
413
+ return {
414
+ action: "continue"
415
+ };
416
+ }
417
+ async function runGlobalGuards(globalGuards, to, from, gen) {
418
+ for (const guard of globalGuards) {
419
+ const outcome = await evaluateGuard(guard, to, from, gen);
420
+ if (outcome.action !== "continue") return outcome;
421
+ }
422
+ return {
423
+ action: "continue"
424
+ };
425
+ }
426
+ function processLoaderResult(result, record, ac, to) {
427
+ if (result.status === "fulfilled") {
428
+ router._loaderData.set(record, result.value);
429
+ return true;
430
+ }
431
+ if (ac.signal.aborted) return true;
432
+ if (router._onError) {
433
+ if (router._onError(result.reason, to) === false) return false;
434
+ }
435
+ router._loaderData.set(record, void 0);
436
+ return true;
437
+ }
438
+ function syncBrowserUrl(path, replace) {
439
+ if (!_isBrowser) return;
440
+ const url = mode === "history" ? path : `#${path}`;
441
+ if (replace) window.history.replaceState(null, "", url);else window.history.pushState(null, "", url);
442
+ }
443
+ function resolveRedirect(to) {
444
+ const leaf = to.matched[to.matched.length - 1];
445
+ if (!leaf?.redirect) return null;
446
+ return sanitizePath(typeof leaf.redirect === "function" ? leaf.redirect(to) : leaf.redirect);
447
+ }
448
+ async function runAllGuards(to, from, gen) {
449
+ const leaveOutcome = await runRouteGuards(from.matched, "beforeLeave", to, from, gen);
450
+ if (leaveOutcome.action !== "continue") return leaveOutcome;
451
+ const enterOutcome = await runRouteGuards(to.matched, "beforeEnter", to, from, gen);
452
+ if (enterOutcome.action !== "continue") return enterOutcome;
453
+ return runGlobalGuards(guards, to, from, gen);
454
+ }
455
+ async function runLoaders(to, gen, ac) {
456
+ const loadableRecords = to.matched.filter(r => r.loader);
457
+ if (loadableRecords.length === 0) return true;
458
+ const loaderCtx = {
459
+ params: to.params,
460
+ query: to.query,
461
+ signal: ac.signal
462
+ };
463
+ const results = await Promise.allSettled(loadableRecords.map(r => {
464
+ if (!r.loader) return Promise.resolve(void 0);
465
+ return r.loader(loaderCtx);
466
+ }));
467
+ if (gen !== _navGen) return false;
468
+ for (let i = 0; i < loadableRecords.length; i++) {
469
+ const result = results[i];
470
+ const record = loadableRecords[i];
471
+ if (!result || !record) continue;
472
+ if (!processLoaderResult(result, record, ac, to)) return false;
473
+ }
474
+ return true;
475
+ }
476
+ function commitNavigation(path, replace, to, from) {
477
+ scrollManager.save(from.path);
478
+ currentPath.set(path);
479
+ syncBrowserUrl(path, replace);
480
+ if (_isBrowser && to.meta.title) document.title = to.meta.title;
481
+ for (const record of router._loaderData.keys()) if (!to.matched.includes(record)) router._loaderData.delete(record);
482
+ for (const hook of afterHooks) try {
483
+ hook(to, from);
484
+ } catch (_err) {}
485
+ if (_isBrowser) queueMicrotask(() => scrollManager.restore(to, from));
486
+ }
487
+ async function navigate(path, replace, redirectDepth = 0) {
488
+ if (redirectDepth > 10) return;
489
+ const gen = ++_navGen;
490
+ loadingSignal.update(n => n + 1);
491
+ const to = resolveRoute(path, routes);
492
+ const from = currentRoute();
493
+ const redirectTarget = resolveRedirect(to);
494
+ if (redirectTarget !== null) {
495
+ loadingSignal.update(n => n - 1);
496
+ return navigate(redirectTarget, replace, redirectDepth + 1);
497
+ }
498
+ const guardOutcome = await runAllGuards(to, from, gen);
499
+ if (guardOutcome.action !== "continue") {
500
+ loadingSignal.update(n => n - 1);
501
+ if (guardOutcome.action === "redirect") return navigate(sanitizePath(guardOutcome.target), replace, redirectDepth + 1);
502
+ return;
503
+ }
504
+ router._abortController?.abort();
505
+ const ac = new AbortController();
506
+ router._abortController = ac;
507
+ if (!(await runLoaders(to, gen, ac))) {
508
+ loadingSignal.update(n => n - 1);
509
+ return;
510
+ }
511
+ commitNavigation(path, replace, to, from);
512
+ loadingSignal.update(n => n - 1);
513
+ }
514
+ const router = {
515
+ routes,
516
+ mode,
517
+ currentRoute,
518
+ _currentPath: currentPath,
519
+ _currentRoute: currentRoute,
520
+ _componentCache: componentCache,
521
+ _loadingSignal: loadingSignal,
522
+ _scrollPositions: /* @__PURE__ */new Map(),
523
+ _scrollBehavior: scrollBehavior,
524
+ _viewDepth: 0,
525
+ _erroredChunks: /* @__PURE__ */new Set(),
526
+ _loaderData: /* @__PURE__ */new Map(),
527
+ _abortController: null,
528
+ _onError: onError,
529
+ _maxCacheSize: maxCacheSize,
530
+ async push(location) {
531
+ if (typeof location === "string") return navigate(sanitizePath(location), false);
532
+ return navigate(resolveNamedPath(location.name, location.params ?? {}, location.query ?? {}, nameIndex), false);
533
+ },
534
+ async replace(path) {
535
+ return navigate(sanitizePath(path), true);
536
+ },
537
+ back() {
538
+ if (_isBrowser) window.history.back();
539
+ },
540
+ beforeEach(guard) {
541
+ guards.push(guard);
542
+ return () => {
543
+ const idx = guards.indexOf(guard);
544
+ if (idx >= 0) guards.splice(idx, 1);
545
+ };
546
+ },
547
+ afterEach(hook) {
548
+ afterHooks.push(hook);
549
+ return () => {
550
+ const idx = afterHooks.indexOf(hook);
551
+ if (idx >= 0) afterHooks.splice(idx, 1);
552
+ };
553
+ },
554
+ loading: () => loadingSignal() > 0,
555
+ destroy() {
556
+ if (_popstateHandler) {
557
+ window.removeEventListener("popstate", _popstateHandler);
558
+ _popstateHandler = null;
559
+ }
560
+ if (_hashchangeHandler) {
561
+ window.removeEventListener("hashchange", _hashchangeHandler);
562
+ _hashchangeHandler = null;
563
+ }
564
+ guards.length = 0;
565
+ afterHooks.length = 0;
566
+ componentCache.clear();
567
+ router._loaderData.clear();
568
+ router._abortController?.abort();
569
+ router._abortController = null;
570
+ },
571
+ _resolve: rawPath => resolveRoute(rawPath, routes)
572
+ };
573
+ return router;
574
+ }
575
+ async function runGuard(guard, to, from) {
576
+ try {
577
+ return await guard(to, from);
578
+ } catch (_err) {
579
+ return false;
580
+ }
581
+ }
582
+ function resolveNamedPath(name, params, query, index) {
583
+ const record = index.get(name);
584
+ if (!record) return "/";
585
+ let path = buildPath(record.path, params);
586
+ const qs = Object.entries(query).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
587
+ if (qs) path += `?${qs}`;
588
+ return path;
589
+ }
590
+ /** Block unsafe navigation targets: javascript/data/vbscript URIs and absolute URLs. */
591
+ function sanitizePath(path) {
592
+ const trimmed = path.trim();
593
+ if (/^(?:javascript|data|vbscript):/i.test(trimmed)) return "/";
594
+ if (/^\/\/|^https?:/i.test(trimmed)) return "/";
595
+ return path;
596
+ }
597
+
598
+ //#endregion
599
+ //#region src/components.tsx
600
+
601
+ /** Prefetch loader data for a route (only once per router + path). */
602
+ function prefetchRoute(router, path) {
603
+ let set = _prefetched.get(router);
604
+ if (!set) {
605
+ set = /* @__PURE__ */new Set();
606
+ _prefetched.set(router, set);
607
+ }
608
+ if (set.has(path)) return;
609
+ set.add(path);
610
+ prefetchLoaderData(router, path).catch(() => {
611
+ set?.delete(path);
612
+ });
613
+ }
614
+ function renderLazyRoute(router, record, raw) {
615
+ if (router._erroredChunks.has(record)) return raw.errorComponent ? h(raw.errorComponent, {}) : null;
616
+ const tryLoad = attempt => raw.loader().then(mod => {
617
+ cacheSet(router, record, typeof mod === "function" ? mod : mod.default);
618
+ router._loadingSignal.update(n => n + 1);
619
+ }).catch(err => {
620
+ if (attempt < 3) return new Promise(res => setTimeout(res, 500 * 2 ** attempt)).then(() => tryLoad(attempt + 1));
621
+ if (typeof window !== "undefined" && isStaleChunk(err)) {
622
+ window.location.reload();
623
+ return;
624
+ }
625
+ router._erroredChunks.add(record);
626
+ router._loadingSignal.update(n => n + 1);
627
+ });
628
+ tryLoad(0);
629
+ return raw.loadingComponent ? h(raw.loadingComponent, {}) : null;
630
+ }
631
+ /**
632
+ * Wraps the route component with a LoaderDataProvider so `useLoaderData()` works
633
+ * inside the component. If the record has no loader, renders the component directly.
634
+ */
635
+ function renderWithLoader(router, record, Comp, route) {
636
+ const routeProps = {
637
+ params: route.params,
638
+ query: route.query,
639
+ meta: route.meta
640
+ };
641
+ if (!record.loader) return h(Comp, routeProps);
642
+ const data = router._loaderData.get(record);
643
+ if (data === void 0 && record.errorComponent) return h(record.errorComponent, routeProps);
644
+ return h(LoaderDataProvider, {
645
+ data,
646
+ children: h(Comp, routeProps)
647
+ });
648
+ }
649
+ /**
650
+ * Thin provider component that pushes LoaderDataContext before children mount.
651
+ * Uses Pyreon's context stack so useLoaderData() reads it during child setup.
652
+ */
653
+ function LoaderDataProvider(props) {
654
+ pushContext(new Map([[LoaderDataContext.id, props.data]]));
655
+ onUnmount(() => popContext());
656
+ return props.children;
657
+ }
658
+ /** Evict oldest cache entries when the component cache exceeds maxCacheSize. */
659
+ function cacheSet(router, record, comp) {
660
+ router._componentCache.set(record, comp);
661
+ if (router._componentCache.size > router._maxCacheSize) {
662
+ const oldest = router._componentCache.keys().next().value;
663
+ router._componentCache.delete(oldest);
664
+ }
665
+ }
666
+ /**
667
+ * Segment-aware prefix check for active link matching.
668
+ * `/admin` is a prefix of `/admin/users` but NOT of `/admin-panel`.
669
+ */
670
+ function isSegmentPrefix(current, target) {
671
+ if (target === "/") return false;
672
+ const cs = current.split("/").filter(Boolean);
673
+ const ts = target.split("/").filter(Boolean);
674
+ if (ts.length > cs.length) return false;
675
+ return ts.every((seg, i) => seg === cs[i]);
676
+ }
677
+ /**
678
+ * Detect a stale chunk error — happens post-deploy when the browser requests
679
+ * a hashed filename that no longer exists on the server. Trigger a full reload
680
+ * so the user gets the new bundle instead of a broken loading state.
681
+ */
682
+ function isStaleChunk(err) {
683
+ if (err instanceof TypeError && String(err.message).includes("Failed to fetch")) return true;
684
+ if (err instanceof SyntaxError) return true;
685
+ return false;
686
+ }
687
+
688
+ //#endregion
689
+ export { RouterContext, RouterLink, RouterProvider, RouterView, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useLoaderData, useRoute, useRouter };
690
+ //# sourceMappingURL=index.d.ts.map