Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(react): inline overlays dismiss when parent component unmounts #26245

Merged
merged 31 commits into from
Mar 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
678d34e
fix(react): inline overlays dismiss when parent component unmounts
sean-perkins Nov 8, 2022
1ea0cd2
test(react): navigate to have history for pop operation
sean-perkins Nov 8, 2022
e345def
fix: memory leak with event handlers
sean-perkins Nov 9, 2022
e4294cd
Merge branch 'main' into fix/react-overlays
sean-perkins Nov 9, 2022
7d8b091
chore: remove duplicate className from merge
sean-perkins Nov 9, 2022
25dbfa9
Merge remote-tracking branch 'origin/main' into fix/react-overlays
sean-perkins Nov 9, 2022
7cd912b
Merge branch 'main' into fix/react-overlays
sean-perkins Nov 17, 2022
3422543
Merge branch 'main' into fix/react-overlays
sean-perkins Nov 22, 2022
d757a0b
Merge branch 'main' into fix/react-overlays
sean-perkins Nov 22, 2022
6ada9f6
Merge branch 'main' into fix/react-overlays
sean-perkins Nov 23, 2022
12ad13c
Merge branch 'main' into fix/react-overlays
sean-perkins Nov 29, 2022
93b201a
fix: detach event listeners on unmount
sean-perkins Nov 30, 2022
811e869
docs: event handler from event store
sean-perkins Nov 30, 2022
50287a8
chore: remove local event listener before dismiss
sean-perkins Nov 30, 2022
e315da5
Merge branch 'main' into fix/react-overlays
sean-perkins Nov 30, 2022
4e68e60
fix: life cycle methods are manually called on unmounted overlays
sean-perkins Nov 30, 2022
f6d5843
Merge remote-tracking branch 'origin/fix/react-overlays' into fix/rea…
sean-perkins Nov 30, 2022
2ac9857
chore: lint formatting
sean-perkins Nov 30, 2022
b4f4e26
Merge remote-tracking branch 'origin/main' into fix/react-overlays
sean-perkins Nov 30, 2022
a521103
Merge branch 'main' into fix/react-overlays
sean-perkins Dec 2, 2022
bc3528a
Merge remote-tracking branch 'origin/main' into fix/react-overlays
sean-perkins Feb 10, 2023
a0af097
fix: skip dismiss transition when component is force unmounted
sean-perkins Feb 10, 2023
b29bd02
fix: import type for @ionic/core/components
sean-perkins Feb 10, 2023
dafb6ac
chore: prettier
sean-perkins Feb 10, 2023
17ad0d6
fix: remove element node
sean-perkins Feb 10, 2023
9520826
chore: typo in comment
sean-perkins Feb 22, 2023
baefa2f
Merge branch 'main' into fix/react-overlays
sean-perkins Feb 22, 2023
0e6e3c5
chore: update comment to apply to all overlays
sean-perkins Feb 27, 2023
dd6d041
Merge remote-tracking branch 'origin/main' into fix/react-overlays
sean-perkins Feb 27, 2023
006270b
Merge branch 'main' into fix/react-overlays
sean-perkins Mar 1, 2023
0404461
Merge branch 'main' into fix/react-overlays
sean-perkins Mar 3, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/react-router/test-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import DynamicIonpageClassnames from './pages/dynamic-ionpage-classnames/Dynamic
import Tabs from './pages/tabs/Tabs';
import TabsSecondary from './pages/tabs/TabsSecondary';
import Params from './pages/params/Params';
import Overlays from './pages/overlays/Overlays';

setupIonicReact();

Expand All @@ -60,6 +61,7 @@ const App: React.FC = () => {
<Route path="/tabs" component={Tabs} />
<Route path="/tabs-secondary" component={TabsSecondary} />
<Route path="/refs" component={Refs} />
<Route path="/overlays" component={Overlays} />
<Route path="/params/:id" component={Params} />
</IonRouterOutlet>
</IonReactRouter>
Expand Down
5 changes: 4 additions & 1 deletion packages/react-router/test-app/src/pages/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,12 @@ const Main: React.FC<MainProps> = () => {
<IonItem routerLink="/dynamic-ionpage-classnames">
<IonLabel>Dynamic IonPage Classnames</IonLabel>
</IonItem>
<IonItem routerLink="/Refs">
<IonItem routerLink="/refs">
<IonLabel>Refs</IonLabel>
</IonItem>
<IonItem routerLink="/overlays">
<IonLabel>Overlays</IonLabel>
</IonItem>
<IonItem routerLink="/tabs" id="go-to-tabs">
<IonLabel>Tabs</IonLabel>
</IonItem>
Expand Down
41 changes: 41 additions & 0 deletions packages/react-router/test-app/src/pages/overlays/Overlays.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { IonButton, IonContent, IonModal } from '@ionic/react';
import { useState } from 'react';
import { useHistory } from 'react-router';

const Overlays: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);

const history = useHistory();

const goBack = () => history.goBack();
const replace = () => history.replace('/');
const push = () => history.push('/');

return (
<>
<IonButton id="openModal" onClick={() => setIsOpen(true)}>
Open Modal
</IonButton>
<IonModal
isOpen={isOpen}
onDidDismiss={() => {
setIsOpen(false);
}}
>
<IonContent>
<IonButton id="goBack" onClick={goBack}>
Go Back
</IonButton>
<IonButton id="replace" onClick={replace}>
Replace
</IonButton>
<IonButton id="push" onClick={push}>
Push
</IonButton>
</IonContent>
</IonModal>
</>
);
};

