diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 6cba702f36..10673bc947 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -73,6 +73,12 @@ export class StackManager extends React.PureComponent { * duplicate transitions during rapid navigation (e.g., Navigate redirects) */ private lastTransition?: { enteringId: string; leavingId?: string }; + /** Tracks whether the component is mounted to guard async transition paths. */ + private _isMounted = false; + /** In-flight requestAnimationFrame IDs from transitionPage, cancelled on unmount. */ + private transitionRafIds: number[] = []; + /** In-flight MutationObserver from waitForComponentsReady, disconnected on unmount. */ + private transitionObserver?: MutationObserver; constructor(props: StackManagerProps) { super(props); @@ -686,6 +692,7 @@ export class StackManager extends React.PureComponent { } componentDidMount() { + this._isMounted = true; if (this.clearOutletTimeout) { /** * The clearOutlet integration with React Router is a bit hacky. @@ -718,6 +725,20 @@ export class StackManager extends React.PureComponent { } componentWillUnmount() { + this._isMounted = false; + + // Cancel any in-flight transition rAFs + for (const id of this.transitionRafIds) { + cancelAnimationFrame(id); + } + this.transitionRafIds = []; + + // Disconnect any in-flight MutationObserver from waitForComponentsReady + if (this.transitionObserver) { + this.transitionObserver.disconnect(); + this.transitionObserver = undefined; + } + if (this.ionPageWaitTimeout) { clearTimeout(this.ionPageWaitTimeout); this.ionPageWaitTimeout = undefined; @@ -1197,10 +1218,19 @@ export class StackManager extends React.PureComponent { if (!resolved && checkReady()) { resolved = true; observer.disconnect(); + if (this.transitionObserver === observer) { + this.transitionObserver = undefined; + } resolve(); } }); + // Disconnect any previous observer before tracking the new one + if (this.transitionObserver) { + this.transitionObserver.disconnect(); + } + this.transitionObserver = observer; + observer.observe(enteringEl, { subtree: true, attributes: true, @@ -1211,6 +1241,9 @@ export class StackManager extends React.PureComponent { if (!resolved) { resolved = true; observer.disconnect(); + if (this.transitionObserver === observer) { + this.transitionObserver = undefined; + } resolve(); } }, 100); @@ -1219,17 +1252,30 @@ export class StackManager extends React.PureComponent { await waitForComponentsReady(); + // Bail out if the component unmounted during waitForComponentsReady + if (!this._isMounted) return; + // Swap visibility in sync with browser's render cycle await new Promise((resolve) => { - requestAnimationFrame(() => { + const outerRafId = requestAnimationFrame(() => { + this.transitionRafIds = this.transitionRafIds.filter((id) => id !== outerRafId); + if (!this._isMounted) { + resolve(); + return; + } enteringEl.classList.remove('ion-page-invisible'); // Second rAF ensures entering is painted before hiding leaving - requestAnimationFrame(() => { - leavingEl.classList.add('ion-page-hidden'); - leavingEl.setAttribute('aria-hidden', 'true'); + const innerRafId = requestAnimationFrame(() => { + this.transitionRafIds = this.transitionRafIds.filter((id) => id !== innerRafId); + if (this._isMounted) { + leavingEl.classList.add('ion-page-hidden'); + leavingEl.setAttribute('aria-hidden', 'true'); + } resolve(); }); + this.transitionRafIds.push(innerRafId); }); + this.transitionRafIds.push(outerRafId); }); } else { await runCommit(enteringViewItem.ionPageElement, leavingEl); diff --git a/packages/react-router/test/base/tests/e2e/specs/transition-unmount-guard.cy.js b/packages/react-router/test/base/tests/e2e/specs/transition-unmount-guard.cy.js new file mode 100644 index 0000000000..98cc1e8827 --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/specs/transition-unmount-guard.cy.js @@ -0,0 +1,77 @@ +const port = 3000; + +describe('Transition Unmount Guard', () => { + /** + * Tests that rapid navigation away from a tabs view during a non-animated + * tab switch transition does not cause errors or stale state. + * + * The non-animated transition path in StackManager.transitionPage uses + * waitForComponentsReady() (MutationObserver + 100ms timeout) followed by + * nested requestAnimationFrame calls. If the component unmounts during this + * async window, the unmount guard should cancel in-flight rAFs and disconnect + * the MutationObserver to prevent DOM manipulation on detached elements. + */ + + it('should handle unmount during non-animated tab switch without errors', () => { + // Navigate to tabs from home (pushes /tabs/tab1 onto history) + cy.visit(`http://localhost:${port}/`); + cy.ionPageVisible('home'); + cy.get('#go-to-tabs').click(); + cy.ionPageVisible('tab1'); + + // Start a tab switch (non-animated transition) and immediately go back to home. + // History: /, /tabs/tab1, /tabs/tab2 — go(-2) jumps back to / + // This unmounts the tabs StackManager while the tab switch transition is in flight. + cy.get('ion-tab-button#tab-button-tab2').click(); + cy.window().then((win) => win.history.go(-2)); + + // Home page should be visible and functional after the rapid unmount + cy.ionPageVisible('home'); + }); + + it('should recover cleanly after unmount during transition and re-navigate to tabs', () => { + cy.visit(`http://localhost:${port}/`); + cy.ionPageVisible('home'); + + // First trip: navigate to tabs, switch tabs, immediately go back to home + cy.get('#go-to-tabs').click(); + cy.ionPageVisible('tab1'); + cy.get('ion-tab-button#tab-button-tab2').click(); + cy.window().then((win) => win.history.go(-2)); + cy.ionPageVisible('home'); + + // Second trip: navigate to tabs again to verify no stale state + cy.get('#go-to-tabs').click(); + cy.ionPageVisible('tab1'); + + // Tab switching should still work correctly + cy.get('ion-tab-button#tab-button-tab2').click(); + cy.ionPageVisible('tab2'); + cy.ionPageHidden('tab1'); + + cy.get('ion-tab-button#tab-button-tab1').click(); + cy.ionPageVisible('tab1'); + cy.ionPageHidden('tab2'); + }); + + it('should handle rapid repeated tab switches followed by unmount', () => { + cy.visit(`http://localhost:${port}/`); + cy.ionPageVisible('home'); + cy.get('#go-to-tabs').click(); + cy.ionPageVisible('tab1'); + + // Rapid tab switches to stack up multiple non-animated transitions + cy.get('ion-tab-button#tab-button-tab2').click(); + cy.get('ion-tab-button#tab-button-tab1').click(); + cy.get('ion-tab-button#tab-button-tab2').click(); + + // Navigate back to home while transitions may still be in flight. + // Multiple tab switches pushed extra entries, so go back enough to reach /. + cy.window().then((win) => win.history.go(-4)); + cy.ionPageVisible('home'); + + // App should still be functional + cy.get('#go-to-tabs').click(); + cy.ionPageVisible('tab1'); + }); +});