@buzola/router 0.0.17 → 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 +6 -6
- 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 +31 -5
- package/dist/engine/router.d.ts.map +1 -1
- package/dist/engine/router.js +47 -10
- package/dist/engine/router.js.map +1 -1
- package/dist/engine/types.d.ts +28 -8
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/engine/utils.d.ts +8 -3
- package/dist/engine/utils.d.ts.map +1 -1
- package/dist/engine/utils.js +16 -3
- package/dist/engine/utils.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- 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/dist/react/provider.d.ts +4 -1
- package/dist/react/provider.d.ts.map +1 -1
- package/dist/react/provider.js +6 -0
- package/dist/react/provider.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 +70 -4
- package/src/__tests__/router.test.ts +223 -4
- 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 +6 -6
- package/src/engine/route-tree.ts +3 -3
- package/src/engine/router.ts +59 -11
- package/src/engine/types.ts +30 -8
- package/src/engine/utils.ts +16 -3
- package/src/index.ts +2 -0
- package/src/react/hooks.ts +2 -2
- package/src/react/link.tsx +5 -5
- package/src/react/outlet.tsx +2 -2
- package/src/react/provider.tsx +10 -2
package/src/engine/router.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { LoaderCache } from './loader-cache.js'
|
|
2
2
|
import { matchRoutes } from './matcher.js'
|
|
3
|
-
import type { BlockerFn, BuzolaNavigateEvent, NavigateOptions, NavigationAdapter, RouterState, RouteTree } from './types.js'
|
|
4
|
-
import { extractParamNames, isSamePath, parseParamSegment } from './utils.js'
|
|
3
|
+
import type { BlockerFn, BuzolaNavigateEvent, NavigateOptions, NavigationAdapter, RouterState, RouteTree, ShouldInterceptFn } from './types.js'
|
|
4
|
+
import { extractParamNames, isFragmentNavigation, isSamePath, parseParamSegment } from './utils.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Thrown when a route guard prevents navigation.
|
|
@@ -45,6 +45,16 @@ export interface RouterOptions {
|
|
|
45
45
|
* Set to 0 to always re-fetch on URL change (default).
|
|
46
46
|
*/
|
|
47
47
|
loaderCacheSize?: number
|
|
48
|
+
/**
|
|
49
|
+
* Decides, per navigation, whether the router handles it in the current document.
|
|
50
|
+
* Return `false` to have the browser load the destination as a new document instead,
|
|
51
|
+
* e.g. to pick up a newly deployed build on the next in-app navigation.
|
|
52
|
+
*
|
|
53
|
+
* Offered every push, replace and traverse the router would otherwise intercept, except
|
|
54
|
+
* fragment navigations. Defaults to intercepting everything. See the README for how
|
|
55
|
+
* traversals and blockers are handled.
|
|
56
|
+
*/
|
|
57
|
+
shouldIntercept?: ShouldInterceptFn
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
/**
|
|
@@ -58,6 +68,7 @@ export class Router {
|
|
|
58
68
|
private readonly _basePath: string
|
|
59
69
|
private readonly persistentParamsFn?: () => Record<string, string>
|
|
60
70
|
private readonly pageRegistry: Record<string, string>
|
|
71
|
+
private readonly shouldIntercept: ShouldInterceptFn | undefined
|
|
61
72
|
private subscribers = new Set<RouterSubscriber>()
|
|
62
73
|
private invalidationListeners = new Set<() => void>()
|
|
63
74
|
private state: RouterState
|
|
@@ -77,6 +88,7 @@ export class Router {
|
|
|
77
88
|
this._basePath = options.basePath ? options.basePath.replace(/\/$/, '') : ''
|
|
78
89
|
this.persistentParamsFn = options.persistentParams
|
|
79
90
|
this.pageRegistry = options.pageRegistry ?? {}
|
|
91
|
+
this.shouldIntercept = options.shouldIntercept
|
|
80
92
|
this._loaderCache = options.loaderCacheSize ? new LoaderCache(options.loaderCacheSize) : undefined
|
|
81
93
|
|
|
82
94
|
// Initialize state from current URL
|
|
@@ -134,7 +146,7 @@ export class Router {
|
|
|
134
146
|
*/
|
|
135
147
|
start(): () => void {
|
|
136
148
|
const handler = (event: BuzolaNavigateEvent) => {
|
|
137
|
-
// Consume the pending options before any early return
|
|
149
|
+
// Consume the pending options before any early return: they belong to the
|
|
138
150
|
// navigation that armed them and must not leak into the next one.
|
|
139
151
|
const scrollBehavior = this.pendingScrollBehavior
|
|
140
152
|
const explicitFocusReset = this.pendingFocusReset
|
|
@@ -163,9 +175,24 @@ export class Router {
|
|
|
163
175
|
return
|
|
164
176
|
}
|
|
165
177
|
|
|
178
|
+
// The browser decides a fragment navigation against the committed entry, which runs ahead of the
|
|
179
|
+
// page on screen while a navigation is pending (e.g. a blocker dialog is open).
|
|
180
|
+
if (this.shouldIntercept && !isFragmentNavigation(this.adapter.getCurrentURL(), url)) {
|
|
181
|
+
const intercept = this.shouldIntercept({
|
|
182
|
+
destination: url,
|
|
183
|
+
from: this.state.location,
|
|
184
|
+
navigationType: event.navigationType,
|
|
185
|
+
userInitiated: event.userInitiated,
|
|
186
|
+
})
|
|
187
|
+
if (!intercept) {
|
|
188
|
+
this.handleDeclined(event)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
166
193
|
const currentNavId = ++this.navigationId
|
|
167
194
|
const useViewTransition = event.viewTransition || this.viewTransitions
|
|
168
|
-
// Query-only navigations stay on the same page
|
|
195
|
+
// Query-only navigations stay on the same page, so keep focus where the user left it
|
|
169
196
|
// (search boxes, filters, pagination). Path changes keep the browser's focus reset,
|
|
170
197
|
// and so do fragment changes, which must move focus to the targeted section.
|
|
171
198
|
const focusReset = explicitFocusReset
|
|
@@ -191,7 +218,7 @@ export class Router {
|
|
|
191
218
|
pendingLocation: url,
|
|
192
219
|
})
|
|
193
220
|
|
|
194
|
-
// A newer navigation has started
|
|
221
|
+
// A newer navigation has started, so abandon this one
|
|
195
222
|
if (this.navigationId !== currentNavId) return
|
|
196
223
|
|
|
197
224
|
// Perform the DOM update
|
|
@@ -204,7 +231,7 @@ export class Router {
|
|
|
204
231
|
pendingLocation: undefined,
|
|
205
232
|
navigationState: this.adapter.getState(),
|
|
206
233
|
})
|
|
207
|
-
// Going to the URL we are already on is still a navigation
|
|
234
|
+
// Going to the URL we are already on is still a navigation: a browser
|
|
208
235
|
// re-requests the document for it. Loader data is keyed by href, so it
|
|
209
236
|
// would silently keep serving stale data without an explicit invalidation.
|
|
210
237
|
if (previousHref === url.href) {
|
|
@@ -240,7 +267,28 @@ export class Router {
|
|
|
240
267
|
*/
|
|
241
268
|
private handleReload(event: BuzolaNavigateEvent): void {
|
|
242
269
|
if (this.blockers.size === 0) return
|
|
270
|
+
this.reloadAfterBlockers(event)
|
|
271
|
+
}
|
|
243
272
|
|
|
273
|
+
/**
|
|
274
|
+
* `shouldIntercept` declined this navigation, so the destination must load as a new document.
|
|
275
|
+
*
|
|
276
|
+
* A push or replace is simply not intercepted, and the browser performs the document navigation itself.
|
|
277
|
+
* That does not work for a traversal, which the browser completes within the document, nor while
|
|
278
|
+
* blockers are registered, since they are async. Those are intercepted, which commits the destination
|
|
279
|
+
* entry, and that entry is reloaded once the blockers allow it.
|
|
280
|
+
*/
|
|
281
|
+
private handleDeclined(event: BuzolaNavigateEvent): void {
|
|
282
|
+
if (event.navigationType !== 'traverse' && this.blockers.size === 0) return
|
|
283
|
+
this.reloadAfterBlockers(event)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Hold the navigation while the blockers run and, if they allow it, reload the current entry,
|
|
288
|
+
* which is already the navigation's destination, because an intercepted navigation commits before
|
|
289
|
+
* its handler runs.
|
|
290
|
+
*/
|
|
291
|
+
private reloadAfterBlockers(event: BuzolaNavigateEvent): void {
|
|
244
292
|
const currentNavId = ++this.navigationId
|
|
245
293
|
event.intercept({
|
|
246
294
|
scroll: 'manual',
|
|
@@ -261,7 +309,7 @@ export class Router {
|
|
|
261
309
|
}
|
|
262
310
|
|
|
263
311
|
/**
|
|
264
|
-
* Hand one navigation to the browser instead of intercepting it, for a URL the SERVER owns
|
|
312
|
+
* Hand one navigation to the browser instead of intercepting it, for a URL the SERVER owns:
|
|
265
313
|
* an auth handoff, a file download, a server-rendered page that happens to share this origin.
|
|
266
314
|
*
|
|
267
315
|
* The Navigation API offers no way for a caller to mark a navigation un-interceptable:
|
|
@@ -271,7 +319,7 @@ export class Router {
|
|
|
271
319
|
*
|
|
272
320
|
* The release is recorded BEFORE the navigation is requested and is keyed to the resolved URL, so
|
|
273
321
|
* it cannot race the event or release some other navigation. Unlike `navigate`, the URL is passed
|
|
274
|
-
* to the browser as given
|
|
322
|
+
* to the browser as given: no base path is prepended, because the target is outside the app.
|
|
275
323
|
*/
|
|
276
324
|
leaveApp(url: string): void {
|
|
277
325
|
this.releasedURL = new URL(url, this.adapter.getCurrentURL()).href
|
|
@@ -280,7 +328,7 @@ export class Router {
|
|
|
280
328
|
|
|
281
329
|
/**
|
|
282
330
|
* Navigate programmatically.
|
|
283
|
-
* The `to` path should not include the base path
|
|
331
|
+
* The `to` path should not include the base path. It is prepended automatically.
|
|
284
332
|
*/
|
|
285
333
|
navigate(to: string, options?: NavigateOptions): void {
|
|
286
334
|
if (options?.resetScroll === false) {
|
|
@@ -361,7 +409,7 @@ export class Router {
|
|
|
361
409
|
missing.push(paramName)
|
|
362
410
|
} else {
|
|
363
411
|
if (isCatchAll) {
|
|
364
|
-
// Catch-all values may contain slashes
|
|
412
|
+
// Catch-all values may contain slashes: insert raw
|
|
365
413
|
result.push(value)
|
|
366
414
|
} else {
|
|
367
415
|
result.push(encodeURIComponent(value))
|
|
@@ -485,7 +533,7 @@ export class Router {
|
|
|
485
533
|
}
|
|
486
534
|
|
|
487
535
|
/**
|
|
488
|
-
* Dispose the router
|
|
536
|
+
* Dispose the router: clear all subscribers and reset state.
|
|
489
537
|
*/
|
|
490
538
|
dispose(): void {
|
|
491
539
|
this.stopFn?.()
|
package/src/engine/types.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type RegisteredPage = keyof BuzolaPageMap & string
|
|
|
29
29
|
export type PageParams<P extends keyof BuzolaPageMap> = BuzolaPageMap[P]
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Effective params for a page
|
|
32
|
+
* Effective params for a page: persistent params become optional.
|
|
33
33
|
* When BuzolaPersistentParams is empty (default), collapses to BuzolaPageMap[P].
|
|
34
34
|
*/
|
|
35
35
|
export type EffectivePageParams<P extends keyof BuzolaPageMap> =
|
|
@@ -50,7 +50,7 @@ export interface StandardSchema<T = unknown> {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/** Route component
|
|
53
|
+
/** Route component: either a regular component or a lazy-loaded one. */
|
|
54
54
|
export type RouteComponent = ComponentType<Record<string, never>> | LazyExoticComponent<ComponentType<Record<string, never>>>
|
|
55
55
|
|
|
56
56
|
/** Configuration for a single route. */
|
|
@@ -63,7 +63,7 @@ export interface RouteConfig {
|
|
|
63
63
|
component?: RouteComponent
|
|
64
64
|
/** Schema for validating search params (Standard Schema compatible). */
|
|
65
65
|
searchSchema?: StandardSchema
|
|
66
|
-
/** Preload function
|
|
66
|
+
/** Preload function: triggers lazy component import ahead of navigation. */
|
|
67
67
|
preload?: () => void
|
|
68
68
|
/** Child routes. */
|
|
69
69
|
children?: RouteConfig[]
|
|
@@ -71,7 +71,7 @@ export interface RouteConfig {
|
|
|
71
71
|
isLayout?: boolean
|
|
72
72
|
/** Whether this is an index route (renders at parent's exact path). */
|
|
73
73
|
isIndex?: boolean
|
|
74
|
-
/** Whether this is a pathless group (e.g., (auth)
|
|
74
|
+
/** Whether this is a pathless group (e.g., (auth), with no URL segment). */
|
|
75
75
|
isPathlessGroup?: boolean
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -101,7 +101,7 @@ export interface RouteNode {
|
|
|
101
101
|
component?: RouteComponent
|
|
102
102
|
/** Search params schema. */
|
|
103
103
|
searchSchema?: StandardSchema
|
|
104
|
-
/** Preload function
|
|
104
|
+
/** Preload function: triggers lazy component import ahead of navigation. */
|
|
105
105
|
preload?: () => void
|
|
106
106
|
/** Whether this is a layout node (renders children via Outlet). */
|
|
107
107
|
isLayout: boolean
|
|
@@ -113,7 +113,7 @@ export interface RouteNode {
|
|
|
113
113
|
|
|
114
114
|
/** A node in the segment trie used for URL matching. */
|
|
115
115
|
export interface TrieNode {
|
|
116
|
-
/** Static children
|
|
116
|
+
/** Static children: exact segment match via Map lookup (O(1)). */
|
|
117
117
|
staticChildren: Map<string, TrieNode>
|
|
118
118
|
/**
|
|
119
119
|
* Dynamic child: matches any single segment. It carries no param name, because routes sharing this
|
|
@@ -174,8 +174,8 @@ export interface NavigateOptions {
|
|
|
174
174
|
resetScroll?: boolean
|
|
175
175
|
/**
|
|
176
176
|
* Whether the browser moves focus after navigation.
|
|
177
|
-
* Defaults to `manual` when only the search string changes
|
|
178
|
-
* pagination controls keep the caret
|
|
177
|
+
* Defaults to `manual` when only the search string changes (so search boxes, filters and
|
|
178
|
+
* pagination controls keep the caret), and to `after-transition` when the path or the hash
|
|
179
179
|
* changes, so fragment links move focus to the section they target.
|
|
180
180
|
*/
|
|
181
181
|
focusReset?: 'after-transition' | 'manual'
|
|
@@ -188,6 +188,28 @@ export interface NavigateOptions {
|
|
|
188
188
|
*/
|
|
189
189
|
export type BlockerFn = () => Promise<boolean>
|
|
190
190
|
|
|
191
|
+
/** A navigation the router is about to intercept, as offered to `shouldIntercept`. */
|
|
192
|
+
export interface InterceptInfo {
|
|
193
|
+
/** Where the navigation goes. */
|
|
194
|
+
destination: URL
|
|
195
|
+
/**
|
|
196
|
+
* The location the router currently shows. While a navigation is pending, this can differ from
|
|
197
|
+
* `navigation.currentEntry`, which has already moved to that navigation's destination.
|
|
198
|
+
*/
|
|
199
|
+
from: URL
|
|
200
|
+
/** How the navigation moves through history. Reloads always go to the browser and are never offered. */
|
|
201
|
+
navigationType: 'push' | 'replace' | 'traverse'
|
|
202
|
+
/** Whether the user initiated the navigation (e.g., clicking a link). */
|
|
203
|
+
userInitiated: boolean
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Decides whether the router handles a navigation in the current document.
|
|
208
|
+
* Return `false` to have the browser load the destination as a new document instead.
|
|
209
|
+
* Called synchronously from the `navigate` event, so it cannot await anything.
|
|
210
|
+
*/
|
|
211
|
+
export type ShouldInterceptFn = (info: InterceptInfo) => boolean
|
|
212
|
+
|
|
191
213
|
/** Navigate event from the Navigation API (simplified). */
|
|
192
214
|
export interface BuzolaNavigateEvent {
|
|
193
215
|
/** The destination URL. */
|
package/src/engine/utils.ts
CHANGED
|
@@ -24,15 +24,28 @@ export function extractParamNames(pattern: string): string[] {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Whether two URLs address the same page
|
|
27
|
+
* Whether two URLs address the same page, i.e. only search/hash differ.
|
|
28
28
|
* Same-path navigations (filters, sorting, pagination, search) keep the page
|
|
29
29
|
* on screen and keep its loader data as stale; path changes do not.
|
|
30
30
|
*
|
|
31
|
-
* Note this ignores the hash. Callers that care about fragment changes
|
|
32
|
-
* handling, for one
|
|
31
|
+
* Note this ignores the hash. Callers that care about fragment changes (focus
|
|
32
|
+
* handling, for one) have to compare `hash` themselves.
|
|
33
33
|
*/
|
|
34
34
|
export function isSamePath(a: string | URL, b: string | URL): boolean {
|
|
35
35
|
const urlA = typeof a === 'string' ? new URL(a) : a
|
|
36
36
|
const urlB = typeof b === 'string' ? new URL(b) : b
|
|
37
37
|
return urlA.origin === urlB.origin && urlA.pathname === urlB.pathname
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether going from `from` to `to` is a fragment navigation: the URLs differ at most in the hash and `to`
|
|
42
|
+
* has one. Browsers always perform those within the current document, whether intercepted or not.
|
|
43
|
+
*/
|
|
44
|
+
export function isFragmentNavigation(from: URL, to: URL): boolean {
|
|
45
|
+
if (to.hash === '') return false
|
|
46
|
+
const fromWithoutHash = new URL(from)
|
|
47
|
+
fromWithoutHash.hash = ''
|
|
48
|
+
const toWithoutHash = new URL(to)
|
|
49
|
+
toWithoutHash.hash = ''
|
|
50
|
+
return fromWithoutHash.href === toWithoutHash.href
|
|
51
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type {
|
|
|
19
19
|
BuzolaPageMap,
|
|
20
20
|
BuzolaPersistentParams,
|
|
21
21
|
EffectivePageParams,
|
|
22
|
+
InterceptInfo,
|
|
22
23
|
NavigateOptions,
|
|
23
24
|
NavigationAdapter,
|
|
24
25
|
PageParams,
|
|
@@ -29,6 +30,7 @@ export type {
|
|
|
29
30
|
RouteNode,
|
|
30
31
|
RouterState,
|
|
31
32
|
RouteTree,
|
|
33
|
+
ShouldInterceptFn,
|
|
32
34
|
StandardSchema,
|
|
33
35
|
} from './engine/types.js'
|
|
34
36
|
|
package/src/react/hooks.ts
CHANGED
|
@@ -29,7 +29,7 @@ function useRouteContext() {
|
|
|
29
29
|
/**
|
|
30
30
|
* Type-safe navigation function.
|
|
31
31
|
* Uses page IDs instead of URL patterns.
|
|
32
|
-
* Persistent params are optional
|
|
32
|
+
* Persistent params are optional: resolved from current URL or fallback.
|
|
33
33
|
*/
|
|
34
34
|
type NavigateFn = <P extends RegisteredPage>(
|
|
35
35
|
to: P,
|
|
@@ -41,7 +41,7 @@ type NavigateFn = <P extends RegisteredPage>(
|
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Hook to get a type-safe navigate function.
|
|
44
|
-
* Navigates using page IDs
|
|
44
|
+
* Navigates using page IDs: the router looks up the route pattern from the page registry.
|
|
45
45
|
*/
|
|
46
46
|
export function useNavigate(): NavigateFn {
|
|
47
47
|
const router = useRouter()
|
package/src/react/link.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef } from 'react'
|
|
|
2
2
|
import type { EffectivePageParams, NavigateOptions, RegisteredPage } from '../engine/types.js'
|
|
3
3
|
import { useRouter, useRouterState } from './hooks.js'
|
|
4
4
|
|
|
5
|
-
// ─── Link props type
|
|
5
|
+
// ─── Link props type: always type-safe via page ID ───────────────────────────
|
|
6
6
|
|
|
7
7
|
type LinkPropsBase = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> & {
|
|
8
8
|
/** Replace the current history entry instead of pushing. */
|
|
@@ -29,9 +29,9 @@ type LinkPropsBase = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>
|
|
|
29
29
|
asChild?: boolean
|
|
30
30
|
/**
|
|
31
31
|
* Prefetch strategy for lazy-loaded route code.
|
|
32
|
-
* - `'intent'
|
|
33
|
-
* - `'render'
|
|
34
|
-
* - `'none'
|
|
32
|
+
* - `'intent'`: preload on mouseEnter/focus (default)
|
|
33
|
+
* - `'render'`: preload as soon as the Link renders
|
|
34
|
+
* - `'none'`: disable prefetching
|
|
35
35
|
*/
|
|
36
36
|
prefetch?: 'intent' | 'render' | 'none'
|
|
37
37
|
}
|
|
@@ -44,7 +44,7 @@ export type LinkProps<P extends RegisteredPage = RegisteredPage> = [keyof Effect
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* Type-safe link component.
|
|
47
|
-
* Uses page IDs for navigation
|
|
47
|
+
* Uses page IDs for navigation: the router looks up the route pattern from the page registry.
|
|
48
48
|
* For simple navigations, renders a native <a> and lets the Navigation API handle it.
|
|
49
49
|
* Uses programmatic navigation when state, replace, resetScroll, or viewTransition is needed.
|
|
50
50
|
*
|
package/src/react/outlet.tsx
CHANGED
|
@@ -12,7 +12,7 @@ function DefaultNotFound(): React.ReactElement {
|
|
|
12
12
|
},
|
|
13
13
|
isDev
|
|
14
14
|
? [
|
|
15
|
-
React.createElement('h1', { key: 'h' }, '404
|
|
15
|
+
React.createElement('h1', { key: 'h' }, '404: Page Not Found'),
|
|
16
16
|
React.createElement('p', { key: 'p1' }, `No route matches "${typeof window !== 'undefined' ? window.location.pathname : ''}".`),
|
|
17
17
|
React.createElement('p', {
|
|
18
18
|
key: 'p2',
|
|
@@ -74,7 +74,7 @@ export function Outlet({ fallback, errorFallback, notFound }: OutletProps): Reac
|
|
|
74
74
|
}, [state, depth, effectiveDepth, matches, match, routeContext.params])
|
|
75
75
|
|
|
76
76
|
if (!match) {
|
|
77
|
-
// At root depth with no matches
|
|
77
|
+
// At root depth with no matches: show 404
|
|
78
78
|
if (depth === 0 && matches.length === 0) {
|
|
79
79
|
if (notFound === null) return null
|
|
80
80
|
return (notFound ?? React.createElement(DefaultNotFound, null)) as React.ReactElement
|
package/src/react/provider.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react'
|
|
2
2
|
import { createBrowserNavigationAdapter } from '../engine/navigation-adapter.js'
|
|
3
3
|
import { Router } from '../engine/router.js'
|
|
4
|
-
import type { RouteMatch, RouteTree } from '../engine/types.js'
|
|
4
|
+
import type { RouteMatch, RouteTree, ShouldInterceptFn } from '../engine/types.js'
|
|
5
5
|
import { RouteContext, type RouteContextValue, RouterContext } from './context.js'
|
|
6
6
|
import { Outlet } from './outlet.js'
|
|
7
7
|
|
|
@@ -25,12 +25,14 @@ export type BuzolaProviderProps =
|
|
|
25
25
|
middleware?: MiddlewareComponent
|
|
26
26
|
}
|
|
27
27
|
& (
|
|
28
|
-
| { router: Router; routes?: never; persistentParams?: never; pageRegistry?: never }
|
|
28
|
+
| { router: Router; routes?: never; persistentParams?: never; pageRegistry?: never; shouldIntercept?: never }
|
|
29
29
|
| {
|
|
30
30
|
router?: never
|
|
31
31
|
routes: RouteTree
|
|
32
32
|
persistentParams?: () => Record<string, string>
|
|
33
33
|
pageRegistry?: Record<string, string>
|
|
34
|
+
/** Decides per navigation whether the router handles it; see `RouterOptions.shouldIntercept`. */
|
|
35
|
+
shouldIntercept?: ShouldInterceptFn
|
|
34
36
|
}
|
|
35
37
|
)
|
|
36
38
|
|
|
@@ -39,6 +41,11 @@ export type BuzolaProviderProps =
|
|
|
39
41
|
* Subscribes to router state and provides context to the React tree.
|
|
40
42
|
*/
|
|
41
43
|
export function BuzolaProvider({ routes, children, middleware: Middleware, ...props }: BuzolaProviderProps): React.ReactElement {
|
|
44
|
+
// The router is created once, so it reads the latest prop through a ref rather than the first render's closure.
|
|
45
|
+
const shouldInterceptRef = useRef(props.shouldIntercept)
|
|
46
|
+
useLayoutEffect(() => {
|
|
47
|
+
shouldInterceptRef.current = props.shouldIntercept
|
|
48
|
+
})
|
|
42
49
|
const routerRef = useRef<Router | undefined>(props.router)
|
|
43
50
|
if (!routerRef.current) {
|
|
44
51
|
routerRef.current = new Router({
|
|
@@ -46,6 +53,7 @@ export function BuzolaProvider({ routes, children, middleware: Middleware, ...pr
|
|
|
46
53
|
adapter: createBrowserNavigationAdapter(),
|
|
47
54
|
persistentParams: props.persistentParams,
|
|
48
55
|
pageRegistry: props.pageRegistry,
|
|
56
|
+
shouldIntercept: info => shouldInterceptRef.current?.(info) ?? true,
|
|
49
57
|
})
|
|
50
58
|
}
|
|
51
59
|
const router = routerRef.current
|