@pyreon/router 0.24.5 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/router",
3
- "version": "0.24.5",
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.5",
48
- "@pyreon/reactivity": "^0.24.5",
49
- "@pyreon/runtime-dom": "^0.24.5"
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",
@@ -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
@@ -1,6 +0,0 @@
1
- /**
2
- * Minimal process type — just enough for `process.env.NODE_ENV` checks.
3
- * Avoids requiring @types/node in consumers that import pyreon source
4
- * via the `"bun"` export condition.
5
- */
6
- declare var process: { env: { NODE_ENV?: string } }