@buzola/router 0.0.18 → 0.0.19
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.js +1 -1
- package/dist/engine/loader-cache.d.ts +1 -1
- package/dist/engine/loader-cache.js +1 -1
- package/dist/engine/loader-state.d.ts +5 -5
- package/dist/engine/loader-state.d.ts.map +1 -1
- package/dist/engine/loader-state.js +8 -8
- package/dist/engine/loader-state.js.map +1 -1
- package/dist/engine/matcher.js +4 -4
- package/dist/engine/matcher.js.map +1 -1
- package/dist/engine/navigation-adapter.js +2 -2
- package/dist/engine/navigation-adapter.js.map +1 -1
- package/dist/engine/route-tree.js +2 -2
- package/dist/engine/route-tree.js.map +1 -1
- package/dist/engine/router.d.ts +6 -6
- package/dist/engine/router.js +10 -10
- package/dist/engine/router.js.map +1 -1
- package/dist/engine/types.d.ts +8 -8
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/engine/utils.d.ts +3 -3
- package/dist/engine/utils.js +3 -3
- package/dist/react/hooks.d.ts +2 -2
- package/dist/react/hooks.js +1 -1
- package/dist/react/link.d.ts +4 -4
- package/dist/react/link.js +1 -1
- package/dist/react/outlet.js +2 -2
- package/dist/react/outlet.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/create-page.test.ts +1 -1
- package/src/__tests__/loader-cache.test.ts +1 -1
- package/src/__tests__/loader-state.test.ts +12 -12
- package/src/__tests__/navigation-adapter.test.ts +2 -2
- package/src/__tests__/react.test.tsx +4 -4
- package/src/__tests__/router.test.ts +2 -2
- package/src/__tests__/setup-dom.ts +1 -1
- package/src/__tests__/type-safety.test.tsx +22 -22
- package/src/define/create-page.tsx +1 -1
- package/src/engine/loader-cache.ts +1 -1
- package/src/engine/loader-state.ts +9 -9
- package/src/engine/matcher.ts +4 -4
- package/src/engine/navigation-adapter.ts +2 -2
- package/src/engine/route-tree.ts +3 -3
- package/src/engine/router.ts +11 -11
- package/src/engine/types.ts +8 -8
- package/src/engine/utils.ts +3 -3
- package/src/react/hooks.ts +2 -2
- package/src/react/link.tsx +5 -5
- package/src/react/outlet.tsx +2 -2
package/dist/engine/utils.js
CHANGED
|
@@ -22,12 +22,12 @@ export function extractParamNames(pattern) {
|
|
|
22
22
|
return names;
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
|
-
* Whether two URLs address the same page
|
|
25
|
+
* Whether two URLs address the same page, i.e. only search/hash differ.
|
|
26
26
|
* Same-path navigations (filters, sorting, pagination, search) keep the page
|
|
27
27
|
* on screen and keep its loader data as stale; path changes do not.
|
|
28
28
|
*
|
|
29
|
-
* Note this ignores the hash. Callers that care about fragment changes
|
|
30
|
-
* handling, for one
|
|
29
|
+
* Note this ignores the hash. Callers that care about fragment changes (focus
|
|
30
|
+
* handling, for one) have to compare `hash` themselves.
|
|
31
31
|
*/
|
|
32
32
|
export function isSamePath(a, b) {
|
|
33
33
|
const urlA = typeof a === 'string' ? new URL(a) : a;
|
package/dist/react/hooks.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export declare function useRouter(): Router;
|
|
|
7
7
|
/**
|
|
8
8
|
* Type-safe navigation function.
|
|
9
9
|
* Uses page IDs instead of URL patterns.
|
|
10
|
-
* Persistent params are optional
|
|
10
|
+
* Persistent params are optional: resolved from current URL or fallback.
|
|
11
11
|
*/
|
|
12
12
|
type NavigateFn = <P extends RegisteredPage>(to: P, ...args: [keyof EffectivePageParams<P>] extends [never] ? [options?: NavigateOptions] : {} extends EffectivePageParams<P> ? [options?: NavigateOptions & {
|
|
13
13
|
params?: EffectivePageParams<P>;
|
|
@@ -16,7 +16,7 @@ type NavigateFn = <P extends RegisteredPage>(to: P, ...args: [keyof EffectivePag
|
|
|
16
16
|
}]) => void;
|
|
17
17
|
/**
|
|
18
18
|
* Hook to get a type-safe navigate function.
|
|
19
|
-
* Navigates using page IDs
|
|
19
|
+
* Navigates using page IDs: the router looks up the route pattern from the page registry.
|
|
20
20
|
*/
|
|
21
21
|
export declare function useNavigate(): NavigateFn;
|
|
22
22
|
/**
|
package/dist/react/hooks.js
CHANGED
|
@@ -23,7 +23,7 @@ function useRouteContext() {
|
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* Hook to get a type-safe navigate function.
|
|
26
|
-
* Navigates using page IDs
|
|
26
|
+
* Navigates using page IDs: the router looks up the route pattern from the page registry.
|
|
27
27
|
*/
|
|
28
28
|
export function useNavigate() {
|
|
29
29
|
const router = useRouter();
|
package/dist/react/link.d.ts
CHANGED
|
@@ -25,9 +25,9 @@ type LinkPropsBase = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>
|
|
|
25
25
|
asChild?: boolean;
|
|
26
26
|
/**
|
|
27
27
|
* Prefetch strategy for lazy-loaded route code.
|
|
28
|
-
* - `'intent'
|
|
29
|
-
* - `'render'
|
|
30
|
-
* - `'none'
|
|
28
|
+
* - `'intent'`: preload on mouseEnter/focus (default)
|
|
29
|
+
* - `'render'`: preload as soon as the Link renders
|
|
30
|
+
* - `'none'`: disable prefetching
|
|
31
31
|
*/
|
|
32
32
|
prefetch?: 'intent' | 'render' | 'none';
|
|
33
33
|
};
|
|
@@ -43,7 +43,7 @@ export type LinkProps<P extends RegisteredPage = RegisteredPage> = [keyof Effect
|
|
|
43
43
|
};
|
|
44
44
|
/**
|
|
45
45
|
* Type-safe link component.
|
|
46
|
-
* Uses page IDs for navigation
|
|
46
|
+
* Uses page IDs for navigation: the router looks up the route pattern from the page registry.
|
|
47
47
|
* For simple navigations, renders a native <a> and lets the Navigation API handle it.
|
|
48
48
|
* Uses programmatic navigation when state, replace, resetScroll, or viewTransition is needed.
|
|
49
49
|
*
|
package/dist/react/link.js
CHANGED
|
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useRef } from 'react';
|
|
|
3
3
|
import { useRouter, useRouterState } from './hooks.js';
|
|
4
4
|
/**
|
|
5
5
|
* Type-safe link component.
|
|
6
|
-
* Uses page IDs for navigation
|
|
6
|
+
* Uses page IDs for navigation: the router looks up the route pattern from the page registry.
|
|
7
7
|
* For simple navigations, renders a native <a> and lets the Navigation API handle it.
|
|
8
8
|
* Uses programmatic navigation when state, replace, resetScroll, or viewTransition is needed.
|
|
9
9
|
*
|
package/dist/react/outlet.js
CHANGED
|
@@ -8,7 +8,7 @@ function DefaultNotFound() {
|
|
|
8
8
|
style: { fontFamily: 'system-ui, sans-serif', padding: '2rem', maxWidth: '600px', margin: '0 auto' },
|
|
9
9
|
}, isDev
|
|
10
10
|
? [
|
|
11
|
-
React.createElement('h1', { key: 'h' }, '404
|
|
11
|
+
React.createElement('h1', { key: 'h' }, '404: Page Not Found'),
|
|
12
12
|
React.createElement('p', { key: 'p1' }, `No route matches "${typeof window !== 'undefined' ? window.location.pathname : ''}".`),
|
|
13
13
|
React.createElement('p', {
|
|
14
14
|
key: 'p2',
|
|
@@ -56,7 +56,7 @@ export function Outlet({ fallback, errorFallback, notFound }) {
|
|
|
56
56
|
};
|
|
57
57
|
}, [state, depth, effectiveDepth, matches, match, routeContext.params]);
|
|
58
58
|
if (!match) {
|
|
59
|
-
// At root depth with no matches
|
|
59
|
+
// At root depth with no matches: show 404
|
|
60
60
|
if (depth === 0 && matches.length === 0) {
|
|
61
61
|
if (notFound === null)
|
|
62
62
|
return null;
|
package/dist/react/outlet.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"outlet.js","sourceRoot":"","sources":["../../src/react/outlet.tsx"],"names":[],"mappings":";AAAA,OAAO,KAAK,EAAE,EAAE,QAAQ,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,OAAO,CAAA;AACrD,OAAO,EAAE,YAAY,EAA0B,MAAM,cAAc,CAAA;AACnE,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAEnD,MAAM,KAAK,GAAG,OAAO,OAAO,KAAK,WAAW,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,CAAA;AAErF,SAAS,eAAe;IACvB,OAAO,KAAK,CAAC,aAAa,CACzB,KAAK,EACL;QACC,KAAK,EAAE,EAAE,UAAU,EAAE,uBAAuB,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE;KACpG,EACD,KAAK;QACJ,CAAC,CAAC;YACD,KAAK,CAAC,aAAa,CAAC,IAAI,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE,
|
|
1
|
+
{"version":3,"file":"outlet.js","sourceRoot":"","sources":["../../src/react/outlet.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -610,7 +610,7 @@ describe('redirect()', () => {
|
|
|
610
610
|
})
|
|
611
611
|
.render(() => null)
|
|
612
612
|
|
|
613
|
-
expect(typeof receivedRedirect).toBe('undefined') // not called yet
|
|
613
|
+
expect(typeof receivedRedirect).toBe('undefined') // not called yet: component not rendered
|
|
614
614
|
// Verify the type is correct by checking the function signature
|
|
615
615
|
expect(page.__buzolaPage).toBe(true)
|
|
616
616
|
})
|
|
@@ -74,7 +74,7 @@ describe('PageLoaderState', () => {
|
|
|
74
74
|
})
|
|
75
75
|
|
|
76
76
|
describe('invalidation (cache key change)', () => {
|
|
77
|
-
it('with stale data
|
|
77
|
+
it('with stale data: returns stale data with isLoading and starts background fetch', () => {
|
|
78
78
|
const state = createState()
|
|
79
79
|
resolveCtx(state, { cacheKey: 'k:0' })
|
|
80
80
|
state.commitResult('stale-data')
|
|
@@ -93,12 +93,12 @@ describe('PageLoaderState', () => {
|
|
|
93
93
|
expect(bgLoad).toBeDefined()
|
|
94
94
|
})
|
|
95
95
|
|
|
96
|
-
it('without stale data
|
|
96
|
+
it('without stale data: returns suspend instruction', () => {
|
|
97
97
|
const state = createState()
|
|
98
98
|
const promise = Promise.resolve('data')
|
|
99
99
|
const instruction = resolveCtx(state, { cacheKey: 'k:0', load: () => promise })
|
|
100
100
|
|
|
101
|
-
// No commitResult called
|
|
101
|
+
// No commitResult called: no stale data
|
|
102
102
|
const second = resolveCtx(state, { cacheKey: 'k:1', load: () => Promise.resolve('new') })
|
|
103
103
|
expect(second.action).toBe('suspend')
|
|
104
104
|
})
|
|
@@ -149,7 +149,7 @@ describe('PageLoaderState', () => {
|
|
|
149
149
|
onBackgroundSettled: settled1,
|
|
150
150
|
})
|
|
151
151
|
|
|
152
|
-
// Immediately invalidate again
|
|
152
|
+
// Immediately invalidate again: supersedes the first background fetch
|
|
153
153
|
state.commitBackgroundResult('stale') // commit so hasResolved stays true
|
|
154
154
|
const settled2 = mock(() => {})
|
|
155
155
|
resolveCtx(state, {
|
|
@@ -200,7 +200,7 @@ describe('PageLoaderState', () => {
|
|
|
200
200
|
resolveCtx(state, { url: 'http://localhost/a', cacheKey: 'a:0', loaderCache: cache })
|
|
201
201
|
state.commitResult('data-a')
|
|
202
202
|
|
|
203
|
-
// Navigate to B
|
|
203
|
+
// Navigate to B: should save A's data to cache
|
|
204
204
|
resolveCtx(state, { url: 'http://localhost/b', cacheKey: 'b:0', loaderCache: cache })
|
|
205
205
|
|
|
206
206
|
expect(cache.size).toBe(1)
|
|
@@ -218,7 +218,7 @@ describe('PageLoaderState', () => {
|
|
|
218
218
|
resolveCtx(state, { url: 'http://localhost/b', cacheKey: 'b:0', loaderCache: cache })
|
|
219
219
|
state.commitResult('data-b')
|
|
220
220
|
|
|
221
|
-
// Navigate back to A
|
|
221
|
+
// Navigate back to A: should restore from cache
|
|
222
222
|
const instruction = resolveCtx(state, {
|
|
223
223
|
url: 'http://localhost/a',
|
|
224
224
|
cacheKey: 'a:0',
|
|
@@ -229,11 +229,11 @@ describe('PageLoaderState', () => {
|
|
|
229
229
|
expect(instruction.action).toBe('render')
|
|
230
230
|
if (instruction.action === 'render') {
|
|
231
231
|
expect(instruction.data).toBe('data-a')
|
|
232
|
-
expect(instruction.isLoading).toBe(true) // SWR
|
|
232
|
+
expect(instruction.isLoading).toBe(true) // SWR: stale data + background reload
|
|
233
233
|
}
|
|
234
234
|
})
|
|
235
235
|
|
|
236
|
-
it('keeps data as stale when only the query changes
|
|
236
|
+
it('keeps data as stale when only the query changes: no suspend', () => {
|
|
237
237
|
const cache = new LoaderCache(10)
|
|
238
238
|
const state = createState()
|
|
239
239
|
|
|
@@ -301,7 +301,7 @@ describe('PageLoaderState', () => {
|
|
|
301
301
|
// Load A without committing (no stale data to cache)
|
|
302
302
|
resolveCtx(state, { url: 'http://localhost/a', cacheKey: 'a:0', loaderCache: cache })
|
|
303
303
|
|
|
304
|
-
// Navigate to B
|
|
304
|
+
// Navigate to B: no cache for B
|
|
305
305
|
const instruction = resolveCtx(state, {
|
|
306
306
|
url: 'http://localhost/b',
|
|
307
307
|
cacheKey: 'b:0',
|
|
@@ -317,7 +317,7 @@ describe('PageLoaderState', () => {
|
|
|
317
317
|
const cache = new LoaderCache(10)
|
|
318
318
|
const state1 = createState()
|
|
319
319
|
|
|
320
|
-
// First mount
|
|
320
|
+
// First mount: load and dispose
|
|
321
321
|
resolveCtx(state1, { url: 'http://localhost/a', cacheKey: 'a:0', loaderCache: cache })
|
|
322
322
|
state1.commitResult('cached-data')
|
|
323
323
|
state1.dispose(cache) // saves to cache
|
|
@@ -386,13 +386,13 @@ describe('PageLoaderState', () => {
|
|
|
386
386
|
})
|
|
387
387
|
|
|
388
388
|
describe('no loader cache', () => {
|
|
389
|
-
it('works without loaderCache
|
|
389
|
+
it('works without loaderCache: no stale caching', () => {
|
|
390
390
|
const state = createState()
|
|
391
391
|
|
|
392
392
|
resolveCtx(state, { url: 'http://localhost/a', cacheKey: 'a:0' })
|
|
393
393
|
state.commitResult('data-a')
|
|
394
394
|
|
|
395
|
-
// URL change without cache
|
|
395
|
+
// URL change without cache: no restore
|
|
396
396
|
const instruction = resolveCtx(state, { url: 'http://localhost/b', cacheKey: 'b:0' })
|
|
397
397
|
expect(instruction.action).toBe('suspend')
|
|
398
398
|
})
|
|
@@ -39,7 +39,7 @@ describe('createBrowserNavigationAdapter', () => {
|
|
|
39
39
|
/* The case actually seen in the wild: the getter returns the empty string on a
|
|
40
40
|
document that is not fully active, which is what Safari handed us. `new
|
|
41
41
|
URL('')` throws just as `new URL(null)` does, and it throws out of the
|
|
42
|
-
`Router` constructor during render
|
|
42
|
+
`Router` constructor during render: a blank page, not a degraded route. An
|
|
43
43
|
empty string is falsy but not nullish, so this is the case `??` would miss. */
|
|
44
44
|
it('falls back to the document URL when the entry reports an empty URL', () => {
|
|
45
45
|
const adapter = withNavigation({ url: '' })
|
|
@@ -50,7 +50,7 @@ describe('createBrowserNavigationAdapter', () => {
|
|
|
50
50
|
/* `url` is also typed nullable: the getter returns null for entries whose
|
|
51
51
|
document is not the current one and was fetched under a `no-referrer` or
|
|
52
52
|
`origin` referrer policy. That branch needs `sameDocument` to be false, which
|
|
53
|
-
`currentEntry` never is, so it should not be reachable through this call
|
|
53
|
+
`currentEntry` never is, so it should not be reachable through this call,
|
|
54
54
|
but the type permits it and the fallback costs nothing. */
|
|
55
55
|
it('falls back to the document URL when the entry reports no URL', () => {
|
|
56
56
|
const adapter = withNavigation({ url: null })
|
|
@@ -563,7 +563,7 @@ describe('Page loaders', () => {
|
|
|
563
563
|
})
|
|
564
564
|
await flushNav()
|
|
565
565
|
|
|
566
|
-
// No Suspense fallback
|
|
566
|
+
// No Suspense fallback: the stale render stays up until the reload lands
|
|
567
567
|
expect(container.textContent).toBe('loads:1')
|
|
568
568
|
|
|
569
569
|
await act(async () => {
|
|
@@ -672,7 +672,7 @@ describe('Outlet', () => {
|
|
|
672
672
|
{
|
|
673
673
|
path: '/admin',
|
|
674
674
|
isLayout: true,
|
|
675
|
-
// No component
|
|
675
|
+
// No component: should be skipped
|
|
676
676
|
children: [
|
|
677
677
|
{ path: '/dashboard', component: () => <div>Dashboard</div> },
|
|
678
678
|
],
|
|
@@ -952,7 +952,7 @@ describe('useBlocker', () => {
|
|
|
952
952
|
|
|
953
953
|
expect(getByTestId('state').textContent).toBe('idle')
|
|
954
954
|
|
|
955
|
-
// Try to navigate
|
|
955
|
+
// Try to navigate: should be blocked
|
|
956
956
|
router.navigate('/about')
|
|
957
957
|
await flushNav()
|
|
958
958
|
|
|
@@ -1123,7 +1123,7 @@ describe('ErrorBoundary', () => {
|
|
|
1123
1123
|
|
|
1124
1124
|
expect(container.textContent).toContain('Route error: boom')
|
|
1125
1125
|
|
|
1126
|
-
// Change resetKey and stop throwing
|
|
1126
|
+
// Change resetKey and stop throwing: should reset
|
|
1127
1127
|
rerender(
|
|
1128
1128
|
<ErrorBoundary resetKey="b">
|
|
1129
1129
|
<MaybeThrow shouldThrow={false} />
|
|
@@ -307,7 +307,7 @@ describe('Router same-URL navigation', () => {
|
|
|
307
307
|
expect(count()).toBe(1)
|
|
308
308
|
})
|
|
309
309
|
|
|
310
|
-
it('does not invalidate when the URL changes
|
|
310
|
+
it('does not invalidate when the URL changes: the href-keyed loader reloads on its own', () => {
|
|
311
311
|
const { router, count } = createCountingRouter('http://localhost/')
|
|
312
312
|
|
|
313
313
|
router.navigate('/list')
|
|
@@ -408,7 +408,7 @@ describe('Router focus reset', () => {
|
|
|
408
408
|
try {
|
|
409
409
|
const { router, captured } = createCapturingRouter('http://localhost/list')
|
|
410
410
|
|
|
411
|
-
// No route matches
|
|
411
|
+
// No route matches: the router bails out before intercepting
|
|
412
412
|
router.navigate('/unmatched', { focusReset: 'manual' })
|
|
413
413
|
expect(captured).toHaveLength(0)
|
|
414
414
|
|
|
@@ -29,7 +29,7 @@ describe('createPage route type safety', () => {
|
|
|
29
29
|
})
|
|
30
30
|
|
|
31
31
|
it('rejects dynamic route without params', () => {
|
|
32
|
-
// @ts-expect-error
|
|
32
|
+
// @ts-expect-error: :id in route but no .params() defined
|
|
33
33
|
const _page = createPage().route('/users/:id').render(() => null)
|
|
34
34
|
expect(true).toBe(true)
|
|
35
35
|
})
|
|
@@ -45,7 +45,7 @@ describe('createPage route type safety', () => {
|
|
|
45
45
|
it('rejects dynamic route when param is not in schema', () => {
|
|
46
46
|
const _page = createPage()
|
|
47
47
|
.params(mockSchema<{ userId: string }>())
|
|
48
|
-
// @ts-expect-error
|
|
48
|
+
// @ts-expect-error: :id is not a key of { userId: string }
|
|
49
49
|
.route('/users/:id')
|
|
50
50
|
.render(({ params }) => null)
|
|
51
51
|
expect(true).toBe(true)
|
|
@@ -62,7 +62,7 @@ describe('createPage route type safety', () => {
|
|
|
62
62
|
it('rejects when one route param is missing from schema', () => {
|
|
63
63
|
const _page = createPage()
|
|
64
64
|
.params(mockSchema<{ userId: string }>())
|
|
65
|
-
// @ts-expect-error
|
|
65
|
+
// @ts-expect-error: :postId is not in { userId: string }
|
|
66
66
|
.route('/users/:userId/posts/:postId')
|
|
67
67
|
.render(({ params }) => null)
|
|
68
68
|
expect(true).toBe(true)
|
|
@@ -97,14 +97,14 @@ describe('createPage catch type safety', () => {
|
|
|
97
97
|
|
|
98
98
|
it('no .catch() after .catch()', () => {
|
|
99
99
|
const withCatch = createPage().catch(({ error }) => <div>{error.message}</div>)
|
|
100
|
-
// @ts-expect-error
|
|
100
|
+
// @ts-expect-error: .catch() should not be available after .catch()
|
|
101
101
|
withCatch.catch
|
|
102
102
|
expect(true).toBe(true)
|
|
103
103
|
})
|
|
104
104
|
|
|
105
105
|
it('no .loader() after .catch()', () => {
|
|
106
106
|
const withCatch = createPage().catch(({ error }) => <div>{error.message}</div>)
|
|
107
|
-
// @ts-expect-error
|
|
107
|
+
// @ts-expect-error: .loader() should not be available after .catch()
|
|
108
108
|
withCatch.loader
|
|
109
109
|
expect(true).toBe(true)
|
|
110
110
|
})
|
|
@@ -125,7 +125,7 @@ describe('createPage catch type safety', () => {
|
|
|
125
125
|
it('.catch() handler has empty params when no schema defined', () => {
|
|
126
126
|
const _page = createPage()
|
|
127
127
|
.catch(({ params }) => {
|
|
128
|
-
// @ts-expect-error
|
|
128
|
+
// @ts-expect-error: no params defined, accessing unknown key
|
|
129
129
|
params.anything
|
|
130
130
|
return null
|
|
131
131
|
})
|
|
@@ -144,7 +144,7 @@ describe('LinkProps type safety', () => {
|
|
|
144
144
|
})
|
|
145
145
|
|
|
146
146
|
it('static page rejects params', () => {
|
|
147
|
-
// @ts-expect-error
|
|
147
|
+
// @ts-expect-error: static page should not accept params
|
|
148
148
|
const _a: LinkProps<'about'> = { to: 'about', params: { foo: 'bar' } }
|
|
149
149
|
expect(true).toBe(true)
|
|
150
150
|
})
|
|
@@ -152,13 +152,13 @@ describe('LinkProps type safety', () => {
|
|
|
152
152
|
it('dynamic page requires params', () => {
|
|
153
153
|
const _a: LinkProps<'users/detail'> = { to: 'users/detail', params: { userId: '1' } }
|
|
154
154
|
|
|
155
|
-
// @ts-expect-error
|
|
155
|
+
// @ts-expect-error: missing required params
|
|
156
156
|
const _b: LinkProps<'users/detail'> = { to: 'users/detail' }
|
|
157
157
|
expect(true).toBe(true)
|
|
158
158
|
})
|
|
159
159
|
|
|
160
160
|
it('dynamic page requires correct param names', () => {
|
|
161
|
-
// @ts-expect-error
|
|
161
|
+
// @ts-expect-error: wrong param name
|
|
162
162
|
const _a: LinkProps<'users/detail'> = { to: 'users/detail', params: { id: '1' } }
|
|
163
163
|
expect(true).toBe(true)
|
|
164
164
|
})
|
|
@@ -171,14 +171,14 @@ describe('LinkProps type safety', () => {
|
|
|
171
171
|
|
|
172
172
|
const _b: LinkProps<'users/posts'> = {
|
|
173
173
|
to: 'users/posts',
|
|
174
|
-
// @ts-expect-error
|
|
174
|
+
// @ts-expect-error: missing postId
|
|
175
175
|
params: { userId: '1' },
|
|
176
176
|
}
|
|
177
177
|
expect(true).toBe(true)
|
|
178
178
|
})
|
|
179
179
|
|
|
180
180
|
it('rejects unregistered pages', () => {
|
|
181
|
-
// @ts-expect-error
|
|
181
|
+
// @ts-expect-error: page not in BuzolaPageMap
|
|
182
182
|
const _a: LinkProps<'unknown'> = { to: 'unknown' }
|
|
183
183
|
expect(true).toBe(true)
|
|
184
184
|
})
|
|
@@ -196,7 +196,7 @@ describe('Link JSX generic inference', () => {
|
|
|
196
196
|
})
|
|
197
197
|
|
|
198
198
|
it('rejects params on static page', () => {
|
|
199
|
-
// @ts-expect-error
|
|
199
|
+
// @ts-expect-error: static page should not accept params
|
|
200
200
|
const _el = <Link to="about" params={{ foo: 'bar' }}>About</Link>
|
|
201
201
|
expect(true).toBe(true)
|
|
202
202
|
})
|
|
@@ -204,13 +204,13 @@ describe('Link JSX generic inference', () => {
|
|
|
204
204
|
it('requires params on dynamic page', () => {
|
|
205
205
|
const _el = <Link to="users/detail" params={{ userId: '1' }}>User</Link>
|
|
206
206
|
|
|
207
|
-
// @ts-expect-error
|
|
207
|
+
// @ts-expect-error: missing required params
|
|
208
208
|
const _el2 = <Link to="users/detail">User</Link>
|
|
209
209
|
expect(true).toBe(true)
|
|
210
210
|
})
|
|
211
211
|
|
|
212
212
|
it('rejects wrong param names', () => {
|
|
213
|
-
// @ts-expect-error
|
|
213
|
+
// @ts-expect-error: wrong param name
|
|
214
214
|
const _el = <Link to="users/detail" params={{ id: '1' }}>User</Link>
|
|
215
215
|
expect(true).toBe(true)
|
|
216
216
|
})
|
|
@@ -218,13 +218,13 @@ describe('Link JSX generic inference', () => {
|
|
|
218
218
|
it('requires all params for multi-param page', () => {
|
|
219
219
|
const _ok = <Link to="users/posts" params={{ userId: '1', postId: '2' }}>Post</Link>
|
|
220
220
|
|
|
221
|
-
// @ts-expect-error
|
|
221
|
+
// @ts-expect-error: missing postId
|
|
222
222
|
const _bad = <Link to="users/posts" params={{ userId: '1' }}>Post</Link>
|
|
223
223
|
expect(true).toBe(true)
|
|
224
224
|
})
|
|
225
225
|
|
|
226
226
|
it('rejects unregistered pages', () => {
|
|
227
|
-
// @ts-expect-error
|
|
227
|
+
// @ts-expect-error: page not in BuzolaPageMap
|
|
228
228
|
const _el = <Link to="nope">Nope</Link>
|
|
229
229
|
expect(true).toBe(true)
|
|
230
230
|
})
|
|
@@ -258,7 +258,7 @@ describe('NavigateFn type safety', () => {
|
|
|
258
258
|
})
|
|
259
259
|
|
|
260
260
|
it('static page rejects params', () => {
|
|
261
|
-
// @ts-expect-error
|
|
261
|
+
// @ts-expect-error: static page should not accept params
|
|
262
262
|
noop('home', { params: { foo: 'bar' } })
|
|
263
263
|
expect(true).toBe(true)
|
|
264
264
|
})
|
|
@@ -266,19 +266,19 @@ describe('NavigateFn type safety', () => {
|
|
|
266
266
|
it('dynamic page requires options with params', () => {
|
|
267
267
|
noop('users/detail', { params: { userId: '1' } })
|
|
268
268
|
|
|
269
|
-
// @ts-expect-error
|
|
269
|
+
// @ts-expect-error: missing required options with params
|
|
270
270
|
noop('users/detail')
|
|
271
271
|
expect(true).toBe(true)
|
|
272
272
|
})
|
|
273
273
|
|
|
274
274
|
it('dynamic page requires correct param names', () => {
|
|
275
|
-
// @ts-expect-error
|
|
275
|
+
// @ts-expect-error: wrong param name (id instead of userId)
|
|
276
276
|
noop('users/detail', { params: { id: '1' } })
|
|
277
277
|
expect(true).toBe(true)
|
|
278
278
|
})
|
|
279
279
|
|
|
280
280
|
it('dynamic page rejects params passed directly as options (without params wrapper)', () => {
|
|
281
|
-
// @ts-expect-error
|
|
281
|
+
// @ts-expect-error: { userId: '1' } is not valid, must be { params: { userId: '1' } }
|
|
282
282
|
noop('users/detail', { userId: '1' })
|
|
283
283
|
expect(true).toBe(true)
|
|
284
284
|
})
|
|
@@ -286,13 +286,13 @@ describe('NavigateFn type safety', () => {
|
|
|
286
286
|
it('multi-param page requires all params', () => {
|
|
287
287
|
noop('users/posts', { params: { userId: '1', postId: '2' } })
|
|
288
288
|
|
|
289
|
-
// @ts-expect-error
|
|
289
|
+
// @ts-expect-error: missing postId
|
|
290
290
|
noop('users/posts', { params: { userId: '1' } })
|
|
291
291
|
expect(true).toBe(true)
|
|
292
292
|
})
|
|
293
293
|
|
|
294
294
|
it('rejects unregistered pages', () => {
|
|
295
|
-
// @ts-expect-error
|
|
295
|
+
// @ts-expect-error: page not in BuzolaPageMap
|
|
296
296
|
noop('unknown')
|
|
297
297
|
expect(true).toBe(true)
|
|
298
298
|
})
|
|
@@ -156,7 +156,7 @@ function combineLoaders(loaders: LoaderFn[]): LoaderFn | undefined {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
/**
|
|
159
|
-
* Follow a loader redirect, unless it points at the URL the page is already on
|
|
159
|
+
* Follow a loader redirect, unless it points at the URL the page is already on:
|
|
160
160
|
* that navigation re-runs the loader, which returns the same redirect, forever.
|
|
161
161
|
*/
|
|
162
162
|
function followRedirect(router: Router | null, target: BuzolaRedirect): void {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Global LRU cache for page loader data.
|
|
3
3
|
* Shared across all page components to limit total memory usage.
|
|
4
|
-
* Uses Map insertion order for LRU eviction
|
|
4
|
+
* Uses Map insertion order for LRU eviction: oldest entries are evicted first.
|
|
5
5
|
*/
|
|
6
6
|
export class LoaderCache {
|
|
7
7
|
private cache = new Map<string, unknown>()
|
|
@@ -8,7 +8,7 @@ export type LoaderInstruction =
|
|
|
8
8
|
| { action: 'suspend'; promise: Promise<unknown> }
|
|
9
9
|
|
|
10
10
|
export interface LoaderResolveContext {
|
|
11
|
-
/** Current URL href
|
|
11
|
+
/** Current URL href: used for stale cache keying */
|
|
12
12
|
currentUrlHref: string
|
|
13
13
|
/** Cache key that changes on URL change or invalidation */
|
|
14
14
|
cacheKey: string
|
|
@@ -31,7 +31,7 @@ let nextId = 0
|
|
|
31
31
|
/**
|
|
32
32
|
* Manages loader data lifecycle outside of React's render cycle.
|
|
33
33
|
*
|
|
34
|
-
* This state survives React Suspense retries
|
|
34
|
+
* This state survives React Suspense retries. When use() suspends, React
|
|
35
35
|
* discards uncommitted hook state (useRef, useState, useMemo). Closure/instance
|
|
36
36
|
* state is preserved, preventing infinite suspend loops from promise recreation.
|
|
37
37
|
*
|
|
@@ -43,7 +43,7 @@ export class PageLoaderState {
|
|
|
43
43
|
private activeCacheKey: string | null = null
|
|
44
44
|
private activePromise: Promise<unknown> | null = null
|
|
45
45
|
private activeResolvedData: unknown
|
|
46
|
-
/** URL the currently held data was loaded for
|
|
46
|
+
/** URL the currently held data was loaded for: data is cached under this href, not the active one. */
|
|
47
47
|
private resolvedUrlHref: string | null = null
|
|
48
48
|
private hasResolved = false
|
|
49
49
|
private pendingBackgroundKey: string | null = null
|
|
@@ -62,7 +62,7 @@ export class PageLoaderState {
|
|
|
62
62
|
|
|
63
63
|
this.handleUrlChange(currentUrlHref, loaderCache)
|
|
64
64
|
|
|
65
|
-
// ─── Same cache key
|
|
65
|
+
// ─── Same cache key: return current state ─────
|
|
66
66
|
if (this.activeCacheKey === cacheKey) {
|
|
67
67
|
if (this.pendingBackgroundKey === cacheKey) {
|
|
68
68
|
return { action: 'render', data: this.activeResolvedData, isLoading: true }
|
|
@@ -110,14 +110,14 @@ export class PageLoaderState {
|
|
|
110
110
|
this.hasResolved = true
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
/** Commit background SWR success
|
|
113
|
+
/** Commit background SWR success: update data and clear pending flag */
|
|
114
114
|
commitBackgroundResult(data: unknown): void {
|
|
115
115
|
this.activeResolvedData = data
|
|
116
116
|
this.resolvedUrlHref = this.activeUrlHref
|
|
117
117
|
this.pendingBackgroundKey = null
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
/** Mark background load as failed
|
|
120
|
+
/** Mark background load as failed: next render will suspend with rejected promise */
|
|
121
121
|
commitBackgroundError(): void {
|
|
122
122
|
this.hasResolved = false
|
|
123
123
|
this.pendingBackgroundKey = null
|
|
@@ -141,7 +141,7 @@ export class PageLoaderState {
|
|
|
141
141
|
|
|
142
142
|
private handleUrlChange(currentUrlHref: string, loaderCache: LoaderCache | undefined): void {
|
|
143
143
|
if (this.activeUrlHref !== null && this.activeUrlHref !== currentUrlHref) {
|
|
144
|
-
// URL changed
|
|
144
|
+
// URL changed: save the held data to stale cache under the href it was loaded for
|
|
145
145
|
if (loaderCache && this.hasResolved && this.activeResolvedData !== undefined && this.resolvedUrlHref) {
|
|
146
146
|
this.saveToStaleCache(loaderCache, this.resolvedUrlHref, this.activeResolvedData)
|
|
147
147
|
}
|
|
@@ -152,7 +152,7 @@ export class PageLoaderState {
|
|
|
152
152
|
this.resolvedUrlHref = currentUrlHref
|
|
153
153
|
this.hasResolved = true
|
|
154
154
|
} else if (!isSamePath(this.activeUrlHref, currentUrlHref)) {
|
|
155
|
-
// Different path
|
|
155
|
+
// Different path: the held data describes another page, so drop it and suspend
|
|
156
156
|
this.hasResolved = false
|
|
157
157
|
this.activeResolvedData = undefined
|
|
158
158
|
this.resolvedUrlHref = null
|
|
@@ -161,7 +161,7 @@ export class PageLoaderState {
|
|
|
161
161
|
// keep the data as stale so resolve() serves it while the new query loads.
|
|
162
162
|
this.pendingBackgroundKey = null
|
|
163
163
|
} else if (this.activeUrlHref === null && loaderCache) {
|
|
164
|
-
// Remount
|
|
164
|
+
// Remount: try to restore from stale cache
|
|
165
165
|
const cached = this.restoreFromStaleCache(loaderCache, currentUrlHref)
|
|
166
166
|
if (cached.hit) {
|
|
167
167
|
this.activeResolvedData = cached.value
|
package/src/engine/matcher.ts
CHANGED
|
@@ -20,7 +20,7 @@ function matchTrie(
|
|
|
20
20
|
values: string[],
|
|
21
21
|
pathname: string,
|
|
22
22
|
): RouteMatch[] | null {
|
|
23
|
-
// All segments consumed
|
|
23
|
+
// All segments consumed: check if this trie node has a terminal route
|
|
24
24
|
if (index === segments.length) {
|
|
25
25
|
if (node.route) {
|
|
26
26
|
return buildMatchChain(node.route, values, pathname)
|
|
@@ -30,20 +30,20 @@ function matchTrie(
|
|
|
30
30
|
|
|
31
31
|
const segment = segments[index]
|
|
32
32
|
|
|
33
|
-
// 1. Try static child first
|
|
33
|
+
// 1. Try static child first: O(1) Map lookup, always most specific
|
|
34
34
|
const staticChild = node.staticChildren.get(segment)
|
|
35
35
|
if (staticChild) {
|
|
36
36
|
const result = matchTrie(staticChild, segments, index + 1, values, pathname)
|
|
37
37
|
if (result) return result
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
// 2. Try dynamic child
|
|
40
|
+
// 2. Try dynamic child: matches any single segment
|
|
41
41
|
if (node.dynamicChild) {
|
|
42
42
|
const result = matchTrie(node.dynamicChild, segments, index + 1, [...values, decodeURIComponent(segment)], pathname)
|
|
43
43
|
if (result) return result
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// 3. Try catch-all child
|
|
46
|
+
// 3. Try catch-all child: consumes all remaining segments
|
|
47
47
|
if (node.catchAllChild) {
|
|
48
48
|
const remaining = segments.slice(index).map(s => decodeURIComponent(s)).join('/')
|
|
49
49
|
return buildMatchChain(node.catchAllChild, [...values, remaining], pathname)
|