fix: prevent screenshot capture in nested modals#4134
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a custom Portal and PortalProvider system to render modal overlays at the root of the view hierarchy, ensuring they are properly covered by native secure-canvas overlays. It replaces standard React Native Modal components with ModalBox and a new CenteredModal component across several pickers and views. The review feedback highlights a few critical improvements: dynamically retrieving screen height in HopPicker to handle orientation changes, wrapping CenteredModal in a <Portal> and ensuring its children are wrapped in a <View> to prevent runtime crashes with TouchableWithoutFeedback, and adding a dependency array to the useEffect hook in Portal.tsx to avoid redundant state updates.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
|
|
||
| const DEFAULT_TITLE = localeString('components.HopPicker.defaultTitle'); | ||
| const MAX_NUMBER_ROUTE_HINTS_LND = 20; | ||
| const { height: SCREEN_HEIGHT } = Dimensions.get('window'); |
There was a problem hiding this comment.
Caching the screen height at the module level using Dimensions.get('window') is a React Native anti-pattern. If the device orientation changes, or if the app is run in split-screen/multi-window mode (common on tablets and foldables), SCREEN_HEIGHT will not be updated, leading to incorrect modal heights and layout issues.
Instead, you should retrieve the window dimensions dynamically inside the render method.
|
|
||
| return ( |
| import * as React from 'react'; | ||
| import { TouchableWithoutFeedback, View } from 'react-native'; | ||
|
|
||
| import ModalBox from '../ModalBox'; | ||
|
|
||
| interface CenteredModalProps { | ||
| isOpen: boolean; | ||
| onClose: () => void; | ||
| children: React.ReactNode; | ||
| } | ||
|
|
||
| const CenteredModal: React.FC<CenteredModalProps> = ({ | ||
| isOpen, | ||
| onClose, | ||
| children | ||
| }) => ( | ||
| <ModalBox | ||
| isOpen={isOpen} | ||
| onClosed={onClose} | ||
| entry="center" | ||
| backdrop | ||
| backdropPressToClose | ||
| backButtonClose | ||
| swipeToClose={false} | ||
| backdropOpacity={0.5} | ||
| style={{ backgroundColor: 'transparent' }} | ||
| > | ||
| <TouchableWithoutFeedback onPress={onClose}> | ||
| <View | ||
| style={{ | ||
| flex: 1, | ||
| justifyContent: 'center', | ||
| alignItems: 'center' | ||
| }} | ||
| > | ||
| <TouchableWithoutFeedback onPress={() => {}}> | ||
| {children} | ||
| </TouchableWithoutFeedback> | ||
| </View> | ||
| </TouchableWithoutFeedback> | ||
| </ModalBox> | ||
| ); | ||
|
|
||
| export default CenteredModal; |
There was a problem hiding this comment.
To ensure that CenteredModal always renders at the root of the view hierarchy and avoids any nested modal or z-index issues (which is the primary goal of this PR), it should be wrapped in the newly introduced <Portal> component, just like HopPicker and UTXOPicker.
Additionally, TouchableWithoutFeedback in React Native expects exactly one child element. If children passed to CenteredModal is a fragment, an array of elements, or null/undefined, the app will crash at runtime with React.Children.only expected to receive a single React element child. Wrapping {children} in a <View> inside the inner TouchableWithoutFeedback prevents this crash.
import * as React from 'react';
import { TouchableWithoutFeedback, View } from 'react-native';
import ModalBox from '../ModalBox';
import { Portal } from '../Portal';
interface CenteredModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
const CenteredModal: React.FC<CenteredModalProps> = ({
isOpen,
onClose,
children
}) => (
<Portal>
<ModalBox
isOpen={isOpen}
onClosed={onClose}
entry="center"
backdrop
backdropPressToClose
backButtonClose
swipeToClose={false}
backdropOpacity={0.5}
style={{ backgroundColor: 'transparent' }}
>
<TouchableWithoutFeedback onPress={onClose}>
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center'
}}
>
<TouchableWithoutFeedback onPress={() => {}}>
<View>{children}</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</ModalBox>
</Portal>
);
export default CenteredModal;
| React.useEffect(() => { | ||
| ctx?.setNode(key, children); | ||
| }); |
There was a problem hiding this comment.
The useEffect hook that registers/updates the portal node has no dependency array, meaning it runs on every single render of the Portal component. If the parent component re-renders frequently (e.g., during animations, text input, or state updates), this will trigger setNode on every render, causing unnecessary state updates in PortalProvider and redundant re-renders of the portal container.
Adding [ctx, key, children] as the dependency array ensures that the portal registration is only updated when the context, key, or children actually change.
| React.useEffect(() => { | |
| ctx?.setNode(key, children); | |
| }); | |
| React.useEffect(() => { | |
| ctx?.setNode(key, children); | |
| }, [ctx, key, children]); |
References
- Avoid redundant state updates. Do not reset a state variable if it has already been set to the desired value by a previous action.
c87eb64 to
767965e
Compare
Description
Based on #4118
This pull request is categorized as a:
Checklist
yarn run tscand made sure my code compiles correctlyyarn run lintand made sure my code didn’t contain any problematic patternsyarn run prettierand made sure my code is formatted correctlyyarn run testand made sure all of the tests passTesting
If you modified or added a utility file, did you add new unit tests?
I have tested this PR on the following platforms (please specify OS version and phone model/VM):
I have tested this PR with the following types of nodes (please specify node version and API version where appropriate):
On-device
Remote
Locales
Third Party Dependencies and Packages
yarnafter this PR is merged inpackage.jsonandyarn.lockhave been properly updatedOther: