@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
@@ -34,4 +34,17 @@ export function isSamePath(a, b) {
34
34
  const urlB = typeof b === 'string' ? new URL(b) : b;
35
35
  return urlA.origin === urlB.origin && urlA.pathname === urlB.pathname;
36
36
  }
37
+ /**
38
+ * Whether going from `from` to `to` is a fragment navigation: the URLs differ at most in the hash and `to`
39
+ * has one. Browsers always perform those within the current document, whether intercepted or not.
40
+ */
41
+ export function isFragmentNavigation(from, to) {
42
+ if (to.hash === '')
43
+ return false;
44
+ const fromWithoutHash = new URL(from);
45
+ fromWithoutHash.hash = '';
46
+ const toWithoutHash = new URL(to);
47
+ toWithoutHash.hash = '';
48
+ return fromWithoutHash.href === toWithoutHash.href;
49
+ }
37
50
  //# sourceMappingURL=utils.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"utils.js","sourceRoot":"","sources":["../../src/engine/utils.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,OAAe;IAChD,MAAM,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAA;IACxC,MAAM,IAAI,GAAG,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;IACjE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,CAAA;AAC5B,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,OAAe;IAChD,MAAM,KAAK,GAAa,EAAE,CAAA;IAC1B,KAAK,MAAM,OAAO,IAAI,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QAC1C,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YAC7B,KAAK,CAAC,IAAI,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAA;QAC5C,CAAC;IACF,CAAC;IACD,OAAO,KAAK,CAAA;AACb,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,CAAe,EAAE,CAAe;IAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACnD,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACnD,OAAO,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,QAAQ,KAAK,IAAI,CAAC,QAAQ,CAAA;AACtE,CAAC"}
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../../src/engine/utils.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,OAAe;IAChD,MAAM,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAA;IACxC,MAAM,IAAI,GAAG,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;IACjE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,CAAA;AAC5B,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,OAAe;IAChD,MAAM,KAAK,GAAa,EAAE,CAAA;IAC1B,KAAK,MAAM,OAAO,IAAI,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QAC1C,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YAC7B,KAAK,CAAC,IAAI,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAA;QAC5C,CAAC;IACF,CAAC;IACD,OAAO,KAAK,CAAA;AACb,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,CAAe,EAAE,CAAe;IAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACnD,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACnD,OAAO,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,QAAQ,KAAK,IAAI,CAAC,QAAQ,CAAA;AACtE,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAAS,EAAE,EAAO;IACtD,IAAI,EAAE,CAAC,IAAI,KAAK,EAAE;QAAE,OAAO,KAAK,CAAA;IAChC,MAAM,eAAe,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,CAAA;IACrC,eAAe,CAAC,IAAI,GAAG,EAAE,CAAA;IACzB,MAAM,aAAa,GAAG,IAAI,GAAG,CAAC,EAAE,CAAC,CAAA;IACjC,aAAa,CAAC,IAAI,GAAG,EAAE,CAAA;IACvB,OAAO,eAAe,CAAC,IAAI,KAAK,aAAa,CAAC,IAAI,CAAA;AACnD,CAAC"}
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export type { CreateRouteNodeOptions } from './engine/route-tree.js';
6
6
  export { matchRoutes } from './engine/matcher.js';
7
7
  export { createBrowserNavigationAdapter, createMemoryNavigationAdapter } from './engine/navigation-adapter.js';
8
8
  export type { MemoryNavigationAdapter, MemoryNavigationAdapterOptions } from './engine/navigation-adapter.js';
9
- export type { BlockerFn, BuzolaNavigateEvent, BuzolaPageMap, BuzolaPersistentParams, EffectivePageParams, NavigateOptions, NavigationAdapter, PageParams, RegisteredPage, RouteComponent, RouteConfig, RouteMatch, RouteNode, RouterState, RouteTree, StandardSchema, } from './engine/types.js';
9
+ export type { BlockerFn, BuzolaNavigateEvent, BuzolaPageMap, BuzolaPersistentParams, EffectivePageParams, InterceptInfo, NavigateOptions, NavigationAdapter, PageParams, RegisteredPage, RouteComponent, RouteConfig, RouteMatch, RouteNode, RouterState, RouteTree, ShouldInterceptFn, StandardSchema, } from './engine/types.js';
10
10
  export { s } from './engine/schema.js';
11
11
  export { BuzolaRedirect, createPage, redirect } from './define/create-page.js';
