@sigmela/router 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +1508 -501
  5. package/lib/module/RouterContext.js +1 -1
  6. package/lib/module/ScreenStack/ScreenStack.web.js +343 -117
  7. package/lib/module/ScreenStack/ScreenStackContext.js +15 -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 +79 -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 +117 -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 +22 -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 +21 -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,280 +308,950 @@ 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) {
343
+ if (__DEV__) {
344
+ throw new Error(`Route not found: "${pathname}"`);
345
+ }
325
346
  return;
326
347
  }
327
- if (matched.scope === 'tab' && this.tabBar && matched.tabIndex !== undefined) {
328
- this.onTabIndexChange(matched.tabIndex);
348
+ const activator = base.stackId ? this.stackActivators.get(base.stackId) : undefined;
349
+ if (activator) {
350
+ activator();
329
351
  }
330
- const matchResult = matched.match(pathname);
352
+ const matchResult = base.matchPath(pathname);
331
353
  const params = matchResult ? matchResult.params : undefined;
332
-
333
- // Prevent duplicate push when navigating to the same screen already on top of its stack
334
354
  if (action === 'push') {
335
- const top = this.getTopForTarget(matched.stackId);
336
- if (top && top.routeId === matched.routeId) {
337
- const prev = top.params ? JSON.stringify(top.params) : '';
338
- const next = params ? JSON.stringify(params) : '';
339
- if (prev === next) {
355
+ if (base.stackId) {
356
+ let existing = this.findExistingRoute(base.stackId, base.routeId, pathname, params ?? {});
357
+ if (!existing) {
358
+ existing = this.findExistingRouteByPathname(base.stackId, pathname, params ?? {});
359
+ }
360
+ if (existing) {
361
+ this.log('push: found existing item in stack history, using popTo', {
362
+ key: existing.key,
363
+ routeId: existing.routeId,
364
+ path: existing.path
365
+ });
366
+ const normalizedParams = params && Object.keys(params).length > 0 ? params : undefined;
367
+ const updatedExisting = {
368
+ ...existing,
369
+ routeId: base.routeId,
370
+ params: normalizedParams,
371
+ query: query,
372
+ path: pathname,
373
+ component: base.component,
374
+ options: this.mergeOptions(base.options, base.stackId),
375
+ pattern: base.path
376
+ };
377
+ this.applyHistoryChange('popTo', updatedExisting);
340
378
  return;
341
379
  }
342
380
  }
343
381
  }
344
-
345
- // Optional dedupe for replace: no-op when nothing changes at the top
346
382
  if (action === 'replace' && opts?.dedupe) {
347
- const top = this.getTopForTarget(matched.stackId);
348
- if (top && top.routeId === matched.routeId) {
349
- const sameParams = JSON.stringify(top.params ?? {}) === JSON.stringify(params ?? {});
350
- const sameQuery = JSON.stringify(top.query ?? {}) === JSON.stringify(query ?? {});
383
+ const top = this.getTopOfStack(base.stackId);
384
+ this.log('dedupe: checking top of stack', {
385
+ top: top ? {
386
+ key: top.key,
387
+ routeId: top.routeId,
388
+ path: top.path
389
+ } : null,
390
+ matched: {
391
+ routeId: base.routeId,
392
+ pathname
393
+ }
394
+ });
395
+ if (top && top.routeId === base.routeId) {
396
+ const sameParams = this.areShallowEqual(top.params ?? {}, params ?? {});
351
397
  const samePath = (top.path ?? '') === pathname;
352
- if (sameParams && sameQuery && samePath) {
398
+ const sameIdentity = sameParams && samePath;
399
+ const sameQuery = this.areShallowEqual(top.query ?? {}, query ?? {});
400
+ this.log('dedupe: top matches routeId, checking identity (params+path) and query', {
401
+ sameParams,
402
+ samePath,
403
+ sameIdentity,
404
+ sameQuery
405
+ });
406
+ if (sameIdentity && sameQuery) {
407
+ this.log('dedupe: already at target, syncing state');
408
+ this.syncStateForSameRoute(base, pathname, params, query);
409
+ return;
410
+ }
411
+ if (sameIdentity && !sameQuery) {
412
+ this.log('dedupe: same identity, updating query on top via replace');
413
+ const normalizedParams = params && Object.keys(params).length > 0 ? params : undefined;
414
+ const updatedTop = {
415
+ ...top,
416
+ params: normalizedParams,
417
+ query: query,
418
+ path: pathname
419
+ };
420
+ this.applyHistoryChange('replace', updatedTop);
421
+ return;
422
+ }
423
+ }
424
+ if (base.stackId) {
425
+ const existing = this.findExistingRoute(base.stackId, base.routeId, pathname, params ?? {});
426
+ if (existing) {
427
+ const normalizedParams = params && Object.keys(params).length > 0 ? params : undefined;
428
+ const updatedExisting = {
429
+ ...existing,
430
+ params: normalizedParams,
431
+ query: query,
432
+ path: pathname
433
+ };
434
+ this.log('dedupe: found existing item, calling popTo', {
435
+ key: updatedExisting.key
436
+ });
437
+ this.applyHistoryChange('popTo', updatedExisting);
353
438
  return;
354
439
  }
355
440
  }
356
441
  }
357
-
358
- // If there's a controller, execute it first
359
- if (matched.controller) {
442
+ if (base.controller) {
360
443
  const controllerInput = {
361
444
  params,
362
445
  query
363
446
  };
447
+ let didPresent = false;
364
448
  const present = passProps => {
365
- const newItem = this.createHistoryItem(matched, params, query, pathname, passProps);
449
+ if (didPresent) return;
450
+ didPresent = true;
451
+ if (navigationToken !== this.navigationToken) {
452
+ this.log('controller: present ignored (stale navigation)', {
453
+ navigationToken,
454
+ current: this.navigationToken
455
+ });
456
+ return;
457
+ }
458
+ const newItem = this.createHistoryItem(base, params, query, pathname, passProps);
366
459
  this.applyHistoryChange(action, newItem);
367
460
  };
368
- matched.controller(controllerInput, present);
461
+ base.controller(controllerInput, present);
369
462
  return;
370
463
  }
371
- const newItem = this.createHistoryItem(matched, params, query, pathname);
464
+ const newItem = this.createHistoryItem(base, params, query, pathname);
372
465
  this.applyHistoryChange(action, newItem);
373
466
  }
374
467
  createHistoryItem(matched, params, query, pathname, passProps) {
468
+ const normalizedParams = params && Object.keys(params).length > 0 ? params : undefined;
375
469
  return {
376
470
  key: this.generateKey(),
377
- scope: matched.scope,
378
471
  routeId: matched.routeId,
379
472
  component: matched.component,
380
473
  options: this.mergeOptions(matched.options, matched.stackId),
381
- params,
474
+ params: normalizedParams,
382
475
  query: query,
383
476
  passProps,
384
- tabIndex: matched.tabIndex,
385
477
  stackId: matched.stackId,
386
478
  pattern: matched.path,
387
479
  path: pathname
388
480
  };
389
481
  }
390
-
391
- // Internal helpers
482
+ applyHistoryChange(action, item) {
483
+ const stackId = item.stackId;
484
+ if (!stackId) return;
485
+ const prevHist = this.state.history;
486
+ let nextHist = prevHist;
487
+ this.log('applyHistoryChange', {
488
+ action,
489
+ stackId,
490
+ item: {
491
+ key: item.key,
492
+ routeId: item.routeId,
493
+ path: item.path
494
+ }
495
+ });
496
+ if (action === 'push') {
497
+ nextHist = [...prevHist, item];
498
+ } else if (action === 'replace') {
499
+ let replaced = false;
500
+ const copy = [...prevHist];
501
+ for (let i = copy.length - 1; i >= 0; i--) {
502
+ const h = copy[i];
503
+ if (h) {
504
+ if (h.stackId === stackId) {
505
+ copy[i] = {
506
+ ...item,
507
+ key: h.key
508
+ };
509
+ replaced = true;
510
+ break;
511
+ }
512
+ }
513
+ }
514
+ if (!replaced) {
515
+ copy.push(item);
516
+ }
517
+ nextHist = copy;
518
+ } else if (action === 'pop') {
519
+ const copy = [...prevHist];
520
+ for (let i = copy.length - 1; i >= 0; i--) {
521
+ const h = copy[i];
522
+ if (h.stackId === stackId) {
523
+ copy.splice(i, 1);
524
+ break;
525
+ }
526
+ }
527
+ nextHist = copy;
528
+ } else if (action === 'popTo') {
529
+ const targetKey = item.key;
530
+ const keysToRemove = new Set();
531
+ let foundIndex = -1;
532
+ let foundItem = null;
533
+ for (let i = prevHist.length - 1; i >= 0; i--) {
534
+ const h = prevHist[i];
535
+ if (h.stackId !== stackId) {
536
+ continue;
537
+ }
538
+ if (h.key === targetKey) {
539
+ foundIndex = i;
540
+ foundItem = h;
541
+ break;
542
+ }
543
+ keysToRemove.add(h.key);
544
+ }
545
+ if (foundIndex === -1 || !foundItem) {
546
+ return;
547
+ }
548
+ const copy = prevHist.filter(h => !keysToRemove.has(h.key));
549
+ const updatedItem = {
550
+ ...foundItem,
551
+ ...item,
552
+ key: targetKey
553
+ };
554
+ const itemIndex = copy.findIndex(h => h.key === targetKey);
555
+ if (itemIndex >= 0) {
556
+ copy.splice(itemIndex, 1);
557
+ }
558
+ copy.push(updatedItem);
559
+ nextHist = copy;
560
+ }
561
+ this.setState({
562
+ history: nextHist
563
+ });
564
+ this.recomputeActiveRoute();
565
+ this.emit(this.listeners);
566
+ }
567
+ setState(next) {
568
+ const prev = this.state;
569
+ const nextState = {
570
+ history: next.history ?? prev.history
571
+ };
572
+ this.state = nextState;
573
+ if (nextState.history !== prev.history) {
574
+ this.updateStackHistories();
575
+ }
576
+ this.log('setState', nextState);
577
+ }
578
+ updateStackHistories() {
579
+ const stackIds = new Set();
580
+ this.state.history.forEach(item => {
581
+ if (item.stackId) stackIds.add(item.stackId);
582
+ });
583
+ const changedStackIds = new Set();
584
+ stackIds.forEach(stackId => {
585
+ const current = this.state.history.filter(item => item.stackId === stackId);
586
+ const previous = this.stackHistories.get(stackId);
587
+ if (previous && this.areArraysEqual(previous, current)) {
588
+ return;
589
+ }
590
+ this.stackHistories.set(stackId, current);
591
+ changedStackIds.add(stackId);
592
+ });
593
+ const currentStackIds = new Set(stackIds);
594
+ this.stackHistories.forEach((history, stackId) => {
595
+ if (!currentStackIds.has(stackId) && history.length > 0) {
596
+ this.stackHistories.delete(stackId);
597
+ changedStackIds.add(stackId);
598
+ } else if (!currentStackIds.has(stackId) && history.length === 0) {}
599
+ });
600
+ changedStackIds.forEach(stackId => {
601
+ this.emit(this.stackListeners.get(stackId));
602
+ });
603
+ }
604
+ areArraysEqual(a, b) {
605
+ if (a.length !== b.length) return false;
606
+ for (let i = 0; i < a.length; i++) {
607
+ if (a[i] !== b[i]) return false;
608
+ }
609
+ return true;
610
+ }
611
+ syncStateForSameRoute(base, pathname, _params, _query) {
612
+ if (!base.stackId) {
613
+ this.recomputeActiveRoute();
614
+ this.emit(this.listeners);
615
+ return;
616
+ }
617
+ const {
618
+ targetStackId,
619
+ targetRouteId
620
+ } = this.resolveTargetStackAndRoute(base, pathname);
621
+ const rootStackId = this.root?.getId();
622
+ if (rootStackId) {
623
+ this.activateContainerForRoute(targetRouteId, rootStackId);
624
+ }
625
+ this.activateStack(targetStackId);
626
+ this.ensureStackHasSeed(targetStackId, rootStackId ?? base.stackId);
627
+ this.updateActiveRouteFromStack(targetStackId, pathname);
628
+ this.emit(this.listeners);
629
+ }
630
+ resolveTargetStackAndRoute(base, pathname) {
631
+ const rootStackId = this.root?.getId();
632
+ let targetStackId = base.stackId;
633
+ let targetRouteId = base.routeId;
634
+ const currentActivePath = this.activeRoute?.path;
635
+ const currentActivePathname = currentActivePath ? this.parsePath(currentActivePath).pathname : '';
636
+ if (base.stackId === rootStackId && pathname === '/' && currentActivePathname !== '/') {
637
+ const childStackRoute = this.findChildStackRouteForPathname(rootStackId, pathname);
638
+ if (childStackRoute) {
639
+ targetStackId = childStackRoute.stackId;
640
+ targetRouteId = childStackRoute.routeId;
641
+ }
642
+ }
643
+ return {
644
+ targetStackId,
645
+ targetRouteId
646
+ };
647
+ }
648
+ findChildStackRouteForPathname(rootStackId, pathname) {
649
+ for (const route of this.registry) {
650
+ if (route.stackId !== rootStackId && route.pathnamePattern === pathname && !route.queryPattern) {
651
+ const rootRoute = this.findRootRouteWithContainer(rootStackId);
652
+ if (rootRoute?.childNode && this.isRouteInContainer(route.routeId, rootRoute.childNode)) {
653
+ return route;
654
+ }
655
+ }
656
+ }
657
+ return undefined;
658
+ }
659
+ findRootRouteWithContainer(rootStackId) {
660
+ return this.registry.find(r => r.stackId === rootStackId && r.childNode && canSwitchToRoute(r.childNode));
661
+ }
662
+ isRouteInContainer(routeId, container) {
663
+ if (canLookupRoutes(container) && container.hasRoute(routeId)) {
664
+ return true;
665
+ }
666
+ const visit = node => {
667
+ const routes = node.getNodeRoutes();
668
+ for (const r of routes) {
669
+ if (r.routeId === routeId) return true;
670
+ if (r.childNode && visit(r.childNode)) return true;
671
+ }
672
+ const children = node.getNodeChildren();
673
+ for (const child of children) {
674
+ if (visit(child.node)) return true;
675
+ }
676
+ return false;
677
+ };
678
+ return visit(container);
679
+ }
680
+ activateContainerForRoute(targetRouteId, rootStackId) {
681
+ const container = this.findContainerInStack(rootStackId);
682
+ if (container && canSwitchToRoute(container)) {
683
+ container.switchToRoute(targetRouteId);
684
+ }
685
+ }
686
+ findContainerInStack(stackId) {
687
+ const stackHistory = this.getStackHistory(stackId);
688
+ for (let i = stackHistory.length - 1; i >= 0; i--) {
689
+ const item = stackHistory[i];
690
+ if (item) {
691
+ const compiled = this.registry.find(r => r.routeId === item.routeId);
692
+ if (compiled?.childNode && canSwitchToRoute(compiled.childNode)) {
693
+ return compiled.childNode;
694
+ }
695
+ }
696
+ }
697
+ const rootRoute = this.findRootRouteWithContainer(stackId);
698
+ return rootRoute?.childNode;
699
+ }
700
+ activateStack(stackId) {
701
+ const activator = this.stackActivators.get(stackId);
702
+ if (activator) {
703
+ activator();
704
+ }
705
+ }
706
+ ensureStackHasSeed(stackId, rootStackId) {
707
+ const stackHistory = this.getStackHistory(stackId);
708
+ if (stackHistory.length > 0) return;
709
+ const stackNode = this.stackById.get(stackId);
710
+ if (!stackNode) return;
711
+ const seed = this.getAutoSeed(stackNode);
712
+ if (!seed) return;
713
+ const compiled = this.registry.find(r => r.routeId === seed.routeId);
714
+ const meta = this.routeById.get(seed.routeId);
715
+ const path = compiled?.path ?? meta?.path ?? seed.path;
716
+ const seedStackId = seed.stackId ?? stackNode.getId();
717
+ const item = {
718
+ key: this.generateKey(),
719
+ routeId: seed.routeId,
720
+ component: compiled?.component ?? (() => null),
721
+ options: this.mergeOptions(compiled?.options, seedStackId),
722
+ params: seed.params ?? {},
723
+ stackId: seedStackId,
724
+ path,
725
+ pattern: compiled?.path ?? seed.path
726
+ };
727
+ const prevHist = this.state.history;
728
+ const nextHist = [...prevHist, item];
729
+ this.setState({
730
+ history: nextHist
731
+ });
732
+ this.emit(this.stackListeners.get(seedStackId));
733
+ if (seedStackId !== stackId && rootStackId) {
734
+ this.activateContainerForRoute(seed.routeId, rootStackId);
735
+ }
736
+ }
737
+ updateActiveRouteFromStack(stackId, pathname) {
738
+ const stackHistory = this.getStackHistory(stackId);
739
+ if (stackHistory.length > 0) {
740
+ const topOfStack = stackHistory[stackHistory.length - 1];
741
+ if (topOfStack) {
742
+ const meta = this.routeById.get(topOfStack.routeId);
743
+ this.activeRoute = meta ? {
744
+ ...meta,
745
+ routeId: topOfStack.routeId,
746
+ params: topOfStack.params,
747
+ query: topOfStack.query,
748
+ path: topOfStack.path
749
+ } : {
750
+ routeId: topOfStack.routeId,
751
+ stackId: topOfStack.stackId,
752
+ params: topOfStack.params,
753
+ query: topOfStack.query,
754
+ path: topOfStack.path
755
+ };
756
+ return;
757
+ }
758
+ }
759
+ const targetRoute = this.registry.find(r => r.stackId === stackId && r.pathnamePattern === pathname);
760
+ if (targetRoute) {
761
+ const meta = this.routeById.get(targetRoute.routeId);
762
+ this.activeRoute = meta ? {
763
+ ...meta,
764
+ routeId: targetRoute.routeId,
765
+ params: {},
766
+ query: {},
767
+ path: targetRoute.path
768
+ } : {
769
+ routeId: targetRoute.routeId,
770
+ stackId: stackId,
771
+ params: {},
772
+ query: {},
773
+ path: targetRoute.path
774
+ };
775
+ }
776
+ }
777
+ emit(set) {
778
+ if (!set) return;
779
+ // Do not allow one listener to break all others.
780
+ for (const l of Array.from(set)) {
781
+ try {
782
+ l();
783
+ } catch (e) {
784
+ if (this.debugEnabled) {
785
+ console.error('[Router] listener error', e);
786
+ }
787
+ }
788
+ }
789
+ }
790
+ getTopOfStack(stackId) {
791
+ if (!stackId) return undefined;
792
+ const slice = this.getStackHistory(stackId);
793
+ return slice.length > 0 ? slice[slice.length - 1] : undefined;
794
+ }
795
+ findExistingRoute(stackId, routeId, pathname, params) {
796
+ const stackHistory = this.getStackHistory(stackId);
797
+ return stackHistory.find(item => {
798
+ if (item.routeId !== routeId) return false;
799
+ const itemPathname = item.path ? this.parsePath(item.path).pathname : '';
800
+ if (itemPathname !== pathname) return false;
801
+ return this.areShallowEqual(item.params ?? {}, params);
802
+ });
803
+ }
804
+ findExistingRouteByPathname(stackId, pathname, params) {
805
+ const stackHistory = this.getStackHistory(stackId);
806
+ return stackHistory.find(item => {
807
+ const itemPathname = item.path ? this.parsePath(item.path).pathname : '';
808
+ if (itemPathname !== pathname) return false;
809
+ return this.areShallowEqual(item.params ?? {}, params);
810
+ });
811
+ }
392
812
  buildRegistry() {
393
813
  this.registry.length = 0;
394
- const addFromStack = (stack, scope, extras) => {
395
- const stackId = stack.getId();
396
- this.stackById.set(stackId, stack);
397
- for (const r of stack.getRoutes()) {
398
- this.registry.push({
814
+ const addFromNode = (node, basePath) => {
815
+ const normalizedBasePath = this.normalizeBasePath(basePath);
816
+ const baseSpecificity = this.computeBasePathSpecificity(normalizedBasePath);
817
+ const routes = node.getNodeRoutes();
818
+ const stackId = routes.length > 0 ? node.getId() : undefined;
819
+ if (stackId) {
820
+ this.stackById.set(stackId, node);
821
+ }
822
+ for (const r of routes) {
823
+ const compiled = {
399
824
  routeId: r.routeId,
400
- scope,
401
- path: r.path,
402
- match: r.match,
825
+ path: this.combinePathWithBase(r.path, normalizedBasePath),
826
+ pathnamePattern: r.pathnamePattern === '*' ? '*' : this.joinPaths(normalizedBasePath, r.pathnamePattern),
827
+ isWildcardPath: r.isWildcardPath,
828
+ queryPattern: r.queryPattern,
829
+ baseSpecificity: r.baseSpecificity + baseSpecificity,
830
+ matchPath: pathname => {
831
+ const stripped = this.stripBasePath(normalizedBasePath, pathname);
832
+ if (stripped === null) return false;
833
+ if (r.isWildcardPath) {
834
+ return {
835
+ params: {}
836
+ };
837
+ }
838
+ const target = stripped === '' ? '/' : stripped;
839
+ return r.matchPath(target);
840
+ },
403
841
  component: r.component,
404
842
  controller: r.controller,
405
843
  options: r.options,
406
- tabIndex: extras.tabIndex,
407
- stackId
408
- });
409
- this.routeById.set(r.routeId, {
410
- path: r.path,
411
844
  stackId,
412
- tabIndex: extras.tabIndex,
413
- scope
845
+ childNode: r.childNode
846
+ };
847
+ this.registry.push(compiled);
848
+ if (stackId) {
849
+ this.routeById.set(r.routeId, {
850
+ path: compiled.path,
851
+ stackId
852
+ });
853
+ }
854
+ this.log('buildRegistry route', {
855
+ routeId: compiled.routeId,
856
+ path: compiled.path,
857
+ pathnamePattern: compiled.pathnamePattern,
858
+ isWildcardPath: compiled.isWildcardPath,
859
+ baseSpecificity: compiled.baseSpecificity,
860
+ stackId
414
861
  });
862
+ if (r.childNode) {
863
+ const nextBaseForChild = r.isWildcardPath ? normalizedBasePath : this.joinPaths(normalizedBasePath, r.pathnamePattern);
864
+ addFromNode(r.childNode, nextBaseForChild);
865
+ }
415
866
  }
416
- // init empty slice
417
- if (!this.stackSlices.has(stackId)) this.stackSlices.set(stackId, EMPTY_ARRAY);
418
- };
419
- if (isNavigationStackLike(this.root)) {
420
- addFromStack(this.root, 'root', {});
421
- } else if (this.tabBar) {
422
- const state = this.tabBar.getState();
423
- state.tabs.forEach((tab, idx) => {
424
- const stack = this.tabBar.stacks[tab.tabKey];
425
- if (stack) {
426
- addFromStack(stack, 'tab', {
427
- tabIndex: idx
428
- });
867
+ const children = node.getNodeChildren();
868
+ for (const child of children) {
869
+ const nextBase = child.prefix === '' ? normalizedBasePath : this.joinPaths(normalizedBasePath, child.prefix);
870
+ const childId = child.node.getId();
871
+ if (child.onMatch) {
872
+ this.stackActivators.set(childId, child.onMatch);
429
873
  }
430
- });
874
+ addFromNode(child.node, nextBase);
875
+ }
876
+ };
877
+ if (this.root) {
878
+ addFromNode(this.root, '');
879
+ }
880
+ }
881
+ normalizeBasePath(input) {
882
+ if (!input || input === '/') {
883
+ return '';
431
884
  }
432
- if (this.global) {
433
- addFromStack(this.global, 'global', {});
885
+ let normalized = input.startsWith('/') ? input : `/${input}`;
886
+ if (normalized.length > 1 && normalized.endsWith('/')) {
887
+ normalized = normalized.slice(0, -1);
434
888
  }
889
+ return normalized;
435
890
  }
436
- seedInitialHistory() {
437
- if (this.state.history.length > 0) return;
438
- if (this.tabBar) {
439
- const state = this.tabBar.getState();
440
- const activeIdx = state.index ?? 0;
441
- const route = state.tabs[activeIdx];
442
- if (!route) return;
443
- const stack = this.tabBar.stacks[route.tabKey];
444
- if (stack) {
445
- const first = stack.getFirstRoute();
446
- if (first) {
447
- const newItem = {
448
- key: this.generateKey(),
449
- scope: 'tab',
450
- routeId: first.routeId,
451
- component: first.component,
452
- options: this.mergeOptions(first.options, stack.getId()),
453
- params: {},
454
- tabIndex: activeIdx,
455
- stackId: stack.getId()
456
- };
457
- this.applyHistoryChange('push', newItem);
891
+ joinPaths(basePath, childPath) {
892
+ if (childPath === '*') return '*';
893
+ const base = this.normalizeBasePath(basePath);
894
+ const child = this.normalizeBasePath(childPath || '/');
895
+ if (!base && !child) return '/';
896
+ if (!base) return child || '/';
897
+ if (!child) return base || '/';
898
+ const joined = `${base}${child.startsWith('/') ? child : `/${child}`}`;
899
+ return joined || '/';
900
+ }
901
+ stripBasePath(basePath, pathname) {
902
+ const normalizedBase = this.normalizeBasePath(basePath);
903
+ if (!normalizedBase) {
904
+ return pathname || '/';
905
+ }
906
+ if (normalizedBase === '/') {
907
+ return pathname || '/';
908
+ }
909
+ if (pathname === normalizedBase) {
910
+ return '/';
911
+ }
912
+ if (pathname.startsWith(`${normalizedBase}/`)) {
913
+ const rest = pathname.slice(normalizedBase.length);
914
+ return rest.length ? rest : '/';
915
+ }
916
+ return null;
917
+ }
918
+ combinePathWithBase(path, basePath) {
919
+ const parsed = qs.parseUrl(path);
920
+ const urlPart = parsed.url || '/';
921
+ const query = parsed.query;
922
+ const isWildcard = urlPart === '*';
923
+ const combinedPathname = isWildcard ? `${this.normalizeBasePath(basePath)}*` : this.joinPaths(basePath, urlPart);
924
+ const hasQuery = query && Object.keys(query).length > 0;
925
+ if (!hasQuery) {
926
+ return combinedPathname || '/';
927
+ }
928
+ return `${combinedPathname}?${qs.stringify(query)}`;
929
+ }
930
+ computeBasePathSpecificity(basePath) {
931
+ if (!basePath) return 0;
932
+ const segments = basePath.split('/').filter(Boolean);
933
+ return segments.length * 2;
934
+ }
935
+ getAutoSeed(node) {
936
+ const explicitSeed = node.seed?.();
937
+ if (explicitSeed) {
938
+ return explicitSeed;
939
+ }
940
+ const activeChildId = node.getActiveChildId?.();
941
+ if (activeChildId) {
942
+ const activeChild = node.getNodeChildren().find(child => child.node.getId() === activeChildId);
943
+ if (activeChild) {
944
+ const childSeed = this.getAutoSeed(activeChild.node);
945
+ if (childSeed) {
946
+ return childSeed;
458
947
  }
459
948
  }
460
- return;
461
949
  }
462
- if (isNavigationStackLike(this.root)) {
463
- const first = this.root.getFirstRoute();
464
- if (first) {
465
- const newItem = {
950
+ const routes = node.getNodeRoutes();
951
+ if (routes.length > 0 && routes[0]) {
952
+ const firstRoute = routes[0];
953
+ return {
954
+ routeId: firstRoute.routeId,
955
+ params: {},
956
+ path: firstRoute.path,
957
+ stackId: node.getId()
958
+ };
959
+ }
960
+ return null;
961
+ }
962
+ seedInitialHistory() {
963
+ if (this.state.history.length > 0) return;
964
+ if (this.root) {
965
+ const seed = this.getAutoSeed(this.root);
966
+ if (seed) {
967
+ const compiled = this.registry.find(r => r.routeId === seed.routeId);
968
+ const meta = this.routeById.get(seed.routeId);
969
+ const path = compiled?.path ?? meta?.path ?? seed.path;
970
+ const stackId = seed.stackId ?? this.root.getId();
971
+ const item = {
466
972
  key: this.generateKey(),
467
- scope: 'root',
468
- routeId: first.routeId,
469
- component: first.component,
470
- options: this.mergeOptions(first.options, this.root.getId()),
471
- params: {},
472
- stackId: this.root.getId()
973
+ routeId: seed.routeId,
974
+ component: compiled?.component ?? (() => null),
975
+ options: this.mergeOptions(compiled?.options, stackId),
976
+ params: seed.params ?? {},
977
+ stackId,
978
+ path,
979
+ pattern: compiled?.path ?? seed.path
473
980
  };
474
- this.applyHistoryChange('push', newItem);
981
+ this.applyHistoryChange('push', item);
982
+ this.addChildNodeSeedsToHistory(seed.routeId);
475
983
  }
984
+ return;
476
985
  }
477
986
  }
478
- matchRoute(path) {
479
- for (const r of this.registry) {
480
- if (r.match(path)) return r;
987
+ addChildNodeSeedsToItems(routeId, items, finalRouteId) {
988
+ this.log('addChildNodeSeeds: called', {
989
+ routeId,
990
+ finalRouteId,
991
+ itemsLength: items.length,
992
+ items: items.map(i => ({
993
+ routeId: i.routeId,
994
+ stackId: i.stackId,
995
+ path: i.path
996
+ }))
997
+ });
998
+ const compiled = this.registry.find(r => r.routeId === routeId);
999
+ if (!compiled || !compiled.childNode) {
1000
+ this.log('addChildNodeSeeds: no childNode', {
1001
+ routeId
1002
+ });
1003
+ return;
1004
+ }
1005
+ const childNode = compiled.childNode;
1006
+ if (finalRouteId && canSwitchToRoute(childNode)) {
1007
+ this.log('addChildNodeSeeds: setting active child', {
1008
+ finalRouteId
1009
+ });
1010
+ childNode.switchToRoute(finalRouteId);
1011
+ }
1012
+ const childSeed = this.getAutoSeed(childNode);
1013
+ if (!childSeed) {
1014
+ this.log('addChildNodeSeeds: no seed returned', {
1015
+ routeId
1016
+ });
1017
+ return;
1018
+ }
1019
+ this.log('addChildNodeSeeds: got seed', {
1020
+ routeId: childSeed.routeId,
1021
+ stackId: childSeed.stackId,
1022
+ path: childSeed.path
1023
+ });
1024
+ const childCompiled = this.registry.find(r => r.routeId === childSeed.routeId);
1025
+ const childMeta = this.routeById.get(childSeed.routeId);
1026
+ const childPath = childCompiled?.path ?? childMeta?.path ?? childSeed.path;
1027
+ const childStackId = childSeed.stackId ?? childNode.getId();
1028
+ const existingItem = items.find(item => item.routeId === childSeed.routeId && item.stackId === childStackId);
1029
+ if (existingItem) {
1030
+ this.log('addChildNodeSeeds: skipping duplicate', {
1031
+ routeId: childSeed.routeId,
1032
+ stackId: childStackId,
1033
+ path: childPath,
1034
+ existingItem: {
1035
+ key: existingItem.key,
1036
+ routeId: existingItem.routeId,
1037
+ stackId: existingItem.stackId,
1038
+ path: existingItem.path
1039
+ }
1040
+ });
1041
+ return;
1042
+ }
1043
+ this.log('addChildNodeSeeds: adding child item', {
1044
+ routeId: childSeed.routeId,
1045
+ stackId: childStackId,
1046
+ path: childPath,
1047
+ itemsLength: items.length
1048
+ });
1049
+ const childItem = {
1050
+ key: this.generateKey(),
1051
+ routeId: childSeed.routeId,
1052
+ component: childCompiled?.component ?? (() => null),
1053
+ options: this.mergeOptions(childCompiled?.options, childStackId),
1054
+ params: childSeed.params ?? {},
1055
+ stackId: childStackId,
1056
+ path: childPath,
1057
+ pattern: childCompiled?.path ?? childSeed.path
1058
+ };
1059
+ // Insert right after the parent route item, so that child stack seeds do not
1060
+ // accidentally become the active route for deep-links (order matters).
1061
+ let parentIndex = -1;
1062
+ for (let i = items.length - 1; i >= 0; i--) {
1063
+ const it = items[i];
1064
+ if (it && it.routeId === routeId) {
1065
+ parentIndex = i;
1066
+ break;
1067
+ }
1068
+ }
1069
+ const insertIndex = parentIndex >= 0 ? parentIndex + 1 : items.length;
1070
+ items.splice(insertIndex, 0, childItem);
1071
+ if (childSeed.routeId) {
1072
+ this.addChildNodeSeedsToItems(childSeed.routeId, items, finalRouteId);
481
1073
  }
482
- return undefined;
483
- }
484
- generateKey() {
485
- return `route-${nanoid()}`;
486
1074
  }
487
- parsePath(path) {
488
- const parsed = qs.parseUrl(path);
489
- return {
490
- pathname: parsed.url,
491
- query: parsed.query
1075
+ addChildNodeSeedsToHistory(routeId) {
1076
+ const compiled = this.registry.find(r => r.routeId === routeId);
1077
+ if (!compiled || !compiled.childNode) return;
1078
+ const childNode = compiled.childNode;
1079
+ const childSeed = this.getAutoSeed(childNode);
1080
+ if (!childSeed) return;
1081
+ const childCompiled = this.registry.find(r => r.routeId === childSeed.routeId);
1082
+ const childMeta = this.routeById.get(childSeed.routeId);
1083
+ const childPath = childCompiled?.path ?? childMeta?.path ?? childSeed.path;
1084
+ const childStackId = childSeed.stackId ?? childNode.getId();
1085
+ const childItem = {
1086
+ key: this.generateKey(),
1087
+ routeId: childSeed.routeId,
1088
+ component: childCompiled?.component ?? (() => null),
1089
+ options: this.mergeOptions(childCompiled?.options, childStackId),
1090
+ params: childSeed.params ?? {},
1091
+ stackId: childStackId,
1092
+ path: childPath,
1093
+ pattern: childCompiled?.path ?? childSeed.path
492
1094
  };
1095
+ this.applyHistoryChange('push', childItem);
1096
+ if (childSeed.routeId) {
1097
+ this.addChildNodeSeedsToHistory(childSeed.routeId);
1098
+ }
493
1099
  }
494
- applyHistoryChange(action, item) {
495
- const sid = item.stackId;
496
- if (action === 'push') {
497
- this.setState({
498
- history: [...this.state.history, item]
1100
+ matchBaseRoute(pathname, query) {
1101
+ this.log('matchBaseRoute', {
1102
+ pathname,
1103
+ query
1104
+ });
1105
+ let best;
1106
+ const candidates = [];
1107
+ for (const r of this.registry) {
1108
+ if (!r.stackId) continue;
1109
+ let pathMatch;
1110
+ if (r.isWildcardPath) {
1111
+ pathMatch = {
1112
+ params: {}
1113
+ };
1114
+ } else {
1115
+ pathMatch = r.matchPath(pathname);
1116
+ }
1117
+ if (!pathMatch) continue;
1118
+ if (!this.matchQueryPattern(r.queryPattern, query)) continue;
1119
+ let spec = r.baseSpecificity;
1120
+ const hasQueryPattern = r.queryPattern && Object.keys(r.queryPattern).length > 0;
1121
+ if (hasQueryPattern) {
1122
+ spec += 1000;
1123
+ }
1124
+ this.log('matchBaseRoute candidate', {
1125
+ routeId: r.routeId,
1126
+ path: r.path,
1127
+ baseSpecificity: r.baseSpecificity,
1128
+ adjustedSpecificity: spec,
1129
+ hasQueryPattern,
1130
+ stackId: r.stackId
499
1131
  });
500
- const prevSlice = this.stackSlices.get(sid) ?? EMPTY_ARRAY;
501
- const nextSlice = [...prevSlice, item];
502
- this.stackSlices.set(sid, nextSlice);
503
- this.emit(this.stackListeners.get(sid));
504
- this.recomputeVisibleRoute();
505
- this.emit(this.listeners);
506
- } else if (action === 'replace') {
507
- const prevTop = this.state.history[this.state.history.length - 1];
508
- const prevSid = prevTop?.stackId;
509
- this.setState({
510
- history: [...this.state.history.slice(0, -1), item]
1132
+ if (!best || spec > best.specificity) {
1133
+ best = {
1134
+ route: r,
1135
+ specificity: spec
1136
+ };
1137
+ candidates.length = 0;
1138
+ candidates.push(best);
1139
+ } else if (spec === best.specificity) {
1140
+ candidates.push({
1141
+ route: r,
1142
+ specificity: spec
1143
+ });
1144
+ }
1145
+ }
1146
+ if (candidates.length > 1) {
1147
+ this.log('matchBaseRoute: multiple candidates with same specificity', {
1148
+ candidatesCount: candidates.length,
1149
+ candidates: candidates.map(c => ({
1150
+ routeId: c.route.routeId,
1151
+ stackId: c.route.stackId,
1152
+ path: c.route.path
1153
+ }))
511
1154
  });
512
- if (prevSid && prevSid !== sid) {
513
- // Cross-stack replace: do not modify the source stack.
514
- // Update target stack without growing it unnecessarily.
515
- const targetSlice = this.stackSlices.get(sid) ?? EMPTY_ARRAY;
516
- if (targetSlice.length === 0) {
517
- this.stackSlices.set(sid, [item]);
518
- } else {
519
- const targetTop = targetSlice[targetSlice.length - 1];
520
- const sameRoute = targetTop?.routeId === item.routeId;
521
- const sameParams = JSON.stringify(targetTop?.params ?? {}) === JSON.stringify(item.params ?? {});
522
- if (sameRoute && sameParams) {
523
- // No change needed for target stack
524
- this.stackSlices.set(sid, targetSlice);
525
- } else {
526
- // Replace top of target stack
527
- const replaced = [...targetSlice.slice(0, -1), item];
528
- this.stackSlices.set(sid, replaced);
1155
+ const rootStackId = this.root?.getId();
1156
+ let bestFromHistory;
1157
+ let bestChildStack;
1158
+ let bestChildStackWithHistory;
1159
+ for (const candidate of candidates) {
1160
+ const candidateStackId = candidate.route.stackId;
1161
+ const stackHistory = this.getStackHistory(candidateStackId);
1162
+ const hasMatchingItem = stackHistory.some(item => {
1163
+ const itemPathname = item.path ? this.parsePath(item.path).pathname : '';
1164
+ return itemPathname === pathname;
1165
+ });
1166
+ this.log('matchBaseRoute: checking candidate', {
1167
+ routeId: candidate.route.routeId,
1168
+ stackId: candidateStackId,
1169
+ isRootStack: candidateStackId === rootStackId,
1170
+ stackHistoryLength: stackHistory.length,
1171
+ hasMatchingItem
1172
+ });
1173
+ if (hasMatchingItem) {
1174
+ if (candidateStackId !== rootStackId) {
1175
+ bestChildStack = candidate.route;
1176
+ this.log('matchBaseRoute: found child stack candidate with matching item', {
1177
+ routeId: candidate.route.routeId,
1178
+ stackId: candidateStackId
1179
+ });
1180
+ break;
1181
+ } else if (!bestFromHistory) {
1182
+ bestFromHistory = candidate.route;
1183
+ }
1184
+ } else if (candidateStackId !== rootStackId && stackHistory.length > 0) {
1185
+ if (!bestChildStackWithHistory) {
1186
+ bestChildStackWithHistory = candidate.route;
1187
+ this.log('matchBaseRoute: found child stack candidate with history', {
1188
+ routeId: candidate.route.routeId,
1189
+ stackId: candidateStackId,
1190
+ stackHistoryLength: stackHistory.length
1191
+ });
529
1192
  }
530
1193
  }
531
- this.emit(this.stackListeners.get(sid));
532
- } else {
533
- // Same-stack replace: replace top element
534
- const prevSlice = this.stackSlices.get(sid) ?? EMPTY_ARRAY;
535
- const nextSlice = prevSlice.length ? [...prevSlice.slice(0, -1), item] : [item];
536
- this.stackSlices.set(sid, nextSlice);
537
- this.emit(this.stackListeners.get(sid));
538
1194
  }
539
- this.recomputeVisibleRoute();
540
- this.emit(this.listeners);
541
- } else if (action === 'pop') {
542
- // Remove specific item by key from global history
543
- const nextHist = this.state.history.filter(h => h.key !== item.key);
544
- this.setState({
545
- history: nextHist
1195
+ if (bestChildStack && best) {
1196
+ best = {
1197
+ route: bestChildStack,
1198
+ specificity: best.specificity
1199
+ };
1200
+ this.log('matchBaseRoute: selected child stack with matching item', {
1201
+ routeId: bestChildStack.routeId,
1202
+ stackId: bestChildStack.stackId
1203
+ });
1204
+ } else if (bestChildStackWithHistory && best) {
1205
+ best = {
1206
+ route: bestChildStackWithHistory,
1207
+ specificity: best.specificity
1208
+ };
1209
+ this.log('matchBaseRoute: selected child stack', {
1210
+ routeId: bestChildStackWithHistory.routeId,
1211
+ stackId: bestChildStackWithHistory.stackId
1212
+ });
1213
+ } else if (bestFromHistory && best) {
1214
+ best = {
1215
+ route: bestFromHistory,
1216
+ specificity: best.specificity
1217
+ };
1218
+ this.log('matchBaseRoute: selected root stack with history', {
1219
+ routeId: bestFromHistory.routeId,
1220
+ stackId: bestFromHistory.stackId
1221
+ });
1222
+ }
1223
+ }
1224
+ if (best) {
1225
+ this.log('matchBaseRoute winner', {
1226
+ routeId: best.route.routeId,
1227
+ path: best.route.path,
1228
+ stackId: best.route.stackId,
1229
+ specificity: best.specificity,
1230
+ candidatesCount: candidates.length
546
1231
  });
547
-
548
- // Update slice only if the last item matches the popped one
549
- const prevSlice = this.stackSlices.get(sid) ?? EMPTY_ARRAY;
550
- const last = prevSlice.length ? prevSlice[prevSlice.length - 1] : undefined;
551
- if (last && last.key === item.key) {
552
- const nextSlice = prevSlice.slice(0, -1);
553
- this.stackSlices.set(sid, nextSlice);
554
- this.emit(this.stackListeners.get(sid));
555
- }
556
- this.recomputeVisibleRoute();
557
- this.emit(this.listeners);
1232
+ } else {
1233
+ this.log('matchBaseRoute no match');
558
1234
  }
1235
+ return best?.route;
559
1236
  }
560
- setState(next) {
561
- const prev = this.state;
562
- const nextState = {
563
- history: next.history ?? prev.history,
564
- activeTabIndex: next.activeTabIndex ?? prev.activeTabIndex
565
- };
566
- this.state = nextState;
567
- // Callers will emit updates explicitly.
568
- }
569
- emit(set) {
570
- if (!set) return;
571
- set.forEach(l => l());
1237
+ generateKey() {
1238
+ return `route-${nanoid()}`;
572
1239
  }
573
- getTopForTarget(stackId) {
574
- if (!stackId) return undefined;
575
- const slice = this.stackSlices.get(stackId) ?? EMPTY_ARRAY;
576
- return slice.length ? slice[slice.length - 1] : undefined;
1240
+ parsePath(path) {
1241
+ const parsed = qs.parseUrl(path);
1242
+ const result = {
1243
+ pathname: parsed.url,
1244
+ query: parsed.query
1245
+ };
1246
+ this.log('parsePath', {
1247
+ input: path,
1248
+ output: result
1249
+ });
1250
+ return result;
577
1251
  }
578
1252
  mergeOptions(routeOptions, stackId) {
579
- const stackDefaults = stackId ? this.findStackById(stackId)?.getDefaultOptions() : undefined;
1253
+ const stackNode = stackId ? this.findStackById(stackId) : undefined;
1254
+ const stackDefaults = stackNode?.getDefaultOptions?.();
580
1255
  const routerDefaults = this.routerScreenOptions;
581
1256
  if (!routerDefaults && !stackDefaults && !routeOptions) return undefined;
582
1257
  const merged = {
@@ -592,8 +1267,76 @@ export class Router {
592
1267
  findStackById(stackId) {
593
1268
  return this.stackById.get(stackId);
594
1269
  }
595
-
596
- // ==== Web integration (History API) ====
1270
+ areShallowEqual(a, b) {
1271
+ if (a === b) return true;
1272
+ if (!a || !b) return false;
1273
+ const aKeys = Object.keys(a);
1274
+ const bKeys = Object.keys(b);
1275
+ if (aKeys.length !== bKeys.length) return false;
1276
+ for (const k of aKeys) {
1277
+ if (!(k in b)) return false;
1278
+ const av = a[k];
1279
+ const bv = b[k];
1280
+ if (av === bv) continue;
1281
+ const aArr = Array.isArray(av);
1282
+ const bArr = Array.isArray(bv);
1283
+ if (aArr || bArr) {
1284
+ if (!aArr || !bArr) return false;
1285
+ if (av.length !== bv.length) return false;
1286
+ for (let i = 0; i < av.length; i++) {
1287
+ if (av[i] !== bv[i]) return false;
1288
+ }
1289
+ continue;
1290
+ }
1291
+ return false;
1292
+ }
1293
+ return true;
1294
+ }
1295
+ matchQueryPattern(pattern, query) {
1296
+ if (!pattern) {
1297
+ return true;
1298
+ }
1299
+ for (const [key, token] of Object.entries(pattern)) {
1300
+ const raw = query[key];
1301
+ if (raw == null) {
1302
+ this.log('matchQueryPattern: key missing', {
1303
+ key,
1304
+ token,
1305
+ query
1306
+ });
1307
+ return false;
1308
+ }
1309
+ if (typeof raw !== 'string') {
1310
+ this.log('matchQueryPattern: non-string value', {
1311
+ key,
1312
+ token,
1313
+ value: raw
1314
+ });
1315
+ return false;
1316
+ }
1317
+ if (token.type === 'const') {
1318
+ if (raw !== token.value) {
1319
+ this.log('matchQueryPattern: const mismatch', {
1320
+ key,
1321
+ expected: token.value,
1322
+ actual: raw
1323
+ });
1324
+ return false;
1325
+ }
1326
+ } else if (token.type === 'param') {
1327
+ this.log('matchQueryPattern: param ok', {
1328
+ key,
1329
+ value: raw,
1330
+ paramName: token.name
1331
+ });
1332
+ }
1333
+ }
1334
+ this.log('matchQueryPattern: success', {
1335
+ pattern,
1336
+ query
1337
+ });
1338
+ return true;
1339
+ }
597
1340
  isWebEnv() {
598
1341
  const g = globalThis;
599
1342
  return !!(g.addEventListener && g.history && g.location);
@@ -602,54 +1345,135 @@ export class Router {
602
1345
  const g = globalThis;
603
1346
  return g.location ? `${g.location.pathname}${g.location.search}` : '/';
604
1347
  }
605
- readIndex(state) {
1348
+ readHistoryIndex(state) {
606
1349
  if (state && typeof state === 'object' && '__srIndex' in state) {
607
1350
  const idx = state.__srIndex;
608
1351
  if (typeof idx === 'number') return idx;
609
1352
  }
610
1353
  return 0;
611
1354
  }
1355
+ getHistoryIndexOrNull() {
1356
+ const g = globalThis;
1357
+ const st = g.history?.state;
1358
+ if (st && typeof st === 'object' && '__srIndex' in st) {
1359
+ const idx = st.__srIndex;
1360
+ return typeof idx === 'number' ? idx : null;
1361
+ }
1362
+ return null;
1363
+ }
612
1364
  getHistoryIndex() {
613
1365
  const g = globalThis;
614
- return this.readIndex(g.history?.state);
1366
+ return this.readHistoryIndex(g.history?.state);
615
1367
  }
616
1368
  ensureHistoryIndex() {
617
1369
  const g = globalThis;
618
1370
  const st = g.history?.state ?? {};
619
- if (this.readIndex(st) === 0 && !(st && typeof st === 'object' && '__srIndex' in st)) {
620
- const next = {
621
- ...st,
1371
+ let next = st;
1372
+ let changed = false;
1373
+ if (!('__srIndex' in next)) {
1374
+ next = {
1375
+ ...next,
622
1376
  __srIndex: 0
623
1377
  };
1378
+ changed = true;
1379
+ }
1380
+ if (!('__srPath' in next)) {
1381
+ next = {
1382
+ ...next,
1383
+ __srPath: this.getCurrentUrl()
1384
+ };
1385
+ changed = true;
1386
+ }
1387
+ if (changed) {
624
1388
  try {
625
1389
  g.history?.replaceState(next, '', g.location?.href);
626
- } catch {
627
- // ignore
1390
+ } catch {}
1391
+ }
1392
+ }
1393
+ getRouterPathFromHistory() {
1394
+ const g = globalThis;
1395
+ const st = g.history?.state;
1396
+ if (st && typeof st === 'object' && '__srPath' in st) {
1397
+ const p = st.__srPath;
1398
+ if (typeof p === 'string' && p.length > 0) {
1399
+ return p;
628
1400
  }
629
1401
  }
1402
+ return this.getCurrentUrl();
630
1403
  }
631
- pushUrl(to) {
1404
+ getReplaceDedupeFromHistory() {
1405
+ const g = globalThis;
1406
+ const st = g.history?.state;
1407
+ if (st && typeof st === 'object' && '__srReplaceDedupe' in st) {
1408
+ const v = st.__srReplaceDedupe;
1409
+ return typeof v === 'boolean' ? v : false;
1410
+ }
1411
+ return false;
1412
+ }
1413
+ shouldSyncPathWithUrl(path) {
1414
+ const {
1415
+ pathname,
1416
+ query
1417
+ } = this.parsePath(path);
1418
+ const base = this.matchBaseRoute(pathname, query);
1419
+ if (!base) return true;
1420
+ const mergedOptions = this.mergeOptions(base.options, base.stackId);
1421
+ const rawsyncWithUrl = mergedOptions?.syncWithUrl;
1422
+ if (typeof rawsyncWithUrl === 'boolean') {
1423
+ return rawsyncWithUrl;
1424
+ }
1425
+ return true;
1426
+ }
1427
+ pushUrl(to, opts) {
632
1428
  const g = globalThis;
633
1429
  const st = g.history?.state ?? {};
634
- const prev = this.readIndex(st);
1430
+ const prev = this.readHistoryIndex(st);
1431
+ const base = st;
1432
+ const syncWithUrl = opts?.syncWithUrl ?? true;
1433
+ const routerPath = to;
1434
+ const visualUrl = syncWithUrl ? to : this.getCurrentUrl();
635
1435
  const next = {
636
- ...st,
637
- __srIndex: prev + 1
1436
+ ...base,
1437
+ __srIndex: prev + 1,
1438
+ __srPath: routerPath
638
1439
  };
639
- g.history?.pushState(next, '', to);
640
- if (g.Event && g.dispatchEvent) {
641
- g.dispatchEvent(new g.Event('pushState'));
642
- }
1440
+ g.history?.pushState(next, '', visualUrl);
643
1441
  }
644
- replaceUrl(to) {
1442
+ replaceUrl(to, opts) {
645
1443
  const g = globalThis;
646
1444
  const st = g.history?.state ?? {};
647
- g.history?.replaceState(st, '', to);
648
- if (g.Event && g.dispatchEvent) {
649
- g.dispatchEvent(new g.Event('replaceState'));
1445
+ const base = st;
1446
+ const syncWithUrl = opts?.syncWithUrl ?? true;
1447
+ const routerPath = to;
1448
+ const visualUrl = syncWithUrl ? to : this.getCurrentUrl();
1449
+ const next = {
1450
+ ...base,
1451
+ __srPath: routerPath,
1452
+ __srReplaceDedupe: !!opts?.dedupe
1453
+ };
1454
+ g.history?.replaceState(next, '', visualUrl);
1455
+ }
1456
+ replaceUrlSilently(to) {
1457
+ if (!this.isWebEnv()) return;
1458
+ this.suppressHistorySyncCount += 1;
1459
+ this.replaceUrl(to, {
1460
+ syncWithUrl: true
1461
+ });
1462
+ }
1463
+ buildUrlFromActiveRoute() {
1464
+ const ar = this.activeRoute;
1465
+ if (!ar || !ar.path) {
1466
+ return null;
650
1467
  }
1468
+ const path = ar.path;
1469
+ const query = ar.query;
1470
+ if (!query || Object.keys(query).length === 0) {
1471
+ return path;
1472
+ }
1473
+ const search = qs.stringify(query);
1474
+ return `${path}${search && search !== '' ? `?${search}` : ''}`;
651
1475
  }
652
- patchHistoryOnce() {
1476
+ patchBrowserHistoryOnce() {
653
1477
  const g = globalThis;
654
1478
  const key = Symbol.for('sigmela_router_history_patch');
655
1479
  if (g[key]) return;
@@ -674,42 +1498,78 @@ export class Router {
674
1498
  }
675
1499
  g[key] = true;
676
1500
  }
677
- lastBrowserIndex = 0;
678
- pendingReplaceDedupe = false;
679
1501
  setupBrowserHistory() {
680
1502
  const g = globalThis;
681
- this.patchHistoryOnce();
1503
+ const gAny = g;
1504
+ const activeKey = Symbol.for('sigmela_router_active_instance');
1505
+ this.patchBrowserHistoryOnce();
682
1506
  this.ensureHistoryIndex();
683
1507
  this.lastBrowserIndex = this.getHistoryIndex();
1508
+
1509
+ // If a new Router instance is created (e.g. Fast Refresh / HMR), ensure only the latest
1510
+ // instance reacts to browser history events to avoid duplicate navigation updates.
1511
+ gAny[activeKey] = this;
684
1512
  const onHistory = ev => {
685
- const url = this.getCurrentUrl();
1513
+ if (gAny[activeKey] !== this) return;
686
1514
  if (ev.type === 'pushState') {
687
- this.lastBrowserIndex = this.getHistoryIndex();
688
- this.performNavigation(url, 'push');
1515
+ const path = this.getRouterPathFromHistory();
1516
+ const idx = this.getHistoryIndexOrNull();
1517
+ this.lastBrowserIndex = idx !== null ? idx : Math.max(0, this.lastBrowserIndex + 1);
1518
+ this.performNavigation(path, 'push');
689
1519
  return;
690
1520
  }
691
1521
  if (ev.type === 'replaceState') {
692
- const dedupe = this.pendingReplaceDedupe === true;
693
- this.performNavigation(url, 'replace', {
1522
+ if (this.suppressHistorySyncCount > 0) {
1523
+ this.log('onHistory: replaceState suppressed (internal URL sync)');
1524
+ this.suppressHistorySyncCount -= 1;
1525
+ const idx = this.getHistoryIndexOrNull();
1526
+ this.lastBrowserIndex = idx !== null ? idx : this.lastBrowserIndex;
1527
+ return;
1528
+ }
1529
+ const path = this.getRouterPathFromHistory();
1530
+ const dedupe = this.getReplaceDedupeFromHistory();
1531
+ this.performNavigation(path, 'replace', {
694
1532
  dedupe
695
1533
  });
696
- this.lastBrowserIndex = this.getHistoryIndex();
697
- this.pendingReplaceDedupe = false;
1534
+ const idx = this.getHistoryIndexOrNull();
1535
+ this.lastBrowserIndex = idx !== null ? idx : this.lastBrowserIndex;
698
1536
  return;
699
1537
  }
700
1538
  if (ev.type === 'popstate') {
701
- const idx = this.getHistoryIndex();
702
- const delta = idx - this.lastBrowserIndex;
1539
+ const url = this.getRouterPathFromHistory();
1540
+ const idx = this.getHistoryIndexOrNull();
1541
+ const delta = idx !== null ? idx - this.lastBrowserIndex : 0;
1542
+ this.log('popstate event', {
1543
+ url,
1544
+ idx,
1545
+ prevIndex: this.lastBrowserIndex,
1546
+ delta
1547
+ });
1548
+
1549
+ // If browser history index isn't available (external history manipulations),
1550
+ // treat popstate as a soft replace. The route path still comes from __srPath when present.
1551
+ if (idx === null) {
1552
+ this.performNavigation(url, 'replace', {
1553
+ dedupe: true
1554
+ });
1555
+ return;
1556
+ }
703
1557
  if (delta < 0) {
704
- let steps = -delta;
705
- while (steps-- > 0) this.popOnce();
706
- const target = this.parsePath(url).pathname;
707
- const current = this.getVisibleRoute()?.path;
708
- if (current !== target) this.performNavigation(url, 'replace');
1558
+ this.performNavigation(url, 'replace', {
1559
+ dedupe: true
1560
+ });
709
1561
  } else if (delta > 0) {
1562
+ this.log('popstate: forward history step, treat as push', {
1563
+ url
1564
+ });
710
1565
  this.performNavigation(url, 'push');
711
1566
  } else {
712
- this.performNavigation(url, 'replace');
1567
+ this.log('popstate: same index, soft replace+dedupe', {
1568
+ url
1569
+ });
1570
+ this.performNavigation(url, 'replace', {
1571
+ dedupe: true
1572
+ });
713
1573
  }
714
1574
  this.lastBrowserIndex = idx;
715
1575
  }
@@ -718,120 +1578,267 @@ export class Router {
718
1578
  g.addEventListener?.('replaceState', onHistory);
719
1579
  g.addEventListener?.('popstate', onHistory);
720
1580
  }
721
- popOnce() {
722
- if (this.tryPopActiveStack()) return;
1581
+ syncUrlAfterInternalPop(popped) {
1582
+ if (!this.isWebEnv()) return;
1583
+ const compiled = this.registry.find(r => r.routeId === popped.routeId);
1584
+ const isQueryRoute = compiled && compiled.queryPattern && Object.keys(compiled.queryPattern).length > 0;
1585
+ if (isQueryRoute) {
1586
+ const currentUrl = this.getCurrentUrl();
1587
+ const {
1588
+ pathname,
1589
+ query
1590
+ } = this.parsePath(currentUrl);
1591
+ const pattern = compiled.queryPattern;
1592
+ const nextQuery = {
1593
+ ...query
1594
+ };
1595
+ for (const key of Object.keys(pattern)) {
1596
+ delete nextQuery[key];
1597
+ }
1598
+ const hasQuery = Object.keys(nextQuery).length > 0;
1599
+ const newSearch = hasQuery ? `?${qs.stringify(nextQuery)}` : '';
1600
+ const nextUrl = `${pathname}${newSearch}`;
1601
+ this.log('syncUrlWithStateAfterInternalPop (query)', {
1602
+ poppedRouteId: popped.routeId,
1603
+ from: currentUrl,
1604
+ to: nextUrl
1605
+ });
1606
+ this.replaceUrlSilently(nextUrl);
1607
+ return;
1608
+ }
1609
+ const from = this.getCurrentUrl();
1610
+ const nextUrl = this.buildUrlFromActiveRoute();
1611
+ if (!nextUrl) {
1612
+ this.log('syncUrlWithStateAfterInternalPop: no activeRoute, skip URL sync', {
1613
+ from
1614
+ });
1615
+ return;
1616
+ }
1617
+ this.log('syncUrlAfterInternalPop (base)', {
1618
+ poppedRouteId: popped.routeId,
1619
+ from,
1620
+ to: nextUrl
1621
+ });
1622
+ this.replaceUrlSilently(nextUrl);
723
1623
  }
724
-
725
- // Attempts to pop exactly one screen within the active stack only.
726
- // Returns true if a pop occurred; false otherwise.
727
- tryPopActiveStack() {
728
- const handlePop = item => {
729
- if (item.options?.stackPresentation === 'sheet') {
730
- const dismisser = this.sheetDismissers.get(item.key);
731
- if (dismisser) {
732
- this.unregisterSheetDismisser(item.key);
733
- dismisser();
734
- return true;
1624
+ popFromActiveStack() {
1625
+ const active = this.activeRoute;
1626
+ if (!active || !active.stackId) {
1627
+ if (this.root) {
1628
+ const sid = this.root.getId();
1629
+ if (sid) {
1630
+ const stackHistory = this.getStackHistory(sid);
1631
+ if (stackHistory.length > 0) {
1632
+ const top = stackHistory[stackHistory.length - 1];
1633
+ const isModalOrSheet = top.options?.stackPresentation === 'modal' || top.options?.stackPresentation === 'sheet';
1634
+ if (stackHistory.length > 1 || isModalOrSheet) {
1635
+ if (top.options?.stackPresentation === 'sheet') {
1636
+ const dismisser = this.sheetDismissers.get(top.key);
1637
+ if (dismisser) {
1638
+ this.unregisterSheetDismisser(top.key);
1639
+ dismisser();
1640
+ return top;
1641
+ }
1642
+ }
1643
+ this.applyHistoryChange('pop', top);
1644
+ return top;
1645
+ }
1646
+ }
735
1647
  }
736
1648
  }
737
- this.applyHistoryChange('pop', item);
738
- return true;
739
- };
740
- if (this.global) {
741
- const gid = this.global.getId();
742
- const gslice = this.getStackHistory(gid);
743
- const gtop = gslice.length ? gslice[gslice.length - 1] : undefined;
744
- if (gtop) {
745
- return handlePop(gtop);
746
- }
747
- }
748
- if (this.tabBar) {
749
- const idx = this.getActiveTabIndex();
750
- const state = this.tabBar.getState();
751
- const route = state.tabs[idx];
752
- if (!route) return false;
753
- const stack = this.tabBar.stacks[route.tabKey];
754
- if (!stack) return false;
755
- const sid = stack.getId();
756
- const slice = this.getStackHistory(sid);
757
- if (slice.length > 1) {
758
- const top = slice[slice.length - 1];
759
- if (top) {
760
- return handlePop(top);
761
- }
1649
+ return null;
1650
+ }
1651
+ let activeItem;
1652
+ for (let i = this.state.history.length - 1; i >= 0; i--) {
1653
+ const h = this.state.history[i];
1654
+ if (h && active && h.stackId === active.stackId) {
1655
+ activeItem = h;
1656
+ break;
762
1657
  }
763
- return false;
764
1658
  }
765
- if (isNavigationStackLike(this.root)) {
766
- const sid = this.root.getId();
767
- const slice = this.getStackHistory(sid);
768
- if (slice.length > 1) {
769
- const top = slice[slice.length - 1];
770
- if (top) {
771
- return handlePop(top);
772
- }
1659
+ if (!active || !activeItem || !activeItem.stackId) return null;
1660
+ const stackHistory = this.getStackHistory(activeItem.stackId);
1661
+ const isModalOrSheet = activeItem.options?.stackPresentation === 'modal' || activeItem.options?.stackPresentation === 'sheet';
1662
+ const allowRootPop = activeItem.options?.allowRootPop === true;
1663
+ if (stackHistory.length <= 1 && !isModalOrSheet && !allowRootPop) {
1664
+ return null;
1665
+ }
1666
+ if (activeItem.options?.stackPresentation === 'sheet') {
1667
+ const dismisser = this.sheetDismissers.get(activeItem.key);
1668
+ if (dismisser) {
1669
+ this.unregisterSheetDismisser(activeItem.key);
1670
+ dismisser();
1671
+ return activeItem;
773
1672
  }
774
1673
  }
775
- return false;
1674
+ const top = stackHistory[stackHistory.length - 1];
1675
+ this.applyHistoryChange('pop', top);
1676
+ return top;
776
1677
  }
777
-
778
- // Expand deep URL into a stack chain on initial load
779
- parse(url) {
1678
+ buildHistoryFromUrl(url, reuseKeysFrom) {
780
1679
  const {
781
1680
  pathname,
782
1681
  query
783
1682
  } = this.parsePath(url);
784
- const deepest = this.matchRoute(pathname);
785
- if (!deepest) {
1683
+ this.log('parse', {
1684
+ url,
1685
+ pathname,
1686
+ query
1687
+ });
1688
+ const baseRoute = this.matchBaseRoute(pathname, {});
1689
+ const deepest = this.matchBaseRoute(pathname, query);
1690
+ const hasDeepestQueryPattern = deepest && deepest.queryPattern && Object.keys(deepest.queryPattern).length > 0;
1691
+ const isDeepestOverlay = hasDeepestQueryPattern && deepest && baseRoute && deepest.routeId !== baseRoute.routeId;
1692
+ if (!deepest || isDeepestOverlay && !baseRoute) {
1693
+ if (__DEV__) {
1694
+ console.warn(`[Router] parse: no base route found for "${pathname}", seeding initial history`);
1695
+ }
786
1696
  this.seedInitialHistory();
1697
+ this.recomputeActiveRoute();
1698
+ this.emit(this.listeners);
787
1699
  return;
788
1700
  }
789
- if (deepest.scope === 'tab' && this.tabBar && deepest.tabIndex !== undefined) {
790
- this.onTabIndexChange(deepest.tabIndex);
791
- }
792
- const parts = pathname.split('/').filter(Boolean);
793
- const prefixes = new Array(parts.length + 1);
794
- let acc = '';
795
- prefixes[0] = '/';
796
- for (let i = 0; i < parts.length; i++) {
797
- acc += `/${parts[i]}`;
798
- prefixes[i + 1] = acc;
1701
+ const segments = pathname.split('/').filter(Boolean);
1702
+ const prefixes = segments.length > 0 ? segments.map((_, i) => '/' + segments.slice(0, i + 1).join('/')) : ['/'];
1703
+ if (!prefixes.includes('/')) {
1704
+ prefixes.unshift('/');
799
1705
  }
800
- const candidates = [];
801
- for (const route of this.registry) {
802
- if (route.stackId !== deepest.stackId || route.scope !== deepest.scope) {
803
- continue;
804
- }
805
- const segmentsCount = route.path.split('/').filter(Boolean).length;
806
- const candidateUrl = prefixes[segmentsCount];
807
- if (!candidateUrl) {
808
- continue;
1706
+ const items = [];
1707
+ prefixes.forEach((prefixPath, index) => {
1708
+ this.log('parse prefix', {
1709
+ index,
1710
+ prefixPath
1711
+ });
1712
+ let routeForPrefix;
1713
+ if (index === prefixes.length - 1) {
1714
+ routeForPrefix = baseRoute || deepest;
1715
+ } else {
1716
+ let best;
1717
+ for (const route of this.registry) {
1718
+ if (route.queryPattern) continue;
1719
+ const matchResult = route.matchPath(prefixPath);
1720
+ if (!matchResult) continue;
1721
+ const spec = route.baseSpecificity;
1722
+ this.log('parse candidate for prefix', {
1723
+ prefixPath,
1724
+ routeId: route.routeId,
1725
+ path: route.path,
1726
+ baseSpecificity: spec
1727
+ });
1728
+ if (!best || spec > best.spec) {
1729
+ best = {
1730
+ route,
1731
+ spec
1732
+ };
1733
+ }
1734
+ }
1735
+ routeForPrefix = best?.route;
809
1736
  }
810
- const matchResult = route.match(candidateUrl);
811
- if (!matchResult) {
812
- continue;
1737
+ if (!routeForPrefix) {
1738
+ this.log('parse: no route for prefix', {
1739
+ index,
1740
+ prefixPath
1741
+ });
1742
+ return;
813
1743
  }
814
- candidates.push({
815
- params: matchResult.params,
816
- segmentCount: segmentsCount,
817
- candidateUrl,
818
- route
1744
+ const matchResult = routeForPrefix.matchPath(prefixPath);
1745
+ const params = matchResult ? matchResult.params : undefined;
1746
+ const itemQuery = index === prefixes.length - 1 && !isDeepestOverlay ? query : {};
1747
+ const item = this.createHistoryItem(routeForPrefix, params, itemQuery, prefixPath);
1748
+ this.log('parse: push item', {
1749
+ index,
1750
+ routeId: routeForPrefix.routeId,
1751
+ path: routeForPrefix.path,
1752
+ prefixPath,
1753
+ params,
1754
+ itemQuery
819
1755
  });
1756
+ items.push(item);
1757
+ });
1758
+ let overlayItem;
1759
+ if (isDeepestOverlay && deepest && baseRoute) {
1760
+ overlayItem = this.createHistoryItem(deepest, undefined, query, pathname);
1761
+ this.log('parse: push overlay item', {
1762
+ routeId: deepest.routeId,
1763
+ path: deepest.path,
1764
+ pathname,
1765
+ query
1766
+ });
1767
+ items.push(overlayItem);
1768
+ }
1769
+ if (items.length > 0) {
1770
+ const lastItem = items[items.length - 1];
1771
+ const lastItemCompiled = lastItem ? this.registry.find(r => r.routeId === lastItem.routeId) : undefined;
1772
+ const isLastItemOverlay = lastItem && lastItemCompiled && lastItemCompiled.queryPattern && Object.keys(lastItemCompiled.queryPattern).length > 0;
1773
+ const finalRouteId = isLastItemOverlay && items.length > 1 ? items[items.length - 2]?.routeId : lastItem?.routeId;
1774
+ const searchStartIndex = isLastItemOverlay ? items.length - 2 : items.length - 1;
1775
+ for (let i = searchStartIndex; i >= 0; i--) {
1776
+ const item = items[i];
1777
+ if (item && item.routeId) {
1778
+ const compiled = this.registry.find(r => r.routeId === item.routeId);
1779
+ if (compiled && compiled.childNode) {
1780
+ this.addChildNodeSeedsToItems(item.routeId, items, finalRouteId);
1781
+ // Don't break: we may have multiple nested containers (e.g. TabBar -> SplitView).
1782
+ // All of them must be activated/seeded to make the UI consistent on initial deep-link.
1783
+ }
1784
+ }
1785
+ }
1786
+ if (overlayItem) {
1787
+ const overlayIndex = items.findIndex(item => item.key === overlayItem.key);
1788
+ if (overlayIndex >= 0 && overlayIndex < items.length - 1) {
1789
+ items.splice(overlayIndex, 1);
1790
+ items.push(overlayItem);
1791
+ }
1792
+ }
820
1793
  }
821
- if (!candidates.length) {
1794
+ if (!items.length) {
1795
+ if (__DEV__) {
1796
+ console.warn('[Router] parse: no items built for URL, seeding initial history');
1797
+ }
822
1798
  this.seedInitialHistory();
1799
+ this.recomputeActiveRoute();
1800
+ this.emit(this.listeners);
823
1801
  return;
824
1802
  }
825
- candidates.sort((a, b) => a.segmentCount - b.segmentCount);
826
- const items = candidates.map((c, i) => this.createHistoryItem(c.route, c.params, i === candidates.length - 1 ? query : {}, c.candidateUrl));
827
- const first = items[0];
828
- const sid = first?.stackId ?? deepest.stackId;
1803
+
1804
+ // When rebuilding history (e.g. tab reset), preserve keys for matching items
1805
+ // so web animations don't replay for the whole root stack.
1806
+ this.reuseKeysFromPreviousHistory(items, reuseKeysFrom);
829
1807
  this.setState({
830
1808
  history: items
831
1809
  });
832
- if (sid) {
833
- this.stackSlices.set(sid, items);
834
- this.emit(this.stackListeners.get(sid));
1810
+ this.stackListeners.forEach(set => this.emit(set));
1811
+ this.recomputeActiveRoute();
1812
+ this.emit(this.listeners);
1813
+ }
1814
+ reuseKeysFromPreviousHistory(next, prev) {
1815
+ if (!prev || prev.length === 0) return;
1816
+ const used = new Set();
1817
+ const sameItemIdentity = (a, b) => {
1818
+ if (a.routeId !== b.routeId) return false;
1819
+ if ((a.stackId ?? '') !== (b.stackId ?? '')) return false;
1820
+ if ((a.path ?? '') !== (b.path ?? '')) return false;
1821
+ const sameParams = this.areShallowEqual(a.params ?? {}, b.params ?? {});
1822
+ if (!sameParams) return false;
1823
+ const sameQuery = this.areShallowEqual(a.query ?? {}, b.query ?? {});
1824
+ if (!sameQuery) return false;
1825
+
1826
+ // Keep presentation stable: modal/sheet items should not steal keys from push items.
1827
+ const ap = a.options?.stackPresentation ?? null;
1828
+ const bp = b.options?.stackPresentation ?? null;
1829
+ if (ap !== bp) return false;
1830
+ return true;
1831
+ };
1832
+ for (const item of next) {
1833
+ for (let i = prev.length - 1; i >= 0; i--) {
1834
+ const candidate = prev[i];
1835
+ if (!candidate) continue;
1836
+ if (used.has(candidate.key)) continue;
1837
+ if (!sameItemIdentity(item, candidate)) continue;
1838
+ item.key = candidate.key;
1839
+ used.add(candidate.key);
1840
+ break;
1841
+ }
835
1842
  }
836
1843
  }
837
1844
  }