@xmachines/play-router 3.0.0 → 5.0.0

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 (85) hide show
  1. package/README.md +111 -116
  2. package/dist/base-route-map.d.ts +24 -13
  3. package/dist/base-route-map.d.ts.map +1 -1
  4. package/dist/base-route-map.js +116 -27
  5. package/dist/base-route-map.js.map +1 -1
  6. package/dist/build-tree.d.ts +16 -1
  7. package/dist/build-tree.d.ts.map +1 -1
  8. package/dist/build-tree.js +22 -4
  9. package/dist/build-tree.js.map +1 -1
  10. package/dist/create-route-map-from-tree.d.ts +13 -7
  11. package/dist/create-route-map-from-tree.d.ts.map +1 -1
  12. package/dist/create-route-map-from-tree.js +14 -6
  13. package/dist/create-route-map-from-tree.js.map +1 -1
  14. package/dist/errors.d.ts +2 -134
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/errors.js +11 -151
  17. package/dist/errors.js.map +1 -1
  18. package/dist/find-route.d.ts +13 -1
  19. package/dist/find-route.d.ts.map +1 -1
  20. package/dist/find-route.js +33 -26
  21. package/dist/find-route.js.map +1 -1
  22. package/dist/index.d.ts +8 -18
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +6 -12
  25. package/dist/index.js.map +1 -1
  26. package/dist/provider-lifecycle.d.ts +18 -6
  27. package/dist/provider-lifecycle.d.ts.map +1 -1
  28. package/dist/provider-lifecycle.js +10 -5
  29. package/dist/provider-lifecycle.js.map +1 -1
  30. package/dist/router-bridge-base.d.ts +73 -38
  31. package/dist/router-bridge-base.d.ts.map +1 -1
  32. package/dist/router-bridge-base.js +304 -124
  33. package/dist/router-bridge-base.js.map +1 -1
  34. package/dist/router-sync.d.ts +3 -27
  35. package/dist/router-sync.d.ts.map +1 -1
  36. package/dist/router-sync.js +27 -45
  37. package/dist/router-sync.js.map +1 -1
  38. package/dist/types.d.ts +206 -102
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/xstate/create-route-map.d.ts +30 -0
  41. package/dist/xstate/create-route-map.d.ts.map +1 -0
  42. package/dist/{create-route-map.js → xstate/create-route-map.js} +11 -6
  43. package/dist/xstate/create-route-map.js.map +1 -0
  44. package/dist/{extract-routes.d.ts → xstate/extract-routes.d.ts} +4 -2
  45. package/dist/xstate/extract-routes.d.ts.map +1 -0
  46. package/dist/{extract-routes.js → xstate/extract-routes.js} +11 -6
  47. package/dist/xstate/extract-routes.js.map +1 -0
  48. package/dist/xstate/index.d.ts +35 -0
  49. package/dist/xstate/index.d.ts.map +1 -0
  50. package/dist/xstate/index.js +33 -0
  51. package/dist/xstate/index.js.map +1 -0
  52. package/dist/{machine-to-graph.d.ts → xstate/machine-to-graph.d.ts} +1 -1
  53. package/dist/xstate/machine-to-graph.d.ts.map +1 -0
  54. package/dist/{machine-to-graph.js → xstate/machine-to-graph.js} +2 -2
  55. package/dist/xstate/machine-to-graph.js.map +1 -0
  56. package/dist/xstate/machine-types.d.ts +42 -0
  57. package/dist/xstate/machine-types.d.ts.map +1 -0
  58. package/dist/xstate/machine-types.js +2 -0
  59. package/dist/xstate/machine-types.js.map +1 -0
  60. package/dist/{query.d.ts → xstate/query.d.ts} +6 -4
  61. package/dist/xstate/query.d.ts.map +1 -0
  62. package/dist/{query.js → xstate/query.js} +3 -2
  63. package/dist/xstate/query.js.map +1 -0
  64. package/package.json +30 -21
  65. package/dist/base-path.d.ts +0 -209
  66. package/dist/base-path.d.ts.map +0 -1
  67. package/dist/base-path.js +0 -418
  68. package/dist/base-path.js.map +0 -1
  69. package/dist/create-route-map.d.ts +0 -44
  70. package/dist/create-route-map.d.ts.map +0 -1
  71. package/dist/create-route-map.js.map +0 -1
  72. package/dist/extract-routes.d.ts.map +0 -1
  73. package/dist/extract-routes.js.map +0 -1
  74. package/dist/framework-params.d.ts +0 -144
  75. package/dist/framework-params.d.ts.map +0 -1
  76. package/dist/framework-params.js +0 -291
  77. package/dist/framework-params.js.map +0 -1
  78. package/dist/machine-to-graph.d.ts.map +0 -1
  79. package/dist/machine-to-graph.js.map +0 -1
  80. package/dist/query.d.ts.map +0 -1
  81. package/dist/query.js.map +0 -1
  82. package/dist/url-pattern-utils.d.ts +0 -93
  83. package/dist/url-pattern-utils.d.ts.map +0 -1
  84. package/dist/url-pattern-utils.js +0 -233
  85. package/dist/url-pattern-utils.js.map +0 -1
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The class holds the logic that each router bridge shares, and that is 90% of the
5
5
  * code of every bridge:
6
- * - The TC39 Signal watcher of the direction from the actor to the router
6
+ * - The atom watcher of the direction from the actor to the router
7
7
  * - `lastSyncedPath`, for the echo suppression in the direction from the actor to the router
8
8
  * - The `isProcessingNavigation` flag, which stops a loop of a guard redirect in `syncActorFromRouter` only
9
9
  * - `syncRouterFromActor` and `syncActorFromRouter`, with the read of each URL parameter
@@ -42,11 +42,10 @@
42
42
  *
43
43
  * @see [Play RFC](../../docs/rfc/play.md) - invariant INV-04
44
44
  */