12
12
  export type { CatchContext, PageDefinition, PageProps, RedirectFn } from './define/create-page.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAEtD,OAAO,EAAE,sBAAsB,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAA;AACnE,YAAY,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAEzE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AACxE,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAEpE,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAEjD,OAAO,EAAE,8BAA8B,EAAE,6BAA6B,EAAE,MAAM,gCAAgC,CAAA;AAC9G,YAAY,EAAE,uBAAuB,EAAE,8BAA8B,EAAE,MAAM,gCAAgC,CAAA;AAG7G,YAAY,EACX,SAAS,EACT,mBAAmB,EACnB,aAAa,EACb,sBAAsB,EACtB,mBAAmB,EACnB,eAAe,EACf,iBAAiB,EACjB,UAAU,EACV,cAAc,EACd,cAAc,EACd,WAAW,EACX,UAAU,EACV,SAAS,EACT,WAAW,EACX,SAAS,EACT,cAAc,GACd,MAAM,mBAAmB,CAAA;AAG1B,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAA;AAGtC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAC9E,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAA;AAGlG,OAAO,EACN,cAAc,EACd,aAAa,EACb,IAAI,EACJ,MAAM,EACN,YAAY,EACZ,aAAa,EACb,UAAU,EACV,aAAa,EACb,WAAW,EACX,kBAAkB,EAClB,SAAS,EACT,QAAQ,EACR,SAAS,EACT,cAAc,EACd,eAAe,GACf,MAAM,kBAAkB,CAAA;AACzB,YAAY,EAAE,YAAY,EAAE,mBAAmB,EAAE,SAAS,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAEtD,OAAO,EAAE,sBAAsB,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAA;AACnE,YAAY,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAEzE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AACxE,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAEpE,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAEjD,OAAO,EAAE,8BAA8B,EAAE,6BAA6B,EAAE,MAAM,gCAAgC,CAAA;AAC9G,YAAY,EAAE,uBAAuB,EAAE,8BAA8B,EAAE,MAAM,gCAAgC,CAAA;AAG7G,YAAY,EACX,SAAS,EACT,mBAAmB,EACnB,aAAa,EACb,sBAAsB,EACtB,mBAAmB,EACnB,aAAa,EACb,eAAe,EACf,iBAAiB,EACjB,UAAU,EACV,cAAc,EACd,cAAc,EACd,WAAW,EACX,UAAU,EACV,SAAS,EACT,WAAW,EACX,SAAS,EACT,iBAAiB,EACjB,cAAc,GACd,MAAM,mBAAmB,CAAA;AAG1B,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAA;AAGtC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAC9E,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAA;AAGlG,OAAO,EACN,cAAc,EACd,aAAa,EACb,IAAI,EACJ,MAAM,EACN,YAAY,EACZ,aAAa,EACb,UAAU,EACV,aAAa,EACb,WAAW,EACX,kBAAkB,EAClB,SAAS,EACT,QAAQ,EACR,SAAS,EACT,cAAc,EACd,eAAe,GACf,MAAM,kBAAkB,CAAA;AACzB,YAAY,EAAE,YAAY,EAAE,mBAAmB,EAAE,SAAS,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA"}
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAEtD,OAAO,EAAE,sBAAsB,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAA;AAGnE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAGxE,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAEjD,OAAO,EAAE,8BAA8B,EAAE,6BAA6B,EAAE,MAAM,gCAAgC,CAAA;AAuB9G,gFAAgF;AAChF,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAA;AAEtC,+EAA+E;AAC/E,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAG9E,+EAA+E;AAC/E,OAAO,EACN,cAAc,EACd,aAAa,EACb,IAAI,EACJ,MAAM,EACN,YAAY,EACZ,aAAa,EACb,UAAU,EACV,aAAa,EACb,WAAW,EACX,kBAAkB,EAClB,SAAS,EACT,QAAQ,EACR,SAAS,EACT,cAAc,EACd,eAAe,GACf,MAAM,kBAAkB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAEtD,OAAO,EAAE,sBAAsB,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAA;AAGnE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAGxE,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAEjD,OAAO,EAAE,8BAA8B,EAAE,6BAA6B,EAAE,MAAM,gCAAgC,CAAA;AAyB9G,gFAAgF;AAChF,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAA;AAEtC,+EAA+E;AAC/E,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAG9E,+EAA+E;AAC/E,OAAO,EACN,cAAc,EACd,aAAa,EACb,IAAI,EACJ,MAAM,EACN,YAAY,EACZ,aAAa,EACb,UAAU,EACV,aAAa,EACb,WAAW,EACX,kBAAkB,EAClB,SAAS,EACT,QAAQ,EACR,SAAS,EACT,cAAc,EACd,eAAe,GACf,MAAM,kBAAkB,CAAA"}
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { Router } from '../engine/router.js';
3
- import type { RouteMatch, RouteTree } from '../engine/types.js';
3
+ import type { RouteMatch, RouteTree, ShouldInterceptFn } from '../engine/types.js';
4
4
  /** Props passed to the middleware component. */
5
5
  export interface MiddlewareProps {
6
6
  /** Merged params from all matched routes. */
@@ -21,11 +21,14 @@ export type BuzolaProviderProps = {
21
21
  routes?: never;
22
22
  persistentParams?: never;
23
23
  pageRegistry?: never;
24
+ shouldIntercept?: never;
24
25
  } | {
25
26
  router?: never;
26
27
  routes: RouteTree;
27
28
  persistentParams?: () => Record<string, string>;
28
29
  pageRegistry?: Record<string, string>;
30
+ /** Decides per navigation whether the router handles it; see `RouterOptions.shouldIntercept`. */
31
+ shouldIntercept?: ShouldInterceptFn;
29
32
  });
