@sigmela/router 0.2.8 → 0.3.1

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 (39) hide show
  1. package/README.md +107 -1
  2. package/lib/module/Drawer/Drawer.js +250 -0
  3. package/lib/module/Drawer/DrawerContext.js +4 -0
  4. package/lib/module/Drawer/DrawerIcon.web.js +47 -0
  5. package/lib/module/Drawer/RenderDrawer.native.js +244 -0
  6. package/lib/module/Drawer/RenderDrawer.web.js +197 -0
  7. package/lib/module/Drawer/useDrawer.js +11 -0
  8. package/lib/module/Navigation.js +4 -2
  9. package/lib/module/NavigationStack.js +14 -4
  10. package/lib/module/Router.js +214 -60
  11. package/lib/module/RouterContext.js +1 -1
  12. package/lib/module/ScreenStack/ScreenStack.web.js +78 -12
  13. package/lib/module/ScreenStackItem/ScreenStackItem.js +7 -7
  14. package/lib/module/ScreenStackItem/ScreenStackItem.web.js +65 -6
  15. package/lib/module/ScreenStackSheetItem/ScreenStackSheetItem.native.js +4 -5
  16. package/lib/module/SplitView/RenderSplitView.web.js +5 -7
  17. package/lib/module/SplitView/SplitView.js +10 -2
  18. package/lib/module/StackRenderer.js +10 -4
  19. package/lib/module/TabBar/RenderTabBar.native.js +55 -24
  20. package/lib/module/TabBar/RenderTabBar.web.js +25 -2
  21. package/lib/module/TabBar/TabBar.js +8 -1
  22. package/lib/module/TabBar/TabIcon.web.js +12 -7
  23. package/lib/module/index.js +2 -0
  24. package/lib/module/styles.css +255 -91
  25. package/lib/typescript/src/Drawer/Drawer.d.ts +100 -0
  26. package/lib/typescript/src/Drawer/DrawerContext.d.ts +3 -0
  27. package/lib/typescript/src/Drawer/DrawerIcon.web.d.ts +7 -0
  28. package/lib/typescript/src/Drawer/RenderDrawer.native.d.ts +8 -0
  29. package/lib/typescript/src/Drawer/RenderDrawer.web.d.ts +8 -0
  30. package/lib/typescript/src/Drawer/useDrawer.d.ts +2 -0
  31. package/lib/typescript/src/NavigationStack.d.ts +1 -0
  32. package/lib/typescript/src/Router.d.ts +13 -0
  33. package/lib/typescript/src/ScreenStack/ScreenStackContext.d.ts +1 -1
  34. package/lib/typescript/src/ScreenStack/animationHelpers.d.ts +1 -1
  35. package/lib/typescript/src/SplitView/SplitView.d.ts +2 -0
  36. package/lib/typescript/src/TabBar/TabBar.d.ts +10 -1
  37. package/lib/typescript/src/index.d.ts +5 -0
  38. package/lib/typescript/src/types.d.ts +12 -3
  39. package/package.json +28 -12
