@buzola/router 0.0.4 → 0.0.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.
Files changed (38) hide show
  1. package/dist/define/create-page.d.ts +7 -6
  2. package/dist/define/create-page.d.ts.map +1 -1
  3. package/dist/define/create-page.js +33 -120
  4. package/dist/define/create-page.js.map +1 -1
  5. package/dist/engine/loader-state.d.ts +68 -0
  6. package/dist/engine/loader-state.d.ts.map +1 -0
  7. package/dist/engine/loader-state.js +135 -0
  8. package/dist/engine/loader-state.js.map +1 -0
  9. package/dist/engine/route-tree.d.ts +1 -0
  10. package/dist/engine/route-tree.d.ts.map +1 -1
  11. package/dist/engine/route-tree.js +5 -2
  12. package/dist/engine/route-tree.js.map +1 -1
  13. package/dist/engine/router.d.ts +6 -0
  14. package/dist/engine/router.d.ts.map +1 -1
  15. package/dist/engine/router.js +19 -1
  16. package/dist/engine/router.js.map +1 -1
  17. package/dist/engine/types.d.ts +6 -0
  18. package/dist/engine/types.d.ts.map +1 -1
  19. package/dist/react/hooks.d.ts +2 -0
  20. package/dist/react/hooks.d.ts.map +1 -1
  21. package/dist/react/hooks.js +5 -1
  22. package/dist/react/hooks.js.map +1 -1
  23. package/dist/react/link.d.ts +7 -0
  24. package/dist/react/link.d.ts.map +1 -1
  25. package/dist/react/link.js +33 -6
  26. package/dist/react/link.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/__tests__/loader-state.test.ts +339 -0
  29. package/src/__tests__/matcher.test.ts +29 -0
  30. package/src/__tests__/router.test.ts +58 -0
  31. package/src/__tests__/type-safety.test.tsx +24 -0
  32. package/src/define/create-page.tsx +49 -140
  33. package/src/engine/loader-state.ts +178 -0
  34. package/src/engine/route-tree.ts +6 -1
  35. package/src/engine/router.ts +19 -1
  36. package/src/engine/types.ts +6 -0
  37. package/src/react/hooks.ts +5 -1
  38. package/src/react/link.tsx +49 -5
@@ -116,6 +116,35 @@ describe('matchRoutes', () => {
116
116
  expect(matches).toBeNull()
117
117
  })
118
118
 