30
33
  /**
31
34
  * Root provider for Buzola router.
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../../src/react/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8E,MAAM,OAAO,CAAA;AAElG,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAC5C,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAI/D,gDAAgD;AAChD,MAAM,WAAW,eAAe;IAC/B,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,2CAA2C;IAC3C,OAAO,EAAE,UAAU,EAAE,CAAA;IACrB,uCAAuC;IACvC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CACzB;AAED,gEAAgE;AAChE,MAAM,MAAM,mBAAmB,GAAG,KAAK,CAAC,aAAa,CAAC,eAAe,CAAC,CAAA;AAEtE,MAAM,MAAM,mBAAmB,GAC5B;IACD,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC1B,8FAA8F;IAC9F,UAAU,CAAC,EAAE,mBAAmB,CAAA;CAChC,GACC,CACC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,KAAK,CAAC;IAAC,gBAAgB,CAAC,EAAE,KAAK,CAAC;IAAC,YAAY,CAAC,EAAE,KAAK,CAAA;CAAE,GAClF;IACD,MAAM,CAAC,EAAE,KAAK,CAAA;IACd,MAAM,EAAE,SAAS,CAAA;IACjB,gBAAgB,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/C,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACrC,CACD,CAAA;AAEF;;;GAGG;AACH,wBAAgB,cAAc,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAAE,GAAG,KAAK,EAAE,EAAE,mBAAmB,GAAG,KAAK,CAAC,YAAY,CA+C9H"}
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../../src/react/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8E,MAAM,OAAO,CAAA;AAElG,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAC5C,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAIlF,gDAAgD;AAChD,MAAM,WAAW,eAAe;IAC/B,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,2CAA2C;IAC3C,OAAO,EAAE,UAAU,EAAE,CAAA;IACrB,uCAAuC;IACvC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CACzB;AAED,gEAAgE;AAChE,MAAM,MAAM,mBAAmB,GAAG,KAAK,CAAC,aAAa,CAAC,eAAe,CAAC,CAAA;AAEtE,MAAM,MAAM,mBAAmB,GAC5B;IACD,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC1B,8FAA8F;IAC9F,UAAU,CAAC,EAAE,mBAAmB,CAAA;CAChC,GACC,CACC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,KAAK,CAAC;IAAC,gBAAgB,CAAC,EAAE,KAAK,CAAC;IAAC,YAAY,CAAC,EAAE,KAAK,CAAC;IAAC,eAAe,CAAC,EAAE,KAAK,CAAA;CAAE,GAC3G;IACD,MAAM,CAAC,EAAE,KAAK,CAAA;IACd,MAAM,EAAE,SAAS,CAAA;IACjB,gBAAgB,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/C,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACrC,iGAAiG;IACjG,eAAe,CAAC,EAAE,iBAAiB,CAAA;CACnC,CACD,CAAA;AAEF;;;GAGG;AACH,wBAAgB,cAAc,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAAE,GAAG,KAAK,EAAE,EAAE,mBAAmB,GAAG,KAAK,CAAC,YAAY,CAqD9H"}
@@ -9,6 +9,11 @@ import { Outlet } from './outlet.js';
9
9
  * Subscribes to router state and provides context to the React tree.
10
10
  */
