@pyreon/router 0.13.1 → 0.15.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 +467 -56
- package/lib/types/index.d.ts +218 -13
- package/package.json +6 -5
- package/src/components.tsx +299 -32
- package/src/env.d.ts +6 -0
- package/src/index.ts +5 -0
- package/src/loader.ts +18 -2
- package/src/manifest.ts +63 -0
- package/src/match.ts +48 -8
- package/src/not-found.ts +75 -0
- package/src/redirect.ts +63 -0
- package/src/router.ts +263 -45
- package/src/tests/loader.test.ts +149 -0
- package/src/tests/manifest-snapshot.test.ts +5 -1
- package/src/tests/match.test.ts +31 -0
- package/src/tests/native-markers.test.ts +18 -0
- package/src/tests/redirect.test.ts +96 -0
- package/src/tests/router.browser.test.tsx +68 -1
- package/src/tests/router.test.ts +686 -1
- package/src/tests/routerlink-reactive-to.browser.test.tsx +158 -0
- package/src/types.ts +95 -1
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/components.tsx
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import type { ComponentFn, Props, VNodeChild } from '@pyreon/core'
|
|
2
|
-
import {
|
|
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'
|
|
3
13
|
import { LoaderDataContext, prefetchLoaderData } from './loader'
|
|
4
14
|
import { isLazy, RouterContext, setActiveRouter } from './router'
|
|
5
15
|
import type { LazyComponent, ResolvedRoute, RouteRecord, Router, RouterInstance } from './types'
|
|
@@ -74,30 +84,108 @@ export const RouterView: ComponentFn<RouterViewProps> = (props) => {
|
|
|
74
84
|
router._viewDepth--
|
|
75
85
|
})
|
|
76
86
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
// ── Structure / data decoupling ───────────────────────────────────────────
|
|
88
|
+
//
|
|
89
|
+
// Pre-fix the reactive child accessor read `_loadingSignal` and the full
|
|
90
|
+
// `currentRoute` snapshot. The framework's `mountReactive` tears down and
|
|
91
|
+
// rebuilds the entire subtree on every accessor re-emission, so any
|
|
92
|
+
// unrelated route signal (loader writes, lazy resolution, navigation
|
|
93
|
+
// start/end counters, param changes that don't change the matched record)
|
|
94
|
+
// would tear down the layout, then the page, then everything below it.
|
|
95
|
+
// For a single page load with one cold-start `router.replace()`, that
|
|
96
|
+
// produced ~9 cascading remounts of the layout — confirmed empirically
|
|
97
|
+
// by instance counters.
|
|
98
|
+
//
|
|
99
|
+
// The fix decouples STRUCTURE (which RouteRecord is mounted at this depth
|
|
100
|
+
// + which component to render for it) from DATA (params / query / loader
|
|
101
|
+
// data flowing into the rendered component). One computed returns BOTH
|
|
102
|
+
// the record and its resolved component as an atomic pair — re-emits ONLY
|
|
103
|
+
// when either side changes (reference equality on both fields). Loader
|
|
104
|
+
// writes / param changes / navigation counters don't re-emit; the rendered
|
|
105
|
+
// component receives route data through reactive props + the
|
|
106
|
+
// `LoaderDataProvider` context, which subscribe per-component to the
|
|
107
|
+
// signals they actually care about, so a param change re-renders just the
|
|
108
|
+
// page leaf — not the layout chain above it.
|
|
109
|
+
//
|
|
110
|
+
// The structure is intentionally a SINGLE computed (not two layered ones):
|
|
111
|
+
// when `currentRoute` changes, the reactive child accessor must see a
|
|
112
|
+
// CONSISTENT (rec, comp) pair on its next re-run. With two layered
|
|
113
|
+
// computeds the child accessor subscribes to both, and the order in which
|
|
114
|
+
// those two notify the child is unspecified — if the child runs after rec
|
|
115
|
+
// is notified but before comp re-evaluates, it reads the new rec paired
|
|
116
|
+
// with the OLD comp. Empirically that produced rec=/button paired with
|
|
117
|
+
// comp=HomePage, leaving the previous page rendered after navigation.
|
|
118
|
+
// Combining them into one computed forces atomic emission.
|
|
119
|
+
interface DepthEntry {
|
|
120
|
+
rec: RouteRecord | null
|
|
121
|
+
comp: ComponentFn | null
|
|
122
|
+
/**
|
|
123
|
+
* True when lazy resolution exhausted retries and the chunk is in
|
|
124
|
+
* `_erroredChunks`. Tracked structurally so the entry re-emits when
|
|
125
|
+
* the error state flips on — otherwise `equals` would block the
|
|
126
|
+
* { rec, comp: null } → { rec, comp: null, errored: true } transition
|
|
127
|
+
* (`comp` and `rec` are unchanged) and the error component would
|
|
128
|
+
* never render.
|
|
129
|
+
*/
|
|
130
|
+
errored: boolean
|
|
131
|
+
/**
|
|
132
|
+
* The full ResolvedRoute reference at the time this entry was emitted.
|
|
133
|
+
* `currentRoute` is a `computed` keyed on `currentPath` — same path
|
|
134
|
+
* returns the same memoized reference, different path returns a new
|
|
135
|
+
* one. Tracking the reference in `equals` makes the depth re-emit on
|
|
136
|
+
* any real navigation (params change, query change, hash change) even
|
|
137
|
+
* when the matched record at this depth stays the same — required so
|
|
138
|
+
* `/user/42 → /user/99` re-renders the User component with new params
|
|
139
|
+
* — while NOT re-emitting on navigate-flow noise (`_loadingSignal`
|
|
140
|
+
* start/end ticks, lazy resolution writes that complete without
|
|
141
|
+
* changing currentPath). One emit per real navigation, not per
|
|
142
|
+
* within-navigation signal tick.
|
|
143
|
+
*/
|
|
144
|
+
route: ResolvedRoute
|
|
145
|
+
}
|
|
146
|
+
const depthEntry = computed<DepthEntry>(
|
|
147
|
+
() => {
|
|
148
|
+
const route = router.currentRoute()
|
|
149
|
+
const rec = route.matched[depth] ?? null
|
|
150
|
+
if (!rec) return { rec: null, comp: null, errored: false, route }
|
|
151
|
+
// Subscribe to `_loadingSignal` so lazy resolution wakes this
|
|
152
|
+
// computed up — when the cache fills, we re-emit with comp set.
|
|
153
|
+
router._loadingSignal()
|
|
154
|
+
const errored = router._erroredChunks.has(rec)
|
|
155
|
+
if (errored) return { rec, comp: null, errored: true, route }
|
|
156
|
+
const cached = router._componentCache.get(rec)
|
|
157
|
+
if (cached) return { rec, comp: cached, errored: false, route }
|
|
158
|
+
const raw = rec.component
|
|
159
|
+
if (!isLazy(raw)) {
|
|
160
|
+
cacheSet(router, rec, raw)
|
|
161
|
+
return { rec, comp: raw, errored: false, route }
|
|
162
|
+
}
|
|
163
|
+
// Lazy and not yet cached — `child()` below renders the lazy
|
|
164
|
+
// fallback and triggers the load; once the load completes,
|
|
165
|
+
// `_loadingSignal` ticks and this computed re-emits with `comp` set.
|
|
166
|
+
return { rec, comp: null, errored: false, route }
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
equals: (a, b) =>
|
|
170
|
+
a.rec === b.rec &&
|
|
171
|
+
a.comp === b.comp &&
|
|
172
|
+
a.errored === b.errored &&
|
|
173
|
+
a.route === b.route,
|
|
174
|
+
},
|
|
175
|
+
)
|
|
92
176
|
|
|
93
|
-
|
|
177
|
+
const child = (): VNodeChild => {
|
|
178
|
+
const { rec, comp, route } = depthEntry()
|
|
179
|
+
if (!rec) return null
|
|
94
180
|
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
return renderWithLoader(router, record, raw, route)
|
|
181
|
+
if (comp) {
|
|
182
|
+
return renderWithLoader(router, rec, comp, route)
|
|
98
183
|
}
|
|
99
184
|
|
|
100
|
-
|
|
185
|
+
// Component not yet cached — kick off the lazy load. `renderLazyRoute`
|
|
186
|
+
// mutates `_loadingSignal` and `_componentCache` on completion, which
|
|
187
|
+
// re-emits `depthEntry` and re-runs this accessor with `comp` set.
|
|
188
|
+
return renderLazyRoute(router, rec, rec.component as LazyComponent)
|
|
101
189
|
}
|
|
102
190
|
|
|
103
191
|
return h('div', { 'data-pyreon-router-view': true }, child as unknown as VNodeChild)
|
|
@@ -117,17 +205,18 @@ export interface RouterLinkProps extends Props {
|
|
|
117
205
|
exact?: boolean
|
|
118
206
|
/**
|
|
119
207
|
* Prefetch strategy for loader data:
|
|
120
|
-
* - "
|
|
208
|
+
* - "intent" (default) — prefetch on hover AND focus (covers mouse + keyboard)
|
|
209
|
+
* - "hover" — prefetch on hover only
|
|
121
210
|
* - "viewport" — prefetch when the link scrolls into the viewport
|
|
122
211
|
* - "none" — no prefetching
|
|
123
212
|
*/
|
|
124
|
-
prefetch?: 'hover' | 'viewport' | 'none'
|
|
213
|
+
prefetch?: 'intent' | 'hover' | 'viewport' | 'none'
|
|
125
214
|
children?: VNodeChild | null
|
|
126
215
|
}
|
|
127
216
|
|
|
128
217
|
export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
|
|
129
218
|
const router = useContext(RouterContext)
|
|
130
|
-
const prefetchMode = props.prefetch ?? '
|
|
219
|
+
const prefetchMode = props.prefetch ?? 'intent'
|
|
131
220
|
|
|
132
221
|
const handleClick = (e: MouseEvent) => {
|
|
133
222
|
e.preventDefault()
|
|
@@ -139,13 +228,35 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
|
|
|
139
228
|
}
|
|
140
229
|
}
|
|
141
230
|
|
|
142
|
-
const
|
|
143
|
-
if (
|
|
231
|
+
const triggerPrefetch = () => {
|
|
232
|
+
if (!router) return
|
|
144
233
|
prefetchRoute(router as RouterInstance, props.to)
|
|
145
234
|
}
|
|
146
235
|
|
|
236
|
+
const handleMouseEnter = () => {
|
|
237
|
+
if (prefetchMode === 'hover' || prefetchMode === 'intent') triggerPrefetch()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const handleFocus = () => {
|
|
241
|
+
if (prefetchMode === 'intent') triggerPrefetch()
|
|
242
|
+
}
|
|
243
|
+
|
|
147
244
|
const inst = router as RouterInstance | null
|
|
148
|
-
|
|
245
|
+
// `href` MUST be an accessor, not a string captured at setup. `props.to`
|
|
246
|
+
// is a getter when the parent passes a reactive expression (the JSX
|
|
247
|
+
// compiler wraps `<RouterLink to={someExpr}>` as `_rp(() => someExpr)`).
|
|
248
|
+
// Capturing into a string at setup time freezes the URL — passing the
|
|
249
|
+
// accessor lets `applyProp` wrap it in `renderEffect` so href tracks the
|
|
250
|
+
// underlying signal.
|
|
251
|
+
const href = (): string =>
|
|
252
|
+
inst?.mode === 'history' ? `${inst._base}${props.to}` : `#${props.to}`
|
|
253
|
+
|
|
254
|
+
const isExactMatch = (): boolean => {
|
|
255
|
+
if (!router) return false
|
|
256
|
+
const target = props.to
|
|
257
|
+
if (typeof target !== 'string') return false
|
|
258
|
+
return router.currentRoute().path === target
|
|
259
|
+
}
|
|
149
260
|
|
|
150
261
|
const activeClass = (): string => {
|
|
151
262
|
if (!router) return ''
|
|
@@ -161,6 +272,8 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
|
|
|
161
272
|
return classes.join(' ').trim()
|
|
162
273
|
}
|
|
163
274
|
|
|
275
|
+
const ariaCurrent = (): string | undefined => isExactMatch() ? 'page' : undefined
|
|
276
|
+
|
|
164
277
|
// Viewport prefetching — observe link visibility with IntersectionObserver
|
|
165
278
|
const ref = createRef<Element>()
|
|
166
279
|
if (prefetchMode === 'viewport' && router && typeof IntersectionObserver !== 'undefined') {
|
|
@@ -180,17 +293,51 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
|
|
|
180
293
|
onUnmount(() => observer.disconnect())
|
|
181
294
|
}
|
|
182
295
|
|
|
183
|
-
// Forward all non-RouterLink props (style,
|
|
184
|
-
|
|
296
|
+
// Forward all non-RouterLink props (style, id, data-*, etc.) to the <a>.
|
|
297
|
+
// `class` is pulled out separately so it can be MERGED with the internal
|
|
298
|
+
// active-class accessor — overriding the user's class silently dropped any
|
|
299
|
+
// conditional class the consumer wanted (e.g. `class={() => cond ? 'on' : ''}`).
|
|
300
|
+
const {
|
|
301
|
+
to: _to,
|
|
302
|
+
replace: _replace,
|
|
303
|
+
activeClass: _ac,
|
|
304
|
+
exactActiveClass: _eac,
|
|
305
|
+
exact: _exact,
|
|
306
|
+
prefetch: _prefetch,
|
|
307
|
+
class: userClass,
|
|
308
|
+
children,
|
|
309
|
+
...rest
|
|
310
|
+
} = props as RouterLinkProps & { class?: ClassValue | (() => ClassValue) }
|
|
311
|
+
|
|
312
|
+
// Compose the user-provided `class` (string / array / object / function) with
|
|
313
|
+
// the internal `activeClass` accessor. Returning a function lets `applyProp`
|
|
314
|
+
// wrap it in `renderEffect` once — so navigation re-evaluates BOTH sides on
|
|
315
|
+
// every route change without rebuilding the link.
|
|
316
|
+
const mergedClass = (): string => {
|
|
317
|
+
const userResolved =
|
|
318
|
+
typeof userClass === 'function' ? (userClass as () => ClassValue)() : userClass
|
|
319
|
+
return cx([userResolved, activeClass()] as ClassValue)
|
|
320
|
+
}
|
|
185
321
|
|
|
186
322
|
return h(
|
|
187
323
|
'a',
|
|
188
|
-
{
|
|
324
|
+
{
|
|
325
|
+
...rest,
|
|
326
|
+
ref,
|
|
327
|
+
href,
|
|
328
|
+
class: mergedClass,
|
|
329
|
+
'aria-current': ariaCurrent,
|
|
330
|
+
onClick: handleClick,
|
|
331
|
+
onMouseEnter: handleMouseEnter,
|
|
332
|
+
onFocus: handleFocus,
|
|
333
|
+
},
|
|
189
334
|
children ?? props.to,
|
|
190
335
|
)
|
|
191
336
|
}
|
|
192
337
|
|
|
193
338
|
/** Prefetch loader data for a route (only once per router + path). */
|
|
339
|
+
const MAX_PREFETCH_CACHE = 50
|
|
340
|
+
|
|
194
341
|
function prefetchRoute(router: RouterInstance, path: string): void {
|
|
195
342
|
let set = _prefetched.get(router)
|
|
196
343
|
if (!set) {
|
|
@@ -198,6 +345,11 @@ function prefetchRoute(router: RouterInstance, path: string): void {
|
|
|
198
345
|
_prefetched.set(router, set)
|
|
199
346
|
}
|
|
200
347
|
if (set.has(path)) return
|
|
348
|
+
// Evict oldest entries when cache is full to prevent unbounded growth
|
|
349
|
+
if (set.size >= MAX_PREFETCH_CACHE) {
|
|
350
|
+
const first = set.values().next().value as string
|
|
351
|
+
set.delete(first)
|
|
352
|
+
}
|
|
201
353
|
set.add(path)
|
|
202
354
|
prefetchLoaderData(router, path).catch(() => {
|
|
203
355
|
// Silently ignore — prefetch is best-effort
|
|
@@ -276,12 +428,118 @@ function renderLoaderContent(
|
|
|
276
428
|
routeProps: Record<string, unknown>,
|
|
277
429
|
): VNodeChild {
|
|
278
430
|
const data = router._loaderData.get(record)
|
|
279
|
-
|
|
431
|
+
|
|
432
|
+
if (data !== undefined) {
|
|
433
|
+
return h(LoaderDataProvider, { data, children: h(Comp, routeProps) })
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Data not yet available — show pending component if configured
|
|
437
|
+
if (record.pendingComponent) {
|
|
438
|
+
return h(PendingLoader as unknown as ComponentFn, {
|
|
439
|
+
router,
|
|
440
|
+
record,
|
|
441
|
+
Comp,
|
|
442
|
+
routeProps,
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (record.errorComponent) {
|
|
280
447
|
return h(record.errorComponent, routeProps)
|
|
281
448
|
}
|
|
282
449
|
return h(LoaderDataProvider, { data, children: h(Comp, routeProps) })
|
|
283
450
|
}
|
|
284
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Signal-based pending component with timing control.
|
|
454
|
+
*
|
|
455
|
+
* State machine: hidden → pending → ready
|
|
456
|
+
* - hidden: initial state, nothing shown (lasts pendingMs)
|
|
457
|
+
* - pending: pendingComponent shown (lasts at least pendingMinMs)
|
|
458
|
+
* - ready: real component shown (loader data arrived + minTime elapsed)
|
|
459
|
+
*/
|
|
460
|
+
function PendingLoader(props: {
|
|
461
|
+
router: RouterInstance
|
|
462
|
+
record: RouteRecord
|
|
463
|
+
Comp: ComponentFn
|
|
464
|
+
routeProps: Record<string, unknown>
|
|
465
|
+
}): VNodeChild {
|
|
466
|
+
const { router, record, Comp, routeProps } = props
|
|
467
|
+
const pendingMs = record.pendingMs ?? 0
|
|
468
|
+
const pendingMinMs = record.pendingMinMs ?? 200
|
|
469
|
+
|
|
470
|
+
type Phase = 'hidden' | 'pending' | 'ready'
|
|
471
|
+
const phase = signal<Phase>(pendingMs === 0 ? 'pending' : 'hidden')
|
|
472
|
+
|
|
473
|
+
let pendingTimer: ReturnType<typeof setTimeout> | null = null
|
|
474
|
+
let minTimer: ReturnType<typeof setTimeout> | null = null
|
|
475
|
+
let minTimeElapsed = pendingMs === 0 ? false : true // if no delay, minTime matters
|
|
476
|
+
let dataReady = false
|
|
477
|
+
|
|
478
|
+
if (pendingMs === 0) {
|
|
479
|
+
// Show pending immediately, start minTime countdown
|
|
480
|
+
minTimeElapsed = false
|
|
481
|
+
minTimer = setTimeout(() => {
|
|
482
|
+
minTimeElapsed = true
|
|
483
|
+
minTimer = null
|
|
484
|
+
if (dataReady) phase.set('ready')
|
|
485
|
+
}, pendingMinMs)
|
|
486
|
+
} else {
|
|
487
|
+
// Delay before showing pending
|
|
488
|
+
pendingTimer = setTimeout(() => {
|
|
489
|
+
pendingTimer = null
|
|
490
|
+
if (dataReady) {
|
|
491
|
+
// Data arrived during delay — skip pending entirely
|
|
492
|
+
phase.set('ready')
|
|
493
|
+
} else {
|
|
494
|
+
phase.set('pending')
|
|
495
|
+
minTimeElapsed = false
|
|
496
|
+
minTimer = setTimeout(() => {
|
|
497
|
+
minTimeElapsed = true
|
|
498
|
+
minTimer = null
|
|
499
|
+
if (dataReady) phase.set('ready')
|
|
500
|
+
}, pendingMinMs)
|
|
501
|
+
}
|
|
502
|
+
}, pendingMs)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Watch for loader data arrival
|
|
506
|
+
const checkData = () => {
|
|
507
|
+
const data = router._loaderData.get(record)
|
|
508
|
+
if (data !== undefined) {
|
|
509
|
+
dataReady = true
|
|
510
|
+
if (phase.peek() === 'hidden') {
|
|
511
|
+
// Data arrived before pendingMs — skip pending, go straight to ready
|
|
512
|
+
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null }
|
|
513
|
+
phase.set('ready')
|
|
514
|
+
} else if (minTimeElapsed) {
|
|
515
|
+
phase.set('ready')
|
|
516
|
+
}
|
|
517
|
+
// else: pending is showing but minTime hasn't elapsed — wait for minTimer
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Poll via loadingSignal reactivity — re-checks when navigation completes
|
|
522
|
+
// This runs inside the reactive accessor below
|
|
523
|
+
|
|
524
|
+
onUnmount(() => {
|
|
525
|
+
if (pendingTimer) clearTimeout(pendingTimer)
|
|
526
|
+
if (minTimer) clearTimeout(minTimer)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
return (() => {
|
|
530
|
+
// Track router's loading signal to re-run when loader completes
|
|
531
|
+
router._loadingSignal()
|
|
532
|
+
checkData()
|
|
533
|
+
|
|
534
|
+
const p = phase()
|
|
535
|
+
if (p === 'hidden') return null
|
|
536
|
+
if (p === 'pending') return h(record.pendingComponent!, routeProps)
|
|
537
|
+
// ready
|
|
538
|
+
const data = router._loaderData.get(record)
|
|
539
|
+
return h(LoaderDataProvider, { data, children: h(Comp, routeProps) })
|
|
540
|
+
}) as unknown as VNodeChild
|
|
541
|
+
}
|
|
542
|
+
|
|
285
543
|
/**
|
|
286
544
|
* Thin provider component that pushes LoaderDataContext before children mount.
|
|
287
545
|
* Uses Pyreon's context stack so useLoaderData() reads it during child setup.
|
|
@@ -323,3 +581,12 @@ function isStaleChunk(err: unknown): boolean {
|
|
|
323
581
|
if (err instanceof SyntaxError) return true
|
|
324
582
|
return false
|
|
325
583
|
}
|
|
584
|
+
|
|
585
|
+
// Mark router framework components as native — compat-mode jsx() runtimes
|
|
586
|
+
// (react/preact/vue/solid-compat) skip wrapCompatComponent for these so their
|
|
587
|
+
// provide() / useContext() / onUnmount() / effect() / IntersectionObserver
|
|
588
|
+
// setup runs inside Pyreon's lifecycle frame instead of the compat wrapper's
|
|
589
|
+
// runUntracked accessor.
|
|
590
|
+
nativeCompat(RouterProvider)
|
|
591
|
+
nativeCompat(RouterView)
|
|
592
|
+
nativeCompat(RouterLink)
|
package/src/env.d.ts
ADDED
package/src/index.ts
CHANGED
|
@@ -44,6 +44,10 @@
|
|
|
44
44
|
export type { RouterLinkProps, RouterProviderProps, RouterViewProps } from './components'
|
|
45
45
|
// Components
|
|
46
46
|
export { RouterLink, RouterProvider, RouterView } from './components'
|
|
47
|
+
export type { NotFoundBoundaryProps } from './not-found'
|
|
48
|
+
export { isNotFoundError, NotFoundBoundary, notFound } from './not-found'
|
|
49
|
+
export type { RedirectStatus } from './redirect'
|
|
50
|
+
export { getRedirectInfo, isRedirectError, redirect } from './redirect'
|
|
47
51
|
export { hydrateLoaderData, prefetchLoaderData, serializeLoaderData, useLoaderData } from './loader'
|
|
48
52
|
// Match utilities (useful for SSR route pre-fetching)
|
|
49
53
|
export {
|
|
@@ -68,6 +72,7 @@ export {
|
|
|
68
72
|
useSearchParams,
|
|
69
73
|
useTransition,
|
|
70
74
|
useTypedSearchParams,
|
|
75
|
+
useValidatedSearch,
|
|
71
76
|
} from './router'
|
|
72
77
|
// Types
|
|
73
78
|
// Data loaders
|
package/src/loader.ts
CHANGED
|
@@ -2,6 +2,10 @@ import type { Context } from '@pyreon/core'
|
|
|
2
2
|
import { createContext, useContext } from '@pyreon/core'
|
|
3
3
|
import type { RouterInstance } from './types'
|
|
4
4
|
|
|
5
|
+
// Dev-mode gate + counter sink. See packages/internals/perf-harness for contract.
|
|
6
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
7
|
+
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
8
|
+
|
|
5
9
|
/**
|
|
6
10
|
* Context frame that holds the loader data for the currently rendered route record.
|
|
7
11
|
* Pushed by RouterView's withLoaderData wrapper before invoking the route component.
|
|
@@ -28,12 +32,23 @@ export function useLoaderData<T = unknown>(): T {
|
|
|
28
32
|
* SSR helper: pre-run all loaders for the given path before rendering.
|
|
29
33
|
* Call this before `renderToString` so route components can read data via `useLoaderData()`.
|
|
30
34
|
*
|
|
35
|
+
* The optional `request` is forwarded to each loader's `LoaderContext.request`,
|
|
36
|
+
* letting server-side loaders read cookies / auth headers and `throw redirect()`
|
|
37
|
+
* before the layout renders. A loader that throws `redirect()` propagates the
|
|
38
|
+
* thrown error here — the SSR handler's `catch` converts it into a 302/307
|
|
39
|
+
* `Location:` Response.
|
|
40
|
+
*
|
|
31
41
|
* @example
|
|
32
42
|
* const router = createRouter({ routes, url: req.url })
|
|
33
|
-
* await prefetchLoaderData(router, req.url)
|
|
43
|
+
* await prefetchLoaderData(router, req.url, request)
|
|
34
44
|
* const html = await renderToString(h(App, { router }))
|
|
35
45
|
*/
|
|
36
|
-
export async function prefetchLoaderData(
|
|
46
|
+
export async function prefetchLoaderData(
|
|
47
|
+
router: RouterInstance,
|
|
48
|
+
path: string,
|
|
49
|
+
request?: Request,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
if (__DEV__) _countSink.__pyreon_count__?.('router.prefetch')
|
|
37
52
|
const route = router._resolve(path)
|
|
38
53
|
// Use a local AbortController — prefetch is best-effort and must NOT
|
|
39
54
|
// clobber `router._abortController`, which belongs to the active
|
|
@@ -48,6 +63,7 @@ export async function prefetchLoaderData(router: RouterInstance, path: string):
|
|
|
48
63
|
params: route.params,
|
|
49
64
|
query: route.query,
|
|
50
65
|
signal: ac.signal,
|
|
66
|
+
...(request ? { request } : {}),
|
|
51
67
|
})
|
|
52
68
|
router._loaderData.set(r, data)
|
|
53
69
|
}),
|
package/src/manifest.ts
CHANGED
|
@@ -254,6 +254,69 @@ const User = () => {
|
|
|
254
254
|
}`,
|
|
255
255
|
seeAlso: ['useMiddlewareData', 'useRoute'],
|
|
256
256
|
},
|
|
257
|
+
{
|
|
258
|
+
name: 'redirect',
|
|
259
|
+
kind: 'function',
|
|
260
|
+
signature: 'redirect(url: string, status?: 301 | 302 | 303 | 307 | 308): never',
|
|
261
|
+
summary:
|
|
262
|
+
"Throw inside a route loader to redirect the navigation BEFORE the layout renders. On SSR (initial nav), the thrown error is converted by `@pyreon/server`'s handler into a real HTTP `302`/`307` `Location:` response — no layout HTML leaves the server. On CSR (subsequent nav), the redirect propagates through the navigate flow and triggers `router.replace()` before any matched route's component mounts. Replaces the fragile `onMount + router.push()` workaround for auth-gates under nested-layout dev SSR + hydration. Default status is `307` (Temporary Redirect, method-preserving).",
|
|
263
|
+
example: `// src/routes/app/_layout.tsx
|
|
264
|
+
import { redirect, type LoaderContext } from "@pyreon/router"
|
|
265
|
+
|
|
266
|
+
export async function loader(ctx: LoaderContext) {
|
|
267
|
+
// SSR: read from request headers; CSR: read from document.cookie
|
|
268
|
+
const cookie = ctx.request?.headers.get("cookie")
|
|
269
|
+
?? (typeof document !== "undefined" ? document.cookie : "")
|
|
270
|
+
const sid = /(?:^|;\\s*)sid=([^;]+)/.exec(cookie)?.[1]
|
|
271
|
+
if (!sid) redirect("/login")
|
|
272
|
+
const session = await getSession(sid)
|
|
273
|
+
if (!session) redirect("/login")
|
|
274
|
+
return { session }
|
|
275
|
+
}`,
|
|
276
|
+
mistakes: [
|
|
277
|
+
'Calling `redirect()` outside a loader (in a component body, an event handler, etc.) — the helper expects to be caught by the loader-runner. For imperative redirects from event handlers, use `router.replace(target)` instead.',
|
|
278
|
+
"Forgetting to make `LoaderContext.request` access optional. It's populated only on SSR; CSR loaders see `request: undefined`. Read both: `ctx.request?.headers.get('cookie') ?? document.cookie`.",
|
|
279
|
+
'Using `redirect()` for control-flow that should be a `<Match>` / `<Show>` conditional — the helper is for redirecting the URL, not for branching the rendered output.',
|
|
280
|
+
'Returning `redirect()` instead of throwing it. The helper has return type `never` and throws — `return redirect(...)` is misleading and may suppress the throw under TS strict-null checks.',
|
|
281
|
+
'Picking the wrong status. Default `307` preserves the request method (POST stays POST after redirect). Use `302`/`303` to force GET on the target. Use `301`/`308` for PERMANENT moves (browsers cache them aggressively).',
|
|
282
|
+
'Assuming `redirect()` cancels every loader in a sibling chain. The first loader to throw wins; later loaders in the same `Promise.allSettled` batch may have already started executing before the redirect short-circuits. Treat them as best-effort.',
|
|
283
|
+
],
|
|
284
|
+
seeAlso: ['notFound', 'useLoaderData', 'isRedirectError'],
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: 'isRedirectError',
|
|
288
|
+
kind: 'function',
|
|
289
|
+
signature: 'isRedirectError(err: unknown): boolean',
|
|
290
|
+
summary:
|
|
291
|
+
'Type guard for errors thrown by `redirect()`. Used internally by the router (CSR) and `@pyreon/server` (SSR) to distinguish redirect-control-flow errors from real failures. Useful in custom error boundaries that should let redirects pass through to the framework instead of catching them.',
|
|
292
|
+
example: `import { ErrorBoundary } from "@pyreon/core"
|
|
293
|
+
import { isRedirectError } from "@pyreon/router"
|
|
294
|
+
|
|
295
|
+
<ErrorBoundary fallback={(err, reset) => {
|
|
296
|
+
if (isRedirectError(err)) throw err // let the framework handle it
|
|
297
|
+
return <ErrorPage error={err} onReset={reset} />
|
|
298
|
+
}}>
|
|
299
|
+
<App />
|
|
300
|
+
</ErrorBoundary>`,
|
|
301
|
+
seeAlso: ['redirect', 'isNotFoundError', 'getRedirectInfo'],
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: 'getRedirectInfo',
|
|
305
|
+
kind: 'function',
|
|
306
|
+
signature: 'getRedirectInfo(err: unknown): { url: string; status: 301 | 302 | 303 | 307 | 308 } | null',
|
|
307
|
+
summary:
|
|
308
|
+
"Extract the redirect URL and status from a thrown RedirectError. Returns `null` for non-redirect errors. Used by `@pyreon/server`'s SSR handler to convert the thrown error into a 302/307 `Response`.",
|
|
309
|
+
example: `import { getRedirectInfo } from "@pyreon/router"
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
await prefetchLoaderData(router, path, request)
|
|
313
|
+
} catch (err) {
|
|
314
|
+
const info = getRedirectInfo(err)
|
|
315
|
+
if (info) return new Response(null, { status: info.status, headers: { Location: info.url } })
|
|
316
|
+
throw err
|
|
317
|
+
}`,
|
|
318
|
+
seeAlso: ['redirect', 'isRedirectError'],
|
|
319
|
+
},
|
|
257
320
|
{
|
|
258
321
|
name: 'useSearchParams',
|
|
259
322
|
kind: 'hook',
|
package/src/match.ts
CHANGED
|
@@ -6,17 +6,22 @@ import type { ResolvedRoute, RouteMeta, RouteRecord } from './types'
|
|
|
6
6
|
* Parse a query string into key-value pairs. Duplicate keys are overwritten
|
|
7
7
|
* (last value wins). Use `parseQueryMulti` to preserve duplicates as arrays.
|
|
8
8
|
*/
|
|
9
|
+
/** Decode a query component: `+` → space (per application/x-www-form-urlencoded), then URI-decode. */
|
|
10
|
+
function decodeQueryComponent(raw: string): string {
|
|
11
|
+
return decodeURIComponent(raw.replace(/\+/g, ' '))
|
|
12
|
+
}
|
|
13
|
+
|
|
9
14
|
export function parseQuery(qs: string): Record<string, string> {
|
|
10
15
|
if (!qs) return {}
|
|
11
16
|
const result: Record<string, string> = {}
|
|
12
17
|
for (const part of qs.split('&')) {
|
|
13
18
|
const eqIdx = part.indexOf('=')
|
|
14
19
|
if (eqIdx < 0) {
|
|
15
|
-
const key =
|
|
20
|
+
const key = decodeQueryComponent(part)
|
|
16
21
|
if (key) result[key] = ''
|
|
17
22
|
} else {
|
|
18
|
-
const key =
|
|
19
|
-
const val =
|
|
23
|
+
const key = decodeQueryComponent(part.slice(0, eqIdx))
|
|
24
|
+
const val = decodeQueryComponent(part.slice(eqIdx + 1))
|
|
20
25
|
if (key) result[key] = val
|
|
21
26
|
}
|
|
22
27
|
}
|
|
@@ -38,11 +43,11 @@ export function parseQueryMulti(qs: string): Record<string, string | string[]> {
|
|
|
38
43
|
let key: string
|
|
39
44
|
let val: string
|
|
40
45
|
if (eqIdx < 0) {
|
|
41
|
-
key =
|
|
46
|
+
key = decodeQueryComponent(part)
|
|
42
47
|
val = ''
|
|
43
48
|
} else {
|
|
44
|
-
key =
|
|
45
|
-
val =
|
|
49
|
+
key = decodeQueryComponent(part.slice(0, eqIdx))
|
|
50
|
+
val = decodeQueryComponent(part.slice(eqIdx + 1))
|
|
46
51
|
}
|
|
47
52
|
if (!key) continue
|
|
48
53
|
const existing = result[key]
|
|
@@ -288,7 +293,18 @@ function flattenOne(
|
|
|
288
293
|
return
|
|
289
294
|
}
|
|
290
295
|
|
|
291
|
-
|
|
296
|
+
// fs-router emits absolute paths for nested children (e.g. parent
|
|
297
|
+
// `/app` with child `/app/dashboard`, NOT child `dashboard`). Concating
|
|
298
|
+
// parent segments with the child's already-absolute segments would
|
|
299
|
+
// produce `/app/app/dashboard` — the staticMap then lookups the wrong
|
|
300
|
+
// key and resolveRoute returns `matched: []` for any such request.
|
|
301
|
+
// Detect "child path is absolute" (`path` starts with `/`) and skip the
|
|
302
|
+
// parent-segment prefix in that case — the child's own segments ARE
|
|
303
|
+
// the full intended path. Relative children (`dashboard`, `:id`)
|
|
304
|
+
// continue to inherit the parent's segments via concatenation.
|
|
305
|
+
const childPath = c.route.path
|
|
306
|
+
const isAbsoluteChild = typeof childPath === 'string' && childPath.startsWith('/')
|
|
307
|
+
const joined = isAbsoluteChild ? c.segments : [...parentSegments, ...c.segments]
|
|
292
308
|
if (c.children && c.children.length > 0) {
|
|
293
309
|
flattenWalk(result, c.children, joined, chain, meta)
|
|
294
310
|
}
|
|
@@ -558,6 +574,7 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
|
|
|
558
574
|
hash,
|
|
559
575
|
matched: staticMatch.matchedChain,
|
|
560
576
|
meta: staticMatch.meta,
|
|
577
|
+
search: runValidateSearch(staticMatch.matchedChain, query),
|
|
561
578
|
}
|
|
562
579
|
}
|
|
563
580
|
|
|
@@ -579,6 +596,7 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
|
|
|
579
596
|
hash,
|
|
580
597
|
matched: match.matched,
|
|
581
598
|
meta: mergeMeta(match.matched),
|
|
599
|
+
search: runValidateSearch(match.matched, query),
|
|
582
600
|
}
|
|
583
601
|
}
|
|
584
602
|
}
|
|
@@ -594,6 +612,7 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
|
|
|
594
612
|
hash,
|
|
595
613
|
matched: dynMatch.matched,
|
|
596
614
|
meta: mergeMeta(dynMatch.matched),
|
|
615
|
+
search: runValidateSearch(dynMatch.matched, query),
|
|
597
616
|
}
|
|
598
617
|
}
|
|
599
618
|
|
|
@@ -607,10 +626,31 @@ export function resolveRoute(rawPath: string, routes: RouteRecord[]): ResolvedRo
|
|
|
607
626
|
hash,
|
|
608
627
|
matched: w.matchedChain,
|
|
609
628
|
meta: w.meta,
|
|
629
|
+
search: runValidateSearch(w.matchedChain, query),
|
|
610
630
|
}
|
|
611
631
|
}
|
|
612
632
|
|
|
613
|
-
return { path: cleanPath, params: {}, query, hash, matched: [], meta: {} }
|
|
633
|
+
return { path: cleanPath, params: {}, query, hash, matched: [], meta: {}, search: {} }
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Run validateSearch from the deepest matched route that has one. */
|
|
637
|
+
function runValidateSearch(
|
|
638
|
+
matched: RouteRecord[],
|
|
639
|
+
query: Record<string, string>,
|
|
640
|
+
): Record<string, unknown> {
|
|
641
|
+
// Walk from leaf to root — first validateSearch wins (most specific route)
|
|
642
|
+
for (let i = matched.length - 1; i >= 0; i--) {
|
|
643
|
+
const validate = matched[i]?.validateSearch
|
|
644
|
+
if (validate) {
|
|
645
|
+
try {
|
|
646
|
+
return validate(query)
|
|
647
|
+
} catch {
|
|
648
|
+
// Validation failed — return raw query as-is
|
|
649
|
+
return { ...query }
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return {}
|
|
614
654
|
}
|
|
615
655
|
|
|
616
656
|
/** Merge meta from matched routes (leaf takes precedence) */
|