export default Overlays;
41 changes: 41 additions & 0 deletions packages/react-router/test-app/tests/e2e/specs/overlays.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const port = 3000;

describe('Overlays', () => {
it('should remove the overlay when going back to the previous route', () => {
// Requires navigation history to perform a pop
cy.visit(`http://localhost:${port}`);
cy.visit(`http://localhost:${port}/overlays`);

cy.get('#openModal').click();

cy.get('ion-modal').should('exist');

cy.get('#goBack').click();

cy.get('ion-modal').should('not.exist');
});

it('should remove the overlay when pushing to a new route', () => {
cy.visit(`http://localhost:${port}/overlays`);

cy.get('#openModal').click();

cy.get('ion-modal').should('exist');

cy.get('#push').click();

cy.get('ion-modal').should('not.exist');
});

it('should remove the overlay when replacing the route', () => {
cy.visit(`http://localhost:${port}/overlays`);

cy.get('#openModal').click();

cy.get('ion-modal').should('exist');

cy.get('#replace').click();

cy.get('ion-modal').should('not.exist');
});
});
129 changes: 74 additions & 55 deletions packages/react/src/components/createInlineOverlayComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { OverlayEventDetail } from '@ionic/core/components';
import type { HTMLIonOverlayElement, OverlayEventDetail } from '@ionic/core/components';
import React, { createElement } from 'react';