11
11
  export function BuzolaProvider({ routes, children, middleware: Middleware, ...props }) {
12
+ // The router is created once, so it reads the latest prop through a ref rather than the first render's closure.
13
+ const shouldInterceptRef = useRef(props.shouldIntercept);
14
+ useLayoutEffect(() => {
15
+ shouldInterceptRef.current = props.shouldIntercept;
16
+ });
12
17
  const routerRef = useRef(props.router);
13
18
  if (!routerRef.current) {
14
19
  routerRef.current = new Router({
@@ -16,6 +21,7 @@ export function BuzolaProvider({ routes, children, middleware: Middleware, ...pr
16
21
  adapter: createBrowserNavigationAdapter(),
17
22
  persistentParams: props.persistentParams,
18
23
  pageRegistry: props.pageRegistry,
24
+ shouldIntercept: info => shouldInterceptRef.current?.(info) ?? true,
19
25
  });
20
26
  }
21
27
  const router = routerRef.current;
@@ -1 +1 @@
1
- {"version":3,"file":"provider.js","sourceRoot":"","sources":["../../src/react/provider.tsx"],"names":[],"mappings":";AAAA,OAAO,KAAK,EAAE,EAAE,WAAW,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAA;AAClG,OAAO,EAAE,8BAA8B,EAAE,MAAM,iCAAiC,CAAA;AAChF,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAE5C,OAAO,EAAE,YAAY,EAA0B,aAAa,EAAE,MAAM,cAAc,CAAA;AAClF,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AA+BpC;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAAE,GAAG,KAAK,EAAuB;IACzG,MAAM,SAAS,GAAG,MAAM,CAAqB,KAAK,CAAC,MAAM,CAAC,CAAA;IAC1D,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,CAAC;QACxB,SAAS,CAAC,OAAO,GAAG,IAAI,MAAM,CAAC;YAC9B,MAAM,EAAE,MAAO;YACf,OAAO,EAAE,8BAA8B,EAAE;YACzC,gBAAgB,EAAE,KAAK,CAAC,gBAAgB;YACxC,YAAY,EAAE,KAAK,CAAC,YAAY;SAChC,CAAC,CAAA;IACH,CAAC;IACD,MAAM,MAAM,GAAG,SAAS,CAAC,OAAO,CAAA;IAChC,MAAM,SAAS,GAAG,WAAW,CAAC,CAAC,EAAc,EAAE,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IACjF,MAAM,WAAW,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IAClE,MAAM,KAAK,GAAG,oBAAoB,CAAC,SAAS,EAAE,WAAW,EAAE,WAAW,CAAC,CAAA;IAEvE,gCAAgC;IAChC,eAAe,CAAC,GAAG,EAAE;QACpB,OAAO,MAAM,CAAC,KAAK,EAAE,CAAA;IACtB,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IAEZ,MAAM,iBAAiB,GAAG,OAAO,CAAoB,GAAG,EAAE,CAAC,CAAC;QAC3D,KAAK;QACL,KAAK,EAAE,CAAC;QACR,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,MAAM,EAAE,EAAE;KACV,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAA;IAEZ,+CAA+C;IAC/C,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,EAAE;QAC9B,IAAI,MAAM,GAA2B,EAAE,CAAA;QACvC,KAAK,MAAM,KAAK,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;YACnC,MAAM,GAAG,EAAE,GAAG,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,EAAE,CAAA;QACxC,CAAC;QACD,OAAO,MAAM,CAAA;IACd,CAAC,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAA;IAEnB,MAAM,OAAO,GAAG,QAAQ,IAAI,KAAC,MAAM,KAAG,CAAA;IAEtC,OAAO,CACN,KAAC,aAAa,IAAC,KAAK,EAAE,MAAM,YAC3B,KAAC,YAAY,IAAC,KAAK,EAAE,iBAAiB,YACpC,UAAU;gBACV,CAAC,CAAC,KAAC,UAAU,IAAC,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,YAAG,OAAO,GAAc;gBAC/E,CAAC,CAAC,OAAO,GACI,GACA,CAChB,CAAA;AACF,CAAC"}
1
+ {"version":3,"file":"provider.js","sourceRoot":"","sources":["../../src/react/provider.tsx"],"names":[],"mappings":";AAAA,OAAO,KAAK,EAAE,EAAE,WAAW,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAA;AAClG,OAAO,EAAE,8BAA8B,EAAE,MAAM,iCAAiC,CAAA;AAChF,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAE5C,OAAO,EAAE,YAAY,EAA0B,aAAa,EAAE,MAAM,cAAc,CAAA;AAClF,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAiCpC;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAAE,GAAG,KAAK,EAAuB;IACzG,gHAAgH;IAChH,MAAM,kBAAkB,GAAG,MAAM,CAAC,KAAK,CAAC,eAAe,CAAC,CAAA;IACxD,eAAe,CAAC,GAAG,EAAE;QACpB,kBAAkB,CAAC,OAAO,GAAG,KAAK,CAAC,eAAe,CAAA;IACnD,CAAC,CAAC,CAAA;IACF,MAAM,SAAS,GAAG,MAAM,CAAqB,KAAK,CAAC,MAAM,CAAC,CAAA;IAC1D,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,CAAC;QACxB,SAAS,CAAC,OAAO,GAAG,IAAI,MAAM,CAAC;YAC9B,MAAM,EAAE,MAAO;YACf,OAAO,EAAE,8BAA8B,EAAE;YACzC,gBAAgB,EAAE,KAAK,CAAC,gBAAgB;YACxC,YAAY,EAAE,KAAK,CAAC,YAAY;YAChC,eAAe,EAAE,IAAI,CAAC,EAAE,CAAC,kBAAkB,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,IAAI,IAAI;SACnE,CAAC,CAAA;IACH,CAAC;IACD,MAAM,MAAM,GAAG,SAAS,CAAC,OAAO,CAAA;IAChC,MAAM,SAAS,GAAG,WAAW,CAAC,CAAC,EAAc,EAAE,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IACjF,MAAM,WAAW,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IAClE,MAAM,KAAK,GAAG,oBAAoB,CAAC,SAAS,EAAE,WAAW,EAAE,WAAW,CAAC,CAAA;IAEvE,gCAAgC;IAChC,eAAe,CAAC,GAAG,EAAE;QACpB,OAAO,MAAM,CAAC,KAAK,EAAE,CAAA;IACtB,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IAEZ,MAAM,iBAAiB,GAAG,OAAO,CAAoB,GAAG,EAAE,CAAC,CAAC;QAC3D,KAAK;QACL,KAAK,EAAE,CAAC;QACR,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,MAAM,EAAE,EAAE;KACV,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAA;IAEZ,+CAA+C;IAC/C,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,EAAE;QAC9B,IAAI,MAAM,GAA2B,EAAE,CAAA;QACvC,KAAK,MAAM,KAAK,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;YACnC,MAAM,GAAG,EAAE,GAAG,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,EAAE,CAAA;QACxC,CAAC;QACD,OAAO,MAAM,CAAA;IACd,CAAC,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAA;IAEnB,MAAM,OAAO,GAAG,QAAQ,IAAI,KAAC,MAAM,KAAG,CAAA;IAEtC,OAAO,CACN,KAAC,aAAa,IAAC,KAAK,EAAE,MAAM,YAC3B,KAAC,YAAY,IAAC,KAAK,EAAE,iBAAiB,YACpC,UAAU;gBACV,CAAC,CAAC,KAAC,UAAU,IAAC,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,YAAG,OAAO,GAAc;gBAC/E,CAAC,CAAC,OAAO,GACI,GACA,CAChB,CAAA;AACF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buzola/router",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -367,3 +367,36 @@ describe('matchRoutes', () => {
367
367
  expect(matches).toBeNull()
368
368
  })
369
369
  })
370
+
371
+ describe('matchRoutes with differently named params at the same position', () => {
372
+ const tree = createTestTree([
373
+ { path: '/app/programs/:programId/sessions/create', component: dummyComponent },
374
+ { path: '/app/programs/:id/sessions', component: dummyComponent },
375
+ { path: '/app/programs/:slug/files/:path+', component: dummyComponent },
376
+ ])
377
+
378
+ it('names the value by the route that matched, not the one registered first', () => {
379
+ const matches = matchRoutes(tree, new URL('http://localhost/app/programs/42/sessions'))
380
+ expect(matches!.at(-1)!.node.fullPath).toBe('/app/programs/:id/sessions')
381
+ expect(matches!.at(-1)!.params).toEqual({ id: '42' })
382
+ })
383
+
384
+ it('still gives the first route its own name', () => {
385
+ const matches = matchRoutes(tree, new URL('http://localhost/app/programs/42/sessions/create'))
386
+ expect(matches!.at(-1)!.params).toEqual({ programId: '42' })
387
+ })
388
+
389
+ it('names values before a catch-all by the matched route too', () => {
390
+ const matches = matchRoutes(tree, new URL('http://localhost/app/programs/42/files/a/b'))
391
+ expect(matches!.at(-1)!.params).toEqual({ slug: '42', path: 'a/b' })
392
+ })
393
+
394
+ it('does not leak a value from a branch that failed to match', () => {
395
+ const tree = createTestTree([
396
+ { path: '/a/:x/b', component: dummyComponent },
397
+ { path: '/a/:y/:z/c', component: dummyComponent },
398
+ ])
399
+ const matches = matchRoutes(tree, new URL('http://localhost/a/1/b/c'))
400
+ expect(matches!.at(-1)!.params).toEqual({ y: '1', z: 'b' })
401
+ })
402
+ })
@@ -1134,3 +1134,69 @@ describe('ErrorBoundary', () => {
1134
1134
  spy.mockRestore()
1135
1135
  })
1136
1136
  })