119
+ it('matches routes with trailing slash', () => {
120
+ const tree = createTestTree([
121
+ { path: '/about', component: dummyComponent },
122
+ ])
123
+
124
+ const matches = matchRoutes(tree, new URL('http://localhost/about/'))
125
+ expect(matches).not.toBeNull()
126
+ expect(matches).toHaveLength(1)
127
+ expect(matches![0].node.fullPath).toBe('/about')
128
+ })
129
+
130
+ it('matches nested routes with trailing slash', () => {
131
+ const tree = createTestTree([
132
+ {
133
+ path: '/users',
134
+ component: dummyComponent,
135
+ isLayout: true,
136
+ children: [
137
+ { path: '/:userId', component: dummyComponent },
138
+ ],
139
+ },
140
+ ])
141
+
142
+ const matches = matchRoutes(tree, new URL('http://localhost/users/42/'))
143
+ expect(matches).not.toBeNull()
144
+ expect(matches).toHaveLength(2)
145
+ expect(matches![1].params).toEqual({ userId: '42' })
146
+ })
147
+
119
148
  it('does not match partial paths', () => {
120
149
  const tree = createTestTree([
121
150
  { path: '/about', component: dummyComponent },
@@ -212,3 +212,61 @@ describe('Router.resolveParams', () => {
212
212
  expect(result).toEqual({})
213
213
  })
214
214
  })
215
+
216
+ describe('Router.preload', () => {
217
+ it('calls preload on matching route nodes', () => {
218
+ const preloadFn = mock(() => {})
219
+ const routes = buildRouteTree([
220
+ { path: '/', component: dummyComponent, isIndex: true },
221
+ { path: '/users/:userId', component: dummyComponent, preload: preloadFn },
222
+ ])
223
+ const adapter = createMemoryNavigationAdapter({ initialURL: 'http://localhost/' })
224
+ const router = new Router({
225
+ routes,
226
+ adapter,
227
+ pageRegistry: { 'users/detail': '/users/:userId' },
228
+ })
229
+
230
+ router.preload('users/detail', { userId: '42' })
231
+ expect(preloadFn).toHaveBeenCalledTimes(1)
232
+ })
233
+
234
+ it('calls preload on all matched nodes (layout + page)', () => {
235
+ const layoutPreload = mock(() => {})
236
+ const pagePreload = mock(() => {})
237
+ const routes = buildRouteTree([{
238
+ path: '/users',
239
+ component: dummyComponent,
240
+ preload: layoutPreload,
241
+ isLayout: true,
242
+ children: [
243
+ { path: '/:userId', component: dummyComponent, preload: pagePreload },
244
+ ],
245
+ }])
246
+ const adapter = createMemoryNavigationAdapter({ initialURL: 'http://localhost/' })
247
+ const router = new Router({
248
+ routes,
249
+ adapter,
250
+ pageRegistry: { 'users/detail': '/users/:userId' },
251
+ })
252
+
253
+ router.preload('users/detail', { userId: '1' })
254
+ expect(layoutPreload).toHaveBeenCalledTimes(1)
255
+ expect(pagePreload).toHaveBeenCalledTimes(1)
256
+ })
257
+
258
+ it('does not throw for unmatched page', () => {
259
+ const routes = buildRouteTree([
260
+ { path: '/', component: dummyComponent, isIndex: true },
261
+ ])
262
+ const adapter = createMemoryNavigationAdapter({ initialURL: 'http://localhost/' })
263
+ const router = new Router({
264
+ routes,
265
+ adapter,
266
+ pageRegistry: { 'about': '/about' },
267
+ })
268
+
269
+ // Should not throw
270
+ router.preload('about')
271
+ })
272
+ })
@@ -108,6 +108,30 @@ describe('createPage catch type safety', () => {
108
108
  withCatch.loader
109
109
  expect(true).toBe(true)
110
110
  })
111
+
112
+ it('.catch() handler receives typed params', () => {
113
+ const _page = createPage()
114
+ .params(mockSchema<{ userId: string; tab: string }>())
115
+ .catch(({ error, params, retry }) => {
116
+ // params should be typed as { userId: string; tab: string }
117
+ const _userId: string = params.userId
118
+ const _tab: string = params.tab
119
+ return <div>{error.message}</div>
120
+ })
121
+ .render(() => null)
122
+ expect(true).toBe(true)
123
+ })
124
+
125
+ it('.catch() handler has empty params when no schema defined', () => {
126
+ const _page = createPage()
127
+ .catch(({ params }) => {
128
+ // @ts-expect-error — no params defined, accessing unknown key
129
+ params.anything
130
+ return null
131
+ })
132
+ .render(() => null)
133
+ expect(true).toBe(true)
134
+ })
111
135
  })
112
136
 
113
137
  // ─── LinkProps ──────────────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  import type { ReactElement } from 'react'
2
2
  import { type ComponentType, use, useCallback, useEffect, useMemo, useReducer, useState } from 'react'
3
- import type { LoaderCache } from '../engine/loader-cache.js'
3
+ import { PageLoaderState } from '../engine/loader-state.js'
4
4
  import { isOptionalSchema, type ParamLiteral, resolveParamLiteral, validateSchema } from '../engine/schema.js'
5
5
  import type { EffectivePageParams, RegisteredPage, RouteComponent, StandardSchema } from '../engine/types.js'
6
6
  import { extractParamNames } from '../engine/utils.js'
