@buzola/router 0.0.3 → 0.0.4
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/dist/define/create-page.d.ts +17 -5
- package/dist/define/create-page.d.ts.map +1 -1
- package/dist/define/create-page.js +78 -38
- package/dist/define/create-page.js.map +1 -1
- package/dist/engine/loader-cache.d.ts +18 -0
- package/dist/engine/loader-cache.d.ts.map +1 -0
- package/dist/engine/loader-cache.js +48 -0
- package/dist/engine/loader-cache.js.map +1 -0
- package/dist/engine/matcher.d.ts +1 -1
- package/dist/engine/matcher.d.ts.map +1 -1
- package/dist/engine/navigation-adapter.d.ts +1 -1
- package/dist/engine/navigation-adapter.d.ts.map +1 -1
- package/dist/engine/route-tree.d.ts +1 -2
- package/dist/engine/route-tree.d.ts.map +1 -1
- package/dist/engine/route-tree.js +5 -4
- package/dist/engine/route-tree.js.map +1 -1
- package/dist/engine/router.d.ts +7 -10
- package/dist/engine/router.d.ts.map +1 -1
- package/dist/engine/router.js +23 -67
- package/dist/engine/router.js.map +1 -1
- package/dist/engine/schema.d.ts +5 -1
- package/dist/engine/schema.d.ts.map +1 -1
- package/dist/engine/schema.js +11 -1
- package/dist/engine/schema.js.map +1 -1
- package/dist/engine/types.d.ts +2 -15
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/engine/utils.d.ts +8 -0
- package/dist/engine/utils.d.ts.map +1 -1
- package/dist/engine/utils.js +10 -3
- package/dist/engine/utils.js.map +1 -1
- package/dist/index.d.ts +14 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -7
- package/dist/index.js.map +1 -1
- package/dist/react/context.d.ts +2 -2
- package/dist/react/context.d.ts.map +1 -1
- package/dist/react/hooks.d.ts +3 -3
- package/dist/react/hooks.d.ts.map +1 -1
- package/dist/react/hooks.js +11 -9
- package/dist/react/hooks.js.map +1 -1
- package/dist/react/index.d.ts +11 -11
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +6 -6
- package/dist/react/index.js.map +1 -1
- package/dist/react/link.d.ts +5 -0
- package/dist/react/link.d.ts.map +1 -1
- package/dist/react/link.js +10 -1
- package/dist/react/link.js.map +1 -1
- package/dist/react/outlet.d.ts +3 -1
- package/dist/react/outlet.d.ts.map +1 -1
- package/dist/react/outlet.js +25 -1
- package/dist/react/outlet.js.map +1 -1
- package/dist/react/provider.d.ts +15 -2
- package/dist/react/provider.d.ts.map +1 -1
- package/dist/react/provider.js +15 -4
- package/dist/react/provider.js.map +1 -1
- package/package.json +6 -3
- package/src/__tests__/create-page.test.ts +42 -6
- package/src/__tests__/loader-cache.test.ts +115 -0
- package/src/__tests__/matcher.test.ts +3 -3
- package/src/__tests__/react.test.tsx +1020 -0
- package/src/__tests__/router.test.ts +4 -214
- package/src/__tests__/setup-dom.ts +21 -0
- package/src/define/create-page.tsx +94 -46
- package/src/engine/loader-cache.ts +55 -0
- package/src/engine/matcher.ts +1 -1
- package/src/engine/navigation-adapter.ts +1 -1
- package/src/engine/route-tree.ts +4 -6
- package/src/engine/router.ts +27 -73
- package/src/engine/schema.ts +13 -2
- package/src/engine/types.ts +2 -18
- package/src/engine/utils.ts +11 -3
- package/src/index.ts +15 -17
- package/src/react/context.ts +2 -2
- package/src/react/hooks.ts +14 -13
- package/src/react/index.ts +21 -11
- package/src/react/link.tsx +16 -0
- package/src/react/outlet.tsx +32 -1
- package/src/react/provider.tsx +33 -5
- package/dist/__tests__/create-page.test.d.ts +0 -2
- package/dist/__tests__/create-page.test.d.ts.map +0 -1
- package/dist/__tests__/create-page.test.js +0 -457
- package/dist/__tests__/create-page.test.js.map +0 -1
- package/dist/__tests__/matcher.test.d.ts +0 -2
- package/dist/__tests__/matcher.test.d.ts.map +0 -1
- package/dist/__tests__/matcher.test.js +0 -108
- package/dist/__tests__/matcher.test.js.map +0 -1
- package/dist/__tests__/router.test.d.ts +0 -2
- package/dist/__tests__/router.test.d.ts.map +0 -1
- package/dist/__tests__/router.test.js +0 -327
- package/dist/__tests__/router.test.js.map +0 -1
- package/dist/__tests__/type-safety.test.d.ts +0 -15
- package/dist/__tests__/type-safety.test.d.ts.map +0 -1
- package/dist/__tests__/type-safety.test.js +0 -163
- package/dist/__tests__/type-safety.test.js.map +0 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it, mock } from 'bun:test'
|
|
2
|
-
import { createMemoryNavigationAdapter } from '../engine/navigation-adapter'
|
|
3
|
-
import { buildRouteTree } from '../engine/route-tree'
|
|
4
|
-
import { Router } from '../engine/router'
|
|
5
|
-
import type {
|
|
2
|
+
import { createMemoryNavigationAdapter } from '../engine/navigation-adapter.js'
|
|
3
|
+
import { buildRouteTree } from '../engine/route-tree.js'
|
|
4
|
+
import { Router } from '../engine/router.js'
|
|
5
|
+
import type { RouteConfig } from '../engine/types.js'
|
|
6
6
|
|
|
7
7
|
function dummyComponent() {
|
|
8
8
|
return null
|
|
@@ -107,216 +107,6 @@ describe('Router', () => {
|
|
|
107
107
|
expect(router.buildPath('/')).toBe('/')
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
-
it('handles route guards', async () => {
|
|
111
|
-
const guard = mock(() => false) // Block navigation
|
|
112
|
-
|
|
113
|
-
const { router } = createRouter([
|
|
114
|
-
{ path: '/', component: dummyComponent, isIndex: true },
|
|
115
|
-
{ path: '/protected', component: dummyComponent, beforeEnter: guard },
|
|
116
|
-
])
|
|
117
|
-
|
|
118
|
-
router.navigate('/protected')
|
|
119
|
-
// Guard blocked it, should stay at /
|
|
120
|
-
expect(router.getState().location.pathname).toBe('/')
|
|
121
|
-
expect(guard).toHaveBeenCalled()
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
it('handles async route guards that allow navigation', async () => {
|
|
125
|
-
const guard = mock(() => Promise.resolve(true))
|
|
126
|
-
|
|
127
|
-
const { router } = createRouter([
|
|
128
|
-
{ path: '/', component: dummyComponent, isIndex: true },
|
|
129
|
-
{ path: '/protected', component: dummyComponent, beforeEnter: guard },
|
|
130
|
-
])
|
|
131
|
-
|
|
132
|
-
router.navigate('/protected')
|
|
133
|
-
// Async guard — need to wait for microtask
|
|
134
|
-
await new Promise(resolve => setTimeout(resolve, 10))
|
|
135
|
-
expect(router.getState().location.pathname).toBe('/protected')
|
|
136
|
-
expect(guard).toHaveBeenCalled()
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
it('handles async route guards that block navigation', async () => {
|
|
140
|
-
const guard = mock(() => Promise.resolve(false))
|
|
141
|
-
|
|
142
|
-
const { router } = createRouter([
|
|
143
|
-
{ path: '/', component: dummyComponent, isIndex: true },
|
|
144
|
-
{ path: '/protected', component: dummyComponent, beforeEnter: guard },
|
|
145
|
-
])
|
|
146
|
-
|
|
147
|
-
router.navigate('/protected')
|
|
148
|
-
await new Promise(resolve => setTimeout(resolve, 10))
|
|
149
|
-
expect(router.getState().location.pathname).toBe('/')
|
|
150
|
-
expect(guard).toHaveBeenCalled()
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
it('handles route guards that redirect', async () => {
|
|
154
|
-
const guard = mock(() => ({ redirect: '/login' }))
|
|
155
|
-
|
|
156
|
-
const { router } = createRouter([
|
|
157
|
-
{ path: '/', component: dummyComponent, isIndex: true },
|
|
158
|
-
{ path: '/login', component: dummyComponent },
|
|
159
|
-
{ path: '/protected', component: dummyComponent, beforeEnter: guard },
|
|
160
|
-
])
|
|
161
|
-
|
|
162
|
-
router.navigate('/protected')
|
|
163
|
-
await new Promise(resolve => setTimeout(resolve, 10))
|
|
164
|
-
expect(router.getState().location.pathname).toBe('/login')
|
|
165
|
-
expect(guard).toHaveBeenCalled()
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
it('handles async route guards that redirect', async () => {
|
|
169
|
-
const guard = mock(() => Promise.resolve({ redirect: '/login' }))
|
|
170
|
-
|
|
171
|
-
const { router } = createRouter([
|
|
172
|
-
{ path: '/', component: dummyComponent, isIndex: true },
|
|
173
|
-
{ path: '/login', component: dummyComponent },
|
|
174
|
-
{ path: '/protected', component: dummyComponent, beforeEnter: guard },
|
|
175
|
-
])
|
|
176
|
-
|
|
177
|
-
router.navigate('/protected')
|
|
178
|
-
await new Promise(resolve => setTimeout(resolve, 10))
|
|
179
|
-
expect(router.getState().location.pathname).toBe('/login')
|
|
180
|
-
expect(guard).toHaveBeenCalled()
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
it('dispose stops intercepting navigations', () => {
|
|
184
|
-
const { router } = createRouter([
|
|
185
|
-
{ path: '/', component: dummyComponent, isIndex: true },
|
|
186
|
-
{ path: '/about', component: dummyComponent },
|
|
187
|
-
])
|
|
188
|
-
|
|
189
|
-
const subscriber = mock(() => {})
|
|
190
|
-
router.subscribe(subscriber)
|
|
191
|
-
router.dispose()
|
|
192
|
-
|
|
193
|
-
router.navigate('/about')
|
|
194
|
-
expect(subscriber).not.toHaveBeenCalled()
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
it('updates routes for HMR', () => {
|
|
198
|
-
const { router } = createRouter([
|
|
199
|
-
{ path: '/', component: dummyComponent, isIndex: true },
|
|
200
|
-
])
|
|
201
|
-
|
|
202
|
-
const newRoutes = buildRouteTree([
|
|
203
|
-
{ path: '/', component: dummyComponent, isIndex: true },
|
|
204
|
-
{ path: '/new-route', component: dummyComponent },
|
|
205
|
-
])
|
|
206
|
-
|
|
207
|
-
router.updateRoutes(newRoutes)
|
|
208
|
-
router.navigate('/new-route')
|
|
209
|
-
expect(router.getState().location.pathname).toBe('/new-route')
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
it('dispose clears all subscribers', () => {
|
|
213
|
-
const { router } = createRouter([
|
|
214
|
-
{ path: '/', component: dummyComponent, isIndex: true },
|
|
215
|
-
{ path: '/about', component: dummyComponent },
|
|
216
|
-
])
|
|
217
|
-
|
|
218
|
-
const subscriber = mock(() => {})
|
|
219
|
-
router.subscribe(subscriber)
|
|
220
|
-
router.dispose()
|
|
221
|
-
|
|
222
|
-
router.navigate('/about')
|
|
223
|
-
expect(subscriber).not.toHaveBeenCalled()
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
it('passes GuardContext with AbortSignal to guards', async () => {
|
|
227
|
-
let receivedContext: GuardContext | undefined
|
|
228
|
-
const guard = mock((_match: unknown, context: GuardContext) => {
|
|
229
|
-
receivedContext = context
|
|
230
|
-
return true
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
const { router } = createRouter([
|
|
234
|
-
{ path: '/', component: dummyComponent, isIndex: true },
|
|
235
|
-
{ path: '/protected', component: dummyComponent, beforeEnter: guard },
|
|
236
|
-
])
|
|
237
|
-
|
|
238
|
-
router.navigate('/protected')
|
|
239
|
-
await new Promise(resolve => setTimeout(resolve, 10))
|
|
240
|
-
expect(guard).toHaveBeenCalled()
|
|
241
|
-
expect(receivedContext).toBeDefined()
|
|
242
|
-
expect(receivedContext!.signal).toBeInstanceOf(AbortSignal)
|
|
243
|
-
expect(receivedContext!.signal.aborted).toBe(false)
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
it('aborts previous guard signal on new navigation', async () => {
|
|
247
|
-
let firstSignal: AbortSignal | undefined
|
|
248
|
-
let resolveFirst: (() => void) | undefined
|
|
249
|
-
|
|
250
|
-
const slowGuard = mock((_match: unknown, context: GuardContext) => {
|
|
251
|
-
firstSignal = context.signal
|
|
252
|
-
return new Promise<boolean>(resolve => {
|
|
253
|
-
resolveFirst = () => resolve(true)
|
|
254
|
-
})
|
|
255
|
-
})
|
|
256
|
-
|
|
257
|
-
const { router } = createRouter([
|
|
258
|
-
{ path: '/', component: dummyComponent, isIndex: true },
|
|
259
|
-
{ path: '/slow', component: dummyComponent, beforeEnter: slowGuard },
|
|
260
|
-
{ path: '/fast', component: dummyComponent },
|
|
261
|
-
])
|
|
262
|
-
|
|
263
|
-
// Start navigation to /slow (guard will hang)
|
|
264
|
-
router.navigate('/slow')
|
|
265
|
-
await new Promise(resolve => setTimeout(resolve, 10))
|
|
266
|
-
expect(firstSignal).toBeDefined()
|
|
267
|
-
expect(firstSignal!.aborted).toBe(false)
|
|
268
|
-
|
|
269
|
-
// Start a new navigation — should abort the previous signal
|
|
270
|
-
router.navigate('/fast')
|
|
271
|
-
await new Promise(resolve => setTimeout(resolve, 10))
|
|
272
|
-
expect(firstSignal!.aborted).toBe(true)
|
|
273
|
-
|
|
274
|
-
// Clean up
|
|
275
|
-
resolveFirst?.()
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
it('throws on guard redirect to non-existent route', async () => {
|
|
279
|
-
const guard = mock(() => ({ redirect: '/nowhere' }))
|
|
280
|
-
|
|
281
|
-
const routes = buildRouteTree([
|
|
282
|
-
{ path: '/', component: dummyComponent, isIndex: true },
|
|
283
|
-
{ path: '/protected', component: dummyComponent, beforeEnter: guard },
|
|
284
|
-
])
|
|
285
|
-
const adapter = createMemoryNavigationAdapter({ initialURL: 'http://localhost/' })
|
|
286
|
-
|
|
287
|
-
// Intercept the navigate event to capture the handler promise for assertions
|
|
288
|
-
// while converting the error to NavigationAbortedError so the adapter swallows it.
|
|
289
|
-
let handlerPromise: Promise<void> | undefined
|
|
290
|
-
const origAddEventListener = adapter.addEventListener
|
|
291
|
-
adapter.addEventListener = (_type, listener) => {
|
|
292
|
-
origAddEventListener.call(adapter, _type, (event) => {
|
|
293
|
-
const origEventIntercept = event.intercept.bind(event)
|
|
294
|
-
event.intercept = (opts) => {
|
|
295
|
-
handlerPromise = opts.handler()
|
|
296
|
-
origEventIntercept({
|
|
297
|
-
...opts,
|
|
298
|
-
handler: () =>
|
|
299
|
-
handlerPromise!.catch(() => {
|
|
300
|
-
// Convert to NavigationAbortedError so the adapter swallows it
|
|
301
|
-
const err = new Error()
|
|
302
|
-
err.name = 'NavigationAbortedError'
|
|
303
|
-
throw err
|
|
304
|
-
}),
|
|
305
|
-
})
|
|
306
|
-
}
|
|
307
|
-
listener(event)
|
|
308
|
-
})
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const router = new Router({ routes, adapter })
|
|
312
|
-
router.start()
|
|
313
|
-
|
|
314
|
-
router.navigate('/protected')
|
|
315
|
-
expect(handlerPromise).toBeDefined()
|
|
316
|
-
await expect(handlerPromise!).rejects.toThrow('does not match any route')
|
|
317
|
-
expect(router.getState().location.pathname).toBe('/')
|
|
318
|
-
})
|
|
319
|
-
|
|
320
110
|
it('generates deterministic path-based IDs', () => {
|
|
321
111
|
const routes = buildRouteTree([
|
|
322
112
|
{
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { GlobalWindow } from 'happy-dom'
|
|
2
|
+
|
|
3
|
+
const window = new GlobalWindow()
|
|
4
|
+
|
|
5
|
+
for (const key of Object.getOwnPropertyNames(window)) {
|
|
6
|
+
if (key in globalThis) continue
|
|
7
|
+
try {
|
|
8
|
+
Object.defineProperty(globalThis, key, {
|
|
9
|
+
value: (window as unknown as Record<string, unknown>)[key],
|
|
10
|
+
writable: true,
|
|
11
|
+
configurable: true,
|
|
12
|
+
})
|
|
13
|
+
} catch {
|
|
14
|
+
// Some properties can't be set — skip them
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Object.defineProperty(globalThis, 'window', { value: window, writable: true, configurable: true })
|
|
19
|
+
Object.defineProperty(globalThis, 'document', { value: window.document, writable: true, configurable: true })
|
|
20
|
+
Object.defineProperty(globalThis, 'navigator', { value: window.navigator, writable: true, configurable: true })
|
|
21
|
+
Object.defineProperty(globalThis, 'HTMLElement', { value: window.HTMLElement, writable: true, configurable: true })
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { ReactElement } from 'react'
|
|
2
2
|
import { type ComponentType, use, useCallback, useEffect, useMemo, useReducer, useState } from 'react'
|
|
3
|
-
import
|
|
4
|
-
import type
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
3
|
+
import type { LoaderCache } from '../engine/loader-cache.js'
|
|
4
|
+
import { isOptionalSchema, type ParamLiteral, resolveParamLiteral, validateSchema } from '../engine/schema.js'
|
|
5
|
+
import type { EffectivePageParams, RegisteredPage, RouteComponent, StandardSchema } from '../engine/types.js'
|
|
6
|
+
import { extractParamNames } from '../engine/utils.js'
|
|
7
|
+
import { RouteContext, RouterContext } from '../react/context.js'
|
|
8
|
+
import { ErrorBoundary } from '../react/error-boundary.js'
|
|
8
9
|
|
|
9
10
|
export interface PageProps<TParams> {
|
|
10
11
|
params: TParams
|
|
@@ -29,7 +30,7 @@ export interface PageDefinition<TParams = Record<string, never>> {
|
|
|
29
30
|
route?: string
|
|
30
31
|
paramsSchema?: StandardSchema
|
|
31
32
|
paramsMeta: ParamMeta[]
|
|
32
|
-
loader?: (ctx: { params: any }) => Promise<unknown>
|
|
33
|
+
loader?: (ctx: { params: any; redirect: RedirectFn }) => Promise<unknown>
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
// ─── Route pattern type safety ──────────────────────────────────────────────
|
|
@@ -113,18 +114,47 @@ function resolveParamsInput<T>(input: StandardSchema<T> | ParamsShape): {
|
|
|
113
114
|
return { schema, paramsMeta }
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
// ─── Redirect response ───────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export class BuzolaRedirect {
|
|
120
|
+
readonly __buzolaRedirect = true
|
|
121
|
+
constructor(
|
|
122
|
+
readonly pageId: string,
|
|
123
|
+
readonly params?: Record<string, string>,
|
|
124
|
+
) {}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type RedirectFn = <P extends RegisteredPage>(
|
|
128
|
+
to: P,
|
|
129
|
+
...args: [keyof EffectivePageParams<P>] extends [never] ? []
|
|
130
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
131
|
+
: {} extends EffectivePageParams<P> ? [params?: EffectivePageParams<P>]
|
|
132
|
+
: [params: EffectivePageParams<P>]
|
|
133
|
+
) => BuzolaRedirect
|
|
134
|
+
|
|
135
|
+
export const redirect: RedirectFn = (pageId: string, params?: Record<string, string>): BuzolaRedirect => {
|
|
136
|
+
return new BuzolaRedirect(pageId, params)
|
|
137
|
+
}
|
|
138
|
+
|
|
116
139
|
// ─── Loader helpers ─────────────────────────────────────────────────────────
|
|
117
140
|
|
|
118
|
-
type LoaderFn = (ctx: { params: any }) => Promise<any>
|
|
141
|
+
type LoaderFn = (ctx: { params: any; redirect: RedirectFn }) => Promise<any>
|
|
119
142
|
|
|
120
143
|
function combineLoaders(loaders: LoaderFn[]): LoaderFn | undefined {
|
|
121
144
|
if (loaders.length === 0) return undefined
|
|
122
145
|
if (loaders.length === 1) return loaders[0]
|
|
123
|
-
return async (ctx) =>
|
|
146
|
+
return async (ctx) => {
|
|
147
|
+
const results = await Promise.all(loaders.map(fn => fn(ctx)))
|
|
148
|
+
const redir = results.find(r => r instanceof BuzolaRedirect)
|
|
149
|
+
if (redir) return redir
|
|
150
|
+
return Object.assign({}, ...results)
|
|
151
|
+
}
|
|
124
152
|
}
|
|
125
153
|
|
|
126
154
|
// ─── Component factory ──────────────────────────────────────────────────────
|
|
127
155
|
|
|
156
|
+
let nextPageId = 0
|
|
157
|
+
|
|
128
158
|
function createPageComponent<TParams>(
|
|
129
159
|
schema: StandardSchema<TParams> | undefined,
|
|
130
160
|
paramsMeta: ParamMeta[],
|
|
@@ -145,18 +175,27 @@ function createPageComponent<TParams>(
|
|
|
145
175
|
let activeUrlHref: string | null = null
|
|
146
176
|
let activeCacheKey: string | null = null
|
|
147
177
|
let activePromise: Promise<unknown> | null = null
|
|
148
|
-
let activeResolvedData: unknown
|
|
178
|
+
let activeResolvedData: unknown
|
|
149
179
|
let hasResolved = false
|
|
150
180
|
let pendingBackgroundKey: string | null = null
|
|
151
181
|
|
|
152
|
-
//
|
|
153
|
-
const
|
|
182
|
+
// Unique ID for namespacing entries in the global loader cache
|
|
183
|
+
const pageId = nextPageId++
|
|
154
184
|
|
|
155
|
-
function
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
185
|
+
function staleCacheKey(urlHref: string): string {
|
|
186
|
+
return `${pageId}:${urlHref}`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function staleCacheGet(cache: LoaderCache, urlHref: string): { hit: true; value: unknown } | { hit: false } {
|
|
190
|
+
const key = staleCacheKey(urlHref)
|
|
191
|
+
if (!cache.has(key)) return { hit: false }
|
|
192
|
+
const value = cache.get(key)
|
|
193
|
+
cache.delete(key)
|
|
194
|
+
return { hit: true, value }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function staleCacheSet(cache: LoaderCache, urlHref: string, value: unknown): void {
|
|
198
|
+
cache.set(staleCacheKey(urlHref), value)
|
|
160
199
|
}
|
|
161
200
|
|
|
162
201
|
function BuzolaPage(): ReactElement | null {
|
|
@@ -204,13 +243,7 @@ function createPageComponent<TParams>(
|
|
|
204
243
|
}
|
|
205
244
|
|
|
206
245
|
if (schema) {
|
|
207
|
-
|
|
208
|
-
if ('issues' in result) {
|
|
209
|
-
throw new Error(
|
|
210
|
-
`Page params validation failed: ${result.issues.map(i => i.message).join(', ')}`,
|
|
211
|
-
)
|
|
212
|
-
}
|
|
213
|
-
return result.value
|
|
246
|
+
return validateSchema(schema, merged, 'Page params validation failed: ')
|
|
214
247
|
}
|
|
215
248
|
|
|
216
249
|
return merged as TParams
|
|
@@ -226,10 +259,11 @@ function createPageComponent<TParams>(
|
|
|
226
259
|
return router.onInvalidate(() => setLoaderKey(k => k + 1))
|
|
227
260
|
}, [router])
|
|
228
261
|
|
|
229
|
-
const
|
|
262
|
+
const loaderCache = router?.loaderCache
|
|
230
263
|
|
|
231
|
-
let data: unknown
|
|
264
|
+
let data: unknown
|
|
232
265
|
let isLoading = false
|
|
266
|
+
let shouldRedirect = false
|
|
233
267
|
|
|
234
268
|
if (loaderFn) {
|
|
235
269
|
const currentUrlHref = routeContext.state.location.href
|
|
@@ -237,25 +271,26 @@ function createPageComponent<TParams>(
|
|
|
237
271
|
|
|
238
272
|
// URL changed — save old data to stale cache, restore from stale cache if available
|
|
239
273
|
if (activeUrlHref !== null && activeUrlHref !== currentUrlHref) {
|
|
240
|
-
if (hasResolved && activeResolvedData !== undefined) {
|
|
241
|
-
|
|
242
|
-
evictStaleCache(cacheSize)
|
|
274
|
+
if (loaderCache && hasResolved && activeResolvedData !== undefined) {
|
|
275
|
+
staleCacheSet(loaderCache, activeUrlHref, activeResolvedData)
|
|
243
276
|
}
|
|
244
277
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
278
|
+
const cached = loaderCache && staleCacheGet(loaderCache, currentUrlHref)
|
|
279
|
+
if (cached?.hit) {
|
|
280
|
+
activeResolvedData = cached.value
|
|
248
281
|
hasResolved = true
|
|
249
282
|
} else {
|
|
250
283
|
hasResolved = false
|
|
251
284
|
activeResolvedData = undefined
|
|
252
285
|
}
|
|
253
286
|
pendingBackgroundKey = null
|
|
254
|
-
} else if (activeUrlHref === null &&
|
|
287
|
+
} else if (activeUrlHref === null && loaderCache) {
|
|
255
288
|
// Remount — restore from stale cache
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
289
|
+
const cached = staleCacheGet(loaderCache, currentUrlHref)
|
|
290
|
+
if (cached.hit) {
|
|
291
|
+
activeResolvedData = cached.value
|
|
292
|
+
hasResolved = true
|
|
293
|
+
}
|
|
259
294
|
}
|
|
260
295
|
activeUrlHref = currentUrlHref
|
|
261
296
|
|
|
@@ -276,7 +311,7 @@ function createPageComponent<TParams>(
|
|
|
276
311
|
}
|
|
277
312
|
} else if (hasResolved) {
|
|
278
313
|
// Cache key changed + we have stale data → background reload (SWR)
|
|
279
|
-
const promise = loaderFn({ params })
|
|
314
|
+
const promise = loaderFn({ params, redirect })
|
|
280
315
|
activeCacheKey = cacheKey
|
|
281
316
|
activePromise = promise
|
|
282
317
|
pendingBackgroundKey = cacheKey
|
|
@@ -287,6 +322,10 @@ function createPageComponent<TParams>(
|
|
|
287
322
|
(result) => {
|
|
288
323
|
// Only apply if this is still the active load
|
|
289
324
|
if (activeCacheKey === cacheKey) {
|
|
325
|
+
if (result instanceof BuzolaRedirect) {
|
|
326
|
+
router?.navigateToPage(result.pageId, result.params, { replace: true })
|
|
327
|
+
return
|
|
328
|
+
}
|
|
290
329
|
activeResolvedData = result
|
|
291
330
|
pendingBackgroundKey = null
|
|
292
331
|
forceUpdate()
|
|
@@ -304,21 +343,28 @@ function createPageComponent<TParams>(
|
|
|
304
343
|
)
|
|
305
344
|
} else {
|
|
306
345
|
// No stale data — first load, suspend via use()
|
|
307
|
-
const promise = loaderFn({ params })
|
|
346
|
+
const promise = loaderFn({ params, redirect })
|
|
308
347
|
activeCacheKey = cacheKey
|
|
309
348
|
activePromise = promise
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
349
|
+
const result = use(promise)
|
|
350
|
+
if (result instanceof BuzolaRedirect) {
|
|
351
|
+
router?.navigateToPage(result.pageId, result.params, { replace: true })
|
|
352
|
+
shouldRedirect = true
|
|
353
|
+
} else {
|
|
354
|
+
data = result
|
|
355
|
+
}
|
|
356
|
+
if (!shouldRedirect) {
|
|
357
|
+
activeResolvedData = data
|
|
358
|
+
hasResolved = true
|
|
359
|
+
}
|
|
313
360
|
}
|
|
314
361
|
}
|
|
315
362
|
|
|
316
363
|
// On unmount, save active data to stale cache and reset active state
|
|
317
364
|
useEffect(() => {
|
|
318
365
|
return () => {
|
|
319
|
-
if (hasResolved && activeResolvedData !== undefined && activeUrlHref) {
|
|
320
|
-
|
|
321
|
-
evictStaleCache(cacheSize)
|
|
366
|
+
if (loaderCache && hasResolved && activeResolvedData !== undefined && activeUrlHref) {
|
|
367
|
+
staleCacheSet(loaderCache, activeUrlHref, activeResolvedData)
|
|
322
368
|
}
|
|
323
369
|
hasResolved = false
|
|
324
370
|
activeResolvedData = undefined
|
|
@@ -329,6 +375,8 @@ function createPageComponent<TParams>(
|
|
|
329
375
|
}
|
|
330
376
|
}, [])
|
|
331
377
|
|
|
378
|
+
if (shouldRedirect) return null
|
|
379
|
+
|
|
332
380
|
return <RenderComponent params={params} data={data} invalidate={invalidate} isLoading={isLoading} />
|
|
333
381
|
}
|
|
334
382
|
|
|
@@ -383,13 +431,13 @@ interface WithLoaderChain<TParams, TData>
|
|
|
383
431
|
extends WithRoute<TParams, PageProps<TParams> & { data: TData; invalidate: () => void; isLoading: boolean }>
|
|
384
432
|
{
|
|
385
433
|
loader<TNew extends Record<string, unknown>>(
|
|
386
|
-
fn: (ctx: { params: TParams }) => Promise<TNew>,
|
|
434
|
+
fn: (ctx: { params: TParams; redirect: RedirectFn }) => Promise<TNew | BuzolaRedirect>,
|
|
387
435
|
): WithLoaderChain<TParams, TData & TNew>
|
|
388
436
|
}
|
|
389
437
|
|
|
390
438
|
interface WithLoader<TParams> extends WithRoute<TParams, PageProps<TParams>> {
|
|
391
439
|
loader<TData extends Record<string, unknown>>(
|
|
392
|
-
fn: (ctx: { params: TParams }) => Promise<TData>,
|
|
440
|
+
fn: (ctx: { params: TParams; redirect: RedirectFn }) => Promise<TData | BuzolaRedirect>,
|
|
393
441
|
): WithLoaderChain<TParams, TData>
|
|
394
442
|
}
|
|
395
443
|
|
|
@@ -397,7 +445,7 @@ interface PageBuilder {
|
|
|
397
445
|
params<T extends ParamsShape>(shape: T): WithLoader<InferShape<T>>
|
|
398
446
|
params<T extends Record<string, unknown>>(schema: StandardSchema<T>): WithLoader<T>
|
|
399
447
|
loader<TData extends Record<string, unknown>>(
|
|
400
|
-
fn: (ctx: { params: Record<string, never
|
|
448
|
+
fn: (ctx: { params: Record<string, never>; redirect: RedirectFn }) => Promise<TData | BuzolaRedirect>,
|
|
401
449
|
): WithLoaderChain<Record<string, never>, TData>
|
|
402
450
|
catch(handler: CatchHandler): WithCatch<Record<string, never>, PageProps<Record<string, never>>>
|
|
403
451
|
route<R extends string>(
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global LRU cache for page loader data.
|
|
3
|
+
* Shared across all page components to limit total memory usage.
|
|
4
|
+
* Uses Map insertion order for LRU eviction — oldest entries are evicted first.
|
|
5
|
+
*/
|
|
6
|
+
export class LoaderCache {
|
|
7
|
+
private cache = new Map<string, unknown>()
|
|
8
|
+
private _maxSize: number
|
|
9
|
+
|
|
10
|
+
constructor(maxSize: number) {
|
|
11
|
+
if (maxSize < 0) {
|
|
12
|
+
throw new Error(`[buzola] LoaderCache maxSize must be non-negative, got ${maxSize}`)
|
|
13
|
+
}
|
|
14
|
+
this._maxSize = maxSize
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get maxSize(): number {
|
|
18
|
+
return this._maxSize
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get size(): number {
|
|
22
|
+
return this.cache.size
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get(key: string): unknown {
|
|
26
|
+
const value = this.cache.get(key)
|
|
27
|
+
if (value !== undefined || this.cache.has(key)) {
|
|
28
|
+
// Move to end (most recently used)
|
|
29
|
+
this.cache.delete(key)
|
|
30
|
+
this.cache.set(key, value)
|
|
31
|
+
}
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
has(key: string): boolean {
|
|
36
|
+
return this.cache.has(key)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
set(key: string, value: unknown): void {
|
|
40
|
+
this.cache.delete(key)
|
|
41
|
+
this.cache.set(key, value)
|
|
42
|
+
while (this.cache.size > this._maxSize) {
|
|
43
|
+
const first = this.cache.keys().next().value!
|
|
44
|
+
this.cache.delete(first)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
delete(key: string): void {
|
|
49
|
+
this.cache.delete(key)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
clear(): void {
|
|
53
|
+
this.cache.clear()
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/engine/matcher.ts
CHANGED
package/src/engine/route-tree.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RouteComponent, RouteConfig,
|
|
1
|
+
import type { RouteComponent, RouteConfig, RouteNode, StandardSchema } from './types.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Normalize a path segment — ensure it starts with "/" and strip trailing slashes.
|
|
@@ -24,7 +24,6 @@ export interface CreateRouteNodeOptions {
|
|
|
24
24
|
fullPath: string
|
|
25
25
|
component?: RouteComponent
|
|
26
26
|
searchSchema?: StandardSchema
|
|
27
|
-
beforeEnter?: RouteGuard
|
|
28
27
|
children?: RouteNode[]
|
|
29
28
|
parent?: RouteNode
|
|
30
29
|
isLayout?: boolean
|
|
@@ -40,7 +39,6 @@ export function createRouteNode(options: CreateRouteNodeOptions): RouteNode {
|
|
|
40
39
|
fullPath,
|
|
41
40
|
component,
|
|
42
41
|
searchSchema,
|
|
43
|
-
beforeEnter,
|
|
44
42
|
children = [],
|
|
45
43
|
parent,
|
|
46
44
|
isLayout = false,
|
|
@@ -69,7 +67,6 @@ export function createRouteNode(options: CreateRouteNodeOptions): RouteNode {
|
|
|
69
67
|
prefixPattern,
|
|
70
68
|
component,
|
|
71
69
|
searchSchema,
|
|
72
|
-
beforeEnter,
|
|
73
70
|
children,
|
|
74
71
|
parent,
|
|
75
72
|
isLayout,
|
|
@@ -101,7 +98,9 @@ function buildRouteNodes(configs: RouteConfig[], parentPath: string): RouteNode[
|
|
|
101
98
|
const isIndex = config.isIndex ?? (!isLayout && (config.path === '/' || config.path === ''))
|
|
102
99
|
|
|
103
100
|
let fullPath: string
|
|
104
|
-
if (
|
|
101
|
+
if (config.matchPath != null) {
|
|
102
|
+
fullPath = config.matchPath
|
|
103
|
+
} else if (isIndex) {
|
|
105
104
|
fullPath = parentPath || '/'
|
|
106
105
|
} else {
|
|
107
106
|
fullPath = joinPaths(parentPath || '/', segment)
|
|
@@ -116,7 +115,6 @@ function buildRouteNodes(configs: RouteConfig[], parentPath: string): RouteNode[
|
|
|
116
115
|
fullPath,
|
|
117
116
|
component: config.component,
|
|
118
117
|
searchSchema: config.searchSchema,
|
|
119
|
-
beforeEnter: config.beforeEnter,
|
|
120
118
|
children,
|
|
121
119
|
isLayout,
|
|
122
120
|
isIndex,
|