1137
+
1138
+ // ─── BuzolaProvider shouldIntercept ──────────────────────────────────────────
1139
+
1140
+ describe('BuzolaProvider shouldIntercept', () => {
1141
+ /* The provider builds its router on the browser adapter, so stand in for the slice of the
1142
+ Navigation API it uses and raise navigate events by hand. */
1143
+ interface FakeNavigateEvent {
1144
+ destination: { url: string }
1145
+ canIntercept: boolean
1146
+ navigationType: 'push'
1147
+ userInitiated: boolean
1148
+ intercept: () => void
1149
+ }
1150
+
1151
+ function withNavigation() {
1152
+ const listeners = new Set<(event: FakeNavigateEvent) => void>()
1153
+ Object.defineProperty(window, 'navigation', {
1154
+ value: {
1155
+ currentEntry: { url: 'http://localhost/', getState: () => undefined },
1156
+ addEventListener: (type: string, listener: (event: FakeNavigateEvent) => void) => listeners.add(listener),
1157
+ removeEventListener: (type: string, listener: (event: FakeNavigateEvent) => void) => listeners.delete(listener),
1158
+ },
1159
+ writable: true,
1160
+ configurable: true,
1161
+ })
1162
+ return function push(url: string) {
1163
+ const intercept = mock(() => {})
1164
+ for (const listener of listeners) {
1165
+ listener({ destination: { url }, canIntercept: true, navigationType: 'push', userInitiated: true, intercept })
1166
+ }
1167
+ return intercept
1168
+ }
1169
+ }
1170
+
1171
+ afterEach(() => {
1172
+ Reflect.deleteProperty(window, 'navigation')
1173
+ })
1174
+
1175
+ const routes = buildRouteTree([
1176
+ { path: '/', component: Home, isIndex: true },
1177
+ { path: '/about', component: About },
1178
+ ])
1179
+
1180
+ it('lets the browser take a navigation the prop declines', () => {
1181
+ const push = withNavigation()
1182
+ render(<BuzolaProvider routes={routes} shouldIntercept={() => false} />)
1183
+
1184
+ expect(push('http://localhost/about')).not.toHaveBeenCalled()
1185
+ })
1186
+
1187
+ it('consults the prop from the latest render', () => {
1188
+ const push = withNavigation()
1189
+ const { rerender } = render(<BuzolaProvider routes={routes} shouldIntercept={() => true} />)
1190
+
1191
+ rerender(<BuzolaProvider routes={routes} shouldIntercept={() => false} />)
1192
+
1193
+ expect(push('http://localhost/about')).not.toHaveBeenCalled()
1194
+ })
1195
+
1196
+ it('intercepts as before without the prop', () => {
1197
+ const push = withNavigation()
1198
+ render(<BuzolaProvider routes={routes} />)
1199
+
1200
+ expect(push('http://localhost/about')).toHaveBeenCalledTimes(1)
1201
+ })
1202
+ })
@@ -3,7 +3,7 @@ import { matchRoutes } from '../engine/matcher.js'
3
3
  import { createMemoryNavigationAdapter } from '../engine/navigation-adapter.js'
