@ionic/react-router 8.8.19 → 8.8.20-dev.11787158518.168a2fea

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,245 +1,2187 @@
1
- import { __rest } from 'tslib';
2
- import { createBrowserHistory, createHashHistory } from 'history';
3
- import React from 'react';
4
- import { withRouter, Router } from 'react-router-dom';
5
- 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';
1
+ import React, { useRef, useState, useEffect, useCallback } from 'react';
2
+ import { Route, matchPath as matchPath$1, Routes, Navigate, UNSAFE_RouteContext, matchRoutes, useLocation, useNavigate, BrowserRouter, useNavigationType, HashRouter } from 'react-router-dom';
3
+ import { IonRoute, ViewStacks, generateId, ViewLifeCycleManager, createDebugLogger, StackContext, RouteManagerContext, LocationHistory, NavManager, getConfig } from '@ionic/react';
4
+ import { MemoryRouter, useLocation as useLocation$1, useNavigationType as useNavigationType$1 } from 'react-router';
7
5
 
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,
13
- }
14
- : {}))));
15
- }
16
- }
6
+ const IonRouteInner = ({ path, index, caseSensitive, element }) => {
7
+ return React.createElement(Route, { path: path, index: index, caseSensitive: caseSensitive, element: element });
8
+ };
17
9
 