@@ -11,12 +11,15 @@ export interface PageProps<TParams> {
11
11
  params: TParams
12
12
  }
13
13
 
14
- export interface CatchContext {
14
+ export interface CatchContext<TParams = Record<string, never>> {
15
15
  error: Error
16
+ params: TParams
16
17
  retry: () => void
17
18
  }
18
19
 
19
- type CatchHandler = React.ReactNode | ((ctx: CatchContext) => React.ReactNode)
20
+ type CatchHandler<TParams = Record<string, never>> =
21
+ | React.ReactNode
22
+ | ((ctx: CatchContext<TParams>) => React.ReactNode)
20
23
 
21
24
  export interface ParamMeta {
22
25
  name: string
@@ -153,50 +156,20 @@ function combineLoaders(loaders: LoaderFn[]): LoaderFn | undefined {
153
156
 
154
157
  // ─── Component factory ──────────────────────────────────────────────────────
155
158
 
156
- let nextPageId = 0
157
-
158
159
  function createPageComponent<TParams>(
159
160
  schema: StandardSchema<TParams> | undefined,
160
161
  paramsMeta: ParamMeta[],
161
162
  loaderFn: LoaderFn | undefined,
162
163
  routePattern: string | undefined,
163
164
  RenderComponent: ComponentType<any>,
164
- catchHandler?: CatchHandler,
165
+ catchHandler?: CatchHandler<TParams>,
165
166
  ): PageDefinition<TParams> {
166
167
  const arrayParams = new Set(paramsMeta.filter(m => m.array).map(m => m.name))
167
168
 
168
- // Loader promise cache lives in the closure, outside React's render cycle.
169
- // This is critical: when use() suspends during the initial render, React
170
- // discards all uncommitted hook state (useRef, useState, useMemo).
171
- // On retry, hooks reinitialize, and useMemo/useRef would create a new promise,
172
- // causing an infinite suspend loop. The closure cache survives this.
173
-
174
- // Active load state (current URL)
175
- let activeUrlHref: string | null = null
176
- let activeCacheKey: string | null = null
177
- let activePromise: Promise<unknown> | null = null
178
- let activeResolvedData: unknown
179
- let hasResolved = false
180
- let pendingBackgroundKey: string | null = null
181
-
182
- // Unique ID for namespacing entries in the global loader cache
183
- const pageId = nextPageId++
184
-
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)
199
- }
169
+ // Loader state lives in the closure, outside React's render cycle.
170
+ // This is critical: when use() suspends, React discards uncommitted hook
171
+ // state. The PageLoaderState instance survives Suspense retries.
172
+ const loaderState = loaderFn ? new PageLoaderState() : undefined
200
173
 
201
174
  function BuzolaPage(): ReactElement | null {
202
175
  const routeContext = use(RouteContext)
@@ -259,120 +232,51 @@ function createPageComponent<TParams>(
259
232
  return router.onInvalidate(() => setLoaderKey(k => k + 1))
260
233
  }, [router])
261
234
 
262
- const loaderCache = router?.loaderCache
263
-
264
235
  let data: unknown
265
236
  let isLoading = false
266
237
  let shouldRedirect = false
267
238
 
268
- if (loaderFn) {
239
+ if (loaderFn && loaderState) {
269
240
  const currentUrlHref = routeContext.state.location.href
270
241
  const cacheKey = `${currentUrlHref}:${loaderKey}`
271
242
 
272
- // URL changed — save old data to stale cache, restore from stale cache if available
273
- if (activeUrlHref !== null && activeUrlHref !== currentUrlHref) {
274
- if (loaderCache && hasResolved && activeResolvedData !== undefined) {
275
- staleCacheSet(loaderCache, activeUrlHref, activeResolvedData)
276
- }
277
-
278
- const cached = loaderCache && staleCacheGet(loaderCache, currentUrlHref)
279
- if (cached?.hit) {
280
- activeResolvedData = cached.value
281
- hasResolved = true
282
- } else {
283
- hasResolved = false
284
- activeResolvedData = undefined
285
- }
286
- pendingBackgroundKey = null
287
- } else if (activeUrlHref === null && loaderCache) {
288
- // Remount — restore from stale cache
289
- const cached = staleCacheGet(loaderCache, currentUrlHref)
290
- if (cached.hit) {
291
- activeResolvedData = cached.value
292
- hasResolved = true
293
- }
294
- }
295
- activeUrlHref = currentUrlHref
296
-
297
- if (activeCacheKey === cacheKey) {
298
- // Cache hit — check if background load is pending
299
- if (pendingBackgroundKey === cacheKey) {
300
- data = activeResolvedData
301
- isLoading = true
302
- } else {
303
- // Either resolved or first-time suspend
304
- if (hasResolved) {
305
- data = activeResolvedData
243
+ const instruction = loaderState.resolve({
244
+ currentUrlHref,
245
+ cacheKey,
246
+ load: () => loaderFn({ params, redirect }),
247
+ loaderCache: router?.loaderCache,
248
+ onBackgroundSettled: (outcome) => {
249
+ if (outcome.ok) {
250
+ if (outcome.result instanceof BuzolaRedirect) {
251
+ router?.navigateToPage(outcome.result.pageId, outcome.result.params, { replace: true })
252
+ return
253
+ }
254
+ loaderState.commitBackgroundResult(outcome.result)
306
255
  } else {
307
- data = use(activePromise!)
308
- activeResolvedData = data
309
- hasResolved = true
256
+ loaderState.commitBackgroundError()
310
257
  }
311
- }
312
- } else if (hasResolved) {
313
- // Cache key changed + we have stale data → background reload (SWR)
314
- const promise = loaderFn({ params, redirect })
315
- activeCacheKey = cacheKey
316
- activePromise = promise
317
- pendingBackgroundKey = cacheKey
318
- data = activeResolvedData
319
- isLoading = true
320
-
321
- promise.then(
322
- (result) => {
323
- // Only apply if this is still the active load
324
- if (activeCacheKey === cacheKey) {
325
- if (result instanceof BuzolaRedirect) {
326
- router?.navigateToPage(result.pageId, result.params, { replace: true })
327
- return
328
- }
329
- activeResolvedData = result
330
- pendingBackgroundKey = null
331
- forceUpdate()
332
- }
333
- },
334
- (error) => {
335
- // Store rejected promise so use() triggers ErrorBoundary
336
- if (activeCacheKey === cacheKey) {
337
- activePromise = promise
338
- hasResolved = false
339
- pendingBackgroundKey = null
340
- forceUpdate()
341
- }
342
- },
343
- )
258
+ forceUpdate()
259
+ },
260
+ })
261
+
262
+ if (instruction.action === 'render') {
263
+ data = instruction.data
264
+ isLoading = instruction.isLoading
344
265
  } else {
345
- // No stale data — first load, suspend via use()
346
- const promise = loaderFn({ params, redirect })
347
- activeCacheKey = cacheKey
348
- activePromise = promise
349
- const result = use(promise)
266
+ const result = use(instruction.promise)
350
267
  if (result instanceof BuzolaRedirect) {
351
268
  router?.navigateToPage(result.pageId, result.params, { replace: true })
352
269
  shouldRedirect = true
353
270
  } else {
354
271
  data = result
355
- }
356
- if (!shouldRedirect) {
357
- activeResolvedData = data
358
- hasResolved = true
272
+ loaderState.commitResult(data)
359
273
  }
360
274
  }
361
275
  }
362
276
 
363
- // On unmount, save active data to stale cache and reset active state
277
+ // On unmount, save active data to stale cache and reset
364
278
  useEffect(() => {
365
- return () => {
366
- if (loaderCache && hasResolved && activeResolvedData !== undefined && activeUrlHref) {
367
- staleCacheSet(loaderCache, activeUrlHref, activeResolvedData)
368
- }
369
- hasResolved = false
370
- activeResolvedData = undefined
371
- activeCacheKey = null
372
- activePromise = null
373
- pendingBackgroundKey = null
374
- activeUrlHref = null
375
- }
279
+ return () => loaderState?.dispose(router?.loaderCache)
376
280
  }, [])
377
281
 
378
282
  if (shouldRedirect) return null
@@ -386,7 +290,12 @@ function createPageComponent<TParams>(
386
290
  const retry = useCallback(() => setRetryCount(c => c + 1), [])
387
291
  const resetKey = `${routeContext?.state.location.href ?? ''}:${retryCount}`
388
292
  const boundFallback = typeof catchHandler === 'function'
389
- ? (error: Error) => (catchHandler as (ctx: CatchContext) => React.ReactNode)({ error, retry })
293
+ ? (error: Error) =>
294
+ (catchHandler as (ctx: CatchContext<TParams>) => React.ReactNode)({
295
+ error,
296
+ params: (routeContext?.params ?? {}) as TParams,
297
+ retry,
298
+ })
390
299
  : catchHandler
391
300
  return (
392
301
  <ErrorBoundary fallback={boundFallback} resetKey={resetKey}>
@@ -417,11 +326,11 @@ interface WithCatch<TParams, TRenderProps> {
417
326
  }
418
327
 
419
328
  interface WithRoute<TParams, TRenderProps> {
420
- catch(handler: CatchHandler): WithCatch<TParams, TRenderProps>
329
+ catch(handler: CatchHandler<TParams>): WithCatch<TParams, TRenderProps>
421
330
  route<R extends string>(
422
331
  pattern: [ExtractRouteParams<R>] extends [keyof TParams & string] ? R : never,
423
332
  ): {
424
- catch(handler: CatchHandler): { render(fn: ComponentType<TRenderProps>): PageDefinition<TParams> }
333
+ catch(handler: CatchHandler<TParams>): { render(fn: ComponentType<TRenderProps>): PageDefinition<TParams> }
425
334
  render(fn: ComponentType<TRenderProps>): PageDefinition<TParams>
426
335
  }
427
336
  render(fn: ComponentType<TRenderProps>): PageDefinition<TParams>
@@ -447,11 +356,11 @@ interface PageBuilder {
447
356
  loader<TData extends Record<string, unknown>>(
448
357
  fn: (ctx: { params: Record<string, never>; redirect: RedirectFn }) => Promise<TData | BuzolaRedirect>,
449
358
  ): WithLoaderChain<Record<string, never>, TData>
450
- catch(handler: CatchHandler): WithCatch<Record<string, never>, PageProps<Record<string, never>>>
359
+ catch(handler: CatchHandler<Record<string, never>>): WithCatch<Record<string, never>, PageProps<Record<string, never>>>
451
360
  route<R extends string>(
452
361
  pattern: [ExtractRouteParams<R>] extends [never] ? R : never,
453
362
  ): {
454
- catch(handler: CatchHandler): { render(fn: ComponentType<PageProps<Record<string, never>>>): PageDefinition }
363
+ catch(handler: CatchHandler<Record<string, never>>): { render(fn: ComponentType<PageProps<Record<string, never>>>): PageDefinition }
455
364
  render(fn: ComponentType<PageProps<Record<string, never>>>): PageDefinition
456
365
  }
457
366
  render(fn: ComponentType<PageProps<Record<string, never>>>): PageDefinition
@@ -461,7 +370,7 @@ function withCatchAndRender(
461
370
  schema: StandardSchema | undefined,
462
371
  paramsMeta: ParamMeta[],
463
372
  loaders: LoaderFn[],
464
- catchHandler: CatchHandler,
373
+ catchHandler: CatchHandler<any>,
465
374
  ) {
466
375
  return {
467
376
  route(pattern: string) {
@@ -486,12 +395,12 @@ function withRouteAndRender(
486
395
  loader(loaderFn: LoaderFn) {
487
396
  return withRouteAndRender(schema, paramsMeta, [...loaders, loaderFn])
488
397
  },
489
- catch(handler: CatchHandler) {
398
+ catch(handler: CatchHandler<any>) {
490
399
  return withCatchAndRender(schema, paramsMeta, loaders, handler)
491
400
  },
492
401
  route(pattern: string) {
493
402
  return {
494
- catch(handler: CatchHandler) {
403
+ catch(handler: CatchHandler<any>) {
495
404
  return {
496
405
  render(fn: ComponentType<any>) {
497
406
  return createPageComponent(schema, paramsMeta, combineLoaders(loaders), pattern, fn, handler)
@@ -0,0 +1,178 @@
1
+ import type { LoaderCache } from './loader-cache.js'
2
+
3
+ // ─── Types ──────────────────────────────────────────────────────────────────
4
+
5
+ export type LoaderInstruction =
6
+ | { action: 'render'; data: unknown; isLoading: boolean }
7
+ | { action: 'suspend'; promise: Promise<unknown> }
8
+
9
+ export interface LoaderResolveContext {
10
+ /** Current URL href — used for stale cache keying */
11
+ currentUrlHref: string
12
+ /** Cache key that changes on URL change or invalidation */
13
+ cacheKey: string
14
+ /** Factory that starts the actual data fetch */
15
+ load: () => Promise<unknown>
16
+ /** Global loader cache for cross-navigation stale data */
17
+ loaderCache: LoaderCache | undefined
18
+ /**
19
+ * Called when a background (SWR) fetch settles.
20
+ * Caller is responsible for calling commitBackgroundResult/commitBackgroundError
21
+ * and triggering a React re-render.
22
+ */
23
+ onBackgroundSettled: (outcome: { ok: true; result: unknown } | { ok: false }) => void
24
+ }
25
+
26
+ // ─── State machine ──────────────────────────────────────────────────────────
27
+
28
+ let nextId = 0
29
+
30
+ /**
31
+ * Manages loader data lifecycle outside of React's render cycle.
32
+ *
33
+ * This state survives React Suspense retries — when use() suspends, React
34
+ * discards uncommitted hook state (useRef, useState, useMemo). Closure/instance
35
+ * state is preserved, preventing infinite suspend loops from promise recreation.
36
+ *
37
+ * Supports stale-while-revalidate: when the cache key changes but stale data
38
+ * exists, returns stale data immediately and starts a background fetch.
39
+ */
40
+ export class PageLoaderState {
41
+ private activeUrlHref: string | null = null
42
+ private activeCacheKey: string | null = null
43
+ private activePromise: Promise<unknown> | null = null
44
+ private activeResolvedData: unknown
45
+ private hasResolved = false
46
+ private pendingBackgroundKey: string | null = null
47
+ private readonly id: number
48
+
49
+ constructor() {
50
+ this.id = nextId++
51
+ }
52
+
53
+ /**
54
+ * Determine what the component should do during render.
55
+ * Returns either resolved data to render, or a promise to suspend on.
56
+ */
57
+ resolve(ctx: LoaderResolveContext): LoaderInstruction {
58
+ const { currentUrlHref, cacheKey, load, loaderCache, onBackgroundSettled } = ctx
59
+
60
+ this.handleUrlChange(currentUrlHref, loaderCache)
61
+
62
+ // ─── Same cache key — return current state ─────
63
+ if (this.activeCacheKey === cacheKey) {
64
+ if (this.pendingBackgroundKey === cacheKey) {
65
+ return { action: 'render', data: this.activeResolvedData, isLoading: true }
66
+ }
67
+ if (this.hasResolved) {
68
+ return { action: 'render', data: this.activeResolvedData, isLoading: false }
69
+ }
70
+ return { action: 'suspend', promise: this.activePromise! }
71
+ }
72
+
73
+ // ─── Cache key changed + stale data available → SWR ─────
74
+ if (this.hasResolved) {
75
+ const promise = load()
76
+ this.activeCacheKey = cacheKey
77
+ this.activePromise = promise
78
+ this.pendingBackgroundKey = cacheKey
79
+
80
+ promise.then(
81
+ (result) => {
82
+ if (this.activeCacheKey === cacheKey) {
83
+ onBackgroundSettled({ ok: true, result })
84
+ }
85
+ },
86
+ () => {
87
+ if (this.activeCacheKey === cacheKey) {
88
+ onBackgroundSettled({ ok: false })
89
+ }
90
+ },
91
+ )
92
+
93
+ return { action: 'render', data: this.activeResolvedData, isLoading: true }
94
+ }
95
+
96
+ // ─── No stale data → first load, suspend ─────
97
+ const promise = load()
98
+ this.activeCacheKey = cacheKey
99
+ this.activePromise = promise
100
+ return { action: 'suspend', promise }
101
+ }
102
+
103
+ /** Commit data after use() resolves (suspend case) */
104
+ commitResult(data: unknown): void {
105
+ this.activeResolvedData = data
106
+ this.hasResolved = true
107
+ }
108
+
109
+ /** Commit background SWR success — update data and clear pending flag */
110
+ commitBackgroundResult(data: unknown): void {
111
+ this.activeResolvedData = data
112
+ this.pendingBackgroundKey = null
113
+ }
114
+
115
+ /** Mark background load as failed — next render will suspend with rejected promise */
116
+ commitBackgroundError(): void {
117
+ this.hasResolved = false
118
+ this.pendingBackgroundKey = null
119
+ }
120
+
121
+ /** Save state to stale cache and reset (called on unmount) */
122
+ dispose(loaderCache: LoaderCache | undefined): void {
123
+ if (loaderCache && this.hasResolved && this.activeResolvedData !== undefined && this.activeUrlHref) {
124
+ this.saveToStaleCache(loaderCache, this.activeUrlHref, this.activeResolvedData)
125
+ }
126
+ this.activeUrlHref = null
127
+ this.activeCacheKey = null
128
+ this.activePromise = null
129
+ this.activeResolvedData = undefined
130
+ this.hasResolved = false
131
+ this.pendingBackgroundKey = null
132
+ }
133
+
134
+ // ─── Private ────────────────────────────────────────────────────────────
135
+
136
+ private handleUrlChange(currentUrlHref: string, loaderCache: LoaderCache | undefined): void {
137
+ if (this.activeUrlHref !== null && this.activeUrlHref !== currentUrlHref) {
138
+ // URL changed — save old data to stale cache
139
+ if (loaderCache && this.hasResolved && this.activeResolvedData !== undefined) {
140
+ this.saveToStaleCache(loaderCache, this.activeUrlHref, this.activeResolvedData)
141
+ }
142
+ // Try to restore from stale cache for new URL
143
+ const cached = loaderCache && this.restoreFromStaleCache(loaderCache, currentUrlHref)
144
+ if (cached?.hit) {
145
+ this.activeResolvedData = cached.value
146
+ this.hasResolved = true
147
+ } else {
148
+ this.hasResolved = false
149
+ this.activeResolvedData = undefined
150
+ }
151
+ this.pendingBackgroundKey = null
152
+ } else if (this.activeUrlHref === null && loaderCache) {
153
+ // Remount — try to restore from stale cache
154
+ const cached = this.restoreFromStaleCache(loaderCache, currentUrlHref)
155
+ if (cached.hit) {
156
+ this.activeResolvedData = cached.value
157
+ this.hasResolved = true
158
+ }
159
+ }
160
+ this.activeUrlHref = currentUrlHref
161
+ }
162
+
163
+ private staleCacheKey(urlHref: string): string {
164
+ return `${this.id}:${urlHref}`
165
+ }
166
+
167
+ private restoreFromStaleCache(cache: LoaderCache, urlHref: string): { hit: true; value: unknown } | { hit: false } {
168
+ const key = this.staleCacheKey(urlHref)
169
+ if (!cache.has(key)) return { hit: false }
170
+ const value = cache.get(key)
171
+ cache.delete(key)
172
+ return { hit: true, value }
173
+ }
174
+
175
+ private saveToStaleCache(cache: LoaderCache, urlHref: string, value: unknown): void {
176
+ cache.set(this.staleCacheKey(urlHref), value)
177
+ }
178
+ }
@@ -24,6 +24,7 @@ export interface CreateRouteNodeOptions {
24
24
  fullPath: string
25
25
  component?: RouteComponent
26
26
  searchSchema?: StandardSchema
27
+ preload?: () => void
27
28
  children?: RouteNode[]
28
29
  parent?: RouteNode
29
30
  isLayout?: boolean
@@ -39,6 +40,7 @@ export function createRouteNode(options: CreateRouteNodeOptions): RouteNode {
39
40
  fullPath,
40
41
  component,
41
42
  searchSchema,
43
+ preload,
42
44
  children = [],
43
45
  parent,
44
46
  isLayout = false,
@@ -50,7 +52,8 @@ export function createRouteNode(options: CreateRouteNodeOptions): RouteNode {
50
52
  let pattern: URLPattern | undefined
51
53
  let prefixPattern: URLPattern | undefined
52
54
  if (!isLayout || isIndex) {
53
- pattern = new URLPattern({ pathname: fullPath === '' ? '/' : fullPath })
55
+ const p = fullPath === '' ? '/' : fullPath
56
+ pattern = new URLPattern({ pathname: p === '/' ? p : `${p}{/}?` })
54
57
  }
55
58
  if (isLayout && !isIndex) {
56
59
  // Layouts get a prefix pattern that matches any path starting with their fullPath.
@@ -67,6 +70,7 @@ export function createRouteNode(options: CreateRouteNodeOptions): RouteNode {
67
70
  prefixPattern,
68
71
  component,
69
72
  searchSchema,
73
+ preload,
70
74
  children,
71
75
  parent,
72
76
  isLayout,
@@ -115,6 +119,7 @@ function buildRouteNodes(configs: RouteConfig[], parentPath: string): RouteNode[
115
119
  fullPath,
116
120
  component: config.component,
117
121
  searchSchema: config.searchSchema,
122
+ preload: config.preload,
118
123
  children,
119
124
  isLayout,
120
125
  isIndex,
@@ -158,10 +158,11 @@ export class Router {
158
158
  }
159
159
  }
160
160
 
161
- // Set pending state
161
+ // Set pending state with target location
162
162
  this.setState({
163
163
  ...this.state,
164
164
  isPending: true,
165
+ pendingLocation: url,
165
166
  })
166
167
 
167
168
  // A newer navigation has started — abandon this one
@@ -173,6 +174,7 @@ export class Router {
173
174
  location: url,
174
175
  matches,
175
176
  isPending: false,
177
+ pendingLocation: undefined,
176
178
  navigationState: this.adapter.getState(),
177
179
  })
178
180
  }
@@ -229,6 +231,22 @@ export class Router {
229
231
  return this.buildPath(pattern, resolved)
230
232
  }
231
233
 
234
+ /**
235
+ * Preload a page by its page ID.
236
+ * Triggers lazy component imports for all matching route nodes,
237
+ * so the code is ready when the user navigates.
238
+ */
239
+ preload(pageId: string, params?: Record<string, string>): void {
240
+ const path = this.buildPagePath(pageId, params)
241
+ const url = new URL(path, 'http://localhost')
242
+ const matchUrl = this.createMatchUrl(url)
243
+ const matches = matchRoutes(this.routes, matchUrl)
244
+ if (!matches) return
245
+ for (const match of matches) {
246
+ match.node.preload?.()
247
+ }
248
+ }
249
+
232
250
  /**
233
251
  * Go back in history.
234
252
  */