4
4
  import { buildRouteTree } from '../engine/route-tree.js'
5
5
  import { Router } from '../engine/router.js'
6
- import type { BuzolaNavigateEvent, NavigationAdapter, RouteConfig } from '../engine/types.js'
6
+ import type { BuzolaNavigateEvent, InterceptInfo, NavigationAdapter, RouteConfig, ShouldInterceptFn } from '../engine/types.js'
7
7
 
8
8
  function dummyComponent() {
9
9
  return null
@@ -12,7 +12,7 @@ function dummyComponent() {
12
12
  function createRouter(
13
13
  configs: RouteConfig[],
14
14
  initialURL = 'http://localhost/',
15
- options?: { persistentParams?: () => Record<string, string> },
15
+ options?: { persistentParams?: () => Record<string, string>; shouldIntercept?: ShouldInterceptFn },
16
16
  ) {
17
17
  const routes = buildRouteTree(configs)
18
18
  const adapter = createMemoryNavigationAdapter({ initialURL })
@@ -474,3 +474,266 @@ describe('Router.leaveApp', () => {
474
474
  expect(router.getState().location.pathname).toBe('/')
475
475
  })
476
476
  })
477
+
478
+ describe('Router reload', () => {
479
+ const flush = () => new Promise(resolve => setTimeout(resolve, 0))
480
+
481
+ it('lets a reload through to the browser so it loads a fresh document', () => {
482
+ const { adapter } = createRouter([{ path: '/list', component: dummyComponent }], 'http://localhost/list')
483
+
484
+ adapter.reload()
485
+
486
+ expect(adapter.reloadCount()).toBe(1)
487
+ })
488
+
489
+ it('asks the blockers first and reloads once they allow it', async () => {
490
+ const { router, adapter } = createRouter([{ path: '/list', component: dummyComponent }], 'http://localhost/list')
491
+ const blocker = mock(async () => true)
492
+ router.addBlocker(blocker)
493
+
494
+ adapter.reload()
495
+ await flush()
496
+
497
+ expect(blocker).toHaveBeenCalledTimes(1)
498
+ expect(adapter.reloadCount()).toBe(1)
499
+ })
500
+
501
+ it('does not reload when a blocker refuses', async () => {
502
+ const { router, adapter } = createRouter([{ path: '/list', component: dummyComponent }], 'http://localhost/list')
503
+ router.addBlocker(async () => false)
504
+
505
+ adapter.reload()
506
+ await flush()
507
+
508
+ expect(adapter.reloadCount()).toBe(0)
509
+ })
510
+
511
+ it('still intercepts a push to the current URL', () => {
512
+ const { router, adapter } = createRouter([{ path: '/list', component: dummyComponent }], 'http://localhost/list')
513
+ const before = router.getState().location
514
+
515
+ router.navigate('/list')
516
+
517
+ expect(adapter.reloadCount()).toBe(0)
518
+ expect(router.getState().location).not.toBe(before)
519
+ })
520
+ })
521
+
522
+ describe('Router shouldIntercept', () => {
523
+ const flush = () => new Promise(resolve => setTimeout(resolve, 0))
524
+ const configs: RouteConfig[] = [
525
+ { path: '/list', component: dummyComponent },
526
+ { path: '/about', component: dummyComponent },
527
+ ]
528
+
529
+ it('intercepts every navigation when the option is not set', () => {
530
+ const { router, adapter } = createRouter(configs, 'http://localhost/list')
531
+
532
+ router.navigate('/about')
533
+ router.back()
534
+
535
+ expect(router.getState().location.pathname).toBe('/list')
536
+ expect(adapter.reloadCount()).toBe(0)
537
+ })
538
+
539
+ it('intercepts a navigation it allows', () => {
540
+ const { router } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => true })
541
+
542
+ router.navigate('/about')
543
+
544
+ expect(router.getState().location.pathname).toBe('/about')
545
+ })
546
+
547
+ it('describes the navigation it is asked about', () => {
548
+ const shouldIntercept = mock((info: InterceptInfo) => true)
549
+ const { router } = createRouter(configs, 'http://localhost/list', { shouldIntercept })
550
+
551
+ router.navigate('/about?tab=team', { replace: true })
552
+
553
+ const info = shouldIntercept.mock.calls[0][0]
554
+ expect(info.destination.href).toBe('http://localhost/about?tab=team')
555
+ expect(info.from.href).toBe('http://localhost/list')
556
+ expect(info.navigationType).toBe('replace')
557
+ expect(info.userInitiated).toBe(false)
558
+ })
559
+
560
+ it('lets a declined push through to the browser as a document navigation', () => {
561
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => false })
562
+
563
+ router.navigate('/about')
564
+
565
+ expect(adapter.getCurrentURL().pathname).toBe('/about')
566
+ // The browser took it: the router neither rendered the destination nor turned it into a reload.
567
+ expect(router.getState().location.pathname).toBe('/list')
568
+ expect(adapter.reloadCount()).toBe(0)
569
+ })
570
+
571
+ it('lets a declined replace through to the browser as a document navigation', () => {
572
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => false })
573
+
574
+ router.navigate('/about', { replace: true })
575
+
576
+ expect(adapter.getCurrentURL().pathname).toBe('/about')
577
+ expect(router.getState().location.pathname).toBe('/list')
578
+ expect(adapter.reloadCount()).toBe(0)
579
+ })
580
+
581
+ it('reloads at the destination of a declined traversal', () => {
582
+ let intercept = true
583
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => intercept })
584
+ router.navigate('/about')
585
+
586
+ intercept = false
587
+ router.back()
588
+
589
+ expect(adapter.getCurrentURL().pathname).toBe('/list')
590
+ expect(adapter.reloadCount()).toBe(1)
591
+ expect(router.getState().location.pathname).toBe('/about')
592
+ })
593
+
594
+ it('intercepts a traversal it allows', () => {
595
+ const shouldIntercept = mock((info: InterceptInfo) => true)
596
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept })
597
+ router.navigate('/about')
598
+
599
+ router.back()
600
+
601
+ expect(shouldIntercept.mock.calls.at(-1)?.[0].navigationType).toBe('traverse')
602
+ expect(router.getState().location.pathname).toBe('/list')
603
+ expect(adapter.reloadCount()).toBe(0)
604
+ })
605
+
606
+ it('is not asked about a reload, which always goes to the browser', () => {
607
+ const shouldIntercept = mock(() => true)
608
+ const { adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept })
609
+
610
+ adapter.reload()
611
+
612
+ expect(shouldIntercept).not.toHaveBeenCalled()
613
+ expect(adapter.reloadCount()).toBe(1)
614
+ })
615
+
616
+ it('is not asked about a fragment navigation, which the browser keeps in the document anyway', () => {
617
+ const shouldIntercept = mock(() => false)
618
+ const { router } = createRouter(configs, 'http://localhost/list', { shouldIntercept })
619
+
620
+ router.navigate('/list#details')
621
+
622
+ expect(shouldIntercept).not.toHaveBeenCalled()
623
+ expect(router.getState().location.hash).toBe('#details')
624
+ })
625
+
626
+ it('is asked when the path changes along with the hash', () => {
627
+ const shouldIntercept = mock(() => false)
628
+ const { router } = createRouter(configs, 'http://localhost/list#details', { shouldIntercept })
629
+
630
+ router.navigate('/about#team')
631
+
632
+ expect(shouldIntercept).toHaveBeenCalledTimes(1)
633
+ expect(router.getState().location.pathname).toBe('/list')
634
+ })
635
+
636
+ it('is asked when the hash is dropped, since that loads a document', () => {
637
+ const shouldIntercept = mock(() => false)
638
+ const { router } = createRouter(configs, 'http://localhost/list#details', { shouldIntercept })
639
+
640
+ router.navigate('/list')
641
+
642
+ expect(shouldIntercept).toHaveBeenCalledTimes(1)
643
+ })
644
+
645
+ it('is not asked about a URL handed to the browser with leaveApp', () => {
646
+ const shouldIntercept = mock(() => true)
647
+ const { router } = createRouter(configs, 'http://localhost/list', { shouldIntercept })
648
+
649
+ router.leaveApp('/about')
650
+
651
+ expect(shouldIntercept).not.toHaveBeenCalled()
652
+ })
653
+
654
+ it('asks the blockers before a declined push and then loads the destination', async () => {
655
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => false })
656
+ const blocker = mock(async () => true)
657
+ router.addBlocker(blocker)
658
+
659
+ router.navigate('/about')
660
+ await flush()
661
+
662
+ expect(blocker).toHaveBeenCalledTimes(1)
663
+ expect(adapter.getCurrentURL().pathname).toBe('/about')
664
+ expect(adapter.reloadCount()).toBe(1)
665
+ expect(router.getState().location.pathname).toBe('/list')
666
+ })
667
+
668
+ it('does not load the destination of a declined navigation when a blocker refuses', async () => {
669
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => false })
670
+ router.addBlocker(async () => false)
671
+
672
+ router.navigate('/about')
673
+ await flush()
674
+
675
+ expect(adapter.reloadCount()).toBe(0)
676
+ expect(adapter.getCurrentURL().pathname).toBe('/about')
677
+ expect(router.getState().location.pathname).toBe('/list')
678
+ })
679
+
680
+ it('asks the blockers before a declined traversal and then loads the destination', async () => {
681
+ let intercept = true
682
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => intercept })
683
+ router.navigate('/about')
684
+ await flush()
685
+ const blocker = mock(async () => true)
686
+ router.addBlocker(blocker)
687
+
688
+ intercept = false
689
+ router.back()
690
+ await flush()
691
+
692
+ expect(blocker).toHaveBeenCalledTimes(1)
693
+ expect(adapter.getCurrentURL().pathname).toBe('/list')
694
+ expect(adapter.reloadCount()).toBe(1)
695
+ expect(router.getState().location.pathname).toBe('/about')
696
+ })
697
+
698
+ it('does not load the destination of a declined traversal when a blocker refuses', async () => {
699
+ let intercept = true
700
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept: () => intercept })
701
+ router.navigate('/about')
702
+ await flush()
703
+ router.addBlocker(async () => false)
704
+
705
+ intercept = false
706
+ router.back()
707
+ await flush()
708
+
709
+ expect(adapter.reloadCount()).toBe(0)
710
+ expect(adapter.getCurrentURL().pathname).toBe('/list')
711
+ expect(router.getState().location.pathname).toBe('/about')
712
+ })
713
+
714
+ it('treats a fragment of the committed entry as a fragment navigation while a navigation is pending', async () => {
715
+ let intercept = true
716
+ const shouldIntercept = mock(() => intercept)
717
+ const { router, adapter } = createRouter(configs, 'http://localhost/list', { shouldIntercept })
718
+ let blockerCalls = 0
719
+ router.addBlocker(async () => {
720
+ blockerCalls++
721
+ // The first navigation stays pending, like one held by an open unsaved-changes dialog.
722
+ if (blockerCalls === 1) return new Promise<boolean>(() => {})
723
+ return true
724
+ })
725
+ router.navigate('/about')
726
+ await flush()
727
+ expect(adapter.getCurrentURL().pathname).toBe('/about')
728
+ expect(router.getState().location.pathname).toBe('/list')
729
+
730
+ intercept = false
731
+ router.navigate('/about#team')
732
+ await flush()
733
+
734
+ // The browser keeps `/about#team` in the document, so declining it would load nothing.
735
+ expect(shouldIntercept).toHaveBeenCalledTimes(1)
736
+ expect(adapter.reloadCount()).toBe(0)
737
+ expect(router.getState().location.href).toBe('http://localhost/about#team')
738
+ })
739
+ })
@@ -1,4 +1,4 @@
1
- import type { RouteMatch, RouteTree, TrieNode } from './types.js'
1
+ import type { RouteMatch, RouteTree, TrieNode, TrieRoute } from './types.js'
2
2
 
