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