Skip to content

Commit 8e40476

Browse files
committed
fix(react-router): scope push cleanup to explicitly preserved views
1 parent f5e6026 commit 8e40476

1 file changed

Lines changed: 54 additions & 8 deletions

File tree

packages/react-router/src/ReactRouter/StackManager.tsx

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,22 @@ const showIonPageElement = (element: HTMLElement | undefined): void => {
6666
}
6767
};
6868

69+
/**
70+
* Variant of `showIonPageElement` for the swipe-back gesture start. Clears
71+
* `display: none` and the hidden class/attribute so the entering view is
72+
* visible, but intentionally keeps any inline `transform` and `opacity` set
73+
* by core's prior forward transition. The gesture's progress animation starts
74+
* from that pose, so clearing them here would cause a visible jump before
75+
* core's progress animation takes over.
76+
*/
77+
const revealIonPageForSwipeBack = (element: HTMLElement | undefined): void => {
78+
if (element) {
79+
element.style.removeProperty('display');
80+
element.classList.remove('ion-page-hidden');
81+
element.removeAttribute('aria-hidden');
82+
}
83+
};
84+
6985
/**
7086
* A leaf view is "preservable" on browser-back (pop) when its React state
7187
* should survive a forward-pop round-trip. Non-parameterized leaf paths
@@ -113,6 +129,15 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
113129
* duplicate transitions during rapid navigation (e.g., Navigate redirects)
114130
*/
115131
private lastTransition?: { enteringId: string; leavingId?: string };
132+
/**
133+
* Views that have been explicitly kept alive by the pop-preserve logic
134+
* (shouldPreserveLeavingView) so a future forward-pop can restore their React
135+
* state. These are candidates for cleanup when a fresh push invalidates the
136+
* forward-history path that made them reachable. Views mounted through
137+
* normal forward-push (which keeps the leaving view alive by default) are
138+
* NOT tracked here.
139+
*/
140+
private preservedViewItems = new Set<ViewItem>();
116141
/** Tracks whether the component is mounted to guard async transition paths. */
117142
private _isMounted = false;
118143
/** In-flight requestAnimationFrame IDs from transitionPage, cancelled on unmount. */
@@ -505,6 +530,9 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
505530
if (!enteringViewItem.mount) {
506531
enteringViewItem.mount = true;
507532
}
533+
// A view that becomes the entering view is no longer a stale preserved view.
534+
// It's back in the active navigation path, so drop it from the cleanup set.
535+
this.preservedViewItems.delete(enteringViewItem);
508536

509537
// Check visibility state BEFORE showing entering view
510538
const enteringWasVisible = enteringViewItem.ionPageElement && isViewVisible(enteringViewItem.ionPageElement);
@@ -581,6 +609,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
581609
routeInfo.routeAction === 'pop' && isViewItemPreservableOnPop(leavingViewItem);
582610
if (routeInfo.routeAction !== 'replace' && !shouldPreserveLeavingView) {
583611
leavingViewItem.mount = false;
612+
} else if (shouldPreserveLeavingView) {
613+
this.preservedViewItems.add(leavingViewItem);
584614
}
585615
this.handleLeavingViewUnmount(routeInfo, enteringViewItem, leavingViewItem);
586616
}
@@ -595,9 +625,11 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
595625
}
596626

597627
/**
598-
* Unmounts any previously-preserved leaf views in this outlet when a push
599-
* action invalidates the forward history path. Runs only on `push` (not
600-
* replace/pop) and skips the entering and leaving view items.
628+
* Unmounts views previously kept alive by the pop-preserve logic when a fresh
629+
* push invalidates the forward-history path that made them reachable. Only
630+
* iterates views explicitly tracked in `preservedViewItems` so that views
631+
* naturally mounted through forward-push (the default leaving-view behavior)
632+
* are left untouched.
601633
*/
602634
private cleanupPreservedViewsOnPush(
603635
routeInfo: RouteInfo,
@@ -607,19 +639,21 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
607639
if (routeInfo.routeAction !== 'push') {
608640
return;
609641
}
642+
if (this.preservedViewItems.size === 0) {
643+
return;
644+
}
610645

611-
const allViews = this.context.getViewItemsForOutlet(this.id);
612-
for (const viewItem of allViews) {
646+
for (const viewItem of Array.from(this.preservedViewItems)) {
613647
if (viewItem === enteringViewItem || viewItem === leavingViewItem) {
648+
this.preservedViewItems.delete(viewItem);
614649
continue;
615650
}
616651
if (!viewItem.mount) {
617-
continue;
618-
}
619-
if (!isViewItemPreservableOnPop(viewItem)) {
652+
this.preservedViewItems.delete(viewItem);
620653
continue;
621654
}
622655
viewItem.mount = false;
656+
this.preservedViewItems.delete(viewItem);
623657
const viewToUnmount = viewItem;
624658
setTimeout(() => {
625659
this.context.unMountViewItem(viewToUnmount);
@@ -863,6 +897,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
863897
routeInfo.routeAction === 'pop' && isViewItemPreservableOnPop(leavingViewItem);
864898
if (routeInfo.routeAction !== 'replace' && !shouldPreserveLeavingView) {
865899
leavingViewItem.mount = false;
900+
} else if (shouldPreserveLeavingView) {
901+
this.preservedViewItems.add(leavingViewItem);
866902
}
867903
this.handleLeavingViewUnmount(routeInfo, enteringViewItem, leavingViewItem);
868904
}
@@ -904,6 +940,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
904940
routeInfo.routeAction === 'pop' && isViewItemPreservableOnPop(latestLeavingView);
905941
if (routeInfo.routeAction !== 'replace' && !shouldPreserveLeavingView) {
906942
latestLeavingView.mount = false;
943+
} else if (shouldPreserveLeavingView) {
944+
this.preservedViewItems.add(latestLeavingView);
907945
}
908946
this.handleLeavingViewUnmount(routeInfo, latestEnteringView, latestLeavingView);
909947
}
@@ -1010,6 +1048,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
10101048
this.outOfScopeUnmountTimeout = undefined;
10111049
}
10121050
this.waitingForIonPage = false;
1051+
this.preservedViewItems.clear();
10131052

10141053
// Hide all views in this outlet before clearing.
10151054
// This is critical for nested outlets - when the parent component unmounts,
@@ -1137,6 +1176,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
11371176
routeInfo.routeAction === 'pop' && isViewItemPreservableOnPop(leavingViewItem);
11381177
if (shouldUnmountLeavingViewItem && !shouldPreserveLeavingView) {
11391178
leavingViewItem.mount = false;
1179+
} else if (shouldUnmountLeavingViewItem && shouldPreserveLeavingView) {
1180+
this.preservedViewItems.add(leavingViewItem);
11401181
}
11411182
}
11421183
}
@@ -1340,6 +1381,11 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
13401381
enteringViewItem.mount = true;
13411382
}
13421383

1384+
// Reveal synchronously. `transitionPage` defers this behind async commit,
1385+
// but the gesture's first progress frame fires in the same tick as onStart,
1386+
// so an async reveal leaves the entering page hidden until the next frame.
1387+
revealIonPageForSwipeBack(enteringViewItem?.ionPageElement);
1388+
13431389
// When the gesture starts, kick off a transition controlled via swipe gesture
13441390
if (enteringViewItem && leavingViewItem) {
13451391
await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true);

0 commit comments

Comments
 (0)