@sigmela/router 0.1.3 → 0.2.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 (52) hide show
  1. package/README.md +177 -833
  2. package/lib/module/Navigation.js +1 -10
  3. package/lib/module/NavigationStack.js +168 -19
  4. package/lib/module/Router.js +1523 -501
  5. package/lib/module/RouterContext.js +1 -1
  6. package/lib/module/ScreenStack/ScreenStack.web.js +388 -117
  7. package/lib/module/ScreenStack/ScreenStackContext.js +21 -0
  8. package/lib/module/ScreenStack/animationHelpers.js +72 -0
  9. package/lib/module/ScreenStackItem/ScreenStackItem.js +2 -1
  10. package/lib/module/ScreenStackItem/ScreenStackItem.web.js +76 -16
  11. package/lib/module/ScreenStackSheetItem/ScreenStackSheetItem.native.js +2 -1
  12. package/lib/module/ScreenStackSheetItem/ScreenStackSheetItem.web.js +1 -1
  13. package/lib/module/SplitView/RenderSplitView.native.js +85 -0
  14. package/lib/module/SplitView/RenderSplitView.web.js +109 -0
  15. package/lib/module/SplitView/SplitView.js +89 -0
  16. package/lib/module/SplitView/SplitViewContext.js +4 -0
  17. package/lib/module/SplitView/index.js +5 -0
  18. package/lib/module/SplitView/useSplitView.js +11 -0
  19. package/lib/module/StackRenderer.js +4 -2
  20. package/lib/module/TabBar/RenderTabBar.native.js +118 -33
  21. package/lib/module/TabBar/RenderTabBar.web.js +52 -47
  22. package/lib/module/TabBar/TabBar.js +116 -3
  23. package/lib/module/TabBar/index.js +4 -1
  24. package/lib/module/TabBar/useTabBarHeight.js +22 -0
  25. package/lib/module/index.js +3 -4
  26. package/lib/module/navigationNode.js +3 -0
  27. package/lib/module/styles.css +693 -28
  28. package/lib/typescript/src/NavigationStack.d.ts +25 -13
  29. package/lib/typescript/src/Router.d.ts +147 -34
  30. package/lib/typescript/src/RouterContext.d.ts +1 -1
  31. package/lib/typescript/src/ScreenStack/ScreenStack.web.d.ts +0 -2
  32. package/lib/typescript/src/ScreenStack/ScreenStackContext.d.ts +31 -0
  33. package/lib/typescript/src/ScreenStack/animationHelpers.d.ts +6 -0
  34. package/lib/typescript/src/ScreenStackItem/ScreenStackItem.types.d.ts +5 -1
  35. package/lib/typescript/src/ScreenStackItem/ScreenStackItem.web.d.ts +1 -1
  36. package/lib/typescript/src/SplitView/RenderSplitView.native.d.ts +8 -0
  37. package/lib/typescript/src/SplitView/RenderSplitView.web.d.ts +8 -0
  38. package/lib/typescript/src/SplitView/SplitView.d.ts +31 -0
  39. package/lib/typescript/src/SplitView/SplitViewContext.d.ts +3 -0
  40. package/lib/typescript/src/SplitView/index.d.ts +5 -0
  41. package/lib/typescript/src/SplitView/useSplitView.d.ts +2 -0
  42. package/lib/typescript/src/StackRenderer.d.ts +2 -1
  43. package/lib/typescript/src/TabBar/TabBar.d.ts +27 -3
  44. package/lib/typescript/src/TabBar/index.d.ts +3 -0
  45. package/lib/typescript/src/TabBar/useTabBarHeight.d.ts +18 -0
  46. package/lib/typescript/src/createController.d.ts +1 -0
  47. package/lib/typescript/src/index.d.ts +4 -3
  48. package/lib/typescript/src/navigationNode.d.ts +41 -0
  49. package/lib/typescript/src/types.d.ts +29 -32
  50. package/package.json +6 -5
  51. package/lib/module/web/TransitionStack.js +0 -227
  52. package/lib/typescript/src/web/TransitionStack.d.ts +0 -21
@@ -3,81 +3,111 @@
3
3
  import { nanoid } from 'nanoid/non-secure';
4
4
  import { Platform } from 'react-native';
5
5
  import qs from 'query-string';