18
10
  /**
19
- * @see https://v5.reactrouter.com/web/api/matchPath
11
+ * The matchPath function is used only for matching paths, not rendering components or elements.
12
+ * @see https://reactrouter.com/v6/utils/match-path
20
13
  */
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,
14
+ const matchPath = ({ pathname, componentProps }) => {
15
+ const { path, index, ...restProps } = componentProps;
16
+ // Handle index routes - they match when pathname is empty or just "/"
17
+ if (index && !path) {
18
+ if (pathname === '' || pathname === '/') {
19
+ return {
20
+ params: {},
21
+ pathname: pathname,
22
+ pathnameBase: pathname || '/',
23
+ pattern: {
24
+ path: '',
25
+ caseSensitive: false,
26
+ end: true,
27
+ },
28
+ };
29
+ }
30
+ return null;
31
+ }
32
+ // Handle empty path routes - they match when pathname is also empty or just "/"
33
+ if (path === '' || path === undefined) {
34
+ if (pathname === '' || pathname === '/') {
35
+ return {
36
+ params: {},
37
+ pathname: pathname,
38
+ pathnameBase: pathname || '/',
39
+ pattern: {
40
+ path: '',
41
+ caseSensitive: restProps.caseSensitive ?? false,
42
+ end: restProps.end ?? true,
43
+ },
44
+ };
45
+ }
46
+ return null;
47
+ }
48
+ // For relative paths (don't start with '/'), normalize both path and pathname for matching
49
+ if (!path.startsWith('/')) {
50
+ const matchOptions = {
51
+ path: `/${path}`,
52
+ ...restProps,
53
+ };
54
+ if (matchOptions?.end === undefined) {
55
+ matchOptions.end = !path.endsWith('*');
56
+ }
57
+ const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
58
+ const match = matchPath$1(matchOptions, normalizedPathname);
59
+ if (match) {
60
+ // Adjust the match to remove the leading '/' we added
61
+ return {
62
+ ...match,
63
+ pathname: pathname,
64
+ pathnameBase: match.pathnameBase === '/' ? '/' : match.pathnameBase.slice(1),
65
+ pattern: {
66
+ ...match.pattern,
67
+ path: path,
68
+ },
69
+ };
70
+ }
71
+ return null;
72
+ }
73
+ // For absolute paths, use React Router's matcher directly.
74
+ // React Router v6 routes default to `end: true` unless the pattern
75
+ // explicitly opts into wildcards with `*`. Mirror that behaviour so
76
+ // matching parity stays aligned with <Route>.
77
+ const matchOptions = {
31
78
  path,
32
- component,
79
+ ...restProps,
33
80
  };
34
- const match = matchPath$1(pathname, matchProps);
35
- if (!match) {
81
+ if (matchOptions?.end === undefined) {
82
+ matchOptions.end = !path.endsWith('*');
83
+ }
84
+ return matchPath$1(matchOptions, pathname);
85
+ };
86
+ /**
87
+ * Determines the portion of a pathname that a given route pattern should match against.
88
+ * For absolute route patterns we return the full pathname. For relative patterns we
89
+ * strip off the already-matched parent segments so React Router receives the remainder.
90
+ */
91
+ const derivePathnameToMatch = (fullPathname, routePath) => {
92
+ // For absolute or empty routes, use the full pathname as-is
93
+ if (!routePath || routePath === '' || routePath.startsWith('/')) {
94
+ return fullPathname;
95
+ }
96
+ const trimmedPath = fullPathname.startsWith('/') ? fullPathname.slice(1) : fullPathname;
97
+ if (!trimmedPath) {
98
+ // For root-level relative routes (pathname is "/" and routePath is relative),
99
+ // return the full pathname so matchPath can normalize both.
100
+ // This allows routes like <Route path="foo/*" .../> at root level to work correctly.
101
+ return fullPathname;
102
+ }
103
+ const fullSegments = trimmedPath.split('/').filter(Boolean);
104
+ if (fullSegments.length === 0) {
105
+ return '';
106
+ }
107
+ const routeSegments = routePath.split('/').filter(Boolean);
108
+ if (routeSegments.length === 0) {
109
+ return trimmedPath;
110
+ }
111
+ const wildcardIndex = routeSegments.findIndex((segment) => segment === '*' || segment === '**');
112
+ if (wildcardIndex >= 0) {
113
+ const baseSegments = routeSegments.slice(0, wildcardIndex);
114
+ if (baseSegments.length === 0) {
115
+ return trimmedPath;
116
+ }
117
+ const startIndex = fullSegments.findIndex((_, idx) => baseSegments.every((seg, segIdx) => {
118
+ const target = fullSegments[idx + segIdx];
119
+ if (!target) {
120
+ return false;
121
+ }
122
+ if (seg.startsWith(':')) {
123
+ return true;
124
+ }
125
+ return target === seg;
126
+ }));
127
+ if (startIndex >= 0) {
128
+ return fullSegments.slice(startIndex).join('/');
129
+ }
130
+ }
131
+ if (routeSegments.length <= fullSegments.length) {
132
+ return fullSegments.slice(fullSegments.length - routeSegments.length).join('/');
133
+ }
134
+ return fullSegments[fullSegments.length - 1] ?? trimmedPath;
135
+ };
136
+
137
+ /**
138
+ * Finds the longest common prefix among an array of paths.
139
+ * Used to determine the scope of an outlet with absolute routes.
140
+ *
141
+ * @param paths An array of absolute path strings.
142
+ * @returns The common prefix shared by all paths.
143
+ */
144
+ const computeCommonPrefix = (paths) => {
145
+ if (paths.length === 0)
146
+ return '';
147
+ if (paths.length === 1) {
148
+ // For a single path, extract the directory-like prefix
149
+ // e.g., /dynamic-routes/home -> /dynamic-routes
150
+ const segments = paths[0].split('/').filter(Boolean);
151
+ if (segments.length > 1) {
152
+ return '/' + segments.slice(0, -1).join('/');
153
+ }
154
+ return '/' + segments[0];
155
+ }
156
+ // Split all paths into segments
157
+ const segmentArrays = paths.map((p) => p.split('/').filter(Boolean));
158
+ const minLength = Math.min(...segmentArrays.map((s) => s.length));
159
+ const commonSegments = [];
160
+ for (let i = 0; i < minLength; i++) {
161
+ const segment = segmentArrays[0][i];
162
+ // Skip segments with route parameters or wildcards
163
+ if (segment.includes(':') || segment.includes('*')) {
164
+ break;
165
+ }
166
+ const allMatch = segmentArrays.every((s) => s[i] === segment);
167
+ if (allMatch) {
168
+ commonSegments.push(segment);
169
+ }
170
+ else {
171
+ break;
172
+ }
173
+ }
174
+ return commonSegments.length > 0 ? '/' + commonSegments.join('/') : '';
175
+ };
176
+ /**
177
+ * Checks if a pathname falls within the scope of a mount path using
178
+ * segment-aware comparison. Prevents false positives like "/tabs-secondary"
179
+ * matching mount path "/tabs".
180
+ */
181
+ const isPathnameInScope = (pathname, mountPath) => {
182
+ if (mountPath === '/')
183
+ return true;
184
+ return pathname === mountPath || pathname.startsWith(mountPath + '/');
185
+ };
186
+ /**
187
+ * Checks if a route path is a "splat-only" route (just `*` or `/*`).
188
+ */
189
+ const isSplatOnlyRoute = (routePath) => {
190
+ return routePath === '*' || routePath === '/*';
191
+ };
192
+ /**
193
+ * Checks if a route has an embedded wildcard (e.g., "tab1/*" but not "*" or "/*").
194
+ */
195
+ const hasEmbeddedWildcard = (routePath) => {
196
+ return !!routePath && routePath.includes('*') && !isSplatOnlyRoute(routePath);
197
+ };
198
+ /**
199
+ * Checks if a route with an embedded wildcard matches a pathname.
200
+ */
201
+ const matchesEmbeddedWildcardRoute = (route, pathname) => {
202
+ const routePath = route.props.path;
203
+ if (!hasEmbeddedWildcard(routePath)) {
204
+ return false;
205
+ }
206
+ return !!matchPath({ pathname, componentProps: route.props });
207
+ };
208
+ /**
209
+ * Checks if a route is a specific match (not wildcard-only or index).
210
+ */
211
+ const isSpecificRouteMatch = (route, remainingPath) => {
212
+ const routePath = route.props.path;
213
+ if (route.props.index || isSplatOnlyRoute(routePath)) {
36
214
  return false;
37
215
  }
38
- return match;
216
+ return !!matchPath({ pathname: remainingPath, componentProps: route.props });
217
+ };
218
+ /**
219
+ * Analyzes route children to determine their characteristics.
220
+ *
221
+ * @param routeChildren The route children to analyze.
222
+ * @returns Analysis of the route characteristics.
223
+ */
224
+ const analyzeRouteChildren = (routeChildren) => {
225
+ const hasRelativeRoutes = routeChildren.some((route) => {
226
+ const path = route.props.path;
227
+ return path && !path.startsWith('/') && path !== '*';
228
+ });
229
+ const hasIndexRoute = routeChildren.some((route) => route.props.index);
230
+ const hasWildcardRoute = routeChildren.some((route) => {
231
+ const routePath = route.props.path;
232
+ return routePath === '*' || routePath === '/*';
233
+ });
234
+ return { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute, routeChildren };
235
+ };
236
+ /**
237
+ * Checks if any route matches as a specific (non-wildcard, non-index) route.
238
+ */
239
+ const findSpecificMatch = (routeChildren, remainingPath) => {
240
+ return routeChildren.some((route) => isSpecificRouteMatch(route, remainingPath) || matchesEmbeddedWildcardRoute(route, remainingPath));
241
+ };
242
+ /**
243
+ * Returns the first route that matches as a specific (non-wildcard, non-index) route.
244
+ */
245
+ const findFirstSpecificMatchingRoute = (routeChildren, remainingPath) => {
246
+ return routeChildren.find((route) => isSpecificRouteMatch(route, remainingPath) || matchesEmbeddedWildcardRoute(route, remainingPath));
247
+ };
248
+ /**
249
+ * Checks if any specific route could plausibly match the remaining path.
250
+ * Used to determine if we should fall back to a wildcard match.
251
+ *
252
+ * Uses exact first-segment matching: the remaining path's first segment
253
+ * must exactly equal a route's first segment to block the wildcard.
254
+ * The outlet's mount path is always known from React Router's RouteContext,
255
+ * so no heuristic-based discovery is needed.
256
+ */
257
+ const couldSpecificRouteMatch = (routeChildren, remainingPath) => {
258
+ const remainingFirstSegment = remainingPath.split('/')[0];
259
+ return routeChildren.some((route) => {
260
+ const routePath = route.props.path;
261
+ if (!routePath || routePath === '*' || routePath === '/*')
262
+ return false;
263
+ if (route.props.index)
264
+ return false;
265
+ const routeFirstSegment = routePath.split('/')[0].replace(/[*:]/g, '');
266
+ if (!routeFirstSegment)
267
+ return false;
268
+ return routeFirstSegment === remainingFirstSegment;
269
+ });
270
+ };
271
+ /**
272
+ * Determines the best parent path from the available matches.
273
+ * Priority: specific > wildcard > index
274
+ */
275
+ const selectBestMatch = (specificMatch, wildcardMatch, indexMatch) => {
276
+ return specificMatch ?? wildcardMatch ?? indexMatch;
277
+ };
278
+ /**
279
+ * Handles outlets with only absolute routes by computing their common prefix.
280
+ */
281
+ const computeAbsoluteRoutesParentPath = (routeChildren, currentPathname, outletMountPath) => {
282
+ const absolutePathRoutes = routeChildren.filter((route) => {
283
+ const path = route.props.path;
284
+ return path && path.startsWith('/');
285
+ });
286
+ if (absolutePathRoutes.length === 0) {
287
+ return undefined;
288
+ }
289
+ const absolutePaths = absolutePathRoutes.map((r) => r.props.path);
290
+ const commonPrefix = computeCommonPrefix(absolutePaths);
291
+ if (!commonPrefix || commonPrefix === '/') {
292
+ return undefined;
293
+ }
294
+ const newOutletMountPath = outletMountPath || commonPrefix;
295
+ if (!currentPathname.startsWith(commonPrefix)) {
296
+ return { parentPath: undefined, outletMountPath: newOutletMountPath };
297
+ }
298
+ return { parentPath: commonPrefix, outletMountPath: newOutletMountPath };
299
+ };
300
+ /**
301
+ * Computes the parent path for a nested outlet based on the current pathname
302
+ * and the outlet's route configuration.
303
+ *
304
+ * When the mount path is known (seeded from React Router's RouteContext), the
305
+ * parent path is simply the mount path — no iterative discovery needed. The
306
+ * iterative fallback only runs for outlets where RouteContext doesn't provide
307
+ * a parent match (typically root-level outlets on first render).
308
+ *
309
+ * @param options The options for computing the parent path.
310
+ * @returns The computed parent path result.
311
+ */
312
+ const computeParentPath = (options) => {
313
+ const { currentPathname, outletMountPath, routeChildren, hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = options;
314
+ // If pathname is outside the established mount path scope, skip computation.
315
+ // Use segment-aware comparison: /tabs-secondary must NOT match /tabs scope.
316
+ if (outletMountPath && !isPathnameInScope(currentPathname, outletMountPath)) {
317
+ return { parentPath: undefined, outletMountPath };
318
+ }
319
+ // Fast path: when the mount path is known (from React Router's RouteContext),
320
+ // the parent path IS the mount path. The iterative segment-by-segment discovery
321
+ // below was needed when the mount depth had to be guessed from URL structure,
322
+ // but with RouteContext we already know exactly where this outlet is mounted.
323
+ if (outletMountPath && (hasRelativeRoutes || hasIndexRoute)) {
324
+ return { parentPath: outletMountPath, outletMountPath };
325
+ }
326
+ // Fallback: mount path not yet known. Iterate through path segments to discover
327
+ // the correct parent depth. This only runs on first render of outlets where
328
+ // RouteContext doesn't provide a parent match (typically root-level outlets,
329
+ // which usually have absolute routes and take the absolute routes path below).
330
+ if (!outletMountPath && (hasRelativeRoutes || hasIndexRoute) && currentPathname.includes('/')) {
331
+ const segments = currentPathname.split('/').filter(Boolean);
332
+ if (segments.length >= 1) {
333
+ let firstSpecificMatch;
334
+ let firstWildcardMatch;
335
+ let indexMatchAtMount;
336
+ for (let i = 1; i <= segments.length; i++) {
337
+ const parentPath = '/' + segments.slice(0, i).join('/');
338
+ const remainingPath = segments.slice(i).join('/');
339
+ // Check for specific route match (highest priority)
340
+ if (!firstSpecificMatch && findSpecificMatch(routeChildren, remainingPath)) {
341
+ // Don't let empty/default path routes (path="" or undefined) drive
342
+ // the parent deeper than a wildcard match. An empty path route matching
343
+ // when remainingPath is "" just means all segments were consumed.
344
+ if (firstWildcardMatch) {
345
+ const matchingRoute = findFirstSpecificMatchingRoute(routeChildren, remainingPath);
346
+ if (matchingRoute) {
347
+ const matchingPath = matchingRoute.props.path;
348
+ if (!matchingPath || matchingPath === '') {
349
+ continue;
350
+ }
351
+ }
352
+ }
353
+ firstSpecificMatch = parentPath;
354
+ break;
355
+ }
356
+ // Check for wildcard match (only if remaining path is non-empty)
357
+ const hasNonEmptyRemaining = remainingPath !== '' && remainingPath !== '/';
358
+ if (!firstWildcardMatch && hasNonEmptyRemaining && hasWildcardRoute) {
359
+ if (!couldSpecificRouteMatch(routeChildren, remainingPath)) {
360
+ firstWildcardMatch = parentPath;
361
+ }
362
+ }
363
+ // Check for index route match
364
+ if ((remainingPath === '' || remainingPath === '/') && hasIndexRoute) {
365
+ indexMatchAtMount = parentPath;
366
+ }
367
+ }
368
+ // Fallback: check root level for embedded wildcard routes (e.g., "tab1/*")
369
+ if (!firstSpecificMatch) {
370
+ const fullRemainingPath = segments.join('/');
371
+ if (routeChildren.some((route) => matchesEmbeddedWildcardRoute(route, fullRemainingPath))) {
372
+ firstSpecificMatch = '/';
373
+ }
374
+ }
375
+ const bestPath = selectBestMatch(firstSpecificMatch, firstWildcardMatch, indexMatchAtMount);
376
+ return { parentPath: bestPath, outletMountPath: bestPath };
377
+ }
378
+ }
379
+ // Handle outlets with only absolute routes
380
+ if (!hasRelativeRoutes && !hasIndexRoute) {
381
+ const result = computeAbsoluteRoutesParentPath(routeChildren, currentPathname, outletMountPath);
382
+ if (result) {
383
+ return result;
384
+ }
385
+ }
386
+ return { parentPath: outletMountPath, outletMountPath };
387
+ };
388
+
389
+ /**
390
+ * Ensures the given path has a leading slash.
391
+ *
392
+ * @param value The path string to normalize.
393
+ * @returns The path with a leading slash.
394
+ */
395
+ const ensureLeadingSlash = (value) => {
396
+ if (value === '') {
397
+ return '/';
398
+ }
399
+ return value.startsWith('/') ? value : `/${value}`;
400
+ };
401
+ /**
402
+ * Strips the trailing slash from a path, unless it's the root path.
403
+ *
404
+ * @param value The path string to normalize.
405
+ * @returns The path without a trailing slash.
406
+ */
407
+ const stripTrailingSlash = (value) => {
408
+ return value.length > 1 && value.endsWith('/') ? value.slice(0, -1) : value;
409
+ };
410
+ /**
411
+ * Normalizes a pathname for comparison by ensuring a leading slash
412
+ * and removing trailing slashes.
413
+ *
414
+ * @param value The pathname to normalize, can be undefined.
415
+ * @returns A normalized pathname string.
416
+ */
417
+ const normalizePathnameForComparison = (value) => {
418
+ if (!value || value === '') {
419
+ return '/';
420
+ }
421
+ const withLeadingSlash = ensureLeadingSlash(value);
422
+ return stripTrailingSlash(withLeadingSlash);
423
+ };
424
+
425
+ /**
426
+ * Extracts the children from a Routes wrapper component.
427
+ * The use of `<Routes />` is encouraged with React Router v6.
428
+ *
429
+ * @param node The React node to extract Routes children from.
430
+ * @returns The children of the Routes component, or undefined if not found.
431
+ */
432
+ const getRoutesChildren = (node) => {
433
+ let routesNode;
434
+ React.Children.forEach(node, (child) => {
435
+ if (child.type === Routes) {
436
+ routesNode = child;
437
+ }
438
+ });
439
+ if (routesNode) {
440
+ // The children of the `<Routes />` component are most likely
441
+ // (and should be) the `<Route />` components.
442
+ return routesNode.props.children;
443
+ }
444
+ return undefined;
445
+ };
446
+ /**
447
+ * Extracts Route children from a node (either directly or from a Routes wrapper).
448
+ *
449
+ * @param children The children to extract routes from.
450
+ * @returns An array of Route elements.
451
+ */
452
+ const extractRouteChildren = (children) => {
453
+ const routesChildren = getRoutesChildren(children) ?? children;
454
+ return React.Children.toArray(routesChildren).filter((child) => React.isValidElement(child) && (child.type === Route || child.type === IonRoute));
455
+ };
456
+ /**
457
+ * Checks if a React element is a Navigate component (redirect).
458
+ *
459
+ * @param element The element to check.
460
+ * @returns True if the element is a Navigate component.
461
+ */
462
+ const isNavigateElement = (element) => {
463
+ return (React.isValidElement(element) &&
464
+ (element.type === Navigate || (typeof element.type === 'function' && element.type.name === 'Navigate')));
465
+ };
466
+
467
+ /**
468
+ * Compares two routes by specificity for sorting (most specific first).
469
+ *
470
+ * Sort order:
471
+ * 1. Index routes come first
472
+ * 2. Wildcard-only routes (* or /*) come last
473
+ * 3. Exact matches (no wildcards/params) before wildcard/param routes
474
+ * 4. Among routes with same status, longer paths are more specific
475
+ */
476
+ const compareRouteSpecificity = (a, b) => {
477
+ // Index routes come first
478
+ if (a.index && !b.index)
479
+ return -1;
480
+ if (!a.index && b.index)
481
+ return 1;
482
+ // Wildcard-only routes (* or /*) should come last
483
+ const aIsWildcardOnly = a.path === '*' || a.path === '/*';
484
+ const bIsWildcardOnly = b.path === '*' || b.path === '/*';
485
+ if (!aIsWildcardOnly && bIsWildcardOnly)
486
+ return -1;
487
+ if (aIsWildcardOnly && !bIsWildcardOnly)
488
+ return 1;
489
+ // Exact matches (no wildcards/params) come before wildcard/param routes
490
+ const aHasWildcard = a.path.includes('*') || a.path.includes(':');
491
+ const bHasWildcard = b.path.includes('*') || b.path.includes(':');
492
+ if (!aHasWildcard && bHasWildcard)
493
+ return -1;
494
+ if (aHasWildcard && !bHasWildcard)
495
+ return 1;
496
+ // Among routes with same wildcard status, longer paths are more specific
497
+ if (a.path.length !== b.path.length) {
498
+ return b.path.length - a.path.length;
499
+ }
500
+ return 0;
501
+ };
502
+ /**
503
+ * Sorts view items by route specificity (most specific first).
504
+ *
505
+ * Sort order aligns with findViewItemByPath in ReactRouterViewStack.tsx:
506
+ * 1. Index routes come first
507
+ * 2. Wildcard-only routes (* or /*) come last
508
+ * 3. Exact matches (no wildcards/params) come before wildcard/param routes
509
+ * 4. Among routes with same wildcard status, longer paths are more specific
510
+ *
511
+ * @param views The view items to sort.
512
+ * @returns A new sorted array of view items.
513
+ */
514
+ const sortViewsBySpecificity = (views) => {
515
+ return [...views].sort((a, b) => compareRouteSpecificity({ path: a.routeData?.childProps?.path || '', index: !!a.routeData?.childProps?.index }, { path: b.routeData?.childProps?.path || '', index: !!b.routeData?.childProps?.index }));
39
516
  };
40
517
 
518
+ /**
519
+ * `ReactRouterViewStack` is a custom navigation manager used in Ionic React
520
+ * apps to map React Router route elements (such as `<IonRoute>`) to "view
521
+ * items" that Ionic can manage in a view stack. This is critical to maintain
522
+ * Ionic’s animation, lifecycle, and history behavior across views.
523
+ */
524
+ /**
525
+ * Delay in milliseconds before removing a Navigate view item after a redirect.
526
+ * This ensures the redirect navigation completes before the view is removed.
527
+ */
528
+ const NAVIGATE_REDIRECT_DELAY_MS = 100;
529
+ /**
530
+ * Delay in milliseconds before cleaning up a view without an IonPage element.
531
+ * This double-checks that the view is truly not needed before removal.
532
+ */
533
+ const VIEW_CLEANUP_DELAY_MS = 200;
534
+ /**
535
+ * Computes the absolute pathnameBase for a route element based on its type.
536
+ * Handles relative paths, index routes, and splat routes differently.
537
+ */
538
+ const computeAbsolutePathnameBase = (routeElement, routeMatch, parentPathnameBase, routeInfoPathname) => {
539
+ const routePath = routeElement.props.path;
540
+ const isRelativePath = routePath && !routePath.startsWith('/');
541
+ const isIndexRoute = !!routeElement.props.index;
542
+ const isSplatOnlyRoute = routePath === '*' || routePath === '/*';
543
+ if (isSplatOnlyRoute) {
544
+ // Splat routes should NOT contribute their matched portion to pathnameBase
545
+ // This aligns with React Router v7's v7_relativeSplatPath behavior
546
+ return parentPathnameBase;
547
+ }
548
+ if (isRelativePath && routeMatch?.pathnameBase) {
549
+ const relativeBase = routeMatch.pathnameBase.startsWith('/')
550
+ ? routeMatch.pathnameBase.slice(1)
551
+ : routeMatch.pathnameBase;
552
+ return parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`;
553
+ }
554
+ if (isIndexRoute) {
555
+ return parentPathnameBase;
556
+ }
557
+ return routeMatch?.pathnameBase || routeInfoPathname;
558
+ };
559
+ /**
560
+ * Gets fallback params from view items in other outlets when parent context is empty.
561
+ * This handles cases where React context propagation doesn't work as expected.
562
+ */
563
+ const getFallbackParamsFromViewItems = (allViewItems, currentOutletId, currentPathname) => {
564
+ const matchingViews = [];
565
+ for (const otherViewItem of allViewItems) {
566
+ if (otherViewItem.outletId === currentOutletId)
567
+ continue;
568
+ const otherMatch = otherViewItem.routeData?.match;
569
+ if (otherMatch?.params && Object.keys(otherMatch.params).length > 0) {
570
+ const matchedPathname = otherMatch.pathnameBase || otherMatch.pathname;
571
+ if (matchedPathname && currentPathname.startsWith(matchedPathname)) {
572
+ matchingViews.push({
573
+ params: otherMatch.params,
574
+ pathLength: matchedPathname.length,
575
+ });
576
+ }
577
+ }
578
+ }
579
+ // Sort ascending by path length so more-specific (longer) paths are applied
580
+ // last and their params take priority over less-specific ones.
581
+ matchingViews.sort((a, b) => a.pathLength - b.pathLength);
582
+ const params = {};
583
+ for (const view of matchingViews) {
584
+ Object.assign(params, view.params);
585
+ }
586
+ return params;
587
+ };
588
+ /**
589
+ * Builds the matches array for RouteContext.
590
+ */
591
+ const buildContextMatches = (parentMatches, combinedParams, routeMatch, routeInfoPathname, absolutePathnameBase, viewItem, routeElement, componentElement) => {
592
+ return [
593
+ ...parentMatches,
594
+ {
595
+ params: combinedParams,
596
+ pathname: routeMatch?.pathname || routeInfoPathname,
597
+ pathnameBase: absolutePathnameBase,
598
+ route: {
599
+ id: viewItem.id,
600
+ path: routeElement.props.path,
601
+ element: componentElement,
602
+ index: !!routeElement.props.index,
603
+ caseSensitive: routeElement.props.caseSensitive,
604
+ hasErrorBoundary: false,
605
+ },
606
+ },
607
+ ];
608
+ };
609
+ const createDefaultMatch = (fullPathname, routeProps) => {
610
+ const isIndexRoute = !!routeProps.index;
611
+ const patternPath = routeProps.path ?? '';
612
+ const pathnameBase = fullPathname === '' ? '/' : fullPathname;
613
+ const computedEnd = routeProps.end !== undefined ? routeProps.end : patternPath !== '' ? !patternPath.endsWith('*') : true;
614
+ return {
615
+ params: {},
616
+ pathname: isIndexRoute ? '' : fullPathname,
617
+ pathnameBase,
618
+ pattern: {
619
+ path: patternPath,
620
+ caseSensitive: routeProps.caseSensitive ?? false,
621
+ end: isIndexRoute ? true : computedEnd,
622
+ },
623
+ };
624
+ };
625
+ const computeRelativeToParent = (pathname, parentPath) => {
626
+ if (!parentPath)
627
+ return null;
628
+ const normalizedParent = normalizePathnameForComparison(parentPath);
629
+ const normalizedPathname = normalizePathnameForComparison(pathname);
630
+ if (normalizedPathname === normalizedParent) {
631
+ return '';
632
+ }
633
+ const withSlash = normalizedParent === '/' ? '/' : normalizedParent + '/';
634
+ if (normalizedPathname.startsWith(withSlash)) {
635
+ return normalizedPathname.slice(withSlash.length);
636
+ }
637
+ return null;
638
+ };
639
+ const resolveIndexRouteMatch = (viewItem, pathname, parentPath) => {
640
+ if (!viewItem.routeData?.childProps?.index) {
641
+ return null;
642
+ }
643
+ // Prefer computing against the parent path when available to align with RRv6 semantics
644
+ const relative = computeRelativeToParent(pathname, parentPath);
645
+ if (relative !== null) {
646
+ // Index routes match only when there is no remaining path
647
+ if (relative === '' || relative === '/') {
648
+ return createDefaultMatch(parentPath || pathname, viewItem.routeData.childProps);
649
+ }
650
+ return null;
651
+ }
652
+ // Fallback: use previously computed match base for equality check
653
+ const previousMatch = viewItem.routeData?.match;
654
+ if (!previousMatch) {
655
+ return null;
656
+ }
657
+ const normalizedPathname = normalizePathnameForComparison(pathname);
658
+ const normalizedBase = normalizePathnameForComparison(previousMatch.pathnameBase || previousMatch.pathname || '');
659
+ return normalizedPathname === normalizedBase ? previousMatch : null;
660
+ };
41
661
  class ReactRouterViewStack extends ViewStacks {
662
+ /**
663
+ * Stores the computed parent path for each outlet.
664
+ * Used by findViewItemByPath to correctly evaluate index route matches
665
+ * without requiring the outlet's React element or route children.
666
+ */
667
+ outletParentPaths = new Map();
668
+ /**
669
+ * Stores the computed mount path for each outlet.
670
+ * Fed back into computeParentPath on subsequent calls to stabilize
671
+ * the parent path computation across navigations (mirrors StackManager.outletMountPath).
672
+ */
673
+ outletMountPaths = new Map();
42
674
  constructor() {
43
675
  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
676
  }
50
- createViewItem(outletId, reactElement, routeInfo, page) {
677
+ /**
678
+ * Creates a new view item for the given outlet and react route element.
679
+ * Associates route props with the matched route path for further lookups.
680
+ */
681
+ createViewItem = (outletId, reactElement, routeInfo, page) => {
682
+ const routePath = reactElement.props.path || '';
683
+ // Check if we already have a view item for this exact route that we can reuse
684
+ // Include wildcard routes like tabs/* since they should be reused
685
+ // Also check unmounted items that might have been preserved for browser navigation
686
+ const existingViewItem = this.getViewItemsForOutlet(outletId).find((v) => {
687
+ const existingRouteProps = v.reactElement?.props ?? {};
688
+ const existingPath = existingRouteProps.path || '';
689
+ const existingElement = existingRouteProps.element;
690
+ const newElement = reactElement.props.element;
691
+ const existingIsIndexRoute = !!existingRouteProps.index;
692
+ const newIsIndexRoute = !!reactElement.props.index;
693
+ // For Navigate components, match by destination
694
+ const existingIsNavigate = React.isValidElement(existingElement) && existingElement.type === Navigate;
695
+ const newIsNavigate = React.isValidElement(newElement) && newElement.type === Navigate;
696
+ if (existingIsNavigate && newIsNavigate) {
697
+ const existingTo = existingElement.props?.to;
698
+ const newTo = newElement.props?.to;
699
+ if (existingTo === newTo) {
700
+ return true;
701
+ }
702
+ }
703
+ if (existingIsIndexRoute && newIsIndexRoute) {
704
+ return true;
705
+ }
706
+ // Reuse view items with the same path
707
+ // Special case: reuse tabs/* and other specific wildcard routes
708
+ // Don't reuse index routes (empty path) or generic catch-all wildcards (*)
709
+ if (existingPath === routePath && existingPath !== '' && existingPath !== '*') {
710
+ // Parameterized routes need pathname matching to ensure /details/1 and /details/2
711
+ // get separate view items. For wildcard routes (e.g., user/:userId/*), compare
712
+ // pathnameBase to allow child path changes while preserving the parent view.
713
+ const hasParams = routePath.includes(':');
714
+ const isWildcard = routePath.includes('*');
715
+ if (hasParams) {
716
+ if (isWildcard) {
717
+ const existingPathnameBase = v.routeData?.match?.pathnameBase;
718
+ const newMatch = matchComponent$1(reactElement, routeInfo.pathname, false, this.outletParentPaths.get(outletId));
719
+ const newPathnameBase = newMatch?.pathnameBase;
720
+ if (existingPathnameBase !== newPathnameBase) {
721
+ return false;
722
+ }
723
+ }
724
+ else {
725
+ const existingPathname = v.routeData?.match?.pathname;
726
+ if (existingPathname !== routeInfo.pathname) {
727
+ return false;
728
+ }
729
+ }
730
+ }
731
+ return true;
732
+ }
733
+ // Also reuse specific wildcard routes like tabs/*
734
+ if (existingPath === routePath && existingPath.endsWith('/*') && existingPath !== '/*') {
735
+ return true;
736
+ }
737
+ return false;
738
+ });
739
+ if (existingViewItem) {
740
+ // Update and ensure the existing view item is properly configured
741
+ existingViewItem.reactElement = reactElement;
742
+ existingViewItem.mount = true;
743
+ existingViewItem.ionPageElement = page || existingViewItem.ionPageElement;
744
+ const updatedMatch = matchComponent$1(reactElement, routeInfo.pathname, false, this.outletParentPaths.get(outletId)) ||
745
+ existingViewItem.routeData?.match ||
746
+ createDefaultMatch(routeInfo.pathname, reactElement.props);
747
+ existingViewItem.routeData = {
748
+ match: updatedMatch,
749
+ childProps: reactElement.props,
750
+ lastPathname: existingViewItem.routeData?.lastPathname, // Preserve navigation history
751
+ };
752
+ return existingViewItem;
753
+ }
754
+ const id = `${outletId}-${generateId(outletId)}`;
51
755
  const viewItem = {
52
- id: generateId('viewItem'),
756
+ id,
53
757
  outletId,
54
758
  ionPageElement: page,
55
759
  reactElement,
56
760
  mount: true,
57
- ionRoute: false,
761
+ ionRoute: true,
58
762
  };
59
763
  if (reactElement.type === IonRoute) {
60
- viewItem.ionRoute = true;
61
764
  viewItem.disableIonPageManagement = reactElement.props.disableIonPageManagement;
62
765
  }
63
- viewItem.routeData = {
64
- match: matchPath({
65
- pathname: routeInfo.pathname,
66
- componentProps: reactElement.props,
67
- }),
68
- childProps: reactElement.props,
69
- };
70
- return viewItem;
766
+ const initialMatch = matchComponent$1(reactElement, routeInfo.pathname, true, this.outletParentPaths.get(outletId)) ||
767
+ createDefaultMatch(routeInfo.pathname, reactElement.props);
768
+ viewItem.routeData = {
769
+ match: initialMatch,
770
+ childProps: reactElement.props,
771
+ };
772
+ this.add(viewItem);
773
+ return viewItem;
774
+ };
775
+ /**
776
+ * Renders a ViewLifeCycleManager for the given view item.
777
+ * Handles cleanup if the view no longer matches.
778
+ *
779
+ * - Deactivates view if it no longer matches the current route
780
+ * - Wraps the route element in <Routes> to support nested routing and ensure remounting
781
+ * - Adds a unique key to <Routes> so React Router remounts routes when switching
782
+ */
783
+ renderViewItem = (viewItem, routeInfo, parentPath, reRender) => {
784
+ const routePath = viewItem.reactElement.props.path || '';
785
+ let match = matchComponent$1(viewItem.reactElement, routeInfo.pathname, false, parentPath);
786
+ if (!match) {
787
+ const indexMatch = resolveIndexRouteMatch(viewItem, routeInfo.pathname, parentPath);
788
+ if (indexMatch) {
789
+ match = indexMatch;
790
+ }
791
+ }
792
+ // For parameterized routes, check if this is a navigation to a different path instance
793
+ // In that case, we should NOT reuse this view - a new view should be created
794
+ const isParameterRoute = routePath.includes(':');
795
+ const previousMatch = viewItem.routeData?.match;
796
+ const isSamePath = match?.pathname === previousMatch?.pathname;
797
+ // Flag to indicate this view should not be reused for this different parameterized path
798
+ const shouldSkipForDifferentParam = isParameterRoute && match && previousMatch && !isSamePath;
799
+ // Don't deactivate views automatically - let the StackManager handle view lifecycle
800
+ // This preserves views in the stack for navigation history like native apps
801
+ // Views will be hidden/shown by the StackManager's transition logic instead of being unmounted
802
+ // Special handling for Navigate components - they should unmount after redirecting
803
+ const elementComponent = viewItem.reactElement?.props?.element;
804
+ const isNavigateComponent = isNavigateElement(elementComponent);
805
+ if (isNavigateComponent) {
806
+ // Navigate components should only be mounted when they match
807
+ // Once they redirect (no longer match), they should be removed completely
808
+ // IMPORTANT: For index routes, we need to check indexMatch too since matchComponent
809
+ // may not properly match index routes without explicit parent path context
810
+ const indexMatch = viewItem.routeData?.childProps?.index
811
+ ? resolveIndexRouteMatch(viewItem, routeInfo.pathname, parentPath)
812
+ : null;
813
+ const hasValidMatch = match || indexMatch;
814
+ if (!hasValidMatch && viewItem.mount) {
815
+ viewItem.mount = false;
816
+ // Schedule removal of the Navigate view item after a short delay
817
+ // This ensures the redirect completes before removal
818
+ setTimeout(() => {
819
+ this.remove(viewItem);
820
+ reRender?.();
821
+ }, NAVIGATE_REDIRECT_DELAY_MS);
822
+ }
823
+ }
824
+ // Components that don't have IonPage elements and no longer match should be cleaned up
825
+ // BUT we need to be careful not to remove them if they're part of browser navigation history
826
+ // This handles components that perform immediate actions like programmatic navigation
827
+ // EXCEPTION: Navigate components should ALWAYS remain mounted until they redirect
828
+ // since they need to be rendered to trigger the navigation
829
+ if (!match && viewItem.mount && !viewItem.ionPageElement && !isNavigateComponent) {
830
+ // Check if this view item should be preserved for browser navigation
831
+ // We'll keep it if it was recently active (within the last navigation)
832
+ const shouldPreserve = viewItem.routeData.lastPathname === routeInfo.pathname ||
833
+ viewItem.routeData.match?.pathname === routeInfo.lastPathname;
834
+ if (!shouldPreserve) {
835
+ // This view item doesn't match and doesn't have an IonPage
836
+ // It's likely a utility component that performs an action and navigates away
837
+ viewItem.mount = false;
838
+ // Schedule removal to allow it to be recreated on next navigation
839
+ setTimeout(() => {
840
+ // Double-check before removing - the view might be needed again
841
+ const stillNotNeeded = !viewItem.mount && !viewItem.ionPageElement;
842
+ if (stillNotNeeded) {
843
+ this.remove(viewItem);
844
+ reRender?.();
845
+ }
846
+ }, VIEW_CLEANUP_DELAY_MS);
847
+ }
848
+ else {
849
+ // Preserve it but unmount it for now
850
+ viewItem.mount = false;
851
+ }
852
+ }
853
+ // Reactivate view if it matches but was previously deactivated
854
+ // Don't reactivate if this is a parameterized route navigating to a different path instance
855
+ // Don't reactivate catch-all wildcard routes — they are created fresh by createViewItem
856
+ const isCatchAllWildcard = routePath === '*' || routePath === '/*';
857
+ if (match && !viewItem.mount && !shouldSkipForDifferentParam && !isCatchAllWildcard) {
858
+ viewItem.mount = true;
859
+ viewItem.routeData.match = match;
860
+ }
861
+ // Deactivate wildcard (catch-all) and empty-path (default) routes when a more-specific route matches.
862
+ // This prevents "Not found" or fallback pages from showing alongside valid routes.
863
+ if (routePath === '*' || routePath === '') {
864
+ // Check if any other view in this outlet has a match for the current route
865
+ const outletViews = this.getViewItemsForOutlet(viewItem.outletId);
866
+ // When parent path context is available, compute the relative pathname once
867
+ // outside the loop since both routeInfo.pathname and parentPath are invariant.
868
+ const relativePathname = parentPath ? computeRelativeToParent(routeInfo.pathname, parentPath) : null;
869
+ let hasSpecificMatch = outletViews.some((v) => {
870
+ if (v.id === viewItem.id)
871
+ return false; // Skip self
872
+ const vRoutePath = v.reactElement?.props?.path || '';
873
+ if (vRoutePath === '*' || vRoutePath === '')
874
+ return false; // Skip other wildcard/empty routes
875
+ // When parent path context is available and the route is relative, use
876
+ // parent-path-aware matching. This avoids false positives from
877
+ // derivePathnameToMatch's tail-slice heuristic, which can incorrectly
878
+ // match route literals that appear at the wrong position in the pathname.
879
+ // Example: pathname /parent/extra/details/99 with route details/:id —
880
+ // the tail-slice extracts ["details","99"] producing a false match.
881
+ if (parentPath && vRoutePath && !vRoutePath.startsWith('/')) {
882
+ if (relativePathname === null) {
883
+ return false; // Pathname is outside this outlet's parent scope
884
+ }
885
+ return !!matchPath({
886
+ pathname: relativePathname,
887
+ componentProps: v.reactElement.props,
888
+ });
889
+ }
890
+ // Fallback to matchComponent when no parent path context is available
891
+ const vMatch = v.reactElement ? matchComponent$1(v.reactElement, routeInfo.pathname) : null;
892
+ return !!vMatch;
893
+ });
894
+ // For catch-all * routes, also deactivate when the pathname matches the outlet's
895
+ // parent path exactly. This means there are no remaining segments for the wildcard
896
+ // to catch, so the empty-path or index route should handle it instead.
897
+ if (!hasSpecificMatch && routePath === '*') {
898
+ const outletParentPath = this.outletParentPaths.get(viewItem.outletId);
899
+ if (outletParentPath) {
900
+ const normalizedParent = normalizePathnameForComparison(outletParentPath);
901
+ const normalizedPathname = normalizePathnameForComparison(routeInfo.pathname);
902
+ if (normalizedPathname === normalizedParent) {
903
+ // Check if there's an empty-path or index view item that should handle this
904
+ const hasDefaultRoute = outletViews.some((v) => {
905
+ if (v.id === viewItem.id)
906
+ return false;
907
+ const vRoutePath = v.reactElement?.props?.path;
908
+ return vRoutePath === '' || vRoutePath === undefined || !!v.routeData?.childProps?.index;
909
+ });
910
+ if (hasDefaultRoute) {
911
+ hasSpecificMatch = true;
912
+ }
913
+ }
914
+ }
915
+ }
916
+ if (hasSpecificMatch) {
917
+ viewItem.mount = false;
918
+ if (viewItem.ionPageElement) {
919
+ viewItem.ionPageElement.classList.add('ion-page-hidden');
920
+ viewItem.ionPageElement.setAttribute('aria-hidden', 'true');
921
+ }
922
+ }
923
+ }
924
+ const routeElement = React.cloneElement(viewItem.reactElement);
925
+ const componentElement = routeElement.props.element;
926
+ // Don't update match for parameterized routes navigating to different path instances
927
+ // This preserves the original match so that findViewItemByPath can correctly skip this view
928
+ if (match && viewItem.routeData.match !== match && !shouldSkipForDifferentParam) {
929
+ viewItem.routeData.match = match;
930
+ }
931
+ const routeMatch = shouldSkipForDifferentParam ? viewItem.routeData?.match : match || viewItem.routeData?.match;
932
+ return (React.createElement(UNSAFE_RouteContext.Consumer, { key: `view-context-${viewItem.id}` }, (parentContext) => {
933
+ const parentMatches = (parentContext?.matches ?? []);
934
+ // Accumulate params from parent matches, with fallback to other outlets
935
+ let accumulatedParentParams = parentMatches.reduce((acc, m) => ({ ...acc, ...m.params }), {});
936
+ if (parentMatches.length === 0 && Object.keys(accumulatedParentParams).length === 0) {
937
+ accumulatedParentParams = getFallbackParamsFromViewItems(this.getAllViewItems(), viewItem.outletId, routeInfo.pathname);
938
+ }
939
+ const combinedParams = { ...accumulatedParentParams, ...(routeMatch?.params ?? {}) };
940
+ const parentPathnameBase = parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase : '/';
941
+ const absolutePathnameBase = computeAbsolutePathnameBase(routeElement, routeMatch, parentPathnameBase, routeInfo.pathname);
942
+ const contextMatches = buildContextMatches(parentMatches, combinedParams, routeMatch, routeInfo.pathname, absolutePathnameBase, viewItem, routeElement, componentElement);
943
+ const routeContextValue = parentContext
944
+ ? { ...parentContext, matches: contextMatches }
945
+ : { outlet: null, matches: contextMatches, isDataRoute: false };
946
+ return (React.createElement(ViewLifeCycleManager, { key: `view-${viewItem.id}`, mount: viewItem.mount, removeView: () => this.remove(viewItem) },
947
+ React.createElement(UNSAFE_RouteContext.Provider, { value: routeContextValue }, componentElement)));
948
+ }));
949
+ };
950
+ /**
951
+ * Re-renders all active view items for the specified outlet.
952
+ * Ensures React elements are updated with the latest match.
953
+ *
954
+ * 1. Iterates through children of IonRouterOutlet
955
+ * 2. Updates each matching viewItem with the current child React element
956
+ * (important for updating props or changes to elements)
957
+ * 3. Returns a list of React components that will be rendered inside the outlet
958
+ * Each view is wrapped in <ViewLifeCycleManager> to manage lifecycle and rendering
959
+ */
960
+ getChildrenToRender = (outletId, ionRouterOutlet, routeInfo, reRender, parentPathnameBase) => {
961
+ const viewItems = this.getViewItemsForOutlet(outletId);
962
+ // Seed the mount path from the parent route context if available.
963
+ // This provides the outlet's mount path immediately on first render,
964
+ // eliminating the need for heuristic-based discovery in computeParentPath.
965
+ if (parentPathnameBase && !this.outletMountPaths.has(outletId)) {
966
+ this.outletMountPaths.set(outletId, parentPathnameBase);
967
+ }
968
+ // Determine parentPath for outlets with relative or index routes.
969
+ // This populates outletParentPaths for findViewItemByPath's matchView
970
+ // and the catch-all deactivation logic in renderViewItem.
971
+ let parentPath = undefined;
972
+ try {
973
+ const routeChildren = extractRouteChildren(ionRouterOutlet.props.children);
974
+ const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
975
+ if (hasRelativeRoutes || hasIndexRoute) {
976
+ const result = computeParentPath({
977
+ currentPathname: routeInfo.pathname,
978
+ outletMountPath: this.outletMountPaths.get(outletId),
979
+ routeChildren,
980
+ hasRelativeRoutes,
981
+ hasIndexRoute,
982
+ hasWildcardRoute,
983
+ });
984
+ parentPath = result.parentPath;
985
+ // Persist the mount path for subsequent calls, mirroring StackManager.outletMountPath.
986
+ // Unlike outletParentPaths (cleared when parentPath is undefined), the mount path is
987
+ // intentionally sticky — it anchors the outlet's scope and is only removed in clear().
988
+ if (result.outletMountPath && !this.outletMountPaths.has(outletId)) {
989
+ this.outletMountPaths.set(outletId, result.outletMountPath);
990
+ }
991
+ }
992
+ }
993
+ catch {
994
+ // Non-fatal: if we fail to compute parentPath, fall back to previous behavior
995
+ }
996
+ // Store the computed parentPath for use in findViewItemByPath.
997
+ // Clear stale entries when parentPath is undefined (e.g., navigated out of scope).
998
+ if (parentPath !== undefined) {
999
+ this.outletParentPaths.set(outletId, parentPath);
1000
+ }
1001
+ else if (this.outletParentPaths.has(outletId)) {
1002
+ this.outletParentPaths.delete(outletId);
1003
+ }
1004
+ // Re-sync each route element onto its stored viewItem so prop changes from a
1005
+ // parent re-render reach the child. extractRouteChildren unwraps the <Routes>
1006
+ // wrapper, which has no path of its own. Without it we'd iterate props.children
1007
+ // directly, never match a viewItem, and the child's props would go stale.
1008
+ extractRouteChildren(ionRouterOutlet.props.children).forEach((child) => {
1009
+ // Match on exact path so a wildcard route doesn't overwrite a specific one.
1010
+ const childPath = child.props.path;
1011
+ const viewItem = viewItems.find((v) => {
1012
+ const viewItemPath = v.reactElement?.props?.path;
1013
+ return viewItemPath === childPath;
1014
+ });
1015
+ if (viewItem) {
1016
+ viewItem.reactElement = child;
1017
+ }
1018
+ });
1019
+ // Filter out duplicate view items by ID (but keep all mounted items)
1020
+ const uniqueViewItems = viewItems.filter((viewItem, index, array) => {
1021
+ // Remove duplicates by ID (keep first occurrence)
1022
+ const isFirstOccurrence = array.findIndex((v) => v.id === viewItem.id) === index;
1023
+ return isFirstOccurrence;
1024
+ });
1025
+ // Filter out unmounted Navigate components to prevent them from being rendered
1026
+ // and triggering unwanted redirects
1027
+ const renderableViewItems = uniqueViewItems.filter((viewItem) => {
1028
+ const elementComponent = viewItem.reactElement?.props?.element;
1029
+ const isNavigateComponent = isNavigateElement(elementComponent);
1030
+ // Exclude unmounted Navigate components from rendering
1031
+ if (isNavigateComponent && !viewItem.mount) {
1032
+ return false;
1033
+ }
1034
+ // Filter out views that are unmounted, have no ionPageElement, and don't match the current route.
1035
+ // These are "stale" views from previous routes that should not be rendered.
1036
+ // Views WITH ionPageElement are handled by the normal lifecycle events.
1037
+ // Views that MATCH the current route should be kept (they might be transitioning).
1038
+ if (!viewItem.mount && !viewItem.ionPageElement) {
1039
+ // Check if this view's route path matches the current pathname
1040
+ const viewRoutePath = viewItem.reactElement?.props?.path;
1041
+ if (viewRoutePath) {
1042
+ // First try exact match using matchComponent
1043
+ const routeMatch = matchComponent$1(viewItem.reactElement, routeInfo.pathname, false, parentPath);
1044
+ if (routeMatch) {
1045
+ // View matches current route, keep it
1046
+ return true;
1047
+ }
1048
+ // For parent routes (like /multiple-tabs or /routing), check if current pathname
1049
+ // starts with this route's path. This handles views with IonSplitPane/IonTabs
1050
+ // that don't have IonPage but should remain mounted while navigating within their children.
1051
+ const normalizedViewPath = normalizePathnameForComparison(viewRoutePath.replace(/\/?\*$/, '')); // Remove trailing wildcard
1052
+ const normalizedCurrentPath = normalizePathnameForComparison(routeInfo.pathname);
1053
+ // Check if current pathname is within this view's route hierarchy
1054
+ const isWithinRouteHierarchy = normalizedCurrentPath === normalizedViewPath || normalizedCurrentPath.startsWith(normalizedViewPath + '/');
1055
+ if (!isWithinRouteHierarchy) {
1056
+ // View is outside current route hierarchy, remove it
1057
+ setTimeout(() => {
1058
+ this.remove(viewItem);
1059
+ reRender();
1060
+ }, 0);
1061
+ return false;
1062
+ }
1063
+ }
1064
+ }
1065
+ return true;
1066
+ });
1067
+ const renderedItems = renderableViewItems.map((viewItem) => this.renderViewItem(viewItem, routeInfo, parentPath, reRender));
1068
+ return renderedItems;
1069
+ };
1070
+ /**
1071
+ * Finds a view item matching the current route, optionally updating its match state.
1072
+ */
1073
+ findViewItemByRouteInfo = (routeInfo, outletId, updateMatch) => {
1074
+ const { viewItem, match } = this.findViewItemByPath(routeInfo.pathname, outletId);
1075
+ const shouldUpdateMatch = updateMatch === undefined || updateMatch === true;
1076
+ if (shouldUpdateMatch && viewItem && match) {
1077
+ viewItem.routeData.match = match;
1078
+ }
1079
+ return viewItem;
1080
+ };
1081
+ /**
1082
+ * Finds the view item that was previously active before a route change.
1083
+ */
1084
+ findLeavingViewItemByRouteInfo = (routeInfo, outletId, mustBeIonRoute = true) => {
1085
+ // If the lastPathname is not set, we cannot find a leaving view item
1086
+ if (!routeInfo.lastPathname) {
1087
+ return undefined;
1088
+ }
1089
+ const { viewItem } = this.findViewItemByPath(routeInfo.lastPathname, outletId, mustBeIonRoute);
1090
+ return viewItem;
1091
+ };
1092
+ /**
1093
+ * Finds a view item by pathname only, used in simpler queries.
1094
+ */
1095
+ findViewItemByPathname = (pathname, outletId) => {
1096
+ const { viewItem } = this.findViewItemByPath(pathname, outletId);
1097
+ return viewItem;
1098
+ };
1099
+ /**
1100
+ * Core function that matches a given pathname against all view items.
1101
+ * Returns both the matched view item and match metadata.
1102
+ */
1103
+ findViewItemByPath(pathname, outletId, mustBeIonRoute, allowDefaultMatch = true) {
1104
+ let viewItem;
1105
+ let match = null;
1106
+ let viewStack;
1107
+ // Capture stored parent paths for use in nested matchView/matchDefaultRoute functions
1108
+ const storedParentPaths = this.outletParentPaths;
1109
+ if (outletId) {
1110
+ viewStack = sortViewsBySpecificity(this.getViewItemsForOutlet(outletId));
1111
+ viewStack.some(matchView);
1112
+ if (!viewItem && allowDefaultMatch)
1113
+ viewStack.some(matchDefaultRoute);
1114
+ }
1115
+ else {
1116
+ const viewItems = sortViewsBySpecificity(this.getAllViewItems());
1117
+ viewItems.some(matchView);
1118
+ if (!viewItem && allowDefaultMatch)
1119
+ viewItems.some(matchDefaultRoute);
1120
+ }
1121
+ // If we still have not found a view item for this outlet, try to find a matching
1122
+ // view item across all outlets and adopt it into the current outlet. This helps
1123
+ // recover when an outlet remounts and receives a new id, leaving views associated
1124
+ // with the previous outlet id.
1125
+ // Do not adopt across outlets; if we didn't find a view for this outlet,
1126
+ // defer to route matching to create a new one.
1127
+ return { viewItem, match };
1128
+ /**
1129
+ * Matches a route path with dynamic parameters (e.g. /tabs/:id)
1130
+ */
1131
+ function matchView(v) {
1132
+ if (mustBeIonRoute && !v.ionRoute)
1133
+ return false;
1134
+ const viewItemPath = v.routeData.childProps.path || '';
1135
+ // Skip unmounted catch-all wildcard views. After back navigation unmounts
1136
+ // a wildcard view, it should not be reused for subsequent navigations.
1137
+ // A fresh wildcard view will be created by createViewItem when needed.
1138
+ if ((viewItemPath === '*' || viewItemPath === '/*') && !v.mount)
1139
+ return false;
1140
+ const isIndexRoute = !!v.routeData.childProps.index;
1141
+ const previousMatch = v.routeData?.match;
1142
+ const outletParentPath = storedParentPaths.get(v.outletId);
1143
+ const result = v.reactElement ? matchComponent$1(v.reactElement, pathname, false, outletParentPath) : null;
1144
+ if (!result) {
1145
+ const indexMatch = resolveIndexRouteMatch(v, pathname, outletParentPath);
1146
+ if (indexMatch) {
1147
+ match = indexMatch;
1148
+ viewItem = v;
1149
+ return true;
1150
+ }
1151
+ // Empty path routes (path="") should match when the pathname matches the
1152
+ // outlet's parent path exactly (no remaining segments). matchComponent doesn't
1153
+ // handle this because it lacks parent path context. Without this check, a
1154
+ // catch-all * view item (which matches any pathname) would be incorrectly
1155
+ // returned instead of the empty path route on back navigation.
1156
+ if (viewItemPath === '' && !isIndexRoute && outletParentPath) {
1157
+ const normalizedParent = normalizePathnameForComparison(outletParentPath);
1158
+ const normalizedPathname = normalizePathnameForComparison(pathname);
1159
+ if (normalizedPathname === normalizedParent) {
1160
+ match = createDefaultMatch(pathname, v.routeData.childProps);
1161
+ viewItem = v;
1162
+ return true;
1163
+ }
1164
+ }
1165
+ }
1166
+ if (result) {
1167
+ const hasParams = result.params && Object.keys(result.params).length > 0;
1168
+ const isSamePath = result.pathname === previousMatch?.pathname;
1169
+ const isWildcardRoute = viewItemPath.includes('*');
1170
+ const isParameterRoute = viewItemPath.includes(':');
1171
+ // Don't allow view items with undefined paths to match specific routes
1172
+ // This prevents broken index route view items from interfering with navigation
1173
+ if (!viewItemPath && !isIndexRoute && pathname !== '/' && pathname !== '') {
1174
+ return false;
1175
+ }
1176
+ // For parameterized routes, check if we should reuse the view item.
1177
+ // Wildcard routes (e.g., user/:userId/*) compare pathnameBase to allow
1178
+ // child path changes while preserving the parent view.
1179
+ if (isParameterRoute && !isSamePath) {
1180
+ if (isWildcardRoute) {
1181
+ const isSameBase = result.pathnameBase === previousMatch?.pathnameBase;
1182
+ if (isSameBase) {
1183
+ match = result;
1184
+ viewItem = v;
1185
+ return true;
1186
+ }
1187
+ }
1188
+ return false;
1189
+ }
1190
+ // For routes without params, or when navigating to the exact same path,
1191
+ // or when there's no previous match, reuse the view item
1192
+ if (!hasParams || isSamePath || !previousMatch) {
1193
+ match = result;
1194
+ viewItem = v;
1195
+ return true;
1196
+ }
1197
+ // For pure wildcard routes (without : params), compare pathnameBase to allow
1198
+ // child path changes while preserving the parent view. This handles container
1199
+ // routes like /tabs/* where switching between /tabs/tab1 and /tabs/tab2
1200
+ // should reuse the same ViewItem.
1201
+ if (isWildcardRoute && !isParameterRoute) {
1202
+ const isSameBase = result.pathnameBase === previousMatch?.pathnameBase;
1203
+ if (isSameBase) {
1204
+ match = result;
1205
+ viewItem = v;
1206
+ return true;
1207
+ }
1208
+ }
1209
+ }
1210
+ return false;
1211
+ }
1212
+ /**
1213
+ * Matches a view with no path prop (default fallback route) or index route.
1214
+ */
1215
+ function matchDefaultRoute(v) {
1216
+ const childProps = v.routeData.childProps;
1217
+ const isDefaultRoute = childProps.path === undefined || childProps.path === '';
1218
+ const isIndexRoute = !!childProps.index;
1219
+ if (isIndexRoute) {
1220
+ const outletParentPath = storedParentPaths.get(v.outletId);
1221
+ const indexMatch = resolveIndexRouteMatch(v, pathname, outletParentPath);
1222
+ if (indexMatch) {
1223
+ match = indexMatch;
1224
+ viewItem = v;
1225
+ return true;
1226
+ }
1227
+ return false;
1228
+ }
1229
+ // For empty path routes, only match if we're at the same level as when the view was created.
1230
+ // This prevents an empty path view item from being reused for different routes.
1231
+ if (isDefaultRoute) {
1232
+ const previousPathnameBase = v.routeData?.match?.pathnameBase || '';
1233
+ const normalizedBase = normalizePathnameForComparison(previousPathnameBase);
1234
+ const normalizedPathname = normalizePathnameForComparison(pathname);
1235
+ if (normalizedPathname !== normalizedBase) {
1236
+ return false;
1237
+ }
1238
+ match = {
1239
+ params: {},
1240
+ pathname,
1241
+ pathnameBase: pathname === '' ? '/' : pathname,
1242
+ pattern: {
1243
+ path: '',
1244
+ caseSensitive: childProps.caseSensitive ?? false,
1245
+ end: true,
1246
+ },
1247
+ };
1248
+ viewItem = v;
1249
+ return true;
1250
+ }
1251
+ return false;
1252
+ }
1253
+ }
1254
+ /**
1255
+ * Clean up old, unmounted view items to prevent memory leaks
1256
+ */
1257
+ cleanupStaleViewItems = (outletId) => {
1258
+ const viewItems = this.getViewItemsForOutlet(outletId);
1259
+ // Keep only the most recent mounted views and a few unmounted ones for history
1260
+ const maxUnmountedItems = 3;
1261
+ const unmountedItems = viewItems.filter((v) => !v.mount);
1262
+ if (unmountedItems.length > maxUnmountedItems) {
1263
+ // Remove oldest unmounted items
1264
+ const itemsToRemove = unmountedItems.slice(0, unmountedItems.length - maxUnmountedItems);
1265
+ itemsToRemove.forEach((item) => {
1266
+ this.remove(item);
1267
+ });
1268
+ }
1269
+ };
1270
+ /**
1271
+ * Override add to prevent duplicate view items with the same ID in the same outlet
1272
+ * But allow multiple view items for the same route path (for navigation history)
1273
+ */
1274
+ add = (viewItem) => {
1275
+ const existingViewItem = this.getViewItemsForOutlet(viewItem.outletId).find((v) => v.id === viewItem.id);
1276
+ if (existingViewItem) {
1277
+ return;
1278
+ }
1279
+ super.add(viewItem);
1280
+ this.cleanupStaleViewItems(viewItem.outletId);
1281
+ };
1282
+ /**
1283
+ * Override clear to also clean up the stored parent path for the outlet.
1284
+ */
1285
+ clear = (outletId) => {
1286
+ this.outletParentPaths.delete(outletId);
1287
+ this.outletMountPaths.delete(outletId);
1288
+ return super.clear(outletId);
1289
+ };
1290
+ /**
1291
+ * Override remove
1292
+ */
1293
+ remove = (viewItem) => {
1294
+ super.remove(viewItem);
1295
+ };
1296
+ }
1297
+ /**
1298
+ * Utility to apply matchPath to a React element and return its match state.
1299
+ */
1300
+ function matchComponent$1(node, pathname, allowFallback = false, parentPath) {
1301
+ const routeProps = node?.props ?? {};
1302
+ const routePath = routeProps.path;
1303
+ let pathnameToMatch;
1304
+ if (parentPath && routePath && !routePath.startsWith('/')) {
1305
+ // When parent path is known, compute exact relative pathname
1306
+ // instead of using the tail-slice heuristic
1307
+ const relative = pathname.startsWith(parentPath) ? pathname.slice(parentPath.length).replace(/^\//, '') : pathname;
1308
+ pathnameToMatch = relative;
1309
+ }
1310
+ else {
1311
+ pathnameToMatch = derivePathnameToMatch(pathname, routePath);
1312
+ }
1313
+ const match = matchPath({
1314
+ pathname: pathnameToMatch,
1315
+ componentProps: routeProps,
1316
+ });
1317
+ if (match || !allowFallback) {
1318
+ return match;
1319
+ }
1320
+ const isIndexRoute = !!routeProps.index;
1321
+ if (isIndexRoute) {
1322
+ return createDefaultMatch(pathname, routeProps);
1323
+ }
1324
+ if (!routePath || routePath === '') {
1325
+ return createDefaultMatch(pathname, routeProps);
1326
+ }
1327
+ return null;
1328
+ }
1329
+
1330
+ function clonePageElement(leavingViewHtml) {
1331
+ let html;
1332
+ if (typeof leavingViewHtml === 'string') {
1333
+ html = leavingViewHtml;
1334
+ }
1335
+ else {
1336
+ html = leavingViewHtml.outerHTML;
1337
+ }
1338
+ if (document) {
1339
+ const newEl = document.createElement('div');
1340
+ newEl.innerHTML = html;
1341
+ newEl.style.zIndex = '';
1342
+ // Remove an existing back button so the new element doesn't get two of them
1343
+ const ionBackButton = newEl.getElementsByTagName('ion-back-button');
1344
+ if (ionBackButton[0]) {
1345
+ ionBackButton[0].remove();
1346
+ }
1347
+ return newEl.firstChild;
1348
+ }
1349
+ return undefined;
1350
+ }
1351
+
1352
+ /**
1353
+ * `StackManager` is responsible for managing page transitions, keeping track
1354
+ * of views (pages), and ensuring that navigation behaves like native apps —
1355
+ * particularly with animations and swipe gestures.
1356
+ */
1357
+ /**
1358
+ * Delay in milliseconds before unmounting a view after a transition completes.
1359
+ * This ensures the page transition animation finishes before the view is removed.
1360
+ */
1361
+ const VIEW_UNMOUNT_DELAY_MS = 250;
1362
+ /**
1363
+ * Delay (ms) to wait for an IonPage to mount before proceeding with a
1364
+ * page transition. Only container routes (nested outlets with no direct
1365
+ * IonPage) actually hit this timeout; normal routes clear it early via
1366
+ * registerIonPage, so a larger value here doesn't affect the happy path.
1367
+ */
1368
+ const ION_PAGE_WAIT_TIMEOUT_MS = 300;
1369
+ /** Off unless the app sets `logLevel: 'DEBUG'`. */
1370
+ const debug = createDebugLogger('react-router');
1371
+ const isViewVisible = (el) => !el.classList.contains('ion-page-invisible') &&
1372
+ !el.classList.contains('ion-page-hidden') &&
1373
+ el.style.visibility !== 'hidden';
1374
+ const hideIonPageElement = (element) => {
1375
+ if (element) {
1376
+ element.classList.add('ion-page-hidden');
1377
+ element.setAttribute('aria-hidden', 'true');
1378
+ }
1379
+ };
1380
+ const showIonPageElement = (element) => {
1381
+ if (element) {
1382
+ element.style.removeProperty('visibility');
1383
+ // core transitions (md.transition.ts, ios.transition.ts) leave inline styles
1384
+ // on the leaving page after animation: `display: none` plus the final
1385
+ // keyframe values for `transform` and `opacity` (MD only). Preserved views
1386
+ // must clear all of these on re-entry so they render in the correct
1387
+ // position when the back direction entering animation is a no-op.
1388
+ element.style.removeProperty('display');
1389
+ element.style.removeProperty('transform');
1390
+ element.style.removeProperty('opacity');
1391
+ element.classList.remove('ion-page-hidden');
1392
+ element.removeAttribute('aria-hidden');
1393
+ }
1394
+ };
1395
+ /**
1396
+ * Variant of `showIonPageElement` for the swipe-back gesture start. Clears
1397
+ * `display: none` and the hidden class/attribute so the entering view is
1398
+ * visible, but intentionally keeps any inline `transform` and `opacity` set
1399
+ * by core's prior forward transition. The gesture's progress animation starts
1400
+ * from that pose, so clearing them here would cause a visible jump before
1401
+ * core's progress animation takes over.
1402
+ */
1403
+ const revealIonPageForSwipeBack = (element) => {
1404
+ if (element) {
1405
+ element.style.removeProperty('display');
1406
+ element.classList.remove('ion-page-hidden');
1407
+ element.removeAttribute('aria-hidden');
1408
+ }
1409
+ };
1410
+ /**
1411
+ * A leaf view is "preservable" on browser-back (pop) when its React state
1412
+ * should survive a forward-pop round-trip. Non-parameterized leaf paths
1413
+ * resolve to the same view item on re-entry, so keeping them mounted retains
1414
+ * user-visible state (scroll, inputs, cleared lists, etc.).
1415
+ *
1416
+ * Excluded:
1417
+ * - Parameterized routes (`/users/:id`): each param value gets a distinct
1418
+ * view item, so preserving them accumulates hidden views in the DOM.
1419
+ * - Wildcard container routes (`/tabs/*`, `*`): wrap nested outlets and must
1420
+ * be destroyed so nested outlet state rebuilds cleanly on re-entry.
1421
+ */
1422
+ const isViewItemPreservableOnPop = (viewItem) => {
1423
+ const path = viewItem?.reactElement?.props?.path;
1424
+ if (!path) {
1425
+ return false;
1426
+ }
1427
+ if (path === '*' || path.endsWith('/*')) {
1428
+ return false;
1429
+ }
1430
+ return !path.includes(':');
1431
+ };
1432
+ class StackManager extends React.PureComponent {
1433
+ id; // Unique id for the router outlet aka outletId
1434
+ ionRouterOutlet;
1435
+ routerOutletElement;
1436
+ prevProps;
1437
+ skipTransition;
1438
+ stackContextValue = {
1439
+ registerIonPage: this.registerIonPage.bind(this),
1440
+ isInOutlet: () => true,
1441
+ };
1442
+ pendingPageTransition = false;
1443
+ waitingForIonPage = false;
1444
+ ionPageWaitTimeout;
1445
+ outOfScopeUnmountTimeout;
1446
+ /** Whether this outlet was previously in scope. */
1447
+ wasInScope = true;
1448
+ /**
1449
+ * Track the last transition's entering and leaving view IDs to prevent
1450
+ * duplicate transitions during rapid navigation (e.g., Navigate redirects)
1451
+ */
1452
+ lastTransition;
1453
+ /**
1454
+ * Views that have been explicitly kept alive by the pop-preserve logic
1455
+ * (shouldPreserveLeavingView) so a future forward-pop can restore their React
1456
+ * state. These are candidates for cleanup when a fresh push invalidates the
1457
+ * forward-history path that made them reachable. Views mounted through
1458
+ * normal forward-push (which keeps the leaving view alive by default) are
1459
+ * NOT tracked here.
1460
+ */
1461
+ preservedViewItems = new Set();
1462
+ /** Tracks whether the component is mounted to guard async transition paths. */
1463
+ _isMounted = false;
1464
+ /** In-flight requestAnimationFrame IDs from transitionPage, cancelled on unmount. */
1465
+ transitionRafIds = [];
1466
+ /** In-flight MutationObserver from waitForComponentsReady, disconnected on unmount. */
1467
+ transitionObserver;
1468
+ /**
1469
+ * Monotonically increasing counter incremented at the start of each transitionPage call.
1470
+ * Used to detect when an async commit() resolves after a newer transition has already run,
1471
+ * preventing the stale commit from hiding an element that the newer transition made visible.
1472
+ */
1473
+ transitionGeneration = 0;
1474
+ /**
1475
+ * The entering element of the most recent transitionPage call.
1476
+ * Used alongside transitionGeneration to undo incorrect ion-page-hidden applied
1477
+ * by a stale animated commit that raced with a newer non-animated transition.
1478
+ */
1479
+ transitionEnteringElement;
1480
+ constructor(props) {
1481
+ super(props);
1482
+ this.registerIonPage = this.registerIonPage.bind(this);
1483
+ this.transitionPage = this.transitionPage.bind(this);
1484
+ this.handlePageTransition = this.handlePageTransition.bind(this);
1485
+ this.id = props.id || `routerOutlet-${generateId('routerOutlet')}`;
1486
+ this.prevProps = undefined;
1487
+ this.skipTransition = false;
1488
+ }
1489
+ outletMountPath = undefined;
1490
+ /**
1491
+ * Whether this outlet is at the root level (no parent route matches).
1492
+ * Derived from UNSAFE_RouteContext in render() — empty matches means root.
1493
+ */
1494
+ isRootOutlet = true;
1495
+ /**
1496
+ * Determines the parent path for nested routing in React Router 6.
1497
+ *
1498
+ * When the mount path is known (seeded from UNSAFE_RouteContext), returns
1499
+ * it directly — no iterative discovery needed. The computeParentPath
1500
+ * fallback only runs for root outlets where RouteContext doesn't provide
1501
+ * a parent match.
1502
+ */
1503
+ getParentPath() {
1504
+ const currentPathname = this.props.routeInfo.pathname;
1505
+ // Prevent out-of-scope outlets from adopting unrelated routes.
1506
+ // Uses segment-aware comparison: /tabs-secondary must NOT match /tabs scope.
1507
+ if (this.outletMountPath && !isPathnameInScope(currentPathname, this.outletMountPath)) {
1508
+ return undefined;
1509
+ }
1510
+ // Fast path: mount path is known from RouteContext. The parent path IS the
1511
+ // mount path — no need to run the iterative computeParentPath algorithm.
1512
+ if (this.outletMountPath && !this.isRootOutlet) {
1513
+ return this.outletMountPath;
1514
+ }
1515
+ // Fallback: root outlet or mount path not yet seeded. Run the full
1516
+ // computeParentPath algorithm to discover the parent depth.
1517
+ if (this.ionRouterOutlet) {
1518
+ const routeChildren = extractRouteChildren(this.ionRouterOutlet.props.children);
1519
+ const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
1520
+ if (!this.isRootOutlet || hasRelativeRoutes || hasIndexRoute) {
1521
+ const result = computeParentPath({
1522
+ currentPathname,
1523
+ outletMountPath: this.outletMountPath,
1524
+ routeChildren,
1525
+ hasRelativeRoutes,
1526
+ hasIndexRoute,
1527
+ hasWildcardRoute,
1528
+ });
1529
+ if (result.outletMountPath && !this.outletMountPath) {
1530
+ this.outletMountPath = result.outletMountPath;
1531
+ }
1532
+ return result.parentPath;
1533
+ }
1534
+ }
1535
+ return this.outletMountPath;
1536
+ }
1537
+ /**
1538
+ * Finds the entering and leaving view items, handling redirect cases.
1539
+ */
1540
+ findViewItems(routeInfo) {
1541
+ const enteringViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id);
1542
+ let leavingViewItem = this.context.findLeavingViewItemByRouteInfo(routeInfo, this.id);
1543
+ // Try to find leaving view by previous pathname
1544
+ if (!leavingViewItem && routeInfo.prevRouteLastPathname) {
1545
+ leavingViewItem = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id);
1546
+ }
1547
+ // For redirects where entering === leaving, find the actual previous view
1548
+ if (enteringViewItem &&
1549
+ leavingViewItem &&
1550
+ enteringViewItem === leavingViewItem &&
1551
+ routeInfo.routeAction === 'replace' &&
1552
+ routeInfo.prevRouteLastPathname) {
1553
+ const actualLeavingView = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id);
1554
+ if (actualLeavingView && actualLeavingView !== enteringViewItem) {
1555
+ leavingViewItem = actualLeavingView;
1556
+ }
1557
+ }
1558
+ // Handle redirect scenario with no leaving view
1559
+ if (enteringViewItem &&
1560
+ !leavingViewItem &&
1561
+ routeInfo.routeAction === 'replace' &&
1562
+ routeInfo.prevRouteLastPathname) {
1563
+ const actualLeavingView = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id);
1564
+ if (actualLeavingView && actualLeavingView !== enteringViewItem) {
1565
+ leavingViewItem = actualLeavingView;
1566
+ }
1567
+ }
1568
+ return { enteringViewItem, leavingViewItem };
1569
+ }
1570
+ shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem) {
1571
+ if (!leavingViewItem) {
1572
+ return false;
1573
+ }
1574
+ if (routeInfo.routeAction === 'replace') {
1575
+ const leavingRoutePath = leavingViewItem?.reactElement?.props?.path;
1576
+ // Never unmount root path or views without a path - needed for back navigation
1577
+ if (!leavingRoutePath || leavingRoutePath === '/' || leavingRoutePath === '') {
1578
+ return false;
1579
+ }
1580
+ // Replace actions unmount the leaving view since it's being replaced in history.
1581
+ return true;
1582
+ }
1583
+ // For non-replace actions, only unmount for back navigation
1584
+ const isForwardPush = routeInfo.routeAction === 'push' && routeInfo.routeDirection === 'forward';
1585
+ if (!isForwardPush && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) {
1586
+ return true;
1587
+ }
1588
+ return false;
1589
+ }
1590
+ /**
1591
+ * Handles out-of-scope outlet. Returns true if transition should be aborted.
1592
+ */
1593
+ handleOutOfScopeOutlet(routeInfo) {
1594
+ if (!this.outletMountPath || isPathnameInScope(routeInfo.pathname, this.outletMountPath)) {
1595
+ this.wasInScope = true;
1596
+ // Cancel any pending deferred unmount from a previous out-of-scope transition.
1597
+ if (this.outOfScopeUnmountTimeout) {
1598
+ clearTimeout(this.outOfScopeUnmountTimeout);
1599
+ this.outOfScopeUnmountTimeout = undefined;
1600
+ }
1601
+ return false;
1602
+ }
1603
+ // Only run the out-of-scope cleanup on the first transition out of scope.
1604
+ // When parameterized routes create multiple StackManager instances with the
1605
+ // same outlet ID, a stale (hidden) instance must not destroy views that an
1606
+ // active instance just created. After the initial cleanup, the stale instance
1607
+ // stays dormant until its mount path becomes in-scope again.
1608
+ if (!this.wasInScope) {
1609
+ return true;
1610
+ }
1611
+ this.wasInScope = false;
1612
+ // For ionPage outlets whose parent outlet has swipe-to-go-back enabled,
1613
+ // preserve child views so they remain visible during the swipe gesture.
1614
+ // Without this, the deferred unmount removes child pages before the gesture
1615
+ // starts, showing an empty shell when swiping back. Views are cleaned up
1616
+ // when the parent outlet pops this view (componentWillUnmount -> clearOutlet).
1617
+ //
1618
+ // Lifecycle events are skipped in this branch because the view stays mounted
1619
+ // and visible. Firing ionViewWillLeave/DidLeave here would be asymmetric with
1620
+ // no matching ionViewWillEnter/DidEnter when the view comes back in scope.
1621
+ const isIonPageOutlet = this.routerOutletElement?.classList.contains('ion-page');
1622
+ if (isIonPageOutlet) {
1623
+ const parentOutlet = this.routerOutletElement?.parentElement?.closest('ion-router-outlet');
1624
+ if (parentOutlet?.swipeGesture === true) {
1625
+ this.dismissPresentedOverlays();
1626
+ return true;
1627
+ }
1628
+ }
1629
+ // Fire lifecycle events on any visible view before unmounting.
1630
+ // When navigating away from a tabbed section, the parent outlet fires
1631
+ // ionViewDidLeave on the tabs container, but the active tab child page
1632
+ // never receives its own lifecycle events because the core transition
1633
+ // dispatches events with bubbles:false. This ensures tab child pages
1634
+ // get ionViewWillLeave/ionViewDidLeave so useIonViewDidLeave fires.
1635
+ const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id);
1636
+ allViewsInOutlet.forEach((viewItem) => {
1637
+ if (viewItem.ionPageElement && isViewVisible(viewItem.ionPageElement)) {
1638
+ viewItem.ionPageElement.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: false, cancelable: false }));
1639
+ viewItem.ionPageElement.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: false, cancelable: false }));
1640
+ }
1641
+ });
1642
+ // Defer removal of view items to allow the parent outlet's leaving-page
1643
+ // animation to complete with content still visible. When the nested outlet
1644
+ // unmounts views immediately, React removes the child DOM elements before
1645
+ // the parent's transition animation can render them. On MD mode the back
1646
+ // animation only animates the leaving page (slide down + fade), so an
1647
+ // empty shell is invisible and the transition appears instant.
1648
+ //
1649
+ // VIEW_UNMOUNT_DELAY_MS exceeds the MD back transition (200ms).
1650
+ this.outOfScopeUnmountTimeout = setTimeout(() => {
1651
+ if (!this._isMounted)
1652
+ return;
1653
+ allViewsInOutlet.forEach((viewItem) => {
1654
+ this.context.unMountViewItem(viewItem);
1655
+ });
1656
+ this.forceUpdate();
1657
+ }, VIEW_UNMOUNT_DELAY_MS);
1658
+ return true;
1659
+ }
1660
+ /**
1661
+ * Handles root navigation by unmounting all non-entering views in this outlet.
1662
+ * Fires ionViewWillLeave / ionViewDidLeave only on views that are currently visible.
1663
+ * Views that are mounted but not visible (e.g., pages earlier in the back stack)
1664
+ * are silently unmounted without lifecycle events, consistent with the behavior
1665
+ * of out-of-scope outlet cleanup.
1666
+ */
1667
+ handleRootNavigation(enteringViewItem) {
1668
+ const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id);
1669
+ allViewsInOutlet.forEach((viewItem) => {
1670
+ if (viewItem === enteringViewItem) {
1671
+ return;
1672
+ }
1673
+ if (viewItem.ionPageElement && isViewVisible(viewItem.ionPageElement)) {
1674
+ viewItem.ionPageElement.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: false, cancelable: false }));
1675
+ viewItem.ionPageElement.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: false, cancelable: false }));
1676
+ }
1677
+ this.context.unMountViewItem(viewItem);
1678
+ });
1679
+ }
1680
+ /**
1681
+ * Handles nested outlet with relative routes but no parent path. Returns true to abort.
1682
+ */
1683
+ handleOutOfContextNestedOutlet(parentPath, leavingViewItem) {
1684
+ if (this.isRootOutlet || parentPath !== undefined || !this.ionRouterOutlet) {
1685
+ return false;
1686
+ }
1687
+ const routesChildren = getRoutesChildren(this.ionRouterOutlet.props.children) ?? this.ionRouterOutlet.props.children;
1688
+ const routeChildren = React.Children.toArray(routesChildren).filter((child) => React.isValidElement(child) && (child.type === Route || child.type === IonRoute));
1689
+ const hasRelativeRoutes = routeChildren.some((route) => {
1690
+ const path = route.props.path;
1691
+ return path && !path.startsWith('/') && path !== '*';
1692
+ });
1693
+ if (hasRelativeRoutes) {
1694
+ hideIonPageElement(leavingViewItem?.ionPageElement);
1695
+ if (leavingViewItem) {
1696
+ leavingViewItem.mount = false;
1697
+ }
1698
+ this.forceUpdate();
1699
+ return true;
1700
+ }
1701
+ return false;
1702
+ }
1703
+ /**
1704
+ * Handles nested outlet with no matching route. Returns true to abort.
1705
+ */
1706
+ handleNoMatchingRoute(enteringRoute, enteringViewItem, leavingViewItem) {
1707
+ if (this.isRootOutlet || enteringRoute || enteringViewItem) {
1708
+ return false;
1709
+ }
1710
+ hideIonPageElement(leavingViewItem?.ionPageElement);
1711
+ if (leavingViewItem) {
1712
+ leavingViewItem.mount = false;
1713
+ }
1714
+ this.forceUpdate();
1715
+ return true;
1716
+ }
1717
+ /**
1718
+ * Handles transition when entering view has ion-page element ready.
1719
+ */
1720
+ handleReadyEnteringView(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem) {
1721
+ const routePath = enteringViewItem.reactElement?.props?.path;
1722
+ const isParameterizedRoute = routePath ? routePath.includes(':') : false;
1723
+ const isWildcardContainerRoute = routePath ? routePath.endsWith('/*') : false;
1724
+ // Handle same-view transitions (parameterized routes like /user/:id or container routes like /tabs/*)
1725
+ // When entering === leaving, the view is already visible - skip transition to prevent flash
1726
+ if (enteringViewItem === leavingViewItem) {
1727
+ if (isParameterizedRoute || isWildcardContainerRoute) {
1728
+ const updatedMatch = matchComponent(enteringViewItem.reactElement, routeInfo.pathname, true, this.outletMountPath);
1729
+ if (updatedMatch) {
1730
+ enteringViewItem.routeData.match = updatedMatch;
1731
+ }
1732
+ const enteringEl = enteringViewItem.ionPageElement;
1733
+ if (enteringEl) {
1734
+ showIonPageElement(enteringEl);
1735
+ enteringEl.classList.remove('ion-page-invisible');
1736
+ // Maintain can-go-back state since we skip transitionPage/commit.
1737
+ // Without this, the back button disappears on re-navigation to a
1738
+ // parameterized route within a nested outlet (e.g. welcome -> item -> back -> item).
1739
+ if (routeInfo.pushedByRoute) {
1740
+ enteringEl.classList.add('can-go-back');
1741
+ }
1742
+ else {
1743
+ enteringEl.classList.remove('can-go-back');
1744
+ }
1745
+ }
1746
+ this.forceUpdate();
1747
+ return;
1748
+ }
1749
+ }
1750
+ // For wildcard container routes, check if we're navigating within the same container.
1751
+ // If both the current pathname and the previous pathname match the same container route,
1752
+ // skip the transition - the nested outlet will handle the actual page change.
1753
+ // This handles cases where leavingViewItem lookup fails (e.g., no IonPage wrapper).
1754
+ if (isWildcardContainerRoute && routeInfo.lastPathname) {
1755
+ // routePath is guaranteed to exist since isWildcardContainerRoute checks routePath?.endsWith('/*')
1756
+ const containerBase = routePath.replace(/\/\*$/, '');
1757
+ const currentInContainer = routeInfo.pathname.startsWith(containerBase + '/') || routeInfo.pathname === containerBase;
1758
+ const previousInContainer = routeInfo.lastPathname.startsWith(containerBase + '/') || routeInfo.lastPathname === containerBase;
1759
+ if (currentInContainer && previousInContainer) {
1760
+ const updatedMatch = matchComponent(enteringViewItem.reactElement, routeInfo.pathname, true, this.outletMountPath);
1761
+ if (updatedMatch) {
1762
+ enteringViewItem.routeData.match = updatedMatch;
1763
+ }
1764
+ this.forceUpdate();
1765
+ return;
1766
+ }
1767
+ }
1768
+ if (!leavingViewItem && this.props.routeInfo.prevRouteLastPathname) {
1769
+ leavingViewItem = this.context.findViewItemByPathname(this.props.routeInfo.prevRouteLastPathname, this.id);
1770
+ }
1771
+ // Re-mount views that were previously unmounted (e.g., navigating back to home)
1772
+ if (!enteringViewItem.mount) {
1773
+ enteringViewItem.mount = true;
1774
+ }
1775
+ // A view that becomes the entering view is no longer a stale preserved view.
1776
+ // It's back in the active navigation path, so drop it from the cleanup set.
1777
+ this.preservedViewItems.delete(enteringViewItem);
1778
+ // Check visibility state BEFORE showing entering view
1779
+ const enteringWasVisible = enteringViewItem.ionPageElement && isViewVisible(enteringViewItem.ionPageElement);
1780
+ const leavingIsHidden = leavingViewItem !== undefined && leavingViewItem.ionPageElement && !isViewVisible(leavingViewItem.ionPageElement);
1781
+ const currentTransition = {
1782
+ enteringId: enteringViewItem.id,
1783
+ leavingId: leavingViewItem?.id,
1784
+ };
1785
+ const isDuplicateTransition = leavingViewItem &&
1786
+ this.lastTransition &&
1787
+ this.lastTransition.leavingId &&
1788
+ this.lastTransition.enteringId === currentTransition.enteringId &&
1789
+ this.lastTransition.leavingId === currentTransition.leavingId;
1790
+ // Skip if transition already performed (e.g., via swipe gesture)
1791
+ if (enteringWasVisible && leavingIsHidden && isDuplicateTransition) {
1792
+ if (this.skipTransition &&
1793
+ shouldUnmountLeavingViewItem &&
1794
+ leavingViewItem &&
1795
+ enteringViewItem !== leavingViewItem) {
1796
+ leavingViewItem.mount = false;
1797
+ // Trigger ionViewDidLeave lifecycle for ViewLifeCycleManager cleanup
1798
+ this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back');
1799
+ }
1800
+ this.skipTransition = false;
1801
+ this.forceUpdate();
1802
+ return;
1803
+ }
1804
+ showIonPageElement(enteringViewItem.ionPageElement);
1805
+ // Handle duplicate transition or swipe gesture completion
1806
+ if (isDuplicateTransition || this.skipTransition) {
1807
+ if (this.skipTransition &&
1808
+ shouldUnmountLeavingViewItem &&
1809
+ leavingViewItem &&
1810
+ enteringViewItem !== leavingViewItem) {
1811
+ leavingViewItem.mount = false;
1812
+ // Re-fire ionViewDidLeave since gesture completed before mount=false was set
1813
+ this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back');
1814
+ }
1815
+ this.skipTransition = false;
1816
+ this.forceUpdate();
1817
+ return;
1818
+ }
1819
+ this.lastTransition = currentTransition;
1820
+ const shouldSkipAnimation = this.applySkipAnimationIfNeeded(enteringViewItem, leavingViewItem);
1821
+ this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, undefined, false, shouldSkipAnimation);
1822
+ if (shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem) {
1823
+ // For replace actions, skip setting mount=false here. handleLeavingViewUnmount
1824
+ // sets it only after its container-to-container guard passes, avoiding zombie state.
1825
+ //
1826
+ // For pop (browser back) on preservable routes (see isViewItemPreservableOnPop),
1827
+ // keep the view alive (hidden by the transition) so its React state survives a
1828
+ // forward-pop round-trip. ionViewDidLeave already fired via the transitionPage()
1829
+ // call above. Swipe-to-go-back still destroys views through the skipTransition
1830
+ // path earlier in this method, matching native gesture behavior.
1831
+ //
1832
+ // handleLeavingViewUnmount below is a no-op for non-replace actions (early return),
1833
+ // so pop-preserved views pass through it untouched.
1834
+ const shouldPreserveLeavingView = routeInfo.routeAction === 'pop' && isViewItemPreservableOnPop(leavingViewItem);
1835
+ if (routeInfo.routeAction !== 'replace' && !shouldPreserveLeavingView) {
1836
+ leavingViewItem.mount = false;
1837
+ }
1838
+ else if (shouldPreserveLeavingView) {
1839
+ this.preservedViewItems.add(leavingViewItem);
1840
+ }
1841
+ this.handleLeavingViewUnmount(routeInfo, enteringViewItem, leavingViewItem);
1842
+ }
1843
+ // Clean up orphaned sibling views after replace actions (redirects)
1844
+ this.cleanupOrphanedSiblingViews(routeInfo, enteringViewItem, leavingViewItem);
1845
+ // On a fresh push, browser forward history is invalidated. Any views we
1846
+ // previously preserved on pop (to support forward navigation) are now
1847
+ // unreachable and should be unmounted so they don't accumulate in the DOM.
1848
+ this.cleanupPreservedViewsOnPush(routeInfo, enteringViewItem, leavingViewItem);
1849
+ }
1850
+ /**
1851
+ * Unmounts views previously kept alive by the pop-preserve logic when a fresh
1852
+ * push invalidates the forward-history path that made them reachable. Only
1853
+ * iterates views explicitly tracked in `preservedViewItems` so that views
1854
+ * naturally mounted through forward-push (the default leaving-view behavior)
1855
+ * are left untouched.
1856
+ */
1857
+ cleanupPreservedViewsOnPush(routeInfo, enteringViewItem, leavingViewItem) {
1858
+ if (routeInfo.routeAction !== 'push') {
1859
+ return;
1860
+ }
1861
+ if (this.preservedViewItems.size === 0) {
1862
+ return;
1863
+ }
1864
+ for (const viewItem of Array.from(this.preservedViewItems)) {
1865
+ if (viewItem === enteringViewItem || viewItem === leavingViewItem) {
1866
+ this.preservedViewItems.delete(viewItem);
1867
+ continue;
1868
+ }
1869
+ if (!viewItem.mount) {
1870
+ this.preservedViewItems.delete(viewItem);
1871
+ continue;
1872
+ }
1873
+ viewItem.mount = false;
1874
+ this.preservedViewItems.delete(viewItem);
1875
+ const viewToUnmount = viewItem;
1876
+ setTimeout(() => {
1877
+ // Skip if a follow-up transition re-entered the view (mount flipped back to true).
1878
+ if (viewToUnmount.mount) {
1879
+ return;
1880
+ }
1881
+ this.context.unMountViewItem(viewToUnmount);
1882
+ this.forceUpdate();
1883
+ }, VIEW_UNMOUNT_DELAY_MS);
1884
+ }
71
1885
  }
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);
78
- });
79
- if (viewItem) {
80
- viewItem.reactElement = child;
1886
+ /**
1887
+ * Handles leaving view unmount for replace actions.
1888
+ */
1889
+ handleLeavingViewUnmount(routeInfo, enteringViewItem, leavingViewItem) {
1890
+ // Only replace actions unmount views; push/pop cache for navigation history
1891
+ if (routeInfo.routeAction !== 'replace') {
1892
+ return;
1893
+ }
1894
+ if (!leavingViewItem.ionPageElement) {
1895
+ leavingViewItem.mount = false;
1896
+ const viewToUnmount = leavingViewItem;
1897
+ setTimeout(() => {
1898
+ // Skip if a follow-up transition re-entered the view (mount flipped back to true).
1899
+ if (viewToUnmount.mount) {
1900
+ return;
1901
+ }
1902
+ this.context.unMountViewItem(viewToUnmount);
1903
+ this.forceUpdate();
1904
+ }, VIEW_UNMOUNT_DELAY_MS);
1905
+ return;
1906
+ }
1907
+ const enteringRoutePath = enteringViewItem.reactElement?.props?.path;
1908
+ const leavingRoutePath = leavingViewItem.reactElement?.props?.path;
1909
+ const isEnteringContainerRoute = enteringRoutePath && enteringRoutePath.endsWith('/*');
1910
+ const isLeavingSpecificRoute = leavingRoutePath &&
1911
+ leavingRoutePath !== '' &&
1912
+ leavingRoutePath !== '*' &&
1913
+ !leavingRoutePath.endsWith('/*') &&
1914
+ !leavingViewItem.reactElement?.props?.index;
1915
+ // Skip removal for container-to-container transitions (e.g., /tabs/* → /settings/*).
1916
+ // These routes manage their own nested outlets; unmounting would disrupt child views.
1917
+ if (isEnteringContainerRoute && !isLeavingSpecificRoute) {
1918
+ return;
1919
+ }
1920
+ leavingViewItem.mount = false;
1921
+ const viewToUnmount = leavingViewItem;
1922
+ setTimeout(() => {
1923
+ // Skip if a follow-up transition re-entered the view (mount flipped back to true).
1924
+ if (viewToUnmount.mount) {
1925
+ return;
81
1926
  }
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
- })));
1927
+ this.context.unMountViewItem(viewToUnmount);
1928
+ this.forceUpdate();
1929
+ }, VIEW_UNMOUNT_DELAY_MS);
1930
+ }
1931
+ /**
1932
+ * Cleans up orphaned sibling views after replace actions or push-to-container navigations.
1933
+ */
1934
+ cleanupOrphanedSiblingViews(routeInfo, enteringViewItem, leavingViewItem) {
1935
+ const enteringRoutePath = enteringViewItem.reactElement?.props?.path;
1936
+ if (!enteringRoutePath) {
1937
+ return;
1938
+ }
1939
+ const leavingRoutePath = leavingViewItem?.reactElement?.props?.path;
1940
+ const isContainerRoute = (path) => path?.endsWith('/*');
1941
+ const isReplaceAction = routeInfo.routeAction === 'replace';
1942
+ const isPushToContainer = routeInfo.routeAction === 'push' && routeInfo.routeDirection === 'none' && isContainerRoute(enteringRoutePath);
1943
+ if (!isReplaceAction && !isPushToContainer) {
1944
+ return;
1945
+ }
1946
+ // Skip cleanup for tab switches
1947
+ const isSameView = enteringViewItem === leavingViewItem;
1948
+ const isSameContainerRoute = isContainerRoute(enteringRoutePath) && leavingRoutePath === enteringRoutePath;
1949
+ const isNavigatingWithinContainer = isPushToContainer &&
1950
+ !leavingViewItem &&
1951
+ routeInfo.prevRouteLastPathname?.startsWith(enteringRoutePath.replace(/\/\*$/, ''));
1952
+ if (isSameView || isSameContainerRoute || isNavigatingWithinContainer) {
1953
+ return;
1954
+ }
1955
+ const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id);
1956
+ const areSiblingRoutes = (path1, path2) => {
1957
+ const path1IsRelative = !path1.startsWith('/');
1958
+ const path2IsRelative = !path2.startsWith('/');
1959
+ if (path1IsRelative && path2IsRelative) {
1960
+ const path1Depth = path1.replace(/\/\*$/, '').split('/').filter(Boolean).length;
1961
+ const path2Depth = path2.replace(/\/\*$/, '').split('/').filter(Boolean).length;
1962
+ return path1Depth === path2Depth && path1Depth <= 1;
89
1963
  }
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;
97
- viewItem.mount = false;
1964
+ const getParent = (path) => {
1965
+ const normalized = path.replace(/\/\*$/, '');
1966
+ const segments = normalized.split('/').filter(Boolean);
1967
+ // Strip trailing parameter segments (e.g., :id) so that
1968
+ // sibling routes like /items/list/:id and /items/detail/:id
1969
+ // resolve to the same parent (/items).
1970
+ while (segments.length > 0 && segments[segments.length - 1].startsWith(':')) {
1971
+ segments.pop();
98
1972
  }
1973
+ segments.pop();
1974
+ return segments.length > 0 ? '/' + segments.join('/') : '/';
1975
+ };
1976
+ const parent = getParent(path1);
1977
+ // Exclude root-level routes from sibling detection to avoid unintended
1978
+ // cleanup of unrelated top-level routes. Also covers single-depth param
1979
+ // routes (e.g., /items/:id) which resolve to root after param stripping.
1980
+ if (parent === '/') {
1981
+ return false;
1982
+ }
1983
+ return parent === getParent(path2);
1984
+ };
1985
+ for (const viewItem of allViewsInOutlet) {
1986
+ const viewRoutePath = viewItem.reactElement?.props?.path;
1987
+ const shouldSkip = viewItem.id === enteringViewItem.id ||
1988
+ (leavingViewItem && viewItem.id === leavingViewItem.id) ||
1989
+ !viewItem.mount ||
1990
+ !viewRoutePath ||
1991
+ // Don't clean up container routes when entering a container route
1992
+ // (e.g., /tabs/* and /settings/* coexist for tab switching)
1993
+ (viewRoutePath.endsWith('/*') && enteringRoutePath.endsWith('/*'));
1994
+ if (shouldSkip) {
1995
+ continue;
1996
+ }
1997
+ const isOrphanedSpecificRoute = !viewRoutePath.endsWith('/*');
1998
+ // Clean up sibling non-container routes that are no longer reachable.
1999
+ let shouldCleanup = false;
2000
+ if ((isReplaceAction || isPushToContainer) && isOrphanedSpecificRoute) {
2001
+ shouldCleanup = areSiblingRoutes(enteringRoutePath, viewRoutePath);
2002
+ }
2003
+ if (shouldCleanup) {
2004
+ hideIonPageElement(viewItem.ionPageElement);
2005
+ viewItem.mount = false;
2006
+ const viewToRemove = viewItem;
2007
+ setTimeout(() => {
2008
+ // Skip if a follow-up transition re-entered the view (mount flipped back to true).
2009
+ if (viewToRemove.mount) {
2010
+ return;
2011
+ }
2012
+ this.context.unMountViewItem(viewToRemove);
2013
+ this.forceUpdate();
2014
+ }, VIEW_UNMOUNT_DELAY_MS);
99
2015
  }
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
2016
  }
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;
119
2017
  }
