mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
fix(react-router): cancel in-flight rAFs and MutationObserver on StackManager unmount
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user