@sigmela/router 0.3.6 → 0.4.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 (118) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +2 -1
  3. package/README.md +108 -479
  4. package/dist/Router.d.ts +190 -0
  5. package/dist/Router.d.ts.map +1 -0
  6. package/dist/components/Modal.d.ts +56 -0
  7. package/dist/components/Modal.d.ts.map +1 -0
  8. package/dist/components/OverlayContentScope.d.ts +8 -0
  9. package/dist/components/OverlayContentScope.d.ts.map +1 -0
  10. package/dist/components/RouterCloseButton.d.ts +9 -0
  11. package/dist/components/RouterCloseButton.d.ts.map +1 -0
  12. package/dist/components/Sheet.d.ts +53 -0
  13. package/dist/components/Sheet.d.ts.map +1 -0
  14. package/dist/components/SheetSurface.d.ts +27 -0
  15. package/dist/components/SheetSurface.d.ts.map +1 -0
  16. package/dist/components/index.d.ts +4 -0
  17. package/dist/components/index.d.ts.map +1 -0
  18. package/dist/components/pageScrollLock.d.ts +3 -0
  19. package/dist/components/pageScrollLock.d.ts.map +1 -0
  20. package/dist/components/sheetDragEngine.d.ts +27 -0
  21. package/dist/components/sheetDragEngine.d.ts.map +1 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +3649 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/navigationHistory.d.ts +17 -0
  27. package/dist/navigationHistory.d.ts.map +1 -0
  28. package/dist/router.css +971 -0
  29. package/dist/types.d.ts +3 -0
  30. package/dist/types.d.ts.map +1 -0
  31. package/dist/utils/id.d.ts +2 -0
  32. package/dist/utils/id.d.ts.map +1 -0
  33. package/package.json +69 -174
  34. package/lib/module/Drawer/Drawer.js +0 -250
  35. package/lib/module/Drawer/DrawerContext.js +0 -4
  36. package/lib/module/Drawer/DrawerIcon.web.js +0 -47
  37. package/lib/module/Drawer/RenderDrawer.native.js +0 -248
  38. package/lib/module/Drawer/RenderDrawer.web.js +0 -197
  39. package/lib/module/Drawer/useDrawer.js +0 -11
  40. package/lib/module/Navigation.js +0 -79
  41. package/lib/module/NavigationStack.js +0 -254
  42. package/lib/module/Router.js +0 -2171
  43. package/lib/module/RouterContext.js +0 -33
  44. package/lib/module/ScreenStack/ScreenStack.native.js +0 -3
  45. package/lib/module/ScreenStack/ScreenStack.web.js +0 -561
  46. package/lib/module/ScreenStack/ScreenStackContext.js +0 -21
  47. package/lib/module/ScreenStack/animationHelpers.js +0 -87
  48. package/lib/module/ScreenStack/index.js +0 -3
  49. package/lib/module/ScreenStackItem/ScreenStackItem.js +0 -70
  50. package/lib/module/ScreenStackItem/ScreenStackItem.types.js +0 -3
  51. package/lib/module/ScreenStackItem/ScreenStackItem.web.js +0 -171
  52. package/lib/module/ScreenStackItem/index.js +0 -3
  53. package/lib/module/ScreenStackSheetItem/ScreenStackSheetItem.native.js +0 -70
  54. package/lib/module/ScreenStackSheetItem/ScreenStackSheetItem.web.js +0 -5
  55. package/lib/module/ScreenStackSheetItem/index.js +0 -3
  56. package/lib/module/SplitView/RenderSplitView.native.js +0 -101
  57. package/lib/module/SplitView/RenderSplitView.web.js +0 -107
  58. package/lib/module/SplitView/SplitView.js +0 -97
  59. package/lib/module/SplitView/SplitViewContext.js +0 -4
  60. package/lib/module/SplitView/index.js +0 -5
  61. package/lib/module/SplitView/useSplitView.js +0 -11
  62. package/lib/module/StackRenderer.js +0 -42
  63. package/lib/module/TabBar/RenderTabBar.native.js +0 -418
  64. package/lib/module/TabBar/RenderTabBar.web.js +0 -218
  65. package/lib/module/TabBar/TabBar.js +0 -208
  66. package/lib/module/TabBar/TabBarContext.js +0 -4
  67. package/lib/module/TabBar/TabIcon.web.js +0 -47
  68. package/lib/module/TabBar/index.js +0 -6
  69. package/lib/module/TabBar/useTabBar.js +0 -11
  70. package/lib/module/TabBar/useTabBarHeight.js +0 -22
  71. package/lib/module/createController.js +0 -5
  72. package/lib/module/index.js +0 -15
  73. package/lib/module/navigationNode.js +0 -3
  74. package/lib/module/package.json +0 -1
  75. package/lib/module/styles.css +0 -1111
  76. package/lib/module/types.js +0 -13
  77. package/lib/typescript/package.json +0 -1
  78. package/lib/typescript/src/Drawer/Drawer.d.ts +0 -100
  79. package/lib/typescript/src/Drawer/DrawerContext.d.ts +0 -3
  80. package/lib/typescript/src/Drawer/DrawerIcon.web.d.ts +0 -7
  81. package/lib/typescript/src/Drawer/RenderDrawer.native.d.ts +0 -8
  82. package/lib/typescript/src/Drawer/RenderDrawer.web.d.ts +0 -8
  83. package/lib/typescript/src/Drawer/useDrawer.d.ts +0 -2
  84. package/lib/typescript/src/Navigation.d.ts +0 -17
  85. package/lib/typescript/src/NavigationStack.d.ts +0 -46
  86. package/lib/typescript/src/Router.d.ts +0 -232
  87. package/lib/typescript/src/RouterContext.d.ts +0 -18
  88. package/lib/typescript/src/ScreenStack/ScreenStack.native.d.ts +0 -2
  89. package/lib/typescript/src/ScreenStack/ScreenStack.web.d.ts +0 -9
  90. package/lib/typescript/src/ScreenStack/ScreenStackContext.d.ts +0 -31
  91. package/lib/typescript/src/ScreenStack/animationHelpers.d.ts +0 -6
  92. package/lib/typescript/src/ScreenStack/index.d.ts +0 -2
  93. package/lib/typescript/src/ScreenStackItem/ScreenStackItem.d.ts +0 -3
  94. package/lib/typescript/src/ScreenStackItem/ScreenStackItem.types.d.ts +0 -14
  95. package/lib/typescript/src/ScreenStackItem/ScreenStackItem.web.d.ts +0 -3
  96. package/lib/typescript/src/ScreenStackItem/index.d.ts +0 -3
  97. package/lib/typescript/src/ScreenStackSheetItem/ScreenStackSheetItem.native.d.ts +0 -11
  98. package/lib/typescript/src/ScreenStackSheetItem/ScreenStackSheetItem.web.d.ts +0 -2
  99. package/lib/typescript/src/ScreenStackSheetItem/index.d.ts +0 -2
  100. package/lib/typescript/src/SplitView/RenderSplitView.native.d.ts +0 -19
  101. package/lib/typescript/src/SplitView/RenderSplitView.web.d.ts +0 -8
  102. package/lib/typescript/src/SplitView/SplitView.d.ts +0 -33
  103. package/lib/typescript/src/SplitView/SplitViewContext.d.ts +0 -3
  104. package/lib/typescript/src/SplitView/index.d.ts +0 -5
  105. package/lib/typescript/src/SplitView/useSplitView.d.ts +0 -2
  106. package/lib/typescript/src/StackRenderer.d.ts +0 -8
  107. package/lib/typescript/src/TabBar/RenderTabBar.native.d.ts +0 -8
  108. package/lib/typescript/src/TabBar/RenderTabBar.web.d.ts +0 -8
  109. package/lib/typescript/src/TabBar/TabBar.d.ts +0 -112
  110. package/lib/typescript/src/TabBar/TabBarContext.d.ts +0 -3
  111. package/lib/typescript/src/TabBar/TabIcon.web.d.ts +0 -7
  112. package/lib/typescript/src/TabBar/index.d.ts +0 -5
  113. package/lib/typescript/src/TabBar/useTabBar.d.ts +0 -2
  114. package/lib/typescript/src/TabBar/useTabBarHeight.d.ts +0 -18
  115. package/lib/typescript/src/createController.d.ts +0 -15
  116. package/lib/typescript/src/index.d.ts +0 -21
  117. package/lib/typescript/src/navigationNode.d.ts +0 -41
  118. package/lib/typescript/src/types.d.ts +0 -140
