@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.
@@ -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
+ }