@buzola/router 0.0.16 → 0.0.18
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/engine/matcher.d.ts.map +1 -1
- package/dist/engine/matcher.js +15 -12
- package/dist/engine/matcher.js.map +1 -1
- package/dist/engine/navigation-adapter.d.ts +2 -0
- package/dist/engine/navigation-adapter.d.ts.map +1 -1
- package/dist/engine/navigation-adapter.js +19 -5
- package/dist/engine/navigation-adapter.js.map +1 -1
- package/dist/engine/route-tree.js +9 -9
- package/dist/engine/route-tree.js.map +1 -1
- package/dist/engine/router.d.ts +36 -1
- package/dist/engine/router.d.ts.map +1 -1
- package/dist/engine/router.js +77 -1
- package/dist/engine/router.js.map +1 -1
- package/dist/engine/types.d.ts +31 -10
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/engine/utils.d.ts +5 -0
- package/dist/engine/utils.d.ts.map +1 -1
- package/dist/engine/utils.js +13 -0
- 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/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__/matcher.test.ts +33 -0
- package/src/__tests__/react.test.tsx +66 -0
- package/src/__tests__/router.test.ts +265 -2
- package/src/engine/matcher.ts +16 -17
- package/src/engine/navigation-adapter.ts +21 -6
- package/src/engine/route-tree.ts +9 -9
- package/src/engine/router.ts +89 -3
- package/src/engine/types.ts +33 -4
- package/src/engine/utils.ts +13 -0
- package/src/index.ts +2 -0
- package/src/react/provider.tsx +10 -2
|
@@ -63,6 +63,9 @@ export function createBrowserNavigationAdapter(): NavigationAdapter {
|
|
|
63
63
|
// browser, so it must go through the plain Location API and stay cross-document.
|
|
64
64
|
window.location.assign(url)
|
|
65
65
|
},
|
|
66
|
+
reload() {
|
|
67
|
+
window.location.reload()
|
|
68
|
+
},
|
|
66
69
|
back() {
|
|
67
70
|
nav.back()
|
|
68
71
|
},
|
|
@@ -108,6 +111,8 @@ interface MemoryEntry {
|
|
|
108
111
|
export interface MemoryNavigationAdapter extends NavigationAdapter {
|
|
109
112
|
/** URLs passed to `leaveApp`, in order. */
|
|
110
113
|
leftApp(): readonly string[]
|
|
114
|
+
/** How many reloads went through to the browser, i.e. were not intercepted. */
|
|
115
|
+
reloadCount(): number
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
export function createMemoryNavigationAdapter(
|
|
@@ -119,11 +124,12 @@ export function createMemoryNavigationAdapter(
|
|
|
119
124
|
let currentIndex = 0
|
|
120
125
|
const listeners = new Set<(event: BuzolaNavigateEvent) => void>()
|
|
121
126
|
const left: string[] = []
|
|
127
|
+
let reloads = 0
|
|
122
128
|
|
|
123
129
|
function emitNavigateEvent(
|
|
124
130
|
url: string,
|
|
125
131
|
navigationType: BuzolaNavigateEvent['navigationType'],
|
|
126
|
-
commitNavigation: () => void,
|
|
132
|
+
commitNavigation: (intercepted: boolean) => void,
|
|
127
133
|
viewTransition?: boolean,
|
|
128
134
|
): void {
|
|
129
135
|
let intercepted = false
|
|
@@ -146,11 +152,11 @@ export function createMemoryNavigationAdapter(
|
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
if (intercepted && interceptHandler) {
|
|
149
|
-
//
|
|
155
|
+
// Like the browser, commit the destination entry before the handler runs. A handler that
|
|
156
|
+
// fails or reloads therefore sees the destination as the current entry.
|
|
157
|
+
commitNavigation(true)
|
|
150
158
|
void interceptHandler().then(
|
|
151
|
-
() => {
|
|
152
|
-
commitNavigation()
|
|
153
|
-
},
|
|
159
|
+
() => {},
|
|
154
160
|
(error) => {
|
|
155
161
|
// Guard aborts are expected — rethrow unexpected errors
|
|
156
162
|
if (error instanceof Error && error.name === 'NavigationAbortedError') return
|
|
@@ -159,7 +165,7 @@ export function createMemoryNavigationAdapter(
|
|
|
159
165
|
)
|
|
160
166
|
} else {
|
|
161
167
|
// No interception — commit immediately
|
|
162
|
-
commitNavigation()
|
|
168
|
+
commitNavigation(false)
|
|
163
169
|
}
|
|
164
170
|
}
|
|
165
171
|
|
|
@@ -175,6 +181,12 @@ export function createMemoryNavigationAdapter(
|
|
|
175
181
|
left.push(resolved)
|
|
176
182
|
emitNavigateEvent(resolved, 'push', () => {})
|
|
177
183
|
},
|
|
184
|
+
reload() {
|
|
185
|
+
// An intercepted reload stays in the document; only one the router let through counts.
|
|
186
|
+
emitNavigateEvent(entries[currentIndex].url, 'reload', (intercepted) => {
|
|
187
|
+
if (!intercepted) reloads++
|
|
188
|
+
})
|
|
189
|
+
},
|
|
178
190
|
navigate(url, options) {
|
|
179
191
|
const resolved = new URL(url, entries[currentIndex].url).href
|
|
180
192
|
const type = options?.replace ? 'replace' : 'push'
|
|
@@ -216,5 +228,8 @@ export function createMemoryNavigationAdapter(
|
|
|
216
228
|
leftApp() {
|
|
217
229
|
return left
|
|
218
230
|
},
|
|
231
|
+
reloadCount() {
|
|
232
|
+
return reloads
|
|
233
|
+
},
|
|
219
234
|
}
|
|
220
235
|
}
|
package/src/engine/route-tree.ts
CHANGED
|
@@ -120,6 +120,7 @@ function createTrieNode(): TrieNode {
|
|
|
120
120
|
|
|
121
121
|
function insertIntoTrie(root: TrieNode, pattern: string, chain: RouteNode[]): void {
|
|
122
122
|
const segments = splitPathSegments(pattern)
|
|
123
|
+
const paramNames: string[] = []
|
|
123
124
|
let node = root
|
|
124
125
|
|
|
125
126
|
for (let i = 0; i < segments.length; i++) {
|
|
@@ -127,18 +128,17 @@ function insertIntoTrie(root: TrieNode, pattern: string, chain: RouteNode[]): vo
|
|
|
127
128
|
|
|
128
129
|
if (segment.startsWith(':') && (segment.endsWith('+') || segment.endsWith('*'))) {
|
|
129
130
|
// Catch-all segment — consumes all remaining segments
|
|
130
|
-
|
|
131
|
-
node.catchAllChild = {
|
|
131
|
+
paramNames.push(segment.slice(1, -1))
|
|
132
|
+
node.catchAllChild = { chain, paramNames }
|
|
132
133
|
return
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
if (segment.startsWith(':')) {
|
|
136
|
-
// Dynamic segment
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
node = node.dynamicChild.node
|
|
137
|
+
// Dynamic segment. The name stays with this route, not the shared trie node: another route
|
|
138
|
+
// through the same position may call it something else.
|
|
139
|
+
paramNames.push(segment.slice(1))
|
|
140
|
+
node.dynamicChild ??= createTrieNode()
|
|
141
|
+
node = node.dynamicChild
|
|
142
142
|
} else {
|
|
143
143
|
// Static segment
|
|
144
144
|
let child = node.staticChildren.get(segment)
|
|
@@ -151,7 +151,7 @@ function insertIntoTrie(root: TrieNode, pattern: string, chain: RouteNode[]): vo
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
// Terminal — route matches at this trie node
|
|
154
|
-
node.route = { chain }
|
|
154
|
+
node.route = { chain, paramNames }
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
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, 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,12 +68,14 @@ 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
|
|
64
75
|
private navigationId = 0
|
|
65
76
|
private stopFn: (() => void) | undefined
|
|
66
77
|
private releasedURL: string | undefined
|
|
78
|
+
private reloadReleased = false
|
|
67
79
|
private blockers = new Set<BlockerFn>()
|
|
68
80
|
private pendingScrollBehavior: 'after-transition' | 'manual' = 'after-transition'
|
|
69
81
|
private pendingFocusReset: 'after-transition' | 'manual' | undefined
|
|
@@ -76,6 +88,7 @@ export class Router {
|
|
|
76
88
|
this._basePath = options.basePath ? options.basePath.replace(/\/$/, '') : ''
|
|
77
89
|
this.persistentParamsFn = options.persistentParams
|
|
78
90
|
this.pageRegistry = options.pageRegistry ?? {}
|
|
91
|
+
this.shouldIntercept = options.shouldIntercept
|
|
79
92
|
this._loaderCache = options.loaderCacheSize ? new LoaderCache(options.loaderCacheSize) : undefined
|
|
80
93
|
|
|
81
94
|
// Initialize state from current URL
|
|
@@ -132,7 +145,7 @@ export class Router {
|
|
|
132
145
|
* Call this once when the router is initialized.
|
|
133
146
|
*/
|
|
134
147
|
start(): () => void {
|
|
135
|
-
const handler = (event:
|
|
148
|
+
const handler = (event: BuzolaNavigateEvent) => {
|
|
136
149
|
// Consume the pending options before any early return — they belong to the
|
|
137
150
|
// navigation that armed them and must not leak into the next one.
|
|
138
151
|
const scrollBehavior = this.pendingScrollBehavior
|
|
@@ -144,7 +157,15 @@ export class Router {
|
|
|
144
157
|
this.releasedURL = undefined
|
|
145
158
|
return
|
|
146
159
|
}
|
|
160
|
+
if (event.navigationType === 'reload' && this.reloadReleased) {
|
|
161
|
+
this.reloadReleased = false
|
|
162
|
+
return
|
|
163
|
+
}
|
|
147
164
|
if (!event.canIntercept) return
|
|
165
|
+
if (event.navigationType === 'reload') {
|
|
166
|
+
this.handleReload(event)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
148
169
|
|
|
149
170
|
const url = new URL(event.destination.url)
|
|
150
171
|
const matchUrl = this.createMatchUrl(url)
|
|
@@ -154,6 +175,21 @@ export class Router {
|
|
|
154
175
|
return
|
|
155
176
|
}
|
|
156
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
|
+
|
|
157
193
|
const currentNavId = ++this.navigationId
|
|
158
194
|
const useViewTransition = event.viewTransition || this.viewTransitions
|
|
159
195
|
// Query-only navigations stay on the same page — keep focus where the user left it
|
|
@@ -222,6 +258,56 @@ export class Router {
|
|
|
222
258
|
return stop
|
|
223
259
|
}
|
|
224
260
|
|
|
261
|
+
/**
|
|
262
|
+
* A reload asks for a fresh document (e.g. to recover from stale chunks after a deploy), so it goes to
|
|
263
|
+
* the browser. Re-rendering the current route from the JavaScript already loaded would defeat it.
|
|
264
|
+
*
|
|
265
|
+
* Blockers still get their say. They are async and a navigation can only be let through synchronously,
|
|
266
|
+
* so the reload is held while they run and, if they allow it, requested again and released.
|
|
267
|
+
*/
|
|
268
|
+
private handleReload(event: BuzolaNavigateEvent): void {
|
|
269
|
+
if (this.blockers.size === 0) return
|
|
270
|
+
this.reloadAfterBlockers(event)
|
|
271
|
+
}
|
|
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 {
|
|
292
|
+
const currentNavId = ++this.navigationId
|
|
293
|
+
event.intercept({
|
|
294
|
+
scroll: 'manual',
|
|
295
|
+
focusReset: 'manual',
|
|
296
|
+
handler: async () => {
|
|
297
|
+
for (const blocker of this.blockers) {
|
|
298
|
+
if (this.navigationId !== currentNavId) return
|
|
299
|
+
const shouldProceed = await blocker()
|
|
300
|
+
if (!shouldProceed) {
|
|
301
|
+
throw new NavigationAbortedError()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (this.navigationId !== currentNavId) return
|
|
305
|
+
this.reloadReleased = true
|
|
306
|
+
this.adapter.reload()
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
225
311
|
/**
|
|
226
312
|
* Hand one navigation to the browser instead of intercepting it, for a URL the SERVER owns —
|
|
227
313
|
* an auth handoff, a file download, a server-rendered page that happens to share this origin.
|
package/src/engine/types.ts
CHANGED
|
@@ -115,10 +115,13 @@ export interface RouteNode {
|
|
|
115
115
|
export interface TrieNode {
|
|
116
116
|
/** Static children — exact segment match via Map lookup (O(1)). */
|
|
117
117
|
staticChildren: Map<string, TrieNode>
|
|
118
|
-
/**
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Dynamic child: matches any single segment. It carries no param name, because routes sharing this
|
|
120
|
+
* position may name it differently; each terminal names the values of its own route.
|
|
121
|
+
*/
|
|
122
|
+
dynamicChild?: TrieNode
|
|
123
|
+
/** Catch-all child: matches all remaining segments. */
|
|
124
|
+
catchAllChild?: TrieRoute
|
|
122
125
|
/** When defined, a route terminates at this trie node. */
|
|
123
126
|
route?: TrieRoute
|
|
124
127
|
}
|
|
@@ -127,6 +130,8 @@ export interface TrieNode {
|
|
|
127
130
|
export interface TrieRoute {
|
|
128
131
|
/** Match chain: array of RouteNode from root layout to leaf page. */
|
|
129
132
|
chain: RouteNode[]
|
|
133
|
+
/** This route's param names, one per dynamic segment in path order (a catch-all name last). */
|
|
134
|
+
paramNames: string[]
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
/** Opaque route tree built from RouteConfig[]. Passed to matchRoutes() and Router. */
|
|
@@ -183,6 +188,28 @@ export interface NavigateOptions {
|
|
|
183
188
|
*/
|
|
184
189
|
export type BlockerFn = () => Promise<boolean>
|
|
185
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
|
+
|
|
186
213
|
/** Navigate event from the Navigation API (simplified). */
|
|
187
214
|
export interface BuzolaNavigateEvent {
|
|
188
215
|
/** The destination URL. */
|
|
@@ -212,6 +239,8 @@ export interface NavigationAdapter {
|
|
|
212
239
|
* The router pairs this with a release so its own listener does not intercept the result.
|
|
213
240
|
*/
|
|
214
241
|
leaveApp(url: string): void
|
|
242
|
+
/** Reload the current document, as `location.reload()` does. */
|
|
243
|
+
reload(): void
|
|
215
244
|
/** Go back. */
|
|
216
245
|
back(): void
|
|
217
246
|
/** Go forward. */
|
package/src/engine/utils.ts
CHANGED
|
@@ -36,3 +36,16 @@ export function isSamePath(a: string | URL, b: string | URL): boolean {
|
|
|
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/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
|