6
-
7
- // Root transition option: allow string shorthand like 'fade'
8
-
9
- function isTabBarLike(obj) {
10
- return obj != null && typeof obj === 'object' && 'onIndexChange' in obj && 'getState' in obj && 'subscribe' in obj && 'stacks' in obj;
6
+ function canSwitchToRoute(node) {
7
+ return node !== undefined && typeof node.switchToRoute === 'function';
11
8
  }
12
- function isNavigationStackLike(obj) {
13
- return obj != null && typeof obj === 'object' && 'getRoutes' in obj && 'getId' in obj;
9
+ function canLookupRoutes(node) {
10
+ return node !== undefined && typeof node.hasRoute === 'function';
14
11
  }
15
12
  const EMPTY_ARRAY = [];
16
13
  export class Router {
17
- tabBar = null;
18
14
  root = null;
19
- global = null;
20
15
  listeners = new Set();
21
16
  registry = [];
22
17
  state = {
23
- history: [],
24
- activeTabIndex: undefined
18
+ history: []
25
19
  };
20
+ debugEnabled = false;
26
21
  sheetDismissers = new Map();
27
-
28
- // per-stack slices and listeners
29
- stackSlices = new Map();
30
22
  stackListeners = new Map();
31
- activeTabListeners = new Set();
32
23
  stackById = new Map();
33
24
  routeById = new Map();
34
- visibleRoute = null;
35
- // Root structure listeners (TabBar ↔ NavigationStack changes)
25
+ stackActivators = new Map();
26
+ stackHistories = new Map();
27
+ activeRoute = null;
36
28
  rootListeners = new Set();
37
29
  rootTransition = undefined;
30
+ lastBrowserIndex = 0;
31
+ suppressHistorySyncCount = 0;
32
+
33
+ // Used to prevent stale controller-driven navigations (controller calls present later).
34
+ navigationToken = 0;
38
35
  constructor(config) {
36
+ this.debugEnabled = config.debug ?? false;
39
37
  this.routerScreenOptions = config.screenOptions;
40
- if (isTabBarLike(config.root)) {
41
- this.tabBar = config.root;
42
- }
43
- if (config.global) {
44
- this.global = config.global;
45
- }
38
+ this.log('ctor');
46
39
  this.root = config.root;
47
- if (this.tabBar) {
48
- this.state = {
49
- history: [],
50
- activeTabIndex: this.tabBar.getState().index
51
- };
52
- }
53
40
  this.buildRegistry();
54
41
  if (this.isWebEnv()) {
55
42
  this.setupBrowserHistory();
56
- this.parse(this.getCurrentUrl());
43
+ const url = this.getCurrentUrl();
44
+ this.buildHistoryFromUrl(url);
57
45
  } else {
58
46
  this.seedInitialHistory();
59
47
  }
60
- this.recomputeVisibleRoute();
48
+ this.recomputeActiveRoute();
49
+ }
50
+ log(message, data) {
51
+ if (this.debugEnabled) {
52
+ if (data !== undefined) {
53
+ console.log(`[Router] ${message}`, data);
54
+ } else {
55
+ console.log(`[Router] ${message}`);
56
+ }
57
+ }
61
58
  }
62
-
63
- // Public API
64
59
  navigate = path => {
65
60
  if (this.isWebEnv()) {
66
- this.pushUrl(path);
61
+ const syncWithUrl = this.shouldSyncPathWithUrl(path);
62
+ this.pushUrl(path, {
63
+ syncWithUrl
64
+ });
67
65
  return;
68
66
  }
69
67
  this.performNavigation(path, 'push');
70
68
  };
71
69
  replace = (path, dedupe) => {
72
70
  if (this.isWebEnv()) {
73
- this.pendingReplaceDedupe = !!dedupe;
74
- this.replaceUrl(path);
71
+ const syncWithUrl = this.shouldSyncPathWithUrl(path);
72
+ this.replaceUrl(path, {
73
+ syncWithUrl,
74
+ dedupe: !!dedupe
75
+ });
75
76
  return;
76
77
  }
77
78
  this.performNavigation(path, 'replace', {
78
79
  dedupe: !!dedupe
79
80
  });
80
81
  };
82
+
83
+ /**
84
+ * Web-only convenience: resets Router navigation state to what a fresh
85
+ * deep-link render would produce for the given URL.
86
+ *
87
+ * This intentionally clears preserved per-tab stacks (useful when tab stacks
88
+ * and browser URL can get out of sync).
89
+ */
90
+ reset = path => {
91
+ if (this.isWebEnv()) {
92
+ const prevHistory = this.state.history;
93
+ // Important: clear current history before matching routes for the new URL.
94
+ // Otherwise, matchBaseRoute() tie-breakers may pick a wrong stack for ambiguous
95
+ // paths like '/' based on previously visited stacks, breaking tab switches.
96
+ this.state = {
97
+ history: []
98
+ };
99
+ this.stackHistories.clear();
100
+ // Update browser URL without triggering onHistory navigation.
101
+ this.replaceUrlSilently(path);
102
+ // Rebuild history exactly like initial URL parsing (wipes other stacks).
103
+ this.buildHistoryFromUrl(path, prevHistory);
104
+ return;
105
+ }
106
+ // Native fallback: closest semantics is a replace navigation.
107
+ this.performNavigation(path, 'replace', {
108
+ dedupe: true
109
+ });
110
+ };
81
111
  registerSheetDismisser = (key, dismisser) => {
82
112
  this.sheetDismissers.set(key, dismisser);
83
113
  };
@@ -86,52 +116,16 @@ export class Router {
86
116
  };
87
117
  goBack = () => {
88
118
  if (this.isWebEnv()) {
89
- const didPop = this.tryPopActiveStack();
90
- if (didPop) {
91
- const path = this.getVisibleRoute()?.path;
92
- if (path) this.replaceUrl(path);
119
+ const prevHistoryRef = this.state.history;
120
+ const popped = this.popFromActiveStack();
121
+ // Sync URL only if Router state actually changed (avoid syncing URL when
122
+ // a UI-level dismisser is used and the history pop will happen later).
123
+ if (popped && this.state.history !== prevHistoryRef) {
124
+ this.syncUrlAfterInternalPop(popped);
93
125
  }
94
126
  return;
95
127
  }
96
- this.popOnce();
97
- };
98
- onTabIndexChange = index => {
99
- if (this.tabBar) {
100
- this.tabBar.onIndexChange(index);
101
- this.setState({
102
- activeTabIndex: index
103
- });
104
- this.emit(this.activeTabListeners);
105
- this.recomputeVisibleRoute();
106
- this.emit(this.listeners);
107
- }
108
- };
109
- setActiveTabIndex = index => {
110
- this.onTabIndexChange(index);
111
- };
112
- ensureTabSeed = index => {
113
- if (!this.tabBar) return;
114
- const state = this.tabBar.getState();
115
- const route = state.tabs[index];
116
- if (!route) return;
117
- const key = route.tabKey;
118
- const stack = this.tabBar.stacks[key];
119
- if (!stack) return;
120
- const hasAny = this.getStackHistory(stack.getId()).length > 0;
121
- if (hasAny) return;
122
- const first = stack.getFirstRoute();
123
- if (!first) return;
124
- const newItem = {
125
- key: this.generateKey(),
126
- scope: 'tab',
127
- routeId: first.routeId,
128
- component: first.component,
129
- options: this.mergeOptions(first.options, stack.getId()),
130
- params: {},
131
- tabIndex: index,
132
- stackId: stack.getId()
133
- };
134
- this.applyHistoryChange('push', newItem);
128
+ this.popFromActiveStack();
135
129
  };
136
130
  getState = () => {
137
131
  return this.state;
@@ -142,10 +136,14 @@ export class Router {
142
136
  this.listeners.delete(listener);
143
137
  };
144
138
  }
145
-
146
- // Per-stack subscriptions
147
139
  getStackHistory = stackId => {
148
- return this.stackSlices.get(stackId) ?? EMPTY_ARRAY;
140
+ if (!stackId) return EMPTY_ARRAY;
141
+ if (!this.stackHistories.has(stackId)) {
142
+ const current = this.state.history.filter(item => item.stackId === stackId);
143
+ this.stackHistories.set(stackId, current);
144
+ return current;
145
+ }
146
+ return this.stackHistories.get(stackId) ?? EMPTY_ARRAY;
149
147
  };
150
148
  subscribeStack = (stackId, cb) => {
151
149
  if (!stackId) return () => {};
@@ -157,23 +155,16 @@ export class Router {
157
155
  set.add(cb);
158
156
  return () => {
159
157
  set.delete(cb);
158
+ if (set.size === 0) {
159
+ this.stackListeners.delete(stackId);
160
+ }
160
161
  };
161
162
  };
162
- getActiveTabIndex = () => {
163
- return this.state.activeTabIndex ?? 0;
164
- };
165
- subscribeActiveTab = cb => {
166
- this.activeTabListeners.add(cb);
167
- return () => this.activeTabListeners.delete(cb);
168
- };
169
163
  getRootStackId() {
170
- return isNavigationStackLike(this.root) ? this.root.getId() : undefined;
164
+ return this.root?.getId();
171
165
  }
172
166
  getGlobalStackId() {
173
- return this.global?.getId();
174
- }
175
- hasTabBar() {
176
- return !!this.tabBar;
167
+ return undefined;
177
168
  }
178
169
  subscribeRoot(listener) {
179
170
  this.rootListeners.add(listener);
@@ -186,116 +177,130 @@ export class Router {
186
177
  return this.rootTransition;
187
178
  }
188
179
  setRoot(nextRoot, options) {
189
- // Update root/tabBar references
190
- this.tabBar = isTabBarLike(nextRoot) ? nextRoot : null;
191
180
  this.root = nextRoot;
192
-
193
- // Save requested transition (stackAnimation string)
194
181
  this.rootTransition = options?.transition ?? undefined;
195
-
196
- // If switching to TabBar, reset selected tab to the first one to avoid
197
- // leaking previously selected tab across auth flow changes.
198
- if (this.tabBar) {
199
- this.tabBar.onIndexChange(0);
200
- }
201
-
202
- // Reset core structures (keep global reference as-is)
203
182
  this.registry.length = 0;
204
- this.stackSlices.clear();
205
183
  this.stackById.clear();
206
184
  this.routeById.clear();
207
-
208
- // Reset state (activeTabIndex from tabBar if present)
185
+ this.stackActivators.clear();
209
186
  this.state = {
210
- history: [],
211
- activeTabIndex: this.tabBar ? this.tabBar.getState().index : undefined
187
+ history: []
212
188
  };
213
-
214
- // Rebuild registry and seed new root
215
189
  this.buildRegistry();
216
190
  this.seedInitialHistory();
217
- this.recomputeVisibleRoute();
191
+ this.recomputeActiveRoute();
218
192
  this.emitRootChange();
219
193
  this.emit(this.listeners);
220
194
  }
221
-
222
- // Visible route (global top if present, else active tab/root top)
223
- getVisibleRoute = () => {
224
- return this.visibleRoute;
195
+ getActiveRoute = () => {
196
+ return this.activeRoute;
225
197
  };
226
- recomputeVisibleRoute() {
227
- // Global top
228
- if (this.global) {
229
- const gid = this.global.getId();
230
- const gslice = this.getStackHistory(gid);
231
- const gtop = gslice.length ? gslice[gslice.length - 1] : undefined;
232
- if (gtop) {
233
- const meta = this.routeById.get(gtop.routeId);
234
- this.visibleRoute = meta ? {
235
- ...meta,
236
- routeId: gtop.routeId,
237
- params: gtop.params,
238
- query: gtop.query,
239
- path: gtop.path
240
- } : {
241
- routeId: gtop.routeId,
242
- stackId: gtop.stackId,
243
- params: gtop.params,
244
- query: gtop.query,
245
- scope: 'global'
198
+ debugGetState() {
199
+ return {
200
+ history: this.state.history.map(h => ({
201
+ key: h.key,
202
+ routeId: h.routeId,
203
+ stackId: h.stackId,
204
+ path: h.path,
205
+ params: h.params,
206
+ query: h.query,
207
+ stackPresentation: h.options?.stackPresentation
208
+ })),
209
+ stackSlices: Array.from(new Set(this.state.history.map(h => h.stackId).filter(Boolean))).map(stackId => ({
210
+ stackId,
211
+ items: this.getStackHistory(stackId).map(i => ({
212
+ key: i.key,
213
+ routeId: i.routeId,
214
+ path: i.path,
215
+ params: i.params,
216
+ query: i.query,
217
+ stackPresentation: i.options?.stackPresentation
218
+ }))
219
+ })),
220
+ activeRoute: this.activeRoute,
221
+ registry: this.registry.map(r => ({
222
+ routeId: r.routeId,
223
+ path: r.path,
224
+ pathnamePattern: r.pathnamePattern,
225
+ stackId: r.stackId,
226
+ isWildcardPath: r.isWildcardPath,
227
+ baseSpecificity: r.baseSpecificity,
228
+ queryPattern: r.queryPattern,
229
+ options: r.options
230
+ }))
231
+ };
232
+ }
233
+ debugMatchRoute(path) {
234
+ const {
235
+ pathname,
236
+ query
237
+ } = this.parsePath(path);
238
+ const matches = [];
239
+ for (const r of this.registry) {
240
+ if (!r.stackId) continue;
241
+ let pathMatch;
242
+ if (r.isWildcardPath) {
243
+ pathMatch = {
244
+ params: {}
246
245
  };
247
- return;
248
- }
249
- }
250
-
251
- // TabBar
252
- if (this.tabBar) {
253
- const idx = this.getActiveTabIndex();
254
- const state = this.tabBar.getState();
255
- const route = state.tabs[idx];
256
- if (route) {
257
- const stack = this.tabBar.stacks[route.tabKey];
258
- if (stack) {
259
- const sid = stack.getId();
260
- const slice = this.getStackHistory(sid);
261
- const top = slice.length ? slice[slice.length - 1] : undefined;
262
- if (top) {
263
- const meta = this.routeById.get(top.routeId);
264
- this.visibleRoute = meta ? {
265
- ...meta,
266
- routeId: top.routeId,
267
- params: top.params,
268
- query: top.query,
269
- path: top.path
270
- } : {
271
- routeId: top.routeId,
272
- stackId: sid,
273
- tabIndex: idx,
274
- params: top.params,
275
- query: top.query,
276
- scope: 'tab'
277
- };
278
- return;
279
- }
280
- } else {
281
- this.visibleRoute = {
282
- routeId: `tab-screen-${idx}`,
283
- tabIndex: idx,
284
- scope: 'tab'
285
- };
286
- return;
287
- }
246
+ } else {
247
+ pathMatch = r.matchPath(pathname);
288
248
  }
249
+ const queryMatch = this.matchQueryPattern(r.queryPattern, query);
250
+ matches.push({
251
+ routeId: r.routeId,
252
+ path: r.path,
253
+ pathnamePattern: r.pathnamePattern,
254
+ stackId: r.stackId,
255
+ isWildcardPath: r.isWildcardPath,
256
+ baseSpecificity: r.baseSpecificity,
257
+ pathMatch: !!pathMatch,
258
+ queryMatch,
259
+ options: r.options
260
+ });
289
261
  }
290
-
291
- // Root stack
292
- if (this.root && isNavigationStackLike(this.root)) {
293
- const sid = this.root.getId();
294
- const slice = this.getStackHistory(sid);
295
- const top = slice.length ? slice[slice.length - 1] : undefined;
262
+ const best = this.matchBaseRoute(pathname, query);
263
+ return {
264
+ input: {
265
+ path,
266
+ pathname,
267
+ query
268
+ },
269
+ matches,
270
+ best: best ? {
271
+ routeId: best.routeId,
272
+ path: best.path,
273
+ stackId: best.stackId,
274
+ options: best.options
275
+ } : null
276
+ };
277
+ }
278
+ debugGetStackInfo(stackId) {
279
+ const slice = this.getStackHistory(stackId);
280
+ return {
281
+ stackId,
282
+ historyLength: slice.length,
283
+ items: slice.map(i => ({
284
+ key: i.key,
285
+ routeId: i.routeId,
286
+ path: i.path,
287
+ params: i.params,
288
+ query: i.query,
289
+ stackPresentation: i.options?.stackPresentation
290
+ }))
291
+ };
292
+ }
293
+ debugGetAllStacks() {
294
+ const stackIds = Array.from(new Set(this.state.history.map(h => h.stackId).filter(Boolean)));
295
+ return stackIds.map(stackId => this.debugGetStackInfo(stackId));
296
+ }
297
+ recomputeActiveRoute() {
298
+ const history = this.state.history;
299
+ if (history.length > 0) {
300
+ const top = history[history.length - 1];
296
301
  if (top) {
297
302
  const meta = this.routeById.get(top.routeId);
298
- this.visibleRoute = meta ? {
303
+ this.activeRoute = meta ? {
299
304
  ...meta,
300
305
  routeId: top.routeId,
301
306
  params: top.params,
@@ -303,283 +308,971 @@ export class Router {
303
308
  path: top.path
304
309
  } : {
305
310
  routeId: top.routeId,
306
- stackId: sid,
311
+ stackId: top.stackId,
307
312
  params: top.params,
308
313
  query: top.query,
309
- scope: 'root'
314
+ path: top.path
310
315
  };
311
316
  return;
312
317
  }
313
318
  }
314
- this.visibleRoute = null;
319
+ this.activeRoute = null;
315
320
  }
316
-
317
- // Internal navigation logic
318
321
  performNavigation(path, action, opts) {
319
322
  const {
320
323
  pathname,
321
324
  query
322
325
  } = this.parsePath(path);
323
- const matched = this.matchRoute(pathname);
324
- if (!matched) {
326
+ const navigationToken = ++this.navigationToken;
327
+ this.log('performNavigation', {
328
+ path,
329
+ pathname,
330
+ query,
331
+ action,
332
+ dedupe: opts?.dedupe
333
+ });
334
+ const base = this.matchBaseRoute(pathname, query);
335
+ this.log('resolveNavigation', {
336
+ base: base ? {
337
+ routeId: base.routeId,
338
+ path: base.path,
339
+ stackId: base.stackId
340
+ } : null
341
+ });
342
+ if (!base) {
325
343
  if (__DEV__) {
326
344
  throw new Error(`Route not found: "${pathname}"`);
327
345
  }
328
346
  return;
329
347
  }
330
- if (matched.scope === 'tab' && this.tabBar && matched.tabIndex !== undefined) {
331
- this.onTabIndexChange(matched.tabIndex);
348
+ const activator = base.stackId ? this.stackActivators.get(base.stackId) : undefined;
349
+ if (activator) {
350
+ activator();
332
351
  }
333
- const matchResult = matched.match(pathname);
352
+ const matchResult = base.matchPath(pathname);
334
353
  const params = matchResult ? matchResult.params : undefined;
335
354
 
336
- // Prevent duplicate push when navigating to the same screen already on top of its stack
355
+ // Smart navigate:
356
+ // If navigate(push) targets the currently active routeId within the same stack, treat it as
357
+ // "same screen, new data" and perform a replace (preserving the existing key) by default.
358
+ //
359
+ // Consumers can opt out per-route via ScreenOptions.allowMultipleInstances=true.
360
+ if (action === 'push' && base.stackId) {
361
+ const mergedOptions = this.mergeOptions(base.options, base.stackId);
362
+ const allowMultipleInstances = mergedOptions?.allowMultipleInstances === true;
363
+ const isActiveSameStack = this.activeRoute?.stackId === base.stackId;
364
+ const isActiveSameRoute = this.activeRoute?.routeId === base.routeId;
365
+
366
+ // Optional safety: only apply to push-presentation screens by default.
367
+ const presentation = mergedOptions?.stackPresentation ?? 'push';
368
+ const isPushPresentation = presentation === 'push';
369
+ if (!allowMultipleInstances && isPushPresentation && isActiveSameStack && isActiveSameRoute) {
370
+ const newItem = this.createHistoryItem(base, params, query, pathname);
371
+ this.applyHistoryChange('replace', newItem);
372
+ return;
373
+ }
374
+ }
337
375
  if (action === 'push') {
338
- const top = this.getTopForTarget(matched.stackId);
339
- if (top && top.routeId === matched.routeId) {
340
- const prev = top.params ? JSON.stringify(top.params) : '';
341
- const next = params ? JSON.stringify(params) : '';
342
- if (prev === next) {
376
+ if (base.stackId) {
377
+ let existing = this.findExistingRoute(base.stackId, base.routeId, pathname, params ?? {});
378
+ if (!existing) {
379
+ existing = this.findExistingRouteByPathname(base.stackId, pathname, params ?? {});
380
+ }
381
+ if (existing) {
382
+ this.log('push: found existing item in stack history, using popTo', {
383
+ key: existing.key,
384
+ routeId: existing.routeId,
385
+ path: existing.path
386
+ });
387
+ const normalizedParams = params && Object.keys(params).length > 0 ? params : undefined;
388
+ const updatedExisting = {
389
+ ...existing,
390
+ routeId: base.routeId,
391
+ params: normalizedParams,
392
+ query: query,
393
+ path: pathname,
394
+ component: base.component,
395
+ options: this.mergeOptions(base.options, base.stackId),
396
+ pattern: base.path
397
+ };
398
+ this.applyHistoryChange('popTo', updatedExisting);
343
399
  return;
344
400
  }
345
401
  }
346
402
  }
347
-
348
- // Optional dedupe for replace: no-op when nothing changes at the top
349
403
  if (action === 'replace' && opts?.dedupe) {
350
- const top = this.getTopForTarget(matched.stackId);
351
- if (top && top.routeId === matched.routeId) {
352
- const sameParams = JSON.stringify(top.params ?? {}) === JSON.stringify(params ?? {});
353
- const sameQuery = JSON.stringify(top.query ?? {}) === JSON.stringify(query ?? {});
404
+ const top = this.getTopOfStack(base.stackId);
405
+ this.log('dedupe: checking top of stack', {
406
+ top: top ? {
407
+ key: top.key,
408
+ routeId: top.routeId,
409
+ path: top.path
410
+ } : null,
411
+ matched: {
412
+ routeId: base.routeId,
413
+ pathname
414
+ }
415
+ });
416
+ if (top && top.routeId === base.routeId) {
417
+ const sameParams = this.areShallowEqual(top.params ?? {}, params ?? {});
354
418
  const samePath = (top.path ?? '') === pathname;
355
- if (sameParams && sameQuery && samePath) {
419
+ const sameIdentity = sameParams && samePath;
420
+ const sameQuery = this.areShallowEqual(top.query ?? {}, query ?? {});
421
+ this.log('dedupe: top matches routeId, checking identity (params+path) and query', {
422
+ sameParams,
423
+ samePath,
424
+ sameIdentity,
425
+ sameQuery
426
+ });
427
+ if (sameIdentity && sameQuery) {
428
+ this.log('dedupe: already at target, syncing state');
429
+ this.syncStateForSameRoute(base, pathname, params, query);
430
+ return;
431
+ }
432
+ if (sameIdentity && !sameQuery) {
433
+ this.log('dedupe: same identity, updating query on top via replace');
434
+ const normalizedParams = params && Object.keys(params).length > 0 ? params : undefined;
435
+ const updatedTop = {
436
+ ...top,
437
+ params: normalizedParams,
438
+ query: query,
439
+ path: pathname
440
+ };
441
+ this.applyHistoryChange('replace', updatedTop);
442
+ return;
443
+ }
444
+ }
445
+ if (base.stackId) {
446
+ const existing = this.findExistingRoute(base.stackId, base.routeId, pathname, params ?? {});
447
+ if (existing) {
448
+ const normalizedParams = params && Object.keys(params).length > 0 ? params : undefined;
449
+ const updatedExisting = {
450
+ ...existing,
451
+ params: normalizedParams,
452
+ query: query,
453
+ path: pathname
454
+ };
455
+ this.log('dedupe: found existing item, calling popTo', {
456
+ key: updatedExisting.key
457
+ });
458
+ this.applyHistoryChange('popTo', updatedExisting);
356
459
  return;
357
460
  }
358
461
  }
359
462
  }
360
-
361
- // If there's a controller, execute it first
362
- if (matched.controller) {
463
+ if (base.controller) {
363
464
  const controllerInput = {
364
465
  params,
365
466
  query
366
467
  };
468
+ let didPresent = false;
367
469
  const present = passProps => {
368
- const newItem = this.createHistoryItem(matched, params, query, pathname, passProps);
470
+ if (didPresent) return;
471
+ didPresent = true;
472
+ if (navigationToken !== this.navigationToken) {
473
+ this.log('controller: present ignored (stale navigation)', {
474
+ navigationToken,
475
+ current: this.navigationToken
476
+ });
477
+ return;
478
+ }
479
+ const newItem = this.createHistoryItem(base, params, query, pathname, passProps);
369
480
  this.applyHistoryChange(action, newItem);
370
481
  };
371
- matched.controller(controllerInput, present);
482
+ base.controller(controllerInput, present);
372
483
  return;
373
484
  }
374
- const newItem = this.createHistoryItem(matched, params, query, pathname);
485
+ const newItem = this.createHistoryItem(base, params, query, pathname);
375
486
  this.applyHistoryChange(action, newItem);
376
487
  }
377
488
  createHistoryItem(matched, params, query, pathname, passProps) {
489
+ const normalizedParams = params && Object.keys(params).length > 0 ? params : undefined;
378
490
  return {
379
491
  key: this.generateKey(),
380
- scope: matched.scope,
381
492
  routeId: matched.routeId,
382
493
  component: matched.component,
383
494
  options: this.mergeOptions(matched.options, matched.stackId),
384
- params,
495
+ params: normalizedParams,
385
496
  query: query,
386
497
  passProps,
387
- tabIndex: matched.tabIndex,
388
498
  stackId: matched.stackId,
389
499
  pattern: matched.path,
390
500
  path: pathname
391
501
  };
392
502
  }
393
-
394
- // Internal helpers
503
+ applyHistoryChange(action, item) {
504
+ const stackId = item.stackId;
505
+ if (!stackId) return;
506
+ const prevHist = this.state.history;
507
+ let nextHist = prevHist;
508
+ this.log('applyHistoryChange', {
509
+ action,
510
+ stackId,
511
+ item: {
512
+ key: item.key,
513
+ routeId: item.routeId,
514
+ path: item.path
515
+ }
516
+ });
517
+ if (action === 'push') {
518
+ nextHist = [...prevHist, item];
519
+ } else if (action === 'replace') {
520
+ let replaced = false;
521
+ const copy = [...prevHist];
522
+ for (let i = copy.length - 1; i >= 0; i--) {
523
+ const h = copy[i];
524
+ if (h) {
525
+ if (h.stackId === stackId) {
526
+ copy[i] = {
527
+ ...item,
528
+ key: h.key
529
+ };
530
+ replaced = true;
531
+ break;
532
+ }
533
+ }
534
+ }
535
+ if (!replaced) {
536
+ copy.push(item);
537
+ }
538
+ nextHist = copy;
539
+ } else if (action === 'pop') {
540
+ const copy = [...prevHist];
541
+ for (let i = copy.length - 1; i >= 0; i--) {
542
+ const h = copy[i];
543
+ if (h.stackId === stackId) {
544
+ copy.splice(i, 1);
545
+ break;
546
+ }
547
+ }
548
+ nextHist = copy;
549
+ } else if (action === 'popTo') {
550
+ const targetKey = item.key;
551
+ const keysToRemove = new Set();
552
+ let foundIndex = -1;
553
+ let foundItem = null;
554
+ for (let i = prevHist.length - 1; i >= 0; i--) {
555
+ const h = prevHist[i];
556
+ if (h.stackId !== stackId) {
557
+ continue;
558
+ }
559
+ if (h.key === targetKey) {
560
+ foundIndex = i;
561
+ foundItem = h;
562
+ break;
563
+ }
564
+ keysToRemove.add(h.key);
565
+ }
566
+ if (foundIndex === -1 || !foundItem) {
567
+ return;
568
+ }
569
+ const copy = prevHist.filter(h => !keysToRemove.has(h.key));
570
+ const updatedItem = {
571
+ ...foundItem,
572
+ ...item,
573
+ key: targetKey
574
+ };
575
+ const itemIndex = copy.findIndex(h => h.key === targetKey);
576
+ if (itemIndex >= 0) {
577
+ copy.splice(itemIndex, 1);
578
+ }
579
+ copy.push(updatedItem);
580
+ nextHist = copy;
581
+ }
582
+ this.setState({
583
+ history: nextHist
584
+ });
585
+ this.recomputeActiveRoute();
586
+ this.emit(this.listeners);
587
+ }
588
+ setState(next) {
589
+ const prev = this.state;
590
+ const nextState = {
591
+ history: next.history ?? prev.history
592
+ };
593
+ this.state = nextState;
594
+ if (nextState.history !== prev.history) {
595
+ this.updateStackHistories();
596
+ }
597
+ this.log('setState', nextState);
598
+ }
599
+ updateStackHistories() {
600
+ const stackIds = new Set();
601
+ this.state.history.forEach(item => {
602
+ if (item.stackId) stackIds.add(item.stackId);
603
+ });
604
+ const changedStackIds = new Set();
605
+ stackIds.forEach(stackId => {
606
+ const current = this.state.history.filter(item => item.stackId === stackId);
607
+ const previous = this.stackHistories.get(stackId);
608
+ if (previous && this.areArraysEqual(previous, current)) {
609
+ return;
610
+ }
611
+ this.stackHistories.set(stackId, current);
612
+ changedStackIds.add(stackId);
613
+ });
614
+ const currentStackIds = new Set(stackIds);
615
+ this.stackHistories.forEach((history, stackId) => {
616
+ if (!currentStackIds.has(stackId) && history.length > 0) {
617
+ this.stackHistories.delete(stackId);
618
+ changedStackIds.add(stackId);
619
+ } else if (!currentStackIds.has(stackId) && history.length === 0) {}
620
+ });
621
+ changedStackIds.forEach(stackId => {
622
+ this.emit(this.stackListeners.get(stackId));
623
+ });
624
+ }
625
+ areArraysEqual(a, b) {
626
+ if (a.length !== b.length) return false;
627
+ for (let i = 0; i < a.length; i++) {
628
+ if (a[i] !== b[i]) return false;
629
+ }
630
+ return true;
631
+ }
632
+ syncStateForSameRoute(base, pathname, _params, _query) {
633
+ if (!base.stackId) {
634
+ this.recomputeActiveRoute();
635
+ this.emit(this.listeners);
636
+ return;
637
+ }
638
+ const {
639
+ targetStackId,
640
+ targetRouteId
641
+ } = this.resolveTargetStackAndRoute(base, pathname);
642
+ const rootStackId = this.root?.getId();
643
+ if (rootStackId) {
644
+ this.activateContainerForRoute(targetRouteId, rootStackId);
645
+ }
646
+ this.activateStack(targetStackId);
647
+ this.ensureStackHasSeed(targetStackId, rootStackId ?? base.stackId);
648
+ this.updateActiveRouteFromStack(targetStackId, pathname);
649
+ this.emit(this.listeners);
650
+ }
651
+ resolveTargetStackAndRoute(base, pathname) {
652
+ const rootStackId = this.root?.getId();
653
+ let targetStackId = base.stackId;
654
+ let targetRouteId = base.routeId;
655
+ const currentActivePath = this.activeRoute?.path;
656
+ const currentActivePathname = currentActivePath ? this.parsePath(currentActivePath).pathname : '';
657
+ if (base.stackId === rootStackId && pathname === '/' && currentActivePathname !== '/') {
658
+ const childStackRoute = this.findChildStackRouteForPathname(rootStackId, pathname);
659
+ if (childStackRoute) {
660
+ targetStackId = childStackRoute.stackId;
661
+ targetRouteId = childStackRoute.routeId;
662
+ }
663
+ }
664
+ return {
665
+ targetStackId,
666
+ targetRouteId
667
+ };
668
+ }
669
+ findChildStackRouteForPathname(rootStackId, pathname) {
670
+ for (const route of this.registry) {
671
+ if (route.stackId !== rootStackId && route.pathnamePattern === pathname && !route.queryPattern) {
672
+ const rootRoute = this.findRootRouteWithContainer(rootStackId);
673
+ if (rootRoute?.childNode && this.isRouteInContainer(route.routeId, rootRoute.childNode)) {
674
+ return route;
675
+ }
676
+ }
677
+ }
678
+ return undefined;
679
+ }
680
+ findRootRouteWithContainer(rootStackId) {
681
+ return this.registry.find(r => r.stackId === rootStackId && r.childNode && canSwitchToRoute(r.childNode));
682
+ }
683
+ isRouteInContainer(routeId, container) {
684
+ if (canLookupRoutes(container) && container.hasRoute(routeId)) {
685
+ return true;
686
+ }
687
+ const visit = node => {
688
+ const routes = node.getNodeRoutes();
689
+ for (const r of routes) {
690
+ if (r.routeId === routeId) return true;
691
+ if (r.childNode && visit(r.childNode)) return true;
692
+ }
693
+ const children = node.getNodeChildren();
694
+ for (const child of children) {
695
+ if (visit(child.node)) return true;
696
+ }
697
+ return false;
698
+ };
699
+ return visit(container);
700
+ }
701
+ activateContainerForRoute(targetRouteId, rootStackId) {
702
+ const container = this.findContainerInStack(rootStackId);
703
+ if (container && canSwitchToRoute(container)) {
704
+ container.switchToRoute(targetRouteId);
705
+ }
706
+ }
707
+ findContainerInStack(stackId) {
708
+ const stackHistory = this.getStackHistory(stackId);
709
+ for (let i = stackHistory.length - 1; i >= 0; i--) {
710
+ const item = stackHistory[i];
711
+ if (item) {
712
+ const compiled = this.registry.find(r => r.routeId === item.routeId);
713
+ if (compiled?.childNode && canSwitchToRoute(compiled.childNode)) {
714
+ return compiled.childNode;
715
+ }
716
+ }
717
+ }
718
+ const rootRoute = this.findRootRouteWithContainer(stackId);
719
+ return rootRoute?.childNode;
720
+ }
721
+ activateStack(stackId) {
722
+ const activator = this.stackActivators.get(stackId);
723
+ if (activator) {
724
+ activator();
725
+ }
726
+ }
727
+ ensureStackHasSeed(stackId, rootStackId) {
728
+ const stackHistory = this.getStackHistory(stackId);
729
+ if (stackHistory.length > 0) return;
730
+ const stackNode = this.stackById.get(stackId);
731
+ if (!stackNode) return;
732
+ const seed = this.getAutoSeed(stackNode);
733
+ if (!seed) return;
734
+ const compiled = this.registry.find(r => r.routeId === seed.routeId);
735
+ const meta = this.routeById.get(seed.routeId);
736
+ const path = compiled?.path ?? meta?.path ?? seed.path;
737
+ const seedStackId = seed.stackId ?? stackNode.getId();
738
+ const item = {
739
+ key: this.generateKey(),
740
+ routeId: seed.routeId,
741
+ component: compiled?.component ?? (() => null),
742
+ options: this.mergeOptions(compiled?.options, seedStackId),
743
+ params: seed.params ?? {},
744
+ stackId: seedStackId,
745
+ path,
746
+ pattern: compiled?.path ?? seed.path
747
+ };
748
+ const prevHist = this.state.history;
749
+ const nextHist = [...prevHist, item];
750
+ this.setState({
751
+ history: nextHist
752
+ });
753
+ this.emit(this.stackListeners.get(seedStackId));
754
+ if (seedStackId !== stackId && rootStackId) {
755
+ this.activateContainerForRoute(seed.routeId, rootStackId);
756
+ }
757
+ }
758
+ updateActiveRouteFromStack(stackId, pathname) {
759
+ const stackHistory = this.getStackHistory(stackId);
760
+ if (stackHistory.length > 0) {
761
+ const topOfStack = stackHistory[stackHistory.length - 1];
762
+ if (topOfStack) {
763
+ const meta = this.routeById.get(topOfStack.routeId);
764
+ this.activeRoute = meta ? {
765
+ ...meta,
766
+ routeId: topOfStack.routeId,
767
+ params: topOfStack.params,
768
+ query: topOfStack.query,
769
+ path: topOfStack.path
770
+ } : {
771
+ routeId: topOfStack.routeId,
772
+ stackId: topOfStack.stackId,
773
+ params: topOfStack.params,
774
+ query: topOfStack.query,
775
+ path: topOfStack.path
776
+ };
777
+ return;
778
+ }
779
+ }
780
+ const targetRoute = this.registry.find(r => r.stackId === stackId && r.pathnamePattern === pathname);
781
+ if (targetRoute) {
782
+ const meta = this.routeById.get(targetRoute.routeId);
783
+ this.activeRoute = meta ? {
784
+ ...meta,
785
+ routeId: targetRoute.routeId,
786
+ params: {},
787
+ query: {},
788
+ path: targetRoute.path
789
+ } : {
790
+ routeId: targetRoute.routeId,
791
+ stackId: stackId,
792
+ params: {},
793
+ query: {},
794
+ path: targetRoute.path
795
+ };
796
+ }
797
+ }
798
+ emit(set) {
799
+ if (!set) return;
800
+ // Do not allow one listener to break all others.
801
+ for (const l of Array.from(set)) {
802
+ try {
803
+ l();
804
+ } catch (e) {
805
+ if (this.debugEnabled) {
806
+ console.error('[Router] listener error', e);
807
+ }
808
+ }
809
+ }
810
+ }
811
+ getTopOfStack(stackId) {
812
+ if (!stackId) return undefined;
813
+ const slice = this.getStackHistory(stackId);
814
+ return slice.length > 0 ? slice[slice.length - 1] : undefined;
815
+ }
816
+ findExistingRoute(stackId, routeId, pathname, params) {
817
+ const stackHistory = this.getStackHistory(stackId);
818
+ return stackHistory.find(item => {
819
+ if (item.routeId !== routeId) return false;
820
+ const itemPathname = item.path ? this.parsePath(item.path).pathname : '';
821
+ if (itemPathname !== pathname) return false;
822
+ return this.areShallowEqual(item.params ?? {}, params);
823
+ });
824
+ }
825
+ findExistingRouteByPathname(stackId, pathname, params) {
826
+ const stackHistory = this.getStackHistory(stackId);
827
+ return stackHistory.find(item => {
828
+ const itemPathname = item.path ? this.parsePath(item.path).pathname : '';
829
+ if (itemPathname !== pathname) return false;
830
+ return this.areShallowEqual(item.params ?? {}, params);
831
+ });
832
+ }
395
833
  buildRegistry() {
396
834
  this.registry.length = 0;
397
- const addFromStack = (stack, scope, extras) => {
398
- const stackId = stack.getId();
399
- this.stackById.set(stackId, stack);
400
- for (const r of stack.getRoutes()) {
401
- this.registry.push({
835
+ const addFromNode = (node, basePath) => {
836
+ const normalizedBasePath = this.normalizeBasePath(basePath);
837
+ const baseSpecificity = this.computeBasePathSpecificity(normalizedBasePath);
838
+ const routes = node.getNodeRoutes();
839
+ const stackId = routes.length > 0 ? node.getId() : undefined;
840
+ if (stackId) {
841
+ this.stackById.set(stackId, node);
842
+ }
843
+ for (const r of routes) {
844
+ const compiled = {
402
845
  routeId: r.routeId,
403
- scope,
404
- path: r.path,
405
- match: r.match,
846
+ path: this.combinePathWithBase(r.path, normalizedBasePath),
847
+ pathnamePattern: r.pathnamePattern === '*' ? '*' : this.joinPaths(normalizedBasePath, r.pathnamePattern),
848
+ isWildcardPath: r.isWildcardPath,
849
+ queryPattern: r.queryPattern,
850
+ baseSpecificity: r.baseSpecificity + baseSpecificity,
851
+ matchPath: pathname => {
852
+ const stripped = this.stripBasePath(normalizedBasePath, pathname);
853
+ if (stripped === null) return false;
854
+ if (r.isWildcardPath) {
855
+ return {
856
+ params: {}
857
+ };
858
+ }
859
+ const target = stripped === '' ? '/' : stripped;
860
+ return r.matchPath(target);
861
+ },
406
862
  component: r.component,
407
863
  controller: r.controller,
408
864
  options: r.options,
409
- tabIndex: extras.tabIndex,
410
- stackId
411
- });
412
- this.routeById.set(r.routeId, {
413
- path: r.path,
414
865
  stackId,
415
- tabIndex: extras.tabIndex,
416
- scope
866
+ childNode: r.childNode
867
+ };
868
+ this.registry.push(compiled);
869
+ if (stackId) {
870
+ this.routeById.set(r.routeId, {
871
+ path: compiled.path,
872
+ stackId
873
+ });
874
+ }
875
+ this.log('buildRegistry route', {
876
+ routeId: compiled.routeId,
877
+ path: compiled.path,
878
+ pathnamePattern: compiled.pathnamePattern,
879
+ isWildcardPath: compiled.isWildcardPath,
880
+ baseSpecificity: compiled.baseSpecificity,
881
+ stackId
417
882
  });
883
+ if (r.childNode) {
884
+ const nextBaseForChild = r.isWildcardPath ? normalizedBasePath : this.joinPaths(normalizedBasePath, r.pathnamePattern);
885
+ addFromNode(r.childNode, nextBaseForChild);
886
+ }
418
887
  }
419
- // init empty slice
420
- if (!this.stackSlices.has(stackId)) this.stackSlices.set(stackId, EMPTY_ARRAY);
421
- };
422
- if (isNavigationStackLike(this.root)) {
423
- addFromStack(this.root, 'root', {});
424
- } else if (this.tabBar) {
425
- const state = this.tabBar.getState();
426
- state.tabs.forEach((tab, idx) => {
427
- const stack = this.tabBar.stacks[tab.tabKey];
428
- if (stack) {
429
- addFromStack(stack, 'tab', {
430
- tabIndex: idx
431
- });
888
+ const children = node.getNodeChildren();
889
+ for (const child of children) {
890
+ const nextBase = child.prefix === '' ? normalizedBasePath : this.joinPaths(normalizedBasePath, child.prefix);
891
+ const childId = child.node.getId();
892
+ if (child.onMatch) {
893
+ this.stackActivators.set(childId, child.onMatch);
432
894
  }
433
- });
895
+ addFromNode(child.node, nextBase);
896
+ }
897
+ };
898
+ if (this.root) {
899
+ addFromNode(this.root, '');
900
+ }
901
+ }
902
+ normalizeBasePath(input) {
903
+ if (!input || input === '/') {
904
+ return '';
434
905
  }
435
- if (this.global) {
436
- addFromStack(this.global, 'global', {});
906
+ let normalized = input.startsWith('/') ? input : `/${input}`;
907
+ if (normalized.length > 1 && normalized.endsWith('/')) {
908
+ normalized = normalized.slice(0, -1);
437
909
  }
910
+ return normalized;
438
911
  }
439
- seedInitialHistory() {
440
- if (this.state.history.length > 0) return;
441
- if (this.tabBar) {
442
- const state = this.tabBar.getState();
443
- const activeIdx = state.index ?? 0;
444
- const route = state.tabs[activeIdx];
445
- if (!route) return;
446
- const stack = this.tabBar.stacks[route.tabKey];
447
- if (stack) {
448
- const first = stack.getFirstRoute();
449
- if (first) {
450
- const newItem = {
451
- key: this.generateKey(),
452
- scope: 'tab',
453
- routeId: first.routeId,
454
- component: first.component,
455
- options: this.mergeOptions(first.options, stack.getId()),
456
- params: {},
457
- tabIndex: activeIdx,
458
- stackId: stack.getId()
459
- };
460
- this.applyHistoryChange('push', newItem);
912
+ joinPaths(basePath, childPath) {
913
+ if (childPath === '*') return '*';
914
+ const base = this.normalizeBasePath(basePath);
915
+ const child = this.normalizeBasePath(childPath || '/');
916
+ if (!base && !child) return '/';
917
+ if (!base) return child || '/';
918
+ if (!child) return base || '/';
919
+ const joined = `${base}${child.startsWith('/') ? child : `/${child}`}`;
920
+ return joined || '/';
921
+ }
922
+ stripBasePath(basePath, pathname) {
923
+ const normalizedBase = this.normalizeBasePath(basePath);
924
+ if (!normalizedBase) {
925
+ return pathname || '/';
926
+ }
927
+ if (normalizedBase === '/') {
928
+ return pathname || '/';
929
+ }
930
+ if (pathname === normalizedBase) {
931
+ return '/';
932
+ }
933
+ if (pathname.startsWith(`${normalizedBase}/`)) {
934
+ const rest = pathname.slice(normalizedBase.length);
935
+ return rest.length ? rest : '/';
936
+ }
937
+ return null;
938
+ }
939
+ combinePathWithBase(path, basePath) {
940
+ const parsed = qs.parseUrl(path);
941
+ const urlPart = parsed.url || '/';
942
+ const query = parsed.query;
943
+ const isWildcard = urlPart === '*';
944
+ const combinedPathname = isWildcard ? `${this.normalizeBasePath(basePath)}*` : this.joinPaths(basePath, urlPart);
945
+ const hasQuery = query && Object.keys(query).length > 0;
946
+ if (!hasQuery) {
947
+ return combinedPathname || '/';
948
+ }
949
+ return `${combinedPathname}?${qs.stringify(query)}`;
950
+ }
951
+ computeBasePathSpecificity(basePath) {
952
+ if (!basePath) return 0;
953
+ const segments = basePath.split('/').filter(Boolean);
954
+ return segments.length * 2;
955
+ }
956
+ getAutoSeed(node) {
957
+ const explicitSeed = node.seed?.();
958
+ if (explicitSeed) {
959
+ return explicitSeed;
960
+ }
961
+ const activeChildId = node.getActiveChildId?.();
962
+ if (activeChildId) {
963
+ const activeChild = node.getNodeChildren().find(child => child.node.getId() === activeChildId);
964
+ if (activeChild) {
965
+ const childSeed = this.getAutoSeed(activeChild.node);
966
+ if (childSeed) {
967
+ return childSeed;
461
968
  }
462
969
  }
463
- return;
464
970
  }
465
- if (isNavigationStackLike(this.root)) {
466
- const first = this.root.getFirstRoute();
467
- if (first) {
468
- const newItem = {
971
+ const routes = node.getNodeRoutes();
972
+ if (routes.length > 0 && routes[0]) {
973
+ const firstRoute = routes[0];
974
+ return {
975
+ routeId: firstRoute.routeId,
976
+ params: {},
977
+ path: firstRoute.path,
978
+ stackId: node.getId()
979
+ };
980
+ }
981
+ return null;
982
+ }
983
+ seedInitialHistory() {
984
+ if (this.state.history.length > 0) return;
985
+ if (this.root) {
986
+ const seed = this.getAutoSeed(this.root);
987
+ if (seed) {
988
+ const compiled = this.registry.find(r => r.routeId === seed.routeId);
989
+ const meta = this.routeById.get(seed.routeId);
990
+ const path = compiled?.path ?? meta?.path ?? seed.path;
991
+ const stackId = seed.stackId ?? this.root.getId();
992
+ const item = {
469
993
  key: this.generateKey(),
470
- scope: 'root',
471
- routeId: first.routeId,
472
- component: first.component,
473
- options: this.mergeOptions(first.options, this.root.getId()),
474
- params: {},
475
- stackId: this.root.getId()
994
+ routeId: seed.routeId,
995
+ component: compiled?.component ?? (() => null),
996
+ options: this.mergeOptions(compiled?.options, stackId),
997
+ params: seed.params ?? {},
998
+ stackId,
999
+ path,
1000
+ pattern: compiled?.path ?? seed.path
476
1001
  };
477
- this.applyHistoryChange('push', newItem);
1002
+ this.applyHistoryChange('push', item);
1003
+ this.addChildNodeSeedsToHistory(seed.routeId);
478
1004
  }
1005
+ return;
479
1006
  }
480
1007
  }
481
- matchRoute(path) {
482
- for (const r of this.registry) {
483
- if (r.match(path)) return r;
1008
+ addChildNodeSeedsToItems(routeId, items, finalRouteId) {
1009
+ this.log('addChildNodeSeeds: called', {
1010
+ routeId,
1011
+ finalRouteId,
1012
+ itemsLength: items.length,
1013
+ items: items.map(i => ({
1014
+ routeId: i.routeId,
1015
+ stackId: i.stackId,
1016
+ path: i.path
1017
+ }))
1018
+ });
1019
+ const compiled = this.registry.find(r => r.routeId === routeId);
1020
+ if (!compiled || !compiled.childNode) {
1021
+ this.log('addChildNodeSeeds: no childNode', {
1022
+ routeId
1023
+ });
1024
+ return;
1025
+ }
1026
+ const childNode = compiled.childNode;
1027
+ if (finalRouteId && canSwitchToRoute(childNode)) {
1028
+ this.log('addChildNodeSeeds: setting active child', {
1029
+ finalRouteId
1030
+ });
1031
+ childNode.switchToRoute(finalRouteId);
1032
+ }
1033
+ const childSeed = this.getAutoSeed(childNode);
1034
+ if (!childSeed) {
1035
+ this.log('addChildNodeSeeds: no seed returned', {
1036
+ routeId
1037
+ });
1038
+ return;
1039
+ }
1040
+ this.log('addChildNodeSeeds: got seed', {
1041
+ routeId: childSeed.routeId,
1042
+ stackId: childSeed.stackId,
1043
+ path: childSeed.path
1044
+ });
1045
+ const childCompiled = this.registry.find(r => r.routeId === childSeed.routeId);
1046
+ const childMeta = this.routeById.get(childSeed.routeId);
1047
+ const childPath = childCompiled?.path ?? childMeta?.path ?? childSeed.path;
1048
+ const childStackId = childSeed.stackId ?? childNode.getId();
1049
+ const existingItem = items.find(item => item.routeId === childSeed.routeId && item.stackId === childStackId);
1050
+ if (existingItem) {
1051
+ this.log('addChildNodeSeeds: skipping duplicate', {
1052
+ routeId: childSeed.routeId,
1053
+ stackId: childStackId,
1054
+ path: childPath,
1055
+ existingItem: {
1056
+ key: existingItem.key,
1057
+ routeId: existingItem.routeId,
1058
+ stackId: existingItem.stackId,
1059
+ path: existingItem.path
1060
+ }
1061
+ });
1062
+ return;
1063
+ }
1064
+ this.log('addChildNodeSeeds: adding child item', {
1065
+ routeId: childSeed.routeId,
1066
+ stackId: childStackId,
1067
+ path: childPath,
1068
+ itemsLength: items.length
1069
+ });
1070
+ const childItem = {
1071
+ key: this.generateKey(),
1072
+ routeId: childSeed.routeId,
1073
+ component: childCompiled?.component ?? (() => null),
1074
+ options: this.mergeOptions(childCompiled?.options, childStackId),
1075
+ params: childSeed.params ?? {},
1076
+ stackId: childStackId,
1077
+ path: childPath,
1078
+ pattern: childCompiled?.path ?? childSeed.path
1079
+ };
1080
+ // Insert right after the parent route item, so that child stack seeds do not
1081
+ // accidentally become the active route for deep-links (order matters).
1082
+ let parentIndex = -1;
1083
+ for (let i = items.length - 1; i >= 0; i--) {
1084
+ const it = items[i];
1085
+ if (it && it.routeId === routeId) {
1086
+ parentIndex = i;
1087
+ break;
1088
+ }
1089
+ }
1090
+ const insertIndex = parentIndex >= 0 ? parentIndex + 1 : items.length;
1091
+ items.splice(insertIndex, 0, childItem);
1092
+ if (childSeed.routeId) {
1093
+ this.addChildNodeSeedsToItems(childSeed.routeId, items, finalRouteId);
484
1094
  }
485
- return undefined;
486
- }
487
- generateKey() {
488
- return `route-${nanoid()}`;
489
1095
  }
490
- parsePath(path) {
491
- const parsed = qs.parseUrl(path);
492
- return {
493
- pathname: parsed.url,
494
- query: parsed.query
1096
+ addChildNodeSeedsToHistory(routeId) {
1097
+ const compiled = this.registry.find(r => r.routeId === routeId);
1098
+ if (!compiled || !compiled.childNode) return;
1099
+ const childNode = compiled.childNode;
1100
+ const childSeed = this.getAutoSeed(childNode);
1101
+ if (!childSeed) return;
1102
+ const childCompiled = this.registry.find(r => r.routeId === childSeed.routeId);
1103
+ const childMeta = this.routeById.get(childSeed.routeId);
1104
+ const childPath = childCompiled?.path ?? childMeta?.path ?? childSeed.path;
1105
+ const childStackId = childSeed.stackId ?? childNode.getId();
1106
+ const childItem = {
1107
+ key: this.generateKey(),
1108
+ routeId: childSeed.routeId,
1109
+ component: childCompiled?.component ?? (() => null),
1110
+ options: this.mergeOptions(childCompiled?.options, childStackId),
1111
+ params: childSeed.params ?? {},
1112
+ stackId: childStackId,
1113
+ path: childPath,
1114
+ pattern: childCompiled?.path ?? childSeed.path
495
1115
  };
1116
+ this.applyHistoryChange('push', childItem);
1117
+ if (childSeed.routeId) {
1118
+ this.addChildNodeSeedsToHistory(childSeed.routeId);
1119
+ }
496
1120
  }
497
- applyHistoryChange(action, item) {
498
- const sid = item.stackId;
499
- if (action === 'push') {
500
- this.setState({
501
- history: [...this.state.history, item]
1121
+ matchBaseRoute(pathname, query) {
1122
+ this.log('matchBaseRoute', {
1123
+ pathname,
1124
+ query
1125
+ });
1126
+ let best;
1127
+ const candidates = [];
1128
+ for (const r of this.registry) {
1129
+ if (!r.stackId) continue;
1130
+ let pathMatch;
1131
+ if (r.isWildcardPath) {
1132
+ pathMatch = {
1133
+ params: {}
1134
+ };
1135
+ } else {
1136
+ pathMatch = r.matchPath(pathname);
1137
+ }
1138
+ if (!pathMatch) continue;
1139
+ if (!this.matchQueryPattern(r.queryPattern, query)) continue;
1140
+ let spec = r.baseSpecificity;
1141
+ const hasQueryPattern = r.queryPattern && Object.keys(r.queryPattern).length > 0;
1142
+ if (hasQueryPattern) {
1143
+ spec += 1000;
1144
+ }
1145
+ this.log('matchBaseRoute candidate', {
1146
+ routeId: r.routeId,
1147
+ path: r.path,
1148
+ baseSpecificity: r.baseSpecificity,
1149
+ adjustedSpecificity: spec,
1150
+ hasQueryPattern,
1151
+ stackId: r.stackId
502
1152
  });
503
- const prevSlice = this.stackSlices.get(sid) ?? EMPTY_ARRAY;
504
- const nextSlice = [...prevSlice, item];
505
- this.stackSlices.set(sid, nextSlice);
506
- this.emit(this.stackListeners.get(sid));
507
- this.recomputeVisibleRoute();
508
- this.emit(this.listeners);
509
- } else if (action === 'replace') {
510
- const prevTop = this.state.history[this.state.history.length - 1];
511
- const prevSid = prevTop?.stackId;
512
- this.setState({
513
- history: [...this.state.history.slice(0, -1), item]
1153
+ if (!best || spec > best.specificity) {
1154
+ best = {
1155
+ route: r,
1156
+ specificity: spec
1157
+ };
1158
+ candidates.length = 0;
1159
+ candidates.push(best);
1160
+ } else if (spec === best.specificity) {
1161
+ candidates.push({
1162
+ route: r,
1163
+ specificity: spec
1164
+ });
1165
+ }
1166
+ }
1167
+ if (candidates.length > 1) {
1168
+ this.log('matchBaseRoute: multiple candidates with same specificity', {
1169
+ candidatesCount: candidates.length,
1170
+ candidates: candidates.map(c => ({
1171
+ routeId: c.route.routeId,
1172
+ stackId: c.route.stackId,
1173
+ path: c.route.path
1174
+ }))
514
1175
  });
515
- if (prevSid && prevSid !== sid) {
516
- // Cross-stack replace: do not modify the source stack.
517
- // Update target stack without growing it unnecessarily.
518
- const targetSlice = this.stackSlices.get(sid) ?? EMPTY_ARRAY;
519
- if (targetSlice.length === 0) {
520
- this.stackSlices.set(sid, [item]);
521
- } else {
522
- const targetTop = targetSlice[targetSlice.length - 1];
523
- const sameRoute = targetTop?.routeId === item.routeId;
524
- const sameParams = JSON.stringify(targetTop?.params ?? {}) === JSON.stringify(item.params ?? {});
525
- if (sameRoute && sameParams) {
526
- // No change needed for target stack
527
- this.stackSlices.set(sid, targetSlice);
528
- } else {
529
- // Replace top of target stack
530
- const replaced = [...targetSlice.slice(0, -1), item];
531
- this.stackSlices.set(sid, replaced);
1176
+ const rootStackId = this.root?.getId();
1177
+ let bestFromHistory;
1178
+ let bestChildStack;
1179
+ let bestChildStackWithHistory;
1180
+ for (const candidate of candidates) {
1181
+ const candidateStackId = candidate.route.stackId;
1182
+ const stackHistory = this.getStackHistory(candidateStackId);
1183
+ const hasMatchingItem = stackHistory.some(item => {
1184
+ const itemPathname = item.path ? this.parsePath(item.path).pathname : '';
1185
+ return itemPathname === pathname;
1186
+ });
1187
+ this.log('matchBaseRoute: checking candidate', {
1188
+ routeId: candidate.route.routeId,
1189
+ stackId: candidateStackId,
1190
+ isRootStack: candidateStackId === rootStackId,
1191
+ stackHistoryLength: stackHistory.length,
1192
+ hasMatchingItem
1193
+ });
1194
+ if (hasMatchingItem) {
1195
+ if (candidateStackId !== rootStackId) {
1196
+ bestChildStack = candidate.route;
1197
+ this.log('matchBaseRoute: found child stack candidate with matching item', {
1198
+ routeId: candidate.route.routeId,
1199
+ stackId: candidateStackId
1200
+ });
1201
+ break;
1202
+ } else if (!bestFromHistory) {
1203
+ bestFromHistory = candidate.route;
1204
+ }
1205
+ } else if (candidateStackId !== rootStackId && stackHistory.length > 0) {
1206
+ if (!bestChildStackWithHistory) {
1207
+ bestChildStackWithHistory = candidate.route;
1208
+ this.log('matchBaseRoute: found child stack candidate with history', {
1209
+ routeId: candidate.route.routeId,
1210
+ stackId: candidateStackId,
1211
+ stackHistoryLength: stackHistory.length
1212
+ });
532
1213
  }
533
1214
  }
534
- this.emit(this.stackListeners.get(sid));
535
- } else {
536
- // Same-stack replace: replace top element
537
- const prevSlice = this.stackSlices.get(sid) ?? EMPTY_ARRAY;
538
- const nextSlice = prevSlice.length ? [...prevSlice.slice(0, -1), item] : [item];
539
- this.stackSlices.set(sid, nextSlice);
540
- this.emit(this.stackListeners.get(sid));
541
1215
  }
542
- this.recomputeVisibleRoute();
543
- this.emit(this.listeners);
544
- } else if (action === 'pop') {
545
- // Remove specific item by key from global history
546
- const nextHist = this.state.history.filter(h => h.key !== item.key);
547
- this.setState({
548
- history: nextHist
1216
+ if (bestChildStack && best) {
1217
+ best = {
1218
+ route: bestChildStack,
1219
+ specificity: best.specificity
1220
+ };
1221
+ this.log('matchBaseRoute: selected child stack with matching item', {
1222
+ routeId: bestChildStack.routeId,
1223
+ stackId: bestChildStack.stackId
1224
+ });
1225
+ } else if (bestChildStackWithHistory && best) {
1226
+ best = {
1227
+ route: bestChildStackWithHistory,
1228
+ specificity: best.specificity
1229
+ };
1230
+ this.log('matchBaseRoute: selected child stack', {
1231
+ routeId: bestChildStackWithHistory.routeId,
1232
+ stackId: bestChildStackWithHistory.stackId
1233
+ });
1234
+ } else if (bestFromHistory && best) {
1235
+ best = {
1236
+ route: bestFromHistory,
1237
+ specificity: best.specificity
1238
+ };
1239
+ this.log('matchBaseRoute: selected root stack with history', {
1240
+ routeId: bestFromHistory.routeId,
1241
+ stackId: bestFromHistory.stackId
1242
+ });
1243
+ }
1244
+ }
1245
+ if (best) {
1246
+ this.log('matchBaseRoute winner', {
1247
+ routeId: best.route.routeId,
1248
+ path: best.route.path,
1249
+ stackId: best.route.stackId,
1250
+ specificity: best.specificity,
1251
+ candidatesCount: candidates.length
549
1252
  });
550
-
551
- // Update slice only if the last item matches the popped one
552
- const prevSlice = this.stackSlices.get(sid) ?? EMPTY_ARRAY;
553
- const last = prevSlice.length ? prevSlice[prevSlice.length - 1] : undefined;
554
- if (last && last.key === item.key) {
555
- const nextSlice = prevSlice.slice(0, -1);
556
- this.stackSlices.set(sid, nextSlice);
557
- this.emit(this.stackListeners.get(sid));
558
- }
559
- this.recomputeVisibleRoute();
560
- this.emit(this.listeners);
1253
+ } else {
1254
+ this.log('matchBaseRoute no match');
561
1255
  }
1256
+ return best?.route;
562
1257
  }
563
- setState(next) {
564
- const prev = this.state;
565
- const nextState = {
566
- history: next.history ?? prev.history,
567
- activeTabIndex: next.activeTabIndex ?? prev.activeTabIndex
568
- };
569
- this.state = nextState;
570
- // Callers will emit updates explicitly.
571
- }
572
- emit(set) {
573
- if (!set) return;
574
- set.forEach(l => l());
1258
+ generateKey() {
1259
+ return `route-${nanoid()}`;
575
1260
  }
576
- getTopForTarget(stackId) {
577
- if (!stackId) return undefined;
578
- const slice = this.stackSlices.get(stackId) ?? EMPTY_ARRAY;
579
- return slice.length ? slice[slice.length - 1] : undefined;
1261
+ parsePath(path) {
1262
+ const parsed = qs.parseUrl(path);
1263
+ const result = {
1264
+ pathname: parsed.url,
1265
+ query: parsed.query
1266
+ };
1267
+ this.log('parsePath', {
1268
+ input: path,
1269
+ output: result
1270
+ });
1271
+ return result;
580
1272
  }
581
1273
  mergeOptions(routeOptions, stackId) {
582
- const stackDefaults = stackId ? this.findStackById(stackId)?.getDefaultOptions() : undefined;
1274
+ const stackNode = stackId ? this.findStackById(stackId) : undefined;
1275
+ const stackDefaults = stackNode?.getDefaultOptions?.();
583
1276
  const routerDefaults = this.routerScreenOptions;
584
1277
  if (!routerDefaults && !stackDefaults && !routeOptions) return undefined;
585
1278
  const merged = {
@@ -595,8 +1288,76 @@ export class Router {
595
1288
  findStackById(stackId) {
596
1289
  return this.stackById.get(stackId);
597
1290
  }
598
-
599
- // ==== Web integration (History API) ====
1291
+ areShallowEqual(a, b) {
1292
+ if (a === b) return true;
1293
+ if (!a || !b) return false;
1294
+ const aKeys = Object.keys(a);
1295
+ const bKeys = Object.keys(b);
1296
+ if (aKeys.length !== bKeys.length) return false;
1297
+ for (const k of aKeys) {
1298
+ if (!(k in b)) return false;
1299
+ const av = a[k];
1300
+ const bv = b[k];
1301
+ if (av === bv) continue;
1302
+ const aArr = Array.isArray(av);
1303
+ const bArr = Array.isArray(bv);
1304
+ if (aArr || bArr) {
1305
+ if (!aArr || !bArr) return false;
1306
+ if (av.length !== bv.length) return false;
1307
+ for (let i = 0; i < av.length; i++) {
1308
+ if (av[i] !== bv[i]) return false;
1309
+ }
1310
+ continue;
1311
+ }
1312
+ return false;
1313
+ }
1314
+ return true;
1315
+ }
1316
+ matchQueryPattern(pattern, query) {
1317
+ if (!pattern) {
1318
+ return true;
1319
+ }
1320
+ for (const [key, token] of Object.entries(pattern)) {
1321
+ const raw = query[key];
1322
+ if (raw == null) {
1323
+ this.log('matchQueryPattern: key missing', {
1324
+ key,
1325
+ token,
1326
+ query
1327
+ });
1328
+ return false;
1329
+ }
1330
+ if (typeof raw !== 'string') {
1331
+ this.log('matchQueryPattern: non-string value', {
1332
+ key,
1333
+ token,
1334
+ value: raw
1335
+ });
1336
+ return false;
1337
+ }
1338
+ if (token.type === 'const') {
1339
+ if (raw !== token.value) {
1340
+ this.log('matchQueryPattern: const mismatch', {
1341
+ key,
1342
+ expected: token.value,
1343
+ actual: raw
1344
+ });
1345
+ return false;
1346
+ }
1347
+ } else if (token.type === 'param') {
1348
+ this.log('matchQueryPattern: param ok', {
1349
+ key,
1350
+ value: raw,
1351
+ paramName: token.name
1352
+ });
1353
+ }
1354
+ }
1355
+ this.log('matchQueryPattern: success', {
1356
+ pattern,
1357
+ query
1358
+ });
1359
+ return true;
1360
+ }
600
1361
  isWebEnv() {
601
1362
  const g = globalThis;
602
1363
  return !!(g.addEventListener && g.history && g.location);
@@ -605,54 +1366,135 @@ export class Router {
605
1366
  const g = globalThis;
606
1367
  return g.location ? `${g.location.pathname}${g.location.search}` : '/';
607
1368
  }
608
- readIndex(state) {
1369
+ readHistoryIndex(state) {
609
1370
  if (state && typeof state === 'object' && '__srIndex' in state) {
610
1371
  const idx = state.__srIndex;
611
1372
  if (typeof idx === 'number') return idx;
612
1373
  }
613
1374
  return 0;
614
1375
  }
1376
+ getHistoryIndexOrNull() {
1377
+ const g = globalThis;
1378
+ const st = g.history?.state;
1379
+ if (st && typeof st === 'object' && '__srIndex' in st) {
1380
+ const idx = st.__srIndex;
1381
+ return typeof idx === 'number' ? idx : null;
1382
+ }
1383
+ return null;
1384
+ }
615
1385
  getHistoryIndex() {
616
1386
  const g = globalThis;
617
- return this.readIndex(g.history?.state);
1387
+ return this.readHistoryIndex(g.history?.state);
618
1388
  }
619
1389
  ensureHistoryIndex() {
620
1390
  const g = globalThis;
621
1391
  const st = g.history?.state ?? {};
622
- if (this.readIndex(st) === 0 && !(st && typeof st === 'object' && '__srIndex' in st)) {
623
- const next = {
624
- ...st,
1392
+ let next = st;
1393
+ let changed = false;
1394
+ if (!('__srIndex' in next)) {
1395
+ next = {
1396
+ ...next,
625
1397
  __srIndex: 0
626
1398
  };
1399
+ changed = true;
1400
+ }
1401
+ if (!('__srPath' in next)) {
1402
+ next = {
1403
+ ...next,
1404
+ __srPath: this.getCurrentUrl()
1405
+ };
1406
+ changed = true;
1407
+ }
1408
+ if (changed) {
627
1409
  try {
628
1410
  g.history?.replaceState(next, '', g.location?.href);
629
- } catch {
630
- // ignore
1411
+ } catch {}
1412
+ }
1413
+ }
1414
+ getRouterPathFromHistory() {
1415
+ const g = globalThis;
1416
+ const st = g.history?.state;
1417
+ if (st && typeof st === 'object' && '__srPath' in st) {
1418
+ const p = st.__srPath;
1419
+ if (typeof p === 'string' && p.length > 0) {
1420
+ return p;
631
1421
  }
632
1422
  }
1423
+ return this.getCurrentUrl();
633
1424
  }
634
- pushUrl(to) {
1425
+ getReplaceDedupeFromHistory() {
1426
+ const g = globalThis;
1427
+ const st = g.history?.state;
1428
+ if (st && typeof st === 'object' && '__srReplaceDedupe' in st) {
1429
+ const v = st.__srReplaceDedupe;
1430
+ return typeof v === 'boolean' ? v : false;
1431
+ }
1432
+ return false;
1433
+ }
1434
+ shouldSyncPathWithUrl(path) {
1435
+ const {
1436
+ pathname,
1437
+ query
1438
+ } = this.parsePath(path);
1439
+ const base = this.matchBaseRoute(pathname, query);
1440
+ if (!base) return true;
1441
+ const mergedOptions = this.mergeOptions(base.options, base.stackId);
1442
+ const rawsyncWithUrl = mergedOptions?.syncWithUrl;
1443
+ if (typeof rawsyncWithUrl === 'boolean') {
1444
+ return rawsyncWithUrl;
1445
+ }
1446
+ return true;
1447
+ }
1448
+ pushUrl(to, opts) {
635
1449
  const g = globalThis;
636
1450
  const st = g.history?.state ?? {};
637
- const prev = this.readIndex(st);
1451
+ const prev = this.readHistoryIndex(st);
1452
+ const base = st;
1453
+ const syncWithUrl = opts?.syncWithUrl ?? true;
1454
+ const routerPath = to;
1455
+ const visualUrl = syncWithUrl ? to : this.getCurrentUrl();
638
1456
  const next = {
639
- ...st,
640
- __srIndex: prev + 1
1457
+ ...base,
1458
+ __srIndex: prev + 1,
1459
+ __srPath: routerPath
641
1460
  };
642
- g.history?.pushState(next, '', to);
643
- if (g.Event && g.dispatchEvent) {
644
- g.dispatchEvent(new g.Event('pushState'));
645
- }
1461
+ g.history?.pushState(next, '', visualUrl);
646
1462
  }
647
- replaceUrl(to) {
1463
+ replaceUrl(to, opts) {
648
1464
  const g = globalThis;
649
1465
  const st = g.history?.state ?? {};
650
- g.history?.replaceState(st, '', to);
651
- if (g.Event && g.dispatchEvent) {
652
- g.dispatchEvent(new g.Event('replaceState'));
1466
+ const base = st;
1467
+ const syncWithUrl = opts?.syncWithUrl ?? true;
1468
+ const routerPath = to;
1469
+ const visualUrl = syncWithUrl ? to : this.getCurrentUrl();
1470
+ const next = {
1471
+ ...base,
1472
+ __srPath: routerPath,
1473
+ __srReplaceDedupe: !!opts?.dedupe
1474
+ };
1475
+ g.history?.replaceState(next, '', visualUrl);
1476
+ }
1477
+ replaceUrlSilently(to) {
1478
+ if (!this.isWebEnv()) return;
1479
+ this.suppressHistorySyncCount += 1;
1480
+ this.replaceUrl(to, {
1481
+ syncWithUrl: true
1482
+ });
1483
+ }
1484
+ buildUrlFromActiveRoute() {
1485
+ const ar = this.activeRoute;
1486
+ if (!ar || !ar.path) {
1487
+ return null;
653
1488
  }
1489
+ const path = ar.path;
1490
+ const query = ar.query;
1491
+ if (!query || Object.keys(query).length === 0) {
1492
+ return path;
1493
+ }
1494
+ const search = qs.stringify(query);
1495
+ return `${path}${search && search !== '' ? `?${search}` : ''}`;
654
1496
  }
655
- patchHistoryOnce() {
1497
+ patchBrowserHistoryOnce() {
656
1498
  const g = globalThis;
657
1499
  const key = Symbol.for('sigmela_router_history_patch');
658
1500
  if (g[key]) return;
@@ -677,42 +1519,78 @@ export class Router {
677
1519
  }
678
1520
  g[key] = true;
679
1521
  }
680
- lastBrowserIndex = 0;
681
- pendingReplaceDedupe = false;
682
1522
  setupBrowserHistory() {
683
1523
  const g = globalThis;
684
- this.patchHistoryOnce();
1524
+ const gAny = g;
1525
+ const activeKey = Symbol.for('sigmela_router_active_instance');
1526
+ this.patchBrowserHistoryOnce();
685
1527
  this.ensureHistoryIndex();
686
1528
  this.lastBrowserIndex = this.getHistoryIndex();
1529
+
1530
+ // If a new Router instance is created (e.g. Fast Refresh / HMR), ensure only the latest
1531
+ // instance reacts to browser history events to avoid duplicate navigation updates.
1532
+ gAny[activeKey] = this;
687
1533
  const onHistory = ev => {
688
- const url = this.getCurrentUrl();
1534
+ if (gAny[activeKey] !== this) return;
689
1535
  if (ev.type === 'pushState') {
690
- this.lastBrowserIndex = this.getHistoryIndex();
691
- this.performNavigation(url, 'push');
1536
+ const path = this.getRouterPathFromHistory();
1537
+ const idx = this.getHistoryIndexOrNull();
1538
+ this.lastBrowserIndex = idx !== null ? idx : Math.max(0, this.lastBrowserIndex + 1);
1539
+ this.performNavigation(path, 'push');
692
1540
  return;
693
1541
  }
694
1542
  if (ev.type === 'replaceState') {
695
- const dedupe = this.pendingReplaceDedupe === true;
696
- this.performNavigation(url, 'replace', {
1543
+ if (this.suppressHistorySyncCount > 0) {
1544
+ this.log('onHistory: replaceState suppressed (internal URL sync)');
1545
+ this.suppressHistorySyncCount -= 1;
1546
+ const idx = this.getHistoryIndexOrNull();
1547
+ this.lastBrowserIndex = idx !== null ? idx : this.lastBrowserIndex;
1548
+ return;
1549
+ }
1550
+ const path = this.getRouterPathFromHistory();
1551
+ const dedupe = this.getReplaceDedupeFromHistory();
1552
+ this.performNavigation(path, 'replace', {
697
1553
  dedupe
698
1554
  });
699
- this.lastBrowserIndex = this.getHistoryIndex();
700
- this.pendingReplaceDedupe = false;
1555
+ const idx = this.getHistoryIndexOrNull();
1556
+ this.lastBrowserIndex = idx !== null ? idx : this.lastBrowserIndex;
701
1557
  return;
702
1558
  }
703
1559
  if (ev.type === 'popstate') {
704
- const idx = this.getHistoryIndex();
705
- const delta = idx - this.lastBrowserIndex;
1560
+ const url = this.getRouterPathFromHistory();
1561
+ const idx = this.getHistoryIndexOrNull();
1562
+ const delta = idx !== null ? idx - this.lastBrowserIndex : 0;
1563
+ this.log('popstate event', {
1564
+ url,
1565
+ idx,
1566
+ prevIndex: this.lastBrowserIndex,
1567
+ delta
1568
+ });
1569
+
1570
+ // If browser history index isn't available (external history manipulations),
1571
+ // treat popstate as a soft replace. The route path still comes from __srPath when present.
1572
+ if (idx === null) {
1573
+ this.performNavigation(url, 'replace', {
1574
+ dedupe: true
1575
+ });
1576
+ return;
1577
+ }
706
1578
  if (delta < 0) {
707
- let steps = -delta;
708
- while (steps-- > 0) this.popOnce();
709
- const target = this.parsePath(url).pathname;
710
- const current = this.getVisibleRoute()?.path;
711
- if (current !== target) this.performNavigation(url, 'replace');
1579
+ this.performNavigation(url, 'replace', {
1580
+ dedupe: true
1581
+ });
712
1582
  } else if (delta > 0) {
1583
+ this.log('popstate: forward history step, treat as push', {
1584
+ url
1585
+ });
713
1586
  this.performNavigation(url, 'push');
714
1587
  } else {
715
- this.performNavigation(url, 'replace');
1588
+ this.log('popstate: same index, soft replace+dedupe', {
1589
+ url
1590
+ });
1591
+ this.performNavigation(url, 'replace', {
1592
+ dedupe: true
1593
+ });
716
1594
  }
717
1595
  this.lastBrowserIndex = idx;
718
1596
  }
@@ -721,123 +1599,267 @@ export class Router {
721
1599
  g.addEventListener?.('replaceState', onHistory);
722
1600
  g.addEventListener?.('popstate', onHistory);
723
1601
  }
724
- popOnce() {
725
- if (this.tryPopActiveStack()) return;
1602
+ syncUrlAfterInternalPop(popped) {
1603
+ if (!this.isWebEnv()) return;
1604
+ const compiled = this.registry.find(r => r.routeId === popped.routeId);
1605
+ const isQueryRoute = compiled && compiled.queryPattern && Object.keys(compiled.queryPattern).length > 0;
1606
+ if (isQueryRoute) {
1607
+ const currentUrl = this.getCurrentUrl();
1608
+ const {
1609
+ pathname,
1610
+ query
1611
+ } = this.parsePath(currentUrl);
1612
+ const pattern = compiled.queryPattern;
1613
+ const nextQuery = {
1614
+ ...query
1615
+ };
1616
+ for (const key of Object.keys(pattern)) {
1617
+ delete nextQuery[key];
1618
+ }
1619
+ const hasQuery = Object.keys(nextQuery).length > 0;
1620
+ const newSearch = hasQuery ? `?${qs.stringify(nextQuery)}` : '';
1621
+ const nextUrl = `${pathname}${newSearch}`;
1622
+ this.log('syncUrlWithStateAfterInternalPop (query)', {
1623
+ poppedRouteId: popped.routeId,
1624
+ from: currentUrl,
1625
+ to: nextUrl
1626
+ });
1627
+ this.replaceUrlSilently(nextUrl);
1628
+ return;
1629
+ }
1630
+ const from = this.getCurrentUrl();
1631
+ const nextUrl = this.buildUrlFromActiveRoute();
1632
+ if (!nextUrl) {
1633
+ this.log('syncUrlWithStateAfterInternalPop: no activeRoute, skip URL sync', {
1634
+ from
1635
+ });
1636
+ return;
1637
+ }
1638
+ this.log('syncUrlAfterInternalPop (base)', {
1639
+ poppedRouteId: popped.routeId,
1640
+ from,
1641
+ to: nextUrl
1642
+ });
1643
+ this.replaceUrlSilently(nextUrl);
726
1644
  }
727
-
728
- // Attempts to pop exactly one screen within the active stack only.
729
- // Returns true if a pop occurred; false otherwise.
730
- tryPopActiveStack() {
731
- const handlePop = item => {
732
- if (item.options?.stackPresentation === 'sheet') {
733
- const dismisser = this.sheetDismissers.get(item.key);
734
- if (dismisser) {
735
- this.unregisterSheetDismisser(item.key);
736
- dismisser();
737
- return true;
1645
+ popFromActiveStack() {
1646
+ const active = this.activeRoute;
1647
+ if (!active || !active.stackId) {
1648
+ if (this.root) {
1649
+ const sid = this.root.getId();
1650
+ if (sid) {
1651
+ const stackHistory = this.getStackHistory(sid);
1652
+ if (stackHistory.length > 0) {
1653
+ const top = stackHistory[stackHistory.length - 1];
1654
+ const isModalOrSheet = top.options?.stackPresentation === 'modal' || top.options?.stackPresentation === 'sheet';
1655
+ if (stackHistory.length > 1 || isModalOrSheet) {
1656
+ if (top.options?.stackPresentation === 'sheet') {
1657
+ const dismisser = this.sheetDismissers.get(top.key);
1658
+ if (dismisser) {
1659
+ this.unregisterSheetDismisser(top.key);
1660
+ dismisser();
1661
+ return top;
1662
+ }
1663
+ }
1664
+ this.applyHistoryChange('pop', top);
1665
+ return top;
1666
+ }
1667
+ }
738
1668
  }
739
1669
  }
740
- this.applyHistoryChange('pop', item);
741
- return true;
742
- };
743
- if (this.global) {
744
- const gid = this.global.getId();
745
- const gslice = this.getStackHistory(gid);
746
- const gtop = gslice.length ? gslice[gslice.length - 1] : undefined;
747
- if (gtop) {
748
- return handlePop(gtop);
749
- }
750
- }
751
- if (this.tabBar) {
752
- const idx = this.getActiveTabIndex();
753
- const state = this.tabBar.getState();
754
- const route = state.tabs[idx];
755
- if (!route) return false;
756
- const stack = this.tabBar.stacks[route.tabKey];
757
- if (!stack) return false;
758
- const sid = stack.getId();
759
- const slice = this.getStackHistory(sid);
760
- if (slice.length > 1) {
761
- const top = slice[slice.length - 1];
762
- if (top) {
763
- return handlePop(top);
764
- }
1670
+ return null;
1671
+ }
1672
+ let activeItem;
1673
+ for (let i = this.state.history.length - 1; i >= 0; i--) {
1674
+ const h = this.state.history[i];
1675
+ if (h && active && h.stackId === active.stackId) {
1676
+ activeItem = h;
1677
+ break;
765
1678
  }
766
- return false;
767
1679
  }
768
- if (isNavigationStackLike(this.root)) {
769
- const sid = this.root.getId();
770
- const slice = this.getStackHistory(sid);
771
- if (slice.length > 1) {
772
- const top = slice[slice.length - 1];
773
- if (top) {
774
- return handlePop(top);
775
- }
1680
+ if (!active || !activeItem || !activeItem.stackId) return null;
1681
+ const stackHistory = this.getStackHistory(activeItem.stackId);
1682
+ const isModalOrSheet = activeItem.options?.stackPresentation === 'modal' || activeItem.options?.stackPresentation === 'sheet';
1683
+ const allowRootPop = activeItem.options?.allowRootPop === true;
1684
+ if (stackHistory.length <= 1 && !isModalOrSheet && !allowRootPop) {
1685
+ return null;
1686
+ }
1687
+ if (activeItem.options?.stackPresentation === 'sheet') {
1688
+ const dismisser = this.sheetDismissers.get(activeItem.key);
1689
+ if (dismisser) {
1690
+ this.unregisterSheetDismisser(activeItem.key);
1691
+ dismisser();
1692
+ return activeItem;
776
1693
  }
777
1694
  }
778
- return false;
1695
+ const top = stackHistory[stackHistory.length - 1];
1696
+ this.applyHistoryChange('pop', top);
1697
+ return top;
779
1698
  }
780
-
781
- // Expand deep URL into a stack chain on initial load
782
- parse(url) {
1699
+ buildHistoryFromUrl(url, reuseKeysFrom) {
783
1700
  const {
784
1701
  pathname,
785
1702
  query
786
1703
  } = this.parsePath(url);
787
- const deepest = this.matchRoute(pathname);
788
- if (!deepest) {
1704
+ this.log('parse', {
1705
+ url,
1706
+ pathname,
1707
+ query
1708
+ });
1709
+ const baseRoute = this.matchBaseRoute(pathname, {});
1710
+ const deepest = this.matchBaseRoute(pathname, query);
1711
+ const hasDeepestQueryPattern = deepest && deepest.queryPattern && Object.keys(deepest.queryPattern).length > 0;
1712
+ const isDeepestOverlay = hasDeepestQueryPattern && deepest && baseRoute && deepest.routeId !== baseRoute.routeId;
1713
+ if (!deepest || isDeepestOverlay && !baseRoute) {
789
1714
  if (__DEV__) {
790
- throw new Error(`Route not found: "${pathname}"`);
1715
+ console.warn(`[Router] parse: no base route found for "${pathname}", seeding initial history`);
791
1716
  }
792
1717
  this.seedInitialHistory();
1718
+ this.recomputeActiveRoute();
1719
+ this.emit(this.listeners);
793
1720
  return;
794
1721
  }
795
- if (deepest.scope === 'tab' && this.tabBar && deepest.tabIndex !== undefined) {
796
- this.onTabIndexChange(deepest.tabIndex);
1722
+ const segments = pathname.split('/').filter(Boolean);
1723
+ const prefixes = segments.length > 0 ? segments.map((_, i) => '/' + segments.slice(0, i + 1).join('/')) : ['/'];
1724
+ if (!prefixes.includes('/')) {
1725
+ prefixes.unshift('/');
797
1726
  }
798
- const parts = pathname.split('/').filter(Boolean);
799
- const prefixes = new Array(parts.length + 1);
800
- let acc = '';
801
- prefixes[0] = '/';
802
- for (let i = 0; i < parts.length; i++) {
803
- acc += `/${parts[i]}`;
804
- prefixes[i + 1] = acc;
805
- }
806
- const candidates = [];
807
- for (const route of this.registry) {
808
- if (route.stackId !== deepest.stackId || route.scope !== deepest.scope) {
809
- continue;
810
- }
811
- const segmentsCount = route.path.split('/').filter(Boolean).length;
812
- const candidateUrl = prefixes[segmentsCount];
813
- if (!candidateUrl) {
814
- continue;
1727
+ const items = [];
1728
+ prefixes.forEach((prefixPath, index) => {
1729
+ this.log('parse prefix', {
1730
+ index,
1731
+ prefixPath
1732
+ });
1733
+ let routeForPrefix;
1734
+ if (index === prefixes.length - 1) {
1735
+ routeForPrefix = baseRoute || deepest;
1736
+ } else {
1737
+ let best;
1738
+ for (const route of this.registry) {
1739
+ if (route.queryPattern) continue;
1740
+ const matchResult = route.matchPath(prefixPath);
1741
+ if (!matchResult) continue;
1742
+ const spec = route.baseSpecificity;
1743
+ this.log('parse candidate for prefix', {
1744
+ prefixPath,
1745
+ routeId: route.routeId,
1746
+ path: route.path,
1747
+ baseSpecificity: spec
1748
+ });
1749
+ if (!best || spec > best.spec) {
1750
+ best = {
1751
+ route,
1752
+ spec
1753
+ };
1754
+ }
1755
+ }
1756
+ routeForPrefix = best?.route;
815
1757
  }
816
- const matchResult = route.match(candidateUrl);
817
- if (!matchResult) {
818
- continue;
1758
+ if (!routeForPrefix) {
1759
+ this.log('parse: no route for prefix', {
1760
+ index,
1761
+ prefixPath
1762
+ });
1763
+ return;
819
1764
  }
820
- candidates.push({
821
- params: matchResult.params,
822
- segmentCount: segmentsCount,
823
- candidateUrl,
824
- route
1765
+ const matchResult = routeForPrefix.matchPath(prefixPath);
1766
+ const params = matchResult ? matchResult.params : undefined;
1767
+ const itemQuery = index === prefixes.length - 1 && !isDeepestOverlay ? query : {};
1768
+ const item = this.createHistoryItem(routeForPrefix, params, itemQuery, prefixPath);
1769
+ this.log('parse: push item', {
1770
+ index,
1771
+ routeId: routeForPrefix.routeId,
1772
+ path: routeForPrefix.path,
1773
+ prefixPath,
1774
+ params,
1775
+ itemQuery
825
1776
  });
1777
+ items.push(item);
1778
+ });
1779
+ let overlayItem;
1780
+ if (isDeepestOverlay && deepest && baseRoute) {
1781
+ overlayItem = this.createHistoryItem(deepest, undefined, query, pathname);
1782
+ this.log('parse: push overlay item', {
1783
+ routeId: deepest.routeId,
1784
+ path: deepest.path,
1785
+ pathname,
1786
+ query
1787
+ });
1788
+ items.push(overlayItem);
1789
+ }
1790
+ if (items.length > 0) {
1791
+ const lastItem = items[items.length - 1];
1792
+ const lastItemCompiled = lastItem ? this.registry.find(r => r.routeId === lastItem.routeId) : undefined;
1793
+ const isLastItemOverlay = lastItem && lastItemCompiled && lastItemCompiled.queryPattern && Object.keys(lastItemCompiled.queryPattern).length > 0;
1794
+ const finalRouteId = isLastItemOverlay && items.length > 1 ? items[items.length - 2]?.routeId : lastItem?.routeId;
1795
+ const searchStartIndex = isLastItemOverlay ? items.length - 2 : items.length - 1;
1796
+ for (let i = searchStartIndex; i >= 0; i--) {
1797
+ const item = items[i];
1798
+ if (item && item.routeId) {
1799
+ const compiled = this.registry.find(r => r.routeId === item.routeId);
1800
+ if (compiled && compiled.childNode) {
1801
+ this.addChildNodeSeedsToItems(item.routeId, items, finalRouteId);
1802
+ // Don't break: we may have multiple nested containers (e.g. TabBar -> SplitView).
1803
+ // All of them must be activated/seeded to make the UI consistent on initial deep-link.
1804
+ }
1805
+ }
1806
+ }
1807
+ if (overlayItem) {
1808
+ const overlayIndex = items.findIndex(item => item.key === overlayItem.key);
1809
+ if (overlayIndex >= 0 && overlayIndex < items.length - 1) {
1810
+ items.splice(overlayIndex, 1);
1811
+ items.push(overlayItem);
1812
+ }
1813
+ }
826
1814
  }
827
- if (!candidates.length) {
1815
+ if (!items.length) {
1816
+ if (__DEV__) {
1817
+ console.warn('[Router] parse: no items built for URL, seeding initial history');
1818
+ }
828
1819
  this.seedInitialHistory();
1820
+ this.recomputeActiveRoute();
1821
+ this.emit(this.listeners);
829
1822
  return;
830
1823
  }
831
- candidates.sort((a, b) => a.segmentCount - b.segmentCount);
832
- const items = candidates.map((c, i) => this.createHistoryItem(c.route, c.params, i === candidates.length - 1 ? query : {}, c.candidateUrl));
833
- const first = items[0];
834
- const sid = first?.stackId ?? deepest.stackId;
1824
+
1825
+ // When rebuilding history (e.g. tab reset), preserve keys for matching items
1826
+ // so web animations don't replay for the whole root stack.
1827
+ this.reuseKeysFromPreviousHistory(items, reuseKeysFrom);
835
1828
  this.setState({
836
1829
  history: items
837
1830
  });
838
- if (sid) {
839
- this.stackSlices.set(sid, items);
840
- this.emit(this.stackListeners.get(sid));
1831
+ this.stackListeners.forEach(set => this.emit(set));
1832
+ this.recomputeActiveRoute();
1833
+ this.emit(this.listeners);
1834
+ }
1835
+ reuseKeysFromPreviousHistory(next, prev) {
1836
+ if (!prev || prev.length === 0) return;
1837
+ const used = new Set();
1838
+ const sameItemIdentity = (a, b) => {
1839
+ if (a.routeId !== b.routeId) return false;
1840
+ if ((a.stackId ?? '') !== (b.stackId ?? '')) return false;
1841
+ if ((a.path ?? '') !== (b.path ?? '')) return false;
1842
+ const sameParams = this.areShallowEqual(a.params ?? {}, b.params ?? {});
1843
+ if (!sameParams) return false;
1844
+ const sameQuery = this.areShallowEqual(a.query ?? {}, b.query ?? {});
1845
+ if (!sameQuery) return false;
1846
+
1847
+ // Keep presentation stable: modal/sheet items should not steal keys from push items.
1848
+ const ap = a.options?.stackPresentation ?? null;
1849
+ const bp = b.options?.stackPresentation ?? null;
1850
+ if (ap !== bp) return false;
1851
+ return true;
1852
+ };
1853
+ for (const item of next) {
1854
+ for (let i = prev.length - 1; i >= 0; i--) {
1855
+ const candidate = prev[i];
1856
+ if (!candidate) continue;
1857
+ if (used.has(candidate.key)) continue;
1858
+ if (!sameItemIdentity(item, candidate)) continue;
1859
+ item.key = candidate.key;
1860
+ used.add(candidate.key);
1861
+ break;
1862
+ }
841
1863
  }
842
1864
  }
843
1865
  }