@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.
- package/dist/define/create-page.d.ts +7 -6
- package/dist/define/create-page.d.ts.map +1 -1
- package/dist/define/create-page.js +33 -120
- package/dist/define/create-page.js.map +1 -1
- package/dist/engine/loader-state.d.ts +68 -0
- package/dist/engine/loader-state.d.ts.map +1 -0
- package/dist/engine/loader-state.js +135 -0
- package/dist/engine/loader-state.js.map +1 -0
- package/dist/engine/route-tree.d.ts +1 -0
- package/dist/engine/route-tree.d.ts.map +1 -1
- package/dist/engine/route-tree.js +5 -2
- package/dist/engine/route-tree.js.map +1 -1
- package/dist/engine/router.d.ts +6 -0
- package/dist/engine/router.d.ts.map +1 -1
- package/dist/engine/router.js +19 -1
- package/dist/engine/router.js.map +1 -1
- package/dist/engine/types.d.ts +6 -0
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/react/hooks.d.ts +2 -0
- package/dist/react/hooks.d.ts.map +1 -1
- package/dist/react/hooks.js +5 -1
- package/dist/react/hooks.js.map +1 -1
- package/dist/react/link.d.ts +7 -0
- package/dist/react/link.d.ts.map +1 -1
- package/dist/react/link.js +33 -6
- package/dist/react/link.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/loader-state.test.ts +339 -0
- package/src/__tests__/matcher.test.ts +29 -0
- package/src/__tests__/router.test.ts +58 -0
- package/src/__tests__/type-safety.test.tsx +24 -0
- package/src/define/create-page.tsx +49 -140
- package/src/engine/loader-state.ts +178 -0
- package/src/engine/route-tree.ts +6 -1
- package/src/engine/router.ts +19 -1
- package/src/engine/types.ts +6 -0
- package/src/react/hooks.ts +5 -1
- 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
|
|
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 =
|
|
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
|
|
169
|
-
// This is critical: when use() suspends
|
|
170
|
-
//
|
|
171
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
308
|
-
activeResolvedData = data
|
|
309
|
-
hasResolved = true
|
|
256
|
+
loaderState.commitBackgroundError()
|
|
310
257
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
+
}
|
package/src/engine/route-tree.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/engine/router.ts
CHANGED
|
@@ -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
|
*/
|