45
- import { Signal, watchSignal } from "@xmachines/play-signals";
46
- import { DuplicateBridgeError, RouterSyncError, URLPatternUnavailableError } from "./errors.js";
47
- import { buildPlayRouteEvent, extractQuery, extractRouteParams, matchRouteMap, sanitizePathname, } from "./router-sync.js";
48
- import { isParameterizedPattern } from "./url-pattern-utils.js";
49
- import { NO_BASE_PATH, firstMarkIndex, joinBasePath, resolveBasePath, stripBasePath, } from "./base-path.js";
45
+ import { NO_BASE_PATH, sanitizePathname, splitLocation, holdsUnsubstitutedParam, joinBasePath, parsePattern, resolveBasePath, stripBasePath, } from "@xmachines/play-url";
46
+ import { watchAtom } from "@xmachines/play-atom";
47
+ import { DuplicateBridgeError, RouterSyncError, InvalidRoutePatternError } from "./errors.js";
48
+ import { buildPlayRouteEvent, extractQuery, extractRouteParams, matchRouteMap, } from "./router-sync.js";
50
49
  /**
51
50
  * The registry of the actors with an active bridge connection, at the module level.
52
51
  *
@@ -85,19 +84,19 @@ export class RouterBridgeBase {
85
84
  /**
86
85
  * The raw value of `actor.currentRoute` that this bridge accounted for already.
87
86
  *
88
- * The field answers ONE question: "did the bridge see this value of the signal
87
+ * The field answers ONE question: "did the bridge see this value of the atom
89
88
  * before?". It is therefore a string, and not a location — a route that resolves to
90
89
  * no URL, such as an unknown stateId or a parameterized pattern, still stops a
91
90
  * second attempt at the same value.
92
91
  *
93
92
  * The constructor and `connect()` seed it, so that the first fire of the watcher of
94
- * the signal pushes nothing for a route that moved nowhere.
93
+ * the atom pushes nothing for a route that moved nowhere.
95
94
  */
96
95
  lastActorRoute = null;
97
96
  /**
98
97
  * The flag guards `syncActorFromRouter` against a re-entrant call from a guard
99
98
  * redirect of the actor itself. Such a call has this sequence: the bridge sends to
100
- * the actor, the signal fires, the bridge pushes to the router, and a second
99
+ * the actor, the atom fires, the bridge pushes to the router, and a second
101
100
  * `syncActorFromRouter` call starts before the first one returns.
102
101
  *
103
102
  * The flag is NOT the echo suppression of the direction from the actor to the
@@ -169,6 +168,13 @@ export class RouterBridgeBase {
169
168
  * the echo suppression therefore keeps comparing like with like.
170
169
  */
171
170
  mount = NO_BASE_PATH;
171
+ /**
172
+ * The caches that every parse and every compilation of this bridge reads.
173
+ *
174
+ * `resolveNavigationPath` and `isActorAtPath` parse a CONCRETE location, and an
175
+ * application writes one for each value that it puts in a param.
176
+ */
177
+ compileOptions;
172
178
  /**
173
179
  * @param actor - A `RoutableActor`, with `currentRoute`, `initialRoute`, and `send`.
174
180
  * @param routeMap - The route map of both directions, for the resolution between a
@@ -181,11 +187,19 @@ export class RouterBridgeBase {
181
187
  constructor(actor, routeMap, options) {
182
188
  this.actor = actor;
183
189
  this.routeMap = routeMap;
190
+ // The caches of this bridge, or the shared defaults. `resolveNavigationPath` and
191
+ // `isActorAtPath` parse a CONCRETE location — one for each value that the
192
+ // application puts in a param — so a server that holds a bridge for each request
193
+ // passes caches that the request owns and drops.
194
+ this.compileOptions = {
195
+ patternCache: options?.patternCache,
196
+ compiledCache: options?.compiledCache,
197
+ };
184
198
  // Resolve the mount BEFORE the seed of lastSyncedPath: an invalid basePath must
185
199
  // fail here, at the construction, and not on the first navigation.
186
200
  this.mount = resolveBasePath(options?.basePath, options?.basePathParams);
187
201
  // Record the route of the actor as accounted for. This stops a loop in the first
188
- // synchronization: the watcher of the signal fires once when `connect()` arms it,
202
+ // synchronization: the watcher of the atom fires once when `connect()` arms it,
189
203
  // and a route that moved nowhere must push nothing.
190
204
  //
191
205
  // The seed goes to `lastActorRoute`, and NOT to `lastSyncedPath`. The route of the
@@ -420,7 +434,7 @@ export class RouterBridgeBase {
420
434
  /**
421
435
  * Connects the router bridge to the Actor.
422
436
  *
423
- * The method installs the TC39 Signal watcher of the direction from the actor to the
437
+ * The method installs the atom watcher of the direction from the actor to the
424
438
  * router. It then starts the watch of the router changes, which each framework does
425
439
  * in its own way.
426
440
  *
@@ -467,14 +481,14 @@ export class RouterBridgeBase {
467
481
  this.hasConnectedOnce = true;
468
482
  // From here on, connect() gives all or nothing. THREE steps below can throw, and
469
483
  // the rollback has to cover each of them:
470
- // - `createRouteWatcher`, on a signal that refuses a watcher
484
+ // - `createRouteWatcher`, on an atom that refuses a watcher
471
485
  // - `watchRouterChanges()`, which belongs to the adapter: `afterNavigate()` of
472
486
  // SvelteKit raises `lifecycle_outside_component` outside the initialization of
473
487
  // a component, and a host reaches that by calling `connectRouter` from a module
474
488
  // scope or a store
475
- // - `performInitialSync()`, with a URLPatternUnavailableError for a parameterized
476
- // route on a runtime without URLPattern, and a RouterSyncError for each other
477
- // failure of the send to the actor
489
+ // - `performInitialSync()`, with an InvalidRoutePatternError for a route that
490
+ // URLPattern refuses, and a RouterSyncError for each other failure of the send
491
+ // to the actor
478
492
  //
479
493
  // The throw needs an action of the caller, and it therefore goes to the caller
480
494
  // without a change.
@@ -483,12 +497,13 @@ export class RouterBridgeBase {
483
497
  // cleanup. Every part above must go away again, or it leaks:
484
498
  // - the router listener keeps firing, and it drives the actor through a bridge
485
499
  // that the caller believes to be dead
486
- // - the signal watcher keeps pushing each actor route to the router
500
+ // - the atom watcher keeps pushing each actor route to the router
487
501
  // - the activeBridges entry keeps the actor, so the NEXT connect() call of the
488
502
  // caller for this actor throws a DuplicateBridgeError. A repair of the real
489
- // cause, for example a load of the polyfill, rescues that actor never.
503
+ // cause, for example a correction of the route pattern, rescues that actor
504
+ // never.
490
505
  try {
491
- // The TC39 Signal watcher of the direction from the actor to the router.
506
+ // The atom watcher of the direction from the actor to the router.
492
507
  this.routeWatcher = createRouteWatcher(this.actor.currentRoute, (route) => {
493
508
  this.syncRouterFromActor(route);
494
509
  });
@@ -501,7 +516,8 @@ export class RouterBridgeBase {
501
516
  // guard of its own, because `unwatchRouterChanges()` belongs to the adapter and
502
517
  // it can throw: a Vue scope that stops, a Solid disposer, a SvelteKit teardown.
503
518
  // That error must NOT replace the one the rollback exists to surface — a caller
504
- // that receives it never learns that its runtime needs a URLPattern polyfill.
519
+ // that receives it learns neither which route pattern URLPattern refused nor
520
+ // which event the actor refused.
505
521
  try {
506
522
  this.disconnect();
507
523
  }
@@ -541,6 +557,18 @@ export class RouterBridgeBase {
541
557
  */