import {
Expand All @@ -9,6 +9,7 @@ import {
mergeRefs,
} from './react-component-lib/utils';
import { createForwardRef } from './utils';
import { detachProps } from './utils/detachProps';

// TODO(FW-2959): types

Expand All @@ -35,7 +36,7 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
}
const displayName = dashToPascalCase(tagName);
const ReactComponent = class extends React.Component<IonicReactInternalProps<PropType>, InlineOverlayState> {
ref: React.RefObject<HTMLElement>;
ref: React.RefObject<HTMLIonOverlayElement>;
wrapperRef: React.RefObject<HTMLElement>;
stableMergedRefs: React.RefCallback<HTMLElement>;

Expand All @@ -54,65 +55,43 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
componentDidMount() {
this.componentDidUpdate(this.props);

/**
* Mount the inner component when the
* overlay is about to open.
*
* For ion-popover, this is when `ionMount` is emitted.
* For other overlays, this is when `willPresent` is emitted.
*/
this.ref.current?.addEventListener('ionMount', () => {
this.setState({ isOpen: true });
});

/**
* Mount the inner component
* when overlay is about to open.
* Also manually call the onWillPresent
* handler if present as setState will
* cause the event handlers to be
* destroyed and re-created.
*/
this.ref.current?.addEventListener('willPresent', (evt: any) => {
this.setState({ isOpen: true });
this.ref.current?.addEventListener('ionMount', this.handleIonMount);
this.ref.current?.addEventListener('willPresent', this.handleWillPresent);
this.ref.current?.addEventListener('didDismiss', this.handleDidDismiss);
}

this.props.onWillPresent && this.props.onWillPresent(evt);
});
componentDidUpdate(prevProps: IonicReactInternalProps<PropType>) {
const node = this.ref.current! as HTMLElement;
attachProps(node, this.props, prevProps);
}

componentWillUnmount() {
const node = this.ref.current;
/**
* Unmount the inner component.
* React will call Node.removeChild
* which expects the child to be
* a direct descendent of the parent
* but due to the presence of
* Web Component slots, this is not
* always the case. To work around this
* we move the inner component to the root
* of the Web Component so React can
* cleanup properly.
* If the overlay is being unmounted, but is still
* open, this means the unmount was triggered outside
* of the overlay being dismissed.
*
* This can happen with:
* - The parent component being unmounted
* - The overlay being conditionally rendered
* - A route change (push/pop/replace)
*
* Unmounting the overlay at this stage should skip
* the dismiss lifecycle, including skipping the transition.
*
*/
this.ref.current?.addEventListener('didDismiss', (evt: any) => {
const wrapper = this.wrapperRef.current;
const el = this.ref.current;

if (node && this.state.isOpen) {
/**
* This component might be unmounted already, if the containing
* element was removed while the popover was still open. (For
* example, if an item contains an inline popover with a button
* that removes the item.)
* Detach the local event listener that performs the state updates,
* before dismissing the overlay, to prevent the callback handlers
* executing after the component has been unmounted. This is to
* avoid memory leaks.
*/
if (wrapper && el) {
el.append(wrapper);
this.setState({ isOpen: false });
}

this.props.onDidDismiss && this.props.onDidDismiss(evt);
});
}

componentDidUpdate(prevProps: IonicReactInternalProps<PropType>) {
const node = this.ref.current! as HTMLElement;
attachProps(node, this.props, prevProps);
node.removeEventListener('didDismiss', this.handleDidDismiss);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we only remove the didDismiss event?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createInlineOverlayComponent binds two internal event listeners for didDismiss and willPresent. Both implementations have an internal state update for:

this.setState({ isOpen: false }); // `true` for willPresent

At this point in the lifecycle of the component the component is unmounting. willPresent cannot fire. However, didDismiss can fire and cause any user-implemented callback handlers for onDidDismiss to run after the React component has unmounted. If this occurs, React will throw an exception in the console about a memory leak.

This code disconnects the internal event listener manually, before the element can dispatch didDismiss.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would detachProps handle removing that listener? If so, would it make sense to instead call detachProps before node.remove and then remove this line?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detachProps only removes event listeners added by attachProps (because we track the events on the element node on a key called __events). This specific event listener is manually added within the React class and isn't removed as a result of detachProps.

node.remove();
liamdebeasi marked this conversation as resolved.
Show resolved Hide resolved
detachProps(node, this.props);
}
}

render() {
Expand Down Expand Up @@ -172,6 +151,46 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
static get displayName() {
return displayName;
}

private handleIonMount = () => {
/**
* Mount the inner component when the
* overlay is about to open.
*
* For ion-popover, this is when `ionMount` is emitted.
liamdebeasi marked this conversation as resolved.
Show resolved Hide resolved
* For other overlays, this is when `willPresent` is emitted.
*/
this.setState({ isOpen: true });
};

private handleWillPresent = (evt: any) => {
this.setState({ isOpen: true });
/**
* Manually call the onWillPresent
* handler if present as setState will
* cause the event handlers to be
* destroyed and re-created.
*/
this.props.onWillPresent && this.props.onWillPresent(evt);
};

private handleDidDismiss = (evt: any) => {
const wrapper = this.wrapperRef.current;
const el = this.ref.current;

/**
* This component might be unmounted already, if the containing
* element was removed while the overlay was still open. (For
* example, if an item contains an inline overlay with a button
* that removes the item.)
*/
if (wrapper && el) {
el.append(wrapper);
this.setState({ isOpen: false });
}

this.props.onDidDismiss && this.props.onDidDismiss(evt);
};
};
return createForwardRef<PropType, ElementType>(ReactComponent, displayName);
};
42 changes: 42 additions & 0 deletions packages/react/src/components/utils/detachProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { isCoveredByReact } from '../react-component-lib/utils';

/**
* The @stencil/react-output-target will bind event listeners for any
* attached props that use the `on` prefix. This function will remove
* those event listeners when the component is unmounted.
*
* This prevents memory leaks and React state updates on unmounted components.
*/
export const detachProps = (node: HTMLElement, props: any) => {
if (node instanceof Element) {
Object.keys(props).forEach((name) => {
if (name.indexOf('on') === 0 && name[2] === name[2].toUpperCase()) {
const eventName = name.substring(2);
const eventNameLc = eventName[0].toLowerCase() + eventName.substring(1);
if (!isCoveredByReact(eventNameLc)) {
/**
* Detach custom event bindings (not built-in React events)
* that were added by the @stencil/react-output-target attachProps function.
*/
detachEvent(node, eventNameLc);
}
}
});
}
};

const detachEvent = (
node: Element & { __events?: { [key: string]: ((e: Event) => any) | undefined } },
eventName: string
) => {
const eventStore = node.__events || (node.__events = {});
/**
* If the event listener was added by attachProps, it will
* be stored in the __events object.
*/
const eventHandler = eventStore[eventName];
if (eventHandler) {
node.removeEventListener(eventName, eventHandler);
eventStore[eventName] = undefined;
}
};