@ionic/react-router 8.7.13-dev.11765925297.1bb45463 → 8.7.13-dev.11766069240.1ab3dde2

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/dist/index.js CHANGED
@@ -1,176 +1,1125 @@
1
1
  import { __rest } from 'tslib';
2
- import { createBrowserHistory, createHashHistory } from 'history';
3
- import React from 'react';
4
- import { withRouter, Router } from 'react-router-dom';
2
+ import React, { useRef, useState, useEffect, useCallback } from 'react';
3
+ import { Route, matchPath as matchPath$1, Routes, Navigate, UNSAFE_RouteContext, useLocation, useNavigate, BrowserRouter, useNavigationType, HashRouter } from 'react-router-dom';
5
4
  import { ViewStacks, generateId, IonRoute, ViewLifeCycleManager, StackContext, RouteManagerContext, getConfig, LocationHistory, NavManager } from '@ionic/react';
6
- import { Route, matchPath as matchPath$1, Router as Router$1 } from 'react-router';
5
+ import { MemoryRouter, useLocation as useLocation$1, useNavigationType as useNavigationType$1 } from 'react-router';
7
6
 
8
- class IonRouteInner extends React.PureComponent {
9
- render() {
10
- return (React.createElement(Route, Object.assign({ path: this.props.path, exact: this.props.exact, render: this.props.render }, (this.props.computedMatch !== undefined
11
- ? {
12
- computedMatch: this.props.computedMatch,
7
+ const IonRouteInner = ({ path, element }) => {
8
+ return React.createElement(Route, { path: path, element: element });
9
+ };
10
+
11
+ /**
12
+ * The matchPath function is used only for matching paths, not rendering components or elements.
13
+ * @see https://reactrouter.com/v6/utils/match-path
14
+ */
15
+ const matchPath = ({ pathname, componentProps }) => {
16
+ var _a, _b;
17
+ const { path, index } = componentProps, restProps = __rest(componentProps, ["path", "index"]);
18
+ // Handle index routes - they match when pathname is empty or just "/"
19
+ if (index && !path) {
20
+ if (pathname === '' || pathname === '/') {
21
+ return {
22
+ params: {},
23
+ pathname: pathname,
24
+ pathnameBase: pathname || '/',
25
+ pattern: {
26
+ path: '',
27
+ caseSensitive: false,
28
+ end: true,
29
+ },
30
+ };
31
+ }
32
+ return null;
33
+ }
34
+ // Handle empty path routes - they match when pathname is also empty or just "/"
35
+ if (path === '' || path === undefined) {
36
+ if (pathname === '' || pathname === '/') {
37
+ return {
38
+ params: {},
39
+ pathname: pathname,
40
+ pathnameBase: pathname || '/',
41
+ pattern: {
42
+ path: '',
43
+ caseSensitive: (_a = restProps.caseSensitive) !== null && _a !== void 0 ? _a : false,
44
+ end: (_b = restProps.end) !== null && _b !== void 0 ? _b : true,
45
+ },
46
+ };
47
+ }
48
+ return null;
49
+ }
50
+ // For relative paths (don't start with '/'), normalize both path and pathname for matching
51
+ if (!path.startsWith('/')) {
52
+ const matchOptions = Object.assign({ path: `/${path}` }, restProps);
53
+ if ((matchOptions === null || matchOptions === void 0 ? void 0 : matchOptions.end) === undefined) {
54
+ matchOptions.end = !path.endsWith('*');
55
+ }
56
+ const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
57
+ const match = matchPath$1(matchOptions, normalizedPathname);
58
+ if (match) {
59
+ // Adjust the match to remove the leading '/' we added
60
+ return Object.assign(Object.assign({}, match), { pathname: pathname, pathnameBase: match.pathnameBase === '/' ? '' : match.pathnameBase.slice(1), pattern: Object.assign(Object.assign({}, match.pattern), { path: path }) });
61
+ }
62
+ return null;
63
+ }
64
+ // For absolute paths, use React Router's matcher directly.
65
+ // React Router v6 routes default to `end: true` unless the pattern
66
+ // explicitly opts into wildcards with `*`. Mirror that behaviour so
67
+ // matching parity stays aligned with <Route>.
68
+ const matchOptions = Object.assign({ path }, restProps);
69
+ if ((matchOptions === null || matchOptions === void 0 ? void 0 : matchOptions.end) === undefined) {
70
+ matchOptions.end = !path.endsWith('*');
71
+ }
72
+ return matchPath$1(matchOptions, pathname);
73
+ };
74
+ /**
75
+ * Determines the portion of a pathname that a given route pattern should match against.
76
+ * For absolute route patterns we return the full pathname. For relative patterns we
77
+ * strip off the already-matched parent segments so React Router receives the remainder.
78
+ */
79
+ const derivePathnameToMatch = (fullPathname, routePath) => {
80
+ var _a;
81
+ // For absolute or empty routes, use the full pathname as-is
82
+ if (!routePath || routePath === '' || routePath.startsWith('/')) {
83
+ return fullPathname;
84
+ }
85
+ const trimmedPath = fullPathname.startsWith('/') ? fullPathname.slice(1) : fullPathname;
86
+ if (!trimmedPath) {
87
+ // For root-level relative routes (pathname is "/" and routePath is relative),
88
+ // return the full pathname so matchPath can normalize both.
89
+ // This allows routes like <Route path="foo/*" .../> at root level to work correctly.
90
+ return fullPathname;
91
+ }
92
+ const fullSegments = trimmedPath.split('/').filter(Boolean);
93
+ if (fullSegments.length === 0) {
94
+ return '';
95
+ }
96
+ const routeSegments = routePath.split('/').filter(Boolean);
97
+ if (routeSegments.length === 0) {
98
+ return trimmedPath;
99
+ }
100
+ const wildcardIndex = routeSegments.findIndex((segment) => segment === '*' || segment === '**');
101
+ if (wildcardIndex >= 0) {
102
+ const baseSegments = routeSegments.slice(0, wildcardIndex);
103
+ if (baseSegments.length === 0) {
104
+ return trimmedPath;
105
+ }
106
+ const startIndex = fullSegments.findIndex((_, idx) => baseSegments.every((seg, segIdx) => {
107
+ const target = fullSegments[idx + segIdx];
108
+ if (!target) {
109
+ return false;
110
+ }
111
+ if (seg.startsWith(':')) {
112
+ return true;
13
113
  }
14
- : {}))));
114
+ return target === seg;
115
+ }));
116
+ if (startIndex >= 0) {
117
+ return fullSegments.slice(startIndex).join('/');
118
+ }
15
119
  }
16
- }
120
+ if (routeSegments.length <= fullSegments.length) {
121
+ return fullSegments.slice(fullSegments.length - routeSegments.length).join('/');
122
+ }
123
+ return (_a = fullSegments[fullSegments.length - 1]) !== null && _a !== void 0 ? _a : trimmedPath;
124
+ };
17
125
 
18
126
  /**
19
- * @see https://v5.reactrouter.com/web/api/matchPath
127
+ * Finds the longest common prefix among an array of paths.
128
+ * Used to determine the scope of an outlet with absolute routes.
129
+ *
130
+ * @param paths An array of absolute path strings.
131
+ * @returns The common prefix shared by all paths.
20
132
  */
21
- const matchPath = ({ pathname, componentProps, }) => {
22
- const { exact, component } = componentProps;
23
- const path = componentProps.path || componentProps.from;
24
- /***
25
- * The props to match against, they are identical
26
- * to the matching props `Route` accepts. It could also be a string
27
- * or an array of strings as shortcut for `{ path }`.
28
- */
29
- const matchProps = {
30
- exact,
31
- path,
32
- component,
33
- };
34
- const match = matchPath$1(pathname, matchProps);
35
- if (!match) {
133
+ const computeCommonPrefix = (paths) => {
134
+ if (paths.length === 0)
135
+ return '';
136
+ if (paths.length === 1) {
137
+ // For a single path, extract the directory-like prefix
138
+ // e.g., /dynamic-routes/home -> /dynamic-routes
139
+ const segments = paths[0].split('/').filter(Boolean);
140
+ if (segments.length > 1) {
141
+ return '/' + segments.slice(0, -1).join('/');
142
+ }
143
+ return '/' + segments[0];
144
+ }
145
+ // Split all paths into segments
146
+ const segmentArrays = paths.map((p) => p.split('/').filter(Boolean));
147
+ const minLength = Math.min(...segmentArrays.map((s) => s.length));
148
+ const commonSegments = [];
149
+ for (let i = 0; i < minLength; i++) {
150
+ const segment = segmentArrays[0][i];
151
+ // Skip segments with route parameters or wildcards
152
+ if (segment.includes(':') || segment.includes('*')) {
153
+ break;
154
+ }
155
+ const allMatch = segmentArrays.every((s) => s[i] === segment);
156
+ if (allMatch) {
157
+ commonSegments.push(segment);
158
+ }
159
+ else {
160
+ break;
161
+ }
162
+ }
163
+ return commonSegments.length > 0 ? '/' + commonSegments.join('/') : '';
164
+ };
165
+ /**
166
+ * Checks if a route path is a "splat-only" route (just `*` or `/*`).
167
+ */
168
+ const isSplatOnlyRoute = (routePath) => {
169
+ return routePath === '*' || routePath === '/*';
170
+ };
171
+ /**
172
+ * Checks if a route has an embedded wildcard (e.g., "tab1/*" but not "*" or "/*").
173
+ */
174
+ const hasEmbeddedWildcard = (routePath) => {
175
+ return !!routePath && routePath.includes('*') && !isSplatOnlyRoute(routePath);
176
+ };
177
+ /**
178
+ * Checks if a route with an embedded wildcard matches a pathname.
179
+ */
180
+ const matchesEmbeddedWildcardRoute = (route, pathname) => {
181
+ const routePath = route.props.path;
182
+ if (!hasEmbeddedWildcard(routePath)) {
36
183
  return false;
37
184
  }
38
- return match;
185
+ return !!matchPath({ pathname, componentProps: route.props });
186
+ };
187
+ /**
188
+ * Checks if a route is a specific match (not wildcard-only or index).
189
+ */
190
+ const isSpecificRouteMatch = (route, remainingPath) => {
191
+ const routePath = route.props.path;
192
+ if (route.props.index || isSplatOnlyRoute(routePath)) {
193
+ return false;
194
+ }
195
+ return !!matchPath({ pathname: remainingPath, componentProps: route.props });
196
+ };
197
+ /**
198
+ * Analyzes route children to determine their characteristics.
199
+ *
200
+ * @param routeChildren The route children to analyze.
201
+ * @returns Analysis of the route characteristics.
202
+ */
203
+ const analyzeRouteChildren = (routeChildren) => {
204
+ const hasRelativeRoutes = routeChildren.some((route) => {
205
+ const path = route.props.path;
206
+ return path && !path.startsWith('/') && path !== '*';
207
+ });
208
+ const hasIndexRoute = routeChildren.some((route) => route.props.index);
209
+ const hasWildcardRoute = routeChildren.some((route) => {
210
+ const routePath = route.props.path;
211
+ return routePath === '*' || routePath === '/*';
212
+ });
213
+ return { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute, routeChildren };
214
+ };
215
+ /**
216
+ * Checks if any route matches as a specific (non-wildcard, non-index) route.
217
+ */
218
+ const findSpecificMatch = (routeChildren, remainingPath) => {
219
+ return routeChildren.some((route) => isSpecificRouteMatch(route, remainingPath) || matchesEmbeddedWildcardRoute(route, remainingPath));
220
+ };
221
+ /**
222
+ * Checks if any specific route could plausibly match the remaining path.
223
+ * Used to determine if we should fall back to a wildcard match.
224
+ */
225
+ const couldSpecificRouteMatch = (routeChildren, remainingPath) => {
226
+ const remainingFirstSegment = remainingPath.split('/')[0];
227
+ return routeChildren.some((route) => {
228
+ const routePath = route.props.path;
229
+ if (!routePath || routePath === '*' || routePath === '/*')
230
+ return false;
231
+ if (route.props.index)
232
+ return false;
233
+ const routeFirstSegment = routePath.split('/')[0].replace(/[*:]/g, '');
234
+ if (!routeFirstSegment)
235
+ return false;
236
+ // Check for prefix overlap (either direction)
237
+ return (routeFirstSegment.startsWith(remainingFirstSegment.slice(0, 3)) ||
238
+ remainingFirstSegment.startsWith(routeFirstSegment.slice(0, 3)));
239
+ });
240
+ };
241
+ /**
242
+ * Checks for index route match when remaining path is empty.
243
+ * Index routes only match at the outlet's mount path level.
244
+ */
245
+ const checkIndexMatch = (parentPath, remainingPath, hasIndexRoute, outletMountPath) => {
246
+ if ((remainingPath === '' || remainingPath === '/') && hasIndexRoute) {
247
+ if (outletMountPath) {
248
+ // Index should only match at the existing mount path
249
+ return parentPath === outletMountPath ? parentPath : undefined;
250
+ }
251
+ // No mount path yet - this would establish it
252
+ return parentPath;
253
+ }
254
+ return undefined;
255
+ };
256
+ /**
257
+ * Determines the best parent path from the available matches.
258
+ * Priority: specific > wildcard > index
259
+ */
260
+ const selectBestMatch = (specificMatch, wildcardMatch, indexMatch) => {
261
+ var _a;
262
+ return (_a = specificMatch !== null && specificMatch !== void 0 ? specificMatch : wildcardMatch) !== null && _a !== void 0 ? _a : indexMatch;
263
+ };
264
+ /**
265
+ * Handles outlets with only absolute routes by computing their common prefix.
266
+ */
267
+ const computeAbsoluteRoutesParentPath = (routeChildren, currentPathname, outletMountPath) => {
268
+ const absolutePathRoutes = routeChildren.filter((route) => {
269
+ const path = route.props.path;
270
+ return path && path.startsWith('/');
271
+ });
272
+ if (absolutePathRoutes.length === 0) {
273
+ return undefined;
274
+ }
275
+ const absolutePaths = absolutePathRoutes.map((r) => r.props.path);
276
+ const commonPrefix = computeCommonPrefix(absolutePaths);
277
+ if (!commonPrefix || commonPrefix === '/') {
278
+ return undefined;
279
+ }
280
+ const newOutletMountPath = outletMountPath || commonPrefix;
281
+ if (!currentPathname.startsWith(commonPrefix)) {
282
+ return { parentPath: undefined, outletMountPath: newOutletMountPath };
283
+ }
284
+ return { parentPath: commonPrefix, outletMountPath: newOutletMountPath };
285
+ };
286
+ /**
287
+ * Computes the parent path for a nested outlet based on the current pathname
288
+ * and the outlet's route configuration.
289
+ *
290
+ * The algorithm finds the shortest parent path where a route matches the remaining path.
291
+ * Priority: specific routes > wildcard routes > index routes (only at mount point)
292
+ *
293
+ * @param options The options for computing the parent path.
294
+ * @returns The computed parent path result.
295
+ */
296
+ const computeParentPath = (options) => {
297
+ const { currentPathname, outletMountPath, routeChildren, hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = options;
298
+ // If pathname is outside the established mount path scope, skip computation
299
+ if (outletMountPath && !currentPathname.startsWith(outletMountPath)) {
300
+ return { parentPath: undefined, outletMountPath };
301
+ }
302
+ if ((hasRelativeRoutes || hasIndexRoute) && currentPathname.includes('/')) {
303
+ const segments = currentPathname.split('/').filter(Boolean);
304
+ if (segments.length >= 1) {
305
+ let firstSpecificMatch;
306
+ let firstWildcardMatch;
307
+ let indexMatchAtMount;
308
+ // Iterate through path segments to find the shortest matching parent path
309
+ for (let i = 1; i <= segments.length; i++) {
310
+ const parentPath = '/' + segments.slice(0, i).join('/');
311
+ const remainingPath = segments.slice(i).join('/');
312
+ // Check for specific route match (highest priority)
313
+ if (!firstSpecificMatch && findSpecificMatch(routeChildren, remainingPath)) {
314
+ firstSpecificMatch = parentPath;
315
+ break;
316
+ }
317
+ // Check for wildcard match (only if remaining path is non-empty)
318
+ const hasNonEmptyRemaining = remainingPath !== '' && remainingPath !== '/';
319
+ if (!firstWildcardMatch && hasNonEmptyRemaining && hasWildcardRoute) {
320
+ if (!couldSpecificRouteMatch(routeChildren, remainingPath)) {
321
+ firstWildcardMatch = parentPath;
322
+ }
323
+ }
324
+ // Check for index route match
325
+ const indexMatch = checkIndexMatch(parentPath, remainingPath, hasIndexRoute, outletMountPath);
326
+ if (indexMatch) {
327
+ indexMatchAtMount = indexMatch;
328
+ }
329
+ }
330
+ // Fallback: check root level for embedded wildcard routes (e.g., "tab1/*")
331
+ if (!firstSpecificMatch) {
332
+ const fullRemainingPath = segments.join('/');
333
+ if (routeChildren.some((route) => matchesEmbeddedWildcardRoute(route, fullRemainingPath))) {
334
+ firstSpecificMatch = '/';
335
+ }
336
+ }
337
+ const bestPath = selectBestMatch(firstSpecificMatch, firstWildcardMatch, indexMatchAtMount);
338
+ // Establish mount path on first successful match
339
+ const newOutletMountPath = outletMountPath || bestPath;
340
+ if (newOutletMountPath && !currentPathname.startsWith(newOutletMountPath)) {
341
+ return { parentPath: undefined, outletMountPath: newOutletMountPath };
342
+ }
343
+ return { parentPath: bestPath, outletMountPath: newOutletMountPath };
344
+ }
345
+ }
346
+ // Handle outlets with only absolute routes
347
+ if (!hasRelativeRoutes && !hasIndexRoute) {
348
+ const result = computeAbsoluteRoutesParentPath(routeChildren, currentPathname, outletMountPath);
349
+ if (result) {
350
+ return result;
351
+ }
352
+ }
353
+ return { parentPath: outletMountPath, outletMountPath };
354
+ };
355
+
356
+ /**
357
+ * Ensures the given path has a leading slash.
358
+ *
359
+ * @param value The path string to normalize.
360
+ * @returns The path with a leading slash.
361
+ */
362
+ const ensureLeadingSlash = (value) => {
363
+ if (value === '') {
364
+ return '/';
365
+ }
366
+ return value.startsWith('/') ? value : `/${value}`;
367
+ };
368
+ /**
369
+ * Strips the trailing slash from a path, unless it's the root path.
370
+ *
371
+ * @param value The path string to normalize.
372
+ * @returns The path without a trailing slash.
373
+ */
374
+ const stripTrailingSlash = (value) => {
375
+ return value.length > 1 && value.endsWith('/') ? value.slice(0, -1) : value;
376
+ };
377
+ /**
378
+ * Normalizes a pathname for comparison by ensuring a leading slash
379
+ * and removing trailing slashes.
380
+ *
381
+ * @param value The pathname to normalize, can be undefined.
382
+ * @returns A normalized pathname string.
383
+ */
384
+ const normalizePathnameForComparison = (value) => {
385
+ if (!value || value === '') {
386
+ return '/';
387
+ }
388
+ const withLeadingSlash = ensureLeadingSlash(value);
389
+ return stripTrailingSlash(withLeadingSlash);
39
390
  };
40
391
 
392
+ /**
393
+ * Extracts the children from a Routes wrapper component.
394
+ * The use of `<Routes />` is encouraged with React Router v6.
395
+ *
396
+ * @param node The React node to extract Routes children from.
397
+ * @returns The children of the Routes component, or undefined if not found.
398
+ */
399
+ const getRoutesChildren = (node) => {
400
+ let routesNode;
401
+ React.Children.forEach(node, (child) => {
402
+ if (child.type === Routes) {
403
+ routesNode = child;
404
+ }
405
+ });
406
+ if (routesNode) {
407
+ // The children of the `<Routes />` component are most likely
408
+ // (and should be) the `<Route />` components.
409
+ return routesNode.props.children;
410
+ }
411
+ return undefined;
412
+ };
413
+ /**
414
+ * Extracts Route children from a node (either directly or from a Routes wrapper).
415
+ *
416
+ * @param children The children to extract routes from.
417
+ * @returns An array of Route elements.
418
+ */
419
+ const extractRouteChildren = (children) => {
420
+ var _a;
421
+ const routesChildren = (_a = getRoutesChildren(children)) !== null && _a !== void 0 ? _a : children;
422
+ return React.Children.toArray(routesChildren).filter((child) => React.isValidElement(child) && child.type === Route);
423
+ };
424
+ /**
425
+ * Checks if a React element is a Navigate component (redirect).
426
+ *
427
+ * @param element The element to check.
428
+ * @returns True if the element is a Navigate component.
429
+ */
430
+ const isNavigateElement = (element) => {
431
+ return (React.isValidElement(element) &&
432
+ (element.type === Navigate || (typeof element.type === 'function' && element.type.name === 'Navigate')));
433
+ };
434
+
435
+ /**
436
+ * Sorts view items by route specificity (most specific first).
437
+ * - Exact matches (no wildcards/params) come first
438
+ * - Among wildcard routes, longer paths are more specific
439
+ *
440
+ * @param views The view items to sort.
441
+ * @returns A new sorted array of view items.
442
+ */
443
+ const sortViewsBySpecificity = (views) => {
444
+ return [...views].sort((a, b) => {
445
+ var _a, _b, _c, _d;
446
+ const pathA = ((_b = (_a = a.routeData) === null || _a === void 0 ? void 0 : _a.childProps) === null || _b === void 0 ? void 0 : _b.path) || '';
447
+ const pathB = ((_d = (_c = b.routeData) === null || _c === void 0 ? void 0 : _c.childProps) === null || _d === void 0 ? void 0 : _d.path) || '';
448
+ // Exact matches (no wildcards/params) come first
449
+ const aHasWildcard = pathA.includes('*') || pathA.includes(':');
450
+ const bHasWildcard = pathB.includes('*') || pathB.includes(':');
451
+ if (!aHasWildcard && bHasWildcard)
452
+ return -1;
453
+ if (aHasWildcard && !bHasWildcard)
454
+ return 1;
455
+ // Among wildcard routes, longer paths are more specific
456
+ return pathB.length - pathA.length;
457
+ });
458
+ };
459
+
460
+ /**
461
+ * `ReactRouterViewStack` is a custom navigation manager used in Ionic React
462
+ * apps to map React Router route elements (such as `<IonRoute>`) to "view
463
+ * items" that Ionic can manage in a view stack. This is critical to maintain
464
+ * Ionic’s animation, lifecycle, and history behavior across views.
465
+ */
466
+ /**
467
+ * Delay in milliseconds before removing a Navigate view item after a redirect.
468
+ * This ensures the redirect navigation completes before the view is removed.
469
+ */
470
+ const NAVIGATE_REDIRECT_DELAY_MS = 100;
471
+ /**
472
+ * Delay in milliseconds before cleaning up a view without an IonPage element.
473
+ * This double-checks that the view is truly not needed before removal.
474
+ */
475
+ const VIEW_CLEANUP_DELAY_MS = 200;
476
+ /**
477
+ * Computes the absolute pathnameBase for a route element based on its type.
478
+ * Handles relative paths, index routes, and splat routes differently.
479
+ */
480
+ const computeAbsolutePathnameBase = (routeElement, routeMatch, parentPathnameBase, routeInfoPathname) => {
481
+ const routePath = routeElement.props.path;
482
+ const isRelativePath = routePath && !routePath.startsWith('/');
483
+ const isIndexRoute = !!routeElement.props.index;
484
+ const isSplatOnlyRoute = routePath === '*' || routePath === '/*';
485
+ if (isSplatOnlyRoute) {
486
+ // Splat routes should NOT contribute their matched portion to pathnameBase
487
+ // This aligns with React Router v7's v7_relativeSplatPath behavior
488
+ return parentPathnameBase;
489
+ }
490
+ if (isRelativePath && (routeMatch === null || routeMatch === void 0 ? void 0 : routeMatch.pathnameBase)) {
491
+ const relativeBase = routeMatch.pathnameBase.startsWith('/')
492
+ ? routeMatch.pathnameBase.slice(1)
493
+ : routeMatch.pathnameBase;
494
+ return parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`;
495
+ }
496
+ if (isIndexRoute) {
497
+ return parentPathnameBase;
498
+ }
499
+ return (routeMatch === null || routeMatch === void 0 ? void 0 : routeMatch.pathnameBase) || routeInfoPathname;
500
+ };
501
+ /**
502
+ * Gets fallback params from view items in other outlets when parent context is empty.
503
+ * This handles cases where React context propagation doesn't work as expected.
504
+ */
505
+ const getFallbackParamsFromViewItems = (allViewItems, currentOutletId, currentPathname) => {
506
+ var _a;
507
+ const params = {};
508
+ for (const otherViewItem of allViewItems) {
509
+ if (otherViewItem.outletId === currentOutletId)
510
+ continue;
511
+ const otherMatch = (_a = otherViewItem.routeData) === null || _a === void 0 ? void 0 : _a.match;
512
+ if ((otherMatch === null || otherMatch === void 0 ? void 0 : otherMatch.params) && Object.keys(otherMatch.params).length > 0) {
513
+ const matchedPathname = otherMatch.pathnameBase || otherMatch.pathname;
514
+ if (matchedPathname && currentPathname.startsWith(matchedPathname)) {
515
+ Object.assign(params, otherMatch.params);
516
+ }
517
+ }
518
+ }
519
+ return params;
520
+ };
521
+ /**
522
+ * Builds the matches array for RouteContext.
523
+ */
524
+ const buildContextMatches = (parentMatches, combinedParams, routeMatch, routeInfoPathname, absolutePathnameBase, viewItem, routeElement, componentElement) => {
525
+ return [
526
+ ...parentMatches,
527
+ {
528
+ params: combinedParams,
529
+ pathname: (routeMatch === null || routeMatch === void 0 ? void 0 : routeMatch.pathname) || routeInfoPathname,
530
+ pathnameBase: absolutePathnameBase,
531
+ route: {
532
+ id: viewItem.id,
533
+ path: routeElement.props.path,
534
+ element: componentElement,
535
+ index: !!routeElement.props.index,
536
+ caseSensitive: routeElement.props.caseSensitive,
537
+ hasErrorBoundary: false,
538
+ },
539
+ },
540
+ ];
541
+ };
542
+ const createDefaultMatch = (fullPathname, routeProps) => {
543
+ var _a, _b;
544
+ const isIndexRoute = !!routeProps.index;
545
+ const patternPath = (_a = routeProps.path) !== null && _a !== void 0 ? _a : '';
546
+ const pathnameBase = fullPathname === '' ? '/' : fullPathname;
547
+ const computedEnd = routeProps.end !== undefined ? routeProps.end : patternPath !== '' ? !patternPath.endsWith('*') : true;
548
+ return {
549
+ params: {},
550
+ pathname: isIndexRoute ? '' : fullPathname,
551
+ pathnameBase,
552
+ pattern: {
553
+ path: patternPath,
554
+ caseSensitive: (_b = routeProps.caseSensitive) !== null && _b !== void 0 ? _b : false,
555
+ end: isIndexRoute ? true : computedEnd,
556
+ },
557
+ };
558
+ };
559
+ const computeRelativeToParent = (pathname, parentPath) => {
560
+ if (!parentPath)
561
+ return null;
562
+ const normalizedParent = normalizePathnameForComparison(parentPath);
563
+ const normalizedPathname = normalizePathnameForComparison(pathname);
564
+ if (normalizedPathname === normalizedParent) {
565
+ return '';
566
+ }
567
+ const withSlash = normalizedParent === '/' ? '/' : normalizedParent + '/';
568
+ if (normalizedPathname.startsWith(withSlash)) {
569
+ return normalizedPathname.slice(withSlash.length);
570
+ }
571
+ return null;
572
+ };
573
+ const resolveIndexRouteMatch = (viewItem, pathname, parentPath) => {
574
+ var _a, _b, _c;
575
+ if (!((_b = (_a = viewItem.routeData) === null || _a === void 0 ? void 0 : _a.childProps) === null || _b === void 0 ? void 0 : _b.index)) {
576
+ return null;
577
+ }
578
+ // Prefer computing against the parent path when available to align with RRv6 semantics
579
+ const relative = computeRelativeToParent(pathname, parentPath);
580
+ if (relative !== null) {
581
+ // Index routes match only when there is no remaining path
582
+ if (relative === '' || relative === '/') {
583
+ return createDefaultMatch(parentPath || pathname, viewItem.routeData.childProps);
584
+ }
585
+ return null;
586
+ }
587
+ // Fallback: use previously computed match base for equality check
588
+ const previousMatch = (_c = viewItem.routeData) === null || _c === void 0 ? void 0 : _c.match;
589
+ if (!previousMatch) {
590
+ return null;
591
+ }
592
+ const normalizedPathname = normalizePathnameForComparison(pathname);
593
+ const normalizedBase = normalizePathnameForComparison(previousMatch.pathnameBase || previousMatch.pathname || '');
594
+ return normalizedPathname === normalizedBase ? previousMatch : null;
595
+ };
41
596
  class ReactRouterViewStack extends ViewStacks {
42
597
  constructor() {
43
598
  super();
44
- this.createViewItem = this.createViewItem.bind(this);
45
- this.findViewItemByRouteInfo = this.findViewItemByRouteInfo.bind(this);
46
- this.findLeavingViewItemByRouteInfo = this.findLeavingViewItemByRouteInfo.bind(this);
47
- this.getChildrenToRender = this.getChildrenToRender.bind(this);
48
- this.findViewItemByPathname = this.findViewItemByPathname.bind(this);
49
- }
50
- createViewItem(outletId, reactElement, routeInfo, page) {
51
- const viewItem = {
52
- id: generateId('viewItem'),
53
- outletId,
54
- ionPageElement: page,
55
- reactElement,
56
- mount: true,
57
- ionRoute: false,
58
- };
59
- if (reactElement.type === IonRoute) {
60
- viewItem.ionRoute = true;
61
- viewItem.disableIonPageManagement = reactElement.props.disableIonPageManagement;
62
- }
63
- viewItem.routeData = {
64
- match: matchPath({
65
- pathname: routeInfo.pathname,
66
- componentProps: reactElement.props,
67
- }),
68
- childProps: reactElement.props,
69
- };
70
- return viewItem;
71
- }
72
- getChildrenToRender(outletId, ionRouterOutlet, routeInfo) {
73
- const viewItems = this.getViewItemsForOutlet(outletId);
74
- // Sync latest routes with viewItems
75
- React.Children.forEach(ionRouterOutlet.props.children, (child) => {
76
- const viewItem = viewItems.find((v) => {
77
- return matchComponent$1(child, v.routeData.childProps.path || v.routeData.childProps.from);
599
+ /**
600
+ * Creates a new view item for the given outlet and react route element.
601
+ * Associates route props with the matched route path for further lookups.
602
+ */
603
+ this.createViewItem = (outletId, reactElement, routeInfo, page) => {
604
+ var _a, _b;
605
+ const routePath = reactElement.props.path || '';
606
+ // Check if we already have a view item for this exact route that we can reuse
607
+ // Include wildcard routes like tabs/* since they should be reused
608
+ // Also check unmounted items that might have been preserved for browser navigation
609
+ const existingViewItem = this.getViewItemsForOutlet(outletId).find((v) => {
610
+ var _a, _b, _c, _d, _e, _f, _g, _h;
611
+ const existingRouteProps = (_b = (_a = v.reactElement) === null || _a === void 0 ? void 0 : _a.props) !== null && _b !== void 0 ? _b : {};
612
+ const existingPath = existingRouteProps.path || '';
613
+ const existingElement = existingRouteProps.element;
614
+ const newElement = reactElement.props.element;
615
+ const existingIsIndexRoute = !!existingRouteProps.index;
616
+ const newIsIndexRoute = !!reactElement.props.index;
617
+ // For Navigate components, match by destination
618
+ const existingIsNavigate = React.isValidElement(existingElement) && existingElement.type === Navigate;
619
+ const newIsNavigate = React.isValidElement(newElement) && newElement.type === Navigate;
620
+ if (existingIsNavigate && newIsNavigate) {
621
+ const existingTo = (_c = existingElement.props) === null || _c === void 0 ? void 0 : _c.to;
622
+ const newTo = (_d = newElement.props) === null || _d === void 0 ? void 0 : _d.to;
623
+ if (existingTo === newTo) {
624
+ return true;
625
+ }
626
+ }
627
+ if (existingIsIndexRoute && newIsIndexRoute) {
628
+ return true;
629
+ }
630
+ // Reuse view items with the same path
631
+ // Special case: reuse tabs/* and other specific wildcard routes
632
+ // Don't reuse index routes (empty path) or generic catch-all wildcards (*)
633
+ if (existingPath === routePath && existingPath !== '' && existingPath !== '*') {
634
+ // Parameterized routes need pathname matching to ensure /details/1 and /details/2
635
+ // get separate view items. For wildcard routes (e.g., user/:userId/*), compare
636
+ // pathnameBase to allow child path changes while preserving the parent view.
637
+ const hasParams = routePath.includes(':');
638
+ const isWildcard = routePath.includes('*');
639
+ if (hasParams) {
640
+ if (isWildcard) {
641
+ const existingPathnameBase = (_f = (_e = v.routeData) === null || _e === void 0 ? void 0 : _e.match) === null || _f === void 0 ? void 0 : _f.pathnameBase;
642
+ const newMatch = matchComponent$1(reactElement, routeInfo.pathname, false);
643
+ const newPathnameBase = newMatch === null || newMatch === void 0 ? void 0 : newMatch.pathnameBase;
644
+ if (existingPathnameBase !== newPathnameBase) {
645
+ return false;
646
+ }
647
+ }
648
+ else {
649
+ const existingPathname = (_h = (_g = v.routeData) === null || _g === void 0 ? void 0 : _g.match) === null || _h === void 0 ? void 0 : _h.pathname;
650
+ if (existingPathname !== routeInfo.pathname) {
651
+ return false;
652
+ }
653
+ }
654
+ }
655
+ return true;
656
+ }
657
+ // Also reuse specific wildcard routes like tabs/*
658
+ if (existingPath === routePath && existingPath.endsWith('/*') && existingPath !== '/*') {
659
+ return true;
660
+ }
661
+ return false;
78
662
  });
79
- if (viewItem) {
80
- viewItem.reactElement = child;
663
+ if (existingViewItem) {
664
+ // Update and ensure the existing view item is properly configured
665
+ existingViewItem.reactElement = reactElement;
666
+ existingViewItem.mount = true;
667
+ existingViewItem.ionPageElement = page || existingViewItem.ionPageElement;
668
+ const updatedMatch = matchComponent$1(reactElement, routeInfo.pathname, false) ||
669
+ ((_a = existingViewItem.routeData) === null || _a === void 0 ? void 0 : _a.match) ||
670
+ createDefaultMatch(routeInfo.pathname, reactElement.props);
671
+ existingViewItem.routeData = {
672
+ match: updatedMatch,
673
+ childProps: reactElement.props,
674
+ lastPathname: (_b = existingViewItem.routeData) === null || _b === void 0 ? void 0 : _b.lastPathname, // Preserve navigation history
675
+ };
676
+ return existingViewItem;
81
677
  }
82
- });
83
- const children = viewItems.map((viewItem) => {
84
- let clonedChild;
85
- if (viewItem.ionRoute && !viewItem.disableIonPageManagement) {
86
- clonedChild = (React.createElement(ViewLifeCycleManager, { key: `view-${viewItem.id}`, mount: viewItem.mount, removeView: () => this.remove(viewItem) }, React.cloneElement(viewItem.reactElement, {
87
- computedMatch: viewItem.routeData.match,
88
- })));
678
+ const id = `${outletId}-${generateId(outletId)}`;
679
+ const viewItem = {
680
+ id,
681
+ outletId,
682
+ ionPageElement: page,
683
+ reactElement,
684
+ mount: true,
685
+ ionRoute: true,
686
+ };
687
+ if (reactElement.type === IonRoute) {
688
+ viewItem.disableIonPageManagement = reactElement.props.disableIonPageManagement;
89
689
  }
90
- else {
91
- const match = matchComponent$1(viewItem.reactElement, routeInfo.pathname);
92
- clonedChild = (React.createElement(ViewLifeCycleManager, { key: `view-${viewItem.id}`, mount: viewItem.mount, removeView: () => this.remove(viewItem) }, React.cloneElement(viewItem.reactElement, {
93
- computedMatch: viewItem.routeData.match,
94
- })));
95
- if (!match && viewItem.routeData.match) {
96
- viewItem.routeData.match = undefined;
690
+ const initialMatch = matchComponent$1(reactElement, routeInfo.pathname, true) ||
691
+ createDefaultMatch(routeInfo.pathname, reactElement.props);
692
+ viewItem.routeData = {
693
+ match: initialMatch,
694
+ childProps: reactElement.props,
695
+ };
696
+ this.add(viewItem);
697
+ return viewItem;
698
+ };
699
+ /**
700
+ * Renders a ViewLifeCycleManager for the given view item.
701
+ * Handles cleanup if the view no longer matches.
702
+ *
703
+ * - Deactivates view if it no longer matches the current route
704
+ * - Wraps the route element in <Routes> to support nested routing and ensure remounting
705
+ * - Adds a unique key to <Routes> so React Router remounts routes when switching
706
+ */
707
+ this.renderViewItem = (viewItem, routeInfo, parentPath) => {
708
+ var _a, _b, _c, _d, _e, _f, _g, _h;
709
+ const routePath = viewItem.reactElement.props.path || '';
710
+ let match = matchComponent$1(viewItem.reactElement, routeInfo.pathname);
711
+ if (!match) {
712
+ const indexMatch = resolveIndexRouteMatch(viewItem, routeInfo.pathname, parentPath);
713
+ if (indexMatch) {
714
+ match = indexMatch;
715
+ }
716
+ }
717
+ // For parameterized routes, check if this is a navigation to a different path instance
718
+ // In that case, we should NOT reuse this view - a new view should be created
719
+ const isParameterRoute = routePath.includes(':');
720
+ const previousMatch = (_a = viewItem.routeData) === null || _a === void 0 ? void 0 : _a.match;
721
+ const isSamePath = (match === null || match === void 0 ? void 0 : match.pathname) === (previousMatch === null || previousMatch === void 0 ? void 0 : previousMatch.pathname);
722
+ // Flag to indicate this view should not be reused for this different parameterized path
723
+ const shouldSkipForDifferentParam = isParameterRoute && match && previousMatch && !isSamePath;
724
+ // Don't deactivate views automatically - let the StackManager handle view lifecycle
725
+ // This preserves views in the stack for navigation history like native apps
726
+ // Views will be hidden/shown by the StackManager's transition logic instead of being unmounted
727
+ // Special handling for Navigate components - they should unmount after redirecting
728
+ const elementComponent = (_c = (_b = viewItem.reactElement) === null || _b === void 0 ? void 0 : _b.props) === null || _c === void 0 ? void 0 : _c.element;
729
+ const isNavigateComponent = isNavigateElement(elementComponent);
730
+ if (isNavigateComponent) {
731
+ // Navigate components should only be mounted when they match
732
+ // Once they redirect (no longer match), they should be removed completely
733
+ // IMPORTANT: For index routes, we need to check indexMatch too since matchComponent
734
+ // may not properly match index routes without explicit parent path context
735
+ const indexMatch = ((_e = (_d = viewItem.routeData) === null || _d === void 0 ? void 0 : _d.childProps) === null || _e === void 0 ? void 0 : _e.index)
736
+ ? resolveIndexRouteMatch(viewItem, routeInfo.pathname, parentPath)
737
+ : null;
738
+ const hasValidMatch = match || indexMatch;
739
+ if (!hasValidMatch && viewItem.mount) {
97
740
  viewItem.mount = false;
741
+ // Schedule removal of the Navigate view item after a short delay
742
+ // This ensures the redirect completes before removal
743
+ setTimeout(() => {
744
+ this.remove(viewItem);
745
+ }, NAVIGATE_REDIRECT_DELAY_MS);
98
746
  }
99
747
  }
100
- return clonedChild;
101
- });
102
- return children;
103
- }
104
- findViewItemByRouteInfo(routeInfo, outletId, updateMatch) {
105
- const { viewItem, match } = this.findViewItemByPath(routeInfo.pathname, outletId);
106
- const shouldUpdateMatch = updateMatch === undefined || updateMatch === true;
107
- if (shouldUpdateMatch && viewItem && match) {
108
- viewItem.routeData.match = match;
109
- }
110
- return viewItem;
111
- }
112
- findLeavingViewItemByRouteInfo(routeInfo, outletId, mustBeIonRoute = true) {
113
- const { viewItem } = this.findViewItemByPath(routeInfo.lastPathname, outletId, mustBeIonRoute);
114
- return viewItem;
115
- }
116
- findViewItemByPathname(pathname, outletId) {
117
- const { viewItem } = this.findViewItemByPath(pathname, outletId);
118
- return viewItem;
748
+ // Components that don't have IonPage elements and no longer match should be cleaned up
749
+ // BUT we need to be careful not to remove them if they're part of browser navigation history
750
+ // This handles components that perform immediate actions like programmatic navigation
751
+ // EXCEPTION: Navigate components should ALWAYS remain mounted until they redirect
752
+ // since they need to be rendered to trigger the navigation
753
+ if (!match && viewItem.mount && !viewItem.ionPageElement && !isNavigateComponent) {
754
+ // Check if this view item should be preserved for browser navigation
755
+ // We'll keep it if it was recently active (within the last navigation)
756
+ const shouldPreserve = viewItem.routeData.lastPathname === routeInfo.pathname ||
757
+ ((_f = viewItem.routeData.match) === null || _f === void 0 ? void 0 : _f.pathname) === routeInfo.lastPathname;
758
+ if (!shouldPreserve) {
759
+ // This view item doesn't match and doesn't have an IonPage
760
+ // It's likely a utility component that performs an action and navigates away
761
+ viewItem.mount = false;
762
+ // Schedule removal to allow it to be recreated on next navigation
763
+ setTimeout(() => {
764
+ // Double-check before removing - the view might be needed again
765
+ const stillNotNeeded = !viewItem.mount && !viewItem.ionPageElement;
766
+ if (stillNotNeeded) {
767
+ this.remove(viewItem);
768
+ }
769
+ }, VIEW_CLEANUP_DELAY_MS);
770
+ }
771
+ else {
772
+ // Preserve it but unmount it for now
773
+ viewItem.mount = false;
774
+ }
775
+ }
776
+ // Reactivate view if it matches but was previously deactivated
777
+ // Don't reactivate if this is a parameterized route navigating to a different path instance
778
+ if (match && !viewItem.mount && !shouldSkipForDifferentParam) {
779
+ viewItem.mount = true;
780
+ viewItem.routeData.match = match;
781
+ }
782
+ // Deactivate wildcard routes and catch-all routes (empty path) when we have specific route matches
783
+ // This prevents "Not found" or fallback pages from showing alongside valid routes
784
+ if (routePath === '*' || routePath === '') {
785
+ // Check if any other view in this outlet has a match for the current route
786
+ const hasSpecificMatch = this.getViewItemsForOutlet(viewItem.outletId).some((v) => {
787
+ var _a, _b;
788
+ if (v.id === viewItem.id)
789
+ return false; // Skip self
790
+ const vRoutePath = ((_b = (_a = v.reactElement) === null || _a === void 0 ? void 0 : _a.props) === null || _b === void 0 ? void 0 : _b.path) || '';
791
+ if (vRoutePath === '*' || vRoutePath === '')
792
+ return false; // Skip other wildcard/empty routes
793
+ // Check if this view item would match the current route
794
+ const vMatch = v.reactElement ? matchComponent$1(v.reactElement, routeInfo.pathname) : null;
795
+ return !!vMatch;
796
+ });
797
+ if (hasSpecificMatch) {
798
+ viewItem.mount = false;
799
+ // Also hide the ion-page element immediately to prevent visual overlap
800
+ if (viewItem.ionPageElement) {
801
+ viewItem.ionPageElement.classList.add('ion-page-hidden');
802
+ viewItem.ionPageElement.setAttribute('aria-hidden', 'true');
803
+ }
804
+ }
805
+ }
806
+ const routeElement = React.cloneElement(viewItem.reactElement);
807
+ const componentElement = routeElement.props.element;
808
+ // Don't update match for parameterized routes navigating to different path instances
809
+ // This preserves the original match so that findViewItemByPath can correctly skip this view
810
+ if (match && viewItem.routeData.match !== match && !shouldSkipForDifferentParam) {
811
+ viewItem.routeData.match = match;
812
+ }
813
+ const routeMatch = shouldSkipForDifferentParam ? (_g = viewItem.routeData) === null || _g === void 0 ? void 0 : _g.match : match || ((_h = viewItem.routeData) === null || _h === void 0 ? void 0 : _h.match);
814
+ return (React.createElement(UNSAFE_RouteContext.Consumer, { key: `view-context-${viewItem.id}` }, (parentContext) => {
815
+ var _a, _b;
816
+ const parentMatches = ((_a = parentContext === null || parentContext === void 0 ? void 0 : parentContext.matches) !== null && _a !== void 0 ? _a : []);
817
+ // Accumulate params from parent matches, with fallback to other outlets
818
+ let accumulatedParentParams = parentMatches.reduce((acc, m) => (Object.assign(Object.assign({}, acc), m.params)), {});
819
+ if (parentMatches.length === 0 && Object.keys(accumulatedParentParams).length === 0) {
820
+ accumulatedParentParams = getFallbackParamsFromViewItems(this.getAllViewItems(), viewItem.outletId, routeInfo.pathname);
821
+ }
822
+ const combinedParams = Object.assign(Object.assign({}, accumulatedParentParams), ((_b = routeMatch === null || routeMatch === void 0 ? void 0 : routeMatch.params) !== null && _b !== void 0 ? _b : {}));
823
+ const parentPathnameBase = parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase : '/';
824
+ const absolutePathnameBase = computeAbsolutePathnameBase(routeElement, routeMatch, parentPathnameBase, routeInfo.pathname);
825
+ const contextMatches = buildContextMatches(parentMatches, combinedParams, routeMatch, routeInfo.pathname, absolutePathnameBase, viewItem, routeElement, componentElement);
826
+ const routeContextValue = parentContext
827
+ ? Object.assign(Object.assign({}, parentContext), { matches: contextMatches }) : { outlet: null, matches: contextMatches, isDataRoute: false };
828
+ return (React.createElement(ViewLifeCycleManager, { key: `view-${viewItem.id}`, mount: viewItem.mount, removeView: () => this.remove(viewItem) },
829
+ React.createElement(UNSAFE_RouteContext.Provider, { value: routeContextValue }, componentElement)));
830
+ }));
831
+ };
832
+ /**
833
+ * Re-renders all active view items for the specified outlet.
834
+ * Ensures React elements are updated with the latest match.
835
+ *
836
+ * 1. Iterates through children of IonRouterOutlet
837
+ * 2. Updates each matching viewItem with the current child React element
838
+ * (important for updating props or changes to elements)
839
+ * 3. Returns a list of React components that will be rendered inside the outlet
840
+ * Each view is wrapped in <ViewLifeCycleManager> to manage lifecycle and rendering
841
+ */
842
+ this.getChildrenToRender = (outletId, ionRouterOutlet, routeInfo) => {
843
+ const viewItems = this.getViewItemsForOutlet(outletId);
844
+ // Determine parentPath for nested outlets to properly evaluate index routes
845
+ let parentPath = undefined;
846
+ try {
847
+ // Only attempt parent path computation for non-root outlets
848
+ // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
849
+ const isRootOutlet = outletId.startsWith('routerOutlet');
850
+ if (!isRootOutlet) {
851
+ const routeChildren = extractRouteChildren(ionRouterOutlet.props.children);
852
+ const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
853
+ if (hasRelativeRoutes || hasIndexRoute) {
854
+ const result = computeParentPath({
855
+ currentPathname: routeInfo.pathname,
856
+ outletMountPath: undefined,
857
+ routeChildren,
858
+ hasRelativeRoutes,
859
+ hasIndexRoute,
860
+ hasWildcardRoute,
861
+ });
862
+ parentPath = result.parentPath;
863
+ }
864
+ }
865
+ }
866
+ catch (e) {
867
+ // Non-fatal: if we fail to compute parentPath, fall back to previous behavior
868
+ }
869
+ // Sync child elements with stored viewItems (e.g. to reflect new props)
870
+ React.Children.forEach(ionRouterOutlet.props.children, (child) => {
871
+ // Ensure the child is a valid React element since we
872
+ // might have whitespace strings or other non-element children
873
+ if (React.isValidElement(child)) {
874
+ // Find view item by exact path match to avoid wildcard routes overwriting specific routes
875
+ const childPath = child.props.path;
876
+ const viewItem = viewItems.find((v) => {
877
+ var _a, _b;
878
+ const viewItemPath = (_b = (_a = v.reactElement) === null || _a === void 0 ? void 0 : _a.props) === null || _b === void 0 ? void 0 : _b.path;
879
+ // Only update if paths match exactly (prevents wildcard routes from overwriting specific routes)
880
+ return viewItemPath === childPath;
881
+ });
882
+ if (viewItem) {
883
+ viewItem.reactElement = child;
884
+ }
885
+ }
886
+ });
887
+ // Filter out duplicate view items by ID (but keep all mounted items)
888
+ const uniqueViewItems = viewItems.filter((viewItem, index, array) => {
889
+ // Remove duplicates by ID (keep first occurrence)
890
+ const isFirstOccurrence = array.findIndex((v) => v.id === viewItem.id) === index;
891
+ return isFirstOccurrence;
892
+ });
893
+ // Filter out unmounted Navigate components to prevent them from being rendered
894
+ // and triggering unwanted redirects
895
+ const renderableViewItems = uniqueViewItems.filter((viewItem) => {
896
+ var _a, _b, _c, _d;
897
+ const elementComponent = (_b = (_a = viewItem.reactElement) === null || _a === void 0 ? void 0 : _a.props) === null || _b === void 0 ? void 0 : _b.element;
898
+ const isNavigateComponent = isNavigateElement(elementComponent);
899
+ // Exclude unmounted Navigate components from rendering
900
+ if (isNavigateComponent && !viewItem.mount) {
901
+ return false;
902
+ }
903
+ // Filter out views that are unmounted, have no ionPageElement, and don't match the current route.
904
+ // These are "stale" views from previous routes that should not be rendered.
905
+ // Views WITH ionPageElement are handled by the normal lifecycle events.
906
+ // Views that MATCH the current route should be kept (they might be transitioning).
907
+ if (!viewItem.mount && !viewItem.ionPageElement) {
908
+ // Check if this view's route path matches the current pathname
909
+ const viewRoutePath = (_d = (_c = viewItem.reactElement) === null || _c === void 0 ? void 0 : _c.props) === null || _d === void 0 ? void 0 : _d.path;
910
+ if (viewRoutePath) {
911
+ // First try exact match using matchComponent
912
+ const routeMatch = matchComponent$1(viewItem.reactElement, routeInfo.pathname);
913
+ if (routeMatch) {
914
+ // View matches current route, keep it
915
+ return true;
916
+ }
917
+ // For parent routes (like /multiple-tabs or /routing), check if current pathname
918
+ // starts with this route's path. This handles views with IonSplitPane/IonTabs
919
+ // that don't have IonPage but should remain mounted while navigating within their children.
920
+ const normalizedViewPath = normalizePathnameForComparison(viewRoutePath.replace(/\/?\*$/, '')); // Remove trailing wildcard
921
+ const normalizedCurrentPath = normalizePathnameForComparison(routeInfo.pathname);
922
+ // Check if current pathname is within this view's route hierarchy
923
+ const isWithinRouteHierarchy = normalizedCurrentPath === normalizedViewPath || normalizedCurrentPath.startsWith(normalizedViewPath + '/');
924
+ if (!isWithinRouteHierarchy) {
925
+ // View is outside current route hierarchy, remove it
926
+ setTimeout(() => {
927
+ this.remove(viewItem);
928
+ }, 0);
929
+ return false;
930
+ }
931
+ }
932
+ }
933
+ return true;
934
+ });
935
+ const renderedItems = renderableViewItems.map((viewItem) => this.renderViewItem(viewItem, routeInfo, parentPath));
936
+ return renderedItems;
937
+ };
938
+ /**
939
+ * Finds a view item matching the current route, optionally updating its match state.
940
+ */
941
+ this.findViewItemByRouteInfo = (routeInfo, outletId, updateMatch) => {
942
+ const { viewItem, match } = this.findViewItemByPath(routeInfo.pathname, outletId);
943
+ const shouldUpdateMatch = updateMatch === undefined || updateMatch === true;
944
+ if (shouldUpdateMatch && viewItem && match) {
945
+ viewItem.routeData.match = match;
946
+ }
947
+ return viewItem;
948
+ };
949
+ /**
950
+ * Finds the view item that was previously active before a route change.
951
+ */
952
+ this.findLeavingViewItemByRouteInfo = (routeInfo, outletId, mustBeIonRoute = true) => {
953
+ // If the lastPathname is not set, we cannot find a leaving view item
954
+ if (!routeInfo.lastPathname) {
955
+ return undefined;
956
+ }
957
+ const { viewItem } = this.findViewItemByPath(routeInfo.lastPathname, outletId, mustBeIonRoute);
958
+ return viewItem;
959
+ };
960
+ /**
961
+ * Finds a view item by pathname only, used in simpler queries.
962
+ */
963
+ this.findViewItemByPathname = (pathname, outletId) => {
964
+ const { viewItem } = this.findViewItemByPath(pathname, outletId);
965
+ return viewItem;
966
+ };
967
+ /**
968
+ * Clean up old, unmounted view items to prevent memory leaks
969
+ */
970
+ this.cleanupStaleViewItems = (outletId) => {
971
+ const viewItems = this.getViewItemsForOutlet(outletId);
972
+ // Keep only the most recent mounted views and a few unmounted ones for history
973
+ const maxUnmountedItems = 3;
974
+ const unmountedItems = viewItems.filter((v) => !v.mount);
975
+ if (unmountedItems.length > maxUnmountedItems) {
976
+ // Remove oldest unmounted items
977
+ const itemsToRemove = unmountedItems.slice(0, unmountedItems.length - maxUnmountedItems);
978
+ itemsToRemove.forEach((item) => {
979
+ this.remove(item);
980
+ });
981
+ }
982
+ };
983
+ /**
984
+ * Override add to prevent duplicate view items with the same ID in the same outlet
985
+ * But allow multiple view items for the same route path (for navigation history)
986
+ */
987
+ this.add = (viewItem) => {
988
+ const existingViewItem = this.getViewItemsForOutlet(viewItem.outletId).find((v) => v.id === viewItem.id);
989
+ if (existingViewItem) {
990
+ return;
991
+ }
992
+ super.add(viewItem);
993
+ this.cleanupStaleViewItems(viewItem.outletId);
994
+ };
995
+ /**
996
+ * Override remove
997
+ */
998
+ this.remove = (viewItem) => {
999
+ super.remove(viewItem);
1000
+ };
119
1001
  }
120
1002
  /**
121
- * Returns the matching view item and the match result for a given pathname.
1003
+ * Core function that matches a given pathname against all view items.
1004
+ * Returns both the matched view item and match metadata.
122
1005
  */
123
- findViewItemByPath(pathname, outletId, mustBeIonRoute) {
1006
+ findViewItemByPath(pathname, outletId, mustBeIonRoute, allowDefaultMatch = true) {
124
1007
  let viewItem;
125
- let match;
1008
+ let match = null;
126
1009
  let viewStack;
127
1010
  if (outletId) {
128
- viewStack = this.getViewItemsForOutlet(outletId);
1011
+ viewStack = sortViewsBySpecificity(this.getViewItemsForOutlet(outletId));
129
1012
  viewStack.some(matchView);
130
- if (!viewItem) {
1013
+ if (!viewItem && allowDefaultMatch)
131
1014
  viewStack.some(matchDefaultRoute);
132
- }
133
1015
  }
134
1016
  else {
135
- const viewItems = this.getAllViewItems();
1017
+ const viewItems = sortViewsBySpecificity(this.getAllViewItems());
136
1018
  viewItems.some(matchView);
137
- if (!viewItem) {
1019
+ if (!viewItem && allowDefaultMatch)
138
1020
  viewItems.some(matchDefaultRoute);
139
- }
140
1021
  }
1022
+ // If we still have not found a view item for this outlet, try to find a matching
1023
+ // view item across all outlets and adopt it into the current outlet. This helps
1024
+ // recover when an outlet remounts and receives a new id, leaving views associated
1025
+ // with the previous outlet id.
1026
+ // Do not adopt across outlets; if we didn't find a view for this outlet,
1027
+ // defer to route matching to create a new one.
141
1028
  return { viewItem, match };
1029
+ /**
1030
+ * Matches a route path with dynamic parameters (e.g. /tabs/:id)
1031
+ */
142
1032
  function matchView(v) {
143
- var _a, _b;
144
- if (mustBeIonRoute && !v.ionRoute) {
1033
+ var _a;
1034
+ if (mustBeIonRoute && !v.ionRoute)
145
1035
  return false;
1036
+ const viewItemPath = v.routeData.childProps.path || '';
1037
+ const isIndexRoute = !!v.routeData.childProps.index;
1038
+ const previousMatch = (_a = v.routeData) === null || _a === void 0 ? void 0 : _a.match;
1039
+ const result = v.reactElement ? matchComponent$1(v.reactElement, pathname) : null;
1040
+ if (!result) {
1041
+ const indexMatch = resolveIndexRouteMatch(v, pathname, undefined);
1042
+ if (indexMatch) {
1043
+ match = indexMatch;
1044
+ viewItem = v;
1045
+ return true;
1046
+ }
146
1047
  }
147
- match = matchPath({
148
- pathname,
149
- componentProps: v.routeData.childProps,
150
- });
151
- if (match) {
152
- /**
153
- * Even though we have a match from react-router, we do not know if the match
154
- * is for this specific view item.
155
- *
156
- * To validate this, we need to check if the path and url match the view item's route data.
157
- */
158
- const hasParameter = match.path.includes(':');
159
- if (!hasParameter || (hasParameter && match.url === ((_b = (_a = v.routeData) === null || _a === void 0 ? void 0 : _a.match) === null || _b === void 0 ? void 0 : _b.url))) {
1048
+ if (result) {
1049
+ const hasParams = result.params && Object.keys(result.params).length > 0;
1050
+ const isSamePath = result.pathname === (previousMatch === null || previousMatch === void 0 ? void 0 : previousMatch.pathname);
1051
+ const isWildcardRoute = viewItemPath.includes('*');
1052
+ const isParameterRoute = viewItemPath.includes(':');
1053
+ // Don't allow view items with undefined paths to match specific routes
1054
+ // This prevents broken index route view items from interfering with navigation
1055
+ if (!viewItemPath && !isIndexRoute && pathname !== '/' && pathname !== '') {
1056
+ return false;
1057
+ }
1058
+ // For parameterized routes, check if we should reuse the view item.
1059
+ // Wildcard routes (e.g., user/:userId/*) compare pathnameBase to allow
1060
+ // child path changes while preserving the parent view.
1061
+ if (isParameterRoute && !isSamePath) {
1062
+ if (isWildcardRoute) {
1063
+ const isSameBase = result.pathnameBase === (previousMatch === null || previousMatch === void 0 ? void 0 : previousMatch.pathnameBase);
1064
+ if (isSameBase) {
1065
+ match = result;
1066
+ viewItem = v;
1067
+ return true;
1068
+ }
1069
+ }
1070
+ return false;
1071
+ }
1072
+ // For routes without params, or when navigating to the exact same path,
1073
+ // or when there's no previous match, reuse the view item
1074
+ if (!hasParams || isSamePath || !previousMatch) {
1075
+ match = result;
1076
+ viewItem = v;
1077
+ return true;
1078
+ }
1079
+ // For wildcard routes (without params), only reuse if the pathname exactly matches
1080
+ if (isWildcardRoute && isSamePath) {
1081
+ match = result;
160
1082
  viewItem = v;
161
1083
  return true;
162
1084
  }
163
1085
  }
164
1086
  return false;
165
1087
  }
1088
+ /**
1089
+ * Matches a view with no path prop (default fallback route) or index route.
1090
+ */
166
1091
  function matchDefaultRoute(v) {
167
- // try to find a route that doesn't have a path or from prop, that will be our default route
168
- if (!v.routeData.childProps.path && !v.routeData.childProps.from) {
1092
+ var _a, _b, _c;
1093
+ const childProps = v.routeData.childProps;
1094
+ const isDefaultRoute = childProps.path === undefined || childProps.path === '';
1095
+ const isIndexRoute = !!childProps.index;
1096
+ if (isIndexRoute) {
1097
+ const indexMatch = resolveIndexRouteMatch(v, pathname, undefined);
1098
+ if (indexMatch) {
1099
+ match = indexMatch;
1100
+ viewItem = v;
1101
+ return true;
1102
+ }
1103
+ return false;
1104
+ }
1105
+ // For empty path routes, only match if we're at the same level as when the view was created.
1106
+ // This prevents an empty path view item from being reused for different routes.
1107
+ if (isDefaultRoute) {
1108
+ const previousPathnameBase = ((_b = (_a = v.routeData) === null || _a === void 0 ? void 0 : _a.match) === null || _b === void 0 ? void 0 : _b.pathnameBase) || '';
1109
+ const normalizedBase = normalizePathnameForComparison(previousPathnameBase);
1110
+ const normalizedPathname = normalizePathnameForComparison(pathname);
1111
+ if (normalizedPathname !== normalizedBase) {
1112
+ return false;
1113
+ }
169
1114
  match = {
170
- path: pathname,
171
- url: pathname,
172
- isExact: true,
173
1115
  params: {},
1116
+ pathname,
1117
+ pathnameBase: pathname === '' ? '/' : pathname,
1118
+ pattern: {
1119
+ path: '',
1120
+ caseSensitive: (_c = childProps.caseSensitive) !== null && _c !== void 0 ? _c : false,
1121
+ end: true,
1122
+ },
174
1123
  };
175
1124
  viewItem = v;
176
1125
  return true;
@@ -179,11 +1128,29 @@ class ReactRouterViewStack extends ViewStacks {
179
1128
  }
180
1129
  }
181
1130
  }
182
- function matchComponent$1(node, pathname) {
183
- return matchPath({
184
- pathname,
185
- componentProps: node.props,
1131
+ /**
1132
+ * Utility to apply matchPath to a React element and return its match state.
1133
+ */
1134
+ function matchComponent$1(node, pathname, allowFallback = false) {
1135
+ var _a;
1136
+ const routeProps = (_a = node === null || node === void 0 ? void 0 : node.props) !== null && _a !== void 0 ? _a : {};
1137
+ const routePath = routeProps.path;
1138
+ const pathnameToMatch = derivePathnameToMatch(pathname, routePath);
1139
+ const match = matchPath({
1140
+ pathname: pathnameToMatch,
1141
+ componentProps: routeProps,
186
1142
  });
1143
+ if (match || !allowFallback) {
1144
+ return match;
1145
+ }
1146
+ const isIndexRoute = !!routeProps.index;
1147
+ if (isIndexRoute) {
1148
+ return createDefaultMatch(pathname, routeProps);
1149
+ }
1150
+ if (!routePath || routePath === '') {
1151
+ return createDefaultMatch(pathname, routeProps);
1152
+ }
1153
+ return null;
187
1154
  }
188
1155
 
189
1156
  function clonePageElement(leavingViewHtml) {
@@ -208,7 +1175,40 @@ function clonePageElement(leavingViewHtml) {
208
1175
  return undefined;
209
1176
  }
210
1177
 
1178
+ /**
1179
+ * `StackManager` is responsible for managing page transitions, keeping track
1180
+ * of views (pages), and ensuring that navigation behaves like native apps —
1181
+ * particularly with animations and swipe gestures.
1182
+ */
1183
+ /**
1184
+ * Delay in milliseconds before unmounting a view after a transition completes.
1185
+ * This ensures the page transition animation finishes before the view is removed.
1186
+ */
1187
+ const VIEW_UNMOUNT_DELAY_MS = 250;
1188
+ /**
1189
+ * Delay in milliseconds to wait for an IonPage element to be mounted before
1190
+ * proceeding with a page transition.
1191
+ */
1192
+ const ION_PAGE_WAIT_TIMEOUT_MS = 50;
211
1193
  const isViewVisible = (el) => !el.classList.contains('ion-page-invisible') && !el.classList.contains('ion-page-hidden');
1194
+ /**
1195
+ * Hides an ion-page element by adding hidden class and aria attribute.
1196
+ */
1197
+ const hideIonPageElement = (element) => {
1198
+ if (element) {
1199
+ element.classList.add('ion-page-hidden');
1200
+ element.setAttribute('aria-hidden', 'true');
1201
+ }
1202
+ };
1203
+ /**
1204
+ * Shows an ion-page element by removing hidden class and aria attribute.
1205
+ */
1206
+ const showIonPageElement = (element) => {
1207
+ if (element) {
1208
+ element.classList.remove('ion-page-hidden');
1209
+ element.removeAttribute('aria-hidden');
1210
+ }
1211
+ };
212
1212
  class StackManager extends React.PureComponent {
213
1213
  constructor(props) {
214
1214
  super(props);
@@ -217,13 +1217,491 @@ class StackManager extends React.PureComponent {
217
1217
  isInOutlet: () => true,
218
1218
  };
219
1219
  this.pendingPageTransition = false;
1220
+ this.waitingForIonPage = false;
1221
+ this.outletMountPath = undefined;
220
1222
  this.registerIonPage = this.registerIonPage.bind(this);
221
1223
  this.transitionPage = this.transitionPage.bind(this);
222
1224
  this.handlePageTransition = this.handlePageTransition.bind(this);
223
- this.id = generateId('routerOutlet');
1225
+ this.id = props.id || `routerOutlet-${generateId('routerOutlet')}`;
224
1226
  this.prevProps = undefined;
225
1227
  this.skipTransition = false;
226
1228
  }
1229
+ /**
1230
+ * Determines the parent path that was matched to reach this outlet.
1231
+ * This helps with nested routing in React Router 6.
1232
+ *
1233
+ * The algorithm finds the shortest parent path where a route matches the remaining path.
1234
+ * Priority: specific routes > wildcard routes > index routes (only at mount point)
1235
+ */
1236
+ getParentPath() {
1237
+ const currentPathname = this.props.routeInfo.pathname;
1238
+ // If this outlet previously established a mount path and the current
1239
+ // pathname is outside of that scope, do not attempt to re-compute a new
1240
+ // parent path. This prevents out-of-scope outlets from "adopting"
1241
+ // unrelated routes (e.g., matching their index route under /overlays).
1242
+ if (this.outletMountPath && !currentPathname.startsWith(this.outletMountPath)) {
1243
+ return undefined;
1244
+ }
1245
+ // Check if this outlet has route children to analyze
1246
+ if (this.ionRouterOutlet) {
1247
+ const routeChildren = extractRouteChildren(this.ionRouterOutlet.props.children);
1248
+ const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
1249
+ // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
1250
+ // But even outlets with auto-generated IDs may need parent path computation
1251
+ // if they have relative routes (indicating they're nested outlets)
1252
+ const isRootOutlet = this.id.startsWith('routerOutlet');
1253
+ const needsParentPath = !isRootOutlet || hasRelativeRoutes || hasIndexRoute;
1254
+ if (needsParentPath) {
1255
+ const result = computeParentPath({
1256
+ currentPathname,
1257
+ outletMountPath: this.outletMountPath,
1258
+ routeChildren,
1259
+ hasRelativeRoutes,
1260
+ hasIndexRoute,
1261
+ hasWildcardRoute,
1262
+ });
1263
+ // Update the outlet mount path if it was set
1264
+ if (result.outletMountPath && !this.outletMountPath) {
1265
+ this.outletMountPath = result.outletMountPath;
1266
+ }
1267
+ return result.parentPath;
1268
+ }
1269
+ }
1270
+ return this.outletMountPath;
1271
+ }
1272
+ /**
1273
+ * Finds the entering and leaving view items for a route transition,
1274
+ * handling special redirect cases.
1275
+ */
1276
+ findViewItems(routeInfo) {
1277
+ const enteringViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id);
1278
+ let leavingViewItem = this.context.findLeavingViewItemByRouteInfo(routeInfo, this.id);
1279
+ // If we don't have a leaving view item, but the route info indicates
1280
+ // that the user has routed from a previous path, then the leaving view
1281
+ // can be found by the last known pathname.
1282
+ if (!leavingViewItem && routeInfo.prevRouteLastPathname) {
1283
+ leavingViewItem = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id);
1284
+ }
1285
+ // Special case for redirects: When a redirect happens inside a nested route,
1286
+ // the entering and leaving view might be the same (the container route like tabs/*).
1287
+ // In this case, we need to look at prevRouteLastPathname to find the actual
1288
+ // view we're transitioning away from.
1289
+ if (enteringViewItem &&
1290
+ leavingViewItem &&
1291
+ enteringViewItem === leavingViewItem &&
1292
+ routeInfo.routeAction === 'replace' &&
1293
+ routeInfo.prevRouteLastPathname) {
1294
+ const actualLeavingView = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id);
1295
+ if (actualLeavingView && actualLeavingView !== enteringViewItem) {
1296
+ leavingViewItem = actualLeavingView;
1297
+ }
1298
+ }
1299
+ // Also check if we're in a redirect scenario where entering and leaving are different
1300
+ // but we still need to handle the actual previous view.
1301
+ if (enteringViewItem &&
1302
+ !leavingViewItem &&
1303
+ routeInfo.routeAction === 'replace' &&
1304
+ routeInfo.prevRouteLastPathname) {
1305
+ const actualLeavingView = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id);
1306
+ if (actualLeavingView && actualLeavingView !== enteringViewItem) {
1307
+ leavingViewItem = actualLeavingView;
1308
+ }
1309
+ }
1310
+ return { enteringViewItem, leavingViewItem };
1311
+ }
1312
+ /**
1313
+ * Determines if the leaving view item should be unmounted after a transition.
1314
+ */
1315
+ shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem) {
1316
+ var _a, _b, _c, _d;
1317
+ if (!leavingViewItem) {
1318
+ return false;
1319
+ }
1320
+ if (routeInfo.routeAction === 'replace') {
1321
+ const enteringRoutePath = (_b = (_a = enteringViewItem === null || enteringViewItem === void 0 ? void 0 : enteringViewItem.reactElement) === null || _a === void 0 ? void 0 : _a.props) === null || _b === void 0 ? void 0 : _b.path;
1322
+ const leavingRoutePath = (_d = (_c = leavingViewItem === null || leavingViewItem === void 0 ? void 0 : leavingViewItem.reactElement) === null || _c === void 0 ? void 0 : _c.props) === null || _d === void 0 ? void 0 : _d.path;
1323
+ // Never unmount the root path "/" - it's the main entry point for back navigation
1324
+ if (leavingRoutePath === '/' || leavingRoutePath === '') {
1325
+ return false;
1326
+ }
1327
+ if (enteringRoutePath && leavingRoutePath) {
1328
+ // Get parent paths to check if routes share a common parent
1329
+ const getParentPath = (path) => {
1330
+ const normalized = path.replace(/\/\*$/, ''); // Remove trailing /*
1331
+ const lastSlash = normalized.lastIndexOf('/');
1332
+ return lastSlash > 0 ? normalized.substring(0, lastSlash) : '/';
1333
+ };
1334
+ const enteringParent = getParentPath(enteringRoutePath);
1335
+ const leavingParent = getParentPath(leavingRoutePath);
1336
+ // Unmount if:
1337
+ // 1. Routes are siblings (same parent, e.g., /page1 and /page2, or /foo/page1 and /foo/page2)
1338
+ // 2. Entering is a child of leaving (redirect, e.g., /tabs -> /tabs/tab1)
1339
+ const areSiblings = enteringParent === leavingParent && enteringParent !== '/';
1340
+ const isChildRedirect = enteringRoutePath.startsWith(leavingRoutePath) ||
1341
+ (leavingRoutePath.endsWith('/*') && enteringRoutePath.startsWith(leavingRoutePath.slice(0, -2)));
1342
+ return areSiblings || isChildRedirect;
1343
+ }
1344
+ return false;
1345
+ }
1346
+ // For non-replace actions, only unmount for back navigation (not forward push)
1347
+ const isForwardPush = routeInfo.routeAction === 'push' && routeInfo.routeDirection === 'forward';
1348
+ if (!isForwardPush && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) {
1349
+ return true;
1350
+ }
1351
+ return false;
1352
+ }
1353
+ /**
1354
+ * Handles the case when the outlet is out of scope (current route is outside mount path).
1355
+ * Returns true if the transition should be aborted.
1356
+ */
1357
+ handleOutOfScopeOutlet(routeInfo) {
1358
+ if (!this.outletMountPath || routeInfo.pathname.startsWith(this.outletMountPath)) {
1359
+ return false;
1360
+ }
1361
+ // Clear any pending unmount timeout to avoid conflicts
1362
+ if (this.outOfScopeUnmountTimeout) {
1363
+ clearTimeout(this.outOfScopeUnmountTimeout);
1364
+ this.outOfScopeUnmountTimeout = undefined;
1365
+ }
1366
+ // When an outlet is out of scope, unmount its views immediately
1367
+ const allViewsInOutlet = this.context.getViewItemsForOutlet ? this.context.getViewItemsForOutlet(this.id) : [];
1368
+ // Unmount and remove all views in this outlet immediately to avoid leftover content
1369
+ allViewsInOutlet.forEach((viewItem) => {
1370
+ hideIonPageElement(viewItem.ionPageElement);
1371
+ this.context.unMountViewItem(viewItem);
1372
+ });
1373
+ this.forceUpdate();
1374
+ return true;
1375
+ }
1376
+ /**
1377
+ * Handles the case when this is a nested outlet with relative routes but no valid parent path.
1378
+ * Returns true if the transition should be aborted.
1379
+ */
1380
+ handleOutOfContextNestedOutlet(parentPath, leavingViewItem) {
1381
+ var _a;
1382
+ // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
1383
+ const isRootOutlet = this.id.startsWith('routerOutlet');
1384
+ if (isRootOutlet || parentPath !== undefined || !this.ionRouterOutlet) {
1385
+ return false;
1386
+ }
1387
+ const routesChildren = (_a = getRoutesChildren(this.ionRouterOutlet.props.children)) !== null && _a !== void 0 ? _a : this.ionRouterOutlet.props.children;
1388
+ const routeChildren = React.Children.toArray(routesChildren).filter((child) => React.isValidElement(child) && child.type === Route);
1389
+ const hasRelativeRoutes = routeChildren.some((route) => {
1390
+ const path = route.props.path;
1391
+ return path && !path.startsWith('/') && path !== '*';
1392
+ });
1393
+ if (hasRelativeRoutes) {
1394
+ // Hide any visible views in this outlet since it's out of scope
1395
+ hideIonPageElement(leavingViewItem === null || leavingViewItem === void 0 ? void 0 : leavingViewItem.ionPageElement);
1396
+ if (leavingViewItem) {
1397
+ leavingViewItem.mount = false;
1398
+ }
1399
+ this.forceUpdate();
1400
+ return true;
1401
+ }
1402
+ return false;
1403
+ }
1404
+ /**
1405
+ * Handles the case when a nested outlet has no matching route.
1406
+ * Returns true if the transition should be aborted.
1407
+ */
1408
+ handleNoMatchingRoute(enteringRoute, enteringViewItem, leavingViewItem) {
1409
+ // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
1410
+ const isRootOutlet = this.id.startsWith('routerOutlet');
1411
+ if (isRootOutlet || enteringRoute || enteringViewItem) {
1412
+ return false;
1413
+ }
1414
+ // Hide any visible views in this outlet since it has no matching route
1415
+ hideIonPageElement(leavingViewItem === null || leavingViewItem === void 0 ? void 0 : leavingViewItem.ionPageElement);
1416
+ if (leavingViewItem) {
1417
+ leavingViewItem.mount = false;
1418
+ }
1419
+ this.forceUpdate();
1420
+ return true;
1421
+ }
1422
+ /**
1423
+ * Handles the transition when entering view item has an ion-page element ready.
1424
+ */
1425
+ handleReadyEnteringView(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem) {
1426
+ var _a, _b;
1427
+ // Handle same view item case (e.g., parameterized route changes)
1428
+ if (enteringViewItem === leavingViewItem) {
1429
+ const routePath = (_b = (_a = enteringViewItem.reactElement) === null || _a === void 0 ? void 0 : _a.props) === null || _b === void 0 ? void 0 : _b.path;
1430
+ const isParameterizedRoute = routePath ? routePath.includes(':') : false;
1431
+ if (isParameterizedRoute) {
1432
+ // Refresh match metadata so the component receives updated params
1433
+ const updatedMatch = matchComponent(enteringViewItem.reactElement, routeInfo.pathname, true);
1434
+ if (updatedMatch) {
1435
+ enteringViewItem.routeData.match = updatedMatch;
1436
+ }
1437
+ const enteringEl = enteringViewItem.ionPageElement;
1438
+ if (enteringEl) {
1439
+ enteringEl.classList.remove('ion-page-hidden', 'ion-page-invisible');
1440
+ enteringEl.removeAttribute('aria-hidden');
1441
+ }
1442
+ this.forceUpdate();
1443
+ return;
1444
+ }
1445
+ }
1446
+ // Try to find leaving view using prev route info if still not found
1447
+ if (!leavingViewItem && this.props.routeInfo.prevRouteLastPathname) {
1448
+ leavingViewItem = this.context.findViewItemByPathname(this.props.routeInfo.prevRouteLastPathname, this.id);
1449
+ }
1450
+ // Ensure the entering view is marked as mounted.
1451
+ // This is critical for views that were previously unmounted (e.g., navigating back to home).
1452
+ // When mount=false, the ViewLifeCycleManager doesn't render the IonPage, so the
1453
+ // ionPageElement reference becomes stale. By setting mount=true, we ensure the view
1454
+ // gets re-rendered and a new IonPage is created.
1455
+ if (!enteringViewItem.mount) {
1456
+ enteringViewItem.mount = true;
1457
+ }
1458
+ // Check visibility state BEFORE showing the entering view.
1459
+ // This must be done before showIonPageElement to get accurate visibility state.
1460
+ const enteringWasVisible = enteringViewItem.ionPageElement && isViewVisible(enteringViewItem.ionPageElement);
1461
+ const leavingIsHidden = leavingViewItem !== undefined && leavingViewItem.ionPageElement && !isViewVisible(leavingViewItem.ionPageElement);
1462
+ // Check for duplicate transition
1463
+ const currentTransition = {
1464
+ enteringId: enteringViewItem.id,
1465
+ leavingId: leavingViewItem === null || leavingViewItem === void 0 ? void 0 : leavingViewItem.id,
1466
+ };
1467
+ const isDuplicateTransition = leavingViewItem &&
1468
+ this.lastTransition &&
1469
+ this.lastTransition.leavingId &&
1470
+ this.lastTransition.enteringId === currentTransition.enteringId &&
1471
+ this.lastTransition.leavingId === currentTransition.leavingId;
1472
+ // Skip transition if entering view was ALREADY visible and leaving view is not visible.
1473
+ // This indicates the transition has already been performed (e.g., via swipe gesture).
1474
+ // IMPORTANT: Only skip if both ionPageElements are the same as when the transition was last done.
1475
+ // If the leaving view's ionPageElement changed (e.g., component re-rendered with different IonPage),
1476
+ // we should NOT skip because the DOM state is inconsistent.
1477
+ if (enteringWasVisible && leavingIsHidden && isDuplicateTransition) {
1478
+ // For swipe-to-go-back, the transition animation was handled by the gesture.
1479
+ // We still need to set mount=false so React unmounts the leaving view.
1480
+ // Only do this when skipTransition is set (indicating gesture completion).
1481
+ if (this.skipTransition &&
1482
+ shouldUnmountLeavingViewItem &&
1483
+ leavingViewItem &&
1484
+ enteringViewItem !== leavingViewItem) {
1485
+ leavingViewItem.mount = false;
1486
+ // Call transitionPage with duration 0 to trigger ionViewDidLeave lifecycle
1487
+ // which is needed for ViewLifeCycleManager to remove the view.
1488
+ this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back');
1489
+ }
1490
+ // Clear skipTransition since we're not calling transitionPage which normally clears it
1491
+ this.skipTransition = false;
1492
+ // Must call forceUpdate to trigger re-render after mount state change
1493
+ this.forceUpdate();
1494
+ return;
1495
+ }
1496
+ // Ensure the entering view is not hidden from previous navigations
1497
+ // This must happen AFTER the visibility check above
1498
+ showIonPageElement(enteringViewItem.ionPageElement);
1499
+ // Skip if this is a duplicate transition (but visibility state didn't match above)
1500
+ // OR if skipTransition is set (swipe gesture already handled the animation)
1501
+ if (isDuplicateTransition || this.skipTransition) {
1502
+ // For swipe-to-go-back, we still need to handle unmounting even if visibility
1503
+ // conditions aren't fully met (animation might still be in progress)
1504
+ if (this.skipTransition &&
1505
+ shouldUnmountLeavingViewItem &&
1506
+ leavingViewItem &&
1507
+ enteringViewItem !== leavingViewItem) {
1508
+ leavingViewItem.mount = false;
1509
+ // For swipe-to-go-back, we need to call transitionPage with duration 0 to
1510
+ // trigger the ionViewDidLeave lifecycle event. The ViewLifeCycleManager
1511
+ // uses componentCanBeDestroyed callback to remove the view, which is
1512
+ // only called from ionViewDidLeave. Since the gesture animation already
1513
+ // completed before mount=false was set, we need to re-fire the lifecycle.
1514
+ this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back');
1515
+ }
1516
+ // Clear skipTransition since we're not calling transitionPage which normally clears it
1517
+ this.skipTransition = false;
1518
+ // Must call forceUpdate to trigger re-render after mount state change
1519
+ this.forceUpdate();
1520
+ return;
1521
+ }
1522
+ this.lastTransition = currentTransition;
1523
+ this.transitionPage(routeInfo, enteringViewItem, leavingViewItem);
1524
+ // Handle unmounting the leaving view
1525
+ if (shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem) {
1526
+ leavingViewItem.mount = false;
1527
+ this.handleLeavingViewUnmount(routeInfo, enteringViewItem, leavingViewItem);
1528
+ }
1529
+ // Clean up any orphaned sibling views that are no longer reachable
1530
+ // This is important for replace actions (like redirects) where sibling views
1531
+ // that were pushed earlier become unreachable
1532
+ this.cleanupOrphanedSiblingViews(routeInfo, enteringViewItem, leavingViewItem);
1533
+ }
1534
+ /**
1535
+ * Handles the delayed unmount of the leaving view item.
1536
+ * For 'replace' actions: handles container route transitions specially.
1537
+ * For back navigation: explicitly unmounts because the ionViewDidLeave lifecycle
1538
+ * fires DURING transitionPage, but mount=false is set AFTER.
1539
+ *
1540
+ * @param routeInfo Current route information
1541
+ * @param enteringViewItem The view being navigated to
1542
+ * @param leavingViewItem The view being navigated from
1543
+ */
1544
+ handleLeavingViewUnmount(routeInfo, enteringViewItem, leavingViewItem) {
1545
+ var _a, _b, _c, _d, _e, _f;
1546
+ if (!leavingViewItem.ionPageElement) {
1547
+ return;
1548
+ }
1549
+ // For push/pop actions, do NOT unmount - views are cached for navigation history.
1550
+ // Push: Forward navigation caches views for back navigation
1551
+ // Pop: Back navigation should not unmount the entering view's history
1552
+ // Only 'replace' actions should actually unmount views since they replace history.
1553
+ if (routeInfo.routeAction !== 'replace') {
1554
+ return;
1555
+ }
1556
+ // For replace actions, check if we should skip removal for nested outlet redirects
1557
+ const enteringRoutePath = (_b = (_a = enteringViewItem.reactElement) === null || _a === void 0 ? void 0 : _a.props) === null || _b === void 0 ? void 0 : _b.path;
1558
+ const leavingRoutePath = (_d = (_c = leavingViewItem.reactElement) === null || _c === void 0 ? void 0 : _c.props) === null || _d === void 0 ? void 0 : _d.path;
1559
+ const isEnteringContainerRoute = enteringRoutePath && enteringRoutePath.endsWith('/*');
1560
+ const isLeavingSpecificRoute = leavingRoutePath &&
1561
+ leavingRoutePath !== '' &&
1562
+ leavingRoutePath !== '*' &&
1563
+ !leavingRoutePath.endsWith('/*') &&
1564
+ !((_f = (_e = leavingViewItem.reactElement) === null || _e === void 0 ? void 0 : _e.props) === null || _f === void 0 ? void 0 : _f.index);
1565
+ // Skip removal for container-to-container transitions (e.g., /tabs/* → /settings/*).
1566
+ // These routes manage their own nested outlets; unmounting would disrupt child views.
1567
+ if (isEnteringContainerRoute && !isLeavingSpecificRoute) {
1568
+ return;
1569
+ }
1570
+ const viewToUnmount = leavingViewItem;
1571
+ setTimeout(() => {
1572
+ this.context.unMountViewItem(viewToUnmount);
1573
+ // Trigger re-render to remove the view from DOM
1574
+ this.forceUpdate();
1575
+ }, VIEW_UNMOUNT_DELAY_MS);
1576
+ }
1577
+ /**
1578
+ * Cleans up orphaned sibling views after a replace action.
1579
+ * When navigating via replace (e.g., through a redirect), sibling views that were
1580
+ * pushed earlier may become orphaned (unreachable via back navigation).
1581
+ * This method identifies and unmounts such views.
1582
+ */
1583
+ cleanupOrphanedSiblingViews(routeInfo, enteringViewItem, leavingViewItem) {
1584
+ var _a, _b, _c, _d;
1585
+ // Only cleanup for replace actions
1586
+ if (routeInfo.routeAction !== 'replace') {
1587
+ return;
1588
+ }
1589
+ const enteringRoutePath = (_b = (_a = enteringViewItem.reactElement) === null || _a === void 0 ? void 0 : _a.props) === null || _b === void 0 ? void 0 : _b.path;
1590
+ if (!enteringRoutePath) {
1591
+ return;
1592
+ }
1593
+ // Get all views in this outlet
1594
+ const allViewsInOutlet = this.context.getViewItemsForOutlet ? this.context.getViewItemsForOutlet(this.id) : [];
1595
+ // Check if routes are "siblings" - direct children of the same outlet at the same level
1596
+ const areSiblingRoutes = (path1, path2) => {
1597
+ // Both are relative routes (don't start with /)
1598
+ const path1IsRelative = !path1.startsWith('/');
1599
+ const path2IsRelative = !path2.startsWith('/');
1600
+ // For relative routes at the outlet root level, they're siblings
1601
+ if (path1IsRelative && path2IsRelative) {
1602
+ // Check if they're at the same depth (no nested slashes, except for wildcards)
1603
+ const path1Depth = path1.replace(/\/\*$/, '').split('/').filter(Boolean).length;
1604
+ const path2Depth = path2.replace(/\/\*$/, '').split('/').filter(Boolean).length;
1605
+ return path1Depth === path2Depth && path1Depth <= 1;
1606
+ }
1607
+ // For absolute routes, check if they share the same parent
1608
+ const getParent = (path) => {
1609
+ const normalized = path.replace(/\/\*$/, '');
1610
+ const lastSlash = normalized.lastIndexOf('/');
1611
+ return lastSlash > 0 ? normalized.substring(0, lastSlash) : '/';
1612
+ };
1613
+ return getParent(path1) === getParent(path2);
1614
+ };
1615
+ for (const viewItem of allViewsInOutlet) {
1616
+ const viewRoutePath = (_d = (_c = viewItem.reactElement) === null || _c === void 0 ? void 0 : _c.props) === null || _d === void 0 ? void 0 : _d.path;
1617
+ // Skip views that shouldn't be cleaned up:
1618
+ // - The entering view itself
1619
+ // - The immediate leaving view (handled separately by handleLeavingViewUnmount)
1620
+ // - Already unmounted views
1621
+ // - Views without a route path
1622
+ // - Container routes (ending in /*) when entering is also a container route
1623
+ const shouldSkip = viewItem.id === enteringViewItem.id ||
1624
+ (leavingViewItem && viewItem.id === leavingViewItem.id) ||
1625
+ !viewItem.mount ||
1626
+ !viewRoutePath ||
1627
+ (viewRoutePath.endsWith('/*') && enteringRoutePath.endsWith('/*'));
1628
+ if (shouldSkip) {
1629
+ continue;
1630
+ }
1631
+ // Check if this is a sibling route that should be cleaned up
1632
+ if (areSiblingRoutes(enteringRoutePath, viewRoutePath)) {
1633
+ // Hide and unmount the orphaned view
1634
+ hideIonPageElement(viewItem.ionPageElement);
1635
+ viewItem.mount = false;
1636
+ // Schedule removal
1637
+ const viewToRemove = viewItem;
1638
+ setTimeout(() => {
1639
+ this.context.unMountViewItem(viewToRemove);
1640
+ this.forceUpdate();
1641
+ }, VIEW_UNMOUNT_DELAY_MS);
1642
+ }
1643
+ }
1644
+ }
1645
+ /**
1646
+ * Handles the case when entering view has no ion-page element yet (waiting for render).
1647
+ */
1648
+ handleWaitingForIonPage(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem) {
1649
+ var _a, _b;
1650
+ const enteringRouteElement = (_b = (_a = enteringViewItem.reactElement) === null || _a === void 0 ? void 0 : _a.props) === null || _b === void 0 ? void 0 : _b.element;
1651
+ // Handle Navigate components (they never render an IonPage)
1652
+ if (isNavigateElement(enteringRouteElement)) {
1653
+ this.waitingForIonPage = false;
1654
+ if (this.ionPageWaitTimeout) {
1655
+ clearTimeout(this.ionPageWaitTimeout);
1656
+ this.ionPageWaitTimeout = undefined;
1657
+ }
1658
+ this.pendingPageTransition = false;
1659
+ // Hide the leaving view immediately for Navigate redirects
1660
+ hideIonPageElement(leavingViewItem === null || leavingViewItem === void 0 ? void 0 : leavingViewItem.ionPageElement);
1661
+ // Don't unmount if entering and leaving are the same view item
1662
+ if (shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem) {
1663
+ leavingViewItem.mount = false;
1664
+ }
1665
+ this.forceUpdate();
1666
+ return;
1667
+ }
1668
+ // Hide leaving view while we wait for the entering view's IonPage to mount
1669
+ hideIonPageElement(leavingViewItem === null || leavingViewItem === void 0 ? void 0 : leavingViewItem.ionPageElement);
1670
+ this.waitingForIonPage = true;
1671
+ if (this.ionPageWaitTimeout) {
1672
+ clearTimeout(this.ionPageWaitTimeout);
1673
+ }
1674
+ this.ionPageWaitTimeout = setTimeout(() => {
1675
+ var _a, _b;
1676
+ this.ionPageWaitTimeout = undefined;
1677
+ if (!this.waitingForIonPage) {
1678
+ return;
1679
+ }
1680
+ this.waitingForIonPage = false;
1681
+ const latestEnteringView = (_a = this.context.findViewItemByRouteInfo(routeInfo, this.id)) !== null && _a !== void 0 ? _a : enteringViewItem;
1682
+ const latestLeavingView = (_b = this.context.findLeavingViewItemByRouteInfo(routeInfo, this.id)) !== null && _b !== void 0 ? _b : leavingViewItem;
1683
+ if (latestEnteringView === null || latestEnteringView === void 0 ? void 0 : latestEnteringView.ionPageElement) {
1684
+ this.transitionPage(routeInfo, latestEnteringView, latestLeavingView !== null && latestLeavingView !== void 0 ? latestLeavingView : undefined);
1685
+ if (shouldUnmountLeavingViewItem && latestLeavingView && latestEnteringView !== latestLeavingView) {
1686
+ latestLeavingView.mount = false;
1687
+ // Call handleLeavingViewUnmount to ensure the view is properly removed
1688
+ this.handleLeavingViewUnmount(routeInfo, latestEnteringView, latestLeavingView);
1689
+ }
1690
+ this.forceUpdate();
1691
+ }
1692
+ }, ION_PAGE_WAIT_TIMEOUT_MS);
1693
+ this.forceUpdate();
1694
+ }
1695
+ /**
1696
+ * Gets the route info to use for finding views during swipe-to-go-back gestures.
1697
+ * This pattern is used in multiple places in setupRouterOutlet.
1698
+ */
1699
+ getSwipeBackRouteInfo() {
1700
+ const { routeInfo } = this.props;
1701
+ return this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute
1702
+ ? this.prevProps.routeInfo
1703
+ : { pathname: routeInfo.pushedByRoute || '' };
1704
+ }
227
1705
  componentDidMount() {
228
1706
  if (this.clearOutletTimeout) {
229
1707
  /**
@@ -255,117 +1733,153 @@ class StackManager extends React.PureComponent {
255
1733
  }
256
1734
  }
257
1735
  componentWillUnmount() {
1736
+ if (this.ionPageWaitTimeout) {
1737
+ clearTimeout(this.ionPageWaitTimeout);
1738
+ this.ionPageWaitTimeout = undefined;
1739
+ }
1740
+ if (this.outOfScopeUnmountTimeout) {
1741
+ clearTimeout(this.outOfScopeUnmountTimeout);
1742
+ this.outOfScopeUnmountTimeout = undefined;
1743
+ }
1744
+ this.waitingForIonPage = false;
1745
+ // Hide all views in this outlet before clearing.
1746
+ // This is critical for nested outlets - when the parent component unmounts,
1747
+ // the nested outlet's componentDidUpdate won't be called, so we must hide
1748
+ // the ion-page elements here to prevent them from remaining visible on top
1749
+ // of other content after navigation to a different route.
1750
+ const allViewsInOutlet = this.context.getViewItemsForOutlet ? this.context.getViewItemsForOutlet(this.id) : [];
1751
+ allViewsInOutlet.forEach((viewItem) => {
1752
+ hideIonPageElement(viewItem.ionPageElement);
1753
+ });
258
1754
  this.clearOutletTimeout = this.context.clearOutlet(this.id);
259
1755
  }
1756
+ /**
1757
+ * Sets the transition between pages within this router outlet.
1758
+ * This function determines the entering and leaving views based on the
1759
+ * provided route information and triggers the appropriate animation.
1760
+ * It also handles scenarios like initial loads, back navigation, and
1761
+ * navigation to the same view with different parameters.
1762
+ *
1763
+ * @param routeInfo It contains info about the current route,
1764
+ * the previous route, and the action taken (e.g., push, replace).
1765
+ *
1766
+ * @returns A promise that resolves when the transition is complete.
1767
+ * If no transition is needed or if the router outlet isn't ready,
1768
+ * the Promise may resolve immediately.
1769
+ */
260
1770
  async handlePageTransition(routeInfo) {
261
- var _a, _b;
1771
+ var _a;
1772
+ // Wait for router outlet to mount
262
1773
  if (!this.routerOutletElement || !this.routerOutletElement.commit) {
263
- /**
264
- * The route outlet has not mounted yet. We need to wait for it to render
265
- * before we can transition the page.
266
- *
267
- * Set a flag to indicate that we should transition the page after
268
- * the component has updated.
269
- */
270
1774
  this.pendingPageTransition = true;
1775
+ return;
271
1776
  }
272
- else {
273
- let enteringViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id);
274
- let leavingViewItem = this.context.findLeavingViewItemByRouteInfo(routeInfo, this.id);
275
- if (!leavingViewItem && routeInfo.prevRouteLastPathname) {
276
- leavingViewItem = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id);
277
- }
278
- // Check if leavingViewItem should be unmounted
279
- if (leavingViewItem) {
280
- if (routeInfo.routeAction === 'replace') {
281
- leavingViewItem.mount = false;
282
- }
283
- else if (!(routeInfo.routeAction === 'push' && routeInfo.routeDirection === 'forward')) {
284
- if (routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) {
285
- leavingViewItem.mount = false;
286
- }
287
- }
288
- else if ((_a = routeInfo.routeOptions) === null || _a === void 0 ? void 0 : _a.unmount) {
289
- leavingViewItem.mount = false;
290
- }
291
- }
292
- const enteringRoute = matchRoute((_b = this.ionRouterOutlet) === null || _b === void 0 ? void 0 : _b.props.children, routeInfo);
293
- if (enteringViewItem) {
294
- enteringViewItem.reactElement = enteringRoute;
295
- }
296
- else if (enteringRoute) {
297
- enteringViewItem = this.context.createViewItem(this.id, enteringRoute, routeInfo);
298
- this.context.addViewItem(enteringViewItem);
299
- }
300
- if (enteringViewItem && enteringViewItem.ionPageElement) {
301
- /**
302
- * If the entering view item is the same as the leaving view item,
303
- * then we don't need to transition.
304
- */
305
- if (enteringViewItem === leavingViewItem) {
306
- /**
307
- * If the entering view item is the same as the leaving view item,
308
- * we are either transitioning using parameterized routes to the same view
309
- * or a parent router outlet is re-rendering as a result of React props changing.
310
- *
311
- * If the route data does not match the current path, the parent router outlet
312
- * is attempting to transition and we cancel the operation.
313
- */
314
- if (enteringViewItem.routeData.match.url !== routeInfo.pathname) {
315
- return;
316
- }
317
- }
318
- /**
319
- * If there isn't a leaving view item, but the route info indicates
320
- * that the user has routed from a previous path, then we need
321
- * to find the leaving view item to transition between.
322
- */
323
- if (!leavingViewItem && this.props.routeInfo.prevRouteLastPathname) {
324
- leavingViewItem = this.context.findViewItemByPathname(this.props.routeInfo.prevRouteLastPathname, this.id);
325
- }
326
- /**
327
- * If the entering view is already visible and the leaving view is not, the transition does not need to occur.
328
- */
329
- if (isViewVisible(enteringViewItem.ionPageElement) &&
330
- leavingViewItem !== undefined &&
331
- !isViewVisible(leavingViewItem.ionPageElement)) {
332
- return;
333
- }
334
- /**
335
- * The view should only be transitioned in the following cases:
336
- * 1. Performing a replace or pop action, such as a swipe to go back gesture
337
- * to animation the leaving view off the screen.
338
- *
339
- * 2. Navigating between top-level router outlets, such as /page-1 to /page-2;
340
- * or navigating within a nested outlet, such as /tabs/tab-1 to /tabs/tab-2.
341
- *
342
- * 3. The entering view is an ion-router-outlet containing a page
343
- * matching the current route and that hasn't already transitioned in.
344
- *
345
- * This should only happen when navigating directly to a nested router outlet
346
- * route or on an initial page load (i.e. refreshing). In cases when loading
347
- * /tabs/tab-1, we need to transition the /tabs page element into the view.
348
- */
349
- this.transitionPage(routeInfo, enteringViewItem, leavingViewItem);
350
- }
351
- else if (leavingViewItem && !enteringRoute && !enteringViewItem) {
352
- // If we have a leavingView but no entering view/route, we are probably leaving to
353
- // another outlet, so hide this leavingView. We do it in a timeout to give time for a
354
- // transition to finish.
355
- // setTimeout(() => {
356
- if (leavingViewItem.ionPageElement) {
357
- leavingViewItem.ionPageElement.classList.add('ion-page-hidden');
358
- leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true');
1777
+ // Find entering and leaving view items
1778
+ const viewItems = this.findViewItems(routeInfo);
1779
+ let enteringViewItem = viewItems.enteringViewItem;
1780
+ const leavingViewItem = viewItems.leavingViewItem;
1781
+ const shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem);
1782
+ // Get parent path for nested outlets
1783
+ const parentPath = this.getParentPath();
1784
+ // Handle out-of-scope outlet (route outside mount path)
1785
+ if (this.handleOutOfScopeOutlet(routeInfo)) {
1786
+ return;
1787
+ }
1788
+ // Clear any pending out-of-scope unmount timeout
1789
+ if (this.outOfScopeUnmountTimeout) {
1790
+ clearTimeout(this.outOfScopeUnmountTimeout);
1791
+ this.outOfScopeUnmountTimeout = undefined;
1792
+ }
1793
+ // Handle nested outlet with relative routes but no valid parent path
1794
+ if (this.handleOutOfContextNestedOutlet(parentPath, leavingViewItem)) {
1795
+ return;
1796
+ }
1797
+ // Find the matching route element
1798
+ const enteringRoute = findRouteByRouteInfo((_a = this.ionRouterOutlet) === null || _a === void 0 ? void 0 : _a.props.children, routeInfo, parentPath);
1799
+ // Handle nested outlet with no matching route
1800
+ if (this.handleNoMatchingRoute(enteringRoute, enteringViewItem, leavingViewItem)) {
1801
+ return;
1802
+ }
1803
+ // Create or update the entering view item
1804
+ if (enteringViewItem && enteringRoute) {
1805
+ enteringViewItem.reactElement = enteringRoute;
1806
+ }
1807
+ else if (enteringRoute) {
1808
+ enteringViewItem = this.context.createViewItem(this.id, enteringRoute, routeInfo);
1809
+ this.context.addViewItem(enteringViewItem);
1810
+ }
1811
+ // Handle transition based on ion-page element availability
1812
+ // Check if the ionPageElement is still in the document.
1813
+ // If the view was previously unmounted (mount=false), the ViewLifeCycleManager
1814
+ // removes the React component from the tree, which removes the IonPage from the DOM.
1815
+ // The ionPageElement reference becomes stale and we need to wait for a new one.
1816
+ const ionPageIsInDocument = (enteringViewItem === null || enteringViewItem === void 0 ? void 0 : enteringViewItem.ionPageElement) && document.body.contains(enteringViewItem.ionPageElement);
1817
+ if (enteringViewItem && ionPageIsInDocument) {
1818
+ // Clear waiting state
1819
+ if (this.waitingForIonPage) {
1820
+ this.waitingForIonPage = false;
1821
+ }
1822
+ if (this.ionPageWaitTimeout) {
1823
+ clearTimeout(this.ionPageWaitTimeout);
1824
+ this.ionPageWaitTimeout = undefined;
1825
+ }
1826
+ this.handleReadyEnteringView(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem);
1827
+ }
1828
+ else if (enteringViewItem && !ionPageIsInDocument) {
1829
+ // Wait for ion-page to mount
1830
+ // This handles both: no ionPageElement, or stale ionPageElement (not in document)
1831
+ // Clear stale reference if the element is no longer in the document
1832
+ if (enteringViewItem.ionPageElement && !document.body.contains(enteringViewItem.ionPageElement)) {
1833
+ enteringViewItem.ionPageElement = undefined;
1834
+ }
1835
+ // Ensure the view is marked as mounted so ViewLifeCycleManager renders the IonPage
1836
+ if (!enteringViewItem.mount) {
1837
+ enteringViewItem.mount = true;
1838
+ }
1839
+ this.handleWaitingForIonPage(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem);
1840
+ return;
1841
+ }
1842
+ else if (!enteringViewItem && !enteringRoute) {
1843
+ // No view or route found - likely leaving to another outlet
1844
+ if (leavingViewItem) {
1845
+ hideIonPageElement(leavingViewItem.ionPageElement);
1846
+ if (shouldUnmountLeavingViewItem) {
1847
+ leavingViewItem.mount = false;
359
1848
  }
360
- // }, 250);
361
1849
  }
362
- this.forceUpdate();
363
1850
  }
1851
+ this.forceUpdate();
364
1852
  }
1853
+ /**
1854
+ * Registers an `<IonPage>` DOM element with the `StackManager`.
1855
+ * This is called when `<IonPage>` has been mounted.
1856
+ *
1857
+ * @param page The element of the rendered `<IonPage>`.
1858
+ * @param routeInfo The route information that associates with `<IonPage>`.
1859
+ */
365
1860
  registerIonPage(page, routeInfo) {
1861
+ this.waitingForIonPage = false;
1862
+ if (this.ionPageWaitTimeout) {
1863
+ clearTimeout(this.ionPageWaitTimeout);
1864
+ this.ionPageWaitTimeout = undefined;
1865
+ }
1866
+ this.pendingPageTransition = false;
366
1867
  const foundView = this.context.findViewItemByRouteInfo(routeInfo, this.id);
367
1868
  if (foundView) {
368
1869
  const oldPageElement = foundView.ionPageElement;
1870
+ /**
1871
+ * FIX for issue #28878: Reject orphaned IonPage registrations.
1872
+ *
1873
+ * When a component conditionally renders different IonPages (e.g., list vs empty state)
1874
+ * using React keys, and state changes simultaneously with navigation, the new IonPage
1875
+ * tries to register for a route we're navigating away from. This creates a stale view.
1876
+ *
1877
+ * Only reject if both pageIds exist and differ, to allow nested outlet registrations.
1878
+ */
1879
+ if (this.shouldRejectOrphanedPage(page, oldPageElement, routeInfo)) {
1880
+ this.hideAndRemoveOrphanedPage(page);
1881
+ return;
1882
+ }
369
1883
  foundView.ionPageElement = page;
370
1884
  foundView.ionRoute = true;
371
1885
  /**
@@ -379,48 +1893,89 @@ class StackManager extends React.PureComponent {
379
1893
  }
380
1894
  this.handlePageTransition(routeInfo);
381
1895
  }
1896
+ /**
1897
+ * Determines if a new IonPage registration should be rejected as orphaned.
1898
+ * This happens when a component re-renders with a different IonPage while navigating away.
1899
+ */
1900
+ shouldRejectOrphanedPage(newPage, oldPageElement, routeInfo) {
1901
+ if (!oldPageElement || oldPageElement === newPage) {
1902
+ return false;
1903
+ }
1904
+ const newPageId = newPage.getAttribute('data-pageid');
1905
+ const oldPageId = oldPageElement.getAttribute('data-pageid');
1906
+ // Only reject if both pageIds exist and are different
1907
+ if (!newPageId || !oldPageId || newPageId === oldPageId) {
1908
+ return false;
1909
+ }
1910
+ // Reject only if we're navigating away from this route
1911
+ return this.props.routeInfo.pathname !== routeInfo.pathname;
1912
+ }
1913
+ /**
1914
+ * Hides an orphaned IonPage and schedules its removal from the DOM.
1915
+ */
1916
+ hideAndRemoveOrphanedPage(page) {
1917
+ page.classList.add('ion-page-hidden');
1918
+ page.setAttribute('aria-hidden', 'true');
1919
+ setTimeout(() => {
1920
+ if (page.parentElement) {
1921
+ page.remove();
1922
+ }
1923
+ }, VIEW_UNMOUNT_DELAY_MS);
1924
+ }
1925
+ /**
1926
+ * Configures the router outlet for the swipe-to-go-back gesture.
1927
+ *
1928
+ * @param routerOutlet The Ionic router outlet component: `<IonRouterOutlet>`.
1929
+ */
382
1930
  async setupRouterOutlet(routerOutlet) {
383
1931
  const canStart = () => {
384
1932
  const config = getConfig();
1933
+ // Check if swipe back is enabled in config (default to true for iOS mode)
385
1934
  const swipeEnabled = config && config.get('swipeBackEnabled', routerOutlet.mode === 'ios');
386
1935
  if (!swipeEnabled) {
387
1936
  return false;
388
1937
  }
389
1938
  const { routeInfo } = this.props;
390
- const propsToUse = this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute
391
- ? this.prevProps.routeInfo
392
- : { pathname: routeInfo.pushedByRoute || '' };
393
- const enteringViewItem = this.context.findViewItemByRouteInfo(propsToUse, this.id, false);
394
- return (!!enteringViewItem &&
395
- /**
396
- * The root url '/' is treated as
397
- * the first view item (but is never mounted),
398
- * so we do not want to swipe back to the
399
- * root url.
400
- */
401
- enteringViewItem.mount &&
402
- /**
403
- * When on the first page (whatever view
404
- * you land on after the root url) it
405
- * is possible for findViewItemByRouteInfo to
406
- * return the exact same view you are currently on.
407
- * Make sure that we are not swiping back to the same
408
- * instances of a view.
409
- */
410
- enteringViewItem.routeData.match.path !== routeInfo.pathname);
1939
+ const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
1940
+ // First try to find the view in the current outlet
1941
+ let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
1942
+ // If not found in current outlet, search all outlets (for cross-outlet swipe back)
1943
+ if (!enteringViewItem) {
1944
+ enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false);
1945
+ }
1946
+ // Check if the ionPageElement is still in the document.
1947
+ // A view might have mount=false but still have its ionPageElement in the DOM
1948
+ // (due to timing differences in unmounting).
1949
+ const ionPageInDocument = Boolean((enteringViewItem === null || enteringViewItem === void 0 ? void 0 : enteringViewItem.ionPageElement) && document.body.contains(enteringViewItem.ionPageElement));
1950
+ const canStartSwipe = !!enteringViewItem &&
1951
+ // Check if we can swipe to this view. Either:
1952
+ // 1. The view is mounted (mount=true), OR
1953
+ // 2. The view's ionPageElement is still in the document
1954
+ // The second case handles views that have been marked for unmount but haven't
1955
+ // actually been removed from the DOM yet.
1956
+ (enteringViewItem.mount || ionPageInDocument) &&
1957
+ // When on the first page it is possible for findViewItemByRouteInfo to
1958
+ // return the exact same view you are currently on.
1959
+ // Make sure that we are not swiping back to the same instances of a view.
1960
+ enteringViewItem.routeData.match.pattern.path !== routeInfo.pathname;
1961
+ return canStartSwipe;
411
1962
  };
412
1963
  const onStart = async () => {
413
1964
  const { routeInfo } = this.props;
414
- const propsToUse = this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute
415
- ? this.prevProps.routeInfo
416
- : { pathname: routeInfo.pushedByRoute || '' };
417
- const enteringViewItem = this.context.findViewItemByRouteInfo(propsToUse, this.id, false);
1965
+ const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
1966
+ // First try to find the view in the current outlet, then search all outlets
1967
+ let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
1968
+ if (!enteringViewItem) {
1969
+ enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false);
1970
+ }
418
1971
  const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
419
- /**
420
- * When the gesture starts, kick off
421
- * a transition that is controlled
422
- * via a swipe gesture.
423
- */
1972
+ // Ensure the entering view is mounted so React keeps rendering it during the gesture.
1973
+ // This is important when the view was previously marked for unmount but its
1974
+ // ionPageElement is still in the DOM.
1975
+ if (enteringViewItem && !enteringViewItem.mount) {
1976
+ enteringViewItem.mount = true;
1977
+ }
1978
+ // When the gesture starts, kick off a transition controlled via swipe gesture
424
1979
  if (enteringViewItem && leavingViewItem) {
425
1980
  await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true);
426
1981
  }
@@ -428,34 +1983,23 @@ class StackManager extends React.PureComponent {
428
1983
  };
429
1984
  const onEnd = (shouldContinue) => {
430
1985
  if (shouldContinue) {
1986
+ // User finished the swipe gesture, so complete the back navigation
431
1987
  this.skipTransition = true;
432
1988
  this.context.goBack();
433
1989
  }
434
1990
  else {
435
- /**
436
- * In the event that the swipe
437
- * gesture was aborted, we should
438
- * re-hide the page that was going to enter.
439
- */
1991
+ // Swipe gesture was aborted - re-hide the page that was going to enter
440
1992
  const { routeInfo } = this.props;
441
- const propsToUse = this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute
442
- ? this.prevProps.routeInfo
443
- : { pathname: routeInfo.pushedByRoute || '' };
444
- const enteringViewItem = this.context.findViewItemByRouteInfo(propsToUse, this.id, false);
1993
+ const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
1994
+ // First try to find the view in the current outlet, then search all outlets
1995
+ let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
1996
+ if (!enteringViewItem) {
1997
+ enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false);
1998
+ }
445
1999
  const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
446
- /**
447
- * Ionic React has a design defect where it
448
- * a) Unmounts the leaving view item when using parameterized routes
449
- * b) Considers the current view to be the entering view when using
450
- * parameterized routes
451
- *
452
- * As a result, we should not hide the view item here
453
- * as it will cause the current view to be hidden.
454
- */
2000
+ // Don't hide if entering and leaving are the same (parameterized route edge case)
455
2001
  if (enteringViewItem !== leavingViewItem && (enteringViewItem === null || enteringViewItem === void 0 ? void 0 : enteringViewItem.ionPageElement) !== undefined) {
456
- const { ionPageElement } = enteringViewItem;
457
- ionPageElement.setAttribute('aria-hidden', 'true');
458
- ionPageElement.classList.add('ion-page-hidden');
2002
+ hideIonPageElement(enteringViewItem.ionPageElement);
459
2003
  }
460
2004
  }
461
2005
  };
@@ -465,6 +2009,18 @@ class StackManager extends React.PureComponent {
465
2009
  onEnd,
466
2010
  };
467
2011
  }
2012
+ /**
2013
+ * Animates the transition between the entering and leaving pages within the
2014
+ * router outlet.
2015
+ *
2016
+ * @param routeInfo Info about the current route.
2017
+ * @param enteringViewItem The view item that is entering.
2018
+ * @param leavingViewItem The view item that is leaving.
2019
+ * @param direction The direction of the transition.
2020
+ * @param progressAnimation Indicates if the transition is part of a
2021
+ * gesture controlled animation (e.g., swipe to go back).
2022
+ * Defaults to `false`.
2023
+ */
468
2024
  async transitionPage(routeInfo, enteringViewItem, leavingViewItem, direction, progressAnimation = false) {
469
2025
  const runCommit = async (enteringEl, leavingEl) => {
470
2026
  const skipTransition = this.skipTransition;
@@ -510,7 +2066,8 @@ class StackManager extends React.PureComponent {
510
2066
  if (leavingViewItem && leavingViewItem.ionPageElement && enteringViewItem === leavingViewItem) {
511
2067
  // If a page is transitioning to another version of itself
512
2068
  // we clone it so we can have an animation to show
513
- const match = matchComponent(leavingViewItem.reactElement, routeInfo.pathname, true);
2069
+ // (e.g., `/user/1` → `/user/2`)
2070
+ const match = matchComponent(leavingViewItem.reactElement, routeInfo.pathname);
514
2071
  if (match) {
515
2072
  const newLeavingElement = clonePageElement(leavingViewItem.ionPageElement.outerHTML);
516
2073
  if (newLeavingElement) {
@@ -520,6 +2077,15 @@ class StackManager extends React.PureComponent {
520
2077
  }
521
2078
  }
522
2079
  else {
2080
+ /**
2081
+ * The route no longer matches the component type of the leaving view.
2082
+ * (e.g., `/user/1` → `/settings`)
2083
+ *
2084
+ * This can also occur in edge cases like rapid navigation
2085
+ * or during parent component re-renders that briefly cause
2086
+ * the view items to be the same instance before the final
2087
+ * route component is determined.
2088
+ */
523
2089
  await runCommit(enteringViewItem.ionPageElement, undefined);
524
2090
  }
525
2091
  }
@@ -535,20 +2101,25 @@ class StackManager extends React.PureComponent {
535
2101
  render() {
536
2102
  const { children } = this.props;
537
2103
  const ionRouterOutlet = React.Children.only(children);
2104
+ // Store reference for use in getParentPath() and handlePageTransition()
538
2105
  this.ionRouterOutlet = ionRouterOutlet;
539
2106
  const components = this.context.getChildrenToRender(this.id, this.ionRouterOutlet, this.props.routeInfo, () => {
2107
+ // Callback triggers re-render when view items are modified during getChildrenToRender
540
2108
  this.forceUpdate();
541
2109
  });
542
2110
  return (React.createElement(StackContext.Provider, { value: this.stackContextValue }, React.cloneElement(ionRouterOutlet, {
543
2111
  ref: (node) => {
544
2112
  if (ionRouterOutlet.props.setRef) {
2113
+ // Needed to handle external refs from devs.
545
2114
  ionRouterOutlet.props.setRef(node);
546
2115
  }
547
2116
  if (ionRouterOutlet.props.forwardedRef) {
2117
+ // Needed to handle external refs from devs.
548
2118
  ionRouterOutlet.props.forwardedRef.current = node;
549
2119
  }
550
2120
  this.routerOutletElement = node;
551
2121
  const { ref } = ionRouterOutlet;
2122
+ // Check for legacy refs.
552
2123
  if (typeof ref === 'function') {
553
2124
  ref(node);
554
2125
  }
@@ -559,169 +2130,391 @@ class StackManager extends React.PureComponent {
559
2130
  return RouteManagerContext;
560
2131
  }
561
2132
  }
562
- function matchRoute(node, routeInfo) {
2133
+ /**
2134
+ * Finds the `<Route />` node matching the current route info.
2135
+ * If no `<Route />` can be matched, a fallback node is returned.
2136
+ * Routes are prioritized by specificity (most specific first).
2137
+ *
2138
+ * @param node The root node to search for `<Route />` nodes.
2139
+ * @param routeInfo The route information to match against.
2140
+ * @param parentPath The parent path that was matched by the parent outlet (for nested routing)
2141
+ */
2142
+ function findRouteByRouteInfo(node, routeInfo, parentPath) {
2143
+ var _a;
563
2144
  let matchedNode;
564
- React.Children.forEach(node, (child) => {
2145
+ let fallbackNode;
2146
+ // `<Route />` nodes are rendered inside of a <Routes /> node
2147
+ const routesChildren = (_a = getRoutesChildren(node)) !== null && _a !== void 0 ? _a : node;
2148
+ // Collect all route children
2149
+ const routeChildren = React.Children.toArray(routesChildren).filter((child) => React.isValidElement(child) && child.type === Route);
2150
+ // Sort routes by specificity (most specific first)
2151
+ const sortedRoutes = routeChildren.sort((a, b) => {
2152
+ const pathA = a.props.path || '';
2153
+ const pathB = b.props.path || '';
2154
+ // Index routes come first
2155
+ if (a.props.index && !b.props.index)
2156
+ return -1;
2157
+ if (!a.props.index && b.props.index)
2158
+ return 1;
2159
+ // Wildcard-only routes (*) should come LAST
2160
+ const aIsWildcardOnly = pathA === '*';
2161
+ const bIsWildcardOnly = pathB === '*';
2162
+ if (!aIsWildcardOnly && bIsWildcardOnly)
2163
+ return -1;
2164
+ if (aIsWildcardOnly && !bIsWildcardOnly)
2165
+ return 1;
2166
+ // Exact matches (no wildcards/params) come before wildcard/param routes
2167
+ const aHasWildcard = pathA.includes('*') || pathA.includes(':');
2168
+ const bHasWildcard = pathB.includes('*') || pathB.includes(':');
2169
+ if (!aHasWildcard && bHasWildcard)
2170
+ return -1;
2171
+ if (aHasWildcard && !bHasWildcard)
2172
+ return 1;
2173
+ // Among routes with same wildcard status, longer paths are more specific
2174
+ if (pathA.length !== pathB.length) {
2175
+ return pathB.length - pathA.length;
2176
+ }
2177
+ return 0;
2178
+ });
2179
+ // For nested routes in React Router 6, we need to extract the relative path
2180
+ // that this outlet should be responsible for matching
2181
+ const originalPathname = routeInfo.pathname;
2182
+ let relativePathnameToMatch = routeInfo.pathname;
2183
+ // Check if we have relative routes (routes that don't start with '/')
2184
+ const hasRelativeRoutes = sortedRoutes.some((r) => r.props.path && !r.props.path.startsWith('/'));
2185
+ const hasIndexRoute = sortedRoutes.some((r) => r.props.index);
2186
+ // SIMPLIFIED: Trust React Router 6's matching more, compute relative path when parent is known
2187
+ if ((hasRelativeRoutes || hasIndexRoute) && parentPath) {
2188
+ const parentPrefix = parentPath.replace('/*', '');
2189
+ // Normalize both paths to start with '/' for consistent comparison
2190
+ const normalizedParent = stripTrailingSlash(parentPrefix.startsWith('/') ? parentPrefix : `/${parentPrefix}`);
2191
+ const normalizedPathname = stripTrailingSlash(routeInfo.pathname);
2192
+ // Only compute relative path if pathname is within parent scope
2193
+ if (normalizedPathname.startsWith(normalizedParent + '/') || normalizedPathname === normalizedParent) {
2194
+ const pathSegments = routeInfo.pathname.split('/').filter(Boolean);
2195
+ const parentSegments = normalizedParent.split('/').filter(Boolean);
2196
+ const relativeSegments = pathSegments.slice(parentSegments.length);
2197
+ relativePathnameToMatch = relativeSegments.join('/'); // Empty string is valid for index routes
2198
+ }
2199
+ }
2200
+ // Find the first matching route
2201
+ for (const child of sortedRoutes) {
2202
+ const childPath = child.props.path;
2203
+ const isAbsoluteRoute = childPath && childPath.startsWith('/');
2204
+ // Determine which pathname to match against:
2205
+ // - For absolute routes: use the original full pathname
2206
+ // - For relative routes with a parent: use the computed relative pathname
2207
+ // - For relative routes at root level (no parent): use the original pathname
2208
+ // (matchPath will handle the relative-to-absolute normalization)
2209
+ const pathnameToMatch = isAbsoluteRoute ? originalPathname : relativePathnameToMatch;
2210
+ // Determine the path portion to match:
2211
+ // - For absolute routes: use derivePathnameToMatch
2212
+ // - For relative routes at root level (no parent): use original pathname
2213
+ // directly since matchPath normalizes both path and pathname
2214
+ // - For relative routes with parent: use derivePathnameToMatch for wildcards,
2215
+ // or the computed relative pathname for non-wildcards
2216
+ let pathForMatch;
2217
+ if (isAbsoluteRoute) {
2218
+ pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath);
2219
+ }
2220
+ else if (!parentPath && childPath) {
2221
+ // Root-level relative route: use the full pathname and let matchPath
2222
+ // handle the normalization (it adds '/' to both path and pathname)
2223
+ pathForMatch = originalPathname;
2224
+ }
2225
+ else if (childPath && childPath.includes('*')) {
2226
+ // Relative wildcard route with parent path: use derivePathnameToMatch
2227
+ pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath);
2228
+ }
2229
+ else {
2230
+ pathForMatch = pathnameToMatch;
2231
+ }
565
2232
  const match = matchPath({
566
- pathname: routeInfo.pathname,
2233
+ pathname: pathForMatch,
567
2234
  componentProps: child.props,
568
2235
  });
569
2236
  if (match) {
570
2237
  matchedNode = child;
2238
+ break;
571
2239
  }
572
- });
2240
+ }
573
2241
  if (matchedNode) {
574
2242
  return matchedNode;
575
2243
  }
576
- // If we haven't found a node
577
- // try to find one that doesn't have a path or from prop, that will be our not found route
578
- React.Children.forEach(node, (child) => {
579
- if (!(child.props.path || child.props.from)) {
580
- matchedNode = child;
2244
+ // If we haven't found a node, try to find one that doesn't have a path prop (fallback route)
2245
+ // BUT only return the fallback if the current pathname is within the outlet's scope.
2246
+ // For outlets with absolute paths, compute the common prefix to determine scope.
2247
+ const absolutePathRoutes = routeChildren.filter((r) => r.props.path && r.props.path.startsWith('/'));
2248
+ // Determine if pathname is within scope before returning fallback
2249
+ let isPathnameInScope = true;
2250
+ if (absolutePathRoutes.length > 0) {
2251
+ // Find common prefix of all absolute paths to determine outlet scope
2252
+ const absolutePaths = absolutePathRoutes.map((r) => r.props.path);
2253
+ const commonPrefix = computeCommonPrefix(absolutePaths);
2254
+ // If we have a common prefix, check if the current pathname is within that scope
2255
+ if (commonPrefix && commonPrefix !== '/') {
2256
+ isPathnameInScope = routeInfo.pathname.startsWith(commonPrefix);
581
2257
  }
582
- });
583
- return matchedNode;
2258
+ }
2259
+ // Only look for fallback route if pathname is within scope
2260
+ if (isPathnameInScope) {
2261
+ for (const child of routeChildren) {
2262
+ if (!child.props.path) {
2263
+ fallbackNode = child;
2264
+ break;
2265
+ }
2266
+ }
2267
+ }
2268
+ return matchedNode !== null && matchedNode !== void 0 ? matchedNode : fallbackNode;
584
2269
  }
585
2270
  function matchComponent(node, pathname, forceExact) {
2271
+ var _a;
2272
+ const routePath = (_a = node === null || node === void 0 ? void 0 : node.props) === null || _a === void 0 ? void 0 : _a.path;
2273
+ const pathnameToMatch = derivePathnameToMatch(pathname, routePath);
586
2274
  return matchPath({
587
- pathname,
588
- componentProps: Object.assign(Object.assign({}, node.props), { exact: forceExact }),
2275
+ pathname: pathnameToMatch,
2276
+ componentProps: Object.assign(Object.assign({}, node.props), { end: forceExact }),
589
2277
  });
590
2278
  }
591
2279
 
592
- class IonRouterInner extends React.PureComponent {
593
- constructor(props) {
594
- super(props);
595
- this.exitViewFromOtherOutletHandlers = [];
596
- this.locationHistory = new LocationHistory();
597
- this.viewStack = new ReactRouterViewStack();
598
- this.routeMangerContextState = {
599
- canGoBack: () => this.locationHistory.canGoBack(),
600
- clearOutlet: this.viewStack.clear,
601
- findViewItemByPathname: this.viewStack.findViewItemByPathname,
602
- getChildrenToRender: this.viewStack.getChildrenToRender,
603
- goBack: () => this.handleNavigateBack(),
604
- createViewItem: this.viewStack.createViewItem,
605
- findViewItemByRouteInfo: this.viewStack.findViewItemByRouteInfo,
606
- findLeavingViewItemByRouteInfo: this.viewStack.findLeavingViewItemByRouteInfo,
607
- addViewItem: this.viewStack.add,
608
- unMountViewItem: this.viewStack.remove,
609
- };
610
- const routeInfo = {
611
- id: generateId('routeInfo'),
612
- pathname: this.props.location.pathname,
613
- search: this.props.location.search,
614
- };
615
- this.locationHistory.add(routeInfo);
616
- this.handleChangeTab = this.handleChangeTab.bind(this);
617
- this.handleResetTab = this.handleResetTab.bind(this);
618
- this.handleNativeBack = this.handleNativeBack.bind(this);
619
- this.handleNavigate = this.handleNavigate.bind(this);
620
- this.handleNavigateBack = this.handleNavigateBack.bind(this);
621
- this.props.registerHistoryListener(this.handleHistoryChange.bind(this));
622
- this.handleSetCurrentTab = this.handleSetCurrentTab.bind(this);
623
- this.state = {
624
- routeInfo,
625
- };
2280
+ /**
2281
+ * `IonRouter` is responsible for managing the application's navigation
2282
+ * state, tracking the history of visited routes, and coordinating
2283
+ * transitions between different views. It intercepts route changes from
2284
+ * React Router and translates them into actions that Ionic can understand
2285
+ * and animate.
2286
+ */
2287
+ const filterUndefinedParams = (params) => {
2288
+ const result = {};
2289
+ for (const key of Object.keys(params)) {
2290
+ const value = params[key];
2291
+ if (value !== undefined) {
2292
+ result[key] = value;
2293
+ }
626
2294
  }
627
- handleChangeTab(tab, path, routeOptions) {
628
- if (!path) {
2295
+ return result;
2296
+ };
2297
+ const areParamsEqual = (a, b) => {
2298
+ const paramsA = a || {};
2299
+ const paramsB = b || {};
2300
+ const keysA = Object.keys(paramsA);
2301
+ const keysB = Object.keys(paramsB);
2302
+ if (keysA.length !== keysB.length) {
2303
+ return false;
2304
+ }
2305
+ return keysA.every((key) => {
2306
+ const valueA = paramsA[key];
2307
+ const valueB = paramsB[key];
2308
+ if (Array.isArray(valueA) && Array.isArray(valueB)) {
2309
+ if (valueA.length !== valueB.length) {
2310
+ return false;
2311
+ }
2312
+ return valueA.every((entry, idx) => entry === valueB[idx]);
2313
+ }
2314
+ return valueA === valueB;
2315
+ });
2316
+ };
2317
+ const IonRouter = ({ children, registerHistoryListener }) => {
2318
+ const location = useLocation();
2319
+ const navigate = useNavigate();
2320
+ const didMountRef = useRef(false);
2321
+ const locationHistory = useRef(new LocationHistory());
2322
+ const currentTab = useRef(undefined);
2323
+ const viewStack = useRef(new ReactRouterViewStack());
2324
+ const incomingRouteParams = useRef(null);
2325
+ const [routeInfo, setRouteInfo] = useState({
2326
+ id: generateId('routeInfo'),
2327
+ pathname: location.pathname,
2328
+ search: location.search,
2329
+ params: {},
2330
+ });
2331
+ useEffect(() => {
2332
+ if (didMountRef.current) {
629
2333
  return;
630
2334
  }
631
- const routeInfo = this.locationHistory.getCurrentRouteInfoForTab(tab);
632
- const [pathname, search] = path.split('?');
633
- if (routeInfo) {
634
- this.incomingRouteParams = Object.assign(Object.assign({}, routeInfo), { routeAction: 'push', routeDirection: 'none' });
635
- if (routeInfo.pathname === pathname) {
636
- this.incomingRouteParams.routeOptions = routeOptions;
637
- this.props.history.push(routeInfo.pathname + (routeInfo.search || ''));
638
- }
639
- else {
640
- this.incomingRouteParams.pathname = pathname;
641
- this.incomingRouteParams.search = search ? '?' + search : undefined;
642
- this.incomingRouteParams.routeOptions = routeOptions;
643
- this.props.history.push(pathname + (search ? '?' + search : ''));
2335
+ // Seed the history stack with the initial location and begin listening
2336
+ // for future navigations once React has committed the mount. This avoids
2337
+ // duplicate entries when React StrictMode runs an extra render pre-commit.
2338
+ locationHistory.current.add(routeInfo);
2339
+ registerHistoryListener(handleHistoryChange);
2340
+ didMountRef.current = true;
2341
+ }, []);
2342
+ // Sync route params extracted by React Router's path matching back into routeInfo.
2343
+ // The view stack's match may contain params (e.g., :id) not present in the initial routeInfo.
2344
+ useEffect(() => {
2345
+ var _a;
2346
+ const activeView = viewStack.current.findViewItemByRouteInfo(routeInfo, undefined, true);
2347
+ const matchedParams = (_a = activeView === null || activeView === void 0 ? void 0 : activeView.routeData.match) === null || _a === void 0 ? void 0 : _a.params;
2348
+ if (matchedParams) {
2349
+ const paramsCopy = filterUndefinedParams(Object.assign({}, matchedParams));
2350
+ if (areParamsEqual(routeInfo.params, paramsCopy)) {
2351
+ return;
644
2352
  }
2353
+ const updatedRouteInfo = Object.assign(Object.assign({}, routeInfo), { params: paramsCopy });
2354
+ locationHistory.current.update(updatedRouteInfo);
2355
+ setRouteInfo(updatedRouteInfo);
645
2356
  }
646
- else {
647
- this.handleNavigate(pathname, 'push', 'none', undefined, routeOptions, tab);
648
- }
649
- }
650
- handleHistoryChange(location, action) {
651
- var _a, _b, _c;
2357
+ }, [routeInfo]);
2358
+ /**
2359
+ * Triggered whenever the history changes, either through user navigation
2360
+ * or programmatic changes. It transforms the raw browser history changes
2361
+ * into `RouteInfo` objects, which are needed Ionic's animations and
2362
+ * navigation patterns.
2363
+ *
2364
+ * @param location The current location object from the history.
2365
+ * @param action The action that triggered the history change.
2366
+ */
2367
+ const handleHistoryChange = (location, action) => {
2368
+ var _a, _b, _c, _d, _e;
652
2369
  let leavingLocationInfo;
653
- if (this.incomingRouteParams) {
654
- if (this.incomingRouteParams.routeAction === 'replace') {
655
- leavingLocationInfo = this.locationHistory.previous();
2370
+ /**
2371
+ * A programmatic navigation was triggered.
2372
+ * e.g., `<Redirect />`, `history.push()`, or `handleNavigate()`
2373
+ */
2374
+ if (incomingRouteParams.current) {
2375
+ /**
2376
+ * The current history entry is overwritten, so the previous entry
2377
+ * is the one we are leaving.
2378
+ */
2379
+ if (((_a = incomingRouteParams.current) === null || _a === void 0 ? void 0 : _a.routeAction) === 'replace') {
2380
+ leavingLocationInfo = locationHistory.current.previous();
656
2381
  }
657
2382
  else {
658
- leavingLocationInfo = this.locationHistory.current();
2383
+ // If the action is 'push' or 'pop', we want to use the current route.
2384
+ leavingLocationInfo = locationHistory.current.current();
659
2385
  }
660
2386
  }
661
2387
  else {
662
- leavingLocationInfo = this.locationHistory.current();
2388
+ /**
2389
+ * An external navigation was triggered
2390
+ * e.g., browser back/forward button or direct link
2391
+ *
2392
+ * The leaving location is the current route.
2393
+ */
2394
+ leavingLocationInfo = locationHistory.current.current();
663
2395
  }
664
2396
  const leavingUrl = leavingLocationInfo.pathname + leavingLocationInfo.search;
665
2397
  if (leavingUrl !== location.pathname) {
666
- if (!this.incomingRouteParams) {
2398
+ if (!incomingRouteParams.current) {
2399
+ // Determine if the destination is a tab route by checking if it matches
2400
+ // the pattern of tab routes (containing /tabs/ in the path)
2401
+ const isTabRoute = /\/tabs(\/|$)/.test(location.pathname);
2402
+ const tabToUse = isTabRoute ? currentTab.current : undefined;
2403
+ // If we're leaving tabs entirely, clear the current tab
2404
+ if (!isTabRoute && currentTab.current) {
2405
+ currentTab.current = undefined;
2406
+ }
2407
+ /**
2408
+ * A `REPLACE` action can be triggered by React Router's
2409
+ * `<Redirect />` component.
2410
+ */
667
2411
  if (action === 'REPLACE') {
668
- this.incomingRouteParams = {
2412
+ incomingRouteParams.current = {
669
2413
  routeAction: 'replace',
670
2414
  routeDirection: 'none',
671
- tab: this.currentTab,
2415
+ tab: tabToUse,
672
2416
  };
673
2417
  }
2418
+ /**
2419
+ * A `POP` action can be triggered by the browser's back/forward
2420
+ * button.
2421
+ */
674
2422
  if (action === 'POP') {
675
- const currentRoute = this.locationHistory.current();
2423
+ const currentRoute = locationHistory.current.current();
2424
+ /**
2425
+ * Check if the current route was "pushed" by a previous route
2426
+ * (indicates a linear history path).
2427
+ */
676
2428
  if (currentRoute && currentRoute.pushedByRoute) {
677
- const prevInfo = this.locationHistory.findLastLocation(currentRoute);
678
- this.incomingRouteParams = Object.assign(Object.assign({}, prevInfo), { routeAction: 'pop', routeDirection: 'back' });
2429
+ const prevInfo = locationHistory.current.findLastLocation(currentRoute);
2430
+ incomingRouteParams.current = Object.assign(Object.assign({}, prevInfo), { routeAction: 'pop', routeDirection: 'back' });
2431
+ // It's a non-linear history path like a direct link.
679
2432
  }
680
2433
  else {
681
- this.incomingRouteParams = {
2434
+ incomingRouteParams.current = {
682
2435
  routeAction: 'pop',
683
2436
  routeDirection: 'none',
684
- tab: this.currentTab,
2437
+ tab: tabToUse,
685
2438
  };
686
2439
  }
687
2440
  }
688
- if (!this.incomingRouteParams) {
689
- this.incomingRouteParams = {
2441
+ if (!incomingRouteParams.current) {
2442
+ const state = location.state;
2443
+ incomingRouteParams.current = {
690
2444
  routeAction: 'push',
691
- routeDirection: ((_a = location.state) === null || _a === void 0 ? void 0 : _a.direction) || 'forward',
692
- routeOptions: (_b = location.state) === null || _b === void 0 ? void 0 : _b.routerOptions,
693
- tab: this.currentTab,
2445
+ routeDirection: (state === null || state === void 0 ? void 0 : state.direction) || 'forward',
2446
+ routeOptions: state === null || state === void 0 ? void 0 : state.routerOptions,
2447
+ tab: tabToUse,
694
2448
  };
695
2449
  }
696
2450
  }
697
2451
  let routeInfo;
698
- if ((_c = this.incomingRouteParams) === null || _c === void 0 ? void 0 : _c.id) {
699
- routeInfo = Object.assign(Object.assign({}, this.incomingRouteParams), { lastPathname: leavingLocationInfo.pathname });
700
- this.locationHistory.add(routeInfo);
2452
+ // If we're navigating away from tabs to a non-tab route, clear the current tab
2453
+ if (!/\/tabs(\/|$)/.test(location.pathname) && currentTab.current) {
2454
+ currentTab.current = undefined;
2455
+ }
2456
+ /**
2457
+ * An existing id indicates that it's re-activating an existing route.
2458
+ * e.g., tab switching or navigating back to a previous route
2459
+ */
2460
+ if ((_b = incomingRouteParams.current) === null || _b === void 0 ? void 0 : _b.id) {
2461
+ routeInfo = Object.assign(Object.assign({}, incomingRouteParams.current), { lastPathname: leavingLocationInfo.pathname });
2462
+ locationHistory.current.add(routeInfo);
2463
+ /**
2464
+ * A new route is being created since it's not re-activating
2465
+ * an existing route.
2466
+ */
701
2467
  }
702
2468
  else {
703
- const isPushed = this.incomingRouteParams.routeAction === 'push' && this.incomingRouteParams.routeDirection === 'forward';
704
- routeInfo = Object.assign(Object.assign({ id: generateId('routeInfo') }, this.incomingRouteParams), { lastPathname: leavingLocationInfo.pathname, pathname: location.pathname, search: location.search, params: this.props.match.params, prevRouteLastPathname: leavingLocationInfo.lastPathname });
2469
+ const isPushed = ((_c = incomingRouteParams.current) === null || _c === void 0 ? void 0 : _c.routeAction) === 'push' &&
2470
+ incomingRouteParams.current.routeDirection === 'forward';
2471
+ routeInfo = Object.assign(Object.assign({ id: generateId('routeInfo') }, incomingRouteParams.current), { lastPathname: leavingLocationInfo.pathname, pathname: location.pathname, search: location.search, params: ((_d = incomingRouteParams.current) === null || _d === void 0 ? void 0 : _d.params)
2472
+ ? filterUndefinedParams(incomingRouteParams.current.params)
2473
+ : {}, prevRouteLastPathname: leavingLocationInfo.lastPathname });
705
2474
  if (isPushed) {
706
- routeInfo.tab = leavingLocationInfo.tab;
2475
+ // Only inherit tab from leaving route if we don't already have one.
2476
+ // This preserves tab context for same-tab navigation while allowing cross-tab navigation.
2477
+ routeInfo.tab = routeInfo.tab || leavingLocationInfo.tab;
707
2478
  routeInfo.pushedByRoute = leavingLocationInfo.pathname;
2479
+ // Triggered by a browser back button or handleNavigateBack.
708
2480
  }
709
2481
  else if (routeInfo.routeAction === 'pop') {
710
- const r = this.locationHistory.findLastLocation(routeInfo);
2482
+ // Find the route that pushed this one.
2483
+ const r = locationHistory.current.findLastLocation(routeInfo);
711
2484
  routeInfo.pushedByRoute = r === null || r === void 0 ? void 0 : r.pushedByRoute;
2485
+ // Navigating to a new tab.
712
2486
  }
713
2487
  else if (routeInfo.routeAction === 'push' && routeInfo.tab !== leavingLocationInfo.tab) {
714
- // If we are switching tabs grab the last route info for the tab and use its pushedByRoute
715
- const lastRoute = this.locationHistory.getCurrentRouteInfoForTab(routeInfo.tab);
716
- routeInfo.pushedByRoute = lastRoute === null || lastRoute === void 0 ? void 0 : lastRoute.pushedByRoute;
2488
+ /**
2489
+ * If we are switching tabs grab the last route info for the
2490
+ * tab and use its `pushedByRoute`.
2491
+ */
2492
+ const lastRoute = locationHistory.current.getCurrentRouteInfoForTab(routeInfo.tab);
2493
+ /**
2494
+ * Tab bar switches (direction 'none') should not create cross-tab back
2495
+ * navigation. Only inherit pushedByRoute from the tab's own history.
2496
+ */
2497
+ if (routeInfo.routeDirection === 'none') {
2498
+ routeInfo.pushedByRoute = lastRoute === null || lastRoute === void 0 ? void 0 : lastRoute.pushedByRoute;
2499
+ }
2500
+ else {
2501
+ routeInfo.pushedByRoute = (_e = lastRoute === null || lastRoute === void 0 ? void 0 : lastRoute.pushedByRoute) !== null && _e !== void 0 ? _e : leavingLocationInfo.pathname;
2502
+ }
2503
+ // Triggered by `history.replace()` or a `<Redirect />` component, etc.
717
2504
  }
718
2505
  else if (routeInfo.routeAction === 'replace') {
719
- // Make sure to set the lastPathname, etc.. to the current route so the page transitions out
720
- const currentRouteInfo = this.locationHistory.current();
721
2506
  /**
722
- * If going from /home to /child, then replacing from
723
- * /child to /home, we don't want the route info to
724
- * say that /home was pushed by /home which is not correct.
2507
+ * Make sure to set the `lastPathname`, etc.. to the current route
2508
+ * so the page transitions out.
2509
+ */
2510
+ const currentRouteInfo = locationHistory.current.current();
2511
+ /**
2512
+ * Special handling for `replace` to ensure correct `pushedByRoute`
2513
+ * and `lastPathname`.
2514
+ *
2515
+ * If going from `/home` to `/child`, then replacing from
2516
+ * `/child` to `/home`, we don't want the route info to
2517
+ * say that `/home` was pushed by `/home` which is not correct.
725
2518
  */
726
2519
  const currentPushedBy = currentRouteInfo === null || currentRouteInfo === void 0 ? void 0 : currentRouteInfo.pushedByRoute;
727
2520
  const pushedByRoute = currentPushedBy !== undefined && currentPushedBy !== routeInfo.pathname
@@ -739,46 +2532,107 @@ class IonRouterInner extends React.PureComponent {
739
2532
  routeInfo.routeDirection = routeInfo.routeDirection || (currentRouteInfo === null || currentRouteInfo === void 0 ? void 0 : currentRouteInfo.routeDirection);
740
2533
  routeInfo.routeAnimation = routeInfo.routeAnimation || (currentRouteInfo === null || currentRouteInfo === void 0 ? void 0 : currentRouteInfo.routeAnimation);
741
2534
  }
742
- this.locationHistory.add(routeInfo);
2535
+ locationHistory.current.add(routeInfo);
743
2536
  }
744
- this.setState({
745
- routeInfo,
746
- });
2537
+ setRouteInfo(routeInfo);
747
2538
  }
748
- this.incomingRouteParams = undefined;
749
- }
2539
+ incomingRouteParams.current = null;
2540
+ };
750
2541
  /**
751
- * history@4.x uses goBack(), history@5.x uses back()
752
- * TODO: If support for React Router <=5 is dropped
753
- * this logic is no longer needed. We can just
754
- * assume back() is available.
2542
+ * Resets the specified tab to its initial, root route.
2543
+ *
2544
+ * @param tab The tab to reset.
2545
+ * @param originalHref The original href for the tab.
2546
+ * @param originalRouteOptions The original route options for the tab.
755
2547
  */
756
- handleNativeBack() {
757
- const history = this.props.history;
758
- const goBack = history.goBack || history.back;
759
- goBack();
760
- }
761
- handleNavigate(path, routeAction, routeDirection, routeAnimation, routeOptions, tab) {
762
- this.incomingRouteParams = Object.assign(this.incomingRouteParams || {}, {
763
- routeAction,
764
- routeDirection,
765
- routeOptions,
766
- routeAnimation,
767
- tab,
768
- });
769
- if (routeAction === 'push') {
770
- this.props.history.push(path);
2548
+ const handleResetTab = (tab, originalHref, originalRouteOptions) => {
2549
+ const routeInfo = locationHistory.current.getFirstRouteInfoForTab(tab);
2550
+ if (routeInfo) {
2551
+ const newRouteInfo = Object.assign({}, routeInfo);
2552
+ newRouteInfo.pathname = originalHref;
2553
+ newRouteInfo.routeOptions = originalRouteOptions;
2554
+ incomingRouteParams.current = Object.assign(Object.assign({}, newRouteInfo), { routeAction: 'pop', routeDirection: 'back' });
2555
+ navigate(newRouteInfo.pathname + (newRouteInfo.search || ''));
2556
+ }
2557
+ };
2558
+ /**
2559
+ * Handles tab changes.
2560
+ *
2561
+ * @param tab The tab to switch to.
2562
+ * @param path The new path for the tab.
2563
+ * @param routeOptions Additional route options.
2564
+ */
2565
+ const handleChangeTab = (tab, path, routeOptions) => {
2566
+ if (!path) {
2567
+ return;
2568
+ }
2569
+ const routeInfo = locationHistory.current.getCurrentRouteInfoForTab(tab);
2570
+ const [pathname, search] = path.split('?');
2571
+ // User has navigated to the current tab before.
2572
+ if (routeInfo) {
2573
+ const routeParams = Object.assign(Object.assign({}, routeInfo), { routeAction: 'push', routeDirection: 'none' });
2574
+ /**
2575
+ * User is navigating to the same tab.
2576
+ * e.g., `/tabs/home` → `/tabs/home`
2577
+ */
2578
+ if (routeInfo.pathname === pathname) {
2579
+ incomingRouteParams.current = Object.assign(Object.assign({}, routeParams), { routeOptions });
2580
+ navigate(routeInfo.pathname + (routeInfo.search || ''));
2581
+ /**
2582
+ * User is navigating to a different tab.
2583
+ * e.g., `/tabs/home` → `/tabs/settings`
2584
+ */
2585
+ }
2586
+ else {
2587
+ incomingRouteParams.current = Object.assign(Object.assign({}, routeParams), { pathname, search: search ? '?' + search : undefined, routeOptions });
2588
+ navigate(pathname + (search ? '?' + search : ''));
2589
+ }
2590
+ // User has not navigated to this tab before.
771
2591
  }
772
2592
  else {
773
- this.props.history.replace(path);
2593
+ handleNavigate(pathname, 'push', 'none', undefined, routeOptions, tab);
774
2594
  }
775
- }
776
- handleNavigateBack(defaultHref = '/', routeAnimation) {
2595
+ };
2596
+ /**
2597
+ * Set the current active tab in `locationHistory`.
2598
+ * This is crucial for maintaining tab history since each tab has
2599
+ * its own navigation stack.
2600
+ *
2601
+ * @param tab The tab to set as active.
2602
+ */
2603
+ const handleSetCurrentTab = (tab) => {
2604
+ currentTab.current = tab;
2605
+ const ri = Object.assign({}, locationHistory.current.current());
2606
+ if (ri.tab !== tab) {
2607
+ ri.tab = tab;
2608
+ locationHistory.current.update(ri);
2609
+ }
2610
+ };
2611
+ /**
2612
+ * Handles the native back button press.
2613
+ * It's usually called when a user presses the platform-native back action.
2614
+ */
2615
+ const handleNativeBack = () => {
2616
+ navigate(-1);
2617
+ };
2618
+ /**
2619
+ * Used to manage the back navigation within the Ionic React's routing
2620
+ * system. It's deeply integrated with Ionic's view lifecycle, animations,
2621
+ * and its custom history tracking (`locationHistory`) to provide a
2622
+ * native-like transition and maintain correct application state.
2623
+ *
2624
+ * @param defaultHref The fallback URL to navigate to if there's no
2625
+ * previous entry in the `locationHistory` stack.
2626
+ * @param routeAnimation A custom animation builder to override the
2627
+ * default "back" animation.
2628
+ */
2629
+ const handleNavigateBack = (defaultHref = '/', routeAnimation) => {
777
2630
  const config = getConfig();
778
2631
  defaultHref = defaultHref ? defaultHref : config && config.get('backButtonDefaultHref');
779
- const routeInfo = this.locationHistory.current();
2632
+ const routeInfo = locationHistory.current.current();
2633
+ // It's a linear navigation.
780
2634
  if (routeInfo && routeInfo.pushedByRoute) {
781
- const prevInfo = this.locationHistory.findLastLocation(routeInfo);
2635
+ const prevInfo = locationHistory.current.findLastLocation(routeInfo);
782
2636
  if (prevInfo) {
783
2637
  /**
784
2638
  * This needs to be passed to handleNavigate
@@ -786,160 +2640,235 @@ class IonRouterInner extends React.PureComponent {
786
2640
  * will be overridden.
787
2641
  */
788
2642
  const incomingAnimation = routeAnimation || routeInfo.routeAnimation;
789
- this.incomingRouteParams = Object.assign(Object.assign({}, prevInfo), { routeAction: 'pop', routeDirection: 'back', routeAnimation: incomingAnimation });
790
- if (routeInfo.lastPathname === routeInfo.pushedByRoute ||
791
- /**
792
- * We need to exclude tab switches/tab
793
- * context changes here because tabbed
794
- * navigation is not linear, but router.back()
795
- * will go back in a linear fashion.
796
- */
797
- (prevInfo.pathname === routeInfo.pushedByRoute && routeInfo.tab === '' && prevInfo.tab === '')) {
798
- /**
799
- * history@4.x uses goBack(), history@5.x uses back()
800
- * TODO: If support for React Router <=5 is dropped
801
- * this logic is no longer needed. We can just
802
- * assume back() is available.
803
- */
804
- const history = this.props.history;
805
- const goBack = history.goBack || history.back;
806
- goBack();
2643
+ incomingRouteParams.current = Object.assign(Object.assign({}, prevInfo), { routeAction: 'pop', routeDirection: 'back', routeAnimation: incomingAnimation });
2644
+ /**
2645
+ * Check if it's a simple linear back navigation (not tabbed).
2646
+ * e.g., `/home` `/settings` back to `/home`
2647
+ */
2648
+ const condition1 = routeInfo.lastPathname === routeInfo.pushedByRoute;
2649
+ const condition2 = prevInfo.pathname === routeInfo.pushedByRoute && routeInfo.tab === '' && prevInfo.tab === '';
2650
+ if (condition1 || condition2) {
2651
+ navigate(-1);
807
2652
  }
808
2653
  else {
809
- this.handleNavigate(prevInfo.pathname + (prevInfo.search || ''), 'pop', 'back', incomingAnimation);
2654
+ /**
2655
+ * It's a non-linear back navigation.
2656
+ * e.g., direct link or tab switch or nested navigation with redirects
2657
+ */
2658
+ handleNavigate(prevInfo.pathname + (prevInfo.search || ''), 'pop', 'back', incomingAnimation);
810
2659
  }
2660
+ /**
2661
+ * `pushedByRoute` exists, but no corresponding previous entry in
2662
+ * the history stack.
2663
+ */
811
2664
  }
812
2665
  else {
813
- this.handleNavigate(defaultHref, 'pop', 'back', routeAnimation);
2666
+ handleNavigate(defaultHref, 'pop', 'back', routeAnimation);
814
2667
  }
2668
+ /**
2669
+ * No `pushedByRoute` (e.g., initial page load or tab root).
2670
+ * Tabs with no back history should not navigate.
2671
+ */
815
2672
  }
816
2673
  else {
817
- this.handleNavigate(defaultHref, 'pop', 'back', routeAnimation);
818
- }
819
- }
820
- handleResetTab(tab, originalHref, originalRouteOptions) {
821
- const routeInfo = this.locationHistory.getFirstRouteInfoForTab(tab);
822
- if (routeInfo) {
823
- const newRouteInfo = Object.assign({}, routeInfo);
824
- newRouteInfo.pathname = originalHref;
825
- newRouteInfo.routeOptions = originalRouteOptions;
826
- this.incomingRouteParams = Object.assign(Object.assign({}, newRouteInfo), { routeAction: 'pop', routeDirection: 'back' });
827
- this.props.history.push(newRouteInfo.pathname + (newRouteInfo.search || ''));
2674
+ if (routeInfo && routeInfo.tab) {
2675
+ return;
2676
+ }
2677
+ handleNavigate(defaultHref, 'pop', 'back', routeAnimation);
828
2678
  }
829
- }
830
- handleSetCurrentTab(tab) {
831
- this.currentTab = tab;
832
- const ri = Object.assign({}, this.locationHistory.current());
833
- if (ri.tab !== tab) {
834
- ri.tab = tab;
835
- this.locationHistory.update(ri);
2679
+ };
2680
+ /**
2681
+ * Used to programmatically navigate through the app.
2682
+ *
2683
+ * @param path The path to navigate to.
2684
+ * @param routeAction The action to take (push, replace, etc.).
2685
+ * @param routeDirection The direction of the navigation (forward,
2686
+ * back, etc.).
2687
+ * @param routeAnimation The animation to use for the transition.
2688
+ * @param routeOptions Additional options for the route.
2689
+ * @param tab The tab to navigate to, if applicable.
2690
+ */
2691
+ const handleNavigate = (path, routeAction, routeDirection, routeAnimation, routeOptions, tab) => {
2692
+ var _a;
2693
+ const normalizedRouteDirection = routeAction === 'push' && routeDirection === undefined ? 'forward' : routeDirection;
2694
+ // When navigating from tabs context, we need to determine if the destination
2695
+ // is also within tabs. If not, we should clear the tab context.
2696
+ let navigationTab = tab;
2697
+ // If no explicit tab is provided and we're in a tab context,
2698
+ // check if the destination path is outside of the current tab context
2699
+ if (!tab && currentTab.current && path) {
2700
+ // Get the current route info to understand where we are
2701
+ const currentRoute = locationHistory.current.current();
2702
+ // If we're navigating from a tab route to a completely different path structure,
2703
+ // we should clear the tab context. This is a simplified check that assumes
2704
+ // tab routes share a common parent path.
2705
+ if (currentRoute && currentRoute.pathname) {
2706
+ // Extract the base tab path (e.g., /routing/tabs from /routing/tabs/home)
2707
+ const tabBaseMatch = currentRoute.pathname.match(/^(.*\/tabs)/);
2708
+ if (tabBaseMatch) {
2709
+ const tabBasePath = tabBaseMatch[1];
2710
+ // If the new path doesn't start with the tab base path, we're leaving tabs
2711
+ if (!path.startsWith(tabBasePath)) {
2712
+ currentTab.current = undefined;
2713
+ navigationTab = undefined;
2714
+ }
2715
+ else {
2716
+ // Still within tabs, preserve the tab context
2717
+ navigationTab = currentTab.current;
2718
+ }
2719
+ }
2720
+ }
836
2721
  }
837
- }
838
- render() {
839
- return (React.createElement(RouteManagerContext.Provider, { value: this.routeMangerContextState },
840
- React.createElement(NavManager, { ionRoute: IonRouteInner, ionRedirect: {}, stackManager: StackManager, routeInfo: this.state.routeInfo, onNativeBack: this.handleNativeBack, onNavigateBack: this.handleNavigateBack, onNavigate: this.handleNavigate, onSetCurrentTab: this.handleSetCurrentTab, onChangeTab: this.handleChangeTab, onResetTab: this.handleResetTab, locationHistory: this.locationHistory }, this.props.children)));
841
- }
842
- }
843
- const IonRouter = withRouter(IonRouterInner);
2722
+ const baseParams = (_a = incomingRouteParams.current) !== null && _a !== void 0 ? _a : {};
2723
+ incomingRouteParams.current = Object.assign(Object.assign({}, baseParams), { routeAction, routeDirection: normalizedRouteDirection, routeOptions,
2724
+ routeAnimation, tab: navigationTab });
2725
+ navigate(path, { replace: routeAction !== 'push' });
2726
+ };
2727
+ const routeMangerContextValue = {
2728
+ canGoBack: () => locationHistory.current.canGoBack(),
2729
+ clearOutlet: viewStack.current.clear,
2730
+ findViewItemByPathname: viewStack.current.findViewItemByPathname,
2731
+ getChildrenToRender: viewStack.current.getChildrenToRender,
2732
+ getViewItemsForOutlet: viewStack.current.getViewItemsForOutlet.bind(viewStack.current),
2733
+ goBack: () => handleNavigateBack(),
2734
+ createViewItem: viewStack.current.createViewItem,
2735
+ findViewItemByRouteInfo: viewStack.current.findViewItemByRouteInfo,
2736
+ findLeavingViewItemByRouteInfo: viewStack.current.findLeavingViewItemByRouteInfo,
2737
+ addViewItem: viewStack.current.add,
2738
+ unMountViewItem: viewStack.current.remove,
2739
+ };
2740
+ return (React.createElement(RouteManagerContext.Provider, { value: routeMangerContextValue },
2741
+ React.createElement(NavManager, { ionRoute: IonRouteInner, ionRedirect: {}, stackManager: StackManager, routeInfo: routeInfo, onNativeBack: handleNativeBack, onNavigateBack: handleNavigateBack, onNavigate: handleNavigate, onSetCurrentTab: handleSetCurrentTab, onChangeTab: handleChangeTab, onResetTab: handleResetTab, locationHistory: locationHistory.current }, children)));
2742
+ };
844
2743
  IonRouter.displayName = 'IonRouter';
845
2744
 
846
- class IonReactRouter extends React.Component {
847
- constructor(props) {
848
- super(props);
849
- const { history } = props, rest = __rest(props, ["history"]);
850
- this.history = history || createBrowserHistory(rest);
851
- this.history.listen(this.handleHistoryChange.bind(this));
852
- this.registerHistoryListener = this.registerHistoryListener.bind(this);
853
- }
2745
+ /**
2746
+ * `IonReactRouter` facilitates the integration of Ionic's specific
2747
+ * navigation and UI management with the standard React Router mechanisms,
2748
+ * allowing an inner Ionic-specific router (`IonRouter`) to react to
2749
+ * navigation events.
2750
+ */
2751
+ /**
2752
+ * This component acts as a bridge to ensure React Router hooks like
2753
+ * `useLocation` and `useNavigationType` are called within the valid
2754
+ * context of a `<BrowserRouter>`.
2755
+ *
2756
+ * It was split from `IonReactRouter` because these hooks must be
2757
+ * descendants of a `<Router>` component, which `BrowserRouter` provides.
2758
+ */
2759
+ const RouterContent$2 = ({ children }) => {
2760
+ const location = useLocation();
2761
+ const navigationType = useNavigationType();
2762
+ const historyListenHandler = useRef();
2763
+ const registerHistoryListener = useCallback((cb) => {
2764
+ historyListenHandler.current = cb;
2765
+ }, []);
854
2766
  /**
855
- * history@4.x passes separate location and action
856
- * params. history@5.x passes location and action
857
- * together as a single object.
858
- * TODO: If support for React Router <=5 is dropped
859
- * this logic is no longer needed. We can just assume
860
- * a single object with both location and action.
2767
+ * Processes navigation changes within the application.
2768
+ *
2769
+ * Its purpose is to relay the current `location` and the associated
2770
+ * `action` ('PUSH', 'POP', or 'REPLACE') to any registered listeners,
2771
+ * primarily for `IonRouter` to manage Ionic-specific UI updates and
2772
+ * navigation stack behavior.
2773
+ *
2774
+ * @param loc The current browser history location object.
2775
+ * @param act The type of navigation action ('PUSH', 'POP', or
2776
+ * 'REPLACE').
861
2777
  */
862
- handleHistoryChange(location, action) {
863
- const locationValue = location.location || location;
864
- const actionValue = location.action || action;
865
- if (this.historyListenHandler) {
866
- this.historyListenHandler(locationValue, actionValue);
2778
+ const handleHistoryChange = useCallback((loc, act) => {
2779
+ if (historyListenHandler.current) {
2780
+ historyListenHandler.current(loc, act);
867
2781
  }
868
- }
869
- registerHistoryListener(cb) {
870
- this.historyListenHandler = cb;
871
- }
872
- render() {
873
- const _a = this.props, { children } = _a, props = __rest(_a, ["children"]);
874
- return (React.createElement(Router, Object.assign({ history: this.history }, props),
875
- React.createElement(IonRouter, { registerHistoryListener: this.registerHistoryListener }, children)));
876
- }
877
- }
2782
+ }, []);
2783
+ useEffect(() => {
2784
+ handleHistoryChange(location, navigationType);
2785
+ }, [location, navigationType, handleHistoryChange]);
2786
+ return React.createElement(IonRouter, { registerHistoryListener: registerHistoryListener }, children);
2787
+ };
2788
+ const IonReactRouter = (_a) => {
2789
+ var { children } = _a, browserRouterProps = __rest(_a, ["children"]);
2790
+ return (React.createElement(BrowserRouter, Object.assign({}, browserRouterProps),
2791
+ React.createElement(RouterContent$2, null, children)));
2792
+ };
878
2793
 
879
- class IonReactMemoryRouter extends React.Component {
880
- constructor(props) {
881
- super(props);
882
- this.history = props.history;
883
- this.history.listen(this.handleHistoryChange.bind(this));
884
- this.registerHistoryListener = this.registerHistoryListener.bind(this);
885
- }
2794
+ /**
2795
+ * `IonReactMemoryRouter` provides a way to use `react-router` in
2796
+ * environments where a traditional browser history (like `BrowserRouter`)
2797
+ * isn't available or desirable.
2798
+ */
2799
+ const RouterContent$1 = ({ children }) => {
2800
+ const location = useLocation$1();
2801
+ const navigationType = useNavigationType$1();
2802
+ const historyListenHandler = useRef();
2803
+ const registerHistoryListener = (cb) => {
2804
+ historyListenHandler.current = cb;
2805
+ };
886
2806
  /**
887
- * history@4.x passes separate location and action
888
- * params. history@5.x passes location and action
889
- * together as a single object.
890
- * TODO: If support for React Router <=5 is dropped
891
- * this logic is no longer needed. We can just assume
892
- * a single object with both location and action.
2807
+ * Processes navigation changes within the application.
2808
+ *
2809
+ * Its purpose is to relay the current `location` and the associated
2810
+ * `action` ('PUSH', 'POP', or 'REPLACE') to any registered listeners,
2811
+ * primarily for `IonRouter` to manage Ionic-specific UI updates and
2812
+ * navigation stack behavior.
2813
+ *
2814
+ * @param location The current browser history location object.
2815
+ * @param action The type of navigation action ('PUSH', 'POP', or
2816
+ * 'REPLACE').
893
2817
  */
894
- handleHistoryChange(location, action) {
895
- const locationValue = location.location || location;
896
- const actionValue = location.action || action;
897
- if (this.historyListenHandler) {
898
- this.historyListenHandler(locationValue, actionValue);
2818
+ const handleHistoryChange = (location, action) => {
2819
+ if (historyListenHandler.current) {
2820
+ historyListenHandler.current(location, action);
899
2821
  }
900
- }
901
- registerHistoryListener(cb) {
902
- this.historyListenHandler = cb;
903
- }
904
- render() {
905
- const _a = this.props, { children } = _a, props = __rest(_a, ["children"]);
906
- return (React.createElement(Router$1, Object.assign({}, props),
907
- React.createElement(IonRouter, { registerHistoryListener: this.registerHistoryListener }, children)));
908
- }
909
- }
2822
+ };
2823
+ useEffect(() => {
2824
+ handleHistoryChange(location, navigationType);
2825
+ }, [location, navigationType]);
2826
+ return React.createElement(IonRouter, { registerHistoryListener: registerHistoryListener }, children);
2827
+ };
2828
+ const IonReactMemoryRouter = (_a) => {
2829
+ var { children } = _a, routerProps = __rest(_a, ["children"]);
2830
+ return (React.createElement(MemoryRouter, Object.assign({}, routerProps),
2831
+ React.createElement(RouterContent$1, null, children)));
2832
+ };
910
2833
 
911
- class IonReactHashRouter extends React.Component {
912
- constructor(props) {
913
- super(props);
914
- const { history } = props, rest = __rest(props, ["history"]);
915
- this.history = history || createHashHistory(rest);
916
- this.history.listen(this.handleHistoryChange.bind(this));
917
- this.registerHistoryListener = this.registerHistoryListener.bind(this);
918
- }
2834
+ /**
2835
+ * `IonReactHashRouter` provides a way to use hash-based routing in Ionic
2836
+ * React applications.
2837
+ */
2838
+ const RouterContent = ({ children }) => {
2839
+ const location = useLocation();
2840
+ const navigationType = useNavigationType();
2841
+ const historyListenHandler = useRef();
2842
+ const registerHistoryListener = (cb) => {
2843
+ historyListenHandler.current = cb;
2844
+ };
919
2845
  /**
920
- * history@4.x passes separate location and action
921
- * params. history@5.x passes location and action
922
- * together as a single object.
923
- * TODO: If support for React Router <=5 is dropped
924
- * this logic is no longer needed. We can just assume
925
- * a single object with both location and action.
2846
+ * Processes navigation changes within the application.
2847
+ *
2848
+ * Its purpose is to relay the current `location` and the associated
2849
+ * `action` ('PUSH', 'POP', or 'REPLACE') to any registered listeners,
2850
+ * primarily for `IonRouter` to manage Ionic-specific UI updates and
2851
+ * navigation stack behavior.
2852
+ *
2853
+ * @param location The current browser history location object.
2854
+ * @param action The type of navigation action ('PUSH', 'POP', or
2855
+ * 'REPLACE').
926
2856
  */
927
- handleHistoryChange(location, action) {
928
- const locationValue = location.location || location;
929
- const actionValue = location.action || action;
930
- if (this.historyListenHandler) {
931
- this.historyListenHandler(locationValue, actionValue);
2857
+ const handleHistoryChange = (location, action) => {
2858
+ if (historyListenHandler.current) {
2859
+ historyListenHandler.current(location, action);
932
2860
  }
933
- }
934
- registerHistoryListener(cb) {
935
- this.historyListenHandler = cb;
936
- }
937
- render() {
938
- const _a = this.props, { children } = _a, props = __rest(_a, ["children"]);
939
- return (React.createElement(Router, Object.assign({ history: this.history }, props),
940
- React.createElement(IonRouter, { registerHistoryListener: this.registerHistoryListener }, children)));
941
- }
942
- }
2861
+ };
2862
+ useEffect(() => {
2863
+ handleHistoryChange(location, navigationType);
2864
+ }, [location, navigationType]);
2865
+ return React.createElement(IonRouter, { registerHistoryListener: registerHistoryListener }, children);
2866
+ };
2867
+ const IonReactHashRouter = (_a) => {
2868
+ var { children } = _a, routerProps = __rest(_a, ["children"]);
2869
+ return (React.createElement(HashRouter, Object.assign({}, routerProps),
2870
+ React.createElement(RouterContent, null, children)));
2871
+ };
943
2872
 
944
2873
  export { IonReactHashRouter, IonReactMemoryRouter, IonReactRouter };
945
2874
  //# sourceMappingURL=index.js.map