@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 +4 -6
- package/src/components.tsx +0 -650
- package/src/env.d.ts +0 -6
- package/src/index.ts +0 -106
- package/src/loader.ts +0 -200
- package/src/manifest.ts +0 -399
- package/src/match.ts +0 -921
- package/src/not-found.ts +0 -75
- package/src/redirect.ts +0 -63
- package/src/router.ts +0 -1424
- package/src/scroll.ts +0 -93
- package/src/tests/integration.test.tsx +0 -298
- package/src/tests/loader.test.ts +0 -1024
- package/src/tests/manifest-snapshot.test.ts +0 -101
- package/src/tests/match.test.ts +0 -782
- package/src/tests/native-markers.test.ts +0 -18
- package/src/tests/redirect.test.ts +0 -96
- package/src/tests/router.browser.test.tsx +0 -509
- package/src/tests/router.test.ts +0 -5498
- package/src/tests/routerlink-reactive-to.browser.test.tsx +0 -158
- package/src/tests/scroll.test.ts +0 -31
- package/src/tests/setup.ts +0 -3
- package/src/types.ts +0 -517
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
|
-
}
|