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