fix(react-router): cancel in-flight rAFs and MutationObserver on StackManager unmount

This commit is contained in:
ShaneK
2026-03-11 10:01:17 -07:00
parent 0b1af4fdcb
commit 640f430ecc
2 changed files with 127 additions and 4 deletions

View File

@@ -73,6 +73,12 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
* 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<StackManagerProps> {
}
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<StackManagerProps> {
}
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<StackManagerProps> {
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<StackManagerProps> {
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<StackManagerProps> {
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<void>((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);

View File

@@ -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');
});
});