@pyreon/router 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +830 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +690 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +322 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +49 -0
- package/src/components.tsx +307 -0
- package/src/index.ts +78 -0
- package/src/loader.ts +97 -0
- package/src/match.ts +264 -0
- package/src/router.ts +451 -0
- package/src/scroll.ts +55 -0
- package/src/tests/router.test.ts +3294 -0
- package/src/tests/setup.ts +3 -0
- package/src/types.ts +242 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import type { ComponentFn, Props, VNode, VNodeChild } from "@pyreon/core"
|
|
2
|
+
import { createRef, h, onUnmount, popContext, pushContext, useContext } from "@pyreon/core"
|
|
3
|
+
import { LoaderDataContext, prefetchLoaderData } from "./loader"
|
|
4
|
+
import { isLazy, RouterContext, setActiveRouter } from "./router"
|
|
5
|
+
import type { LazyComponent, ResolvedRoute, RouteRecord, Router, RouterInstance } from "./types"
|
|
6
|
+
|
|
7
|
+
// Track prefetched paths per router to avoid duplicate fetches
|
|
8
|
+
const _prefetched = new WeakMap<RouterInstance, Set<string>>()
|
|
9
|
+
|
|
10
|
+
// ─── RouterProvider ───────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface RouterProviderProps extends Props {
|
|
13
|
+
router: Router
|
|
14
|
+
children?: VNode | VNodeChild | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const RouterProvider: ComponentFn<RouterProviderProps> = (props) => {
|
|
18
|
+
const router = props.router as RouterInstance
|
|
19
|
+
// Push router into the context stack — isolated per request in SSR via ALS,
|
|
20
|
+
// isolated per component tree in CSR.
|
|
21
|
+
const frame = new Map([[RouterContext.id, router]])
|
|
22
|
+
pushContext(frame)
|
|
23
|
+
onUnmount(() => {
|
|
24
|
+
popContext()
|
|
25
|
+
// Clean up event listeners, caches, abort in-flight navigations.
|
|
26
|
+
// Safe to call multiple times (destroy is idempotent).
|
|
27
|
+
router.destroy()
|
|
28
|
+
setActiveRouter(null)
|
|
29
|
+
})
|
|
30
|
+
// Also set the module fallback so programmatic useRouter() outside a component
|
|
31
|
+
// tree (e.g. navigation guards in event handlers) still works in CSR.
|
|
32
|
+
setActiveRouter(router)
|
|
33
|
+
return (props.children ?? null) as VNode | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── RouterView ───────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export interface RouterViewProps extends Props {
|
|
39
|
+
/** Explicitly pass a router (optional — uses the active router by default) */
|
|
40
|
+
router?: Router
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Renders the matched route component at this nesting level.
|
|
45
|
+
*
|
|
46
|
+
* Nested layouts work by placing a second `<RouterView />` inside the layout
|
|
47
|
+
* component — it automatically renders the next level of the matched route.
|
|
48
|
+
*
|
|
49
|
+
* How depth tracking works:
|
|
50
|
+
* Pyreon components run once in depth-first tree order. Each `RouterView`
|
|
51
|
+
* captures `router._viewDepth` at setup time and immediately increments it,
|
|
52
|
+
* so sibling and child views get the correct index. `onUnmount` decrements
|
|
53
|
+
* the counter so dynamic route swaps work correctly.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* // Route config:
|
|
57
|
+
* { path: "/admin", component: AdminLayout, children: [
|
|
58
|
+
* { path: "users", component: AdminUsers },
|
|
59
|
+
* ]}
|
|
60
|
+
*
|
|
61
|
+
* // AdminLayout renders a nested RouterView:
|
|
62
|
+
* function AdminLayout() {
|
|
63
|
+
* return <div><Sidebar /><RouterView /></div>
|
|
64
|
+
* }
|
|
65
|
+
*/
|
|
66
|
+
export const RouterView: ComponentFn<RouterViewProps> = (props) => {
|
|
67
|
+
const router = ((props.router as RouterInstance | undefined) ??
|
|
68
|
+
useContext(RouterContext)) as RouterInstance | null
|
|
69
|
+
if (!router) return null
|
|
70
|
+
|
|
71
|
+
// Claim this view's depth at setup time (depth-first component init order)
|
|
72
|
+
const depth = router._viewDepth
|
|
73
|
+
router._viewDepth++
|
|
74
|
+
|
|
75
|
+
onUnmount(() => {
|
|
76
|
+
router._viewDepth--
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const child = (): VNodeChild => {
|
|
80
|
+
router._loadingSignal() // reactive — re-renders after lazy load completes
|
|
81
|
+
|
|
82
|
+
const route = router.currentRoute()
|
|
83
|
+
|
|
84
|
+
if (route.matched.length === 0) return null
|
|
85
|
+
|
|
86
|
+
// Render the matched record at this view's depth level
|
|
87
|
+
const record = route.matched[depth]
|
|
88
|
+
if (!record) return null // no component at this nesting level
|
|
89
|
+
|
|
90
|
+
const cached = router._componentCache.get(record)
|
|
91
|
+
if (cached) {
|
|
92
|
+
return renderWithLoader(router, record, cached, route)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const raw = record.component
|
|
96
|
+
|
|
97
|
+
if (!isLazy(raw)) {
|
|
98
|
+
cacheSet(router, record, raw)
|
|
99
|
+
return renderWithLoader(router, record, raw, route)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return renderLazyRoute(router, record, raw)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return h("div", { "data-pyreon-router-view": true }, child as unknown as VNodeChild)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── RouterLink ───────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export interface RouterLinkProps extends Props {
|
|
111
|
+
to: string
|
|
112
|
+
/** If true, uses router.replace() instead of router.push() */
|
|
113
|
+
replace?: boolean
|
|
114
|
+
/** CSS class applied when this link is active (default: "router-link-active") */
|
|
115
|
+
activeClass?: string
|
|
116
|
+
/** CSS class for exact-match active state (default: "router-link-exact-active") */
|
|
117
|
+
exactActiveClass?: string
|
|
118
|
+
/** If true, only applies activeClass on exact match */
|
|
119
|
+
exact?: boolean
|
|
120
|
+
/**
|
|
121
|
+
* Prefetch strategy for loader data:
|
|
122
|
+
* - "hover" (default) — prefetch when the user hovers over the link
|
|
123
|
+
* - "viewport" — prefetch when the link scrolls into the viewport
|
|
124
|
+
* - "none" — no prefetching
|
|
125
|
+
*/
|
|
126
|
+
prefetch?: "hover" | "viewport" | "none"
|
|
127
|
+
children?: VNodeChild | null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
|
|
131
|
+
const router = useContext(RouterContext)
|
|
132
|
+
const prefetchMode = props.prefetch ?? "hover"
|
|
133
|
+
|
|
134
|
+
const handleClick = (e: MouseEvent) => {
|
|
135
|
+
e.preventDefault()
|
|
136
|
+
if (!router) return
|
|
137
|
+
if (props.replace) {
|
|
138
|
+
router.replace(props.to)
|
|
139
|
+
} else {
|
|
140
|
+
router.push(props.to)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const handleMouseEnter = () => {
|
|
145
|
+
if (prefetchMode !== "hover" || !router) return
|
|
146
|
+
prefetchRoute(router as RouterInstance, props.to)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const href = router?.mode === "history" ? props.to : `#${props.to}`
|
|
150
|
+
|
|
151
|
+
const activeClass = (): string => {
|
|
152
|
+
if (!router) return ""
|
|
153
|
+
const current = router.currentRoute().path
|
|
154
|
+
const target = props.to
|
|
155
|
+
const isExact = current === target
|
|
156
|
+
const isActive = isExact || (!props.exact && isSegmentPrefix(current, target))
|
|
157
|
+
|
|
158
|
+
const classes: string[] = []
|
|
159
|
+
if (isActive) classes.push(props.activeClass ?? "router-link-active")
|
|
160
|
+
if (isExact) classes.push(props.exactActiveClass ?? "router-link-exact-active")
|
|
161
|
+
return classes.join(" ").trim()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Viewport prefetching — observe link visibility with IntersectionObserver
|
|
165
|
+
const ref = createRef<Element>()
|
|
166
|
+
if (prefetchMode === "viewport" && router && typeof IntersectionObserver !== "undefined") {
|
|
167
|
+
const observer = new IntersectionObserver((entries) => {
|
|
168
|
+
for (const entry of entries) {
|
|
169
|
+
if (entry.isIntersecting) {
|
|
170
|
+
prefetchRoute(router as RouterInstance, props.to)
|
|
171
|
+
observer.disconnect()
|
|
172
|
+
break
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
// Observe after mount — the ref will be populated once the element is in the DOM
|
|
177
|
+
queueMicrotask(() => {
|
|
178
|
+
observer.observe(ref.current as Element)
|
|
179
|
+
})
|
|
180
|
+
onUnmount(() => observer.disconnect())
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return h(
|
|
184
|
+
"a",
|
|
185
|
+
{ ref, href, class: activeClass, onClick: handleClick, onMouseEnter: handleMouseEnter },
|
|
186
|
+
props.children ?? props.to,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Prefetch loader data for a route (only once per router + path). */
|
|
191
|
+
function prefetchRoute(router: RouterInstance, path: string): void {
|
|
192
|
+
let set = _prefetched.get(router)
|
|
193
|
+
if (!set) {
|
|
194
|
+
set = new Set()
|
|
195
|
+
_prefetched.set(router, set)
|
|
196
|
+
}
|
|
197
|
+
if (set.has(path)) return
|
|
198
|
+
set.add(path)
|
|
199
|
+
prefetchLoaderData(router, path).catch(() => {
|
|
200
|
+
// Silently ignore — prefetch is best-effort
|
|
201
|
+
set?.delete(path)
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function renderLazyRoute(
|
|
206
|
+
router: RouterInstance,
|
|
207
|
+
record: RouteRecord,
|
|
208
|
+
raw: LazyComponent,
|
|
209
|
+
): VNodeChild {
|
|
210
|
+
if (router._erroredChunks.has(record)) {
|
|
211
|
+
return raw.errorComponent ? h(raw.errorComponent, {}) : null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const tryLoad = (attempt: number): Promise<void> =>
|
|
215
|
+
raw
|
|
216
|
+
.loader()
|
|
217
|
+
.then((mod) => {
|
|
218
|
+
const resolved = typeof mod === "function" ? mod : mod.default
|
|
219
|
+
cacheSet(router, record, resolved)
|
|
220
|
+
router._loadingSignal.update((n) => n + 1)
|
|
221
|
+
})
|
|
222
|
+
.catch((err: unknown) => {
|
|
223
|
+
if (attempt < 3) {
|
|
224
|
+
return new Promise<void>((res) => setTimeout(res, 500 * 2 ** attempt)).then(() =>
|
|
225
|
+
tryLoad(attempt + 1),
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
if (typeof window !== "undefined" && isStaleChunk(err)) {
|
|
229
|
+
window.location.reload()
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
router._erroredChunks.add(record)
|
|
234
|
+
router._loadingSignal.update((n) => n + 1)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
tryLoad(0)
|
|
238
|
+
return raw.loadingComponent ? h(raw.loadingComponent, {}) : null
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Wraps the route component with a LoaderDataProvider so `useLoaderData()` works
|
|
245
|
+
* inside the component. If the record has no loader, renders the component directly.
|
|
246
|
+
*/
|
|
247
|
+
function renderWithLoader(
|
|
248
|
+
router: RouterInstance,
|
|
249
|
+
record: RouteRecord,
|
|
250
|
+
Comp: ComponentFn,
|
|
251
|
+
route: Pick<ResolvedRoute, "params" | "query" | "meta">,
|
|
252
|
+
): VNodeChild {
|
|
253
|
+
const routeProps = { params: route.params, query: route.query, meta: route.meta }
|
|
254
|
+
if (!record.loader) {
|
|
255
|
+
return h(Comp, routeProps)
|
|
256
|
+
}
|
|
257
|
+
const data = router._loaderData.get(record)
|
|
258
|
+
// If loader data is undefined and route has an errorComponent, render it
|
|
259
|
+
if (data === undefined && record.errorComponent) {
|
|
260
|
+
return h(record.errorComponent, routeProps)
|
|
261
|
+
}
|
|
262
|
+
return h(LoaderDataProvider, { data, children: h(Comp, routeProps) })
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Thin provider component that pushes LoaderDataContext before children mount.
|
|
267
|
+
* Uses Pyreon's context stack so useLoaderData() reads it during child setup.
|
|
268
|
+
*/
|
|
269
|
+
function LoaderDataProvider(props: { data: unknown; children: VNode | null }): VNode | null {
|
|
270
|
+
const frame = new Map([[LoaderDataContext.id, props.data]])
|
|
271
|
+
pushContext(frame)
|
|
272
|
+
onUnmount(() => popContext())
|
|
273
|
+
return props.children
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Evict oldest cache entries when the component cache exceeds maxCacheSize. */
|
|
277
|
+
function cacheSet(router: RouterInstance, record: RouteRecord, comp: ComponentFn): void {
|
|
278
|
+
router._componentCache.set(record, comp)
|
|
279
|
+
if (router._componentCache.size > router._maxCacheSize) {
|
|
280
|
+
// Map iterates in insertion order — first key is oldest
|
|
281
|
+
const oldest = router._componentCache.keys().next().value as RouteRecord
|
|
282
|
+
router._componentCache.delete(oldest)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Segment-aware prefix check for active link matching.
|
|
288
|
+
* `/admin` is a prefix of `/admin/users` but NOT of `/admin-panel`.
|
|
289
|
+
*/
|
|
290
|
+
function isSegmentPrefix(current: string, target: string): boolean {
|
|
291
|
+
if (target === "/") return false
|
|
292
|
+
const cs = current.split("/").filter(Boolean)
|
|
293
|
+
const ts = target.split("/").filter(Boolean)
|
|
294
|
+
if (ts.length > cs.length) return false
|
|
295
|
+
return ts.every((seg, i) => seg === cs[i])
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Detect a stale chunk error — happens post-deploy when the browser requests
|
|
300
|
+
* a hashed filename that no longer exists on the server. Trigger a full reload
|
|
301
|
+
* so the user gets the new bundle instead of a broken loading state.
|
|
302
|
+
*/
|
|
303
|
+
function isStaleChunk(err: unknown): boolean {
|
|
304
|
+
if (err instanceof TypeError && String(err.message).includes("Failed to fetch")) return true
|
|
305
|
+
if (err instanceof SyntaxError) return true
|
|
306
|
+
return false
|
|
307
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pyreon/router — type-safe client-side router for Pyreon.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - TypeScript param inference from path strings
|
|
6
|
+
* - Nested routes with recursive matching
|
|
7
|
+
* - Per-route and global navigation guards
|
|
8
|
+
* - Redirects (static and dynamic)
|
|
9
|
+
* - Route metadata (title, requiresAuth, scrollBehavior, …)
|
|
10
|
+
* - Named routes + typed navigation
|
|
11
|
+
* - Lazy loading with optional loading component
|
|
12
|
+
* - Scroll restoration
|
|
13
|
+
* - Hash and history mode
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const router = createRouter({
|
|
17
|
+
* routes: [
|
|
18
|
+
* { path: "/", component: Home },
|
|
19
|
+
* { path: "/about", component: About },
|
|
20
|
+
* { path: "/user/:id", component: UserPage, name: "user",
|
|
21
|
+
* meta: { title: "User Profile" } },
|
|
22
|
+
* {
|
|
23
|
+
* path: "/admin",
|
|
24
|
+
* component: AdminLayout,
|
|
25
|
+
* meta: { requiresAuth: true },
|
|
26
|
+
* children: [
|
|
27
|
+
* { path: "users", component: AdminUsers },
|
|
28
|
+
* { path: "settings", component: AdminSettings },
|
|
29
|
+
* ],
|
|
30
|
+
* },
|
|
31
|
+
* { path: "/settings", redirect: "/admin/settings" },
|
|
32
|
+
* { path: "(.*)", component: NotFound },
|
|
33
|
+
* ],
|
|
34
|
+
* })
|
|
35
|
+
*
|
|
36
|
+
* // Typed params:
|
|
37
|
+
* const route = useRoute<"/user/:id">()
|
|
38
|
+
* route().params.id // string
|
|
39
|
+
*
|
|
40
|
+
* // Named navigation:
|
|
41
|
+
* router.push({ name: "user", params: { id: "42" } })
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export type { RouterLinkProps, RouterProviderProps, RouterViewProps } from "./components"
|
|
45
|
+
// Components
|
|
46
|
+
export { RouterLink, RouterProvider, RouterView } from "./components"
|
|
47
|
+
export { hydrateLoaderData, prefetchLoaderData, serializeLoaderData, useLoaderData } from "./loader"
|
|
48
|
+
// Match utilities (useful for SSR route pre-fetching)
|
|
49
|
+
export {
|
|
50
|
+
buildPath,
|
|
51
|
+
findRouteByName,
|
|
52
|
+
parseQuery,
|
|
53
|
+
parseQueryMulti,
|
|
54
|
+
resolveRoute,
|
|
55
|
+
stringifyQuery,
|
|
56
|
+
} from "./match"
|
|
57
|
+
// Router factory + hooks
|
|
58
|
+
export { createRouter, RouterContext, useRoute, useRouter } from "./router"
|
|
59
|
+
// Types
|
|
60
|
+
// Data loaders
|
|
61
|
+
export type {
|
|
62
|
+
AfterEachHook,
|
|
63
|
+
ExtractParams,
|
|
64
|
+
LazyComponent,
|
|
65
|
+
LoaderContext,
|
|
66
|
+
NavigationGuard,
|
|
67
|
+
NavigationGuardResult,
|
|
68
|
+
ResolvedRoute,
|
|
69
|
+
RouteComponent,
|
|
70
|
+
RouteLoaderFn,
|
|
71
|
+
RouteMeta,
|
|
72
|
+
RouteRecord,
|
|
73
|
+
Router,
|
|
74
|
+
RouterOptions,
|
|
75
|
+
ScrollBehaviorFn,
|
|
76
|
+
} from "./types"
|
|
77
|
+
// Lazy helper
|
|
78
|
+
export { lazy } from "./types"
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Context } from "@pyreon/core"
|
|
2
|
+
import { createContext, useContext } from "@pyreon/core"
|
|
3
|
+
import type { RouterInstance } from "./types"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Context frame that holds the loader data for the currently rendered route record.
|
|
7
|
+
* Pushed by RouterView's withLoaderData wrapper before invoking the route component.
|
|
8
|
+
*/
|
|
9
|
+
export const LoaderDataContext: Context<unknown> = createContext<unknown>(undefined)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns the data resolved by the current route's `loader` function.
|
|
13
|
+
* Must be called inside a route component rendered by <RouterView />.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const routes = [{ path: "/users", component: Users, loader: fetchUsers }]
|
|
17
|
+
*
|
|
18
|
+
* function Users() {
|
|
19
|
+
* const users = useLoaderData<User[]>()
|
|
20
|
+
* return h("ul", null, users.map(u => h("li", null, u.name)))
|
|
21
|
+
* }
|
|
22
|
+
*/
|
|
23
|
+
export function useLoaderData<T = unknown>(): T {
|
|
24
|
+
return useContext(LoaderDataContext) as T
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* SSR helper: pre-run all loaders for the given path before rendering.
|
|
29
|
+
* Call this before `renderToString` so route components can read data via `useLoaderData()`.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* const router = createRouter({ routes, url: req.url })
|
|
33
|
+
* await prefetchLoaderData(router, req.url)
|
|
34
|
+
* const html = await renderToString(h(App, { router }))
|
|
35
|
+
*/
|
|
36
|
+
export async function prefetchLoaderData(router: RouterInstance, path: string): Promise<void> {
|
|
37
|
+
const route = router._resolve(path)
|
|
38
|
+
const ac = new AbortController()
|
|
39
|
+
router._abortController = ac
|
|
40
|
+
await Promise.all(
|
|
41
|
+
route.matched
|
|
42
|
+
.filter((r) => r.loader)
|
|
43
|
+
.map(async (r) => {
|
|
44
|
+
const data = await r.loader?.({
|
|
45
|
+
params: route.params,
|
|
46
|
+
query: route.query,
|
|
47
|
+
signal: ac.signal,
|
|
48
|
+
})
|
|
49
|
+
router._loaderData.set(r, data)
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Serialize loader data to a JSON-safe plain object for embedding in SSR HTML.
|
|
56
|
+
* Keys are route path patterns (stable across server and client).
|
|
57
|
+
*
|
|
58
|
+
* @example — SSR handler:
|
|
59
|
+
* await prefetchLoaderData(router, req.url)
|
|
60
|
+
* const { html, head } = await renderWithHead(h(App, null))
|
|
61
|
+
* const page = `...${head}
|
|
62
|
+
* <script>window.__PYREON_LOADER_DATA__=${JSON.stringify(serializeLoaderData(router))}</script>
|
|
63
|
+
* ...${html}...`
|
|
64
|
+
*/
|
|
65
|
+
export function serializeLoaderData(router: RouterInstance): Record<string, unknown> {
|
|
66
|
+
const result: Record<string, unknown> = {}
|
|
67
|
+
for (const [record, data] of router._loaderData) {
|
|
68
|
+
result[record.path] = data
|
|
69
|
+
}
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Hydrate loader data from a serialized object (e.g. `window.__PYREON_LOADER_DATA__`).
|
|
75
|
+
* Populates the router's internal `_loaderData` map so the initial render uses
|
|
76
|
+
* server-fetched data without re-running loaders on the client.
|
|
77
|
+
*
|
|
78
|
+
* Call this before `mount()`, after `createRouter()`.
|
|
79
|
+
*
|
|
80
|
+
* @example — client entry:
|
|
81
|
+
* import { hydrateLoaderData } from "@pyreon/router"
|
|
82
|
+
* const router = createRouter({ routes })
|
|
83
|
+
* hydrateLoaderData(router, window.__PYREON_LOADER_DATA__ ?? {})
|
|
84
|
+
* mount(h(App, null), document.getElementById("app")!)
|
|
85
|
+
*/
|
|
86
|
+
export function hydrateLoaderData(
|
|
87
|
+
router: RouterInstance,
|
|
88
|
+
serialized: Record<string, unknown>,
|
|
89
|
+
): void {
|
|
90
|
+
if (!serialized || typeof serialized !== "object") return
|
|
91
|
+
const route = router._resolve(router.currentRoute().path)
|
|
92
|
+
for (const record of route.matched) {
|
|
93
|
+
if (Object.hasOwn(serialized, record.path)) {
|
|
94
|
+
router._loaderData.set(record, serialized[record.path])
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|