Skip to content

Commit 9621f8e

Browse files
committed
fix(react-router): preventing content that's transitioning out from being removed before it's supposed to be, fixing animations
1 parent ac6f7b0 commit 9621f8e

File tree

2 files changed

+100
-9
lines changed

2 files changed

+100
-9
lines changed

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

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -262,9 +262,19 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
262262
this.outOfScopeUnmountTimeout = undefined;
263263
}
264264

265+
// Remove view items from the stack but do NOT apply ion-page-hidden.
266+
// ion-page-hidden sets display:none which immediately removes content
267+
// from the layout, causing the parent outlet's leaving page to flash
268+
// blank during its transition animation (issue #25477).
269+
//
270+
// Removing from the stack triggers React reconciliation via forceUpdate,
271+
// which removes the DOM elements. React batches this re-render after all
272+
// componentDidUpdate calls in the current cycle, so the parent outlet's
273+
// commit() captures the current DOM state (with content visible) before
274+
// React processes the removal. The compositor's cached layer is unaffected
275+
// by subsequent DOM changes during the animation.
265276
const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id);
266277
allViewsInOutlet.forEach((viewItem) => {
267-
hideIonPageElement(viewItem.ionPageElement);
268278
this.context.unMountViewItem(viewItem);
269279
});
270280

@@ -580,13 +590,15 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
580590

581591
/**
582592
* Determines whether to skip the transition animation and, if so, immediately
583-
* hides the leaving view with inline `display:none`.
593+
* hides the leaving view with inline `visibility:hidden`.
584594
*
585-
* Skips transitions in outlets nested inside a parent IonPage. These outlets
586-
* render pages inside a parent page's content area. The MD animation shows
587-
* both entering and leaving pages simultaneously, causing text overlap and
588-
* nested scrollbars (each page has its own IonContent). Top-level outlets
589-
* are unaffected and animate normally.
595+
* Skips transitions only for outlets nested inside a parent IonPage's content
596+
* area (i.e., an ion-content sits between the outlet and the .ion-page). These
597+
* outlets render child pages inside a parent page's scrollable area, and the MD
598+
* animation shows both entering and leaving pages simultaneously — causing text
599+
* overlap and nested scrollbars. Standard page-level outlets (tabs, routing,
600+
* swipe-to-go-back) animate normally even though they sit inside a framework-
601+
* managed .ion-page wrapper from the parent outlet's view stack.
590602
*
591603
* Uses inline visibility:hidden rather than ion-page-hidden class because
592604
* core's beforeTransition() removes ion-page-hidden via setPageHidden().
@@ -599,8 +611,22 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
599611
enteringViewItem: ViewItem,
600612
leavingViewItem: ViewItem | undefined
601613
): boolean {
602-
const isNestedOutlet = !!this.routerOutletElement?.closest('.ion-page');
603-
const shouldSkip = isNestedOutlet && !!leavingViewItem && enteringViewItem !== leavingViewItem;
614+
// Only skip for outlets genuinely nested inside a page's content area.
615+
// Walk from the outlet up to the nearest .ion-page; if an ion-content
616+
// sits in between, the outlet is inside scrollable page content and
617+
// animating would cause overlapping pages with duplicate scrollbars.
618+
let isInsidePageContent = false;
619+
let el: HTMLElement | null = this.routerOutletElement?.parentElement ?? null;
620+
while (el) {
621+
if (el.classList.contains('ion-page')) break;
622+
if (el.tagName === 'ION-CONTENT') {
623+
isInsidePageContent = true;
624+
break;
625+
}
626+
el = el.parentElement;
627+
}
628+
629+
const shouldSkip = isInsidePageContent && !!leavingViewItem && enteringViewItem !== leavingViewItem;
604630

605631
if (shouldSkip && leavingViewItem?.ionPageElement) {
606632
leavingViewItem.ionPageElement.style.setProperty('visibility', 'hidden');

packages/react-router/test/base/tests/e2e/specs/routing.cy.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,71 @@ describe('Routing Tests', () => {
140140
cy.ionPageVisible('tab3-page');
141141
});
142142

143+
it('Tab 3 > Other Page, tab page should not flash blank during transition', () => {
144+
// Verifies fix for https://github.com/ionic-team/ionic-framework/issues/25477
145+
// Tests that navigating from a tab page to a non-tab page does not cause
146+
// the tab page content to vanish before the transition animation completes.
147+
// Bug: handleOutOfScopeOutlet immediately applied ion-page-hidden (display: none)
148+
// to tab views, causing the leaving page to flash blank during the forward transition.
149+
cy.visit(`http://localhost:${port}/routing/tabs/tab3?ionic:mode=ios`);
150+
cy.ionPageVisible('tab3-page');
151+
152+
// Set up a MutationObserver BEFORE navigating to detect if ion-page-hidden
153+
// (display: none) is ever applied to the tab page during the transition.
154+
cy.window().then((win) => {
155+
const tabPage = win.document.querySelector('[data-pageid="tab3-page"]');
156+
win.__ionPageHiddenApplied = false;
157+
const observer = new MutationObserver((mutations) => {
158+
for (const mutation of mutations) {
159+
if (mutation.target.classList && mutation.target.classList.contains('ion-page-hidden')) {
160+
win.__ionPageHiddenApplied = true;
161+
}
162+
}
163+
});
164+
observer.observe(tabPage, { attributes: true, attributeFilter: ['class'] });
165+
win.__tabPageObserver = observer;
166+
});
167+
168+
// Navigate to non-tab page
169+
cy.contains('ion-button', 'Go to Other Page').click();
170+
cy.ionPageVisible('other-page');
171+
172+
// Verify ion-page-hidden was never applied during the transition
173+
cy.window().then((win) => {
174+
win.__tabPageObserver.disconnect();
175+
expect(win.__ionPageHiddenApplied).to.be.false;
176+
});
177+
});
178+
179+
it('Home > Other Page, tab page should not flash blank during transition', () => {
180+
// Verifies fix for https://github.com/ionic-team/ionic-framework/issues/25477
181+
// Same test as above but from the Home tab using routerLink navigation
182+
cy.visit(`http://localhost:${port}/routing/tabs/home?ionic:mode=ios`);
183+
cy.ionPageVisible('home-page');
184+
185+
cy.window().then((win) => {
186+
const tabPage = win.document.querySelector('[data-pageid="home-page"]');
187+
win.__ionPageHiddenApplied = false;
188+
const observer = new MutationObserver((mutations) => {
189+
for (const mutation of mutations) {
190+
if (mutation.target.classList && mutation.target.classList.contains('ion-page-hidden')) {
191+
win.__ionPageHiddenApplied = true;
192+
}
193+
}
194+
});
195+
observer.observe(tabPage, { attributes: true, attributeFilter: ['class'] });
196+
win.__tabPageObserver = observer;
197+
});
198+
199+
cy.contains('ion-item', 'Other Page').click();
200+
cy.ionPageVisible('other-page');
201+
202+
cy.window().then((win) => {
203+
win.__tabPageObserver.disconnect();
204+
expect(win.__ionPageHiddenApplied).to.be.false;
205+
});
206+
});
207+
143208
it('/ > Menu > Favorites > Menu > Tabs, should be back on Home', () => {
144209
// Tests transferring from one outlet to another and back again via menu
145210
cy.visit(`http://localhost:${port}/routing`);

0 commit comments

Comments
 (0)