@@ -25,8 +25,10 @@ export class Router {
25
25
  stackById = new Map();
26
26
  routeById = new Map();
27
27
  stackActivators = new Map();
28
+ compiledRouteById = new Map();
28
29
  stackHistories = new Map();
29
30
  activeRoute = null;
31
+ activeRouteListeners = new Set();
30
32
  rootListeners = new Set();
31
33
  rootTransition = undefined;
32
34
  // Root swaps should behave like a fresh initial mount (no enter animation).
@@ -34,9 +36,16 @@ export class Router {
34
36
  suppressRootTransitionOnNextRead = false;
35
37
  lastBrowserIndex = 0;
36
38
  suppressHistorySyncCount = 0;
37
-
38
39
  // Used to prevent stale controller-driven navigations (controller calls present later).
39
40
  navigationToken = 0;
41
+ // parsePath one-slot cache
42
+ lastParsedPath = null;
43
+ lastParsedResult = null;
44
+
45
+ // matchBaseRoute one-slot cache
46
+ lastMatchedPathname = null;
47
+ lastMatchedQuery = null;
48
+ lastMatchedResult = null;
40
49
  constructor(config) {
41
50
  this.debugEnabled = config.debug ?? false;
42
51
  this.routerScreenOptions = config.screenOptions;
@@ -82,6 +91,11 @@ export class Router {
82
91
  }
83
92
  }
84
93
  navigate = path => {
94
+ if (!path || typeof path !== 'string') {
95
+ if (__DEV__) throw new Error('Router.navigate: path must be a non-empty string');
96
+ console.warn('[Router] navigate called with invalid path');
97
+ return;
98
+ }
85
99
  if (this.isWebEnv()) {
86
100
  const syncWithUrl = this.shouldSyncPathWithUrl(path);
87
101
  this.pushUrl(path, {
@@ -92,6 +106,11 @@ export class Router {
92
106
  this.performNavigation(path, 'push');
93
107
  };
94
108
  replace = (path, dedupe) => {
109
+ if (!path || typeof path !== 'string') {
110
+ if (__DEV__) throw new Error('Router.replace: path must be a non-empty string');
111
+ console.warn('[Router] replace called with invalid path');
112
+ return;
113
+ }
95
114
  if (this.isWebEnv()) {
96
115
  const syncWithUrl = this.shouldSyncPathWithUrl(path);
97
116
  this.replaceUrl(path, {
@@ -113,6 +132,11 @@ export class Router {
113
132
  * and browser URL can get out of sync).
114
133
  */
115
134
  reset = path => {
135
+ if (!path || typeof path !== 'string') {
136
+ if (__DEV__) throw new Error('Router.reset: path must be a non-empty string');
137
+ console.warn('[Router] reset called with invalid path');
138
+ return;
139
+ }
116
140
  if (this.isWebEnv()) {
117
141
  const prevHistory = this.state.history;
118
142
  // Important: clear current history before matching routes for the new URL.
@@ -190,7 +214,7 @@ export class Router {
190
214
  }
191
215
 
192
216
  // Check if modal has a childNode (NavigationStack added via addModal)
193
- const compiled = this.registry.find(r => r.routeId === modalItem.routeId);
217
+ const compiled = this.getCompiledRoute(modalItem.routeId);
194
218
  const childNode = compiled?.childNode;
195
219
  if (childNode) {
196
220
  // Modal stack: remove all items from child stack AND the modal wrapper item
@@ -200,19 +224,23 @@ export class Router {
200
224
  modalKey: modalItem.key
201
225
  });
202
226
  this.markStackDismissed(childStackId);
227
+
228
+ // Clear child stack's history cache BEFORE setState so listeners see clean state
229
+ this.stackHistories.delete(childStackId);
203
230
  const newHistory = this.state.history.filter(item => item.stackId !== childStackId && item.key !== modalItem.key);
204
231
  this.setState({
205
232
  history: newHistory
206
233
  });
207
-
208
- // Clear child stack's history cache
209
- this.stackHistories.delete(childStackId);
210
234
  } else {
211
235
  // Simple modal: just pop the modal item
236
+ // applyHistoryChange already calls recomputeActiveRoute + emit(this.listeners)
212
237
  this.applyHistoryChange('pop', modalItem);
213
238
  }
214
- this.recomputeActiveRoute();
215
- this.emit(this.listeners);
239
+ if (childNode) {
240
+ // childNode branch uses setState directly, so we need to emit manually
241
+ this.recomputeActiveRoute();
242
+ this.emit(this.listeners);
243
+ }
216
244
 
217
245
  // Sync URL on web
218
246
  if (this.isWebEnv()) {
@@ -228,6 +256,12 @@ export class Router {
228
256
  this.listeners.delete(listener);
229
257
  };
230
258
  }
259
+ subscribeActiveRoute = listener => {
260
+ this.activeRouteListeners.add(listener);
261
+ return () => {
262
+ this.activeRouteListeners.delete(listener);
263
+ };
264
+ };
231
265
  getStackHistory = stackId => {
232
266
  if (!stackId) return EMPTY_ARRAY;
233
267
  if (!this.stackHistories.has(stackId)) {
@@ -268,7 +302,7 @@ export class Router {
268
302
  return () => this.rootListeners.delete(listener);
269
303
  }
270
304
  emitRootChange() {
271
- this.rootListeners.forEach(l => l());
305
+ this.emit(this.rootListeners);
272
306
  }
273
307
  getRootTransition() {
274
308
  if (this.suppressRootTransitionOnNextRead) {
@@ -292,10 +326,18 @@ export class Router {
292
326
  this.registry.length = 0;
293
327
  this.stackById.clear();
294
328
  this.routeById.clear();
329
+ this.compiledRouteById.clear();
295
330
  this.stackActivators.clear();
331
+ this.lastMatchedPathname = null;
332
+ this.lastMatchedQuery = null;
333
+ this.lastMatchedResult = null;
296
334
  this.state = {
297
335
  history: []
298
336
  };
337
+ this.stackHistories.clear();
338
+ this.sheetDismissers.clear();
339
+ this.dismissedStackIds.clear();
340
+ this.stackListeners.clear();
299
341
  this.buildRegistry();
300
342
  this.seedInitialHistory();
301
343
  this.recomputeActiveRoute();
@@ -410,7 +452,7 @@ export class Router {
410
452
  const top = history[history.length - 1];
411
453
  if (top) {
412
454
  const meta = this.routeById.get(top.routeId);
413
- this.activeRoute = meta ? {
455
+ const newRoute = meta ? {
414
456
  ...meta,
415
457
  routeId: top.routeId,
416
458
  params: top.params,
@@ -423,12 +465,23 @@ export class Router {
423
465
  query: top.query,
424
466
  path: top.path
425
467
  };
468
+
469
+ // Shallow compare to keep stable reference and avoid unnecessary re-renders
470
+ if (this.activeRoute && this.activeRoute.routeId === newRoute.routeId && this.activeRoute.stackId === newRoute.stackId && this.activeRoute.path === newRoute.path && this.activeRoute.params === newRoute.params && this.activeRoute.query === newRoute.query) {
471
+ return;
472
+ }
473
+ this.activeRoute = newRoute;
474
+ this.emit(this.activeRouteListeners);
426
475
  return;
427
476
  }
428
477
  }
429
- this.activeRoute = null;
478
+ if (this.activeRoute !== null) {
479
+ this.activeRoute = null;
480
+ this.emit(this.activeRouteListeners);
481
+ }
430
482
  }
431
483
  performNavigation(path, action, opts) {
484
+ if (this.destroyed) return;
432
485
  const {
433
486
  pathname,
434
487
  query
@@ -453,6 +506,7 @@ export class Router {
453
506
  if (__DEV__) {
454
507
  throw new Error(`Route not found: "${pathname}"`);
455
508
  }
509
+ console.warn(`[Router] Route not found: "${pathname}"`);
456
510
  return;
457
511
  }
458
512
 
@@ -507,7 +561,7 @@ export class Router {
507
561
  ...existing,
508
562
  routeId: base.routeId,
509
563
  params: normalizedParams,
510
- query: query,
564
+ query,
511
565
  path: pathname,
512
566
  component: base.component,
513
567
  options: this.mergeOptions(base.options, base.stackId),
@@ -544,7 +598,7 @@ export class Router {
544
598
  });
545
599
  if (sameIdentity && sameQuery) {
546
600
  this.log('dedupe: already at target, syncing state');
547
- this.syncStateForSameRoute(base, pathname, params, query);
601
+ this.syncStateForSameRoute(base, pathname);
548
602
  return;
549
603
  }
550
604
  if (sameIdentity && !sameQuery) {
@@ -553,7 +607,7 @@ export class Router {
553
607
  const updatedTop = {
554
608
  ...top,
555
609
  params: normalizedParams,
556
- query: query,
610
+ query,
557
611
  path: pathname
558
612
  };
559
613
  this.applyHistoryChange('replace', updatedTop);
@@ -567,7 +621,7 @@ export class Router {
567
621
  const updatedExisting = {
568
622
  ...existing,
569
623
  params: normalizedParams,
570
- query: query,
624
+ query,
571
625
  path: pathname
572
626
  };
573
627
  this.log('dedupe: found existing item, calling popTo', {
@@ -622,7 +676,7 @@ export class Router {
622
676
  component: matched.component,
623
677
  options: this.mergeOptions(matched.options, matched.stackId),
624
678
  params: normalizedParams,
625
- query: query,
679
+ query,
626
680
  passProps,
627
681
  stackId: matched.stackId,
628
682
  pattern: matched.path,
@@ -631,7 +685,12 @@ export class Router {
631
685
  }
632
686
  applyHistoryChange(action, item) {
633
687
  const stackId = item.stackId;
634
- if (!stackId) return;
688
+ if (!stackId) {
689
+ if (__DEV__) {
690
+ console.warn(`[Router] Navigation dropped: item has no stackId (routeId: ${item.routeId}, path: ${item.path})`);
691
+ }
692
+ return;
693
+ }
635
694
  const prevHist = this.state.history;
636
695
  let nextHist = prevHist;
637
696
  this.log('applyHistoryChange', {
@@ -669,6 +728,7 @@ export class Router {
669
728
  const copy = [...prevHist];
670
729
  for (let i = copy.length - 1; i >= 0; i--) {
671
730
  const h = copy[i];
731
+ if (!h) continue;
672
732
  if (h.stackId === stackId) {
673
733
  copy.splice(i, 1);
674
734
  break;
@@ -682,6 +742,7 @@ export class Router {
682
742
  let foundItem = null;
683
743
  for (let i = prevHist.length - 1; i >= 0; i--) {
684
744
  const h = prevHist[i];
745
+ if (!h) continue;
685
746
  if (h.stackId !== stackId) {
686
747
  continue;
687
748
  }
@@ -705,27 +766,51 @@ export class Router {
705
766
  if (itemIndex >= 0) {
706
767
  copy.splice(itemIndex, 1);
707
768
  }
708
- copy.push(updatedItem);
769
+ let insertPos = copy.length;
770
+ for (let i = copy.length - 1; i >= 0; i--) {
771
+ if (copy[i]?.stackId === stackId) {
772
+ insertPos = i + 1;
773
+ break;
774
+ }
775
+ }
776
+ copy.splice(insertPos, 0, updatedItem);
709
777
  nextHist = copy;
710
778
  }
711
779
  this.setState({
712
780
  history: nextHist
713
- });
781
+ }, item.stackId);
714
782
  this.recomputeActiveRoute();
715
783
  this.emit(this.listeners);
716
784
  }
717
- setState(next) {
785
+ setState(next, affectedStackId) {
718
786
  const prev = this.state;
719
787
  const nextState = {
720
788
  history: next.history ?? prev.history
721
789
  };
722
790
  this.state = nextState;
723
791
  if (nextState.history !== prev.history) {
724
- this.updateStackHistories();
792
+ this.updateStackHistories(affectedStackId);
725
793
  }
726
794
  this.log('setState', nextState);
727
795
  }
728
- updateStackHistories() {
796
+ updateStackHistories(affectedStackId) {
797
+ if (affectedStackId) {
798
+ // Fast path: only update the one affected stack — O(H) instead of O(S×H)
799
+ const newHist = this.state.history.filter(h => h.stackId === affectedStackId);
800
+ const oldHist = this.stackHistories.get(affectedStackId);
801
+ if (!this.areArraysEqual(oldHist, newHist)) {
802
+ if (newHist.length === 0) {
803
+ this.stackHistories.delete(affectedStackId);
804
+ } else {
805
+ this.stackHistories.set(affectedStackId, newHist);
806
+ }
807
+ const listeners = this.stackListeners.get(affectedStackId);
808
+ if (listeners) this.emit(listeners);
809
+ }
810
+ return;
811
+ }
812
+
813
+ // Slow path: full recompute — used by buildHistoryFromUrl, setRoot, etc.
729
814
  const stackIds = new Set();
730
815
  this.state.history.forEach(item => {
731
816
  if (item.stackId) stackIds.add(item.stackId);
@@ -745,20 +830,21 @@ export class Router {
745
830
  if (!currentStackIds.has(stackId) && history.length > 0) {
746
831
  this.stackHistories.delete(stackId);
747
832
  changedStackIds.add(stackId);
748
- } else if (!currentStackIds.has(stackId) && history.length === 0) {}
833
+ }
749
834
  });
750
835
  changedStackIds.forEach(stackId => {
751
836
  this.emit(this.stackListeners.get(stackId));
752
837
  });
753
838
  }
754
839
  areArraysEqual(a, b) {
840
+ if (!a) return false;
755
841
  if (a.length !== b.length) return false;
756
842
  for (let i = 0; i < a.length; i++) {
757
843
  if (a[i] !== b[i]) return false;
758
844
  }
759
845
  return true;
760
846
  }
761
- syncStateForSameRoute(base, pathname, _params, _query) {
847
+ syncStateForSameRoute(base, pathname) {
762
848
  if (!base.stackId) {
763
849
  this.recomputeActiveRoute();
764
850
  this.emit(this.listeners);
@@ -838,7 +924,7 @@ export class Router {
838
924
  for (let i = stackHistory.length - 1; i >= 0; i--) {
839
925
  const item = stackHistory[i];
840
926
  if (item) {
841
- const compiled = this.registry.find(r => r.routeId === item.routeId);
927
+ const compiled = this.getCompiledRoute(item.routeId);
842
928
  if (compiled?.childNode && canSwitchToRoute(compiled.childNode)) {
843
929
  return compiled.childNode;
844
930
  }
@@ -860,7 +946,7 @@ export class Router {
860
946
  if (!stackNode) return;
861
947
  const seed = this.getAutoSeed(stackNode);
862
948
  if (!seed) return;
863
- const compiled = this.registry.find(r => r.routeId === seed.routeId);
949
+ const compiled = this.getCompiledRoute(seed.routeId);
864
950
  const meta = this.routeById.get(seed.routeId);
865
951
  const path = compiled?.path ?? meta?.path ?? seed.path;
866
952
  const seedStackId = seed.stackId ?? stackNode.getId();
@@ -878,8 +964,9 @@ export class Router {
878
964
  const nextHist = [...prevHist, item];
879
965
  this.setState({
880
966
  history: nextHist
881
- });
882
- this.emit(this.stackListeners.get(seedStackId));
967
+ }, seedStackId);
968
+ // setState with affectedStackId already emits to the stack's listeners
969
+
883
970
  if (seedStackId !== stackId && rootStackId) {
884
971
  this.activateContainerForRoute(seed.routeId, rootStackId);
885
972
  }
@@ -903,6 +990,7 @@ export class Router {
903
990
  query: topOfStack.query,
904
991
  path: topOfStack.path
905
992
  };
993
+ this.emit(this.activeRouteListeners);
906
994
  return;
907
995
  }
908
996
  }
@@ -922,19 +1010,29 @@ export class Router {
922
1010
  query: {},
923
1011
  path: targetRoute.path
924
1012
  };
1013
+ this.emit(this.activeRouteListeners);
925
1014
  }
926
1015
  }
1016
+ emitDepth = 0;
927
1017
  emit(set) {
928
1018
  if (!set) return;
929
- // Do not allow one listener to break all others.
930
- for (const l of Array.from(set)) {
931
- try {
932
- l();
933
- } catch (e) {
934
- if (this.debugEnabled) {
935
- console.error('[Router] listener error', e);
1019
+ if (this.emitDepth > 5) {
1020
+ if (this.debugEnabled) console.warn('[Router] emit recursion limit reached');
1021
+ return;
1022
+ }
1023
+ this.emitDepth++;
1024
+ try {
1025
+ for (const l of Array.from(set)) {
1026
+ try {
1027
+ l();
1028
+ } catch (e) {
1029
+ if (this.debugEnabled) {
1030
+ console.error('[Router] listener error', e);
1031
+ }
936
1032
  }
937
1033
  }
1034
+ } finally {
1035
+ this.emitDepth--;
938
1036
  }
939
1037
  }
940
1038
  getTopOfStack(stackId) {
@@ -1004,6 +1102,7 @@ export class Router {
1004
1102
  childNode: r.childNode
1005
1103
  };
1006
1104
  this.registry.push(compiled);
1105
+ this.compiledRouteById.set(compiled.routeId, compiled);
1007
1106
  if (stackId) {
1008
1107
  this.routeById.set(r.routeId, {
1009
1108
  path: compiled.path,
@@ -1128,7 +1227,7 @@ export class Router {
1128
1227
  if (this.root) {
1129
1228
  const seed = this.getAutoSeed(this.root);
1130
1229
  if (seed) {
1131
- const compiled = this.registry.find(r => r.routeId === seed.routeId);
1230
+ const compiled = this.getCompiledRoute(seed.routeId);
1132
1231
  const meta = this.routeById.get(seed.routeId);
1133
1232
  const path = compiled?.path ?? meta?.path ?? seed.path;
1134
1233
  const stackId = seed.stackId ?? this.root.getId();
@@ -1148,7 +1247,9 @@ export class Router {
1148
1247
  return;
1149
1248
  }
1150
1249
  }
1151
- addChildNodeSeedsToItems(routeId, items, finalRouteId) {
1250
+ addChildNodeSeedsToItems(routeId, items, finalRouteId, visited = new Set()) {
1251
+ if (visited.has(routeId) || visited.size > 50) return;
1252
+ visited.add(routeId);
1152
1253
  this.log('addChildNodeSeeds: called', {
1153
1254
  routeId,
1154
1255
  finalRouteId,
@@ -1159,7 +1260,7 @@ export class Router {
1159
1260
  path: i.path
1160
1261
  }))
1161
1262
  });
1162
- const compiled = this.registry.find(r => r.routeId === routeId);
1263
+ const compiled = this.getCompiledRoute(routeId);
1163
1264
  if (!compiled || !compiled.childNode) {
1164
1265
  this.log('addChildNodeSeeds: no childNode', {
1165
1266
  routeId
@@ -1185,7 +1286,7 @@ export class Router {
1185
1286
  stackId: childSeed.stackId,
1186
1287
  path: childSeed.path
1187
1288
  });
1188
- const childCompiled = this.registry.find(r => r.routeId === childSeed.routeId);
1289
+ const childCompiled = this.getCompiledRoute(childSeed.routeId);
1189
1290
  const childMeta = this.routeById.get(childSeed.routeId);
1190
1291
  const childPath = childCompiled?.path ?? childMeta?.path ?? childSeed.path;
1191
1292
  const childStackId = childSeed.stackId ?? childNode.getId();
@@ -1233,16 +1334,18 @@ export class Router {
1233
1334
  const insertIndex = parentIndex >= 0 ? parentIndex + 1 : items.length;
1234
1335
  items.splice(insertIndex, 0, childItem);
1235
1336
  if (childSeed.routeId) {
1236
- this.addChildNodeSeedsToItems(childSeed.routeId, items, finalRouteId);
1337
+ this.addChildNodeSeedsToItems(childSeed.routeId, items, finalRouteId, visited);
1237
1338
  }
1238
1339
  }
1239
- addChildNodeSeedsToHistory(routeId) {
1240
- const compiled = this.registry.find(r => r.routeId === routeId);
1340
+ addChildNodeSeedsToHistory(routeId, visited = new Set()) {
1341
+ if (visited.has(routeId) || visited.size > 50) return;
1342
+ visited.add(routeId);
1343
+ const compiled = this.getCompiledRoute(routeId);
1241
1344
  if (!compiled || !compiled.childNode) return;
1242
1345
  const childNode = compiled.childNode;
1243
1346
  const childSeed = this.getAutoSeed(childNode);
1244
1347
  if (!childSeed) return;
1245
- const childCompiled = this.registry.find(r => r.routeId === childSeed.routeId);
1348
+ const childCompiled = this.getCompiledRoute(childSeed.routeId);
1246
1349
  const childMeta = this.routeById.get(childSeed.routeId);
1247
1350
  const childPath = childCompiled?.path ?? childMeta?.path ?? childSeed.path;
1248
1351
  const childStackId = childSeed.stackId ?? childNode.getId();
@@ -1258,10 +1361,14 @@ export class Router {
1258
1361
  };
1259
1362
  this.applyHistoryChange('push', childItem);
1260
1363
  if (childSeed.routeId) {
1261
- this.addChildNodeSeedsToHistory(childSeed.routeId);
1364
+ this.addChildNodeSeedsToHistory(childSeed.routeId, visited);
1262
1365
  }
1263
1366
  }
1264
1367
  matchBaseRoute(pathname, query) {
1368
+ // One-slot cache: uses reference equality for query object
1369
+ if (this.lastMatchedPathname !== null && pathname === this.lastMatchedPathname && query === this.lastMatchedQuery) {
1370
+ return this.lastMatchedResult ?? undefined;
1371
+ }
1265
1372
  this.log('matchBaseRoute', {
1266
1373
  pathname,
1267
1374
  query
@@ -1405,17 +1512,26 @@ export class Router {
1405
1512
  } else {
1406
1513
  this.log('matchBaseRoute no match');
1407
1514
  }
1408
- return best?.route;
1515
+ const result = best?.route;
1516
+ this.lastMatchedPathname = pathname;
1517
+ this.lastMatchedQuery = query;
1518
+ this.lastMatchedResult = result ?? null;
1519
+ return result;
1409
1520
  }
1410
1521
  generateKey() {
1411
1522
  return `route-${nanoid()}`;
1412
1523
  }
1413
1524
  parsePath(path) {
1525
+ if (path === this.lastParsedPath && this.lastParsedResult) {
1526
+ return this.lastParsedResult;
1527
+ }
1414
1528
  const parsed = qs.parseUrl(path);
1415
1529
  const result = {
1416
1530
  pathname: parsed.url,
1417
1531
  query: parsed.query
1418
1532
  };
1533
+ this.lastParsedPath = path;
1534
+ this.lastParsedResult = result;
1419
1535
  this.log('parsePath', {
1420
1536
  input: path,
1421
1537
  output: result
@@ -1428,9 +1544,9 @@ export class Router {
1428
1544
  const routerDefaults = this.routerScreenOptions;
1429
1545
  if (!routerDefaults && !stackDefaults && !routeOptions) return undefined;
1430
1546
  const merged = {
1547
+ ...(routerDefaults ?? {}),
1431
1548
  ...(stackDefaults ?? {}),
1432
- ...(routeOptions ?? {}),
1433
- ...(routerDefaults ?? {})
1549
+ ...(routeOptions ?? {})
1434
1550
  };
1435
1551
  if (merged.stackPresentation === 'modal' && merged.convertModalToSheetForAndroid && Platform.OS === 'android') {
1436
1552
  merged.stackPresentation = 'sheet';
@@ -1440,16 +1556,20 @@ export class Router {
1440
1556
  findStackById(stackId) {
1441
1557
  return this.stackById.get(stackId);
1442
1558
  }
1559
+ getCompiledRoute(routeId) {
1560
+ return this.compiledRouteById.get(routeId);
1561
+ }
1443
1562
  areShallowEqual(a, b) {
1444
1563
  if (a === b) return true;
1445
- if (!a || !b) return false;
1446
- const aKeys = Object.keys(a);
1447
- const bKeys = Object.keys(b);
1564
+ const na = a ?? {};
1565
+ const nb = b ?? {};
1566
+ const aKeys = Object.keys(na);
1567
+ const bKeys = Object.keys(nb);
1448
1568
  if (aKeys.length !== bKeys.length) return false;
1449
1569
  for (const k of aKeys) {
1450
- if (!(k in b)) return false;
1451
- const av = a[k];
1452
- const bv = b[k];
1570
+ if (!(k in nb)) return false;
1571
+ const av = na[k];
1572
+ const bv = nb[k];
1453
1573
  if (av === bv) continue;
1454
1574
  const aArr = Array.isArray(av);
1455
1575
  const bArr = Array.isArray(bv);
@@ -1560,7 +1680,9 @@ export class Router {
1560
1680
  if (changed) {
1561
1681
  try {
1562
1682
  g.history?.replaceState(next, '', g.location?.href);
1563
- } catch {}
1683
+ } catch (e) {
1684
+ if (this.debugEnabled) console.warn('[Router] ensureHistoryIndex replaceState failed', e);
1685
+ }
1564
1686
  }
1565
1687
  }
1566
1688
  getRouterPathFromHistory() {
@@ -1628,6 +1750,10 @@ export class Router {
1628
1750
  }
1629
1751
  replaceUrlSilently(to) {
1630
1752
  if (!this.isWebEnv()) return;
1753
+ if (this.suppressHistorySyncCount > 10) {
1754
+ console.warn('[Router] suppressHistorySyncCount reset from', this.suppressHistorySyncCount);
1755
+ this.suppressHistorySyncCount = 0;
1756
+ }
1631
1757
  this.suppressHistorySyncCount += 1;
1632
1758
  this.replaceUrl(to, {
1633
1759
  syncWithUrl: true
@@ -1672,6 +1798,12 @@ export class Router {
1672
1798
  g[key] = true;
1673
1799
  }
1674
1800
  setupBrowserHistory() {
1801
+ // Abort previous listeners in case setupBrowserHistory is called twice
1802
+ this.abortController?.abort();
1803
+ this.abortController = new AbortController();
1804
+ const {
1805
+ signal
1806
+ } = this.abortController;
1675
1807
  const g = globalThis;
1676
1808
  const gAny = g;
1677
1809
  const activeKey = Symbol.for('sigmela_router_active_instance');
@@ -1747,13 +1879,28 @@ export class Router {
1747
1879
  this.lastBrowserIndex = idx;
1748
1880
  }
1749
1881
  };
1750
- g.addEventListener?.('pushState', onHistory);
1751
- g.addEventListener?.('replaceState', onHistory);
1752
- g.addEventListener?.('popstate', onHistory);
1882
+ g.addEventListener?.('pushState', onHistory, {
1883
+ signal
1884
+ });
1885
+ g.addEventListener?.('replaceState', onHistory, {
1886
+ signal
1887
+ });
1888
+ g.addEventListener?.('popstate', onHistory, {
1889
+ signal
1890
+ });
1891
+ }
1892
+ destroyed = false;
1893
+ destroy() {
1894
+ this.destroyed = true;
1895
+ this.abortController?.abort();
1896
+ this.listeners.clear();
1897
+ this.stackListeners.clear();
1898
+ this.activeRouteListeners.clear();
1899
+ this.rootListeners.clear();
1753
1900
  }
1754
1901
  syncUrlAfterInternalPop(popped) {
1755
1902
  if (!this.isWebEnv()) return;
1756
- const compiled = this.registry.find(r => r.routeId === popped.routeId);
1903
+ const compiled = this.getCompiledRoute(popped.routeId);
1757
1904
  const isQueryRoute = compiled && compiled.queryPattern && Object.keys(compiled.queryPattern).length > 0;
1758
1905
  if (isQueryRoute) {
1759
1906
  const currentUrl = this.getCurrentUrl();
@@ -1803,6 +1950,7 @@ export class Router {
1803
1950
  const stackHistory = this.getStackHistory(sid);
1804
1951
  if (stackHistory.length > 0) {
1805
1952
  const top = stackHistory[stackHistory.length - 1];
1953
+ if (!top) return null;
1806
1954
  const isModalOrSheet = top.options?.stackPresentation === 'modal' || top.options?.stackPresentation === 'sheet';
1807
1955
  if (stackHistory.length > 1 || isModalOrSheet) {
1808
1956
  if (top.options?.stackPresentation === 'sheet') {
@@ -1845,6 +1993,7 @@ export class Router {
1845
1993
  }
1846
1994
  }
1847
1995
  const top = stackHistory[stackHistory.length - 1];
1996
+ if (!top) return null;
1848
1997
  this.applyHistoryChange('pop', top);
1849
1998
  return top;
1850
1999
  }
@@ -1865,6 +2014,8 @@ export class Router {
1865
2014
  if (!deepest || isDeepestOverlay && !baseRoute) {
1866
2015
  if (__DEV__) {
1867
2016
  console.warn(`[Router] parse: no base route found for "${pathname}", seeding initial history`);
2017
+ } else {
2018
+ console.warn(`[Router] No base route found for "${pathname}"`);
1868
2019
  }
1869
2020
  this.seedInitialHistory();
1870
2021
  this.recomputeActiveRoute();
@@ -1941,14 +2092,14 @@ export class Router {
1941
2092
  }
1942
2093
  if (items.length > 0) {
1943
2094
  const lastItem = items[items.length - 1];
1944
- const lastItemCompiled = lastItem ? this.registry.find(r => r.routeId === lastItem.routeId) : undefined;
2095
+ const lastItemCompiled = lastItem ? this.getCompiledRoute(lastItem.routeId) : undefined;
1945
2096
  const isLastItemOverlay = lastItem && lastItemCompiled && lastItemCompiled.queryPattern && Object.keys(lastItemCompiled.queryPattern).length > 0;
1946
2097
  const finalRouteId = isLastItemOverlay && items.length > 1 ? items[items.length - 2]?.routeId : lastItem?.routeId;
1947
2098
  const searchStartIndex = isLastItemOverlay ? items.length - 2 : items.length - 1;
1948
2099
  for (let i = searchStartIndex; i >= 0; i--) {
1949
2100
  const item = items[i];
1950
2101
  if (item && item.routeId) {
1951
- const compiled = this.registry.find(r => r.routeId === item.routeId);
2102
+ const compiled = this.getCompiledRoute(item.routeId);
1952
2103
  if (compiled && compiled.childNode) {
1953
2104
  this.addChildNodeSeedsToItems(item.routeId, items, finalRouteId);
1954
2105
  // Don't break: we may have multiple nested containers (e.g. TabBar -> SplitView).
@@ -1967,6 +2118,8 @@ export class Router {
1967
2118
  if (!items.length) {
1968
2119
  if (__DEV__) {
1969
2120
  console.warn('[Router] parse: no items built for URL, seeding initial history');
2121
+ } else {
2122
+ console.warn('[Router] No items built for URL, seeding initial history');
1970
2123
  }
1971
2124
  this.seedInitialHistory();
1972
2125
  this.recomputeActiveRoute();
@@ -1980,7 +2133,8 @@ export class Router {
1980
2133
  this.setState({
1981
2134
  history: items
1982
2135
  });
1983
- this.stackListeners.forEach(set => this.emit(set));
2136
+ // setState -> updateStackHistories already emits to changed stacks
2137
+
1984
2138
  this.recomputeActiveRoute();
1985
2139
  this.emit(this.listeners);
1986
2140
  }
@@ -12,7 +12,7 @@ export const useRouter = () => {
12
12
  };
13
13
  export const useCurrentRoute = () => {
14
14
  const router = useRouter();
15
- const subscribe = React.useCallback(cb => router.subscribe(cb), [router]);
15
+ const subscribe = React.useCallback(cb => router.subscribeActiveRoute(cb), [router]);
16
16
  const get = React.useCallback(() => router.getActiveRoute(), [router]);
17
17
  return React.useSyncExternalStore(subscribe, get, get);
18
18
  };