@buzola/router 0.0.18 → 0.0.20
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.d.ts +3 -1
- package/dist/engine/navigation-adapter.d.ts.map +1 -1
- package/dist/engine/navigation-adapter.js +27 -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 +7 -6
- package/dist/engine/router.d.ts.map +1 -1
- package/dist/engine/router.js +60 -26
- package/dist/engine/router.js.map +1 -1
- package/dist/engine/types.d.ts +15 -9
- 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 +76 -3
- package/src/__tests__/react.test.tsx +4 -4
- package/src/__tests__/router.test.ts +293 -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 +27 -2
- package/src/engine/route-tree.ts +3 -3
- package/src/engine/router.ts +68 -27
- package/src/engine/types.ts +15 -9
- package/src/engine/utils.ts +3 -3
- package/src/env.d.ts +8 -0
- package/src/react/hooks.ts +2 -2
- package/src/react/link.tsx +5 -5
- package/src/react/outlet.tsx +2 -2
|
@@ -26,6 +26,19 @@ export function createBrowserNavigationAdapter(): NavigationAdapter {
|
|
|
26
26
|
navigationType: event.navigationType as BuzolaNavigateEvent['navigationType'],
|
|
27
27
|
userInitiated: event.userInitiated,
|
|
28
28
|
viewTransition,
|
|
29
|
+
whenCommitted(callback) {
|
|
30
|
+
// The next entry change a navigation makes is this one's commit, unless it was cancelled and another
|
|
31
|
+
// navigation got there first. The destination key tells them apart (it is set on traversals).
|
|
32
|
+
// updateCurrentEntry() also fires the event, with no navigation type, and commits nothing: code that
|
|
33
|
+
// saves the scroll position into the entry being left does exactly that while the traversal is pending.
|
|
34
|
+
const key = event.destination.key
|
|
35
|
+
const onChange = (change: NavigationCurrentEntryChangeEvent) => {
|
|
36
|
+
if (change.navigationType === null) return
|
|
37
|
+
nav.removeEventListener('currententrychange', onChange)
|
|
38
|
+
if (nav.currentEntry?.key === key) callback()
|
|
39
|
+
}
|
|
40
|
+
nav.addEventListener('currententrychange', onChange)
|
|
41
|
+
},
|
|
29
42
|
intercept(options) {
|
|
30
43
|
event.intercept({
|
|
31
44
|
handler: options.handler,
|
|
@@ -113,6 +126,8 @@ export interface MemoryNavigationAdapter extends NavigationAdapter {
|
|
|
113
126
|
leftApp(): readonly string[]
|
|
114
127
|
/** How many reloads went through to the browser, i.e. were not intercepted. */
|
|
115
128
|
reloadCount(): number
|
|
129
|
+
/** How many navigations of each type the router intercepted. */
|
|
130
|
+
interceptCount(type: BuzolaNavigateEvent['navigationType']): number
|
|
116
131
|
}
|
|
117
132
|
|
|
118
133
|
export function createMemoryNavigationAdapter(
|
|
@@ -125,6 +140,7 @@ export function createMemoryNavigationAdapter(
|
|
|
125
140
|
const listeners = new Set<(event: BuzolaNavigateEvent) => void>()
|
|
126
141
|
const left: string[] = []
|
|
127
142
|
let reloads = 0
|
|
143
|
+
const intercepts = new Map<BuzolaNavigateEvent['navigationType'], number>()
|
|
128
144
|
|
|
129
145
|
function emitNavigateEvent(
|
|
130
146
|
url: string,
|
|
@@ -134,6 +150,7 @@ export function createMemoryNavigationAdapter(
|
|
|
134
150
|
): void {
|
|
135
151
|
let intercepted = false
|
|
136
152
|
let interceptHandler: (() => Promise<void>) | undefined
|
|
153
|
+
let onCommitted: (() => void) | undefined
|
|
137
154
|
|
|
138
155
|
const event: BuzolaNavigateEvent = {
|
|
139
156
|
destination: { url },
|
|
@@ -141,6 +158,9 @@ export function createMemoryNavigationAdapter(
|
|
|
141
158
|
navigationType,
|
|
142
159
|
userInitiated: false,
|
|
143
160
|
viewTransition,
|
|
161
|
+
whenCommitted(callback) {
|
|
162
|
+
onCommitted = callback
|
|
163
|
+
},
|
|
144
164
|
intercept(options) {
|
|
145
165
|
intercepted = true
|
|
146
166
|
interceptHandler = options.handler
|
|
@@ -152,20 +172,22 @@ export function createMemoryNavigationAdapter(
|
|
|
152
172
|
}
|
|
153
173
|
|
|
154
174
|
if (intercepted && interceptHandler) {
|
|
175
|
+
intercepts.set(navigationType, (intercepts.get(navigationType) ?? 0) + 1)
|
|
155
176
|
// Like the browser, commit the destination entry before the handler runs. A handler that
|
|
156
177
|
// fails or reloads therefore sees the destination as the current entry.
|
|
157
178
|
commitNavigation(true)
|
|
158
179
|
void interceptHandler().then(
|
|
159
180
|
() => {},
|
|
160
181
|
(error) => {
|
|
161
|
-
// Guard aborts are expected
|
|
182
|
+
// Guard aborts are expected: rethrow unexpected errors
|
|
162
183
|
if (error instanceof Error && error.name === 'NavigationAbortedError') return
|
|
163
184
|
throw error
|
|
164
185
|
},
|
|
165
186
|
)
|
|
166
187
|
} else {
|
|
167
|
-
// No interception
|
|
188
|
+
// No interception: commit immediately
|
|
168
189
|
commitNavigation(false)
|
|
190
|
+
onCommitted?.()
|
|
169
191
|
}
|
|
170
192
|
}
|
|
171
193
|
|
|
@@ -231,5 +253,8 @@ export function createMemoryNavigationAdapter(
|
|
|
231
253
|
reloadCount() {
|
|
232
254
|
return reloads
|
|
233
255
|
},
|
|
256
|
+
interceptCount(type) {
|
|
257
|
+
return intercepts.get(type) ?? 0
|
|
258
|
+
},
|
|
234
259
|
}
|
|
235
260
|
}
|
package/src/engine/route-tree.ts
CHANGED
|
@@ -61,7 +61,7 @@ export function buildRouteTree(configs: RouteConfig[], parentPath = ''): RouteTr
|
|
|
61
61
|
interface FlatEntry {
|
|
62
62
|
/** The URL pattern for matching (e.g., "/project/:id/edit"). */
|
|
63
63
|
pattern: string
|
|
64
|
-
/** Layout chain from root to leaf
|
|
64
|
+
/** Layout chain from root to leaf: each becomes a RouteMatch. */
|
|
65
65
|
chain: RouteNode[]
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -127,7 +127,7 @@ function insertIntoTrie(root: TrieNode, pattern: string, chain: RouteNode[]): vo
|
|
|
127
127
|
const segment = segments[i]
|
|
128
128
|
|
|
129
129
|
if (segment.startsWith(':') && (segment.endsWith('+') || segment.endsWith('*'))) {
|
|
130
|
-
// Catch-all segment
|
|
130
|
+
// Catch-all segment: consumes all remaining segments
|
|
131
131
|
paramNames.push(segment.slice(1, -1))
|
|
132
132
|
node.catchAllChild = { chain, paramNames }
|
|
133
133
|
return
|
|
@@ -150,7 +150,7 @@ function insertIntoTrie(root: TrieNode, pattern: string, chain: RouteNode[]): vo
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
// Terminal
|
|
153
|
+
// Terminal: route matches at this trie node
|
|
154
154
|
node.route = { chain, paramNames }
|
|
155
155
|
}
|
|
156
156
|
|
package/src/engine/router.ts
CHANGED
|
@@ -47,7 +47,7 @@ export interface RouterOptions {
|
|
|
47
47
|
loaderCacheSize?: number
|
|
48
48
|
/**
|
|
49
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
|
|
50
|
+
* Return `false` to have the browser load the destination as a new document instead,
|
|
51
51
|
* e.g. to pick up a newly deployed build on the next in-app navigation.
|
|
52
52
|
*
|
|
53
53
|
* Offered every push, replace and traverse the router would otherwise intercept, except
|
|
@@ -146,7 +146,7 @@ export class Router {
|
|
|
146
146
|
*/
|
|
147
147
|
start(): () => void {
|
|
148
148
|
const handler = (event: BuzolaNavigateEvent) => {
|
|
149
|
-
// Consume the pending options before any early return
|
|
149
|
+
// Consume the pending options before any early return: they belong to the
|
|
150
150
|
// navigation that armed them and must not leak into the next one.
|
|
151
151
|
const scrollBehavior = this.pendingScrollBehavior
|
|
152
152
|
const explicitFocusReset = this.pendingFocusReset
|
|
@@ -191,15 +191,38 @@ export class Router {
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
const currentNavId = ++this.navigationId
|
|
194
|
-
const useViewTransition = event.viewTransition || this.viewTransitions
|
|
195
|
-
|
|
194
|
+
const useViewTransition = (event.viewTransition || this.viewTransitions) && typeof document !== 'undefined'
|
|
195
|
+
&& 'startViewTransition' in document
|
|
196
|
+
|
|
197
|
+
// A traversal nobody needs to hold is left to the browser. Safari restores scroll for an intercepted
|
|
198
|
+
// traversal in a later task, and skips the restore if a wheel event reached the page in between. A trackpad
|
|
199
|
+
// back swipe always delivers some, and React always has a wheel listener, so the page comes back unscrolled
|
|
200
|
+
// and the swipe snapshot hangs for its 3 s timeout. Not intercepted, the traversal takes the browser's
|
|
201
|
+
// classic path, which switches the entry and restores scroll in one task. Rendering at the switch keeps the
|
|
202
|
+
// screen in step with the URL (an effect that replaces the entry must not hit the one being left) while
|
|
203
|
+
// the page is still in place before the restore.
|
|
204
|
+
if (event.navigationType === 'traverse' && this.blockers.size === 0 && !useViewTransition && event.whenCommitted) {
|
|
205
|
+
event.whenCommitted(() => {
|
|
206
|
+
if (this.navigationId !== currentNavId) return
|
|
207
|
+
this.commitLocation(url, matches, this.adapter.getState(), event.navigationType)
|
|
208
|
+
})
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Query-only navigations stay on the same page, so keep focus where the user left it
|
|
196
213
|
// (search boxes, filters, pagination). Path changes keep the browser's focus reset,
|
|
197
214
|
// and so do fragment changes, which must move focus to the targeted section.
|
|
198
215
|
const focusReset = explicitFocusReset
|
|
199
216
|
?? (isSamePath(this.state.location, url) && this.state.location.hash === url.hash ? 'manual' : 'after-transition')
|
|
200
217
|
|
|
218
|
+
// Safari 26.5 scrolls an intercepted push or replace to the top only in the web process. The page reports
|
|
219
|
+
// scrollY 0 while the screen stays where it was (a sticky header is gone) until the next scroll, which
|
|
220
|
+
// jumps from the old position (WebKit bug 309542, fixed in 308976@main). window.scrollTo reaches the
|
|
221
|
+
// screen, so the router scrolls to the top itself. A fragment and a traversal stay with the browser.
|
|
222
|
+
const scrollToTop = scrollBehavior === 'after-transition' && event.navigationType !== 'traverse' && url.hash === ''
|
|
223
|
+
|
|
201
224
|
event.intercept({
|
|
202
|
-
scroll: scrollBehavior,
|
|
225
|
+
scroll: scrollToTop ? 'manual' : scrollBehavior,
|
|
203
226
|
focusReset,
|
|
204
227
|
handler: async () => {
|
|
205
228
|
// Check blockers before setting pending state
|
|
@@ -218,28 +241,22 @@ export class Router {
|
|
|
218
241
|
pendingLocation: url,
|
|
219
242
|
})
|
|
220
243
|
|
|
221
|
-
// A newer navigation has started
|
|
244
|
+
// A newer navigation has started, so abandon this one
|
|
222
245
|
if (this.navigationId !== currentNavId) return
|
|
223
246
|
|
|
224
|
-
// Perform the DOM update
|
|
247
|
+
// Perform the DOM update. A view transition runs it later, by when a newer navigation may have
|
|
248
|
+
// committed its own state, so check again.
|
|
225
249
|
const doUpdate = () => {
|
|
226
|
-
|
|
227
|
-
this.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
navigationState: this.adapter.getState(),
|
|
233
|
-
})
|
|
234
|
-
// Going to the URL we are already on is still a navigation — a browser
|
|
235
|
-
// re-requests the document for it. Loader data is keyed by href, so it
|
|
236
|
-
// would silently keep serving stale data without an explicit invalidation.
|
|
237
|
-
if (previousHref === url.href) {
|
|
238
|
-
this.invalidateAll()
|
|
250
|
+
if (this.navigationId !== currentNavId) return
|
|
251
|
+
this.commitLocation(url, matches, this.adapter.getState(), event.navigationType)
|
|
252
|
+
// Instant even where the page sets scroll-behavior: smooth, so the jump does not animate. Safari's
|
|
253
|
+
// own reset is instant as well (Chromium's follows the CSS).
|
|
254
|
+
if (scrollToTop && typeof window !== 'undefined') {
|
|
255
|
+
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
|
|
239
256
|
}
|
|
240
257
|
}
|
|
241
258
|
|
|
242
|
-
if (useViewTransition
|
|
259
|
+
if (useViewTransition) {
|
|
243
260
|
;(document as unknown as { startViewTransition: (cb: () => void) => void }).startViewTransition(doUpdate)
|
|
244
261
|
} else {
|
|
245
262
|
doUpdate()
|
|
@@ -258,6 +275,30 @@ export class Router {
|
|
|
258
275
|
return stop
|
|
259
276
|
}
|
|
260
277
|
|
|
278
|
+
private commitLocation(
|
|
279
|
+
url: URL,
|
|
280
|
+
matches: RouterState['matches'],
|
|
281
|
+
navigationState: unknown,
|
|
282
|
+
navigationType: BuzolaNavigateEvent['navigationType'],
|
|
283
|
+
): void {
|
|
284
|
+
const previousHref = this.state.location.href
|
|
285
|
+
this.setState({
|
|
286
|
+
location: url,
|
|
287
|
+
matches,
|
|
288
|
+
isPending: false,
|
|
289
|
+
pendingLocation: undefined,
|
|
290
|
+
navigationState,
|
|
291
|
+
})
|
|
292
|
+
// Going to the URL we are already on is still a navigation: a browser
|
|
293
|
+
// re-requests the document for it. Loader data is keyed by href, so it
|
|
294
|
+
// would silently keep serving stale data without an explicit invalidation.
|
|
295
|
+
// A traversal re-requests nothing, and reloading would shorten the page before
|
|
296
|
+
// the browser restores its scroll position.
|
|
297
|
+
if (previousHref === url.href && navigationType !== 'traverse') {
|
|
298
|
+
this.invalidateAll()
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
261
302
|
/**
|
|
262
303
|
* A reload asks for a fresh document (e.g. to recover from stale chunks after a deploy), so it goes to
|
|
263
304
|
* the browser. Re-rendering the current route from the JavaScript already loaded would defeat it.
|
|
@@ -284,7 +325,7 @@ export class Router {
|
|
|
284
325
|
}
|
|
285
326
|
|
|
286
327
|
/**
|
|
287
|
-
* Hold the navigation while the blockers run and, if they allow it, reload the current entry
|
|
328
|
+
* Hold the navigation while the blockers run and, if they allow it, reload the current entry,
|
|
288
329
|
* which is already the navigation's destination, because an intercepted navigation commits before
|
|
289
330
|
* its handler runs.
|
|
290
331
|
*/
|
|
@@ -309,7 +350,7 @@ export class Router {
|
|
|
309
350
|
}
|
|
310
351
|
|
|
311
352
|
/**
|
|
312
|
-
* Hand one navigation to the browser instead of intercepting it, for a URL the SERVER owns
|
|
353
|
+
* Hand one navigation to the browser instead of intercepting it, for a URL the SERVER owns:
|
|
313
354
|
* an auth handoff, a file download, a server-rendered page that happens to share this origin.
|
|
314
355
|
*
|
|
315
356
|
* The Navigation API offers no way for a caller to mark a navigation un-interceptable:
|
|
@@ -319,7 +360,7 @@ export class Router {
|
|
|
319
360
|
*
|
|
320
361
|
* The release is recorded BEFORE the navigation is requested and is keyed to the resolved URL, so
|
|
321
362
|
* it cannot race the event or release some other navigation. Unlike `navigate`, the URL is passed
|
|
322
|
-
* to the browser as given
|
|
363
|
+
* to the browser as given: no base path is prepended, because the target is outside the app.
|
|
323
364
|
*/
|
|
324
365
|
leaveApp(url: string): void {
|
|
325
366
|
this.releasedURL = new URL(url, this.adapter.getCurrentURL()).href
|
|
@@ -328,7 +369,7 @@ export class Router {
|
|
|
328
369
|
|
|
329
370
|
/**
|
|
330
371
|
* Navigate programmatically.
|
|
331
|
-
* The `to` path should not include the base path
|
|
372
|
+
* The `to` path should not include the base path. It is prepended automatically.
|
|
332
373
|
*/
|
|
333
374
|
navigate(to: string, options?: NavigateOptions): void {
|
|
334
375
|
if (options?.resetScroll === false) {
|
|
@@ -409,7 +450,7 @@ export class Router {
|
|
|
409
450
|
missing.push(paramName)
|
|
410
451
|
} else {
|
|
411
452
|
if (isCatchAll) {
|
|
412
|
-
// Catch-all values may contain slashes
|
|
453
|
+
// Catch-all values may contain slashes: insert raw
|
|
413
454
|
result.push(value)
|
|
414
455
|
} else {
|
|
415
456
|
result.push(encodeURIComponent(value))
|
|
@@ -533,7 +574,7 @@ export class Router {
|
|
|
533
574
|
}
|
|
534
575
|
|
|
535
576
|
/**
|
|
536
|
-
* Dispose the router
|
|
577
|
+
* Dispose the router: clear all subscribers and reset state.
|
|
537
578
|
*/
|
|
538
579
|
dispose(): void {
|
|
539
580
|
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
|
|
@@ -168,14 +168,14 @@ export interface NavigateOptions {
|
|
|
168
168
|
viewTransition?: boolean
|
|
169
169
|
/**
|
|
170
170
|
* Whether to reset scroll position after navigation.
|
|
171
|
-
* Default is `true
|
|
171
|
+
* Default is `true`: scroll to the top (or to the `#fragment`) on push and replace, restore on traverse.
|
|
172
172
|
* Set to `false` to preserve current scroll position.
|
|
173
173
|
*/
|
|
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'
|
|
@@ -222,6 +222,12 @@ export interface BuzolaNavigateEvent {
|
|
|
222
222
|
userInitiated: boolean
|
|
223
223
|
/** Whether this navigation should use View Transitions API. */
|
|
224
224
|
viewTransition?: boolean
|
|
225
|
+
/**
|
|
226
|
+
* Run `callback` once the browser has made the destination the current entry of a navigation nobody
|
|
227
|
+
* intercepted. It runs before the browser restores the scroll position, and not at all if the navigation
|
|
228
|
+
* is cancelled. An adapter that cannot tell leaves it out, and the router then intercepts traversals.
|
|
229
|
+
*/
|
|
230
|
+
whenCommitted?(callback: () => void): void
|
|
225
231
|
/** Intercept the navigation and handle it client-side. */
|
|
226
232
|
intercept(
|
|
227
233
|
options: { handler: () => Promise<void>; scroll?: 'after-transition' | 'manual'; focusReset?: 'after-transition' | 'manual' },
|
package/src/engine/utils.ts
CHANGED
|
@@ -24,12 +24,12 @@ 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
|
package/src/env.d.ts
CHANGED
|
@@ -37,6 +37,12 @@ interface NavigateEvent extends Event {
|
|
|
37
37
|
scroll(): void
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
interface NavigationCurrentEntryChangeEvent extends Event {
|
|
41
|
+
/** null when the entry changed through `updateCurrentEntry()` rather than a navigation. */
|
|
42
|
+
navigationType: 'push' | 'replace' | 'reload' | 'traverse' | null
|
|
43
|
+
from: NavigationHistoryEntry
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
interface NavigationNavigateOptions {
|
|
41
47
|
state?: unknown
|
|
42
48
|
info?: unknown
|
|
@@ -60,8 +66,10 @@ interface Navigation extends EventTarget {
|
|
|
60
66
|
back(options?: { info?: unknown }): NavigationResult
|
|
61
67
|
forward(options?: { info?: unknown }): NavigationResult
|
|
62
68
|
addEventListener(type: 'navigate', listener: (event: NavigateEvent) => void): void
|
|
69
|
+
addEventListener(type: 'currententrychange', listener: (event: NavigationCurrentEntryChangeEvent) => void): void
|
|
63
70
|
addEventListener(type: string, listener: EventListener): void
|
|
64
71
|
removeEventListener(type: 'navigate', listener: (event: NavigateEvent) => void): void
|
|
72
|
+
removeEventListener(type: 'currententrychange', listener: (event: NavigationCurrentEntryChangeEvent) => void): void
|
|
65
73
|
removeEventListener(type: string, listener: EventListener): void
|
|
66
74
|
}
|
|
67
75
|
|
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
|