@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.
Files changed (95) hide show
  1. package/dist/define/create-page.d.ts +17 -5
  2. package/dist/define/create-page.d.ts.map +1 -1
  3. package/dist/define/create-page.js +78 -38
  4. package/dist/define/create-page.js.map +1 -1
  5. package/dist/engine/loader-cache.d.ts +18 -0
  6. package/dist/engine/loader-cache.d.ts.map +1 -0
  7. package/dist/engine/loader-cache.js +48 -0
  8. package/dist/engine/loader-cache.js.map +1 -0
  9. package/dist/engine/matcher.d.ts +1 -1
  10. package/dist/engine/matcher.d.ts.map +1 -1
  11. package/dist/engine/navigation-adapter.d.ts +1 -1
  12. package/dist/engine/navigation-adapter.d.ts.map +1 -1
  13. package/dist/engine/route-tree.d.ts +1 -2
  14. package/dist/engine/route-tree.d.ts.map +1 -1
  15. package/dist/engine/route-tree.js +5 -4
  16. package/dist/engine/route-tree.js.map +1 -1
  17. package/dist/engine/router.d.ts +7 -10
  18. package/dist/engine/router.d.ts.map +1 -1
  19. package/dist/engine/router.js +23 -67
  20. package/dist/engine/router.js.map +1 -1
  21. package/dist/engine/schema.d.ts +5 -1
  22. package/dist/engine/schema.d.ts.map +1 -1
  23. package/dist/engine/schema.js +11 -1
  24. package/dist/engine/schema.js.map +1 -1
  25. package/dist/engine/types.d.ts +2 -15
  26. package/dist/engine/types.d.ts.map +1 -1
  27. package/dist/engine/utils.d.ts +8 -0
  28. package/dist/engine/utils.d.ts.map +1 -1
  29. package/dist/engine/utils.js +10 -3
  30. package/dist/engine/utils.js.map +1 -1
  31. package/dist/index.d.ts +14 -13
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +8 -7
  34. package/dist/index.js.map +1 -1
  35. package/dist/react/context.d.ts +2 -2
  36. package/dist/react/context.d.ts.map +1 -1
  37. package/dist/react/hooks.d.ts +3 -3
  38. package/dist/react/hooks.d.ts.map +1 -1
  39. package/dist/react/hooks.js +11 -9
  40. package/dist/react/hooks.js.map +1 -1
  41. package/dist/react/index.d.ts +11 -11
  42. package/dist/react/index.d.ts.map +1 -1
  43. package/dist/react/index.js +6 -6
  44. package/dist/react/index.js.map +1 -1
  45. package/dist/react/link.d.ts +5 -0
  46. package/dist/react/link.d.ts.map +1 -1
  47. package/dist/react/link.js +10 -1
  48. package/dist/react/link.js.map +1 -1
  49. package/dist/react/outlet.d.ts +3 -1
  50. package/dist/react/outlet.d.ts.map +1 -1
  51. package/dist/react/outlet.js +25 -1
  52. package/dist/react/outlet.js.map +1 -1
  53. package/dist/react/provider.d.ts +15 -2
  54. package/dist/react/provider.d.ts.map +1 -1
  55. package/dist/react/provider.js +15 -4
  56. package/dist/react/provider.js.map +1 -1
  57. package/package.json +6 -3
  58. package/src/__tests__/create-page.test.ts +42 -6
  59. package/src/__tests__/loader-cache.test.ts +115 -0
  60. package/src/__tests__/matcher.test.ts +3 -3
  61. package/src/__tests__/react.test.tsx +1020 -0
  62. package/src/__tests__/router.test.ts +4 -214
  63. package/src/__tests__/setup-dom.ts +21 -0
  64. package/src/define/create-page.tsx +94 -46
  65. package/src/engine/loader-cache.ts +55 -0
  66. package/src/engine/matcher.ts +1 -1
  67. package/src/engine/navigation-adapter.ts +1 -1
  68. package/src/engine/route-tree.ts +4 -6
  69. package/src/engine/router.ts +27 -73
  70. package/src/engine/schema.ts +13 -2
  71. package/src/engine/types.ts +2 -18
  72. package/src/engine/utils.ts +11 -3
  73. package/src/index.ts +15 -17
  74. package/src/react/context.ts +2 -2
  75. package/src/react/hooks.ts +14 -13
  76. package/src/react/index.ts +21 -11
  77. package/src/react/link.tsx +16 -0
  78. package/src/react/outlet.tsx +32 -1
  79. package/src/react/provider.tsx +33 -5
  80. package/dist/__tests__/create-page.test.d.ts +0 -2
  81. package/dist/__tests__/create-page.test.d.ts.map +0 -1
  82. package/dist/__tests__/create-page.test.js +0 -457
  83. package/dist/__tests__/create-page.test.js.map +0 -1
  84. package/dist/__tests__/matcher.test.d.ts +0 -2
  85. package/dist/__tests__/matcher.test.d.ts.map +0 -1
  86. package/dist/__tests__/matcher.test.js +0 -108
  87. package/dist/__tests__/matcher.test.js.map +0 -1
  88. package/dist/__tests__/router.test.d.ts +0 -2
  89. package/dist/__tests__/router.test.d.ts.map +0 -1
  90. package/dist/__tests__/router.test.js +0 -327
  91. package/dist/__tests__/router.test.js.map +0 -1
  92. package/dist/__tests__/type-safety.test.d.ts +0 -15
  93. package/dist/__tests__/type-safety.test.d.ts.map +0 -1
  94. package/dist/__tests__/type-safety.test.js +0 -163
  95. 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 { GuardContext, RouteConfig } from '../engine/types'
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 { isOptionalSchema, type ParamLiteral, resolveParamLiteral } from '../engine/schema'
4
- import type { RouteComponent, StandardSchema } from '../engine/types'
5
- import { extractParamNames } from '../engine/utils'
6
- import { RouteContext, RouterContext } from '../react/context'
7
- import { ErrorBoundary } from '../react/error-boundary'
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) => Object.assign({}, ...await Promise.all(loaders.map(fn => fn(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 = undefined
178
+ let activeResolvedData: unknown
149
179
  let hasResolved = false
150
180
  let pendingBackgroundKey: string | null = null
151
181
 
152
- // LRU cache for previously visited URLs (url → resolved data)
153
- const staleDataCache = new Map<string, unknown>()
182
+ // Unique ID for namespacing entries in the global loader cache
183
+ const pageId = nextPageId++
154
184
 
155
- function evictStaleCache(maxSize: number): void {
156
- while (staleDataCache.size > maxSize) {
157
- const first = staleDataCache.keys().next().value!
158
- staleDataCache.delete(first)
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
- const result = schema['~standard'].validate(merged)
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 cacheSize = router?.loaderCacheSize ?? 0
262
+ const loaderCache = router?.loaderCache
230
263
 
231
- let data: unknown = undefined
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
- staleDataCache.set(activeUrlHref, activeResolvedData)
242
- evictStaleCache(cacheSize)
274
+ if (loaderCache && hasResolved && activeResolvedData !== undefined) {
275
+ staleCacheSet(loaderCache, activeUrlHref, activeResolvedData)
243
276
  }
244
277
 
245
- if (staleDataCache.has(currentUrlHref)) {
246
- activeResolvedData = staleDataCache.get(currentUrlHref)
247
- staleDataCache.delete(currentUrlHref)
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 && staleDataCache.has(currentUrlHref)) {
287
+ } else if (activeUrlHref === null && loaderCache) {
255
288
  // Remount — restore from stale cache
256
- activeResolvedData = staleDataCache.get(currentUrlHref)
257
- staleDataCache.delete(currentUrlHref)
258
- hasResolved = true
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
- data = use(promise)
311
- activeResolvedData = data
312
- hasResolved = true
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
- staleDataCache.set(activeUrlHref, activeResolvedData)
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> }) => Promise<TData>,
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
+ }
@@ -1,4 +1,4 @@
1
- import type { RouteMatch, RouteNode } from './types'
1
+ import type { RouteMatch, RouteNode } from './types.js'
2
2
 
3
3
  /**
4
4
  * Match a URL against the route tree.
@@ -1,4 +1,4 @@
1
- import type { BuzolaNavigateEvent, NavigateOptions, NavigationAdapter } from './types'
1
+ import type { BuzolaNavigateEvent, NavigateOptions, NavigationAdapter } from './types.js'
2
2
 
3
3
  // ─── Browser adapter ────────────────────────────────────────────────────────
4
4
 
@@ -1,4 +1,4 @@
1
- import type { RouteComponent, RouteConfig, RouteGuard, RouteNode, StandardSchema } from './types'
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 (isIndex) {
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,