3
3
  /**
4
4
  * Match a URL against the route trie.
@@ -10,20 +10,20 @@ import type { RouteMatch, RouteTree, TrieNode } from './types.js'
10
10
  export function matchRoutes(tree: RouteTree, url: URL): RouteMatch[] | null {
11
11
  const pathname = url.pathname
12
12
  const segments = pathname.split('/').filter(Boolean)
13
- return matchTrie(tree.root, segments, 0, {}, pathname)
13
+ return matchTrie(tree.root, segments, 0, [], pathname)
14
14
  }
15
15
 
16
16
  function matchTrie(
17
17
  node: TrieNode,
18
18
  segments: string[],
19
19
  index: number,
20
- params: Record<string, string>,
20
+ values: string[],
21
21
  pathname: string,
22
22
  ): RouteMatch[] | null {
23
23
  // All segments consumed — check if this trie node has a terminal route
24
24
  if (index === segments.length) {
25
25
  if (node.route) {
26
- return buildMatchChain(node.route.chain, params, pathname)
26
+ return buildMatchChain(node.route, values, pathname)
27
27
  }
28
28
  return null
29
29
  }
@@ -33,35 +33,34 @@ function matchTrie(
33
33
  // 1. Try static child first — O(1) Map lookup, always most specific
34
34
  const staticChild = node.staticChildren.get(segment)
35
35
  if (staticChild) {
36
- const result = matchTrie(staticChild, segments, index + 1, params, pathname)
36
+ const result = matchTrie(staticChild, segments, index + 1, values, pathname)
37
37
  if (result) return result
38
38
  }
39
39
 
40
40
  // 2. Try dynamic child — matches any single segment
41
41
  if (node.dynamicChild) {
42
- const newParams = { ...params, [node.dynamicChild.paramName]: decodeURIComponent(segment) }
43
- const result = matchTrie(node.dynamicChild.node, segments, index + 1, newParams, pathname)
42
+ const result = matchTrie(node.dynamicChild, segments, index + 1, [...values, decodeURIComponent(segment)], pathname)
44
43
  if (result) return result
45
44
  }
46
45
 
47
46
  // 3. Try catch-all child — consumes all remaining segments
48
47
  if (node.catchAllChild) {
49
48
  const remaining = segments.slice(index).map(s => decodeURIComponent(s)).join('/')
50
- const newParams = { ...params, [node.catchAllChild.paramName]: remaining }
51
- return buildMatchChain(node.catchAllChild.chain, newParams, pathname)
49
+ return buildMatchChain(node.catchAllChild, [...values, remaining], pathname)
52
50
  }
53
51
 
54
52
  return null
55
53
  }
56
54
 
57
55
  /**
58
- * Build a RouteMatch[] array from a chain of RouteNodes and collected params.
59
- * Each match in the chain gets all collected params (they're merged by the React layer anyway).
56
+ * Build a RouteMatch[] array for a matched route. Values are collected by position and named here,
57
+ * by the matched route's own pattern, since routes sharing a trie position may name it differently.
58
+ * Each match in the chain gets all params (they're merged by the React layer anyway).
60
59
  */
61
- function buildMatchChain(
62
- chain: import('./types.js').RouteNode[],
63
- params: Record<string, string>,
64
- pathname: string,
65
- ): RouteMatch[] {
66
- return chain.map(node => ({ node, params, pathname }))
60
+ function buildMatchChain(route: TrieRoute, values: string[], pathname: string): RouteMatch[] {
61
+ const params: Record<string, string> = {}
62
+ route.paramNames.forEach((name, i) => {
63
+ params[name] = values[i]
64
+ })
65
+ return route.chain.map(node => ({ node, params, pathname }))
67
66
  }