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

rewrite BaseModal to a functional component #25672

Merged
merged 8 commits into from
Aug 25, 2023
Merged
331 changes: 174 additions & 157 deletions src/components/Modal/BaseModal.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import React, {PureComponent} from 'react';
import React, {forwardRef, useCallback, useEffect, useMemo} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import ReactNativeModal from 'react-native-modal';
import {SafeAreaInsetsContext} from 'react-native-safe-area-context';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import styles from '../../styles/styles';
import * as Modal from '../../libs/actions/Modal';
import * as StyleUtils from '../../styles/StyleUtils';
import themeColors from '../../styles/themes/default';
import {propTypes as modalPropTypes, defaultProps as modalDefaultProps} from './modalPropTypes';
import * as Modal from '../../libs/actions/Modal';
import getModalStyles from '../../styles/getModalStyles';
import useWindowDimensions from '../../hooks/useWindowDimensions';
import variables from '../../styles/variables';
import CONST from '../../CONST';
import ComposerFocusManager from '../../libs/ComposerFocusManager';

const propTypes = {
Expand All @@ -24,173 +26,188 @@ const defaultProps = {
forwardedRef: () => {},
};

class BaseModal extends PureComponent {
constructor(props) {
super(props);

this.hideModal = this.hideModal.bind(this);
}

componentDidMount() {
if (!this.props.isVisible) {
return;
}

Modal.willAlertModalBecomeVisible(true);

// To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu
Modal.setCloseModal(this.props.onClose);
}

componentDidUpdate(prevProps) {
if (prevProps.isVisible === this.props.isVisible) {
return;
}

Modal.willAlertModalBecomeVisible(this.props.isVisible);
Modal.setCloseModal(this.props.isVisible ? this.props.onClose : null);
}

componentWillUnmount() {
// Only trigger onClose and setModalVisibility if the modal is unmounting while visible.
if (this.props.isVisible) {
this.hideModal(true);
Modal.willAlertModalBecomeVisible(false);
}

// To prevent closing any modal already unmounted when this modal still remains as visible state
Modal.setCloseModal(null);
}
function BaseModal({
isVisible,
onClose,
shouldSetModalVisibility,
onModalHide,
type,
popoverAnchorPosition,
innerContainerStyle,
outerStyle,
onModalShow,
propagateSwipe,
fullscreen,
animationIn,
animationOut,
useNativeDriver,
hideModalContentWhileAnimating,
animationInTiming,
animationOutTiming,
statusBarTranslucent,
onLayout,
avoidKeyboard,
forwardedRef,
children,
}) {
const {windowWidth, windowHeight, isSmallScreenWidth} = useWindowDimensions();

const safeAreaInsets = useSafeAreaInsets();

/**
* Hides modal
* @param {Boolean} [callHideCallback=true] Should we call the onModalHide callback
*/
hideModal(callHideCallback = true) {
if (this.props.shouldSetModalVisibility) {
Modal.setModalVisibility(false);
}
if (callHideCallback) {
this.props.onModalHide();
const hideModal = useCallback(
(callHideCallback = true) => {
if (shouldSetModalVisibility) {
Modal.setModalVisibility(false);
}
if (callHideCallback) {
onModalHide();
}
Modal.onModalDidClose();
if (!fullscreen) {
ComposerFocusManager.setReadyToFocus();
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- adding onModalHide to the dependency array causes too many unnecessary rerenders
[shouldSetModalVisibility],
);

useEffect(() => {
Modal.willAlertModalBecomeVisible(isVisible);

// To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu
Modal.setCloseModal(isVisible ? onClose : null);

Comment on lines +81 to +82
Copy link
Contributor

Choose a reason for hiding this comment

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

This caused a regression #27359. Previously on component mount Modal.setCloseModal was only called if the modal was rendered visible.

return () => {
// Only trigger onClose and setModalVisibility if the modal is unmounting while visible.
if (isVisible) {
hideModal(true);
Modal.willAlertModalBecomeVisible(false);
}

Comment on lines +85 to +89
Copy link
Contributor

Choose a reason for hiding this comment

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

This will cause a regression. We should not use the isVisible state here as this will be evaluated at the time of render and not when the component unmounts i.e. if you initially render the modal as visible (true) then on unmount this condition will always be true even if the state value change by then.

What we should do instead is to use a ref

    const isVisibleRef = useRef(isVisible);
    useEffect(() => {
        isVisibleRef.current = isVisible;
    }, [isVisible]);

// To prevent closing any modal already unmounted when this modal still remains as visible state
Modal.setCloseModal(null);
};
}, [hideModal, isVisible, onClose]);

Comment on lines +78 to +94
Copy link
Member

Choose a reason for hiding this comment

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

Seems like we have a regression from this change, when onClose callback changes, it will also hide the modal due to the cleanup logic of this effect. #25998

const handleShowModal = () => {
if (shouldSetModalVisibility) {
Modal.setModalVisibility(true);
}
Modal.onModalDidClose();
if (!this.props.fullscreen) {
ComposerFocusManager.setReadyToFocus();
onModalShow();
};

const handleBackdropPress = (e) => {
if (e && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) {
return;
}
}

render() {
const {
modalStyle,
modalContainerStyle,
swipeDirection,
animationIn,
animationOut,
shouldAddTopSafeAreaMargin,
shouldAddBottomSafeAreaMargin,
shouldAddTopSafeAreaPadding,
shouldAddBottomSafeAreaPadding,
hideBackdrop,
} = getModalStyles(
this.props.type,
{
windowWidth: this.props.windowWidth,
windowHeight: this.props.windowHeight,
isSmallScreenWidth: this.props.isSmallScreenWidth,
},
this.props.popoverAnchorPosition,
this.props.innerContainerStyle,
this.props.outerStyle,
);
return (
<ReactNativeModal
onBackdropPress={(e) => {
if (e && e.key === 'Enter') {
return;
}
this.props.onClose();
}}
// Note: Escape key on web/desktop will trigger onBackButtonPress callback
// eslint-disable-next-line react/jsx-props-no-multi-spaces
onBackButtonPress={this.props.onClose}
onModalWillShow={() => {
ComposerFocusManager.resetReadyToFocus();
}}
onModalShow={() => {
if (this.props.shouldSetModalVisibility) {
Modal.setModalVisibility(true);
}
this.props.onModalShow();
}}
propagateSwipe={this.props.propagateSwipe}
onModalHide={this.hideModal}
onDismiss={() => ComposerFocusManager.setReadyToFocus()}
onSwipeComplete={this.props.onClose}
swipeDirection={swipeDirection}
isVisible={this.props.isVisible}
backdropColor={themeColors.overlay}
backdropOpacity={hideBackdrop ? 0 : variables.overlayOpacity}
backdropTransitionOutTiming={0}
hasBackdrop={this.props.fullscreen}
coverScreen={this.props.fullscreen}
style={modalStyle}
deviceHeight={this.props.windowHeight}
deviceWidth={this.props.windowWidth}
animationIn={this.props.animationIn || animationIn}
animationOut={this.props.animationOut || animationOut}
useNativeDriver={this.props.useNativeDriver}
hideModalContentWhileAnimating={this.props.hideModalContentWhileAnimating}
animationInTiming={this.props.animationInTiming}
animationOutTiming={this.props.animationOutTiming}
statusBarTranslucent={this.props.statusBarTranslucent}
onLayout={this.props.onLayout}
avoidKeyboard={this.props.avoidKeyboard}
onClose();
};

const handleDismissModal = () => {
ComposerFocusManager.setReadyToFocus();
};

const {
modalStyle,
modalContainerStyle,
swipeDirection,
animationIn: modalStyleAnimationIn,
animationOut: modalStyleAnimationOut,
shouldAddTopSafeAreaMargin,
shouldAddBottomSafeAreaMargin,
shouldAddTopSafeAreaPadding,
shouldAddBottomSafeAreaPadding,
hideBackdrop,
} = useMemo(
() =>
getModalStyles(
type,
{
windowWidth,
windowHeight,
isSmallScreenWidth,
},
popoverAnchorPosition,
innerContainerStyle,
outerStyle,
),
[innerContainerStyle, isSmallScreenWidth, outerStyle, popoverAnchorPosition, type, windowHeight, windowWidth],
);

const {
paddingTop: safeAreaPaddingTop,
paddingBottom: safeAreaPaddingBottom,
paddingLeft: safeAreaPaddingLeft,
paddingRight: safeAreaPaddingRight,
} = StyleUtils.getSafeAreaPadding(safeAreaInsets);

const modalPaddingStyles = StyleUtils.getModalPaddingStyles({
safeAreaPaddingTop,
safeAreaPaddingBottom,
safeAreaPaddingLeft,
safeAreaPaddingRight,
shouldAddBottomSafeAreaMargin,
shouldAddTopSafeAreaMargin,
shouldAddBottomSafeAreaPadding,
shouldAddTopSafeAreaPadding,
modalContainerStyleMarginTop: modalContainerStyle.marginTop,
modalContainerStyleMarginBottom: modalContainerStyle.marginBottom,
modalContainerStylePaddingTop: modalContainerStyle.paddingTop,
modalContainerStylePaddingBottom: modalContainerStyle.paddingBottom,
insets: safeAreaInsets,
});

return (
<ReactNativeModal
onBackdropPress={handleBackdropPress}
// Note: Escape key on web/desktop will trigger onBackButtonPress callback
// eslint-disable-next-line react/jsx-props-no-multi-spaces
onBackButtonPress={onClose}
onModalShow={handleShowModal}
propagateSwipe={propagateSwipe}
onModalHide={hideModal}
onDismiss={handleDismissModal}
onSwipeComplete={onClose}
swipeDirection={swipeDirection}
isVisible={isVisible}
backdropColor={themeColors.overlay}
backdropOpacity={hideBackdrop ? 0 : variables.overlayOpacity}
backdropTransitionOutTiming={0}
hasBackdrop={fullscreen}
coverScreen={fullscreen}
style={modalStyle}
deviceHeight={windowHeight}
deviceWidth={windowWidth}
animationIn={animationIn || modalStyleAnimationIn}
animationOut={animationOut || modalStyleAnimationOut}
useNativeDriver={useNativeDriver}
hideModalContentWhileAnimating={hideModalContentWhileAnimating}
animationInTiming={animationInTiming}
animationOutTiming={animationOutTiming}
statusBarTranslucent={statusBarTranslucent}
onLayout={onLayout}
avoidKeyboard={avoidKeyboard}
>
<View
style={[styles.defaultModalContainer, modalContainerStyle, modalPaddingStyles, !isVisible && styles.pointerEventsNone]}
ref={forwardedRef}
nativeID="no-drag-area"
>
<SafeAreaInsetsContext.Consumer>
{(insets) => {
const {
paddingTop: safeAreaPaddingTop,
paddingBottom: safeAreaPaddingBottom,
paddingLeft: safeAreaPaddingLeft,
paddingRight: safeAreaPaddingRight,
} = StyleUtils.getSafeAreaPadding(insets);

const modalPaddingStyles = StyleUtils.getModalPaddingStyles({
safeAreaPaddingTop,
safeAreaPaddingBottom,
safeAreaPaddingLeft,
safeAreaPaddingRight,
shouldAddBottomSafeAreaMargin,
shouldAddTopSafeAreaMargin,
shouldAddBottomSafeAreaPadding,
shouldAddTopSafeAreaPadding,
modalContainerStyleMarginTop: modalContainerStyle.marginTop,
modalContainerStyleMarginBottom: modalContainerStyle.marginBottom,
modalContainerStylePaddingTop: modalContainerStyle.paddingTop,
modalContainerStylePaddingBottom: modalContainerStyle.paddingBottom,
insets,
});

return (
<View
style={[styles.defaultModalContainer, modalContainerStyle, modalPaddingStyles, !this.props.isVisible ? styles.pointerEventsNone : {}]}
ref={this.props.forwardedRef}
nativeID="no-drag-area"
>
{this.props.children}
</View>
);
}}
</SafeAreaInsetsContext.Consumer>
</ReactNativeModal>
);
}
{children}
</View>
</ReactNativeModal>
);
}

BaseModal.propTypes = propTypes;
BaseModal.defaultProps = defaultProps;
BaseModal.displayName = 'BaseModal';

export default React.forwardRef((props, ref) => (
export default forwardRef((props, ref) => (
<BaseModal
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
Expand Down
Loading