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