542
558
  performInitialSync(initialRouterPath) {
543
559
  const initialActorRoute = this.actor.currentRoute.get();
560
+ // ONE read of the query of the router, and a LAZY one. The read belongs to the
561
+ // adapter and it can walk the state of a router, so a second call can answer
562
+ // differently — and most branches below reach no query at all.
563
+ let search;
564
+ let searchRead = false;
565
+ const routerSearch = () => {
566
+ if (!searchRead) {
567
+ search = this.getInitialRouterSearch();
568
+ searchRead = true;
569
+ }
570
+ return search;
571
+ };
544
572
  if (typeof initialRouterPath === "string") {
545
573
  // `sanitizePathname` refuses a path of more than 2048 characters, and the RAW
546
574
  // value is no substitute for the clean one: it still carries a query, a
@@ -575,8 +603,15 @@ export class RouterBridgeBase {
575
603
  // The decision below IS the first synchronization, and it runs against a location
576
604
  // under the mount, so nothing is owed any more.
577
605
  this.hasSynchronizedUnderMount = true;
578
- // Test if the actor is at the current location of the router already.
579
- const actorAlreadyAtRouterLocation = this.isActorAtLocation(machinePath, initialActorRoute);
606
+ // Test if the actor is at the current location of the router already. The PATH
607
+ // and the QUERY are two answers of ONE lookup: the path decides whether a restore
608
+ // is possible at all, and the query decides whether the two sides are in step
609
+ // there. `routerSearch` runs behind the `&&`, so a branch that answers on the
610
+ // path alone reads no query.
611
+ const actorAtRouterPath = this.isActorAtPath(machinePath, initialActorRoute);
612
+ const actorAlreadyAtRouterLocation = actorAtRouterPath &&
613
+ initialActorRoute != null &&
614
+ sameQuery(initialActorRoute, routerSearch());
580
615
  // The two are in step already, so nothing is synchronized — but the location has
581
616
  // to be RECORDED, or the echo suppression starts disarmed. `setBasePath` clears
582
617
  // `lastSyncedPath` on a move of the prefix, and this branch left it null: the
@@ -590,7 +625,7 @@ export class RouterBridgeBase {
590
625
  // of one connection, and the record of the old prefix says nothing about this
591
626
  // location.
592
627
  if (actorAlreadyAtRouterLocation) {
593
- this.lastSyncedPath = withSearch(machinePath, this.getInitialRouterSearch());
628
+ this.lastSyncedPath = withSearch(machinePath, routerSearch());
594
629
  }
595
630
  else {
596
631
  // The path of the router is different from the actor route. Is this a deep link, or
@@ -606,16 +641,33 @@ export class RouterBridgeBase {
606
641
  //
607
642
  // The detection: the URL of the router is the initial route of the machine, AND the
608
643
  // actor is at a different route. This is then a restore.
644
+ //
645
+ // The branch reads the LOCATION test above, through the `else` that carries it,
646
+ // and not the path test alone: a fragment that the actor carries and the router
647
+ // reports never — `sanitizePathname` cuts it off — is a difference that only
648
+ // this push can place in the address bar. A query of the ACTOR is such a
649
+ // difference too, and issue #18 carries the decision about it.
650
+ //
651
+ // `carriesNoOwnSuffix` is what keeps the two apart. An actor that stands on the
652
+ // PATH of the router and carries no query and no fragment of its own restores
653
+ // NOTHING: the address bar already shows its place, and a push only rewrites the
654
+ // location that the user opened. The raw comparison against `initialRoute` cannot
655
+ // see that, because a stateId is never string-equal to a path — an actor at
656
+ // `"#home"` against a router at `"/?ref=nav"` read as a move, so the bridge
657
+ // pushed `"/"` over the query the user opened and sent no `play.route` that
658
+ // carried it. The router keeps the priority for such a location, which is the
659
+ // answer that the same bridge gives for the actor route `"/"`.
609
660
  if (initialActorRoute &&
610
661
  machinePath === this.actor.initialRoute &&
611
- initialActorRoute !== this.actor.initialRoute) {
662
+ initialActorRoute !== this.actor.initialRoute &&
663
+ !(actorAtRouterPath && carriesNoOwnSuffix(initialActorRoute))) {
612
664
  this.pushHoldingNavigation(initialActorRoute);
613
665
  }
614
666
  else {
615
- // The search is read HERE, and not at the top of the method: the read belongs
616
- // to the adapter, it can walk the state of a router, and every branch above
617
- // returns without it.
618
- this.syncActorFromRouter(initialRouterPath, this.getInitialRouterSearch());
667
+ // The search is read through `routerSearch`, which reads it one time and only
668
+ // when a branch needs it: the read belongs to the adapter, and it can walk
669
+ // the state of a router.
670
+ this.syncActorFromRouter(initialRouterPath, routerSearch());
619
671
  }
620
672
  }
621
673
  }
@@ -641,7 +693,7 @@ export class RouterBridgeBase {
641
693
  /**
642
694
  * Disconnects the router bridge from the Actor.
643
695
  *
644
- * The method stops the watch of the signal, and it removes the router listener of the
696
+ * The method stops the watch of the atom, and it removes the router listener of the
645
697
  * framework.
646
698
  */
647
699
  disconnect() {
@@ -692,7 +744,7 @@ export class RouterBridgeBase {
692
744
  }
693
745
  // ── The sync methods. They are protected, and a subclass overrides one for its own behavior ──
694
746
  /**
695
- * Writes the location of the router when the route signal of the actor changes.
747
+ * Writes the location of the router when the route atom of the actor changes.
696
748
  *
697
749
  * The method resolves the actor route to a concrete URL path, then it calls
698
750
  * navigateRouter() for the navigation of the framework. When it cannot resolve the
@@ -791,7 +843,7 @@ export class RouterBridgeBase {
791
843
  const owesFirstSync = !this.hasSynchronizedUnderMount;
792
844
  this.hasSynchronizedUnderMount = true;
793
845
  const restoredRoute = this.actor.currentRoute.get();
794
- // `isActorAtLocation` is the test that `performInitialSync` makes BEFORE it looks
846
+ // `isActorAtPath` is the test that `performInitialSync` makes BEFORE it looks
795
847
  // for a restore, and both branches of the owed synchronization below need it. The
796
848
  // actor route can be a stateId, and a stateId is never string-equal to
797
849
  // `initialRoute`, which is a path: an actor that sits at "#home" of the mount point
@@ -801,13 +853,23 @@ export class RouterBridgeBase {
801
853
  //
802
854
  // ONE call, because `getStateIdByPath` and `getPathByStateId` belong to the route
803
855
  // map: a map of a consumer can answer differently on a second call, and the two
804
- // branches would then disagree about the same location.
805
- const actorAtLocation = owesFirstSync && this.isActorAtLocation(machinePath, restoredRoute);
856
+ // branches would then disagree about the same location. The PATH and the QUERY are
857
+ // two answers of that one lookup, exactly as `performInitialSync` reads them.
858
+ const actorAtPath = owesFirstSync && this.isActorAtPath(machinePath, restoredRoute);
859
+ const actorAtLocation = actorAtPath && restoredRoute != null && sameQuery(restoredRoute, search);
860
+ // The RESTORE reads the LOCATION test, exactly as `performInitialSync` does, and it
861
+ // refuses the same empty restore: an actor that stands on the PATH of the location
862
+ // and carries no query and no fragment of its own has nothing to put in the address
863
+ // bar. Without that test an actor at `"#home"` met a host that came back to the
864
+ // mount point with a query, and the bridge pushed the mount point over it and
865
+ // returned `true` — so no `play.route` carried that location to the machine either,
866
+ // which is the fault that the comment above this block names.
806
867
  if (owesFirstSync &&
807
868
  restoredRoute &&
808
869
  machinePath === this.actor.initialRoute &&
809
870
  restoredRoute !== this.actor.initialRoute &&
810
- !actorAtLocation) {
871
+ !actorAtLocation &&
872
+ !(actorAtPath && carriesNoOwnSuffix(restoredRoute))) {
811
873
  return this.pushUnderMount(restoredRoute);
812
874
  }
813
875
  if (owesFirstSync) {
@@ -879,12 +941,12 @@ export class RouterBridgeBase {
879
941
  if (this.isProcessingNavigation)
880
942
  return true;
881
943
  // The route has to resolve to a concrete path BEFORE the method reports that it
882
- // handled the location. `pushResolvedRoute` writes nothing for an unknown stateId
883
- // and for a parameterized pattern, and it still records the RAW value in
884
- // `lastSyncedPath`: a `true` return then left the location in neither side — the
885
- // URL kept the page of the host, the actor heard no `play.route`, and the record of
886
- // the location that the address bar holds was gone, so no later return could be
887
- // recognized either. `reconcileLocationWithMount` guards the same case.
944
+ // handled the location. `pushResolvedRoute` navigates nowhere for an unknown stateId
945
+ // and for a parameterized pattern, and it records the RAW value in `lastActorRoute`
946
+ // alone: a `true` return then left the location in neither side — the URL kept the
947
+ // page of the host, and the actor heard no `play.route`. The record of the location
948
+ // that the address bar holds says nothing about it either, so no later return could
949
+ // be recognized. `reconcileLocationWithMount` guards the same case.
888
950
  //
889
951
  // The resolution travels with the route, so `resolveNavigationPath` runs one time:
890
952
  // the method is `protected`, a subclass can override it, and a second call can
@@ -920,7 +982,7 @@ export class RouterBridgeBase {
920
982
  * reports, and `sanitizePathname` has removed the fragment from that already.
921
983
  */
922
984
  testEchoOfLastPush(route) {
923
- // The FIRST question: did the bridge account for this value of the signal already?
985
+ // The FIRST question: did the bridge account for this value of the atom already?
924
986
  // The test is an exact string compare, because it asks about the VALUE. A route
925
987
  // that resolves to no URL — an unknown stateId, a parameterized pattern — is
926
988
  // therefore covered too, and the bridge tries it one time only.
@@ -937,7 +999,7 @@ export class RouterBridgeBase {
937
999
  if (sameLocation(route, location))
938
1000
  return { isEcho: true };
939
1001
  // A route that is a stateId answers it only after the resolution. The machine that
940
- // mirrors an inbound `play.route` back into its route signal reaches this line:
1002
+ // mirrors an inbound `play.route` back into its route atom reaches this line:
941
1003
  // the bridge wrote "/about" and the actor answers "#about", which is ONE location.
942
1004
  // A compare of the raw value alone missed, and the bridge pushed a location that
943
1005
  // the router already held — one history entry that BACK cannot escape.
@@ -963,8 +1025,9 @@ export class RouterBridgeBase {
963
1025
  * pattern, for a wildcard pattern, and for an unknown id, the method skips the push
964
1026
  * completely: a push of the raw value writes a stateId, a `:param` pattern, or a
965
1027
  * literal `*` into the browser URL. The method still writes the raw route to
966
- * lastSyncedPath. Therefore the dedup guard fires correctly on the next identical
967
- * value of the signal.
1028
+ * lastActorRoute, and it leaves lastSyncedPath as it stands, because that field
1029
+ * records a LOCATION and no location changed. Therefore the dedup guard fires
1030
+ * correctly on the next identical value of the atom.
968
1031
  *
969
1032
  * @param route - The raw value of the actor route.
970
1033
  * @param resolved - The concrete path of that route. The default resolves it here. A
@@ -973,7 +1036,7 @@ export class RouterBridgeBase {
973
1036
  */
974
1037
  pushResolvedRoute(route, resolved = this.resolveNavigationPath(route)) {
975
1038
  // The VALUE is accounted for either way, so the dedup stops the next identical fire
976
- // of the signal even when the route resolves to no URL.
1039
+ // of the atom even when the route resolves to no URL.
977
1040
  this.lastActorRoute = route;
978
1041
  // The LOCATION record takes the concrete path only. A raw stateId here described no
979
1042
  // location that a router ever showed, and `isEchoOfLastSync` compares this field
@@ -1071,7 +1134,7 @@ export class RouterBridgeBase {
1071
1134
  }
1072
1135
  // Record the location BEFORE the send, and record it with its query.
1073
1136
  //
1074
- // The route signal of the actor notifies the watcher of the other direction
1137
+ // The route atom of the actor notifies the watcher of the other direction
1075
1138
  // for this same move, and `syncRouterFromActor` then tests the route of the
1076
1139
  // actor against this field. Two details decide that test:
1077
1140
  //
@@ -1086,7 +1149,7 @@ export class RouterBridgeBase {
1086
1149
  this.lastSyncedPath = withSearch(nextRoute.pathname, search);
1087
1150
  // The router moved, and the actor has not answered yet, so the value that the
1088
1151
  // bridge accounted for describes the agreement of the two sides NO MORE. A value
1089
- // that stayed read the answer of the actor as a repeat of the signal: a guard
1152
+ // that stayed read the answer of the actor as a repeat of the atom: a guard
1090
1153
  // redirect BACK to the route the actor came from — the route that the seed of
1091
1154
  // `connect()` recorded — stopped at the dedup, the bridge pushed nothing, and the
1092
1155
  // address bar kept the location that the guard refused.
@@ -1094,10 +1157,11 @@ export class RouterBridgeBase {
1094
1157
  this.actor.send(nextRoute.event);
1095
1158
  }
1096
1159
  catch (error) {
1097
- // A URLPatternUnavailableError needs an action of the user: it goes to the caller
1098
- // without a change. The caller can therefore find it, and ask the user for a
1099
- // URLPattern polyfill.
1100
- if (error instanceof URLPatternUnavailableError) {
1160
+ // An InvalidRoutePatternError needs an action of the DEVELOPER: a route of the
1161
+ // machine declares a pattern that URLPattern refuses, and no location can fix it.
1162
+ // It goes to the caller without a change, so that the caller can find it and read
1163
+ // the pattern that it names.
1164
+ if (error instanceof InvalidRoutePatternError) {
1101
1165
  throw error;
1102
1166
  }
1103
1167
  throw new RouterSyncError("Failed to sync actor state from router location.", {
@@ -1112,22 +1176,20 @@ export class RouterBridgeBase {
1112
1176
  /**
1113
1177
  * Reads the path parameters of a URL, with the URLPattern API.
1114
1178
  *
1115
- * The method reads `globalThis.URLPattern` at run time, because this library imports
1116
- * no polyfill. When `URLPattern` is absent and the route of the match holds a
1117
- * parameterized segment, the method throws a `URLPatternUnavailableError`. A caller
1118
- * must therefore give a polyfill in an environment without the native URLPattern:
1119
- * Node.js < 24, and an older browser.
1179
+ * The API is always present: `@xmachines/play-router` uses the native URLPattern of the
1180
+ * runtime, and it falls back to `urlpattern-polyfill`, which it carries as an ordinary
1181
+ * dependency. A consumer therefore loads nothing, whatever the runtime.
1120
1182
  *
1121
1183
  * @param pathname - The real URL path, for example '/profile/john'
1122
1184
  * @param stateId - The stateId of the match, for the lookup of the route pattern
1123
1185
  * @returns The path parameters of the read, or an empty object when nothing matches
1124
- * @throws {URLPatternUnavailableError} When URLPattern is absent and the route holds a parameter
1186
+ * @throws {InvalidRoutePatternError} When URLPattern cannot compile the route pattern
1125
1187
  */
1126
1188
  extractParams(pathname, stateId) {
1127
1189
  const pattern = this.routeMap.getPathByStateId(stateId);
1128
1190
  if (!pattern)
1129
1191
  return {};
1130
- return extractRouteParams(pathname, pattern);
1192
+ return extractRouteParams(pathname, pattern, this.compileOptions);
1131
1193
  }
1132
1194
  /**
1133
1195
  * Resolves a value of an actor route to a concrete URL path for a navigation.
@@ -1140,6 +1202,9 @@ export class RouterBridgeBase {
1140
1202
  * `/docs/*`, because no concrete value is available for a real URL
1141
1203
  * - a string that is no path and no known stateId
1142
1204
  *
1205
+ * A LITERAL route of the map comes back with each escape resolved: `/tags/c\+\+` gives
1206
+ * the path `/tags/c++`, which is the path that the route matches.
1207
+ *
1143
1208
  * A lookup of a stateId tries the form `"#stateId"` and the bare form `"stateId"`,
1144
1209
  * through `lookupPathByStateId`. Therefore a route map of your own that holds one
1145
1210
  * form only still resolves the path.
@@ -1157,19 +1222,70 @@ export class RouterBridgeBase {
1157
1222
  const path = mapped ?? (route.startsWith("/") ? route : null);
1158
1223
  if (path === null)
1159
1224
  return null;
1160
- // The PATHNAME half alone decides whether this is a pattern. A query holds a ":"
1161
- // and a "*" legally — a time in "?at=10:30", a URL in "?next=https://x", a glob in
1162
- // "?q=a*" — and a route of the actor carries its query, because `context.query`
1163
- // travels in `currentRoute`. A test of the whole string read such a route as a
1164
- // pattern: `pushResolvedRoute` wrote `lastSyncedPath` and navigated nowhere, so the
1165
- // URL stopped following the actor, and the dedup guard then suppressed the next
1166
- // attempt at the same route as an echo.
1167
- if (isParameterizedPattern(pathnameOf(path)))
1225
+ // A LITERAL route resolves to the one path that it matches, and the source of a
1226
+ // pattern is not always that path: `/tags/c\+\+` matches `/tags/c++`, and
1227
+ // `/i18n/{en}` matches `/i18n/en`. A bridge that pushed the source wrote the
1228
+ // backslashes and the braces into the address bar, where `getStateIdByPath` answered
1229
+ // that location never — so the URL stopped following the actor, and its next move to
1230
+ // the same route was suppressed as an echo of that push.
1231
+ //
1232
+ // The two branches above resolve DIFFERENT amounts, and the difference is what the
1233
+ // string is:
1234
+ //
1235
+ // - A route of the MAP is a pattern source, so every construct in it is grammar and
1236
+ // the parse resolves all of it.
1237
+ // - A route that the ACTOR built is a path already. `buildRouteUrl` of
1238
+ // `@xmachines/play-xstate` reads the whole grammar and resolves every escape, so
1239
+ // a route that IT built carries no backslash. An actor of another kind can still
1240
+ // write one, and a BACKSLASH is the one construct that means grammar in such a
1241
+ // path — and nothing else does. A brace in such a path is a real brace, and
1242
+ // `/i18n/{en}` from an actor is the location `/i18n/{en}`.
1243
+ //
1244
+ // A route of the map is a pattern source WHOLE, and its `?` is a modifier and never
1245
+ // a query. The parse therefore reads all of it, and its answer is complete: a `null`
1246
+ // literal path says that the pattern describes a SET of paths, so the bridge pushes
1247
+ // nothing. A fall to the concrete-path test below answered that question with the
1248
+ // characters of the string instead, and it let every pattern whose grammar carries
1249
+ // no ":" and no "*" through — `/docs/rfc(2119)`, `/books{/v2}?` and `/files/(\d+)`
1250
+ // all reached the address bar as they stand, where `getStateIdByPath` answers them
1251
+ // never.
1252
+ if (mapped != null)
1253
+ return parsePattern(path, this.compileOptions).literalPath;
1254
+ // A route of the ACTOR carries its query, because `context.query` travels in
1255
+ // `currentRoute`, and the PATHNAME half alone reaches the grammar for it. A query
1256
+ // holds a ":" and a "*" legally — a time in "?at=10:30", a URL in "?next=https://x",
1257
+ // a glob in "?q=a*" — and it holds the "?" that the grammar reads as a modifier. A
1258
+ // parse of the whole string called such a route malformed and resolved nothing of
1259
+ // it, so `/tags/c\+\+?ref=nav` went to the address bar with both backslashes; a
1260
+ // PATTERN test of the whole string called it a pattern, so `pushResolvedRoute` wrote
1261
+ // `lastSyncedPath` and navigated nowhere, and the dedup guard then suppressed the
1262
+ // next attempt at the same route as an echo. The suffix carries no grammar, and it
1263
+ // travels to the router unchanged.
1264
+ const { pathname, suffix } = splitLocation(path);
1265
+ // A BACKSLASH is the one construct that reaches an actor route unresolved, and this
1266
+ // is where it resolves.
1267
+ if (pathname.includes("\\")) {
1268
+ const literal = parsePattern(pathname, this.compileOptions).literalPath;
1269
+ // The resolution FAILED, so this route resolves to no concrete path and the
1270
+ // bridge pushes nothing. A fall to the test below refused none of it — a backslash
1271
+ // is neither a ":" nor a "*" — so the SOURCE went to the address bar with its
1272
+ // backslashes, where `getStateIdByPath` answers that location never, and
1273
+ // `pushResolvedRoute` then recorded it and suppressed the next attempt at the same
1274
+ // route as an echo. `encodeURIComponent` leaves "(", ")", "!", "'" and "~" as they
1275
+ // stand, so a param value of `f(x)` under a template that carries an escape —
1276
+ // `/files/c\+\+/:name` — reaches this line and derives no literal path.
1277
+ return literal === null ? null : `${literal}${suffix}`;
1278
+ }
1279
+ // The test is `holdsUnsubstitutedParam` and NOT `isParameterizedPattern`, because
1280
+ // this string is a concrete path and not a pattern. Its own documentation gives the
1281
+ // reason: a real param value carries a character of the grammar often enough, and
1282
+ // the grammar reads a modifier in "/tags/c++" and a group in "/i18n/{en}".
1283
+ if (holdsUnsubstitutedParam(pathname))
1168
1284
  return null;
1169
1285
  return path;
1170
1286
  }
1171
1287
  /**
1172
- * Tells you whether the actor already stands at a location of the router.
1288
+ * Tells you whether the actor already stands on the PATH of a location of the router.
1173
1289
  *
1174
1290
  * The `currentRoute` of the actor can be a stateId, for example `"#app.home"`, while a
1175
1291
  * location of the router is a URL path, for example `"/home"`. Therefore the method
@@ -1178,33 +1294,69 @@ export class RouterBridgeBase {
1178
1294
  * `takeReturnUnderMount`, it reads a location that the HOST chose as a restore of the
1179
1295
  * actor and writes over it.
1180
1296
  *
1181
- * The actor is at the location in three cases: the stateId of the match IS the actor
1297
+ * The actor is on the path in three cases: the stateId of the match IS the actor
1182
1298
  * route, the path of the match IS the actor route, or the actor route is a stateId
1183
1299
  * that resolves to the same registered path as the stateId of the match. Each lookup
1184
1300
  * goes through {@link lookupPathByStateId}, which tries the form `"#stateId"` and also
1185
1301
  * the bare form `"stateId"`. Therefore a route map with `"#about"` recognizes an actor
1186
1302
  * route of `"about"`, and the opposite also works, and this is correct for a
1187
- * structural map of your own that holds one form. An actor route with a concrete path,
1188
- * which starts with `"/"`, goes to a direct comparison with the path of the router
1189
- * only.
1190
- *
1191
- * Each lookup runs ONE time. `getPathByStateId` belongs to the route map: a map of a
1192
- * consumer can answer differently on a second call, and `RouteMap` pays a cache lookup
1193
- * for each of them.
1194
- *
1195
- * @param machinePath - The machine half of the location of the router.
1196
- * @param actorRoute - The raw value of the actor route.
1303
+ * structural map of your own that holds one form.
1304
+ *
1305
+ * An actor route with a concrete path, which starts with `"/"`, goes to a direct
1306
+ * comparison of the pathname against `machinePath`. That comparison runs BEFORE the
1307
+ * map, because a location the map does not hold is still a location the actor can
1308
+ * stand on.
1309
+ *
1310
+ * The QUERY is a question of its own, and {@link sameQuery} answers it. Each caller
1311
+ * asks BOTH, and the two answers do different work. "The two sides are in step" needs
1312
+ * the path AND the query, so a router that carries the only query is a deep link that
1313
+ * the actor has to follow — for a stateId route as much as for a path route. The
1314
+ * RESTORE needs the path, beside `carriesNoOwnSuffix`: an actor that stands here and
1315
+ * carries no query and no fragment of its own has nothing to put in the address bar,
1316
+ * and a push would only rewrite the location the user opened.
1317
+ *
1318
+ * A query that the ACTOR carries is a difference that this branch still reads as a
1319
+ * restore, so an actor at `/?ref=nav` wins against a router at `/?other=1`. `main`
1320
+ * answers the same way, and whether that is a deep link or a restore is a decision
1321
+ * about the rule: issue #18 carries it, and a test pins the behaviour.
1322
+ *
1323
+ * Each lookup runs ONE time, and a caller that needs both answers calls this method
1324
+ * one time too. `getPathByStateId` belongs to the route map: a map of a consumer can
1325
+ * answer differently on a second call, and `RouteMap` pays a cache lookup for each of
1326
+ * them.
1327
+ *
1328
+ * @param machinePath - The machine half of the location of the router, with no query.
1329
+ * @param actorRoute - The raw value of the actor route. It CAN carry a query.
1197
1330
  */
1198
- isActorAtLocation(machinePath, actorRoute) {
1331
+ isActorAtPath(machinePath, actorRoute) {
1199
1332
  if (actorRoute == null)
1200
1333
  return false;
1201
- // The identical string answers the question with no map at all, and it has to run
1202
- // FIRST: the lookup below returns nothing for a location that the map does not
1203
- // hold, and the early return then reported an actor standing exactly at that
1204
- // location as "somewhere else". `performInitialSync` read that as a deep link and
1205
- // drove the actor into the state it was in already. The seed of the constructor hid
1206
- // this for as long as one field carried the route of the actor AND the location.
1207
- if (machinePath === actorRoute)
1334
+ // The PATHNAME half of the actor route.
1335
+ //
1336
+ // An actor route carries its QUERY, because `context.query` travels in
1337
+ // `currentRoute`, and `machinePath` carries none — `sanitizePathname` cut it off
1338
+ // before the caller stripped the prefix. The two are therefore never the identical
1339
+ // string for a route with a query, and a comparison of the whole strings reported an
1340
+ // actor standing exactly at this location as "somewhere else": `performInitialSync`
1341
+ // read that as a deep link and sent a `play.route` for the state the actor was in
1342
+ // already, so every entry action of that state ran a second time.
1343
+ const { pathname: actorPath } = splitLocation(actorRoute);
1344
+ // An ESCAPED literal route resolves on that same half, and its two forms are never
1345
+ // the identical string: the actor carries `/tags/c\+\+`, which is what the template
1346
+ // writes, and the router stands at `/tags/c++`, which is the path that the route
1347
+ // matches and the path that `resolveNavigationPath` pushes. The resolution reads the
1348
+ // PATHNAME, and not the whole string, for two reasons: a `?` is a modifier of the
1349
+ // grammar that makes `/tags/c\+\+?ref=nav` malformed, and a query would key the
1350
+ // bounded parse cache on a string that no route map holds.
1351
+ const literalPath = actorPath.includes("\\")
1352
+ ? parsePattern(actorPath, this.compileOptions).literalPath
1353
+ : null;
1354
+ // The pathname test answers with no map at all, and it has to run FIRST: the lookup
1355
+ // below returns nothing for a location that the map does not hold, and the early
1356
+ // return then reported an actor standing exactly at that location as "somewhere
1357
+ // else". The seed of the constructor hid this for as long as one field carried the
1358
+ // route of the actor AND the location.
1359
+ if (machinePath === (literalPath ?? actorPath))
1208
1360
  return true;
1209
1361
  const resolvedStateId = this.routeMap.getStateIdByPath(machinePath);
1210
1362
  if (resolvedStateId === null || resolvedStateId === undefined)
@@ -1256,10 +1408,10 @@ export class RouterBridgeBase {
1256
1408
  * callback reports the pathname and the search SEPARATELY, and `sanitizePathname`
1257
1409
  * removes the query. A comparison of the pathname alone therefore misses for every
1258
1410
  * route with a query, and the push of this bridge comes back as a `play.route` event
1259
- * of its own navigation. That echo is not only wasted work: it writes to the signal
1260
- * graph from inside the watcher callback of the actor route, and the watcher then
1261
- * arms itself again on a computed that is dirty already, which ends the direction
1262
- * from the actor to the router for the rest of the session.
1411
+ * of its own navigation. That echo is not only wasted work: the bridge sends a
1412
+ * `play.route` for a location that the actor stands on already, so every entry action
1413
+ * of that state runs a second time, and a guard that refuses the repeat moves the
1414
+ * actor to a state that no user asked for.
1263
1415
  *
1264
1416
  * The query is compared as a MAP, and not as a string: a framework can re-serialize
1265
1417
  * a search string in another order, and `URLSearchParams` also accepts the form with
@@ -1279,7 +1431,7 @@ export class RouterBridgeBase {
1279
1431
  // move that keeps the path and changes the query, which a filter link does, and
1280
1432
  // which a `validateSearch` of a route does when it injects a default. The actor
1281
1433
  // then never hears of the move, and the URL and the actor diverge.
1282
- const [lastPath, lastQuery] = splitLocation(last);
1434
+ const { pathname: lastPath, query: lastQuery } = splitLocation(last);
1283
1435
  if (lastPath !== sanitized)
1284
1436
  return false;
1285
1437
  return sortedQuery(lastQuery) === sortedQuery(search ?? "");
@@ -1344,41 +1496,52 @@ export class RouterBridgeBase {
1344
1496
  }
1345
1497
  }
1346
1498
  /**
1347
- * The pathname half of a location: everything in front of the first `?` or `#`.
1499
+ * True when the query of an actor route and the query of the router are the same query.
1500
+ *
1501
+ * `isActorAtPath` answers where the actor stands, and this function answers whether the
1502
+ * two sides agree about the query there. The order of the keys is not significant, which
1503
+ * is what `isEchoOfLastSync` says for the same pair of values.
1348
1504
  *
1349
- * A pattern test reads this half alone, because a query and a fragment carry their own
1350
- * characters and say nothing about the shape of the path.
1505
+ * A query that DIFFERS answers `false`, and it must: the first synchronization gives the
1506
+ * router the priority, so a location with another query is a deep link that the actor has
1507
+ * to follow. An ABSENT query of the actor against a query of the router is such a
1508
+ * difference — without this, a deep link of `/settings?ref=nav` reached an actor that
1509
+ * stood at `/settings` never, and `context.query` stayed empty for the session while
1510
+ * `lastSyncedPath` recorded the query and suppressed every later report of it. A stateId
1511
+ * carries no query of its own, so it stands at a location with one never.
1512
+ *
1513
+ * @param actorRoute - The raw value of the actor route. It CAN carry a query.
1514
+ * @param search - The query of the router, or `undefined` when it has none.
1351
1515
  */
1352
- function pathnameOf(location) {
1353
- const mark = firstMarkIndex(location);
1354
- return mark === -1 ? location : location.slice(0, mark);
1516
+ function sameQuery(actorRoute, search) {
1517
+ const { query: actorQuery, fragment } = splitLocation(actorRoute);
1518
+ // A FRAGMENT is a difference, always. `sanitizePathname` cuts it off, so the router
1519
+ // reports one never and this comparison can confirm one never — and "unknown" must not
1520
+ // read as "the same". An actor that carries `/#intro` against a router at `/` is the
1521
+ // restore that puts the fragment in the address bar, and a `true` here swallowed it:
1522
+ // the bridge recorded the location, sent nothing, and the fragment reached the URL on
1523
+ // no later navigation either.
1524
+ if (fragment !== "")
1525
+ return false;
1526
+ return sortedQuery(actorQuery) === sortedQuery(search ?? "");
1355
1527
  }
1356
1528
  /**
1357
- * Splits a location into its path, its query with no leading `?`, and its fragment
1358
- * with its leading `#`.
1529
+ * True when an actor route carries no query and no fragment of its own.
1359
1530
  *
1360
- * A '#' after index 0 ends the pathname. `pushResolvedRoute` writes what the actor
1361
- * gave — "/docs#intro" — while the callback of the router reports "/docs", because
1362
- * `sanitizePathname` strips the fragment: an echo test that compared the two whole
1363
- * strings missed, so the bridge answered its own push with an event and a history entry
1364
- * that BACK cannot escape.
1531
+ * The RESTORE branch asks this beside {@link RouterBridgeBase.isActorAtPath}. An actor
1532
+ * that stands on the path of the router and answers `true` here restores nothing: the
1533
+ * address bar already shows its place, and a push writes the same path back and drops
1534
+ * whatever the router carried beside it.
1365
1535
  *
1366
- * The fragment comes back as the THIRD field, and each caller decides whether it
1367
- * counts: `isEchoOfLastPush` compares it, because `navigateRouter` writes it, and
1368
- * `isEchoOfLastSync` drops it, because the router reports it never.
1536
+ * A stateId answers `true`, because a stateId carries no query and no fragment. The
1537
+ * leading `#` of `"#home"` is no fragment, which is what {@link splitLocation} says at
1538
+ * index 0.
1369
1539
  *
1370
- * At index 0 the '#' is NOT a fragment: `lastSyncedPath` legitimately holds a bare
1371
- * stateId such as "#home", which `pushResolvedRoute` stores when it can resolve no
1372
- * path. Cutting there would turn that value into the empty string.
1540
+ * @param actorRoute - The raw value of the actor route.
1373
1541
  */
1374
- function splitLocation(location) {
1375
- const fragmentAt = location.indexOf("#", 1);
1376
- const withoutFragment = fragmentAt === -1 ? location : location.slice(0, fragmentAt);
1377
- const fragment = fragmentAt === -1 ? "" : location.slice(fragmentAt);
1378
- const mark = withoutFragment.indexOf("?");
1379
- if (mark === -1)
1380
- return [withoutFragment, "", fragment];
1381
- return [withoutFragment.slice(0, mark), withoutFragment.slice(mark + 1), fragment];
1542
+ function carriesNoOwnSuffix(actorRoute) {
1543
+ const { query, fragment } = splitLocation(actorRoute);
1544
+ return query === "" && fragment === "";
1382
1545
  }
1383
1546
  /** Joins a pathname and a query into one location value. An empty query adds nothing. */
1384
1547
  function withSearch(pathname, search) {
@@ -1408,17 +1571,34 @@ function sortedQuery(search) {
1408
1571
  return pairs.toSorted().join("&");
1409
1572
  }
1410
1573
  function noopCleanup() { }
1411
- function createRouteWatcher(signal, onRoute) {
1574
+ /**
1575
+ * Watches the route atom of the actor.
1576
+ *
1577
+ * The bridge wraps the read in NOTHING. A subscription evaluates a computed atom inside
1578
+ * the `set` of the actor state, so a throw from the derivation would unwind into
1579
+ * `actor.send` and break the transition for every other observer — and `currentRoute`
1580
+ * therefore contains its own failures, reports them through `onError` of the actor, and
1581
+ * keeps its last route. Every subscriber benefits from that rule, and not this bridge
1582
+ * alone.
1583
+ */
1584
+ function createRouteWatcher(atom, onRoute) {
1412
1585
  let cleanup = noopCleanup;
1413
1586
  const watcher = {
1414
- watch(nextSignal) {
1415
- cleanup = watchSignal(nextSignal, onRoute);
1587
+ watch(nextAtom) {
1588
+ // Release the watch that this handle holds already. `RouteWatcherHandle` is a
1589
+ // published type, so a bridge of a consumer can repoint the handle at the atom of
1590
+ // a new actor — and an assignment alone dropped the reference to the first
1591
+ // cleanup, which left the first subscription live for the life of the process.
1592
+ watcher.unwatch();
1593
+ cleanup = watchAtom(nextAtom, onRoute);
1416
1594
  },
1417
1595
  unwatch() {
1418
- cleanup();
1596
+ const release = cleanup;
1597
+ cleanup = noopCleanup;
1598
+ release();
1419
1599
  },
1420
1600
  };
1421
- watcher.watch(signal);
1601
+ watcher.watch(atom);
1422
1602
  return watcher;
1423
1603
  }
1424
1604
  /**
@@ -1431,8 +1611,8 @@ function createRouteWatcher(signal, onRoute) {
1431
1611
  * address bar for ever.
1432
1612
  */
1433
1613
  function sameLocation(a, b) {
1434
- const [aPath, aQuery, aFragment] = splitLocation(a);
1435
- const [bPath, bQuery, bFragment] = splitLocation(b);
1614
+ const { pathname: aPath, query: aQuery, fragment: aFragment } = splitLocation(a);
1615
+ const { pathname: bPath, query: bQuery, fragment: bFragment } = splitLocation(b);
1436
1616
  if (aPath !== bPath)
1437
1617
  return false;
1438
1618
  if (aFragment !== bFragment)