@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.
Files changed (38) hide show
  1. package/dist/engine/matcher.d.ts.map +1 -1
  2. package/dist/engine/matcher.js +15 -12
  3. package/dist/engine/matcher.js.map +1 -1
  4. package/dist/engine/navigation-adapter.d.ts +2 -0
  5. package/dist/engine/navigation-adapter.d.ts.map +1 -1
  6. package/dist/engine/navigation-adapter.js +19 -5
  7. package/dist/engine/navigation-adapter.js.map +1 -1
  8. package/dist/engine/route-tree.js +9 -9
  9. package/dist/engine/route-tree.js.map +1 -1
  10. package/dist/engine/router.d.ts +36 -1
  11. package/dist/engine/router.d.ts.map +1 -1
  12. package/dist/engine/router.js +77 -1
  13. package/dist/engine/router.js.map +1 -1
  14. package/dist/engine/types.d.ts +31 -10
  15. package/dist/engine/types.d.ts.map +1 -1
  16. package/dist/engine/utils.d.ts +5 -0
  17. package/dist/engine/utils.d.ts.map +1 -1
  18. package/dist/engine/utils.js +13 -0
  19. package/dist/engine/utils.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/react/provider.d.ts +4 -1
  24. package/dist/react/provider.d.ts.map +1 -1
  25. package/dist/react/provider.js +6 -0
  26. package/dist/react/provider.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/__tests__/matcher.test.ts +33 -0
  29. package/src/__tests__/react.test.tsx +66 -0
  30. package/src/__tests__/router.test.ts +265 -2
  31. package/src/engine/matcher.ts +16 -17
  32. package/src/engine/navigation-adapter.ts +21 -6
  33. package/src/engine/route-tree.ts +9 -9
  34. package/src/engine/router.ts +89 -3
  35. package/src/engine/types.ts +33 -4
  36. package/src/engine/utils.ts +13 -0
  37. package/src/index.ts +2 -0
  38. 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
- // Run the handler and only commit navigation state on success.
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
  }
@@ -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
- const paramName = segment.slice(1, -1)
131
- node.catchAllChild = { paramName, chain }
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
- const paramName = segment.slice(1)
138
- if (!node.dynamicChild) {
139
- node.dynamicChild = { paramName, node: createTrieNode() }
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 ────────────────────────────────────────────────────────────────
@@ -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: import('./types.js').BuzolaNavigateEvent) => {
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.
@@ -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
- /** Dynamic child — matches any single segment as a named param. */
119
- dynamicChild?: { paramName: string; node: TrieNode }
120
- /** Catch-all child — matches all remaining segments. */
121
- catchAllChild?: { paramName: string; chain: RouteNode[] }
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. */
@@ -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
 
@@ -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