120
2018
  /**
121
- * Returns the matching view item and the match result for a given pathname.
2019
+ * Determines whether to skip the transition animation and, if so, immediately
2020
+ * hides the leaving view with inline `visibility:hidden`.
2021
+ *
2022
+ * Skips transitions only for outlets nested inside a parent IonPage's content
2023
+ * area (i.e., an ion-content sits between the outlet and the .ion-page). These
2024
+ * outlets render child pages inside a parent page's scrollable area, and the MD
2025
+ * animation shows both entering and leaving pages simultaneously — causing text
2026
+ * overlap and nested scrollbars. Standard page-level outlets (tabs, routing,
2027
+ * swipe-to-go-back) animate normally even though they sit inside a framework-
2028
+ * managed .ion-page wrapper from the parent outlet's view stack.
2029
+ *
2030
+ * Uses inline visibility:hidden rather than ion-page-hidden class because
2031
+ * core's beforeTransition() removes ion-page-hidden via setPageHidden().
2032
+ * Inline visibility:hidden survives that removal, keeping the page hidden
2033
+ * until React unmounts it after ionViewDidLeave fires. Unlike display:none,
2034
+ * visibility:hidden preserves element geometry so commit() animations
2035
+ * can resolve normally.
122
2036
  */
123
- findViewItemByPath(pathname, outletId, mustBeIonRoute) {
124
- let viewItem;
125
- let match;
126
- let viewStack;
127
- if (outletId) {
128
- viewStack = this.getViewItemsForOutlet(outletId);
129
- viewStack.some(matchView);
130
- if (!viewItem) {
131
- viewStack.some(matchDefaultRoute);
2037
+ applySkipAnimationIfNeeded(enteringViewItem, leavingViewItem) {
2038
+ // Only skip for outlets genuinely nested inside a page's content area.
2039
+ // Walk from the outlet up to the nearest .ion-page; if an ion-content
2040
+ // sits in between, the outlet is inside scrollable page content and
2041
+ // animating would cause overlapping pages with duplicate scrollbars.
2042
+ let isInsidePageContent = false;
2043
+ let el = this.routerOutletElement?.parentElement ?? null;
2044
+ while (el) {
2045
+ if (el.classList.contains('ion-page'))
2046
+ break;
2047
+ if (el.tagName === 'ION-CONTENT') {
2048
+ isInsidePageContent = true;
2049
+ break;
132
2050
  }
2051
+ el = el.parentElement;
133
2052
  }
134
- else {
135
- const viewItems = this.getAllViewItems();
136
- viewItems.some(matchView);
137
- if (!viewItem) {
138
- viewItems.some(matchDefaultRoute);
139
- }
2053
+ const shouldSkip = isInsidePageContent && !!leavingViewItem && enteringViewItem !== leavingViewItem;
2054
+ if (shouldSkip && leavingViewItem?.ionPageElement) {
2055
+ leavingViewItem.ionPageElement.style.setProperty('visibility', 'hidden');
2056
+ leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true');
140
2057
  }
141
- return { viewItem, match };
142
- function matchView(v) {
143
- var _a, _b;
144
- if (mustBeIonRoute && !v.ionRoute) {
145
- return false;
2058
+ return shouldSkip;
2059
+ }
2060
+ /**
2061
+ * Handles entering view with no ion-page element yet (waiting for render).
2062
+ */
2063
+ handleWaitingForIonPage(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem) {
2064
+ const enteringRouteElement = enteringViewItem.reactElement?.props?.element;
2065
+ // Handle Navigate components (they never render an IonPage)
2066
+ if (isNavigateElement(enteringRouteElement)) {
2067
+ this.waitingForIonPage = false;
2068
+ if (this.ionPageWaitTimeout) {
2069
+ clearTimeout(this.ionPageWaitTimeout);
2070
+ this.ionPageWaitTimeout = undefined;
146
2071
  }
147
- match = matchPath({
148
- pathname,
149
- componentProps: v.routeData.childProps,
2072
+ this.pendingPageTransition = false;
2073
+ // Hide ALL other visible views in this outlet for Navigate redirects.
2074
+ // Same rationale as the timeout path: intermediate redirects can shift
2075
+ // the leaving view reference, leaving the original page visible.
2076
+ const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id);
2077
+ allViewsInOutlet.forEach((viewItem) => {
2078
+ if (viewItem.id !== enteringViewItem.id && viewItem.ionPageElement) {
2079
+ hideIonPageElement(viewItem.ionPageElement);
2080
+ }
150
2081
  });
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))) {
160
- viewItem = v;
161
- return true;
2082
+ // Don't unmount if entering and leaving are the same view item
2083
+ if (shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem) {
2084
+ const shouldPreserveLeavingView = routeInfo.routeAction === 'pop' && isViewItemPreservableOnPop(leavingViewItem);
2085
+ if (routeInfo.routeAction !== 'replace' && !shouldPreserveLeavingView) {
2086
+ leavingViewItem.mount = false;
162
2087
  }
2088
+ else if (shouldPreserveLeavingView) {
2089
+ this.preservedViewItems.add(leavingViewItem);
2090
+ }
2091
+ this.handleLeavingViewUnmount(routeInfo, enteringViewItem, leavingViewItem);
163
2092
  }
164
- return false;
165
- }
166
- 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) {
169
- match = {
170
- path: pathname,
171
- url: pathname,
172
- isExact: true,
173
- params: {},
174
- };
175
- viewItem = v;
176
- return true;
177
- }
178
- return false;
2093
+ this.cleanupPreservedViewsOnPush(routeInfo, enteringViewItem, leavingViewItem);
2094
+ this.forceUpdate();
2095
+ return;
179
2096
  }
180
- }
181
- }
182
- function matchComponent$1(node, pathname) {
183
- return matchPath({
184
- pathname,
185
- componentProps: node.props,
186
- });
187
- }
188
-
189
- function clonePageElement(leavingViewHtml) {
190
- let html;
191
- if (typeof leavingViewHtml === 'string') {
192
- html = leavingViewHtml;
193
- }
194
- else {
195
- html = leavingViewHtml.outerHTML;
196
- }
197
- if (document) {
198
- const newEl = document.createElement('div');
199
- newEl.innerHTML = html;
200
- newEl.style.zIndex = '';
201
- // Remove an existing back button so the new element doesn't get two of them
202
- const ionBackButton = newEl.getElementsByTagName('ion-back-button');
203
- if (ionBackButton[0]) {
204
- ionBackButton[0].remove();
2097
+ // Do not hide the leaving view here - wait until the entering view is ready.
2098
+ // Hiding the leaving view while the entering view is still mounting causes a flash
2099
+ // where both views are hidden/invisible simultaneously.
2100
+ // The leaving view will be hidden in transitionPage() after the entering view is visible.
2101
+ this.waitingForIonPage = true;
2102
+ if (this.ionPageWaitTimeout) {
2103
+ clearTimeout(this.ionPageWaitTimeout);
205
2104
  }
206
- return newEl.firstChild;
2105
+ this.ionPageWaitTimeout = setTimeout(() => {
2106
+ this.ionPageWaitTimeout = undefined;
2107
+ if (!this.waitingForIonPage) {
2108
+ return;
2109
+ }
2110
+ this.waitingForIonPage = false;
2111
+ const latestEnteringView = this.context.findViewItemByRouteInfo(routeInfo, this.id) ?? enteringViewItem;
2112
+ const latestLeavingView = this.context.findLeavingViewItemByRouteInfo(routeInfo, this.id) ?? leavingViewItem;
2113
+ if (latestEnteringView?.ionPageElement) {
2114
+ const shouldSkipAnimation = this.applySkipAnimationIfNeeded(latestEnteringView, latestLeavingView ?? undefined);
2115
+ this.transitionPage(routeInfo, latestEnteringView, latestLeavingView ?? undefined, undefined, false, shouldSkipAnimation);
2116
+ if (shouldUnmountLeavingViewItem && latestLeavingView && latestEnteringView !== latestLeavingView) {
2117
+ const shouldPreserveLeavingView = routeInfo.routeAction === 'pop' && isViewItemPreservableOnPop(latestLeavingView);
2118
+ if (routeInfo.routeAction !== 'replace' && !shouldPreserveLeavingView) {
2119
+ latestLeavingView.mount = false;
2120
+ }
2121
+ else if (shouldPreserveLeavingView) {
2122
+ this.preservedViewItems.add(latestLeavingView);
2123
+ }
2124
+ this.handleLeavingViewUnmount(routeInfo, latestEnteringView, latestLeavingView);
2125
+ }
2126
+ this.cleanupPreservedViewsOnPush(routeInfo, latestEnteringView, latestLeavingView ?? undefined);
2127
+ this.forceUpdate();
2128
+ }
2129
+ else {
2130
+ /**
2131
+ * Timeout fired and entering view still has no ionPageElement.
2132
+ * This happens for container routes that render nested outlets without a direct IonPage.
2133
+ * Hide ALL other visible views in this outlet, not just the computed leaving view.
2134
+ * This handles cases where intermediate redirects (e.g., Navigate in nested routes)
2135
+ * change the leaving view reference, leaving the original page still visible.
2136
+ */
2137
+ const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id);
2138
+ allViewsInOutlet.forEach((viewItem) => {
2139
+ if (viewItem.id !== latestEnteringView.id && viewItem.ionPageElement) {
2140
+ hideIonPageElement(viewItem.ionPageElement);
2141
+ }
2142
+ });
2143
+ this.forceUpdate();
2144
+ // Safety net: after forceUpdate triggers a React render cycle, check if
2145
+ // any pages in this outlet are stuck with ion-page-invisible. This can
2146
+ // happen when view lookup fails (e.g., wildcard-to-index transitions
2147
+ // where the view item gets corrupted). The forceUpdate above causes
2148
+ // React to render the correct component, but ion-page-invisible may
2149
+ // persist if no transition runs for that page.
2150
+ setTimeout(() => {
2151
+ if (!this._isMounted || !this.routerOutletElement)
2152
+ return;
2153
+ const stuckPages = this.routerOutletElement.querySelectorAll(':scope > .ion-page-invisible');
2154
+ stuckPages.forEach((page) => {
2155
+ page.classList.remove('ion-page-invisible');
2156
+ });
2157
+ }, ION_PAGE_WAIT_TIMEOUT_MS);
2158
+ }
2159
+ }, ION_PAGE_WAIT_TIMEOUT_MS);
2160
+ this.forceUpdate();
207
2161
  }
208
- return undefined;
209
- }
210
-
211
- const isViewVisible = (el) => !el.classList.contains('ion-page-invisible') && !el.classList.contains('ion-page-hidden');
212
- class StackManager extends React.PureComponent {
213
- constructor(props) {
214
- super(props);
215
- this.stackContextValue = {
216
- registerIonPage: this.registerIonPage.bind(this),
217
- isInOutlet: () => true,
218
- };
219
- this.pendingPageTransition = false;
220
- this.registerIonPage = this.registerIonPage.bind(this);
221
- this.transitionPage = this.transitionPage.bind(this);
222
- this.handlePageTransition = this.handlePageTransition.bind(this);
223
- this.id = generateId('routerOutlet');
224
- this.prevProps = undefined;
225
- this.skipTransition = false;
2162
+ /**
2163
+ * Gets the route info to use for finding views during swipe-to-go-back gestures.
2164
+ * This pattern is used in multiple places in setupRouterOutlet.
2165
+ */
2166
+ getSwipeBackRouteInfo() {
2167
+ const { routeInfo } = this.props;
2168
+ return this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute
2169
+ ? this.prevProps.routeInfo
2170
+ : { pathname: routeInfo.pushedByRoute || '' };
226
2171
  }
227
2172
  componentDidMount() {
228
- if (this.clearOutletTimeout) {
229
- /**
230
- * The clearOutlet integration with React Router is a bit hacky.
231
- * It uses a timeout to clear the outlet after a transition.
232
- * In React v18, components are mounted and unmounted in development mode
233
- * to check for side effects.
234
- *
235
- * This clearTimeout prevents the outlet from being cleared when the component is re-mounted,
236
- * which should only happen in development mode and as a result of a hot reload.
237
- */
238
- clearTimeout(this.clearOutletTimeout);
239
- }
2173
+ this._isMounted = true;
240
2174
  if (this.routerOutletElement) {
241
2175
  this.setupRouterOutlet(this.routerOutletElement);
242
- this.handlePageTransition(this.props.routeInfo);
2176
+ // Defer to a microtask to avoid calling forceUpdate() synchronously during
2177
+ // React 19's reappearLayoutEffects phase, which re-runs componentDidMount
2178
+ // without a preceding componentWillUnmount and causes "Maximum update depth exceeded".
2179
+ const routeInfo = this.props.routeInfo;
2180
+ queueMicrotask(() => {
2181
+ if (this._isMounted && this.props.routeInfo.pathname === routeInfo.pathname) {
2182
+ this.handlePageTransition(routeInfo);
2183
+ }
2184
+ });
243
2185
  }
244
2186
  }
245
2187
  componentDidUpdate(prevProps) {
@@ -255,117 +2197,196 @@ class StackManager extends React.PureComponent {
255
2197
  }
256
2198
  }
257
2199
  componentWillUnmount() {
258
- this.clearOutletTimeout = this.context.clearOutlet(this.id);
2200
+ this._isMounted = false;
2201
+ // Cancel any in-flight transition rAFs
2202
+ for (const id of this.transitionRafIds) {
2203
+ cancelAnimationFrame(id);
2204
+ }
2205
+ this.transitionRafIds = [];
2206
+ // Disconnect any in-flight MutationObserver from waitForComponentsReady
2207
+ if (this.transitionObserver) {
2208
+ this.transitionObserver.disconnect();
2209
+ this.transitionObserver = undefined;
2210
+ }
2211
+ if (this.ionPageWaitTimeout) {
2212
+ clearTimeout(this.ionPageWaitTimeout);
2213
+ this.ionPageWaitTimeout = undefined;
2214
+ }
2215
+ if (this.outOfScopeUnmountTimeout) {
2216
+ clearTimeout(this.outOfScopeUnmountTimeout);
2217
+ this.outOfScopeUnmountTimeout = undefined;
2218
+ }
2219
+ this.waitingForIonPage = false;
2220
+ this.preservedViewItems.clear();
2221
+ // Hide all views in this outlet before clearing.
2222
+ // This is critical for nested outlets - when the parent component unmounts,
2223
+ // the nested outlet's componentDidUpdate won't be called, so we must hide
2224
+ // the ion-page elements here to prevent them from remaining visible on top
2225
+ // of other content after navigation to a different route.
2226
+ const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id);
2227
+ allViewsInOutlet.forEach((viewItem) => {
2228
+ hideIonPageElement(viewItem.ionPageElement);
2229
+ });
2230
+ this.context.clearOutlet(this.id);
259
2231
  }
2232
+ /**
2233
+ * Sets the transition between pages within this router outlet.
2234
+ * This function determines the entering and leaving views based on the
2235
+ * provided route information and triggers the appropriate animation.
2236
+ * It also handles scenarios like initial loads, back navigation, and
2237
+ * navigation to the same view with different parameters.
2238
+ *
2239
+ * @param routeInfo It contains info about the current route,
2240
+ * the previous route, and the action taken (e.g., push, replace).
2241
+ *
2242
+ * @returns A promise that resolves when the transition is complete.
2243
+ * If no transition is needed or if the router outlet isn't ready,
2244
+ * the Promise may resolve immediately.
2245
+ */
260
2246
  async handlePageTransition(routeInfo) {
261
- var _a, _b;
2247
+ // Wait for router outlet to mount
262
2248
  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
2249
  this.pendingPageTransition = true;
2250
+ return;
271
2251
  }
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);
2252
+ // Find entering and leaving view items
2253
+ const viewItems = this.findViewItems(routeInfo);
2254
+ let enteringViewItem = viewItems.enteringViewItem;
2255
+ let leavingViewItem = viewItems.leavingViewItem;
2256
+ let shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem);
2257
+ // Get parent path for nested outlets
2258
+ const parentPath = this.getParentPath();
2259
+ // Handle out-of-scope outlet (route outside mount path)
2260
+ if (this.handleOutOfScopeOutlet(routeInfo)) {
2261
+ return;
2262
+ }
2263
+ // Handle root navigation: unmount all non-entering views
2264
+ if (routeInfo.routeDirection === 'root') {
2265
+ this.handleRootNavigation(enteringViewItem);
2266
+ leavingViewItem = undefined;
2267
+ shouldUnmountLeavingViewItem = false;
2268
+ }
2269
+ // Clear any pending out-of-scope unmount timeout
2270
+ if (this.outOfScopeUnmountTimeout) {
2271
+ clearTimeout(this.outOfScopeUnmountTimeout);
2272
+ this.outOfScopeUnmountTimeout = undefined;
2273
+ }
2274
+ // Handle nested outlet with relative routes but no valid parent path
2275
+ if (this.handleOutOfContextNestedOutlet(parentPath, leavingViewItem)) {
2276
+ return;
2277
+ }
2278
+ // Find the matching route element
2279
+ const enteringRoute = findRouteByRouteInfo(this.ionRouterOutlet?.props.children, routeInfo, parentPath);
2280
+ // Handle nested outlet with no matching route
2281
+ if (this.handleNoMatchingRoute(enteringRoute, enteringViewItem, leavingViewItem)) {
2282
+ return;
2283
+ }
2284
+ // Create or update the entering view item
2285
+ if (enteringViewItem && enteringRoute) {
2286
+ enteringViewItem.reactElement = enteringRoute;
2287
+ }
2288
+ else if (enteringRoute) {
2289
+ enteringViewItem = this.context.createViewItem(this.id, enteringRoute, routeInfo);
2290
+ this.context.addViewItem(enteringViewItem);
2291
+ }
2292
+ // Handle transition based on ion-page element availability
2293
+ // Check if the ionPageElement is still in the document.
2294
+ // If the view was previously unmounted (mount=false), the ViewLifeCycleManager
2295
+ // removes the React component from the tree, which removes the IonPage from the DOM.
2296
+ // The ionPageElement reference becomes stale and we need to wait for a new one.
2297
+ const ionPageIsInDocument = enteringViewItem?.ionPageElement && document.body.contains(enteringViewItem.ionPageElement);
2298
+ if (enteringViewItem && ionPageIsInDocument) {
2299
+ // Clear waiting state
2300
+ if (this.waitingForIonPage) {
2301
+ this.waitingForIonPage = false;
277
2302
  }
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
- }
2303
+ if (this.ionPageWaitTimeout) {
2304
+ clearTimeout(this.ionPageWaitTimeout);
2305
+ this.ionPageWaitTimeout = undefined;
291
2306
  }
292
- const enteringRoute = matchRoute((_b = this.ionRouterOutlet) === null || _b === void 0 ? void 0 : _b.props.children, routeInfo);
293
- if (enteringViewItem) {
294
- enteringViewItem.reactElement = enteringRoute;
2307
+ this.handleReadyEnteringView(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem);
2308
+ }
2309
+ else if (enteringViewItem && !ionPageIsInDocument) {
2310
+ // Wait for ion-page to mount
2311
+ // This handles both: no ionPageElement, or stale ionPageElement (not in document)
2312
+ // Clear stale reference if the element is no longer in the document
2313
+ if (enteringViewItem.ionPageElement && !document.body.contains(enteringViewItem.ionPageElement)) {
2314
+ enteringViewItem.ionPageElement = undefined;
295
2315
  }
296
- else if (enteringRoute) {
297
- enteringViewItem = this.context.createViewItem(this.id, enteringRoute, routeInfo);
298
- this.context.addViewItem(enteringViewItem);
2316
+ // Ensure the view is marked as mounted so ViewLifeCycleManager renders the IonPage
2317
+ if (!enteringViewItem.mount) {
2318
+ enteringViewItem.mount = true;
299
2319
  }
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;
2320
+ this.handleWaitingForIonPage(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem);
2321
+ return;
2322
+ }
2323
+ else if (!enteringViewItem && !enteringRoute) {
2324
+ // No view or route found - likely leaving to another outlet
2325
+ if (leavingViewItem) {
2326
+ hideIonPageElement(leavingViewItem.ionPageElement);
2327
+ const shouldPreserveLeavingView = routeInfo.routeAction === 'pop' && isViewItemPreservableOnPop(leavingViewItem);
2328
+ if (shouldUnmountLeavingViewItem && !shouldPreserveLeavingView) {
2329
+ leavingViewItem.mount = false;
333
2330
  }
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');
2331
+ else if (shouldUnmountLeavingViewItem && shouldPreserveLeavingView) {
2332
+ this.preservedViewItems.add(leavingViewItem);
359
2333
  }
360
- // }, 250);
361
2334
  }
362
- this.forceUpdate();
363
2335
  }
2336
+ this.forceUpdate();
364
2337
  }
2338
+ /**
2339
+ * Registers an `<IonPage>` DOM element with the `StackManager`.
2340
+ * This is called when `<IonPage>` has been mounted.
2341
+ *
2342
+ * @param page The element of the rendered `<IonPage>`.
2343
+ * @param routeInfo The route information that associates with `<IonPage>`.
2344
+ */
365
2345
  registerIonPage(page, routeInfo) {
2346
+ /**
2347
+ * DO NOT remove ion-page-invisible here.
2348
+ *
2349
+ * PageManager's ref callback adds ion-page-invisible synchronously to prevent flash.
2350
+ * At this point, the <IonPage> div exists but its CHILDREN (header, toolbar, menu-button)
2351
+ * have NOT rendered yet. If we remove ion-page-invisible now, the page becomes visible
2352
+ * with empty/incomplete content, causing a flicker (especially for ion-menu-button which
2353
+ * starts with menu-button-hidden class).
2354
+ *
2355
+ * Instead, let transitionPage handle visibility AFTER waiting for components to be ready.
2356
+ * This ensures the page only becomes visible when its content is fully rendered.
2357
+ */
2358
+ this.waitingForIonPage = false;
2359
+ if (this.ionPageWaitTimeout) {
2360
+ clearTimeout(this.ionPageWaitTimeout);
2361
+ this.ionPageWaitTimeout = undefined;
2362
+ }
2363
+ this.pendingPageTransition = false;
366
2364
  const foundView = this.context.findViewItemByRouteInfo(routeInfo, this.id);
367
2365
  if (foundView) {
368
2366
  const oldPageElement = foundView.ionPageElement;
2367
+ /**
2368
+ * FIX for issue #28878: Reject orphaned IonPage registrations.
2369
+ *
2370
+ * When a component conditionally renders different IonPages (e.g., list vs empty state)
2371
+ * using React keys, and state changes simultaneously with navigation, the new IonPage
2372
+ * tries to register for a route we're navigating away from. This creates a stale view.
2373
+ *
2374
+ * Only reject if both pageIds exist and differ, to allow nested outlet registrations.
2375
+ */
2376
+ if (this.shouldRejectOrphanedPage(page, oldPageElement, routeInfo)) {
2377
+ this.hideAndRemoveOrphanedPage(page);
2378
+ return;
2379
+ }
2380
+ /**
2381
+ * Don't let a nested element (e.g., ion-router-outlet with ionPage prop)
2382
+ * override an existing IonPage registration when the existing element is
2383
+ * an ancestor of the new one. This ensures ionPageElement always points
2384
+ * to the outermost IonPage, which is needed to properly hide the entire
2385
+ * page during back navigation (not just the inner outlet).
2386
+ */
2387
+ if (oldPageElement && oldPageElement !== page && oldPageElement.isConnected && oldPageElement.contains(page)) {
2388
+ return;
2389
+ }
369
2390
  foundView.ionPageElement = page;
370
2391
  foundView.ionRoute = true;
371
2392
  /**
@@ -379,83 +2400,156 @@ class StackManager extends React.PureComponent {
379
2400
  }
380
2401
  this.handlePageTransition(routeInfo);
381
2402
  }
2403
+ /**
2404
+ * Checks if a new IonPage should be rejected (component re-rendered while navigating away).
2405
+ */
2406
+ shouldRejectOrphanedPage(newPage, oldPageElement, routeInfo) {
2407
+ if (!oldPageElement || oldPageElement === newPage) {
2408
+ return false;
2409
+ }
2410
+ const newPageId = newPage.getAttribute('data-pageid');
2411
+ const oldPageId = oldPageElement.getAttribute('data-pageid');
2412
+ if (!newPageId || !oldPageId || newPageId === oldPageId) {
2413
+ return false;
2414
+ }
2415
+ return this.props.routeInfo.pathname !== routeInfo.pathname;
2416
+ }
2417
+ hideAndRemoveOrphanedPage(page) {
2418
+ page.classList.add('ion-page-hidden');
2419
+ page.setAttribute('aria-hidden', 'true');
2420
+ setTimeout(() => {
2421
+ if (page.parentElement) {
2422
+ page.remove();
2423
+ }
2424
+ }, VIEW_UNMOUNT_DELAY_MS);
2425
+ }
2426
+ /**
2427
+ * Dismisses every presented Ionic overlay in the document. Core moves overlays
2428
+ * to ion-app when presented, so they are no longer descendants of this outlet
2429
+ * and stay visible even after the outlet is hidden, blocking the entering view.
2430
+ *
2431
+ * Scope is document-wide because the original outlet-to-overlay DOM linkage is
2432
+ * lost after presentation. Overlays without a trackable presenting element
2433
+ * cannot be safely attributed to a specific outlet.
2434
+ */
2435
+ dismissPresentedOverlays() {
2436
+ // Matches the overlay set tracked by core's getPresentedOverlays (see
2437
+ // core/src/utils/overlays.ts). An overlay is "presented" when it lacks the
2438
+ // `overlay-hidden` class.
2439
+ const overlaySelector = 'ion-modal, ion-popover, ion-action-sheet, ion-alert, ion-loading';
2440
+ document.querySelectorAll(overlaySelector).forEach((overlay) => {
2441
+ if (overlay.classList.contains('overlay-hidden') || typeof overlay.dismiss !== 'function') {
2442
+ return;
2443
+ }
2444
+ overlay.dismiss().catch(() => {
2445
+ /* Overlay may already be dismissing or its canDismiss guard may block it. */
2446
+ });
2447
+ });
2448
+ }
2449
+ /**
2450
+ * Resolves the entering view for a swipe-back gesture.
2451
+ *
2452
+ * Prefers a view owned by this outlet. Falls back to searching all outlets only
2453
+ * when the candidate's ion-page element is a descendant of this outlet. Without
2454
+ * the containment guard, a nested child outlet can claim ownership of a sibling
2455
+ * outlet's view, running the swipe gesture on the wrong router outlet.
2456
+ */
2457
+ findEnteringViewForSwipe(swipeBackRouteInfo) {
2458
+ const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
2459
+ if (enteringViewItem) {
2460
+ return enteringViewItem;
2461
+ }
2462
+ const candidate = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false);
2463
+ if (candidate?.ionPageElement && this.routerOutletElement?.contains(candidate.ionPageElement)) {
2464
+ return candidate;
2465
+ }
2466
+ return undefined;
2467
+ }
2468
+ /**
2469
+ * Configures swipe-to-go-back gesture for the router outlet.
2470
+ */
382
2471
  async setupRouterOutlet(routerOutlet) {
383
2472
  const canStart = () => {
384
- const config = getConfig();
385
- const swipeEnabled = config && config.get('swipeBackEnabled', routerOutlet.mode === 'ios');
386
- if (!swipeEnabled) {
387
- return false;
388
- }
389
2473
  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);
2474
+ const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
2475
+ const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
2476
+ // View might have mount=false but ionPageElement still in DOM
2477
+ const ionPageInDocument = Boolean(enteringViewItem?.ionPageElement && document.body.contains(enteringViewItem.ionPageElement));
2478
+ // For wildcard/parameterized routes, the pattern path (e.g. "/foo/*") will
2479
+ // never equal the resolved pathname (e.g. "/foo/bar"), so the pattern check
2480
+ // alone isn't sufficient. Also, verify the entering view's resolved pathname
2481
+ // differs from the current pathname if they match, the entering and leaving
2482
+ // views are the same and the swipe gesture shouldn't start.
2483
+ const canStartSwipe = !!enteringViewItem &&
2484
+ (enteringViewItem.mount || ionPageInDocument) &&
2485
+ enteringViewItem.routeData.match.pattern.path !== routeInfo.pathname &&
2486
+ enteringViewItem.routeData.match.pathname !== routeInfo.pathname;
2487
+ debug('SwipeBackCanStart', () => ({
2488
+ outletId: this.id,
2489
+ routePathname: routeInfo.pathname,
2490
+ swipeBackPathname: swipeBackRouteInfo?.pathname,
2491
+ enteringViewId: enteringViewItem?.id,
2492
+ enteringViewPath: enteringViewItem?.reactElement?.props?.path,
2493
+ enteringMount: enteringViewItem?.mount,
2494
+ ionPageInDocument,
2495
+ canStartSwipe,
2496
+ }));
2497
+ return canStartSwipe;
411
2498
  };
412
2499
  const onStart = async () => {
413
2500
  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);
2501
+ const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
2502
+ const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
418
2503
  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
- */
2504
+ debug('SwipeBackOnStart:entry', () => ({
2505
+ outletId: this.id,
2506
+ routePathname: routeInfo.pathname,
2507
+ swipeBackPathname: swipeBackRouteInfo?.pathname,
2508
+ enteringViewId: enteringViewItem?.id,
2509
+ enteringViewPath: enteringViewItem?.reactElement?.props?.path,
2510
+ enteringMount: enteringViewItem?.mount,
2511
+ hasEnteringIonPageElement: !!enteringViewItem?.ionPageElement,
2512
+ leavingViewId: leavingViewItem?.id,
2513
+ }));
2514
+ // Ensure the entering view is mounted so React keeps rendering it during the gesture.
2515
+ // This is important when the view was previously marked for unmount but its
2516
+ // ionPageElement is still in the DOM.
2517
+ if (enteringViewItem && !enteringViewItem.mount) {
2518
+ enteringViewItem.mount = true;
2519
+ }
2520
+ // Reveal synchronously. `transitionPage` defers this behind async commit,
2521
+ // but the gesture's first progress frame fires in the same tick as onStart,
2522
+ // so an async reveal leaves the entering page hidden until the next frame.
2523
+ revealIonPageForSwipeBack(enteringViewItem?.ionPageElement);
2524
+ // When the gesture starts, kick off a transition controlled via swipe gesture
424
2525
  if (enteringViewItem && leavingViewItem) {
425
2526
  await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true);
426
2527
  }
2528
+ debug('SwipeBackOnStart:exit', () => ({
2529
+ outletId: this.id,
2530
+ enteringFinalComputedDisplay: enteringViewItem?.ionPageElement
2531
+ ? getComputedStyle(enteringViewItem.ionPageElement).display
2532
+ : null,
2533
+ enteringFinalInlineDisplay: enteringViewItem?.ionPageElement?.style.display ?? null,
2534
+ enteringFinalHiddenClass: enteringViewItem?.ionPageElement?.classList.contains('ion-page-hidden') ?? null,
2535
+ }));
427
2536
  return Promise.resolve();
428
2537
  };
