@sigmela/router 0.1.3 → 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 +1503 -502
- 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,283 +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) {
|
|
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
|
-
|
|
336
|
-
// Prevent duplicate push when navigating to the same screen already on top of its stack
|
|
337
354
|
if (action === 'push') {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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);
|
|
343
378
|
return;
|
|
344
379
|
}
|
|
345
380
|
}
|
|
346
381
|
}
|
|
347
|
-
|
|
348
|
-
// Optional dedupe for replace: no-op when nothing changes at the top
|
|
349
382
|
if (action === 'replace' && opts?.dedupe) {
|
|
350
|
-
const top = this.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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 ?? {});
|
|
354
397
|
const samePath = (top.path ?? '') === pathname;
|
|
355
|
-
|
|
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);
|
|
356
438
|
return;
|
|
357
439
|
}
|
|
358
440
|
}
|
|
359
441
|
}
|
|
360
|
-
|
|
361
|
-
// If there's a controller, execute it first
|
|
362
|
-
if (matched.controller) {
|
|
442
|
+
if (base.controller) {
|
|
363
443
|
const controllerInput = {
|
|
364
444
|
params,
|
|
365
445
|
query
|
|
366
446
|
};
|
|
447
|
+
let didPresent = false;
|
|
367
448
|
const present = passProps => {
|
|
368
|
-
|
|
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);
|
|
369
459
|
this.applyHistoryChange(action, newItem);
|
|
370
460
|
};
|
|
371
|
-
|
|
461
|
+
base.controller(controllerInput, present);
|
|
372
462
|
return;
|
|
373
463
|
}
|
|
374
|
-
const newItem = this.createHistoryItem(
|
|
464
|
+
const newItem = this.createHistoryItem(base, params, query, pathname);
|
|
375
465
|
this.applyHistoryChange(action, newItem);
|
|
376
466
|
}
|
|
377
467
|
createHistoryItem(matched, params, query, pathname, passProps) {
|
|
468
|
+
const normalizedParams = params && Object.keys(params).length > 0 ? params : undefined;
|
|
378
469
|
return {
|
|
379
470
|
key: this.generateKey(),
|
|
380
|
-
scope: matched.scope,
|
|
381
471
|
routeId: matched.routeId,
|
|
382
472
|
component: matched.component,
|
|
383
473
|
options: this.mergeOptions(matched.options, matched.stackId),
|
|
384
|
-
params,
|
|
474
|
+
params: normalizedParams,
|
|
385
475
|
query: query,
|
|
386
476
|
passProps,
|
|
387
|
-
tabIndex: matched.tabIndex,
|
|
388
477
|
stackId: matched.stackId,
|
|
389
478
|
pattern: matched.path,
|
|
390
479
|
path: pathname
|
|
391
480
|
};
|
|
392
481
|
}
|
|
393
|
-
|
|
394
|
-
|
|
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
|
+
}
|
|
395
812
|
buildRegistry() {
|
|
396
813
|
this.registry.length = 0;
|
|
397
|
-
const
|
|
398
|
-
const
|
|
399
|
-
this.
|
|
400
|
-
|
|
401
|
-
|
|
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 = {
|
|
402
824
|
routeId: r.routeId,
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
+
},
|
|
406
841
|
component: r.component,
|
|
407
842
|
controller: r.controller,
|
|
408
843
|
options: r.options,
|
|
409
|
-
tabIndex: extras.tabIndex,
|
|
410
|
-
stackId
|
|
411
|
-
});
|
|
412
|
-
this.routeById.set(r.routeId, {
|
|
413
|
-
path: r.path,
|
|
414
844
|
stackId,
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
417
861
|
});
|
|
862
|
+
if (r.childNode) {
|
|
863
|
+
const nextBaseForChild = r.isWildcardPath ? normalizedBasePath : this.joinPaths(normalizedBasePath, r.pathnamePattern);
|
|
864
|
+
addFromNode(r.childNode, nextBaseForChild);
|
|
865
|
+
}
|
|
418
866
|
}
|
|
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
|
-
});
|
|
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);
|
|
432
873
|
}
|
|
433
|
-
|
|
874
|
+
addFromNode(child.node, nextBase);
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
if (this.root) {
|
|
878
|
+
addFromNode(this.root, '');
|
|
434
879
|
}
|
|
435
|
-
|
|
436
|
-
|
|
880
|
+
}
|
|
881
|
+
normalizeBasePath(input) {
|
|
882
|
+
if (!input || input === '/') {
|
|
883
|
+
return '';
|
|
884
|
+
}
|
|
885
|
+
let normalized = input.startsWith('/') ? input : `/${input}`;
|
|
886
|
+
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
887
|
+
normalized = normalized.slice(0, -1);
|
|
437
888
|
}
|
|
889
|
+
return normalized;
|
|
438
890
|
}
|
|
439
|
-
|
|
440
|
-
if (
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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;
|
|
461
947
|
}
|
|
462
948
|
}
|
|
463
|
-
return;
|
|
464
949
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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 = {
|
|
469
972
|
key: this.generateKey(),
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
476
980
|
};
|
|
477
|
-
this.applyHistoryChange('push',
|
|
981
|
+
this.applyHistoryChange('push', item);
|
|
982
|
+
this.addChildNodeSeedsToHistory(seed.routeId);
|
|
478
983
|
}
|
|
984
|
+
return;
|
|
479
985
|
}
|
|
480
986
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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);
|
|
484
1073
|
}
|
|
485
|
-
return undefined;
|
|
486
|
-
}
|
|
487
|
-
generateKey() {
|
|
488
|
-
return `route-${nanoid()}`;
|
|
489
1074
|
}
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
return
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
495
1094
|
};
|
|
1095
|
+
this.applyHistoryChange('push', childItem);
|
|
1096
|
+
if (childSeed.routeId) {
|
|
1097
|
+
this.addChildNodeSeedsToHistory(childSeed.routeId);
|
|
1098
|
+
}
|
|
496
1099
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
502
1131
|
});
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
+
}))
|
|
514
1154
|
});
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
+
});
|
|
532
1192
|
}
|
|
533
1193
|
}
|
|
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
1194
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
|
549
1231
|
});
|
|
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);
|
|
1232
|
+
} else {
|
|
1233
|
+
this.log('matchBaseRoute no match');
|
|
561
1234
|
}
|
|
1235
|
+
return best?.route;
|
|
562
1236
|
}
|
|
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());
|
|
1237
|
+
generateKey() {
|
|
1238
|
+
return `route-${nanoid()}`;
|
|
575
1239
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const
|
|
579
|
-
|
|
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;
|
|
580
1251
|
}
|
|
581
1252
|
mergeOptions(routeOptions, stackId) {
|
|
582
|
-
const
|
|
1253
|
+
const stackNode = stackId ? this.findStackById(stackId) : undefined;
|
|
1254
|
+
const stackDefaults = stackNode?.getDefaultOptions?.();
|
|
583
1255
|
const routerDefaults = this.routerScreenOptions;
|
|
584
1256
|
if (!routerDefaults && !stackDefaults && !routeOptions) return undefined;
|
|
585
1257
|
const merged = {
|
|
@@ -595,8 +1267,76 @@ export class Router {
|
|
|
595
1267
|
findStackById(stackId) {
|
|
596
1268
|
return this.stackById.get(stackId);
|
|
597
1269
|
}
|
|
598
|
-
|
|
599
|
-
|
|
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
|
+
}
|
|
600
1340
|
isWebEnv() {
|
|
601
1341
|
const g = globalThis;
|
|
602
1342
|
return !!(g.addEventListener && g.history && g.location);
|
|
@@ -605,54 +1345,135 @@ export class Router {
|
|
|
605
1345
|
const g = globalThis;
|
|
606
1346
|
return g.location ? `${g.location.pathname}${g.location.search}` : '/';
|
|
607
1347
|
}
|
|
608
|
-
|
|
1348
|
+
readHistoryIndex(state) {
|
|
609
1349
|
if (state && typeof state === 'object' && '__srIndex' in state) {
|
|
610
1350
|
const idx = state.__srIndex;
|
|
611
1351
|
if (typeof idx === 'number') return idx;
|
|
612
1352
|
}
|
|
613
1353
|
return 0;
|
|
614
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
|
+
}
|
|
615
1364
|
getHistoryIndex() {
|
|
616
1365
|
const g = globalThis;
|
|
617
|
-
return this.
|
|
1366
|
+
return this.readHistoryIndex(g.history?.state);
|
|
618
1367
|
}
|
|
619
1368
|
ensureHistoryIndex() {
|
|
620
1369
|
const g = globalThis;
|
|
621
1370
|
const st = g.history?.state ?? {};
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
1371
|
+
let next = st;
|
|
1372
|
+
let changed = false;
|
|
1373
|
+
if (!('__srIndex' in next)) {
|
|
1374
|
+
next = {
|
|
1375
|
+
...next,
|
|
625
1376
|
__srIndex: 0
|
|
626
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) {
|
|
627
1388
|
try {
|
|
628
1389
|
g.history?.replaceState(next, '', g.location?.href);
|
|
629
|
-
} catch {
|
|
630
|
-
|
|
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;
|
|
631
1400
|
}
|
|
632
1401
|
}
|
|
1402
|
+
return this.getCurrentUrl();
|
|
633
1403
|
}
|
|
634
|
-
|
|
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) {
|
|
635
1428
|
const g = globalThis;
|
|
636
1429
|
const st = g.history?.state ?? {};
|
|
637
|
-
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();
|
|
638
1435
|
const next = {
|
|
639
|
-
...
|
|
640
|
-
__srIndex: prev + 1
|
|
1436
|
+
...base,
|
|
1437
|
+
__srIndex: prev + 1,
|
|
1438
|
+
__srPath: routerPath
|
|
641
1439
|
};
|
|
642
|
-
g.history?.pushState(next, '',
|
|
643
|
-
if (g.Event && g.dispatchEvent) {
|
|
644
|
-
g.dispatchEvent(new g.Event('pushState'));
|
|
645
|
-
}
|
|
1440
|
+
g.history?.pushState(next, '', visualUrl);
|
|
646
1441
|
}
|
|
647
|
-
replaceUrl(to) {
|
|
1442
|
+
replaceUrl(to, opts) {
|
|
648
1443
|
const g = globalThis;
|
|
649
1444
|
const st = g.history?.state ?? {};
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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;
|
|
1467
|
+
}
|
|
1468
|
+
const path = ar.path;
|
|
1469
|
+
const query = ar.query;
|
|
1470
|
+
if (!query || Object.keys(query).length === 0) {
|
|
1471
|
+
return path;
|
|
653
1472
|
}
|
|
1473
|
+
const search = qs.stringify(query);
|
|
1474
|
+
return `${path}${search && search !== '' ? `?${search}` : ''}`;
|
|
654
1475
|
}
|
|
655
|
-
|
|
1476
|
+
patchBrowserHistoryOnce() {
|
|
656
1477
|
const g = globalThis;
|
|
657
1478
|
const key = Symbol.for('sigmela_router_history_patch');
|
|
658
1479
|
if (g[key]) return;
|
|
@@ -677,42 +1498,78 @@ export class Router {
|
|
|
677
1498
|
}
|
|
678
1499
|
g[key] = true;
|
|
679
1500
|
}
|
|
680
|
-
lastBrowserIndex = 0;
|
|
681
|
-
pendingReplaceDedupe = false;
|
|
682
1501
|
setupBrowserHistory() {
|
|
683
1502
|
const g = globalThis;
|
|
684
|
-
|
|
1503
|
+
const gAny = g;
|
|
1504
|
+
const activeKey = Symbol.for('sigmela_router_active_instance');
|
|
1505
|
+
this.patchBrowserHistoryOnce();
|
|
685
1506
|
this.ensureHistoryIndex();
|
|
686
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;
|
|
687
1512
|
const onHistory = ev => {
|
|
688
|
-
|
|
1513
|
+
if (gAny[activeKey] !== this) return;
|
|
689
1514
|
if (ev.type === 'pushState') {
|
|
690
|
-
|
|
691
|
-
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');
|
|
692
1519
|
return;
|
|
693
1520
|
}
|
|
694
1521
|
if (ev.type === 'replaceState') {
|
|
695
|
-
|
|
696
|
-
|
|
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', {
|
|
697
1532
|
dedupe
|
|
698
1533
|
});
|
|
699
|
-
|
|
700
|
-
this.
|
|
1534
|
+
const idx = this.getHistoryIndexOrNull();
|
|
1535
|
+
this.lastBrowserIndex = idx !== null ? idx : this.lastBrowserIndex;
|
|
701
1536
|
return;
|
|
702
1537
|
}
|
|
703
1538
|
if (ev.type === 'popstate') {
|
|
704
|
-
const
|
|
705
|
-
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
|
+
}
|
|
706
1557
|
if (delta < 0) {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
const current = this.getVisibleRoute()?.path;
|
|
711
|
-
if (current !== target) this.performNavigation(url, 'replace');
|
|
1558
|
+
this.performNavigation(url, 'replace', {
|
|
1559
|
+
dedupe: true
|
|
1560
|
+
});
|
|
712
1561
|
} else if (delta > 0) {
|
|
1562
|
+
this.log('popstate: forward history step, treat as push', {
|
|
1563
|
+
url
|
|
1564
|
+
});
|
|
713
1565
|
this.performNavigation(url, 'push');
|
|
714
1566
|
} else {
|
|
715
|
-
this.
|
|
1567
|
+
this.log('popstate: same index, soft replace+dedupe', {
|
|
1568
|
+
url
|
|
1569
|
+
});
|
|
1570
|
+
this.performNavigation(url, 'replace', {
|
|
1571
|
+
dedupe: true
|
|
1572
|
+
});
|
|
716
1573
|
}
|
|
717
1574
|
this.lastBrowserIndex = idx;
|
|
718
1575
|
}
|
|
@@ -721,123 +1578,267 @@ export class Router {
|
|
|
721
1578
|
g.addEventListener?.('replaceState', onHistory);
|
|
722
1579
|
g.addEventListener?.('popstate', onHistory);
|
|
723
1580
|
}
|
|
724
|
-
|
|
725
|
-
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);
|
|
726
1623
|
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
+
}
|
|
738
1647
|
}
|
|
739
1648
|
}
|
|
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
|
-
}
|
|
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;
|
|
765
1657
|
}
|
|
766
|
-
return false;
|
|
767
1658
|
}
|
|
768
|
-
if (
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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;
|
|
776
1672
|
}
|
|
777
1673
|
}
|
|
778
|
-
|
|
1674
|
+
const top = stackHistory[stackHistory.length - 1];
|
|
1675
|
+
this.applyHistoryChange('pop', top);
|
|
1676
|
+
return top;
|
|
779
1677
|
}
|
|
780
|
-
|
|
781
|
-
// Expand deep URL into a stack chain on initial load
|
|
782
|
-
parse(url) {
|
|
1678
|
+
buildHistoryFromUrl(url, reuseKeysFrom) {
|
|
783
1679
|
const {
|
|
784
1680
|
pathname,
|
|
785
1681
|
query
|
|
786
1682
|
} = this.parsePath(url);
|
|
787
|
-
|
|
788
|
-
|
|
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) {
|
|
789
1693
|
if (__DEV__) {
|
|
790
|
-
|
|
1694
|
+
console.warn(`[Router] parse: no base route found for "${pathname}", seeding initial history`);
|
|
791
1695
|
}
|
|
792
1696
|
this.seedInitialHistory();
|
|
1697
|
+
this.recomputeActiveRoute();
|
|
1698
|
+
this.emit(this.listeners);
|
|
793
1699
|
return;
|
|
794
1700
|
}
|
|
795
|
-
|
|
796
|
-
|
|
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('/');
|
|
797
1705
|
}
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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;
|
|
815
1736
|
}
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
1737
|
+
if (!routeForPrefix) {
|
|
1738
|
+
this.log('parse: no route for prefix', {
|
|
1739
|
+
index,
|
|
1740
|
+
prefixPath
|
|
1741
|
+
});
|
|
1742
|
+
return;
|
|
819
1743
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
|
825
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
|
+
}
|
|
826
1793
|
}
|
|
827
|
-
if (!
|
|
1794
|
+
if (!items.length) {
|
|
1795
|
+
if (__DEV__) {
|
|
1796
|
+
console.warn('[Router] parse: no items built for URL, seeding initial history');
|
|
1797
|
+
}
|
|
828
1798
|
this.seedInitialHistory();
|
|
1799
|
+
this.recomputeActiveRoute();
|
|
1800
|
+
this.emit(this.listeners);
|
|
829
1801
|
return;
|
|
830
1802
|
}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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);
|
|
835
1807
|
this.setState({
|
|
836
1808
|
history: items
|
|
837
1809
|
});
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
+
}
|
|
841
1842
|
}
|
|
842
1843
|
}
|
|
843
1844
|
}
|