@pyreon/router 0.24.4 → 0.24.6
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/package.json +4 -6
- package/src/components.tsx +0 -650
- package/src/env.d.ts +0 -6
- package/src/index.ts +0 -106
- package/src/loader.ts +0 -200
- package/src/manifest.ts +0 -399
- package/src/match.ts +0 -921
- package/src/not-found.ts +0 -75
- package/src/redirect.ts +0 -63
- package/src/router.ts +0 -1424
- package/src/scroll.ts +0 -93
- package/src/tests/integration.test.tsx +0 -298
- package/src/tests/loader.test.ts +0 -1024
- package/src/tests/manifest-snapshot.test.ts +0 -101
- package/src/tests/match.test.ts +0 -782
- package/src/tests/native-markers.test.ts +0 -18
- package/src/tests/redirect.test.ts +0 -96
- package/src/tests/router.browser.test.tsx +0 -509
- package/src/tests/router.test.ts +0 -5498
- package/src/tests/routerlink-reactive-to.browser.test.tsx +0 -158
- package/src/tests/scroll.test.ts +0 -31
- package/src/tests/setup.ts +0 -3
- package/src/types.ts +0 -517
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/router",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.6",
|
|
4
4
|
"description": "Official router for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/router#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"lib",
|
|
17
17
|
"!lib/**/*.map",
|
|
18
|
-
"src",
|
|
19
18
|
"README.md",
|
|
20
19
|
"LICENSE"
|
|
21
20
|
],
|
|
@@ -26,7 +25,6 @@
|
|
|
26
25
|
"types": "./lib/types/index.d.ts",
|
|
27
26
|
"exports": {
|
|
28
27
|
".": {
|
|
29
|
-
"bun": "./src/index.ts",
|
|
30
28
|
"import": "./lib/index.js",
|
|
31
29
|
"types": "./lib/types/index.d.ts"
|
|
32
30
|
}
|
|
@@ -44,9 +42,9 @@
|
|
|
44
42
|
"prepublishOnly": "bun run build"
|
|
45
43
|
},
|
|
46
44
|
"dependencies": {
|
|
47
|
-
"@pyreon/core": "^0.24.
|
|
48
|
-
"@pyreon/reactivity": "^0.24.
|
|
49
|
-
"@pyreon/runtime-dom": "^0.24.
|
|
45
|
+
"@pyreon/core": "^0.24.6",
|
|
46
|
+
"@pyreon/reactivity": "^0.24.6",
|
|
47
|
+
"@pyreon/runtime-dom": "^0.24.6"
|
|
50
48
|
},
|
|
51
49
|
"devDependencies": {
|
|
52
50
|
"@happy-dom/global-registrator": "^20.8.9",
|
package/src/components.tsx
DELETED
|
@@ -1,650 +0,0 @@
|
|
|
1
|
-
import type { ClassValue, ComponentFn, Props, VNodeChild } from '@pyreon/core'
|
|
2
|
-
import {
|
|
3
|
-
createRef,
|
|
4
|
-
cx,
|
|
5
|
-
ErrorBoundary,
|
|
6
|
-
h,
|
|
7
|
-
nativeCompat,
|
|
8
|
-
onUnmount,
|
|
9
|
-
provide,
|
|
10
|
-
useContext,
|
|
11
|
-
} from '@pyreon/core'
|
|
12
|
-
import { computed, signal } from '@pyreon/reactivity'
|
|
13
|
-
import { LoaderDataContext, prefetchLoaderData } from './loader'
|
|
14
|
-
import { _setDefaultChromeLayout } from './match'
|
|
15
|
-
import { isLazy, RouterContext, setActiveRouter } from './router'
|
|
16
|
-
import type { LazyComponent, ResolvedRoute, RouteRecord, Router, RouterInstance } from './types'
|
|
17
|
-
|
|
18
|
-
// Track prefetched paths per router to avoid duplicate fetches
|
|
19
|
-
const _prefetched = new WeakMap<RouterInstance, Set<string>>()
|
|
20
|
-
|
|
21
|
-
// ─── RouterProvider ───────────────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
export interface RouterProviderProps extends Props {
|
|
24
|
-
router: Router
|
|
25
|
-
children?: VNodeChild
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const RouterProvider: ComponentFn<RouterProviderProps> = (props) => {
|
|
29
|
-
const router = props.router as RouterInstance
|
|
30
|
-
// Push router into the context stack — isolated per request in SSR via ALS,
|
|
31
|
-
// isolated per component tree in CSR.
|
|
32
|
-
provide(RouterContext, router)
|
|
33
|
-
onUnmount(() => {
|
|
34
|
-
// Clean up event listeners, caches, abort in-flight navigations.
|
|
35
|
-
// Safe to call multiple times (destroy is idempotent).
|
|
36
|
-
router.destroy()
|
|
37
|
-
setActiveRouter(null)
|
|
38
|
-
})
|
|
39
|
-
// Also set the module fallback so programmatic useRouter() outside a component
|
|
40
|
-
// tree (e.g. navigation guards in event handlers) still works in CSR.
|
|
41
|
-
setActiveRouter(router)
|
|
42
|
-
return props.children ?? null
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ─── RouterView ───────────────────────────────────────────────────────────────
|
|
46
|
-
|
|
47
|
-
export interface RouterViewProps extends Props {
|
|
48
|
-
/** Explicitly pass a router (optional — uses the active router by default) */
|
|
49
|
-
router?: Router
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Renders the matched route component at this nesting level.
|
|
54
|
-
*
|
|
55
|
-
* Nested layouts work by placing a second `<RouterView />` inside the layout
|
|
56
|
-
* component — it automatically renders the next level of the matched route.
|
|
57
|
-
*
|
|
58
|
-
* How depth tracking works:
|
|
59
|
-
* Pyreon components run once in depth-first tree order. Each `RouterView`
|
|
60
|
-
* captures `router._viewDepth` at setup time and immediately increments it,
|
|
61
|
-
* so sibling and child views get the correct index. `onUnmount` decrements
|
|
62
|
-
* the counter so dynamic route swaps work correctly.
|
|
63
|
-
*
|
|
64
|
-
* @example
|
|
65
|
-
* // Route config:
|
|
66
|
-
* { path: "/admin", component: AdminLayout, children: [
|
|
67
|
-
* { path: "users", component: AdminUsers },
|
|
68
|
-
* ]}
|
|
69
|
-
*
|
|
70
|
-
* // AdminLayout renders a nested RouterView:
|
|
71
|
-
* function AdminLayout() {
|
|
72
|
-
* return <div><Sidebar /><RouterView /></div>
|
|
73
|
-
* }
|
|
74
|
-
*/
|
|
75
|
-
export const RouterView: ComponentFn<RouterViewProps> = (props) => {
|
|
76
|
-
const router = ((props.router as RouterInstance | undefined) ??
|
|
77
|
-
useContext(RouterContext)) as RouterInstance | null
|
|
78
|
-
if (!router) return null
|
|
79
|
-
|
|
80
|
-
// Claim this view's depth at setup time (depth-first component init order)
|
|
81
|
-
const depth = router._viewDepth
|
|
82
|
-
router._viewDepth++
|
|
83
|
-
|
|
84
|
-
onUnmount(() => {
|
|
85
|
-
router._viewDepth--
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
// ── Structure / data decoupling ───────────────────────────────────────────
|
|
89
|
-
//
|
|
90
|
-
// Pre-fix the reactive child accessor read `_loadingSignal` and the full
|
|
91
|
-
// `currentRoute` snapshot. The framework's `mountReactive` tears down and
|
|
92
|
-
// rebuilds the entire subtree on every accessor re-emission, so any
|
|
93
|
-
// unrelated route signal (loader writes, lazy resolution, navigation
|
|
94
|
-
// start/end counters, param changes that don't change the matched record)
|
|
95
|
-
// would tear down the layout, then the page, then everything below it.
|
|
96
|
-
// For a single page load with one cold-start `router.replace()`, that
|
|
97
|
-
// produced ~9 cascading remounts of the layout — confirmed empirically
|
|
98
|
-
// by instance counters.
|
|
99
|
-
//
|
|
100
|
-
// The fix decouples STRUCTURE (which RouteRecord is mounted at this depth
|
|
101
|
-
// + which component to render for it) from DATA (params / query / loader
|
|
102
|
-
// data flowing into the rendered component). One computed returns BOTH
|
|
103
|
-
// the record and its resolved component as an atomic pair — re-emits ONLY
|
|
104
|
-
// when either side changes (reference equality on both fields). Loader
|
|
105
|
-
// writes / param changes / navigation counters don't re-emit; the rendered
|
|
106
|
-
// component receives route data through reactive props + the
|
|
107
|
-
// `LoaderDataProvider` context, which subscribe per-component to the
|
|
108
|
-
// signals they actually care about, so a param change re-renders just the
|
|
109
|
-
// page leaf — not the layout chain above it.
|
|
110
|
-
//
|
|
111
|
-
// The structure is intentionally a SINGLE computed (not two layered ones):
|
|
112
|
-
// when `currentRoute` changes, the reactive child accessor must see a
|
|
113
|
-
// CONSISTENT (rec, comp) pair on its next re-run. With two layered
|
|
114
|
-
// computeds the child accessor subscribes to both, and the order in which
|
|
115
|
-
// those two notify the child is unspecified — if the child runs after rec
|
|
116
|
-
// is notified but before comp re-evaluates, it reads the new rec paired
|
|
117
|
-
// with the OLD comp. Empirically that produced rec=/button paired with
|
|
118
|
-
// comp=HomePage, leaving the previous page rendered after navigation.
|
|
119
|
-
// Combining them into one computed forces atomic emission.
|
|
120
|
-
interface DepthEntry {
|
|
121
|
-
rec: RouteRecord | null
|
|
122
|
-
comp: ComponentFn | null
|
|
123
|
-
/**
|
|
124
|
-
* True when lazy resolution exhausted retries and the chunk is in
|
|
125
|
-
* `_erroredChunks`. Tracked structurally so the entry re-emits when
|
|
126
|
-
* the error state flips on — otherwise `equals` would block the
|
|
127
|
-
* { rec, comp: null } → { rec, comp: null, errored: true } transition
|
|
128
|
-
* (`comp` and `rec` are unchanged) and the error component would
|
|
129
|
-
* never render.
|
|
130
|
-
*/
|
|
131
|
-
errored: boolean
|
|
132
|
-
/**
|
|
133
|
-
* The full ResolvedRoute reference at the time this entry was emitted.
|
|
134
|
-
* `currentRoute` is a `computed` keyed on `currentPath` — same path
|
|
135
|
-
* returns the same memoized reference, different path returns a new
|
|
136
|
-
* one. Tracking the reference in `equals` makes the depth re-emit on
|
|
137
|
-
* any real navigation (params change, query change, hash change) even
|
|
138
|
-
* when the matched record at this depth stays the same — required so
|
|
139
|
-
* `/user/42 → /user/99` re-renders the User component with new params
|
|
140
|
-
* — while NOT re-emitting on navigate-flow noise (`_loadingSignal`
|
|
141
|
-
* start/end ticks, lazy resolution writes that complete without
|
|
142
|
-
* changing currentPath). One emit per real navigation, not per
|
|
143
|
-
* within-navigation signal tick.
|
|
144
|
-
*/
|
|
145
|
-
route: ResolvedRoute
|
|
146
|
-
}
|
|
147
|
-
const depthEntry = computed<DepthEntry>(
|
|
148
|
-
() => {
|
|
149
|
-
const route = router.currentRoute()
|
|
150
|
-
const rec = route.matched[depth] ?? null
|
|
151
|
-
if (!rec) return { rec: null, comp: null, errored: false, route }
|
|
152
|
-
// Subscribe to `_loadingSignal` so lazy resolution wakes this
|
|
153
|
-
// computed up — when the cache fills, we re-emit with comp set.
|
|
154
|
-
router._loadingSignal()
|
|
155
|
-
const errored = router._erroredChunks.has(rec)
|
|
156
|
-
if (errored) return { rec, comp: null, errored: true, route }
|
|
157
|
-
const cached = router._componentCache.get(rec)
|
|
158
|
-
if (cached) return { rec, comp: cached, errored: false, route }
|
|
159
|
-
const raw = rec.component
|
|
160
|
-
if (!isLazy(raw)) {
|
|
161
|
-
cacheSet(router, rec, raw)
|
|
162
|
-
return { rec, comp: raw, errored: false, route }
|
|
163
|
-
}
|
|
164
|
-
// Lazy and not yet cached — `child()` below renders the lazy
|
|
165
|
-
// fallback and triggers the load; once the load completes,
|
|
166
|
-
// `_loadingSignal` ticks and this computed re-emits with `comp` set.
|
|
167
|
-
return { rec, comp: null, errored: false, route }
|
|
168
|
-
},
|
|
169
|
-
{
|
|
170
|
-
equals: (a, b) =>
|
|
171
|
-
a.rec === b.rec &&
|
|
172
|
-
a.comp === b.comp &&
|
|
173
|
-
a.errored === b.errored &&
|
|
174
|
-
a.route === b.route,
|
|
175
|
-
},
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
const child = (): VNodeChild => {
|
|
179
|
-
const { rec, comp, route } = depthEntry()
|
|
180
|
-
if (!rec) return null
|
|
181
|
-
|
|
182
|
-
if (comp) {
|
|
183
|
-
return renderWithLoader(router, rec, comp, route)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Component not yet cached — kick off the lazy load. `renderLazyRoute`
|
|
187
|
-
// mutates `_loadingSignal` and `_componentCache` on completion, which
|
|
188
|
-
// re-emits `depthEntry` and re-runs this accessor with `comp` set.
|
|
189
|
-
return renderLazyRoute(router, rec, rec.component as LazyComponent)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return h('div', { 'data-pyreon-router-view': true }, child as unknown as VNodeChild)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// ─── RouterLink ───────────────────────────────────────────────────────────────
|
|
196
|
-
|
|
197
|
-
export interface RouterLinkProps extends Props {
|
|
198
|
-
to: string
|
|
199
|
-
/** If true, uses router.replace() instead of router.push() */
|
|
200
|
-
replace?: boolean
|
|
201
|
-
/** CSS class applied when this link is active (default: "router-link-active") */
|
|
202
|
-
activeClass?: string
|
|
203
|
-
/** CSS class for exact-match active state (default: "router-link-exact-active") */
|
|
204
|
-
exactActiveClass?: string
|
|
205
|
-
/** If true, only applies activeClass on exact match */
|
|
206
|
-
exact?: boolean
|
|
207
|
-
/**
|
|
208
|
-
* Prefetch strategy for loader data:
|
|
209
|
-
* - "intent" (default) — prefetch on hover AND focus (covers mouse + keyboard)
|
|
210
|
-
* - "hover" — prefetch on hover only
|
|
211
|
-
* - "viewport" — prefetch when the link scrolls into the viewport
|
|
212
|
-
* - "none" — no prefetching
|
|
213
|
-
*/
|
|
214
|
-
prefetch?: 'intent' | 'hover' | 'viewport' | 'none'
|
|
215
|
-
children?: VNodeChild | null
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
|
|
219
|
-
const router = useContext(RouterContext)
|
|
220
|
-
const prefetchMode = props.prefetch ?? 'intent'
|
|
221
|
-
|
|
222
|
-
const handleClick = (e: MouseEvent) => {
|
|
223
|
-
e.preventDefault()
|
|
224
|
-
if (!router) return
|
|
225
|
-
if (props.replace) {
|
|
226
|
-
router.replace(props.to)
|
|
227
|
-
} else {
|
|
228
|
-
router.push(props.to)
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const triggerPrefetch = () => {
|
|
233
|
-
if (!router) return
|
|
234
|
-
prefetchRoute(router as RouterInstance, props.to)
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const handleMouseEnter = () => {
|
|
238
|
-
if (prefetchMode === 'hover' || prefetchMode === 'intent') triggerPrefetch()
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const handleFocus = () => {
|
|
242
|
-
if (prefetchMode === 'intent') triggerPrefetch()
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const inst = router as RouterInstance | null
|
|
246
|
-
// `href` MUST be an accessor, not a string captured at setup. `props.to`
|
|
247
|
-
// is a getter when the parent passes a reactive expression (the JSX
|
|
248
|
-
// compiler wraps `<RouterLink to={someExpr}>` as `_rp(() => someExpr)`).
|
|
249
|
-
// Capturing into a string at setup time freezes the URL — passing the
|
|
250
|
-
// accessor lets `applyProp` wrap it in `renderEffect` so href tracks the
|
|
251
|
-
// underlying signal.
|
|
252
|
-
const href = (): string =>
|
|
253
|
-
inst?.mode === 'history' ? `${inst._base}${props.to}` : `#${props.to}`
|
|
254
|
-
|
|
255
|
-
const isExactMatch = (): boolean => {
|
|
256
|
-
if (!router) return false
|
|
257
|
-
const target = props.to
|
|
258
|
-
if (typeof target !== 'string') return false
|
|
259
|
-
return router.currentRoute().path === target
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const activeClass = (): string => {
|
|
263
|
-
if (!router) return ''
|
|
264
|
-
const current = router.currentRoute().path
|
|
265
|
-
const target = props.to
|
|
266
|
-
if (typeof target !== 'string') return ''
|
|
267
|
-
const isExact = current === target
|
|
268
|
-
const isActive = isExact || (!props.exact && isSegmentPrefix(current, target))
|
|
269
|
-
|
|
270
|
-
const classes: string[] = []
|
|
271
|
-
if (isActive) classes.push(props.activeClass ?? 'router-link-active')
|
|
272
|
-
if (isExact) classes.push(props.exactActiveClass ?? 'router-link-exact-active')
|
|
273
|
-
return classes.join(' ').trim()
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const ariaCurrent = (): string | undefined => isExactMatch() ? 'page' : undefined
|
|
277
|
-
|
|
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.
|
|
295
|
-
const ref = createRef<Element>()
|
|
296
|
-
if (prefetchMode === 'viewport' && router && typeof IntersectionObserver !== 'undefined') {
|
|
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
|
-
}
|
|
314
|
-
}
|
|
315
|
-
},
|
|
316
|
-
{ rootMargin: '200px' },
|
|
317
|
-
)
|
|
318
|
-
// Observe after mount — the ref will be populated once the element is in the DOM
|
|
319
|
-
queueMicrotask(() => {
|
|
320
|
-
observer.observe(ref.current as Element)
|
|
321
|
-
})
|
|
322
|
-
onUnmount(() => observer.disconnect())
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Forward all non-RouterLink props (style, id, data-*, etc.) to the <a>.
|
|
326
|
-
// `class` is pulled out separately so it can be MERGED with the internal
|
|
327
|
-
// active-class accessor — overriding the user's class silently dropped any
|
|
328
|
-
// conditional class the consumer wanted (e.g. `class={() => cond ? 'on' : ''}`).
|
|
329
|
-
const {
|
|
330
|
-
to: _to,
|
|
331
|
-
replace: _replace,
|
|
332
|
-
activeClass: _ac,
|
|
333
|
-
exactActiveClass: _eac,
|
|
334
|
-
exact: _exact,
|
|
335
|
-
prefetch: _prefetch,
|
|
336
|
-
class: userClass,
|
|
337
|
-
children,
|
|
338
|
-
...rest
|
|
339
|
-
} = props as RouterLinkProps & { class?: ClassValue | (() => ClassValue) }
|
|
340
|
-
|
|
341
|
-
// Compose the user-provided `class` (string / array / object / function) with
|
|
342
|
-
// the internal `activeClass` accessor. Returning a function lets `applyProp`
|
|
343
|
-
// wrap it in `renderEffect` once — so navigation re-evaluates BOTH sides on
|
|
344
|
-
// every route change without rebuilding the link.
|
|
345
|
-
const mergedClass = (): string => {
|
|
346
|
-
const userResolved =
|
|
347
|
-
typeof userClass === 'function' ? (userClass as () => ClassValue)() : userClass
|
|
348
|
-
return cx([userResolved, activeClass()] as ClassValue)
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
return h(
|
|
352
|
-
'a',
|
|
353
|
-
{
|
|
354
|
-
...rest,
|
|
355
|
-
ref,
|
|
356
|
-
href,
|
|
357
|
-
class: mergedClass,
|
|
358
|
-
'aria-current': ariaCurrent,
|
|
359
|
-
onClick: handleClick,
|
|
360
|
-
onMouseEnter: handleMouseEnter,
|
|
361
|
-
onFocus: handleFocus,
|
|
362
|
-
},
|
|
363
|
-
children ?? props.to,
|
|
364
|
-
)
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/** Prefetch loader data for a route (only once per router + path). */
|
|
368
|
-
const MAX_PREFETCH_CACHE = 50
|
|
369
|
-
|
|
370
|
-
function prefetchRoute(router: RouterInstance, path: string): void {
|
|
371
|
-
let set = _prefetched.get(router)
|
|
372
|
-
if (!set) {
|
|
373
|
-
set = new Set()
|
|
374
|
-
_prefetched.set(router, set)
|
|
375
|
-
}
|
|
376
|
-
if (set.has(path)) return
|
|
377
|
-
// Evict oldest entries when cache is full to prevent unbounded growth
|
|
378
|
-
if (set.size >= MAX_PREFETCH_CACHE) {
|
|
379
|
-
const first = set.values().next().value as string
|
|
380
|
-
set.delete(first)
|
|
381
|
-
}
|
|
382
|
-
set.add(path)
|
|
383
|
-
prefetchLoaderData(router, path).catch(() => {
|
|
384
|
-
// Silently ignore — prefetch is best-effort
|
|
385
|
-
set?.delete(path)
|
|
386
|
-
})
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function renderLazyRoute(
|
|
390
|
-
router: RouterInstance,
|
|
391
|
-
record: RouteRecord,
|
|
392
|
-
raw: LazyComponent,
|
|
393
|
-
): VNodeChild {
|
|
394
|
-
if (router._erroredChunks.has(record)) {
|
|
395
|
-
return raw.errorComponent ? h(raw.errorComponent, {}) : null
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
const tryLoad = (attempt: number): Promise<void> =>
|
|
399
|
-
raw
|
|
400
|
-
.loader()
|
|
401
|
-
.then((mod) => {
|
|
402
|
-
const resolved = typeof mod === 'function' ? mod : mod.default
|
|
403
|
-
cacheSet(router, record, resolved)
|
|
404
|
-
router._loadingSignal.update((n) => n + 1)
|
|
405
|
-
})
|
|
406
|
-
.catch((err: unknown) => {
|
|
407
|
-
if (attempt < 3) {
|
|
408
|
-
return new Promise<void>((res) => setTimeout(res, 500 * 2 ** attempt)).then(() =>
|
|
409
|
-
tryLoad(attempt + 1),
|
|
410
|
-
)
|
|
411
|
-
}
|
|
412
|
-
if (typeof window !== 'undefined' && isStaleChunk(err)) {
|
|
413
|
-
window.location.reload()
|
|
414
|
-
return
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
router._erroredChunks.add(record)
|
|
418
|
-
router._loadingSignal.update((n) => n + 1)
|
|
419
|
-
})
|
|
420
|
-
|
|
421
|
-
tryLoad(0)
|
|
422
|
-
return raw.loadingComponent ? h(raw.loadingComponent, {}) : null
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Wraps the route component with a LoaderDataProvider so `useLoaderData()` works
|
|
429
|
-
* inside the component. If the record has no loader, renders the component directly.
|
|
430
|
-
*/
|
|
431
|
-
function renderWithLoader(
|
|
432
|
-
router: RouterInstance,
|
|
433
|
-
record: RouteRecord,
|
|
434
|
-
Comp: ComponentFn,
|
|
435
|
-
route: Pick<ResolvedRoute, 'params' | 'query' | 'meta'>,
|
|
436
|
-
): VNodeChild {
|
|
437
|
-
const routeProps = { params: route.params, query: route.query, meta: route.meta }
|
|
438
|
-
|
|
439
|
-
// If route has an error component, wrap rendering in error boundary
|
|
440
|
-
if (record.errorComponent) {
|
|
441
|
-
return h(ErrorBoundary, {
|
|
442
|
-
fallback: (error: Error) => h(record.errorComponent!, { ...routeProps, error }),
|
|
443
|
-
children: record.loader
|
|
444
|
-
? renderLoaderContent(router, record, Comp, routeProps)
|
|
445
|
-
: h(Comp, routeProps),
|
|
446
|
-
})
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
if (!record.loader) return h(Comp, routeProps)
|
|
450
|
-
return renderLoaderContent(router, record, Comp, routeProps)
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
function renderLoaderContent(
|
|
454
|
-
router: RouterInstance,
|
|
455
|
-
record: RouteRecord,
|
|
456
|
-
Comp: ComponentFn,
|
|
457
|
-
routeProps: Record<string, unknown>,
|
|
458
|
-
): VNodeChild {
|
|
459
|
-
const data = router._loaderData.get(record)
|
|
460
|
-
|
|
461
|
-
if (data !== undefined) {
|
|
462
|
-
return h(LoaderDataProvider, { data, children: h(Comp, routeProps) })
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// Data not yet available — show pending component if configured
|
|
466
|
-
if (record.pendingComponent) {
|
|
467
|
-
return h(PendingLoader as unknown as ComponentFn, {
|
|
468
|
-
router,
|
|
469
|
-
record,
|
|
470
|
-
Comp,
|
|
471
|
-
routeProps,
|
|
472
|
-
})
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
if (record.errorComponent) {
|
|
476
|
-
return h(record.errorComponent, routeProps)
|
|
477
|
-
}
|
|
478
|
-
return h(LoaderDataProvider, { data, children: h(Comp, routeProps) })
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* Signal-based pending component with timing control.
|
|
483
|
-
*
|
|
484
|
-
* State machine: hidden → pending → ready
|
|
485
|
-
* - hidden: initial state, nothing shown (lasts pendingMs)
|
|
486
|
-
* - pending: pendingComponent shown (lasts at least pendingMinMs)
|
|
487
|
-
* - ready: real component shown (loader data arrived + minTime elapsed)
|
|
488
|
-
*/
|
|
489
|
-
function PendingLoader(props: {
|
|
490
|
-
router: RouterInstance
|
|
491
|
-
record: RouteRecord
|
|
492
|
-
Comp: ComponentFn
|
|
493
|
-
routeProps: Record<string, unknown>
|
|
494
|
-
}): VNodeChild {
|
|
495
|
-
const { router, record, Comp, routeProps } = props
|
|
496
|
-
const pendingMs = record.pendingMs ?? 0
|
|
497
|
-
const pendingMinMs = record.pendingMinMs ?? 200
|
|
498
|
-
|
|
499
|
-
type Phase = 'hidden' | 'pending' | 'ready'
|
|
500
|
-
const phase = signal<Phase>(pendingMs === 0 ? 'pending' : 'hidden')
|
|
501
|
-
|
|
502
|
-
let pendingTimer: ReturnType<typeof setTimeout> | null = null
|
|
503
|
-
let minTimer: ReturnType<typeof setTimeout> | null = null
|
|
504
|
-
let minTimeElapsed = pendingMs === 0 ? false : true // if no delay, minTime matters
|
|
505
|
-
let dataReady = false
|
|
506
|
-
|
|
507
|
-
if (pendingMs === 0) {
|
|
508
|
-
// Show pending immediately, start minTime countdown
|
|
509
|
-
minTimeElapsed = false
|
|
510
|
-
minTimer = setTimeout(() => {
|
|
511
|
-
minTimeElapsed = true
|
|
512
|
-
minTimer = null
|
|
513
|
-
if (dataReady) phase.set('ready')
|
|
514
|
-
}, pendingMinMs)
|
|
515
|
-
} else {
|
|
516
|
-
// Delay before showing pending
|
|
517
|
-
pendingTimer = setTimeout(() => {
|
|
518
|
-
pendingTimer = null
|
|
519
|
-
if (dataReady) {
|
|
520
|
-
// Data arrived during delay — skip pending entirely
|
|
521
|
-
phase.set('ready')
|
|
522
|
-
} else {
|
|
523
|
-
phase.set('pending')
|
|
524
|
-
minTimeElapsed = false
|
|
525
|
-
minTimer = setTimeout(() => {
|
|
526
|
-
minTimeElapsed = true
|
|
527
|
-
minTimer = null
|
|
528
|
-
if (dataReady) phase.set('ready')
|
|
529
|
-
}, pendingMinMs)
|
|
530
|
-
}
|
|
531
|
-
}, pendingMs)
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// Watch for loader data arrival
|
|
535
|
-
const checkData = () => {
|
|
536
|
-
const data = router._loaderData.get(record)
|
|
537
|
-
if (data !== undefined) {
|
|
538
|
-
dataReady = true
|
|
539
|
-
if (phase.peek() === 'hidden') {
|
|
540
|
-
// Data arrived before pendingMs — skip pending, go straight to ready
|
|
541
|
-
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null }
|
|
542
|
-
phase.set('ready')
|
|
543
|
-
} else if (minTimeElapsed) {
|
|
544
|
-
phase.set('ready')
|
|
545
|
-
}
|
|
546
|
-
// else: pending is showing but minTime hasn't elapsed — wait for minTimer
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// Poll via loadingSignal reactivity — re-checks when navigation completes
|
|
551
|
-
// This runs inside the reactive accessor below
|
|
552
|
-
|
|
553
|
-
onUnmount(() => {
|
|
554
|
-
if (pendingTimer) clearTimeout(pendingTimer)
|
|
555
|
-
if (minTimer) clearTimeout(minTimer)
|
|
556
|
-
})
|
|
557
|
-
|
|
558
|
-
return (() => {
|
|
559
|
-
// Track router's loading signal to re-run when loader completes
|
|
560
|
-
router._loadingSignal()
|
|
561
|
-
checkData()
|
|
562
|
-
|
|
563
|
-
const p = phase()
|
|
564
|
-
if (p === 'hidden') return null
|
|
565
|
-
if (p === 'pending') return h(record.pendingComponent!, routeProps)
|
|
566
|
-
// ready
|
|
567
|
-
const data = router._loaderData.get(record)
|
|
568
|
-
return h(LoaderDataProvider, { data, children: h(Comp, routeProps) })
|
|
569
|
-
}) as unknown as VNodeChild
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/**
|
|
573
|
-
* Thin provider component that pushes LoaderDataContext before children mount.
|
|
574
|
-
* Uses Pyreon's context stack so useLoaderData() reads it during child setup.
|
|
575
|
-
*/
|
|
576
|
-
function LoaderDataProvider(props: { data: unknown; children: VNodeChild }): VNodeChild {
|
|
577
|
-
provide(LoaderDataContext, props.data)
|
|
578
|
-
return props.children
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
/** Evict oldest cache entries when the component cache exceeds maxCacheSize. */
|
|
582
|
-
function cacheSet(router: RouterInstance, record: RouteRecord, comp: ComponentFn): void {
|
|
583
|
-
router._componentCache.set(record, comp)
|
|
584
|
-
if (router._componentCache.size > router._maxCacheSize) {
|
|
585
|
-
// Map iterates in insertion order — first key is oldest
|
|
586
|
-
const oldest = router._componentCache.keys().next().value as RouteRecord
|
|
587
|
-
router._componentCache.delete(oldest)
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* Segment-aware prefix check for active link matching.
|
|
593
|
-
* `/admin` is a prefix of `/admin/users` but NOT of `/admin-panel`.
|
|
594
|
-
*/
|
|
595
|
-
function isSegmentPrefix(current: string, target: string): boolean {
|
|
596
|
-
if (target === '/') return false
|
|
597
|
-
const cs = current.split('/').filter(Boolean)
|
|
598
|
-
const ts = target.split('/').filter(Boolean)
|
|
599
|
-
if (ts.length > cs.length) return false
|
|
600
|
-
return ts.every((seg, i) => seg === cs[i])
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* Detect a stale chunk error — happens post-deploy when the browser requests
|
|
605
|
-
* a hashed filename that no longer exists on the server. Trigger a full reload
|
|
606
|
-
* so the user gets the new bundle instead of a broken loading state.
|
|
607
|
-
*/
|
|
608
|
-
function isStaleChunk(err: unknown): boolean {
|
|
609
|
-
if (err instanceof TypeError && String(err.message).includes('Failed to fetch')) return true
|
|
610
|
-
if (err instanceof SyntaxError) return true
|
|
611
|
-
return false
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// Mark router framework components as native — compat-mode jsx() runtimes
|
|
615
|
-
// (react/preact/vue/solid-compat) skip wrapCompatComponent for these so their
|
|
616
|
-
// provide() / useContext() / onUnmount() / effect() / IntersectionObserver
|
|
617
|
-
// setup runs inside Pyreon's lifecycle frame instead of the compat wrapper's
|
|
618
|
-
// runUntracked accessor.
|
|
619
|
-
nativeCompat(RouterProvider)
|
|
620
|
-
nativeCompat(RouterView)
|
|
621
|
-
nativeCompat(RouterLink)
|
|
622
|
-
|
|
623
|
-
// ─── DefaultChromeLayout ─────────────────────────────────────────────────────
|
|
624
|
-
//
|
|
625
|
-
// Synthetic layout used by the layout-less-app 404 fallback. When the user
|
|
626
|
-
// has a page-level `notFoundComponent` (`_404.tsx` at the route root without
|
|
627
|
-
// a wrapping `_layout.tsx`), `findNotFoundFallback` in match.ts synthesizes
|
|
628
|
-
// a chain `[DefaultChromeLayout, syntheticLeaf]` and the render pipeline
|
|
629
|
-
// produces 404 HTML wrapped in `<main data-pyreon-default-chrome>` instead
|
|
630
|
-
// of the bare component output.
|
|
631
|
-
//
|
|
632
|
-
// The wrapper is intentionally minimal:
|
|
633
|
-
// - `<main>` provides a semantic landmark for accessibility and SEO.
|
|
634
|
-
// - The `data-pyreon-default-chrome` attribute lets users target the
|
|
635
|
-
// wrapper from CSS if they want to customize spacing / centering.
|
|
636
|
-
// - No prescribed visual styling — the framework can't know the user's
|
|
637
|
-
// design system, so we ship semantics only.
|
|
638
|
-
//
|
|
639
|
-
// Registered via the setter pattern (`_setDefaultChromeLayout`) instead of
|
|
640
|
-
// directly imported into match.ts to avoid a circular dependency: components.tsx
|
|
641
|
-
// depends transitively on match.ts (via router.ts), so match.ts can't import
|
|
642
|
-
// components.tsx without a cycle. The setter call runs at module load —
|
|
643
|
-
// every Pyreon app imports something from `./components.tsx` (RouterProvider,
|
|
644
|
-
// RouterView, RouterLink), which triggers the setter before any resolveRoute
|
|
645
|
-
// call can fire.
|
|
646
|
-
export const DefaultChromeLayout: ComponentFn = () =>
|
|
647
|
-
h('main', { 'data-pyreon-default-chrome': '' }, h(RouterView, null))
|
|
648
|
-
|
|
649
|
-
nativeCompat(DefaultChromeLayout)
|
|
650
|
-
_setDefaultChromeLayout(DefaultChromeLayout)
|
package/src/env.d.ts
DELETED