429
2538
  const onEnd = (shouldContinue) => {
430
2539
  if (shouldContinue) {
2540
+ // User finished the swipe gesture, so complete the back navigation
431
2541
  this.skipTransition = true;
432
2542
  this.context.goBack();
433
2543
  }
434
2544
  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
- */
2545
+ // Swipe gesture was aborted - re-hide the page that was going to enter
440
2546
  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);
2547
+ const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
2548
+ const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
445
2549
  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
- */
455
- 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');
2550
+ // Don't hide if entering and leaving are the same (parameterized route edge case)
2551
+ if (enteringViewItem !== leavingViewItem && enteringViewItem?.ionPageElement !== undefined) {
2552
+ hideIonPageElement(enteringViewItem.ionPageElement);
459
2553
  }
460
2554
  }
461
2555
  };
@@ -465,7 +2559,24 @@ class StackManager extends React.PureComponent {
465
2559
  onEnd,
466
2560
  };
467
2561
  }
468
- async transitionPage(routeInfo, enteringViewItem, leavingViewItem, direction, progressAnimation = false) {
2562
+ /**
2563
+ * Animates the transition between the entering and leaving pages within the
2564
+ * router outlet.
2565
+ *
2566
+ * @param routeInfo Info about the current route.
2567
+ * @param enteringViewItem The view item that is entering.
2568
+ * @param leavingViewItem The view item that is leaving.
2569
+ * @param direction The direction of the transition.
2570
+ * @param progressAnimation Indicates if the transition is part of a
2571
+ * gesture controlled animation (e.g., swipe to go back).
2572
+ * Defaults to `false`.
2573
+ * @param skipAnimation When true, forces `duration: 0` so the page
2574
+ * swap is instant (no visible animation). Used for ionPage outlets
2575
+ * and back navigations that unmount the leaving view to prevent
2576
+ * overlapping content during the transition. Defaults to `false`.
2577
+ */
2578
+ async transitionPage(routeInfo, enteringViewItem, leavingViewItem, direction, progressAnimation = false, skipAnimation = false) {
2579
+ const myGeneration = ++this.transitionGeneration;
469
2580
  const runCommit = async (enteringEl, leavingEl) => {
470
2581
  const skipTransition = this.skipTransition;
471
2582
  /**
@@ -493,24 +2604,56 @@ class StackManager extends React.PureComponent {
493
2604
  }
494
2605
  else {
495
2606
  enteringEl.classList.add('ion-page');
496
- enteringEl.classList.add('ion-page-invisible');
2607
+ /**
2608
+ * Only add ion-page-invisible if the element is not already visible.
2609
+ * During tab switches, the container page (e.g., TabContext wrapper) is
2610
+ * already visible and should remain so. Adding ion-page-invisible would
2611
+ * cause a flash where the visible page briefly becomes invisible.
2612
+ */
2613
+ if (!isViewVisible(enteringEl)) {
2614
+ enteringEl.classList.add('ion-page-invisible');
2615
+ }
497
2616
  }
498
- await routerOutlet.commit(enteringEl, leavingEl, {
499
- duration: skipTransition || directionToUse === undefined ? 0 : undefined,
2617
+ const commitDuration = skipTransition || skipAnimation || directionToUse === undefined ? 0 : undefined;
2618
+ // Race commit against a timeout to recover from hangs
2619
+ const commitPromise = routerOutlet.commit(enteringEl, leavingEl, {
2620
+ duration: commitDuration,
500
2621
  direction: directionToUse,
501
2622
  showGoBack: !!routeInfo.pushedByRoute,
502
2623
  progressAnimation,
503
2624
  animationBuilder: routeInfo.routeAnimation,
504
2625
  });
2626
+ const timeoutMs = 5000;
2627
+ const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve('timeout'), timeoutMs));
2628
+ const result = await Promise.race([commitPromise.then(() => 'done'), timeoutPromise]);
2629
+ // Bail out if the component unmounted during the commit animation
2630
+ if (!this._isMounted)
2631
+ return;
2632
+ if (result === 'timeout') {
2633
+ // Force entering page visible even though commit hung
2634
+ enteringEl.classList.remove('ion-page-invisible');
2635
+ }
2636
+ /**
2637
+ * If a newer transitionPage call ran while this commit was in-flight (e.g., a tab
2638
+ * switch fired during a forward animation), the core commit may have applied
2639
+ * ion-page-hidden to leavingEl even though the newer transition already made it
2640
+ * visible. Undo that stale hide so the newer transition's DOM state wins.
2641
+ */
2642
+ if (myGeneration !== this.transitionGeneration && leavingEl && leavingEl === this.transitionEnteringElement) {
2643
+ showIonPageElement(leavingEl);
2644
+ }
2645
+ if (!progressAnimation) {
2646
+ enteringEl.classList.remove('ion-page-invisible');
2647
+ }
505
2648
  };
506
2649
  const routerOutlet = this.routerOutletElement;
507
2650
  const routeInfoFallbackDirection = routeInfo.routeDirection === 'none' || routeInfo.routeDirection === 'root' ? undefined : routeInfo.routeDirection;
508
- const directionToUse = direction !== null && direction !== void 0 ? direction : routeInfoFallbackDirection;
2651
+ const directionToUse = direction ?? routeInfoFallbackDirection;
509
2652
  if (enteringViewItem && enteringViewItem.ionPageElement && this.routerOutletElement) {
2653
+ this.transitionEnteringElement = enteringViewItem.ionPageElement;
510
2654
  if (leavingViewItem && leavingViewItem.ionPageElement && enteringViewItem === leavingViewItem) {
511
- // If a page is transitioning to another version of itself
512
- // we clone it so we can have an animation to show
513
- const match = matchComponent(leavingViewItem.reactElement, routeInfo.pathname, true);
2655
+ // Clone page for same-view transitions (e.g., /user/1 /user/2)
2656
+ const match = matchComponent(leavingViewItem.reactElement, routeInfo.pathname, undefined, this.outletMountPath);
514
2657
  if (match) {
515
2658
  const newLeavingElement = clonePageElement(leavingViewItem.ionPageElement.outerHTML);
516
2659
  if (newLeavingElement) {
@@ -520,14 +2663,111 @@ class StackManager extends React.PureComponent {
520
2663
  }
521
2664
  }
522
2665
  else {
2666
+ // Route no longer matches (e.g., /user/1 → /settings)
523
2667
  await runCommit(enteringViewItem.ionPageElement, undefined);
524
2668
  }
525
2669
  }
526
2670
  else {
527
- await runCommit(enteringViewItem.ionPageElement, leavingViewItem === null || leavingViewItem === void 0 ? void 0 : leavingViewItem.ionPageElement);
528
- if (leavingViewItem && leavingViewItem.ionPageElement && !progressAnimation) {
529
- leavingViewItem.ionPageElement.classList.add('ion-page-hidden');
530
- leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true');
2671
+ const leavingEl = leavingViewItem?.ionPageElement;
2672
+ // For non-animated transitions, don't pass leaving element to commit() to avoid
2673
+ // flicker caused by commit() briefly unhiding the leaving page
2674
+ const isNonAnimatedTransition = directionToUse === undefined && !progressAnimation;
2675
+ if (isNonAnimatedTransition && leavingEl) {
2676
+ /**
2677
+ * Skip commit() for non-animated transitions (like tab switches).
2678
+ * commit() runs animation logic that can cause intermediate paints
2679
+ * even with duration: 0. Instead, swap visibility synchronously.
2680
+ *
2681
+ * Synchronous DOM class changes are batched into a single browser
2682
+ * paint, so there's no gap frame where neither page is visible and
2683
+ * no overlap frame where both pages are visible.
2684
+ */
2685
+ const enteringEl = enteringViewItem.ionPageElement;
2686
+ // Ensure entering element has proper base classes
2687
+ enteringEl.classList.add('ion-page');
2688
+ // Clear ALL hidden state from entering element. showIonPageElement
2689
+ // removes visibility:hidden (from applySkipAnimationIfNeeded),
2690
+ // ion-page-hidden, and aria-hidden in one call.
2691
+ showIonPageElement(enteringEl);
2692
+ // Handle can-go-back class since we're skipping commit() which normally sets this
2693
+ if (routeInfo.pushedByRoute) {
2694
+ enteringEl.classList.add('can-go-back');
2695
+ }
2696
+ else {
2697
+ enteringEl.classList.remove('can-go-back');
2698
+ }
2699
+ /**
2700
+ * Wait for components to be ready. Menu buttons start hidden (menu-button-hidden)
2701
+ * and become visible after componentDidLoad. Wait for hydration and visibility.
2702
+ */
2703
+ const waitForComponentsReady = () => {
2704
+ return new Promise((resolve) => {
2705
+ const checkReady = () => {
2706
+ const ionicComponents = enteringEl.querySelectorAll('ion-header, ion-toolbar, ion-buttons, ion-menu-button, ion-title, ion-content');
2707
+ const allHydrated = Array.from(ionicComponents).every((el) => el.classList.contains('hydrated'));
2708
+ const menuButtons = enteringEl.querySelectorAll('ion-menu-button');
2709
+ const menuButtonsReady = Array.from(menuButtons).every((el) => !el.classList.contains('menu-button-hidden'));
2710
+ return allHydrated && menuButtonsReady;
2711
+ };
2712
+ if (checkReady()) {
2713
+ resolve();
2714
+ return;
2715
+ }
2716
+ let resolved = false;
2717
+ const observer = new MutationObserver(() => {
2718
+ if (!resolved && checkReady()) {
2719
+ resolved = true;
2720
+ observer.disconnect();
2721
+ if (this.transitionObserver === observer) {
2722
+ this.transitionObserver = undefined;
2723
+ }
2724
+ resolve();
2725
+ }
2726
+ });
2727
+ // Disconnect any previous observer before tracking the new one
2728
+ if (this.transitionObserver) {
2729
+ this.transitionObserver.disconnect();
2730
+ }
2731
+ this.transitionObserver = observer;
2732
+ observer.observe(enteringEl, {
2733
+ subtree: true,
2734
+ attributes: true,
2735
+ attributeFilter: ['class'],
2736
+ });
2737
+ setTimeout(() => {
2738
+ if (!resolved) {
2739
+ resolved = true;
2740
+ observer.disconnect();
2741
+ if (this.transitionObserver === observer) {
2742
+ this.transitionObserver = undefined;
2743
+ }
2744
+ resolve();
2745
+ }
2746
+ }, 100);
2747
+ });
2748
+ };
2749
+ await waitForComponentsReady();
2750
+ // Bail out if the component unmounted during waitForComponentsReady
2751
+ if (!this._isMounted)
2752
+ return;
2753
+ // Swap visibility synchronously - show entering, hide leaving
2754
+ // Skip hiding if a newer transition already made leavingEl the entering view
2755
+ enteringEl.classList.remove('ion-page-invisible');
2756
+ if (myGeneration === this.transitionGeneration || leavingEl !== this.transitionEnteringElement) {
2757
+ leavingEl.classList.add('ion-page-hidden');
2758
+ leavingEl.setAttribute('aria-hidden', 'true');
2759
+ }
2760
+ }
2761
+ else {
2762
+ await runCommit(enteringViewItem.ionPageElement, leavingEl);
2763
+ if (leavingEl && !progressAnimation) {
2764
+ // Skip hiding if a newer transition already made leavingEl the entering view
2765
+ // runCommit's generation check has already restored its visibility in that case
2766
+ if (myGeneration === this.transitionGeneration || leavingEl !== this.transitionEnteringElement) {
2767
+ leavingEl.classList.add('ion-page-hidden');
2768
+ leavingEl.setAttribute('aria-hidden', 'true');
2769
+ }
2770
+ }
531
2771
  }
532
2772
  }
533
2773
  }
@@ -535,206 +2775,495 @@ class StackManager extends React.PureComponent {
535
2775
  render() {
536
2776
  const { children } = this.props;
537
2777
  const ionRouterOutlet = React.Children.only(children);
2778
+ // Store reference for use in getParentPath() and handlePageTransition()
538
2779
  this.ionRouterOutlet = ionRouterOutlet;
539
- const components = this.context.getChildrenToRender(this.id, this.ionRouterOutlet, this.props.routeInfo, () => {
540
- this.forceUpdate();
541
- });
542
- return (React.createElement(StackContext.Provider, { value: this.stackContextValue }, React.cloneElement(ionRouterOutlet, {
543
- ref: (node) => {
544
- if (ionRouterOutlet.props.setRef) {
545
- ionRouterOutlet.props.setRef(node);
546
- }
547
- if (ionRouterOutlet.props.forwardedRef) {
548
- ionRouterOutlet.props.forwardedRef.current = node;
549
- }
550
- this.routerOutletElement = node;
551
- const { ref } = ionRouterOutlet;
552
- if (typeof ref === 'function') {
553
- ref(node);
554
- }
555
- },
556
- }, components)));
2780
+ return (React.createElement(UNSAFE_RouteContext.Consumer, null, (parentContext) => {
2781
+ // Derive the outlet's mount path from React Router's matched route context.
2782
+ // This eliminates the need for heuristic-based mount path discovery in
2783
+ // computeParentPath, since React Router already knows the matched base path.
2784
+ const parentMatches = parentContext?.matches;
2785
+ const parentPathnameBase = parentMatches && parentMatches.length > 0
2786
+ ? parentMatches[parentMatches.length - 1].pathnameBase
2787
+ : undefined;
2788
+ // Derive isRootOutlet from RouteContext: empty matches means root.
2789
+ this.isRootOutlet = !parentMatches || parentMatches.length === 0;
2790
+ // Seed StackManager's mount path from the parent route context
2791
+ if (parentPathnameBase && !this.outletMountPath) {
2792
+ this.outletMountPath = parentPathnameBase;
2793
+ }
2794
+ const components = this.context.getChildrenToRender(this.id, this.ionRouterOutlet, this.props.routeInfo, () => {
2795
+ // Callback triggers re-render when view items are modified during getChildrenToRender
2796
+ this.forceUpdate();
2797
+ }, parentPathnameBase);
2798
+ return (React.createElement(StackContext.Provider, { value: this.stackContextValue }, React.cloneElement(ionRouterOutlet, {
2799
+ ref: (node) => {
2800
+ if (ionRouterOutlet.props.setRef) {
2801
+ // Needed to handle external refs from devs.
2802
+ ionRouterOutlet.props.setRef(node);
2803
+ }
2804
+ if (ionRouterOutlet.props.forwardedRef) {
2805
+ // Needed to handle external refs from devs.
2806
+ ionRouterOutlet.props.forwardedRef.current = node;
2807
+ }
2808
+ this.routerOutletElement = node;
2809
+ const { ref } = ionRouterOutlet;
2810
+ // Check for legacy refs.
2811
+ if (typeof ref === 'function') {
2812
+ ref(node);
2813
+ }
2814
+ },
2815
+ }, components)));
2816
+ }));
557
2817
  }
558
2818
  static get contextType() {
559
2819
  return RouteManagerContext;
560
2820
  }
561
2821
  }
562
- function matchRoute(node, routeInfo) {
563
- let matchedNode;
564
- React.Children.forEach(node, (child) => {
565
- const match = matchPath({
566
- pathname: routeInfo.pathname,
567
- componentProps: child.props,
568
- });
569
- if (match) {
570
- matchedNode = child;
2822
+ /**
2823
+ * Converts React Route elements to RouteObject format for use with matchRoutes().
2824
+ * Filters out pathless routes (which are handled by fallback logic separately).
2825
+ *
2826
+ * When a basename is provided, absolute route paths are relativized by stripping
2827
+ * the basename prefix. This is necessary because matchRoutes() strips the basename
2828
+ * from the LOCATION pathname but not from route paths — absolute paths must be
2829
+ * made relative to the basename for matching to work correctly.
2830
+ *
2831
+ * @param routeChildren The flat array of Route/IonRoute elements from the outlet.
2832
+ * @param basename The resolved parent path (without trailing slash or `/*`) used to relativize absolute paths.
2833
+ */
2834
+ function routeElementsToRouteObjects(routeChildren, basename) {
2835
+ return routeChildren
2836
+ .filter((child) => child.props.path != null || child.props.index)
2837
+ .map((child) => {
2838
+ const handle = { _element: child };
2839
+ let path = child.props.path;
2840
+ // Relativize absolute paths by stripping the basename prefix
2841
+ if (path && path.startsWith('/') && basename) {
2842
+ if (path === basename) {
2843
+ path = '';
2844
+ }
2845
+ else if (path.startsWith(basename + '/')) {
2846
+ path = path.slice(basename.length + 1);
2847
+ }
2848
+ }
2849
+ if (child.props.index) {
2850
+ return {
2851
+ index: true,
2852
+ handle,
2853
+ caseSensitive: child.props.caseSensitive || undefined,
2854
+ };
571
2855
  }
2856
+ return {
2857
+ path,
2858
+ handle,
2859
+ caseSensitive: child.props.caseSensitive || undefined,
2860
+ };
572
2861
  });
573
- if (matchedNode) {
574
- return matchedNode;
2862
+ }
2863
+ /**
2864
+ * Finds the `<Route />` node matching the current route info.
2865
+ * If no `<Route />` can be matched, a fallback node is returned.
2866
+ * Routes are prioritized by specificity (most specific first).
2867
+ *
2868
+ * @param node The root node to search for `<Route />` nodes.
2869
+ * @param routeInfo The route information to match against.
2870
+ * @param parentPath The parent path that was matched by the parent outlet (for nested routing)
2871
+ */
2872
+ function findRouteByRouteInfo(node, routeInfo, parentPath) {
2873
+ let matchedNode;
2874
+ let fallbackNode;
2875
+ // `<Route />` nodes are rendered inside of a <Routes /> node
2876
+ const routesChildren = getRoutesChildren(node) ?? node;
2877
+ // Collect all route children
2878
+ const routeChildren = React.Children.toArray(routesChildren).filter((child) => React.isValidElement(child) && (child.type === Route || child.type === IonRoute));
2879
+ // Delegate route matching to RR6's matchRoutes(), which handles specificity ranking internally.
2880
+ const basename = parentPath ? stripTrailingSlash(parentPath.replace('/*', '')) : undefined;
2881
+ const routeObjects = routeElementsToRouteObjects(routeChildren, basename);
2882
+ const matches = matchRoutes(routeObjects, { pathname: routeInfo.pathname }, basename);
2883
+ if (matches && matches.length > 0) {
2884
+ const bestMatch = matches[matches.length - 1];
2885
+ matchedNode = bestMatch.route.handle?._element ?? undefined;
575
2886
  }
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;
2887
+ // Fallback: try pathless routes, but only if pathname is within scope.
2888
+ if (!matchedNode) {
2889
+ let pathnameInScope = true;
2890
+ if (parentPath) {
2891
+ pathnameInScope = isPathnameInScope(routeInfo.pathname, parentPath);
581
2892
  }
582
- });
583
- return matchedNode;
2893
+ else {
2894
+ const absolutePathRoutes = routeChildren.filter((r) => r.props.path && r.props.path.startsWith('/'));
2895
+ if (absolutePathRoutes.length > 0) {
2896
+ const absolutePaths = absolutePathRoutes.map((r) => r.props.path);
2897
+ const commonPrefix = computeCommonPrefix(absolutePaths);
2898
+ if (commonPrefix && commonPrefix !== '/') {
2899
+ pathnameInScope = routeInfo.pathname.startsWith(commonPrefix);
2900
+ }
2901
+ }
2902
+ }
2903
+ if (pathnameInScope) {
2904
+ for (const child of routeChildren) {
2905
+ if (!child.props.path) {
2906
+ fallbackNode = child;
2907
+ break;
2908
+ }
2909
+ }
2910
+ }
2911
+ }
2912
+ return matchedNode ?? fallbackNode;
584
2913
  }
585
- function matchComponent(node, pathname, forceExact) {
2914
+ function matchComponent(node, pathname, forceExact, parentPath) {
2915
+ const routePath = node?.props?.path;
2916
+ let pathnameToMatch;
2917
+ if (parentPath && routePath && !routePath.startsWith('/')) {
2918
+ // When parent path is known, compute exact relative pathname
2919
+ const relative = pathname.startsWith(parentPath) ? pathname.slice(parentPath.length).replace(/^\//, '') : pathname;
2920
+ pathnameToMatch = relative;
2921
+ }
2922
+ else {
2923
+ pathnameToMatch = derivePathnameToMatch(pathname, routePath);
2924
+ }
586
2925
  return matchPath({
587
- pathname,
588
- componentProps: Object.assign(Object.assign({}, node.props), { exact: forceExact }),
2926
+ pathname: pathnameToMatch,
2927
+ componentProps: {
2928
+ ...node.props,
2929
+ end: forceExact,
2930
+ },
589
2931
  });
590
2932
  }
591
2933
 
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
- };
626
- }
627
- handleChangeTab(tab, path, routeOptions) {
628
- if (!path) {
629
- return;
2934
+ /**
2935
+ * `IonRouter` is responsible for managing the application's navigation
2936
+ * state, tracking the history of visited routes, and coordinating
2937
+ * transitions between different views. It intercepts route changes from
2938
+ * React Router and translates them into actions that Ionic can understand
2939
+ * and animate.
2940
+ */
2941
+ const filterUndefinedParams = (params) => {
2942
+ const result = {};
2943
+ for (const key of Object.keys(params)) {
2944
+ const value = params[key];
2945
+ if (value !== undefined) {
2946
+ result[key] = value;
630
2947
  }
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
- /**
641
- * The recorded search has to match the URL we push, otherwise
642
- * handleHistoryChange sees a URL change where there is none.
643
- */
644
- const normalizedSearch = search ? '?' + search : '';
645
- this.incomingRouteParams.pathname = pathname;
646
- this.incomingRouteParams.search = normalizedSearch;
647
- this.incomingRouteParams.routeOptions = routeOptions;
648
- this.props.history.push(pathname + normalizedSearch);
2948
+ }
2949
+ return result;
2950
+ };
2951
+ /**
2952
+ * Checks if a POP event is a multi-step back navigation (navigate(-n) where n > 1).
2953
+ * Walks the pushedByRoute chain from prevInfo to verify the destination is an ancestor
2954
+ * in the same navigation chain. This distinguishes multi-step back from tab-crossing
2955
+ * back navigation where prevInfo.pathname also differs from the browser destination.
2956
+ */
2957
+ const checkIsMultiStepBack = (prevInfo, destinationPathname, history) => {
2958
+ if (!prevInfo || prevInfo.pathname === destinationPathname)
2959
+ return false;
2960
+ const visited = new Set();
2961
+ let walker = prevInfo;
2962
+ while (walker?.pushedByRoute) {
2963
+ if (visited.has(walker.id))
2964
+ break; // cycle guard
2965
+ visited.add(walker.id);
2966
+ if (walker.pushedByRoute === destinationPathname)
2967
+ return true;
2968
+ walker = history.findLastLocation(walker);
2969
+ }
2970
+ return false;
2971
+ };
2972
+ const areParamsEqual = (a, b) => {
2973
+ const paramsA = a || {};
2974
+ const paramsB = b || {};
2975
+ const keysA = Object.keys(paramsA);
2976
+ const keysB = Object.keys(paramsB);
2977
+ if (keysA.length !== keysB.length) {
2978
+ return false;
2979
+ }
2980
+ return keysA.every((key) => {
2981
+ const valueA = paramsA[key];
2982
+ const valueB = paramsB[key];
2983
+ if (Array.isArray(valueA) && Array.isArray(valueB)) {
2984
+ if (valueA.length !== valueB.length) {
2985
+ return false;
649
2986
  }
2987
+ return valueA.every((entry, idx) => entry === valueB[idx]);
650
2988
  }
651
- else {
652
- this.handleNavigate(pathname, 'push', 'none', undefined, routeOptions, tab);
2989
+ return valueA === valueB;
2990
+ });
2991
+ };
2992
+ const IonRouter = ({ children, registerHistoryListener }) => {
2993
+ const location = useLocation();
2994
+ const navigate = useNavigate();
2995
+ const didMountRef = useRef(false);
2996
+ const locationHistory = useRef(new LocationHistory());
2997
+ const currentTab = useRef(undefined);
2998
+ const viewStack = useRef(new ReactRouterViewStack());
2999
+ const incomingRouteParams = useRef(null);
3000
+ /**
3001
+ * Tracks location keys that the user navigated away from via browser back.
3002
+ * When a POP event's destination key matches the top of this stack, it's a
3003
+ * browser forward navigation. Uses React Router's unique location.key
3004
+ * instead of URLs to correctly handle duplicate URLs in history (e.g.,
3005
+ * navigating to /details, then /settings, then /details via routerLink,
3006
+ * then pressing back).
3007
+ * Cleared on PUSH (new navigation invalidates forward history).
3008
+ */
3009
+ const forwardStack = useRef([]);
3010
+ /**
3011
+ * Tracks the current location key so we can push it onto the forward stack
3012
+ * when navigating back. Updated after each history change.
3013
+ */
3014
+ const currentLocationKeyRef = useRef(location.key);
3015
+ const [routeInfo, setRouteInfo] = useState({
3016
+ id: generateId('routeInfo'),
3017
+ pathname: location.pathname,
3018
+ search: location.search,
3019
+ params: {},
3020
+ });
3021
+ useEffect(() => {
3022
+ if (didMountRef.current) {
3023
+ return;
653
3024
  }
654
- }
655
- handleHistoryChange(location, action) {
656
- var _a, _b, _c;
657
- let leavingLocationInfo;
658
- if (this.incomingRouteParams) {
659
- if (this.incomingRouteParams.routeAction === 'replace') {
660
- leavingLocationInfo = this.locationHistory.previous();
661
- }
662
- else {
663
- leavingLocationInfo = this.locationHistory.current();
3025
+ // Seed the history stack with the initial location and begin listening
3026
+ // for future navigations once React has committed the mount. This avoids
3027
+ // duplicate entries when React StrictMode runs an extra render pre-commit.
3028
+ locationHistory.current.add(routeInfo);
3029
+ // If IonTabBar already called handleSetCurrentTab during render (before this
3030
+ // effect), the tab was stored in currentTab.current but the history entry was
3031
+ // not yet seeded. Apply the pending tab to the seed entry now.
3032
+ if (currentTab.current) {
3033
+ const ri = { ...locationHistory.current.current() };
3034
+ if (ri.tab !== currentTab.current) {
3035
+ ri.tab = currentTab.current;
3036
+ locationHistory.current.update(ri);
664
3037
  }
665
3038
  }
666
- else {
667
- leavingLocationInfo = this.locationHistory.current();
3039
+ registerHistoryListener(handleHistoryChange);
3040
+ didMountRef.current = true;
3041
+ }, []);
3042
+ // Sync route params extracted by React Router's path matching back into routeInfo.
3043
+ // The view stack's match may contain params (e.g., :id) not present in the initial routeInfo.
3044
+ useEffect(() => {
3045
+ const activeView = viewStack.current.findViewItemByRouteInfo(routeInfo, undefined, true);
3046
+ const matchedParams = activeView?.routeData.match?.params;
3047
+ if (matchedParams) {
3048
+ const paramsCopy = filterUndefinedParams({ ...matchedParams });
3049
+ if (areParamsEqual(routeInfo.params, paramsCopy)) {
3050
+ return;
3051
+ }
3052
+ const updatedRouteInfo = {
3053
+ ...routeInfo,
3054
+ params: paramsCopy,
3055
+ };
3056
+ locationHistory.current.update(updatedRouteInfo);
3057
+ setRouteInfo(updatedRouteInfo);
668
3058
  }
3059
+ }, [routeInfo]);
3060
+ /**
3061
+ * Triggered whenever the history changes, either through user navigation
3062
+ * or programmatic changes. It transforms the raw browser history changes
3063
+ * into `RouteInfo` objects, which are needed Ionic's animations and
3064
+ * navigation patterns.
3065
+ *
3066
+ * @param location The current location object from the history.
3067
+ * @param action The action that triggered the history change.
3068
+ */
3069
+ const handleHistoryChange = (location, action) => {
3070
+ /**
3071
+ * The leaving location is always the current route, for both programmatic
3072
+ * and external navigations. Using `previous()` for replace actions was
3073
+ * incorrect: it caused the equality check below to skip navigation when
3074
+ * the replace destination matched the entry two slots back in history.
3075
+ */
3076
+ const leavingLocationInfo = locationHistory.current.current();
669
3077
  const leavingUrl = leavingLocationInfo.pathname + leavingLocationInfo.search;
670
- const currentUrl = location.pathname + (location.search || '');
671
- if (leavingUrl !== currentUrl) {
672
- if (!this.incomingRouteParams) {
3078
+ if (leavingUrl !== location.pathname + location.search) {
3079
+ if (!incomingRouteParams.current) {
3080
+ // Use history-based tab detection instead of URL-pattern heuristics,
3081
+ // so tab routes work with any URL structure (not just paths containing "/tabs").
3082
+ // Fall back to currentTab.current only when the destination is within the
3083
+ // current tab's path hierarchy (prevents non-tab routes from inheriting a tab).
3084
+ let tabToUse = locationHistory.current.findTabForPathname(location.pathname);
3085
+ if (!tabToUse && currentTab.current) {
3086
+ const tabFirstRoute = locationHistory.current.getFirstRouteInfoForTab(currentTab.current);
3087
+ const tabRootPath = tabFirstRoute?.pathname;
3088
+ if (tabRootPath && (location.pathname === tabRootPath || location.pathname.startsWith(tabRootPath + '/'))) {
3089
+ tabToUse = currentTab.current;
3090
+ }
3091
+ }
3092
+ /**
3093
+ * A `REPLACE` action can be triggered by React Router's
3094
+ * `<Navigate />` component.
3095
+ */
673
3096
  if (action === 'REPLACE') {
674
- this.incomingRouteParams = {
3097
+ incomingRouteParams.current = {
675
3098
  routeAction: 'replace',
676
3099
  routeDirection: 'none',
677
- tab: this.currentTab,
3100
+ tab: tabToUse,
678
3101
  };
679
3102
  }
3103
+ /**
3104
+ * A `POP` action can be triggered by the browser's back/forward
3105
+ * button. Both fire as POP events, so we use a forward stack to
3106
+ * distinguish them: when going back, we push the leaving pathname
3107
+ * onto the stack. When the next POP's destination matches the top
3108
+ * of the stack, it's a forward navigation.
3109
+ */
680
3110
  if (action === 'POP') {
681
- const currentRoute = this.locationHistory.current();
682
- if (currentRoute && currentRoute.pushedByRoute) {
683
- const prevInfo = this.locationHistory.findLastLocation(currentRoute);
684
- this.incomingRouteParams = Object.assign(Object.assign({}, prevInfo), { routeAction: 'pop', routeDirection: 'back' });
3111
+ const currentRoute = locationHistory.current.current();
3112
+ const isForwardNavigation = forwardStack.current.length > 0 && forwardStack.current[forwardStack.current.length - 1] === location.key;
3113
+ if (isForwardNavigation) {
3114
+ forwardStack.current.pop();
3115
+ incomingRouteParams.current = {
3116
+ routeAction: 'push',
3117
+ routeDirection: 'forward',
3118
+ tab: tabToUse,
3119
+ };
3120
+ }
3121
+ else if (currentRoute && currentRoute.pushedByRoute) {
3122
+ // Back navigation. Record current location key for potential forward
3123
+ forwardStack.current.push(currentLocationKeyRef.current);
3124
+ const prevInfo = locationHistory.current.findLastLocation(currentRoute);
3125
+ const isMultiStepBack = checkIsMultiStepBack(prevInfo, location.pathname, locationHistory.current);
3126
+ if (isMultiStepBack) {
3127
+ const destinationInfo = locationHistory.current.findLastLocationByPathname(location.pathname);
3128
+ incomingRouteParams.current = {
3129
+ ...(destinationInfo || {}),
3130
+ routeAction: 'pop',
3131
+ routeDirection: 'back',
3132
+ };
3133
+ }
3134
+ else if (prevInfo && prevInfo.pathname !== location.pathname && currentRoute.tab) {
3135
+ // Browser POP destination differs from within-tab back target.
3136
+ // Sync URL via replace, like handleNavigateBack's non-linear path (#25141).
3137
+ incomingRouteParams.current = { ...prevInfo, routeAction: 'pop', routeDirection: 'back' };
3138
+ forwardStack.current = [];
3139
+ handleNavigate(prevInfo.pathname + (prevInfo.search || ''), 'pop', 'back', undefined, undefined, prevInfo.tab);
3140
+ return;
3141
+ }
3142
+ else {
3143
+ incomingRouteParams.current = { ...prevInfo, routeAction: 'pop', routeDirection: 'back' };
3144
+ }
685
3145
  }
686
3146
  else {
687
- this.incomingRouteParams = {
3147
+ // It's a non-linear history path like a direct link.
3148
+ // Still push the current location key so browser forward is detectable.
3149
+ forwardStack.current.push(currentLocationKeyRef.current);
3150
+ incomingRouteParams.current = {
688
3151
  routeAction: 'pop',
689
3152
  routeDirection: 'none',
690
- tab: this.currentTab,
3153
+ tab: tabToUse,
691
3154
  };
692
3155
  }
693
3156
  }
694
- if (!this.incomingRouteParams) {
695
- this.incomingRouteParams = {
3157
+ if (!incomingRouteParams.current) {
3158
+ const state = location.state;
3159
+ incomingRouteParams.current = {
696
3160
  routeAction: 'push',
697
- routeDirection: ((_a = location.state) === null || _a === void 0 ? void 0 : _a.direction) || 'forward',
698
- routeOptions: (_b = location.state) === null || _b === void 0 ? void 0 : _b.routerOptions,
699
- tab: this.currentTab,
3161
+ routeDirection: state?.direction || 'forward',
3162
+ routeOptions: state?.routerOptions,
3163
+ tab: tabToUse,
700
3164
  };
701
3165
  }
702
3166
  }
3167
+ // New navigation (PUSH) invalidates browser forward history,
3168
+ // so clear our forward stack to stay in sync.
3169
+ if (action === 'PUSH') {
3170
+ forwardStack.current = [];
3171
+ }
703
3172
  let routeInfo;
704
- if ((_c = this.incomingRouteParams) === null || _c === void 0 ? void 0 : _c.id) {
705
- routeInfo = Object.assign(Object.assign({}, this.incomingRouteParams), { lastPathname: leavingLocationInfo.pathname });
706
- this.locationHistory.add(routeInfo);
3173
+ // If we're navigating away from tabs to a non-tab route, clear the current tab
3174
+ if (!locationHistory.current.findTabForPathname(location.pathname) && currentTab.current) {
3175
+ currentTab.current = undefined;
3176
+ }
3177
+ /**
3178
+ * An existing id indicates that it's re-activating an existing route.
3179
+ * e.g., tab switching or navigating back to a previous route
3180
+ */
3181
+ if (incomingRouteParams.current?.id) {
3182
+ routeInfo = {
3183
+ ...incomingRouteParams.current,
3184
+ lastPathname: leavingLocationInfo.pathname,
3185
+ };
3186
+ locationHistory.current.add(routeInfo);
3187
+ /**
3188
+ * A new route is being created since it's not re-activating
3189
+ * an existing route.
3190
+ */
707
3191
  }
708
3192
  else {
709
- const isPushed = this.incomingRouteParams.routeAction === 'push' && this.incomingRouteParams.routeDirection === 'forward';
710
- 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 });
3193
+ const isPushed = incomingRouteParams.current?.routeAction === 'push' &&
3194
+ incomingRouteParams.current.routeDirection === 'forward';
3195
+ routeInfo = {
3196
+ id: generateId('routeInfo'),
3197
+ ...incomingRouteParams.current,
3198
+ lastPathname: leavingLocationInfo.pathname, // The URL we just came from
3199
+ pathname: location.pathname, // The current (destination) URL
3200
+ search: location.search,
3201
+ params: incomingRouteParams.current?.params
3202
+ ? filterUndefinedParams(incomingRouteParams.current.params)
3203
+ : {},
3204
+ prevRouteLastPathname: leavingLocationInfo.lastPathname,
3205
+ };
711
3206
  if (isPushed) {
712
- routeInfo.tab = leavingLocationInfo.tab;
3207
+ // Only inherit tab from leaving route if we don't already have one.
3208
+ // This preserves tab context for same-tab navigation while allowing cross-tab navigation.
3209
+ routeInfo.tab = routeInfo.tab || leavingLocationInfo.tab;
3210
+ routeInfo.pushedByRoute = leavingLocationInfo.pathname;
3211
+ }
3212
+ else if (routeInfo.routeAction === 'push' &&
3213
+ routeInfo.routeDirection === 'none' &&
3214
+ routeInfo.tab === leavingLocationInfo.tab) {
3215
+ // Push with routerDirection="none" within the same tab (or non-tab) context.
3216
+ // Still needs pushedByRoute so the back button can navigate back correctly.
3217
+ // Cross-tab navigations with direction "none" are handled by the tab-switching
3218
+ // block below which has different pushedByRoute semantics.
3219
+ routeInfo.tab = routeInfo.tab || leavingLocationInfo.tab;
713
3220
  routeInfo.pushedByRoute = leavingLocationInfo.pathname;
714
3221
  }
715
3222
  else if (routeInfo.routeAction === 'pop') {
716
- const r = this.locationHistory.findLastLocation(routeInfo);
717
- routeInfo.pushedByRoute = r === null || r === void 0 ? void 0 : r.pushedByRoute;
3223
+ // Triggered by a browser back button or handleNavigateBack.
3224
+ // Find the route that pushed this one.
3225
+ const r = locationHistory.current.findLastLocation(routeInfo);
3226
+ routeInfo.pushedByRoute = r?.pushedByRoute;
3227
+ // Navigating to a new tab.
718
3228
  }
719
3229
  else if (routeInfo.routeAction === 'push' && routeInfo.tab !== leavingLocationInfo.tab) {
720
- // If we are switching tabs grab the last route info for the tab and use its pushedByRoute
721
- const lastRoute = this.locationHistory.getCurrentRouteInfoForTab(routeInfo.tab);
722
- routeInfo.pushedByRoute = lastRoute === null || lastRoute === void 0 ? void 0 : lastRoute.pushedByRoute;
3230
+ /**
3231
+ * If we are switching tabs grab the last route info for the
3232
+ * tab and use its `pushedByRoute`.
3233
+ */
3234
+ const lastRoute = locationHistory.current.getCurrentRouteInfoForTab(routeInfo.tab);
3235
+ /**
3236
+ * Tab bar switches (direction 'none') should not create cross-tab back
3237
+ * navigation. Only inherit pushedByRoute from the tab's own history.
3238
+ */
3239
+ if (routeInfo.routeDirection === 'none') {
3240
+ routeInfo.pushedByRoute = lastRoute?.pushedByRoute;
3241
+ }
3242
+ else {
3243
+ routeInfo.pushedByRoute = lastRoute?.pushedByRoute ?? leavingLocationInfo.pathname;
3244
+ }
3245
+ // Triggered by `navigate()` with replace or a `<Navigate />` component, etc.
723
3246
  }
724
3247
  else if (routeInfo.routeAction === 'replace') {
725
- // Make sure to set the lastPathname, etc.. to the current route so the page transitions out
726
- const currentRouteInfo = this.locationHistory.current();
727
3248
  /**
728
- * If going from /home to /child, then replacing from
729
- * /child to /home, we don't want the route info to
730
- * say that /home was pushed by /home which is not correct.
3249
+ * Make sure to set the `lastPathname`, etc.. to the current route
3250
+ * so the page transitions out.
3251
+ */
3252
+ const currentRouteInfo = locationHistory.current.current();
3253
+ /**
3254
+ * Special handling for `replace` to ensure correct `pushedByRoute`
3255
+ * and `lastPathname`.
3256
+ *
3257
+ * If going from `/home` to `/child`, then replacing from
3258
+ * `/child` to `/home`, we don't want the route info to
3259
+ * say that `/home` was pushed by `/home` which is not correct.
731
3260
  */
732
- const currentPushedBy = currentRouteInfo === null || currentRouteInfo === void 0 ? void 0 : currentRouteInfo.pushedByRoute;
3261
+ const currentPushedBy = currentRouteInfo?.pushedByRoute;
733
3262
  const pushedByRoute = currentPushedBy !== undefined && currentPushedBy !== routeInfo.pathname
734
3263
  ? currentPushedBy
735
3264
  : routeInfo.pushedByRoute;
736
- routeInfo.lastPathname = (currentRouteInfo === null || currentRouteInfo === void 0 ? void 0 : currentRouteInfo.pathname) || routeInfo.lastPathname;
737
- routeInfo.prevRouteLastPathname = currentRouteInfo === null || currentRouteInfo === void 0 ? void 0 : currentRouteInfo.lastPathname;
3265
+ routeInfo.lastPathname = currentRouteInfo?.pathname || routeInfo.lastPathname;
3266
+ routeInfo.prevRouteLastPathname = currentRouteInfo?.lastPathname;
738
3267
  routeInfo.pushedByRoute = pushedByRoute;
739
3268
  /**
740
3269
  * When replacing routes we should still prefer
@@ -742,49 +3271,137 @@ class IonRouterInner extends React.PureComponent {
742
3271
  * has specified when navigating first instead of relying
743
3272
  * on previously used directions/animations.
744
3273
  */
745
- routeInfo.routeDirection = routeInfo.routeDirection || (currentRouteInfo === null || currentRouteInfo === void 0 ? void 0 : currentRouteInfo.routeDirection);
746
- routeInfo.routeAnimation = routeInfo.routeAnimation || (currentRouteInfo === null || currentRouteInfo === void 0 ? void 0 : currentRouteInfo.routeAnimation);
3274
+ routeInfo.routeDirection = routeInfo.routeDirection || currentRouteInfo?.routeDirection;
3275
+ routeInfo.routeAnimation = routeInfo.routeAnimation || currentRouteInfo?.routeAnimation;
747
3276
  }
748
- this.locationHistory.add(routeInfo);
3277
+ locationHistory.current.add(routeInfo);
749
3278
  }
750
- this.setState({
751
- routeInfo,
752
- });
3279
+ setRouteInfo(routeInfo);
753
3280
  }
754
- this.incomingRouteParams = undefined;
755
- }
3281
+ // Update the current location key after processing the history change.
3282
+ // This ensures the forward stack records the correct key when navigating back.
3283
+ currentLocationKeyRef.current = location.key;
3284
+ incomingRouteParams.current = null;
3285
+ };
756
3286
  /**
757
- * history@4.x uses goBack(), history@5.x uses back()
758
- * TODO: If support for React Router <=5 is dropped
759
- * this logic is no longer needed. We can just
760
- * assume back() is available.
3287
+ * Resets the specified tab to its initial, root route.
3288
+ *
3289
+ * @param tab The tab to reset.
3290
+ * @param originalHref The original href for the tab.
3291
+ * @param originalRouteOptions The original route options for the tab.
761
3292
  */
762
- handleNativeBack() {
763
- const history = this.props.history;
764
- const goBack = history.goBack || history.back;
765
- goBack();
766
- }
767
- handleNavigate(path, routeAction, routeDirection, routeAnimation, routeOptions, tab) {
768
- this.incomingRouteParams = Object.assign(this.incomingRouteParams || {}, {
769
- routeAction,
770
- routeDirection,
771
- routeOptions,
772
- routeAnimation,
773
- tab,
774
- });
775
- if (routeAction === 'push') {
776
- this.props.history.push(path);
3293
+ const handleResetTab = (tab, originalHref, originalRouteOptions) => {
3294
+ const routeInfo = locationHistory.current.getFirstRouteInfoForTab(tab);
3295
+ if (routeInfo) {
3296
+ const [pathname, search] = originalHref.split('?');
3297
+ const newRouteInfo = { ...routeInfo };
3298
+ newRouteInfo.pathname = pathname;
3299
+ newRouteInfo.search = search ? '?' + search : '';
3300
+ newRouteInfo.routeOptions = originalRouteOptions;
3301
+ incomingRouteParams.current = { ...newRouteInfo, routeAction: 'pop', routeDirection: 'back' };
3302
+ navigate(newRouteInfo.pathname + (newRouteInfo.search || ''));
3303
+ }
3304
+ };
3305
+ /**
3306
+ * Handles tab changes.
3307
+ *
3308
+ * @param tab The tab to switch to.
3309
+ * @param path The new path for the tab.
3310
+ * @param routeOptions Additional route options.
3311
+ */
3312
+ const handleChangeTab = (tab, path, routeOptions) => {
3313
+ if (!path) {
3314
+ return;
3315
+ }
3316
+ const routeInfo = locationHistory.current.getCurrentRouteInfoForTab(tab);
3317
+ const [pathname, search] = path.split('?');
3318
+ // User has navigated to the current tab before.
3319
+ if (routeInfo) {
3320
+ const routeParams = {
3321
+ ...routeInfo,
3322
+ routeAction: 'push',
3323
+ routeDirection: 'none',
3324
+ };
3325
+ /**
3326
+ * User is navigating to the same tab.
3327
+ * e.g., `/tabs/home` → `/tabs/home`
3328
+ */
3329
+ if (routeInfo.pathname === pathname) {
3330
+ const newSearch = search ? '?' + search : routeInfo.search;
3331
+ incomingRouteParams.current = {
3332
+ ...routeParams,
3333
+ search: newSearch || '',
3334
+ routeOptions,
3335
+ };
3336
+ navigate(routeInfo.pathname + (newSearch || ''));
3337
+ /**
3338
+ * User is navigating to a different tab.
3339
+ * e.g., `/tabs/home` → `/tabs/settings`
3340
+ */
3341
+ }
3342
+ else {
3343
+ incomingRouteParams.current = {
3344
+ ...routeParams,
3345
+ pathname,
3346
+ search: search ? '?' + search : '',
3347
+ routeOptions,
3348
+ };
3349
+ navigate(pathname + (search ? '?' + search : ''));
3350
+ }
3351
+ // User has not navigated to this tab before.
777
3352
  }
778
3353
  else {
779
- this.props.history.replace(path);
3354
+ const fullPath = pathname + (search ? '?' + search : '');
3355
+ handleNavigate(fullPath, 'push', 'none', undefined, routeOptions, tab);
780
3356
  }
781
- }
782
- handleNavigateBack(defaultHref = '/', routeAnimation) {
3357
+ };
3358
+ /**
3359
+ * Set the current active tab in `locationHistory`.
3360
+ * This is crucial for maintaining tab history since each tab has
3361
+ * its own navigation stack.
3362
+ *
3363
+ * @param tab The tab to set as active.
3364
+ */
3365
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
3366
+ const handleSetCurrentTab = (tab, _routeInfo) => {
3367
+ currentTab.current = tab;
3368
+ const current = locationHistory.current.current();
3369
+ if (!current) {
3370
+ // locationHistory not yet seeded (e.g., called during initial render
3371
+ // before mount effect). The mount effect will seed the correct entry.
3372
+ return;
3373
+ }
3374
+ const ri = { ...current };
3375
+ if (ri.tab !== tab) {
3376
+ ri.tab = tab;
3377
+ locationHistory.current.update(ri);
3378
+ }
3379
+ };
3380
+ /**
3381
+ * Handles the native back button press.
3382
+ * It's usually called when a user presses the platform-native back action.
3383
+ */
3384
+ const handleNativeBack = () => {
3385
+ navigate(-1);
3386
+ };
3387
+ /**
3388
+ * Used to manage the back navigation within the Ionic React's routing
3389
+ * system. It's deeply integrated with Ionic's view lifecycle, animations,
3390
+ * and its custom history tracking (`locationHistory`) to provide a
3391
+ * native-like transition and maintain correct application state.
3392
+ *
3393
+ * @param defaultHref The fallback URL to navigate to if there's no
3394
+ * previous entry in the `locationHistory` stack.
3395
+ * @param routeAnimation A custom animation builder to override the
3396
+ * default "back" animation.
3397
+ */
3398
+ const handleNavigateBack = (defaultHref, routeAnimation) => {
783
3399
  const config = getConfig();
784
- defaultHref = defaultHref ? defaultHref : config && config.get('backButtonDefaultHref');
785
- const routeInfo = this.locationHistory.current();
3400
+ defaultHref = defaultHref ?? (config && config.get('backButtonDefaultHref'));
3401
+ const routeInfo = locationHistory.current.current();
3402
+ // It's a linear navigation.
786
3403
  if (routeInfo && routeInfo.pushedByRoute) {
787
- const prevInfo = this.locationHistory.findLastLocation(routeInfo);
3404
+ const prevInfo = locationHistory.current.findLastLocation(routeInfo);
788
3405
  if (prevInfo) {
789
3406
  /**
790
3407
  * This needs to be passed to handleNavigate
@@ -792,160 +3409,292 @@ class IonRouterInner extends React.PureComponent {
792
3409
  * will be overridden.
793
3410
  */
794
3411
  const incomingAnimation = routeAnimation || routeInfo.routeAnimation;
795
- this.incomingRouteParams = Object.assign(Object.assign({}, prevInfo), { routeAction: 'pop', routeDirection: 'back', routeAnimation: incomingAnimation });
796
- if (routeInfo.lastPathname === routeInfo.pushedByRoute ||
797
- /**
798
- * We need to exclude tab switches/tab
799
- * context changes here because tabbed
800
- * navigation is not linear, but router.back()
801
- * will go back in a linear fashion.
802
- */
803
- (prevInfo.pathname === routeInfo.pushedByRoute && routeInfo.tab === '' && prevInfo.tab === '')) {
804
- /**
805
- * history@4.x uses goBack(), history@5.x uses back()
806
- * TODO: If support for React Router <=5 is dropped
807
- * this logic is no longer needed. We can just
808
- * assume back() is available.
809
- */
810
- const history = this.props.history;
811
- const goBack = history.goBack || history.back;
812
- goBack();
3412
+ incomingRouteParams.current = {
3413
+ ...prevInfo,
3414
+ routeAction: 'pop',
3415
+ routeDirection: 'back',
3416
+ routeAnimation: incomingAnimation,
3417
+ };
3418
+ /**
3419
+ * Check if it's a simple linear back navigation (not tabbed).
3420
+ * e.g., `/home` `/settings` back to `/home`
3421
+ */
3422
+ const condition1 = routeInfo.lastPathname === routeInfo.pushedByRoute;
3423
+ const condition2 = prevInfo.pathname === routeInfo.pushedByRoute && !routeInfo.tab && !prevInfo.tab;
3424
+ if (condition1 || condition2) {
3425
+ // Record the current location key so browser forward is detectable
3426
+ forwardStack.current.push(currentLocationKeyRef.current);
3427
+ navigate(-1);
813
3428
  }
814
3429
  else {
815
- this.handleNavigate(prevInfo.pathname + (prevInfo.search || ''), 'pop', 'back', incomingAnimation);
3430
+ /**
3431
+ * It's a non-linear back navigation.
3432
+ * e.g., direct link or tab switch or nested navigation with redirects
3433
+ * Clear forward stack since the REPLACE-based navigate resets history
3434
+ * position, making any prior forward entries unreachable.
3435
+ */
3436
+ forwardStack.current = [];
3437
+ handleNavigate(prevInfo.pathname + (prevInfo.search || ''), 'pop', 'back', incomingAnimation);
816
3438
  }
3439
+ /**
3440
+ * `pushedByRoute` exists, but no corresponding previous entry in
3441
+ * the history stack.
3442
+ */
817
3443
  }
818
- else {
819
- this.handleNavigate(defaultHref, 'pop', 'back', routeAnimation);
3444
+ else if (defaultHref) {
3445
+ handleNavigate(defaultHref, 'pop', 'back', routeAnimation);
820
3446
  }
3447
+ /**
3448
+ * No `pushedByRoute` (e.g., initial page load or tab root).
3449
+ * Navigate to defaultHref so the back button works on direct
3450
+ * deep-link loads (e.g., loading /tab1/child directly).
3451
+ * Only navigate when defaultHref is explicitly set. The core
3452
+ * back-button component hides itself when no defaultHref is
3453
+ * provided, so a click here means the user set one intentionally.
3454
+ */
821
3455
  }
822
- else {
823
- this.handleNavigate(defaultHref, 'pop', 'back', routeAnimation);
3456
+ else if (defaultHref) {
3457
+ handleNavigate(defaultHref, 'pop', 'back', routeAnimation);
824
3458
  }
825
- }
826
- handleResetTab(tab, originalHref, originalRouteOptions) {
827
- const routeInfo = this.locationHistory.getFirstRouteInfoForTab(tab);
828
- if (routeInfo) {
829
- const newRouteInfo = Object.assign({}, routeInfo);
830
- newRouteInfo.pathname = originalHref;
831
- newRouteInfo.routeOptions = originalRouteOptions;
832
- this.incomingRouteParams = Object.assign(Object.assign({}, newRouteInfo), { routeAction: 'pop', routeDirection: 'back' });
833
- this.props.history.push(newRouteInfo.pathname + (newRouteInfo.search || ''));
3459
+ };
3460
+ /**
3461
+ * Used to programmatically navigate through the app.
3462
+ *
3463
+ * @param path The path to navigate to.
3464
+ * @param routeAction The action to take (push, replace, etc.).
3465
+ * @param routeDirection The direction of the navigation (forward,
3466
+ * back, etc.).
3467
+ * @param routeAnimation The animation to use for the transition.
3468
+ * @param routeOptions Additional options for the route.
3469
+ * @param tab The tab to navigate to, if applicable.
3470
+ */
3471
+ const handleNavigate = (path, routeAction, routeDirection, routeAnimation, routeOptions, tab) => {
3472
+ const normalizedRouteDirection = routeAction === 'push' && routeDirection === undefined ? 'forward' : routeDirection;
3473
+ // When navigating from tabs context, we need to determine if the destination
3474
+ // is also within tabs. If not, we should clear the tab context.
3475
+ let navigationTab = tab;
3476
+ // If no explicit tab is provided and we're in a tab context,
3477
+ // check if the destination path is outside of the current tab context.
3478
+ // Uses history-based tab detection instead of URL pattern matching,
3479
+ // so it works with any tab URL structure.
3480
+ if (!tab && currentTab.current && path) {
3481
+ // Check if destination was previously visited in a tab context
3482
+ const destinationTab = locationHistory.current.findTabForPathname(path);
3483
+ if (destinationTab) {
3484
+ // Previously visited as a tab route - use the known tab
3485
+ navigationTab = destinationTab;
3486
+ }
3487
+ else {
3488
+ // New destination - check if it's a child of the current tab's root path
3489
+ const tabFirstRoute = locationHistory.current.getFirstRouteInfoForTab(currentTab.current);
3490
+ if (tabFirstRoute) {
3491
+ const tabRootPath = tabFirstRoute.pathname;
3492
+ if (path === tabRootPath || path.startsWith(tabRootPath + '/')) {
3493
+ // Still within the current tab's path hierarchy
3494
+ navigationTab = currentTab.current;
3495
+ }
3496
+ else {
3497
+ // Destination is outside the current tab context
3498
+ currentTab.current = undefined;
3499
+ navigationTab = undefined;
3500
+ }
3501
+ }
3502
+ }
834
3503
  }
835
- }
836
- handleSetCurrentTab(tab) {
837
- this.currentTab = tab;
838
- const ri = Object.assign({}, this.locationHistory.current());
839
- if (ri.tab !== tab) {
840
- ri.tab = tab;
841
- this.locationHistory.update(ri);
3504
+ // When a replace action targets the same URL as the immediately previous
3505
+ // history entry, using replaceState would create a duplicate browser history
3506
+ // entry (the previous and current entries would both have the same URL).
3507
+ // Navigate back to the previous entry instead to avoid the duplicate.
3508
+ // Keep routeAction as 'replace' so StackManager correctly unmounts the
3509
+ // leaving view through handleLeavingViewUnmount rather than treating it
3510
+ // as a browser-back pop (which preserves views for back/forward history).
3511
+ if (routeAction === 'replace') {
3512
+ const prevEntry = locationHistory.current.previous();
3513
+ const currentEntry = locationHistory.current.current();
3514
+ const prevPath = prevEntry ? prevEntry.pathname + (prevEntry.search || '') : undefined;
3515
+ if (prevEntry && currentEntry && prevEntry !== currentEntry && prevPath === path) {
3516
+ incomingRouteParams.current = {
3517
+ ...prevEntry,
3518
+ routeAction: 'replace',
3519
+ routeDirection: 'back',
3520
+ routeAnimation,
3521
+ };
3522
+ forwardStack.current.push(currentLocationKeyRef.current);
3523
+ navigate(-1);
3524
+ return;
3525
+ }
842
3526
  }
843
- }
844
- render() {
845
- return (React.createElement(RouteManagerContext.Provider, { value: this.routeMangerContextState },
846
- 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)));
847
- }
848
- }
849
- const IonRouter = withRouter(IonRouterInner);
3527
+ const baseParams = incomingRouteParams.current ?? {};
3528
+ incomingRouteParams.current = {
3529
+ ...baseParams,
3530
+ routeAction,
3531
+ routeDirection: normalizedRouteDirection,
3532
+ routeOptions,
3533
+ routeAnimation,
3534
+ tab: navigationTab,
3535
+ };
3536
+ navigate(path, { replace: routeAction !== 'push' });
3537
+ };
3538
+ /**
3539
+ * Navigates to a new root path, clearing Ionic's navigation history so that
3540
+ * canGoBack() returns false after the transition. All previously mounted views
3541
+ * are unmounted. Useful for post-login / post-logout root navigation.
3542
+ *
3543
+ * @param pathname The path to navigate to.
3544
+ * @param routeAnimation An optional custom animation builder.
3545
+ */
3546
+ const handleNavigateRoot = (pathname, routeAnimation) => {
3547
+ currentTab.current = undefined;
3548
+ forwardStack.current = [];
3549
+ incomingRouteParams.current = {
3550
+ routeAction: 'replace',
3551
+ routeDirection: 'root',
3552
+ routeAnimation,
3553
+ };
3554
+ navigate(pathname, { replace: true });
3555
+ };
3556
+ const routeMangerContextValue = {
3557
+ canGoBack: () => locationHistory.current.canGoBack(),
3558
+ clearOutlet: viewStack.current.clear,
3559
+ findViewItemByPathname: viewStack.current.findViewItemByPathname,
3560
+ getChildrenToRender: viewStack.current.getChildrenToRender,
3561
+ getViewItemsForOutlet: viewStack.current.getViewItemsForOutlet.bind(viewStack.current),
3562
+ goBack: () => handleNavigateBack(),
3563
+ createViewItem: viewStack.current.createViewItem,
3564
+ findViewItemByRouteInfo: viewStack.current.findViewItemByRouteInfo,
3565
+ findLeavingViewItemByRouteInfo: viewStack.current.findLeavingViewItemByRouteInfo,
3566
+ addViewItem: viewStack.current.add,
3567
+ unMountViewItem: viewStack.current.remove,
3568
+ };
3569
+ return (React.createElement(RouteManagerContext.Provider, { value: routeMangerContextValue },
3570
+ React.createElement(NavManager, { ionRoute: IonRouteInner, stackManager: StackManager, routeInfo: routeInfo, onNativeBack: handleNativeBack, onNavigateBack: handleNavigateBack, onNavigate: handleNavigate, onNavigateRoot: handleNavigateRoot, onSetCurrentTab: handleSetCurrentTab, onChangeTab: handleChangeTab, onResetTab: handleResetTab, locationHistory: locationHistory.current }, children)));
3571
+ };
850
3572
  IonRouter.displayName = 'IonRouter';
851
3573
 
852
- class IonReactRouter extends React.Component {
853
- constructor(props) {
854
- super(props);
855
- const { history } = props, rest = __rest(props, ["history"]);
856
- this.history = history || createBrowserHistory(rest);
857
- this.history.listen(this.handleHistoryChange.bind(this));
858
- this.registerHistoryListener = this.registerHistoryListener.bind(this);
859
- }
3574
+ /**
3575
+ * `IonReactRouter` facilitates the integration of Ionic's specific
3576
+ * navigation and UI management with the standard React Router mechanisms,
3577
+ * allowing an inner Ionic-specific router (`IonRouter`) to react to
3578
+ * navigation events.
3579
+ */
3580
+ /**
3581
+ * This component acts as a bridge to ensure React Router hooks like
3582
+ * `useLocation` and `useNavigationType` are called within the valid
3583
+ * context of a `<BrowserRouter>`.
3584
+ *
3585
+ * It was split from `IonReactRouter` because these hooks must be
3586
+ * descendants of a `<Router>` component, which `BrowserRouter` provides.
3587
+ */
3588
+ const RouterContent$2 = ({ children }) => {
3589
+ const location = useLocation();
3590
+ const navigationType = useNavigationType();
3591
+ const historyListenHandler = useRef();
3592
+ const registerHistoryListener = useCallback((cb) => {
3593
+ historyListenHandler.current = cb;
3594
+ }, []);
860
3595
  /**
861
- * history@4.x passes separate location and action
862
- * params. history@5.x passes location and action
863
- * together as a single object.
864
- * TODO: If support for React Router <=5 is dropped
865
- * this logic is no longer needed. We can just assume
866
- * a single object with both location and action.
3596
+ * Processes navigation changes within the application.
3597
+ *
3598
+ * Its purpose is to relay the current `location` and the associated
3599
+ * `action` ('PUSH', 'POP', or 'REPLACE') to any registered listeners,
3600
+ * primarily for `IonRouter` to manage Ionic-specific UI updates and
3601
+ * navigation stack behavior.
3602
+ *
3603
+ * @param loc The current browser history location object.
3604
+ * @param act The type of navigation action ('PUSH', 'POP', or
3605
+ * 'REPLACE').
867
3606
  */
868
- handleHistoryChange(location, action) {
869
- const locationValue = location.location || location;
870
- const actionValue = location.action || action;
871
- if (this.historyListenHandler) {
872
- this.historyListenHandler(locationValue, actionValue);
3607
+ const handleHistoryChange = useCallback((loc, act) => {
3608
+ if (historyListenHandler.current) {
3609
+ historyListenHandler.current(loc, act);
873
3610
  }
874
- }
875
- registerHistoryListener(cb) {
876
- this.historyListenHandler = cb;
877
- }
878
- render() {
879
- const _a = this.props, { children } = _a, props = __rest(_a, ["children"]);
880
- return (React.createElement(Router, Object.assign({ history: this.history }, props),
881
- React.createElement(IonRouter, { registerHistoryListener: this.registerHistoryListener }, children)));
882
- }
883
- }
3611
+ }, []);
3612
+ useEffect(() => {
3613
+ handleHistoryChange(location, navigationType);
3614
+ }, [location, navigationType, handleHistoryChange]);
3615
+ return React.createElement(IonRouter, { registerHistoryListener: registerHistoryListener }, children);
3616
+ };
3617
+ const IonReactRouter = ({ children, ...browserRouterProps }) => {
3618
+ return (React.createElement(BrowserRouter, { ...browserRouterProps },
3619
+ React.createElement(RouterContent$2, null, children)));
3620
+ };
884
3621
 
885
- class IonReactMemoryRouter extends React.Component {
886
- constructor(props) {
887
- super(props);
888
- this.history = props.history;
889
- this.history.listen(this.handleHistoryChange.bind(this));
890
- this.registerHistoryListener = this.registerHistoryListener.bind(this);
891
- }
3622
+ /**
3623
+ * `IonReactMemoryRouter` provides a way to use `react-router` in
3624
+ * environments where a traditional browser history (like `BrowserRouter`)
3625
+ * isn't available or desirable.
3626
+ */
3627
+ const RouterContent$1 = ({ children }) => {
3628
+ const location = useLocation$1();
3629
+ const navigationType = useNavigationType$1();
3630
+ const historyListenHandler = useRef();
3631
+ const registerHistoryListener = useCallback((cb) => {
3632
+ historyListenHandler.current = cb;
3633
+ }, []);
892
3634
  /**
893
- * history@4.x passes separate location and action
894
- * params. history@5.x passes location and action
895
- * together as a single object.
896
- * TODO: If support for React Router <=5 is dropped
897
- * this logic is no longer needed. We can just assume
898
- * a single object with both location and action.
3635
+ * Processes navigation changes within the application.
3636
+ *
3637
+ * Its purpose is to relay the current `location` and the associated
3638
+ * `action` ('PUSH', 'POP', or 'REPLACE') to any registered listeners,
3639
+ * primarily for `IonRouter` to manage Ionic-specific UI updates and
3640
+ * navigation stack behavior.
3641
+ *
3642
+ * @param location The current browser history location object.
3643
+ * @param action The type of navigation action ('PUSH', 'POP', or
3644
+ * 'REPLACE').
899
3645
  */
900
- handleHistoryChange(location, action) {
901
- const locationValue = location.location || location;
902
- const actionValue = location.action || action;
903
- if (this.historyListenHandler) {
904
- this.historyListenHandler(locationValue, actionValue);
3646
+ const handleHistoryChange = useCallback((loc, act) => {
3647
+ if (historyListenHandler.current) {
3648
+ historyListenHandler.current(loc, act);
905
3649
  }
906
- }
907
- registerHistoryListener(cb) {
908
- this.historyListenHandler = cb;
909
- }
910
- render() {
911
- const _a = this.props, { children } = _a, props = __rest(_a, ["children"]);
912
- return (React.createElement(Router$1, Object.assign({}, props),
913
- React.createElement(IonRouter, { registerHistoryListener: this.registerHistoryListener }, children)));
914
- }
915
- }
3650
+ }, []);
3651
+ useEffect(() => {
3652
+ handleHistoryChange(location, navigationType);
3653
+ }, [location, navigationType, handleHistoryChange]);
3654
+ return React.createElement(IonRouter, { registerHistoryListener: registerHistoryListener }, children);
3655
+ };
3656
+ const IonReactMemoryRouter = ({ children, ...routerProps }) => {
3657
+ return (React.createElement(MemoryRouter, { ...routerProps },
3658
+ React.createElement(RouterContent$1, null, children)));
3659
+ };
916
3660
 
917
- class IonReactHashRouter extends React.Component {
918
- constructor(props) {
919
- super(props);
920
- const { history } = props, rest = __rest(props, ["history"]);
921
- this.history = history || createHashHistory(rest);
922
- this.history.listen(this.handleHistoryChange.bind(this));
923
- this.registerHistoryListener = this.registerHistoryListener.bind(this);
924
- }
3661
+ /**
3662
+ * `IonReactHashRouter` provides a way to use hash-based routing in Ionic
3663
+ * React applications.
3664
+ */
3665
+ const RouterContent = ({ children }) => {
3666
+ const location = useLocation();
3667
+ const navigationType = useNavigationType();
3668
+ const historyListenHandler = useRef();
3669
+ const registerHistoryListener = useCallback((cb) => {
3670
+ historyListenHandler.current = cb;
3671
+ }, []);
925
3672
  /**
926
- * history@4.x passes separate location and action
927
- * params. history@5.x passes location and action
928
- * together as a single object.
929
- * TODO: If support for React Router <=5 is dropped
930
- * this logic is no longer needed. We can just assume
931
- * a single object with both location and action.
3673
+ * Processes navigation changes within the application.
3674
+ *
3675
+ * Its purpose is to relay the current `location` and the associated
3676
+ * `action` ('PUSH', 'POP', or 'REPLACE') to any registered listeners,
3677
+ * primarily for `IonRouter` to manage Ionic-specific UI updates and
3678
+ * navigation stack behavior.
3679
+ *
3680
+ * @param location The current browser history location object.
3681
+ * @param action The type of navigation action ('PUSH', 'POP', or
3682
+ * 'REPLACE').
932
3683
  */
933
- handleHistoryChange(location, action) {
934
- const locationValue = location.location || location;
935
- const actionValue = location.action || action;
936
- if (this.historyListenHandler) {
937
- this.historyListenHandler(locationValue, actionValue);
3684
+ const handleHistoryChange = useCallback((loc, act) => {
3685
+ if (historyListenHandler.current) {
3686
+ historyListenHandler.current(loc, act);
938
3687
  }
939
- }
940
- registerHistoryListener(cb) {
941
- this.historyListenHandler = cb;
942
- }
943
- render() {
944
- const _a = this.props, { children } = _a, props = __rest(_a, ["children"]);
945
- return (React.createElement(Router, Object.assign({ history: this.history }, props),
946
- React.createElement(IonRouter, { registerHistoryListener: this.registerHistoryListener }, children)));
947
- }
948
- }
3688
+ }, []);
3689
+ useEffect(() => {
3690
+ handleHistoryChange(location, navigationType);
3691
+ }, [location, navigationType, handleHistoryChange]);
3692
+ return React.createElement(IonRouter, { registerHistoryListener: registerHistoryListener }, children);
3693
+ };
3694
+ const IonReactHashRouter = ({ children, ...routerProps }) => {
3695
+ return (React.createElement(HashRouter, { ...routerProps },
3696
+ React.createElement(RouterContent, null, children)));
3697
+ };
949
3698
 
950
3699
  export { IonReactHashRouter, IonReactMemoryRouter, IonReactRouter };
951
3700
  //# sourceMappingURL=index.js.map