@pyreon/router 0.24.4 → 0.24.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts DELETED
@@ -1,106 +0,0 @@
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 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'
51
- export {
52
- hydrateLoaderData,
53
- prefetchLoaderData,
54
- serializeLoaderData,
55
- stringifyLoaderData,
56
- useLoaderData,
57
- } from './loader'
58
- // Match utilities (useful for SSR route pre-fetching)
59
- export {
60
- buildPath,
61
- findRouteByName,
62
- parseQuery,
63
- parseQueryMulti,
64
- resolveRoute,
65
- stringifyQuery,
66
- } from './match'
67
- // Router factory + hooks
68
- export {
69
- createRouter,
70
- onBeforeRouteLeave,
71
- onBeforeRouteUpdate,
72
- RouterContext,
73
- useBlocker,
74
- useIsActive,
75
- useRoute,
76
- useRouter,
77
- useMiddlewareData,
78
- useSearchParams,
79
- useTransition,
80
- useTypedSearchParams,
81
- useValidatedSearch,
82
- } from './router'
83
- // Types
84
- // Data loaders
85
- export type {
86
- AfterEachHook,
87
- Blocker,
88
- BlockerFn,
89
- ExtractParams,
90
- LazyComponent,
91
- LoaderContext,
92
- NavigationGuard,
93
- NavigationGuardResult,
94
- ResolvedRoute,
95
- RouteComponent,
96
- RouteLoaderFn,
97
- RouteMeta,
98
- RouteMiddleware,
99
- RouteMiddlewareContext,
100
- RouteRecord,
101
- Router,
102
- RouterOptions,
103
- ScrollBehaviorFn,
104
- } from './types'
105
- // Lazy helper
106
- export { lazy } from './types'
package/src/loader.ts DELETED
@@ -1,200 +0,0 @@
1
- import type { Context } from '@pyreon/core'
2
- import { createContext, useContext } from '@pyreon/core'
3
- import type { RouterInstance } from './types'
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
-
9
- /**
10
- * Context frame that holds the loader data for the currently rendered route record.
11
- * Pushed by RouterView's withLoaderData wrapper before invoking the route component.
12
- */
13
- export const LoaderDataContext: Context<unknown> = createContext<unknown>(undefined)
14
-
15
- /**
16
- * Returns the data resolved by the current route's `loader` function.
17
- * Must be called inside a route component rendered by <RouterView />.
18
- *
19
- * @example
20
- * const routes = [{ path: "/users", component: Users, loader: fetchUsers }]
21
- *
22
- * function Users() {
23
- * const users = useLoaderData<User[]>()
24
- * return h("ul", null, users.map(u => h("li", null, u.name)))
25
- * }
26
- */
27
- export function useLoaderData<T = unknown>(): T {
28
- return useContext(LoaderDataContext) as T
29
- }
30
-
31
- /**
32
- * SSR helper: pre-run all loaders for the given path before rendering.
33
- * Call this before `renderToString` so route components can read data via `useLoaderData()`.
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
- *
41
- * @example
42
- * const router = createRouter({ routes, url: req.url })
43
- * await prefetchLoaderData(router, req.url, request)
44
- * const html = await renderToString(h(App, { router }))
45
- */
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')
52
- const route = router._resolve(path)
53
- // Use a local AbortController — prefetch is best-effort and must NOT
54
- // clobber `router._abortController`, which belongs to the active
55
- // navigation. Previously, hovering a link during a navigation replaced
56
- // the nav's controller, destroying its abort capability.
57
- const ac = new AbortController()
58
- await Promise.all(
59
- route.matched
60
- .filter((r) => r.loader)
61
- .map(async (r) => {
62
- const data = await r.loader?.({
63
- params: route.params,
64
- query: route.query,
65
- signal: ac.signal,
66
- ...(request ? { request } : {}),
67
- })
68
- router._loaderData.set(r, data)
69
- }),
70
- )
71
- }
72
-
73
- /**
74
- * Serialize loader data to a JSON-safe plain object for embedding in SSR HTML.
75
- * Keys are route path patterns (stable across server and client).
76
- *
77
- * @example — SSR handler:
78
- * await prefetchLoaderData(router, req.url)
79
- * const { html, head } = await renderWithHead(h(App, null))
80
- * const page = `...${head}
81
- * <script>window.__PYREON_LOADER_DATA__=${JSON.stringify(serializeLoaderData(router))}</script>
82
- * ...${html}...`
83
- */
84
- export function serializeLoaderData(router: RouterInstance): Record<string, unknown> {
85
- const result: Record<string, unknown> = {}
86
- for (const [record, data] of router._loaderData) {
87
- result[record.path] = data
88
- }
89
- return result
90
- }
91
-
92
- /**
93
- * Serialize loader data to JSON for embedding in an SSR `<script>` tag.
94
- *
95
- * M2.2 — Drop-in replacement for `JSON.stringify(serializeLoaderData(router))`
96
- * with three correctness wins:
97
- * 1. **Strips functions / symbols / undefined values silently** so a loader
98
- * that accidentally returns `{ data, fn: () => {} }` doesn't crash
99
- * hydration — `JSON.stringify` drops these by default for the value
100
- * itself but THROWS on circular references containing them. The custom
101
- * replacer drops them inline so the surrounding object survives.
102
- * 2. **Detects circular references** with a WeakSet and emits a clear
103
- * `[Pyreon] Loader returned circular reference at key "<path>"` error
104
- * naming the offending key instead of `Converting circular structure
105
- * to JSON` (which doesn't tell the user which loader is broken).
106
- * 3. **Escapes `</`** so embedding the JSON inside `<script>` can't break
107
- * out of the script tag — already done at every call site but now
108
- * centralised so all four callers (handler string-mode, handler stream-
109
- * mode, SSG entry, dev SSR) get the escape uniformly.
110
- *
111
- * Returns the safely-escaped JSON string ready to drop into a `<script>`
112
- * tag's body. Throws (with the Pyreon-prefixed error) on circular refs so
113
- * the caller's existing try/catch wraps it correctly — silent serialization
114
- * failures were the pre-fix shape.
115
- *
116
- * @example
117
- * const json = stringifyLoaderData(serializeLoaderData(router))
118
- * const tag = `<script>window.__PYREON_LOADER_DATA__=${json}</script>`
119
- */
120
- export function stringifyLoaderData(loaderData: Record<string, unknown>): string {
121
- // True cycle detection: track the ANCESTOR PATH only (add on descend,
122
- // remove on ascend), NOT every object ever visited. The prior
123
- // implementation kept an all-seen WeakSet that was never pruned, so any
124
- // object referenced more than once — a DAG, not a cycle — falsely threw
125
- // "circular reference" and 500'd the SSR response. Shared references are
126
- // extremely common in loader payloads (`{ author: user, lastEditor: user }`
127
- // where both are the same ORM instance; a list whose rows share a lookup
128
- // object). `JSON.stringify` serializes those fine; only a real cycle must
129
- // throw. A `JSON.stringify` replacer has no "leave" hook, so cycle
130
- // detection runs as a single recursive pre-pass that maintains the
131
- // ancestor set, then `JSON.stringify` does the (now cycle-free) encode.
132
- const ancestors = new Set<object>()
133
- const detectCycle = (value: unknown, path: string): void => {
134
- if (value === null || typeof value !== 'object') return
135
- // Respect `toJSON` so detection matches what JSON.stringify actually
136
- // serializes (Date/etc. become primitives — no cycle through them).
137
- const v =
138
- typeof (value as { toJSON?: unknown }).toJSON === 'function'
139
- ? (value as { toJSON: () => unknown }).toJSON()
140
- : value
141
- if (v === null || typeof v !== 'object') return
142
- const obj = v as object
143
- if (ancestors.has(obj)) {
144
- throw new Error(
145
- `[Pyreon] Loader returned circular reference at "${path || '<root>'}". ` +
146
- `Loaders must return JSON-serializable data (no cycles, no functions, no Date/Map/Set without a custom replacer). ` +
147
- `Common cause: returning a Mongo/Prisma model with back-references intact.`,
148
- )
149
- }
150
- ancestors.add(obj)
151
- if (Array.isArray(obj)) {
152
- for (let i = 0; i < obj.length; i++) detectCycle(obj[i], `${path}[${i}]`)
153
- } else {
154
- for (const k of Object.keys(obj)) {
155
- const child = (obj as Record<string, unknown>)[k]
156
- // Mirror the encode-time drop: function/symbol values are not
157
- // serialized, so a cycle reachable only THROUGH one can't occur.
158
- if (typeof child === 'function' || typeof child === 'symbol') continue
159
- detectCycle(child, path ? `${path}.${k}` : k)
160
- }
161
- }
162
- ancestors.delete(obj) // ascend — siblings / shared refs are NOT cycles
163
- }
164
- detectCycle(loaderData, '')
165
-
166
- const replacer = (_key: string, value: unknown): unknown => {
167
- // Drop silently. JSON.stringify already drops these as VALUES, but an
168
- // explicit drop also handles array entries (where it'd convert to null
169
- // otherwise — undesirable for downstream typed hydration).
170
- if (typeof value === 'function' || typeof value === 'symbol') return undefined
171
- return value
172
- }
173
- return JSON.stringify(loaderData, replacer).replace(/<\//g, '<\\/')
174
- }
175
-
176
- /**
177
- * Hydrate loader data from a serialized object (e.g. `window.__PYREON_LOADER_DATA__`).
178
- * Populates the router's internal `_loaderData` map so the initial render uses
179
- * server-fetched data without re-running loaders on the client.
180
- *
181
- * Call this before `mount()`, after `createRouter()`.
182
- *
183
- * @example — client entry:
184
- * import { hydrateLoaderData } from "@pyreon/router"
185
- * const router = createRouter({ routes })
186
- * hydrateLoaderData(router, window.__PYREON_LOADER_DATA__ ?? {})
187
- * mount(h(App, null), document.getElementById("app")!)
188
- */
189
- export function hydrateLoaderData(
190
- router: RouterInstance,
191
- serialized: Record<string, unknown>,
192
- ): void {
193
- if (!serialized || typeof serialized !== 'object') return
194
- const route = router._resolve(router.currentRoute().path)
195
- for (const record of route.matched) {
196
- if (Object.hasOwn(serialized, record.path)) {
197
- router._loaderData.set(record, serialized[record.path])
198
- }
199
- }
200
- }