@@ -1,2171 +0,0 @@
1
- "use strict";
2
-
3
- import { nanoid } from 'nanoid/non-secure';
4
- import { Platform } from 'react-native';
5
- import qs from 'query-string';
6
- import { isModalLikePresentation } from "./types.js";
7
- function canSwitchToRoute(node) {
8
- return node !== undefined && typeof node.switchToRoute === 'function';
9
- }
10
- function canLookupRoutes(node) {
11
- return node !== undefined && typeof node.hasRoute === 'function';
12
- }
13
- const EMPTY_ARRAY = [];
14
- export class Router {
15
- root = null;
16
- listeners = new Set();
17
- registry = [];
18
- state = {
19
- history: []
20
- };
21
- debugEnabled = false;
22
- sheetDismissers = new Map();
23
- dismissedStackIds = new Set();
24
- stackListeners = new Map();
25
- stackById = new Map();
26
- routeById = new Map();
27
- stackActivators = new Map();
28
- compiledRouteById = new Map();
29
- stackHistories = new Map();
30
- activeRoute = null;
31
- activeRouteListeners = new Set();
32
- rootListeners = new Set();
33
- rootTransition = undefined;
34
- // Root swaps should behave like a fresh initial mount (no enter animation).
35
- // We keep the API option for compatibility, but suppress transition application.
36
- suppressRootTransitionOnNextRead = false;
37
- lastBrowserIndex = 0;
38
- suppressHistorySyncCount = 0;
39
- // Used to prevent stale controller-driven navigations (controller calls present later).
40
- navigationToken = 0;
41
- // parsePath one-slot cache
42
- lastParsedPath = null;
43
- lastParsedResult = null;
44
-
45
- // matchBaseRoute one-slot cache
46
- lastMatchedPathname = null;
47
- lastMatchedQuery = null;
48
- lastMatchedResult = null;
49
- constructor(config) {
50
- this.debugEnabled = config.debug ?? false;
51
- this.routerScreenOptions = config.screenOptions;
52
- this.log('ctor');
53
- this.roots = config.roots;
54
- this.activeRootKey = config.root;
55
- const initialRoot = this.roots[this.activeRootKey];
56
- if (!initialRoot) {
57
- throw new Error(`Router: root "${String(this.activeRootKey)}" not found in config.roots`);
58
- }
59
- this.root = initialRoot;
60
- this.buildRegistry();
61
- if (this.isWebEnv()) {
62
- this.setupBrowserHistory();
63
- const url = this.getCurrentUrl();
64
- this.buildHistoryFromUrl(url);
65
- } else {
66
- this.seedInitialHistory();
67
- }
68
- this.recomputeActiveRoute();
69
- }
70
- isDebugEnabled() {
71
- return this.debugEnabled;
72
- }
73
- isStackBeingDismissed(stackId) {
74
- if (!stackId) return false;
75
- return this.dismissedStackIds.has(stackId);
76
- }
77
- clearStackDismissed(stackId) {
78
- if (!stackId) return;
79
- this.dismissedStackIds.delete(stackId);
80
- }
81
- markStackDismissed(stackId) {
82
- this.dismissedStackIds.add(stackId);
83
- }
84
- log(message, data) {
85
- if (this.debugEnabled) {
86
- if (data !== undefined) {
87
- console.log(`[Router] ${message}`, data);
88
- } else {
89
- console.log(`[Router] ${message}`);
90
- }
91
- }
92
- }
93
- navigate = path => {
94
- if (!path || typeof path !== 'string') {
95
- if (__DEV__) throw new Error('Router.navigate: path must be a non-empty string');
96
- console.warn('[Router] navigate called with invalid path');
97
- return;
98
- }
99
- if (this.isWebEnv()) {
100
- const syncWithUrl = this.shouldSyncPathWithUrl(path);
101
- this.pushUrl(path, {
102
- syncWithUrl
103
- });
104
- return;
105
- }
106
- this.performNavigation(path, 'push');
107
- };
108
- replace = (path, dedupe) => {
109
- if (!path || typeof path !== 'string') {
110
- if (__DEV__) throw new Error('Router.replace: path must be a non-empty string');
111
- console.warn('[Router] replace called with invalid path');
112
- return;
113
- }
114
- if (this.isWebEnv()) {
115
- const syncWithUrl = this.shouldSyncPathWithUrl(path);
116
- this.replaceUrl(path, {
117
- syncWithUrl,
118
- dedupe: !!dedupe
119
- });
120
- return;
121
- }
122
- this.performNavigation(path, 'replace', {
123
- dedupe: !!dedupe
124
- });
125
- };
126
-
127
- /**
128
- * Web-only convenience: resets Router navigation state to what a fresh
129
- * deep-link render would produce for the given URL.
130
- *
131
- * This intentionally clears preserved per-tab stacks (useful when tab stacks
132
- * and browser URL can get out of sync).
133
- */
134
- reset = path => {
135
- if (!path || typeof path !== 'string') {
136
- if (__DEV__) throw new Error('Router.reset: path must be a non-empty string');
137
- console.warn('[Router] reset called with invalid path');
138
- return;
139
- }
140
- if (this.isWebEnv()) {
141
- const prevHistory = this.state.history;
142
- // Important: clear current history before matching routes for the new URL.
143
- // Otherwise, matchBaseRoute() tie-breakers may pick a wrong stack for ambiguous
144
- // paths like '/' based on previously visited stacks, breaking tab switches.
145
- this.state = {
146
- history: []
147
- };
148
- this.stackHistories.clear();
149
- // Update browser URL without triggering onHistory navigation.
150
- this.replaceUrlSilently(path);
151
- // Rebuild history exactly like initial URL parsing (wipes other stacks).
152
- this.buildHistoryFromUrl(path, prevHistory);
153
- return;
154
- }
155
- // Native fallback: closest semantics is a replace navigation.
156
- this.performNavigation(path, 'replace', {
157
- dedupe: true
158
- });
159
- };
160
- registerSheetDismisser = (key, dismisser) => {
161
- this.sheetDismissers.set(key, dismisser);
162
- };
163
- unregisterSheetDismisser = key => {
164
- this.sheetDismissers.delete(key);
165
- };
166
- goBack = () => {
167
- if (this.isWebEnv()) {
168
- const prevHistoryRef = this.state.history;
169
- const popped = this.popFromActiveStack();
170
- // Sync URL only if Router state actually changed (avoid syncing URL when
171
- // a UI-level dismisser is used and the history pop will happen later).
172
- if (popped && this.state.history !== prevHistoryRef) {
173
- this.syncUrlAfterInternalPop(popped);
174
- }
175
- return;
176
- }
177
- this.popFromActiveStack();
178
- };
179
-
180
- /**
181
- * Closes the nearest modal or sheet, regardless of navigation depth inside it.
182
- * Useful when a NavigationStack is rendered inside a modal and you want to
183
- * close the entire modal from any screen within it.
184
- */
185
- dismiss = () => {
186
- // Find the nearest modal/sheet item in history (searching from end)
187
- let modalItem = null;
188
- for (let i = this.state.history.length - 1; i >= 0; i--) {
189
- const item = this.state.history[i];
190
- if (item && isModalLikePresentation(item.options?.stackPresentation)) {
191
- modalItem = item;
192
- break;
193
- }
194
- }
195
- if (!modalItem) {
196
- this.log('dismiss: no modal found in history');
197
- return;
198
- }
199
- this.log('dismiss: closing modal', {
200
- key: modalItem.key,
201
- routeId: modalItem.routeId,
202
- stackId: modalItem.stackId,
203
- presentation: modalItem.options?.stackPresentation
204
- });
205
-
206
- // Handle sheet dismisser if registered
207
- if (modalItem.options?.stackPresentation === 'sheet') {
208
- const dismisser = this.sheetDismissers.get(modalItem.key);
209
- if (dismisser) {
210
- this.unregisterSheetDismisser(modalItem.key);
211
- dismisser();
212
- return;
213
- }
214
- }
215
-
216
- // Check if modal has a childNode (NavigationStack added via addModal)
217
- const compiled = this.getCompiledRoute(modalItem.routeId);
218
- const childNode = compiled?.childNode;
219
- if (childNode) {
220
- // Modal stack: remove all items from child stack AND the modal wrapper item
221
- const childStackId = childNode.getId();
222
- this.log('dismiss: closing modal stack', {
223
- childStackId,
224
- modalKey: modalItem.key
225
- });
226
- this.markStackDismissed(childStackId);
227
-
228
- // Clear child stack's history cache BEFORE setState so listeners see clean state
229
- this.stackHistories.delete(childStackId);
230
- const newHistory = this.state.history.filter(item => item.stackId !== childStackId && item.key !== modalItem.key);
231
- this.setState({
232
- history: newHistory
233
- });
234
- } else {
235
- // Simple modal: just pop the modal item
236
- // applyHistoryChange already calls recomputeActiveRoute + emit(this.listeners)
237
- this.applyHistoryChange('pop', modalItem);
238
- }
239
- if (childNode) {
240
- // childNode branch uses setState directly, so we need to emit manually
241
- this.recomputeActiveRoute();
242
- this.emit(this.listeners);
243
- }
244
-
245
- // Sync URL on web
246
- if (this.isWebEnv()) {
247
- this.syncUrlAfterInternalPop(modalItem);
248
- }
249
- };
250
- getState = () => {
251
- return this.state;
252
- };
253
- subscribe(listener) {
254
- this.listeners.add(listener);
255
- return () => {
256
- this.listeners.delete(listener);
257
- };
258
- }
259
- subscribeActiveRoute = listener => {
260
- this.activeRouteListeners.add(listener);
261
- return () => {
262
- this.activeRouteListeners.delete(listener);
263
- };
264
- };
265
- getStackHistory = stackId => {
266
- if (!stackId) return EMPTY_ARRAY;
267
- if (!this.stackHistories.has(stackId)) {
268
- const current = this.state.history.filter(item => item.stackId === stackId);
269
- this.stackHistories.set(stackId, current);
270
- return current;
271
- }
272
- return this.stackHistories.get(stackId) ?? EMPTY_ARRAY;
273
- };
274
-
275
- /**
276
- * Returns all history items in navigation order.
277
- * Useful for rendering all screens including modal stacks.
278
- */
279
- getFullHistory = () => {
280
- return this.state.history;
281
- };
282
- subscribeStack = (stackId, cb) => {
283
- if (!stackId) return () => {};
284
- let set = this.stackListeners.get(stackId);
285
- if (!set) {
286
- set = new Set();
287
- this.stackListeners.set(stackId, set);
288
- }
289
- set.add(cb);
290
- return () => {
291
- set.delete(cb);
292
- if (set.size === 0) {
293
- this.stackListeners.delete(stackId);
294
- }
295
- };
296
- };
297
- getRootStackId() {
298
- return this.root?.getId();
299
- }
300
- subscribeRoot(listener) {
301
- this.rootListeners.add(listener);
302
- return () => this.rootListeners.delete(listener);
303
- }
304
- emitRootChange() {
305
- this.emit(this.rootListeners);
306
- }
307
- getRootTransition() {
308
- if (this.suppressRootTransitionOnNextRead) {
309
- this.suppressRootTransitionOnNextRead = false;
310
- // Ensure we don't accidentally apply it on subsequent renders.
311
- this.rootTransition = undefined;
312
- return undefined;
313
- }
314
- return this.rootTransition;
315
- }
316
- setRoot(nextRootKey, options) {
317
- const nextRoot = this.roots[nextRootKey];
318
- if (!nextRoot) {
319
- throw new Error(`Router: root "${String(nextRootKey)}" not found in config.roots`);
320
- }
321
- this.activeRootKey = nextRootKey;
322
- this.root = nextRoot;
323
- this.rootTransition = options?.transition ?? undefined;
324
- // Make the incoming root behave like initial: suppress enter animation.
325
- this.suppressRootTransitionOnNextRead = true;
326
- this.registry.length = 0;
327
- this.stackById.clear();
328
- this.routeById.clear();
329
- this.compiledRouteById.clear();
330
- this.stackActivators.clear();
331
- this.lastMatchedPathname = null;
332
- this.lastMatchedQuery = null;
333
- this.lastMatchedResult = null;
334
- this.state = {
335
- history: []
336
- };
337
- this.stackHistories.clear();
338
- this.sheetDismissers.clear();
339
- this.dismissedStackIds.clear();
340
- this.stackListeners.clear();
341
- this.buildRegistry();
342
- this.seedInitialHistory();
343
- this.recomputeActiveRoute();
344
- this.emitRootChange();
345
- this.emit(this.listeners);
346
- }
347
- getActiveRoute = () => {
348
- return this.activeRoute;
349
- };
350
- debugGetState() {
351
- return {
352
- history: this.state.history.map(h => ({
353
- key: h.key,
354
- routeId: h.routeId,
355
- stackId: h.stackId,
356
- path: h.path,
357
- params: h.params,
358
- query: h.query,
359
- stackPresentation: h.options?.stackPresentation
360
- })),
361
- stackSlices: Array.from(new Set(this.state.history.map(h => h.stackId).filter(Boolean))).map(stackId => ({
362
- stackId,
363
- items: this.getStackHistory(stackId).map(i => ({
364
- key: i.key,
365
- routeId: i.routeId,
366
- path: i.path,
367
- params: i.params,
368
- query: i.query,
369
- stackPresentation: i.options?.stackPresentation
370
- }))
371
- })),
372
- activeRoute: this.activeRoute,
373
- registry: this.registry.map(r => ({
374
- routeId: r.routeId,
375
- path: r.path,
376
- pathnamePattern: r.pathnamePattern,
377
- stackId: r.stackId,
378
- isWildcardPath: r.isWildcardPath,
379
- baseSpecificity: r.baseSpecificity,
380
- queryPattern: r.queryPattern,
381
- options: r.options
382
- }))
383
- };
384
- }
385
- debugMatchRoute(path) {
386
- const {
387
- pathname,
388
- query
389
- } = this.parsePath(path);
390
- const matches = [];
391
- for (const r of this.registry) {
392
- if (!r.stackId) continue;
393
- let pathMatch;
394
- if (r.isWildcardPath) {
395
- pathMatch = {
396
- params: {}
397
- };
398
- } else {
399
- pathMatch = r.matchPath(pathname);
400
- }
401
- const queryMatch = this.matchQueryPattern(r.queryPattern, query);
402
- matches.push({
403
- routeId: r.routeId,
404
- path: r.path,
405
- pathnamePattern: r.pathnamePattern,
406
- stackId: r.stackId,
407
- isWildcardPath: r.isWildcardPath,
408
- baseSpecificity: r.baseSpecificity,
409
- pathMatch: !!pathMatch,
410
- queryMatch,
411
- options: r.options
412
- });
413
- }
414
- const best = this.matchBaseRoute(pathname, query);
415
- return {
416
- input: {
417
- path,
418
- pathname,
419
- query
420
- },
421
- matches,
422
- best: best ? {
423
- routeId: best.routeId,
424
- path: best.path,
425
- stackId: best.stackId,
426
- options: best.options
427
- } : null
428
- };
429
- }
430
- debugGetStackInfo(stackId) {
431
- const slice = this.getStackHistory(stackId);
432
- return {
433
- stackId,
434
- historyLength: slice.length,
435
- items: slice.map(i => ({
436
- key: i.key,
437
- routeId: i.routeId,
438
- path: i.path,
439
- params: i.params,
440
- query: i.query,
441
- stackPresentation: i.options?.stackPresentation
442
- }))
443
- };
444
- }
445
- debugGetAllStacks() {
446
- const stackIds = Array.from(new Set(this.state.history.map(h => h.stackId).filter(Boolean)));
447
- return stackIds.map(stackId => this.debugGetStackInfo(stackId));
448
- }
449
- recomputeActiveRoute() {
450
- const history = this.state.history;
451
- if (history.length > 0) {
452
- const top = history[history.length - 1];
453
- if (top) {
454
- const meta = this.routeById.get(top.routeId);
455
- const newRoute = meta ? {
456
- ...meta,
457
- routeId: top.routeId,
458
- params: top.params,
459
- query: top.query,
460
- path: top.path
461
- } : {
462
- routeId: top.routeId,
463
- stackId: top.stackId,
464
- params: top.params,
465
- query: top.query,
466
- path: top.path
467
- };
468
-
469
- // Shallow compare to keep stable reference and avoid unnecessary re-renders
470
- if (this.activeRoute && this.activeRoute.routeId === newRoute.routeId && this.activeRoute.stackId === newRoute.stackId && this.activeRoute.path === newRoute.path && this.activeRoute.params === newRoute.params && this.activeRoute.query === newRoute.query) {
471
- return;
472
- }
473
- this.activeRoute = newRoute;
474
- this.emit(this.activeRouteListeners);
475
- return;
476
- }
477
- }
478
- if (this.activeRoute !== null) {
479
- this.activeRoute = null;
480
- this.emit(this.activeRouteListeners);
481
- }
482
- }
483
- performNavigation(path, action, opts) {
484
- if (this.destroyed) return;
485
- const {
486
- pathname,
487
- query
488
- } = this.parsePath(path);
489
- const navigationToken = ++this.navigationToken;
490
- this.log('performNavigation', {
491
- path,
492
- pathname,
493
- query,
494
- action,
495
- dedupe: opts?.dedupe
496
- });
497
- const base = this.matchBaseRoute(pathname, query);
498
- this.log('resolveNavigation', {
499
- base: base ? {
500
- routeId: base.routeId,
501
- path: base.path,
502
- stackId: base.stackId
503
- } : null
504
- });
505
- if (!base) {
506
- if (__DEV__) {
507
- throw new Error(`Route not found: "${pathname}"`);
508
- }
509
- console.warn(`[Router] Route not found: "${pathname}"`);
510
- return;
511
- }
512
-
513
- // Ensure the root-level container (e.g. TabBar) is activated for the matched route.
514
- // This is important when the matched route belongs to a nested stack inside a composite node
515
- // like SplitView: stackActivators won't fire for the tab itself in that case.
516
- const rootStackId = this.root?.getId();
517
- if (rootStackId) {
518
- this.activateContainerForRoute(base.routeId, rootStackId);
519
- }
520
- const activator = base.stackId ? this.stackActivators.get(base.stackId) : undefined;
521
- if (activator) {
522
- activator();
523
- }
524
- const matchResult = base.matchPath(pathname);
525
- const params = matchResult ? matchResult.params : undefined;
526
-
527
- // Smart navigate:
528
- // If navigate(push) targets the currently active routeId within the same stack, treat it as
529
- // "same screen, new data" and perform a replace (preserving the existing key) by default.
530
- //
531
- // Consumers can opt out per-route via ScreenOptions.allowMultipleInstances=true.
532
- if (action === 'push' && base.stackId) {
533
- const mergedOptions = this.mergeOptions(base.options, base.stackId);
534
- const allowMultipleInstances = mergedOptions?.allowMultipleInstances === true;
535
- const isActiveSameStack = this.activeRoute?.stackId === base.stackId;
536
- const isActiveSameRoute = this.activeRoute?.routeId === base.routeId;
537
-
538
- // Optional safety: only apply to push-presentation screens by default.
539
- const presentation = mergedOptions?.stackPresentation ?? 'push';
540
- const isPushPresentation = presentation === 'push';
541
- if (!allowMultipleInstances && isPushPresentation && isActiveSameStack && isActiveSameRoute) {
542
- const newItem = this.createHistoryItem(base, params, query, pathname);
543
- this.applyHistoryChange('replace', newItem);
544
- return;
545
- }
546
- }
547
- if (action === 'push') {
548
- if (base.stackId) {
549
- let existing = this.findExistingRoute(base.stackId, base.routeId, pathname, params ?? {});
550
- if (!existing) {
551
- existing = this.findExistingRouteByPathname(base.stackId, pathname, params ?? {});
552
- }
553
- if (existing) {
554
- this.log('push: found existing item in stack history, using popTo', {
555
- key: existing.key,
556
- routeId: existing.routeId,
557
- path: existing.path
558
- });
559
- const normalizedParams = params && Object.keys(params).length > 0 ? params : undefined;
560
- const updatedExisting = {
561
- ...existing,
562
- routeId: base.routeId,
563
- params: normalizedParams,
564
- query,
565
- path: pathname,
566
- component: base.component,
567
- options: this.mergeOptions(base.options, base.stackId),
568
- pattern: base.path
569
- };
570
- this.applyHistoryChange('popTo', updatedExisting);
571
- return;
572
- }
573
- }
574
- }
575
- if (action === 'replace' && opts?.dedupe) {
576
- const top = this.getTopOfStack(base.stackId);
577
- this.log('dedupe: checking top of stack', {
578
- top: top ? {
579
- key: top.key,
580
- routeId: top.routeId,
581
- path: top.path
582
- } : null,
583
- matched: {
584
- routeId: base.routeId,
585
- pathname
586
- }
587
- });
588
- if (top && top.routeId === base.routeId) {
589
- const sameParams = this.areShallowEqual(top.params ?? {}, params ?? {});
590
- const samePath = (top.path ?? '') === pathname;
591
- const sameIdentity = sameParams && samePath;
592
- const sameQuery = this.areShallowEqual(top.query ?? {}, query ?? {});
593
- this.log('dedupe: top matches routeId, checking identity (params+path) and query', {
594
- sameParams,
595
- samePath,
596
- sameIdentity,
597
- sameQuery
598
- });
599
- if (sameIdentity && sameQuery) {
600
- this.log('dedupe: already at target, syncing state');
601
- this.syncStateForSameRoute(base, pathname);
602
- return;
603
- }
604
- if (sameIdentity && !sameQuery) {
605
- this.log('dedupe: same identity, updating query on top via replace');
606
- const normalizedParams = params && Object.keys(params).length > 0 ? params : undefined;
607
- const updatedTop = {
608
- ...top,
609
- params: normalizedParams,
610
- query,
611
- path: pathname
612
- };
613
- this.applyHistoryChange('replace', updatedTop);
614
- return;
615
- }
616
- }
617
- if (base.stackId) {
618
- const existing = this.findExistingRoute(base.stackId, base.routeId, pathname, params ?? {});
619
- if (existing) {
620
- const normalizedParams = params && Object.keys(params).length > 0 ? params : undefined;
621
- const updatedExisting = {
622
- ...existing,
623
- params: normalizedParams,
624
- query,
625
- path: pathname
626
- };
627
- this.log('dedupe: found existing item, calling popTo', {
628
- key: updatedExisting.key
629
- });
630
- this.applyHistoryChange('popTo', updatedExisting);
631
- return;
632
- }
633
- }
634
- }
635
- if (base.controller) {
636
- const controllerInput = {
637
- params,
638
- query
639
- };
640
- let didPresent = false;
641
- const present = passProps => {
642
- if (didPresent) return;
643
- didPresent = true;
644
- if (navigationToken !== this.navigationToken) {
645
- this.log('controller: present ignored (stale navigation)', {
646
- navigationToken,
647
- current: this.navigationToken
648
- });
649
- return;
650
- }
651
- const newItem = this.createHistoryItem(base, params, query, pathname, passProps);
652
- this.applyHistoryChange(action, newItem);
653
-
654
- // Seed child node if present
655
- if (base.childNode) {
656
- this.addChildNodeSeedsToHistory(base.routeId);
657
- }
658
- };
659
- base.controller(controllerInput, present);
660
- return;
661
- }
662
- const newItem = this.createHistoryItem(base, params, query, pathname);
663
- this.applyHistoryChange(action, newItem);
664
-
665
- // If the matched route has a childNode (e.g., NavigationStack added via addModal),
666
- // seed the child stack's history so StackRenderer has items to render.
667
- if (base.childNode) {
668
- this.addChildNodeSeedsToHistory(base.routeId);
669
- }
670
- }
671
- createHistoryItem(matched, params, query, pathname, passProps) {
672
- const normalizedParams = params && Object.keys(params).length > 0 ? params : undefined;
673
- return {
674
- key: this.generateKey(),
675
- routeId: matched.routeId,
676
- component: matched.component,
677
- options: this.mergeOptions(matched.options, matched.stackId),
678
- params: normalizedParams,
679
- query,
680
- passProps,
681
- stackId: matched.stackId,
682
- pattern: matched.path,
683
- path: pathname
684
- };
685
- }
686
- applyHistoryChange(action, item) {
687
- const stackId = item.stackId;
688
- if (!stackId) {
689
- if (__DEV__) {
690
- console.warn(`[Router] Navigation dropped: item has no stackId (routeId: ${item.routeId}, path: ${item.path})`);
691
- }
692
- return;
693
- }
694
- const prevHist = this.state.history;
695
- let nextHist = prevHist;
696
- this.log('applyHistoryChange', {
697
- action,
698
- stackId,
699
- item: {
700
- key: item.key,
701
- routeId: item.routeId,
702
- path: item.path
703
- }
704
- });
705
- if (action === 'push') {
706
- nextHist = [...prevHist, item];
707
- } else if (action === 'replace') {
708
- let replaced = false;
709
- const copy = [...prevHist];
710
- for (let i = copy.length - 1; i >= 0; i--) {
711
- const h = copy[i];
712
- if (h) {
713
- if (h.stackId === stackId) {
714
- copy[i] = {
715
- ...item,
716
- key: h.key
717
- };
718
- replaced = true;
719
- break;
720
- }
721
- }
722
- }
723
- if (!replaced) {
724
- copy.push(item);
725
- }
726
- nextHist = copy;
727
- } else if (action === 'pop') {
728
- const copy = [...prevHist];
729
- for (let i = copy.length - 1; i >= 0; i--) {
730
- const h = copy[i];
731
- if (!h) continue;
732
- if (h.stackId === stackId) {
733
- copy.splice(i, 1);
734
- break;
735
- }
736
- }
737
- nextHist = copy;
738
- } else if (action === 'popTo') {
739
- const targetKey = item.key;
740
- const keysToRemove = new Set();
741
- let foundIndex = -1;
742
- let foundItem = null;
743
- for (let i = prevHist.length - 1; i >= 0; i--) {
744
- const h = prevHist[i];
745
- if (!h) continue;
746
- if (h.stackId !== stackId) {
747
- continue;
748
- }
749
- if (h.key === targetKey) {
750
- foundIndex = i;
751
- foundItem = h;
752
- break;
753
- }
754
- keysToRemove.add(h.key);
755
- }
756
- if (foundIndex === -1 || !foundItem) {
757
- return;
758
- }
759
- const copy = prevHist.filter(h => !keysToRemove.has(h.key));
760
- const updatedItem = {
761
- ...foundItem,
762
- ...item,
763
- key: targetKey
764
- };
765
- const itemIndex = copy.findIndex(h => h.key === targetKey);
766
- if (itemIndex >= 0) {
767
- copy.splice(itemIndex, 1);
768
- }
769
- let insertPos = copy.length;
770
- for (let i = copy.length - 1; i >= 0; i--) {
771
- if (copy[i]?.stackId === stackId) {
772
- insertPos = i + 1;
773
- break;
774
- }
775
- }
776
- copy.splice(insertPos, 0, updatedItem);
777
- nextHist = copy;
778
- }
779
- this.setState({
780
- history: nextHist
781
- }, item.stackId);
782
- this.recomputeActiveRoute();
783
- this.emit(this.listeners);
784
- }
785
- setState(next, affectedStackId) {
786
- const prev = this.state;
787
- const nextState = {
788
- history: next.history ?? prev.history
789
- };
790
- this.state = nextState;
791
- if (nextState.history !== prev.history) {
792
- this.updateStackHistories(affectedStackId);
793
- }
794
- this.log('setState', nextState);
795
- }
796
- updateStackHistories(affectedStackId) {
797
- if (affectedStackId) {
798
- // Fast path: only update the one affected stack — O(H) instead of O(S×H)
799
- const newHist = this.state.history.filter(h => h.stackId === affectedStackId);
800
- const oldHist = this.stackHistories.get(affectedStackId);
801
- if (!this.areArraysEqual(oldHist, newHist)) {
802
- if (newHist.length === 0) {
803
- this.stackHistories.delete(affectedStackId);
804
- } else {
805
- this.stackHistories.set(affectedStackId, newHist);
806
- }
807
- const listeners = this.stackListeners.get(affectedStackId);
808
- if (listeners) this.emit(listeners);
809
- }
810
- return;
811
- }
812
-
813
- // Slow path: full recompute — used by buildHistoryFromUrl, setRoot, etc.
814
- const stackIds = new Set();
815
- this.state.history.forEach(item => {
816
- if (item.stackId) stackIds.add(item.stackId);
817
- });
818
- const changedStackIds = new Set();
819
- stackIds.forEach(stackId => {
820
- const current = this.state.history.filter(item => item.stackId === stackId);
821
- const previous = this.stackHistories.get(stackId);
822
- if (previous && this.areArraysEqual(previous, current)) {
823
- return;
824
- }
825
- this.stackHistories.set(stackId, current);
826
- changedStackIds.add(stackId);
827
- });
828
- const currentStackIds = new Set(stackIds);
829
- this.stackHistories.forEach((history, stackId) => {
830
- if (!currentStackIds.has(stackId) && history.length > 0) {
831
- this.stackHistories.delete(stackId);
832
- changedStackIds.add(stackId);
833
- }
834
- });
835
- changedStackIds.forEach(stackId => {
836
- this.emit(this.stackListeners.get(stackId));
837
- });
838
- }
839
- areArraysEqual(a, b) {
840
- if (!a) return false;
841
- if (a.length !== b.length) return false;
842
- for (let i = 0; i < a.length; i++) {
843
- if (a[i] !== b[i]) return false;
844
- }
845
- return true;
846
- }
847
- syncStateForSameRoute(base, pathname) {
848
- if (!base.stackId) {
849
- this.recomputeActiveRoute();
850
- this.emit(this.listeners);
851
- return;
852
- }
853
- const {
854
- targetStackId,
855
- targetRouteId
856
- } = this.resolveTargetStackAndRoute(base, pathname);
857
- const rootStackId = this.root?.getId();
858
- if (rootStackId) {
859
- this.activateContainerForRoute(targetRouteId, rootStackId);
860
- }
861
- this.activateStack(targetStackId);
862
- this.ensureStackHasSeed(targetStackId, rootStackId ?? base.stackId);
863
- this.updateActiveRouteFromStack(targetStackId, pathname);
864
- this.emit(this.listeners);
865
- }
866
- resolveTargetStackAndRoute(base, pathname) {
867
- const rootStackId = this.root?.getId();
868
- let targetStackId = base.stackId;
869
- let targetRouteId = base.routeId;
870
- const currentActivePath = this.activeRoute?.path;
871
- const currentActivePathname = currentActivePath ? this.parsePath(currentActivePath).pathname : '';
872
- if (base.stackId === rootStackId && pathname === '/' && currentActivePathname !== '/') {
873
- const childStackRoute = this.findChildStackRouteForPathname(rootStackId, pathname);
874
- if (childStackRoute) {
875
- targetStackId = childStackRoute.stackId;
876
- targetRouteId = childStackRoute.routeId;
877
- }
878
- }
879
- return {
880
- targetStackId,
881
- targetRouteId
882
- };
883
- }
884
- findChildStackRouteForPathname(rootStackId, pathname) {
885
- for (const route of this.registry) {
886
- if (route.stackId !== rootStackId && route.pathnamePattern === pathname && !route.queryPattern) {
887
- const rootRoute = this.findRootRouteWithContainer(rootStackId);
888
- if (rootRoute?.childNode && this.isRouteInContainer(route.routeId, rootRoute.childNode)) {
889
- return route;
890
- }
891
- }
892
- }
893
- return undefined;
894
- }
895
- findRootRouteWithContainer(rootStackId) {
896
- return this.registry.find(r => r.stackId === rootStackId && r.childNode && canSwitchToRoute(r.childNode));
897
- }
898
- isRouteInContainer(routeId, container) {
899
- if (canLookupRoutes(container) && container.hasRoute(routeId)) {
900
- return true;
901
- }
902
- const visit = node => {
903
- const routes = node.getNodeRoutes();
904
- for (const r of routes) {
905
- if (r.routeId === routeId) return true;
906
- if (r.childNode && visit(r.childNode)) return true;
907
- }
908
- const children = node.getNodeChildren();
909
- for (const child of children) {
910
- if (visit(child.node)) return true;
911
- }
912
- return false;
913
- };
914
- return visit(container);
915
- }
916
- activateContainerForRoute(targetRouteId, rootStackId) {
917
- const container = this.findContainerInStack(rootStackId);
918
- if (container && canSwitchToRoute(container)) {
919
- container.switchToRoute(targetRouteId);
920
- }
921
- }
922
- findContainerInStack(stackId) {
923
- const stackHistory = this.getStackHistory(stackId);
924
- for (let i = stackHistory.length - 1; i >= 0; i--) {
925
- const item = stackHistory[i];
926
- if (item) {
927
- const compiled = this.getCompiledRoute(item.routeId);
928
- if (compiled?.childNode && canSwitchToRoute(compiled.childNode)) {
929
- return compiled.childNode;
930
- }
931
- }
932
- }
933
- const rootRoute = this.findRootRouteWithContainer(stackId);
934
- return rootRoute?.childNode;
935
- }
936
- activateStack(stackId) {
937
- const activator = this.stackActivators.get(stackId);
938
- if (activator) {
939
- activator();
940
- }
941
- }
942
- ensureStackHasSeed(stackId, rootStackId) {
943
- const stackHistory = this.getStackHistory(stackId);
944
- if (stackHistory.length > 0) return;
945
- const stackNode = this.stackById.get(stackId);
946
- if (!stackNode) return;
947
- const seed = this.getAutoSeed(stackNode);
948
- if (!seed) return;
949
- const compiled = this.getCompiledRoute(seed.routeId);
950
- const meta = this.routeById.get(seed.routeId);
951
- const path = compiled?.path ?? meta?.path ?? seed.path;
952
- const seedStackId = seed.stackId ?? stackNode.getId();
953
- const item = {
954
- key: this.generateKey(),
955
- routeId: seed.routeId,
956
- component: compiled?.component ?? (() => null),
957
- options: this.mergeOptions(compiled?.options, seedStackId),
958
- params: seed.params ?? {},
959
- stackId: seedStackId,
960
- path,
961
- pattern: compiled?.path ?? seed.path
962
- };
963
- const prevHist = this.state.history;
964
- const nextHist = [...prevHist, item];
965
- this.setState({
966
- history: nextHist
967
- }, seedStackId);
968
- // setState with affectedStackId already emits to the stack's listeners
969
-
970
- if (seedStackId !== stackId && rootStackId) {
971
- this.activateContainerForRoute(seed.routeId, rootStackId);
972
- }
973
- }
974
- updateActiveRouteFromStack(stackId, pathname) {
975
- const stackHistory = this.getStackHistory(stackId);
976
- if (stackHistory.length > 0) {
977
- const topOfStack = stackHistory[stackHistory.length - 1];
978
- if (topOfStack) {
979
- const meta = this.routeById.get(topOfStack.routeId);
980
- this.activeRoute = meta ? {
981
- ...meta,
982
- routeId: topOfStack.routeId,
983
- params: topOfStack.params,
984
- query: topOfStack.query,
985
- path: topOfStack.path
986
- } : {
987
- routeId: topOfStack.routeId,
988
- stackId: topOfStack.stackId,
989
- params: topOfStack.params,
990
- query: topOfStack.query,
991
- path: topOfStack.path
992
- };
993
- this.emit(this.activeRouteListeners);
994
- return;
995
- }
996
- }
997
- const targetRoute = this.registry.find(r => r.stackId === stackId && r.pathnamePattern === pathname);
998
- if (targetRoute) {
999
- const meta = this.routeById.get(targetRoute.routeId);
1000
- this.activeRoute = meta ? {
1001
- ...meta,
1002
- routeId: targetRoute.routeId,
1003
- params: {},
1004
- query: {},
1005
- path: targetRoute.path
1006
- } : {
1007
- routeId: targetRoute.routeId,
1008
- stackId: stackId,
1009
- params: {},
1010
- query: {},
1011
- path: targetRoute.path
1012
- };
1013
- this.emit(this.activeRouteListeners);
1014
- }
1015
- }
1016
- emitDepth = 0;
1017
- emit(set) {
1018
- if (!set) return;
1019
- if (this.emitDepth > 5) {
1020
- if (this.debugEnabled) console.warn('[Router] emit recursion limit reached');
1021
- return;
1022
- }
1023
- this.emitDepth++;
1024
- try {
1025
- for (const l of Array.from(set)) {
1026
- try {
1027
- l();
1028
- } catch (e) {
1029
- if (this.debugEnabled) {
1030
- console.error('[Router] listener error', e);
1031
- }
1032
- }
1033
- }
1034
- } finally {
1035
- this.emitDepth--;
1036
- }
1037
- }
1038
- getTopOfStack(stackId) {
1039
- if (!stackId) return undefined;
1040
- const slice = this.getStackHistory(stackId);
1041
- return slice.length > 0 ? slice[slice.length - 1] : undefined;
1042
- }
1043
- findExistingRoute(stackId, routeId, pathname, params) {
1044
- const stackHistory = this.getStackHistory(stackId);
1045
- return stackHistory.find(item => {
1046
- if (item.routeId !== routeId) return false;
1047
- const itemPathname = item.path ? this.parsePath(item.path).pathname : '';
1048
- if (itemPathname !== pathname) return false;
1049
- return this.areShallowEqual(item.params ?? {}, params);
1050
- });
1051
- }
1052
- findExistingRouteByPathname(stackId, pathname, params) {
1053
- const stackHistory = this.getStackHistory(stackId);
1054
- return stackHistory.find(item => {
1055
- const itemPathname = item.path ? this.parsePath(item.path).pathname : '';
1056
- if (itemPathname !== pathname) return false;
1057
- return this.areShallowEqual(item.params ?? {}, params);
1058
- });
1059
- }
1060
- buildRegistry() {
1061
- this.registry.length = 0;
1062
- const addFromNode = (node, basePath, inheritedOptions) => {
1063
- const normalizedBasePath = this.normalizeBasePath(basePath);
1064
- const baseSpecificity = this.computeBasePathSpecificity(normalizedBasePath);
1065
- const routes = node.getNodeRoutes();
1066
- const stackId = routes.length > 0 ? node.getId() : undefined;
1067
- if (stackId) {
1068
- this.stackById.set(stackId, node);
1069
- }
1070
- let isFirstRoute = true;
1071
- for (const r of routes) {
1072
- // Merge options: first route inherits parent options (e.g., for nested stacks)
1073
- const mergedOptions = isFirstRoute && inheritedOptions ? {
1074
- ...inheritedOptions,
1075
- ...r.options
1076
- } : r.options;
1077
-
1078
- // Always register the route.
1079
- // If it has a childNode, r.component is already childNode.getRenderer() (set by extractComponent).
1080
- const compiled = {
1081
- routeId: r.routeId,
1082
- path: this.combinePathWithBase(r.path, normalizedBasePath),
1083
- pathnamePattern: r.pathnamePattern === '*' ? '*' : this.joinPaths(normalizedBasePath, r.pathnamePattern),
1084
- isWildcardPath: r.isWildcardPath,
1085
- queryPattern: r.queryPattern,
1086
- baseSpecificity: r.baseSpecificity + baseSpecificity,
1087
- matchPath: pathname => {
1088
- const stripped = this.stripBasePath(normalizedBasePath, pathname);
1089
- if (stripped === null) return false;
1090
- if (r.isWildcardPath) {
1091
- return {
1092
- params: {}
1093
- };
1094
- }
1095
- const target = stripped === '' ? '/' : stripped;
1096
- return r.matchPath(target);
1097
- },
1098
- component: r.component,
1099
- controller: r.controller,
1100
- options: mergedOptions,
1101
- stackId,
1102
- childNode: r.childNode
1103
- };
1104
- this.registry.push(compiled);
1105
- this.compiledRouteById.set(compiled.routeId, compiled);
1106
- if (stackId) {
1107
- this.routeById.set(r.routeId, {
1108
- path: compiled.path,
1109
- stackId
1110
- });
1111
- }
1112
- this.log('buildRegistry route', {
1113
- routeId: compiled.routeId,
1114
- path: compiled.path,
1115
- pathnamePattern: compiled.pathnamePattern,
1116
- isWildcardPath: compiled.isWildcardPath,
1117
- baseSpecificity: compiled.baseSpecificity,
1118
- stackId,
1119
- hasChildNode: !!compiled.childNode
1120
- });
1121
- isFirstRoute = false;
1122
-
1123
- // Also register routes from childNode (for navigation inside the nested stack)
1124
- if (r.childNode) {
1125
- const nextBaseForChild = r.isWildcardPath ? normalizedBasePath : this.joinPaths(normalizedBasePath, r.pathnamePattern);
1126
- // Child routes don't inherit parent options - they use their own
1127
- addFromNode(r.childNode, nextBaseForChild);
1128
- }
1129
- }
1130
- const children = node.getNodeChildren();
1131
- for (const child of children) {
1132
- const nextBase = child.prefix === '' ? normalizedBasePath : this.joinPaths(normalizedBasePath, child.prefix);
1133
- const childId = child.node.getId();
1134
- if (child.onMatch) {
1135
- this.stackActivators.set(childId, child.onMatch);
1136
- }
1137
- addFromNode(child.node, nextBase);
1138
- }
1139
- };
1140
- if (this.root) {
1141
- addFromNode(this.root, '');
1142
- }
1143
- }
1144
- normalizeBasePath(input) {
1145
- if (!input || input === '/') {
1146
- return '';
1147
- }
1148
- let normalized = input.startsWith('/') ? input : `/${input}`;
1149
- if (normalized.length > 1 && normalized.endsWith('/')) {
1150
- normalized = normalized.slice(0, -1);
1151
- }
1152
- return normalized;
1153
- }
1154
- joinPaths(basePath, childPath) {
1155
- if (childPath === '*') return '*';
1156
- const base = this.normalizeBasePath(basePath);
1157
- const child = this.normalizeBasePath(childPath || '/');
1158
- if (!base && !child) return '/';
1159
- if (!base) return child || '/';
1160
- if (!child) return base || '/';
1161
- const joined = `${base}${child.startsWith('/') ? child : `/${child}`}`;
1162
- return joined || '/';
1163
- }
1164
- stripBasePath(basePath, pathname) {
1165
- const normalizedBase = this.normalizeBasePath(basePath);
1166
- if (!normalizedBase) {
1167
- return pathname || '/';
1168
- }
1169
- if (normalizedBase === '/') {
1170
- return pathname || '/';
1171
- }
1172
- if (pathname === normalizedBase) {
1173
- return '/';
1174
- }
1175
- if (pathname.startsWith(`${normalizedBase}/`)) {
1176
- const rest = pathname.slice(normalizedBase.length);
1177
- return rest.length ? rest : '/';
1178
- }
1179
- return null;
1180
- }
1181
- combinePathWithBase(path, basePath) {
1182
- const parsed = qs.parseUrl(path);
1183
- const urlPart = parsed.url || '/';
1184
- const query = parsed.query;
1185
- const isWildcard = urlPart === '*';
1186
- const combinedPathname = isWildcard ? `${this.normalizeBasePath(basePath)}*` : this.joinPaths(basePath, urlPart);
1187
- const hasQuery = query && Object.keys(query).length > 0;
1188
- if (!hasQuery) {
1189
- return combinedPathname || '/';
1190
- }
1191
- return `${combinedPathname}?${qs.stringify(query)}`;
1192
- }
1193
- computeBasePathSpecificity(basePath) {
1194
- if (!basePath) return 0;
1195
- const segments = basePath.split('/').filter(Boolean);
1196
- return segments.length * 2;
1197
- }
1198
- getAutoSeed(node) {
1199
- const explicitSeed = node.seed?.();
1200
- if (explicitSeed) {
1201
- return explicitSeed;
1202
- }
1203
- const activeChildId = node.getActiveChildId?.();
1204
- if (activeChildId) {
1205
- const activeChild = node.getNodeChildren().find(child => child.node.getId() === activeChildId);
1206
- if (activeChild) {
1207
- const childSeed = this.getAutoSeed(activeChild.node);
1208
- if (childSeed) {
1209
- return childSeed;
1210
- }
1211
- }
1212
- }
1213
- const routes = node.getNodeRoutes();
1214
- if (routes.length > 0 && routes[0]) {
1215
- const firstRoute = routes[0];
1216
- return {
1217
- routeId: firstRoute.routeId,
1218
- params: {},
1219
- path: firstRoute.path,
1220
- stackId: node.getId()
1221
- };
1222
- }
1223
- return null;
1224
- }
1225
- seedInitialHistory() {
1226
- if (this.state.history.length > 0) return;
1227
- if (this.root) {
1228
- const seed = this.getAutoSeed(this.root);
1229
- if (seed) {
1230
- const compiled = this.getCompiledRoute(seed.routeId);
1231
- const meta = this.routeById.get(seed.routeId);
1232
- const path = compiled?.path ?? meta?.path ?? seed.path;
1233
- const stackId = seed.stackId ?? this.root.getId();
1234
- const item = {
1235
- key: this.generateKey(),
1236
- routeId: seed.routeId,
1237
- component: compiled?.component ?? (() => null),
1238
- options: this.mergeOptions(compiled?.options, stackId),
1239
- params: seed.params ?? {},
1240
- stackId,
1241
- path,
1242
- pattern: compiled?.path ?? seed.path
1243
- };
1244
- this.applyHistoryChange('push', item);
1245
- this.addChildNodeSeedsToHistory(seed.routeId);
1246
- }
1247
- return;
1248
- }
1249
- }
1250
- addChildNodeSeedsToItems(routeId, items, finalRouteId, visited = new Set()) {
1251
- if (visited.has(routeId) || visited.size > 50) return;
1252
- visited.add(routeId);
1253
- this.log('addChildNodeSeeds: called', {
1254
- routeId,
1255
- finalRouteId,
1256
- itemsLength: items.length,
1257
- items: items.map(i => ({
1258
- routeId: i.routeId,
1259
- stackId: i.stackId,
1260
- path: i.path
1261
- }))
1262
- });
1263
- const compiled = this.getCompiledRoute(routeId);
1264
- if (!compiled || !compiled.childNode) {
1265
- this.log('addChildNodeSeeds: no childNode', {
1266
- routeId
1267
- });
1268
- return;
1269
- }
1270
- const childNode = compiled.childNode;
1271
- if (finalRouteId && canSwitchToRoute(childNode)) {
1272
- this.log('addChildNodeSeeds: setting active child', {
1273
- finalRouteId
1274
- });
1275
- childNode.switchToRoute(finalRouteId);
1276
- }
1277
- const childSeed = this.getAutoSeed(childNode);
1278
- if (!childSeed) {
1279
- this.log('addChildNodeSeeds: no seed returned', {
1280
- routeId
1281
- });
1282
- return;
1283
- }
1284
- this.log('addChildNodeSeeds: got seed', {
1285
- routeId: childSeed.routeId,
1286
- stackId: childSeed.stackId,
1287
- path: childSeed.path
1288
- });
1289
- const childCompiled = this.getCompiledRoute(childSeed.routeId);
1290
- const childMeta = this.routeById.get(childSeed.routeId);
1291
- const childPath = childCompiled?.path ?? childMeta?.path ?? childSeed.path;
1292
- const childStackId = childSeed.stackId ?? childNode.getId();
1293
- const existingItem = items.find(item => item.routeId === childSeed.routeId && item.stackId === childStackId);
1294
- if (existingItem) {
1295
- this.log('addChildNodeSeeds: skipping duplicate', {
1296
- routeId: childSeed.routeId,
1297
- stackId: childStackId,
1298
- path: childPath,
1299
- existingItem: {
1300
- key: existingItem.key,
1301
- routeId: existingItem.routeId,
1302
- stackId: existingItem.stackId,
1303
- path: existingItem.path
1304
- }
1305
- });
1306
- return;
1307
- }
1308
- this.log('addChildNodeSeeds: adding child item', {
1309
- routeId: childSeed.routeId,
1310
- stackId: childStackId,
1311
- path: childPath,
1312
- itemsLength: items.length
1313
- });
1314
- const childItem = {
1315
- key: this.generateKey(),
1316
- routeId: childSeed.routeId,
1317
- component: childCompiled?.component ?? (() => null),
1318
- options: this.mergeOptions(childCompiled?.options, childStackId),
1319
- params: childSeed.params ?? {},
1320
- stackId: childStackId,
1321
- path: childPath,
1322
- pattern: childCompiled?.path ?? childSeed.path
1323
- };
1324
- // Insert right after the parent route item, so that child stack seeds do not
1325
- // accidentally become the active route for deep-links (order matters).
1326
- let parentIndex = -1;
1327
- for (let i = items.length - 1; i >= 0; i--) {
1328
- const it = items[i];
1329
- if (it && it.routeId === routeId) {
1330
- parentIndex = i;
1331
- break;
1332
- }
1333
- }
1334
- const insertIndex = parentIndex >= 0 ? parentIndex + 1 : items.length;
1335
- items.splice(insertIndex, 0, childItem);
1336
- if (childSeed.routeId) {
1337
- this.addChildNodeSeedsToItems(childSeed.routeId, items, finalRouteId, visited);
1338
- }
1339
- }
1340
- addChildNodeSeedsToHistory(routeId, visited = new Set()) {
1341
- if (visited.has(routeId) || visited.size > 50) return;
1342
- visited.add(routeId);
1343
- const compiled = this.getCompiledRoute(routeId);
1344
- if (!compiled || !compiled.childNode) return;
1345
- const childNode = compiled.childNode;
1346
- const childSeed = this.getAutoSeed(childNode);
1347
- if (!childSeed) return;
1348
- const childCompiled = this.getCompiledRoute(childSeed.routeId);
1349
- const childMeta = this.routeById.get(childSeed.routeId);
1350
- const childPath = childCompiled?.path ?? childMeta?.path ?? childSeed.path;
1351
- const childStackId = childSeed.stackId ?? childNode.getId();
1352
- const childItem = {
1353
- key: this.generateKey(),
1354
- routeId: childSeed.routeId,
1355
- component: childCompiled?.component ?? (() => null),
1356
- options: this.mergeOptions(childCompiled?.options, childStackId),
1357
- params: childSeed.params ?? {},
1358
- stackId: childStackId,
1359
- path: childPath,
1360
- pattern: childCompiled?.path ?? childSeed.path
1361
- };
1362
- this.applyHistoryChange('push', childItem);
1363
- if (childSeed.routeId) {
1364
- this.addChildNodeSeedsToHistory(childSeed.routeId, visited);
1365
- }
1366
- }
1367
- matchBaseRoute(pathname, query) {
1368
- // One-slot cache: uses reference equality for query object
1369
- if (this.lastMatchedPathname !== null && pathname === this.lastMatchedPathname && query === this.lastMatchedQuery) {
1370
- return this.lastMatchedResult ?? undefined;
1371
- }
1372
- this.log('matchBaseRoute', {
1373
- pathname,
1374
- query
1375
- });
1376
- let best;
1377
- const candidates = [];
1378
- for (const r of this.registry) {
1379
- if (!r.stackId) continue;
1380
- let pathMatch;
1381
- if (r.isWildcardPath) {
1382
- pathMatch = {
1383
- params: {}
1384
- };
1385
- } else {
1386
- pathMatch = r.matchPath(pathname);
1387
- }
1388
- if (!pathMatch) continue;
1389
- if (!this.matchQueryPattern(r.queryPattern, query)) continue;
1390
- let spec = r.baseSpecificity;
1391
- const hasQueryPattern = r.queryPattern && Object.keys(r.queryPattern).length > 0;
1392
- if (hasQueryPattern) {
1393
- spec += 1000;
1394
- }
1395
-
1396
- // Routes with childNode AND modal/sheet presentation are "wrapper" routes
1397
- // that should take priority over the child stack's own routes when both match.
1398
- // This ensures addModal('/path', NavigationStack) renders the wrapper modal
1399
- // and not the child stack's first screen directly.
1400
- if (r.childNode && isModalLikePresentation(r.options?.stackPresentation)) {
1401
- spec += 1;
1402
- }
1403
- this.log('matchBaseRoute candidate', {
1404
- routeId: r.routeId,
1405
- path: r.path,
1406
- baseSpecificity: r.baseSpecificity,
1407
- adjustedSpecificity: spec,
1408
- hasQueryPattern,
1409
- hasChildNode: !!r.childNode,
1410
- stackId: r.stackId
1411
- });
1412
- if (!best || spec > best.specificity) {
1413
- best = {
1414
- route: r,
1415
- specificity: spec
1416
- };
1417
- candidates.length = 0;
1418
- candidates.push(best);
1419
- } else if (spec === best.specificity) {
1420
- candidates.push({
1421
- route: r,
1422
- specificity: spec
1423
- });
1424
- }
1425
- }
1426
- if (candidates.length > 1) {
1427
- this.log('matchBaseRoute: multiple candidates with same specificity', {
1428
- candidatesCount: candidates.length,
1429
- candidates: candidates.map(c => ({
1430
- routeId: c.route.routeId,
1431
- stackId: c.route.stackId,
1432
- path: c.route.path
1433
- }))
1434
- });
1435
- const rootStackId = this.root?.getId();
1436
- let bestFromHistory;
1437
- let bestChildStack;
1438
- let bestChildStackWithHistory;
1439
- for (const candidate of candidates) {
1440
- const candidateStackId = candidate.route.stackId;
1441
- const stackHistory = this.getStackHistory(candidateStackId);
1442
- const hasMatchingItem = stackHistory.some(item => {
1443
- const itemPathname = item.path ? this.parsePath(item.path).pathname : '';
1444
- return itemPathname === pathname;
1445
- });
1446
- this.log('matchBaseRoute: checking candidate', {
1447
- routeId: candidate.route.routeId,
1448
- stackId: candidateStackId,
1449
- isRootStack: candidateStackId === rootStackId,
1450
- stackHistoryLength: stackHistory.length,
1451
- hasMatchingItem
1452
- });
1453
- if (hasMatchingItem) {
1454
- if (candidateStackId !== rootStackId) {
1455
- bestChildStack = candidate.route;
1456
- this.log('matchBaseRoute: found child stack candidate with matching item', {
1457
- routeId: candidate.route.routeId,
1458
- stackId: candidateStackId
1459
- });
1460
- break;
1461
- } else if (!bestFromHistory) {
1462
- bestFromHistory = candidate.route;
1463
- }
1464
- } else if (candidateStackId !== rootStackId && stackHistory.length > 0) {
1465
- if (!bestChildStackWithHistory) {
1466
- bestChildStackWithHistory = candidate.route;
1467
- this.log('matchBaseRoute: found child stack candidate with history', {
1468
- routeId: candidate.route.routeId,
1469
- stackId: candidateStackId,
1470
- stackHistoryLength: stackHistory.length
1471
- });
1472
- }
1473
- }
1474
- }
1475
- if (bestChildStack && best) {
1476
- best = {
1477
- route: bestChildStack,
1478
- specificity: best.specificity
1479
- };
1480
- this.log('matchBaseRoute: selected child stack with matching item', {
1481
- routeId: bestChildStack.routeId,
1482
- stackId: bestChildStack.stackId
1483
- });
1484
- } else if (bestChildStackWithHistory && best) {
1485
- best = {
1486
- route: bestChildStackWithHistory,
1487
- specificity: best.specificity
1488
- };
1489
- this.log('matchBaseRoute: selected child stack', {
1490
- routeId: bestChildStackWithHistory.routeId,
1491
- stackId: bestChildStackWithHistory.stackId
1492
- });
1493
- } else if (bestFromHistory && best) {
1494
- best = {
1495
- route: bestFromHistory,
1496
- specificity: best.specificity
1497
- };
1498
- this.log('matchBaseRoute: selected root stack with history', {
1499
- routeId: bestFromHistory.routeId,
1500
- stackId: bestFromHistory.stackId
1501
- });
1502
- }
1503
- }
1504
- if (best) {
1505
- this.log('matchBaseRoute winner', {
1506
- routeId: best.route.routeId,
1507
- path: best.route.path,
1508
- stackId: best.route.stackId,
1509
- specificity: best.specificity,
1510
- candidatesCount: candidates.length
1511
- });
1512
- } else {
1513
- this.log('matchBaseRoute no match');
1514
- }
1515
- const result = best?.route;
1516
- this.lastMatchedPathname = pathname;
1517
- this.lastMatchedQuery = query;
1518
- this.lastMatchedResult = result ?? null;
1519
- return result;
1520
- }
1521
- generateKey() {
1522
- return `route-${nanoid()}`;
1523
- }
1524
- parsePath(path) {
1525
- if (path === this.lastParsedPath && this.lastParsedResult) {
1526
- return this.lastParsedResult;
1527
- }
1528
- const parsed = qs.parseUrl(path);
1529
- const result = {
1530
- pathname: parsed.url,
1531
- query: parsed.query
1532
- };
1533
- this.lastParsedPath = path;
1534
- this.lastParsedResult = result;
1535
- this.log('parsePath', {
1536
- input: path,
1537
- output: result
1538
- });
1539
- return result;
1540
- }
1541
- mergeOptions(routeOptions, stackId) {
1542
- const stackNode = stackId ? this.findStackById(stackId) : undefined;
1543
- const stackDefaults = stackNode?.getDefaultOptions?.();
1544
- const routerDefaults = this.routerScreenOptions;
1545
- if (!routerDefaults && !stackDefaults && !routeOptions) return undefined;
1546
- const merged = {
1547
- ...(routerDefaults ?? {}),
1548
- ...(stackDefaults ?? {}),
1549
- ...(routeOptions ?? {})
1550
- };
1551
- if (merged.stackPresentation === 'modal' && merged.convertModalToSheetForAndroid && Platform.OS === 'android') {
1552
- merged.stackPresentation = 'sheet';
1553
- }
1554
- return merged;
1555
- }
1556
- findStackById(stackId) {
1557
- return this.stackById.get(stackId);
1558
- }
1559
- getCompiledRoute(routeId) {
1560
- return this.compiledRouteById.get(routeId);
1561
- }
1562
- areShallowEqual(a, b) {
1563
- if (a === b) return true;
1564
- const na = a ?? {};
1565
- const nb = b ?? {};
1566
- const aKeys = Object.keys(na);
1567
- const bKeys = Object.keys(nb);
1568
- if (aKeys.length !== bKeys.length) return false;
1569
- for (const k of aKeys) {
1570
- if (!(k in nb)) return false;
1571
- const av = na[k];
1572
- const bv = nb[k];
1573
- if (av === bv) continue;
1574
- const aArr = Array.isArray(av);
1575
- const bArr = Array.isArray(bv);
1576
- if (aArr || bArr) {
1577
- if (!aArr || !bArr) return false;
1578
- if (av.length !== bv.length) return false;
1579
- for (let i = 0; i < av.length; i++) {
1580
- if (av[i] !== bv[i]) return false;
1581
- }
1582
- continue;
1583
- }
1584
- return false;
1585
- }
1586
- return true;
1587
- }
1588
- matchQueryPattern(pattern, query) {
1589
- if (!pattern) {
1590
- return true;
1591
- }
1592
- for (const [key, token] of Object.entries(pattern)) {
1593
- const raw = query[key];
1594
- if (raw == null) {
1595
- this.log('matchQueryPattern: key missing', {
1596
- key,
1597
- token,
1598
- query
1599
- });
1600
- return false;
1601
- }
1602
- if (typeof raw !== 'string') {
1603
- this.log('matchQueryPattern: non-string value', {
1604
- key,
1605
- token,
1606
- value: raw
1607
- });
1608
- return false;
1609
- }
1610
- if (token.type === 'const') {
1611
- if (raw !== token.value) {
1612
- this.log('matchQueryPattern: const mismatch', {
1613
- key,
1614
- expected: token.value,
1615
- actual: raw
1616
- });
1617
- return false;
1618
- }
1619
- } else if (token.type === 'param') {
1620
- this.log('matchQueryPattern: param ok', {
1621
- key,
1622
- value: raw,
1623
- paramName: token.name
1624
- });
1625
- }
1626
- }
1627
- this.log('matchQueryPattern: success', {
1628
- pattern,
1629
- query
1630
- });
1631
- return true;
1632
- }
1633
- isWebEnv() {
1634
- const g = globalThis;
1635
- return !!(g.addEventListener && g.history && g.location);
1636
- }
1637
- getCurrentUrl() {
1638
- const g = globalThis;
1639
- return g.location ? `${g.location.pathname}${g.location.search}` : '/';
1640
- }
1641
- readHistoryIndex(state) {
1642
- if (state && typeof state === 'object' && '__srIndex' in state) {
1643
- const idx = state.__srIndex;
1644
- if (typeof idx === 'number') return idx;
1645
- }
1646
- return 0;
1647
- }
1648
- getHistoryIndexOrNull() {
1649
- const g = globalThis;
1650
- const st = g.history?.state;
1651
- if (st && typeof st === 'object' && '__srIndex' in st) {
1652
- const idx = st.__srIndex;
1653
- return typeof idx === 'number' ? idx : null;
1654
- }
1655
- return null;
1656
- }
1657
- getHistoryIndex() {
1658
- const g = globalThis;
1659
- return this.readHistoryIndex(g.history?.state);
1660
- }
1661
- ensureHistoryIndex() {
1662
- const g = globalThis;
1663
- const st = g.history?.state ?? {};
1664
- let next = st;
1665
- let changed = false;
1666
- if (!('__srIndex' in next)) {
1667
- next = {
1668
- ...next,
1669
- __srIndex: 0
1670
- };
1671
- changed = true;
1672
- }
1673
- if (!('__srPath' in next)) {
1674
- next = {
1675
- ...next,
1676
- __srPath: this.getCurrentUrl()
1677
- };
1678
- changed = true;
1679
- }
1680
- if (changed) {
1681
- try {
1682
- g.history?.replaceState(next, '', g.location?.href);
1683
- } catch (e) {
1684
- if (this.debugEnabled) console.warn('[Router] ensureHistoryIndex replaceState failed', e);
1685
- }
1686
- }
1687
- }
1688
- getRouterPathFromHistory() {
1689
- const g = globalThis;
1690
- const st = g.history?.state;
1691
- if (st && typeof st === 'object' && '__srPath' in st) {
1692
- const p = st.__srPath;
1693
- if (typeof p === 'string' && p.length > 0) {
1694
- return p;
1695
- }
1696
- }
1697
- return this.getCurrentUrl();
1698
- }
1699
- getReplaceDedupeFromHistory() {
1700
- const g = globalThis;
1701
- const st = g.history?.state;
1702
- if (st && typeof st === 'object' && '__srReplaceDedupe' in st) {
1703
- const v = st.__srReplaceDedupe;
1704
- return typeof v === 'boolean' ? v : false;
1705
- }
1706
- return false;
1707
- }
1708
- shouldSyncPathWithUrl(path) {
1709
- const {
1710
- pathname,
1711
- query
1712
- } = this.parsePath(path);
1713
- const base = this.matchBaseRoute(pathname, query);
1714
- if (!base) return true;
1715
- const mergedOptions = this.mergeOptions(base.options, base.stackId);
1716
- const rawsyncWithUrl = mergedOptions?.syncWithUrl;
1717
- if (typeof rawsyncWithUrl === 'boolean') {
1718
- return rawsyncWithUrl;
1719
- }
1720
- return true;
1721
- }
1722
- pushUrl(to, opts) {
1723
- const g = globalThis;
1724
- const st = g.history?.state ?? {};
1725
- const prev = this.readHistoryIndex(st);
1726
- const base = st;
1727
- const syncWithUrl = opts?.syncWithUrl ?? true;
1728
- const routerPath = to;
1729
- const visualUrl = syncWithUrl ? to : this.getCurrentUrl();
1730
- const next = {
1731
- ...base,
1732
- __srIndex: prev + 1,
1733
- __srPath: routerPath
1734
- };
1735
- g.history?.pushState(next, '', visualUrl);
1736
- }
1737
- replaceUrl(to, opts) {
1738
- const g = globalThis;
1739
- const st = g.history?.state ?? {};
1740
- const base = st;
1741
- const syncWithUrl = opts?.syncWithUrl ?? true;
1742
- const routerPath = to;
1743
- const visualUrl = syncWithUrl ? to : this.getCurrentUrl();
1744
- const next = {
1745
- ...base,
1746
- __srPath: routerPath,
1747
- __srReplaceDedupe: !!opts?.dedupe
1748
- };
1749
- g.history?.replaceState(next, '', visualUrl);
1750
- }
1751
- replaceUrlSilently(to) {
1752
- if (!this.isWebEnv()) return;
1753
- if (this.suppressHistorySyncCount > 10) {
1754
- console.warn('[Router] suppressHistorySyncCount reset from', this.suppressHistorySyncCount);
1755
- this.suppressHistorySyncCount = 0;
1756
- }
1757
- this.suppressHistorySyncCount += 1;
1758
- this.replaceUrl(to, {
1759
- syncWithUrl: true
1760
- });
1761
- }
1762
- buildUrlFromActiveRoute() {
1763
- const ar = this.activeRoute;
1764
- if (!ar || !ar.path) {
1765
- return null;
1766
- }
1767
- const path = ar.path;
1768
- const query = ar.query;
1769
- if (!query || Object.keys(query).length === 0) {
1770
- return path;
1771
- }
1772
- const search = qs.stringify(query);
1773
- return `${path}${search && search !== '' ? `?${search}` : ''}`;
1774
- }
1775
- patchBrowserHistoryOnce() {
1776
- const g = globalThis;
1777
- const key = Symbol.for('sigmela_router_history_patch');
1778
- if (g[key]) return;
1779
- const hist = g.history;
1780
- if (hist && hist.pushState && hist.replaceState) {
1781
- const originalPush = hist.pushState.bind(hist);
1782
- const originalReplace = hist.replaceState.bind(hist);
1783
- hist.pushState = (data, unused, url) => {
1784
- const res = originalPush(data, unused, url);
1785
- if (g.Event && g.dispatchEvent) {
1786
- g.dispatchEvent(new g.Event('pushState'));
1787
- }
1788
- return res;
1789
- };
1790
- hist.replaceState = (data, unused, url) => {
1791
- const res = originalReplace(data, unused, url);
1792
- if (g.Event && g.dispatchEvent) {
1793
- g.dispatchEvent(new g.Event('replaceState'));
1794
- }
1795
- return res;
1796
- };
1797
- }
1798
- g[key] = true;
1799
- }
1800
- setupBrowserHistory() {
1801
- // Abort previous listeners in case setupBrowserHistory is called twice
1802
- this.abortController?.abort();
1803
- this.abortController = new AbortController();
1804
- const {
1805
- signal
1806
- } = this.abortController;
1807
- const g = globalThis;
1808
- const gAny = g;
1809
- const activeKey = Symbol.for('sigmela_router_active_instance');
1810
- this.patchBrowserHistoryOnce();
1811
- this.ensureHistoryIndex();
1812
- this.lastBrowserIndex = this.getHistoryIndex();
1813
-
1814
- // If a new Router instance is created (e.g. Fast Refresh / HMR), ensure only the latest
1815
- // instance reacts to browser history events to avoid duplicate navigation updates.
1816
- gAny[activeKey] = this;
1817
- const onHistory = ev => {
1818
- if (gAny[activeKey] !== this) return;
1819
- if (ev.type === 'pushState') {
1820
- const path = this.getRouterPathFromHistory();
1821
- const idx = this.getHistoryIndexOrNull();
1822
- this.lastBrowserIndex = idx !== null ? idx : Math.max(0, this.lastBrowserIndex + 1);
1823
- this.performNavigation(path, 'push');
1824
- return;
1825
- }
1826
- if (ev.type === 'replaceState') {
1827
- if (this.suppressHistorySyncCount > 0) {
1828
- this.log('onHistory: replaceState suppressed (internal URL sync)');
1829
- this.suppressHistorySyncCount -= 1;
1830
- const idx = this.getHistoryIndexOrNull();
1831
- this.lastBrowserIndex = idx !== null ? idx : this.lastBrowserIndex;
1832
- return;
1833
- }
1834
- const path = this.getRouterPathFromHistory();
1835
- const dedupe = this.getReplaceDedupeFromHistory();
1836
- this.performNavigation(path, 'replace', {
1837
- dedupe
1838
- });
1839
- const idx = this.getHistoryIndexOrNull();
1840
- this.lastBrowserIndex = idx !== null ? idx : this.lastBrowserIndex;
1841
- return;
1842
- }
1843
- if (ev.type === 'popstate') {
1844
- const url = this.getRouterPathFromHistory();
1845
- const idx = this.getHistoryIndexOrNull();
1846
- const delta = idx !== null ? idx - this.lastBrowserIndex : 0;
1847
- this.log('popstate event', {
1848
- url,
1849
- idx,
1850
- prevIndex: this.lastBrowserIndex,
1851
- delta
1852
- });
1853
-
1854
- // If browser history index isn't available (external history manipulations),
1855
- // treat popstate as a soft replace. The route path still comes from __srPath when present.
1856
- if (idx === null) {
1857
- this.performNavigation(url, 'replace', {
1858
- dedupe: true
1859
- });
1860
- return;
1861
- }
1862
- if (delta < 0) {
1863
- this.performNavigation(url, 'replace', {
1864
- dedupe: true
1865
- });
1866
- } else if (delta > 0) {
1867
- this.log('popstate: forward history step, treat as push', {
1868
- url
1869
- });
1870
- this.performNavigation(url, 'push');
1871
- } else {
1872
- this.log('popstate: same index, soft replace+dedupe', {
1873
- url
1874
- });
1875
- this.performNavigation(url, 'replace', {
1876
- dedupe: true
1877
- });
1878
- }
1879
- this.lastBrowserIndex = idx;
1880
- }
1881
- };
1882
- g.addEventListener?.('pushState', onHistory, {
1883
- signal
1884
- });
1885
- g.addEventListener?.('replaceState', onHistory, {
1886
- signal
1887
- });
1888
- g.addEventListener?.('popstate', onHistory, {
1889
- signal
1890
- });
1891
- }
1892
- destroyed = false;
1893
- destroy() {
1894
- this.destroyed = true;
1895
- this.abortController?.abort();
1896
- this.listeners.clear();
1897
- this.stackListeners.clear();
1898
- this.activeRouteListeners.clear();
1899
- this.rootListeners.clear();
1900
- }
1901
- syncUrlAfterInternalPop(popped) {
1902
- if (!this.isWebEnv()) return;
1903
- const compiled = this.getCompiledRoute(popped.routeId);
1904
- const isQueryRoute = compiled && compiled.queryPattern && Object.keys(compiled.queryPattern).length > 0;
1905
- if (isQueryRoute) {
1906
- const currentUrl = this.getCurrentUrl();
1907
- const {
1908
- pathname,
1909
- query
1910
- } = this.parsePath(currentUrl);
1911
- const pattern = compiled.queryPattern;
1912
- const nextQuery = {
1913
- ...query
1914
- };
1915
- for (const key of Object.keys(pattern)) {
1916
- delete nextQuery[key];
1917
- }
1918
- const hasQuery = Object.keys(nextQuery).length > 0;
1919
- const newSearch = hasQuery ? `?${qs.stringify(nextQuery)}` : '';
1920
- const nextUrl = `${pathname}${newSearch}`;
1921
- this.log('syncUrlWithStateAfterInternalPop (query)', {
1922
- poppedRouteId: popped.routeId,
1923
- from: currentUrl,
1924
- to: nextUrl
1925
- });
1926
- this.replaceUrlSilently(nextUrl);
1927
- return;
1928
- }
1929
- const from = this.getCurrentUrl();
1930
- const nextUrl = this.buildUrlFromActiveRoute();
1931
- if (!nextUrl) {
1932
- this.log('syncUrlWithStateAfterInternalPop: no activeRoute, skip URL sync', {
1933
- from
1934
- });
1935
- return;
1936
- }
1937
- this.log('syncUrlAfterInternalPop (base)', {
1938
- poppedRouteId: popped.routeId,
1939
- from,
1940
- to: nextUrl
1941
- });
1942
- this.replaceUrlSilently(nextUrl);
1943
- }
1944
- popFromActiveStack() {
1945
- const active = this.activeRoute;
1946
- if (!active || !active.stackId) {
1947
- if (this.root) {
1948
- const sid = this.root.getId();
1949
- if (sid) {
1950
- const stackHistory = this.getStackHistory(sid);
1951
- if (stackHistory.length > 0) {
1952
- const top = stackHistory[stackHistory.length - 1];
1953
- if (!top) return null;
1954
- const isModalOrSheet = top.options?.stackPresentation === 'modal' || top.options?.stackPresentation === 'sheet';
1955
- if (stackHistory.length > 1 || isModalOrSheet) {
1956
- if (top.options?.stackPresentation === 'sheet') {
1957
- const dismisser = this.sheetDismissers.get(top.key);
1958
- if (dismisser) {
1959
- this.unregisterSheetDismisser(top.key);
1960
- dismisser();
1961
- return top;
1962
- }
1963
- }
1964
- this.applyHistoryChange('pop', top);
1965
- return top;
1966
- }
1967
- }
1968
- }
1969
- }
1970
- return null;
1971
- }
1972
- let activeItem;
1973
- for (let i = this.state.history.length - 1; i >= 0; i--) {
1974
- const h = this.state.history[i];
1975
- if (h && active && h.stackId === active.stackId) {
1976
- activeItem = h;
1977
- break;
1978
- }
1979
- }
1980
- if (!active || !activeItem || !activeItem.stackId) return null;
1981
- const stackHistory = this.getStackHistory(activeItem.stackId);
1982
- const isModalOrSheet = activeItem.options?.stackPresentation === 'modal' || activeItem.options?.stackPresentation === 'sheet';
1983
- const allowRootPop = activeItem.options?.allowRootPop === true;
1984
- if (stackHistory.length <= 1 && !isModalOrSheet && !allowRootPop) {
1985
- return null;
1986
- }
1987
- if (activeItem.options?.stackPresentation === 'sheet') {
1988
- const dismisser = this.sheetDismissers.get(activeItem.key);
1989
- if (dismisser) {
1990
- this.unregisterSheetDismisser(activeItem.key);
1991
- dismisser();
1992
- return activeItem;
1993
- }
1994
- }
1995
- const top = stackHistory[stackHistory.length - 1];
1996
- if (!top) return null;
1997
- this.applyHistoryChange('pop', top);
1998
- return top;
1999
- }
2000
- buildHistoryFromUrl(url, reuseKeysFrom) {
2001
- const {
2002
- pathname,
2003
- query
2004
- } = this.parsePath(url);
2005
- this.log('parse', {
2006
- url,
2007
- pathname,
2008
- query
2009
- });
2010
- const baseRoute = this.matchBaseRoute(pathname, {});
2011
- const deepest = this.matchBaseRoute(pathname, query);
2012
- const hasDeepestQueryPattern = deepest && deepest.queryPattern && Object.keys(deepest.queryPattern).length > 0;
2013
- const isDeepestOverlay = hasDeepestQueryPattern && deepest && baseRoute && deepest.routeId !== baseRoute.routeId;
2014
- if (!deepest || isDeepestOverlay && !baseRoute) {
2015
- if (__DEV__) {
2016
- console.warn(`[Router] parse: no base route found for "${pathname}", seeding initial history`);
2017
- } else {
2018
- console.warn(`[Router] No base route found for "${pathname}"`);
2019
- }
2020
- this.seedInitialHistory();
2021
- this.recomputeActiveRoute();
2022
- this.emit(this.listeners);
2023
- return;
2024
- }
2025
- const segments = pathname.split('/').filter(Boolean);
2026
- const prefixes = segments.length > 0 ? segments.map((_, i) => '/' + segments.slice(0, i + 1).join('/')) : ['/'];
2027
- if (!prefixes.includes('/')) {
2028
- prefixes.unshift('/');
2029
- }
2030
- const items = [];
2031
- prefixes.forEach((prefixPath, index) => {
2032
- this.log('parse prefix', {
2033
- index,
2034
- prefixPath
2035
- });
2036
- let routeForPrefix;
2037
- if (index === prefixes.length - 1) {
2038
- routeForPrefix = baseRoute || deepest;
2039
- } else {
2040
- let best;
2041
- for (const route of this.registry) {
2042
- if (route.queryPattern) continue;
2043
- const matchResult = route.matchPath(prefixPath);
2044
- if (!matchResult) continue;
2045
- const spec = route.baseSpecificity;
2046
- this.log('parse candidate for prefix', {
2047
- prefixPath,
2048
- routeId: route.routeId,
2049
- path: route.path,
2050
- baseSpecificity: spec
2051
- });
2052
- if (!best || spec > best.spec) {
2053
- best = {
2054
- route,
2055
- spec
2056
- };
2057
- }
2058
- }
2059
- routeForPrefix = best?.route;
2060
- }
2061
- if (!routeForPrefix) {
2062
- this.log('parse: no route for prefix', {
2063
- index,
2064
- prefixPath
2065
- });
2066
- return;
2067
- }
2068
- const matchResult = routeForPrefix.matchPath(prefixPath);
2069
- const params = matchResult ? matchResult.params : undefined;
2070
- const itemQuery = index === prefixes.length - 1 && !isDeepestOverlay ? query : {};
2071
- const item = this.createHistoryItem(routeForPrefix, params, itemQuery, prefixPath);
2072
- this.log('parse: push item', {
2073
- index,
2074
- routeId: routeForPrefix.routeId,
2075
- path: routeForPrefix.path,
2076
- prefixPath,
2077
- params,
2078
- itemQuery
2079
- });
2080
- items.push(item);
2081
- });
2082
- let overlayItem;
2083
- if (isDeepestOverlay && deepest && baseRoute) {
2084
- overlayItem = this.createHistoryItem(deepest, undefined, query, pathname);
2085
- this.log('parse: push overlay item', {
2086
- routeId: deepest.routeId,
2087
- path: deepest.path,
2088
- pathname,
2089
- query
2090
- });
2091
- items.push(overlayItem);
2092
- }
2093
- if (items.length > 0) {
2094
- const lastItem = items[items.length - 1];
2095
- const lastItemCompiled = lastItem ? this.getCompiledRoute(lastItem.routeId) : undefined;
2096
- const isLastItemOverlay = lastItem && lastItemCompiled && lastItemCompiled.queryPattern && Object.keys(lastItemCompiled.queryPattern).length > 0;
2097
- const finalRouteId = isLastItemOverlay && items.length > 1 ? items[items.length - 2]?.routeId : lastItem?.routeId;
2098
- const searchStartIndex = isLastItemOverlay ? items.length - 2 : items.length - 1;
2099
- for (let i = searchStartIndex; i >= 0; i--) {
2100
- const item = items[i];
2101
- if (item && item.routeId) {
2102
- const compiled = this.getCompiledRoute(item.routeId);
2103
- if (compiled && compiled.childNode) {
2104
- this.addChildNodeSeedsToItems(item.routeId, items, finalRouteId);
2105
- // Don't break: we may have multiple nested containers (e.g. TabBar -> SplitView).
2106
- // All of them must be activated/seeded to make the UI consistent on initial deep-link.
2107
- }
2108
- }
2109
- }
2110
- if (overlayItem) {
2111
- const overlayIndex = items.findIndex(item => item.key === overlayItem.key);
2112
- if (overlayIndex >= 0 && overlayIndex < items.length - 1) {
2113
- items.splice(overlayIndex, 1);
2114
- items.push(overlayItem);
2115
- }
2116
- }
2117
- }
2118
- if (!items.length) {
2119
- if (__DEV__) {
2120
- console.warn('[Router] parse: no items built for URL, seeding initial history');
2121
- } else {
2122
- console.warn('[Router] No items built for URL, seeding initial history');
2123
- }
2124
- this.seedInitialHistory();
2125
- this.recomputeActiveRoute();
2126
- this.emit(this.listeners);
2127
- return;
2128
- }
2129
-
2130
- // When rebuilding history (e.g. tab reset), preserve keys for matching items
2131
- // so web animations don't replay for the whole root stack.
2132
- this.reuseKeysFromPreviousHistory(items, reuseKeysFrom);
2133
- this.setState({
2134
- history: items
2135
- });
2136
- // setState -> updateStackHistories already emits to changed stacks
2137
-
2138
- this.recomputeActiveRoute();
2139
- this.emit(this.listeners);
2140
- }
2141
- reuseKeysFromPreviousHistory(next, prev) {
2142
- if (!prev || prev.length === 0) return;
2143
- const used = new Set();
2144
- const sameItemIdentity = (a, b) => {
2145
- if (a.routeId !== b.routeId) return false;
2146
- if ((a.stackId ?? '') !== (b.stackId ?? '')) return false;
2147
- if ((a.path ?? '') !== (b.path ?? '')) return false;
2148
- const sameParams = this.areShallowEqual(a.params ?? {}, b.params ?? {});
2149
- if (!sameParams) return false;
2150
- const sameQuery = this.areShallowEqual(a.query ?? {}, b.query ?? {});
2151
- if (!sameQuery) return false;
2152
-
2153
- // Keep presentation stable: modal/sheet items should not steal keys from push items.
2154
- const ap = a.options?.stackPresentation ?? null;
2155
- const bp = b.options?.stackPresentation ?? null;
2156
- if (ap !== bp) return false;
2157
- return true;
2158
- };
2159
- for (const item of next) {
2160
- for (let i = prev.length - 1; i >= 0; i--) {
2161
- const candidate = prev[i];
2162
- if (!candidate) continue;
2163
- if (used.has(candidate.key)) continue;
2164
- if (!sameItemIdentity(item, candidate)) continue;
2165
- item.key = candidate.key;
2166
- used.add(candidate.key);
2167
- break;
2168
- }
2169
- }
2170
- }
2171
- }