Skip to content

Commit ee27c28

Browse files
fix: make in-app webview back button tappable on iOS (#29693)
## **Description** After the React Navigation v5 → v6 migration (#26691) and the follow-up iOS overlap fix (#29020), the back arrow in the in-app `SimpleWebview` (used e.g. when tapping "View on block explorer" after a swap) renders behind the iOS status bar / Dynamic Island and is unclickable, leaving the user trapped on the screen. Root cause: - `SimpleWebview` was injecting its header through `navigation.setOptions({ header: () => <HeaderCompactStandard …/> })`. - In `@react-navigation/stack` v6, custom `header` functions do **not** receive automatic `paddingTop: headerStatusBarHeight` (see `node_modules/@react-navigation/stack/src/views/Header/HeaderContainer.tsx`); the custom header must own its safe-area inset. - The previous iOS overlap fix worked around that by setting `includesTopInset: Device.isAndroid()`, which on iOS resulted in **no top safe-area inset at all**, so the back button rendered at `y = 0` underneath the status bar. Solution (matches the pattern already used by `WebviewModal.tsx` in Deposit): - Render `HeaderCompactStandard` directly inside `SimpleWebview` with `includesTopInset` always on, so `useSafeAreaInsets()` (already used by `HeaderBase`) drives the top padding consistently on both platforms. - Hide the inner `Stack.Navigator` headers in both webview wrappers (`MainNavigator.js` and `App.tsx`) via `screenOptions={{ headerShown: false }}` so we don't double-render headers. - Remove the brittle `setOptions` custom-header wiring; this also removes the need for the `Device.isAndroid()` platform branch and the `rounded-t-2xl` styling that was masking the issue. The navigation contract used by callers (`navigation.navigate(Routes.WEBVIEW.MAIN, { screen: Routes.WEBVIEW.SIMPLE, params: { url } })`) is unchanged, so `BridgeTransactionDetails`, `BlockExplorersModal`, and other consumers keep working as-is. Longer-term, the team has discussed routing all external links through a single service that selects between WebView and `inAppReborn` (Pedro / Joao in the originating Slack thread); that refactor is intentionally out of scope here — this PR just unblocks the user. ## **Changelog** CHANGELOG entry: Fixed an iOS bug where the back button in the in-app webview (e.g. "View on block explorer") was rendered behind the status bar and could not be tapped. ## **Related issues** Fixes: No issue — bug surfaced in the `#metamask-core-mobile-ux` Slack thread on 2026-04-28 (browser back button unclickable after swap → "View on block explorer"). Andre Alves asked for a board entry but a Jira ticket had not been created at the time this fix was opened. ## **Manual testing steps** \`\`\`gherkin Feature: In-app webview back button is reachable on iOS Scenario: User exits the in-app webview opened from a swap transaction Given the user has completed a swap on iOS And the user is on the swap transaction details screen When the user taps "View on block explorer" Then the in-app webview opens with the block explorer page And the header is rendered fully below the status bar / Dynamic Island And the back arrow in the top-left of the header is tappable When the user taps the back arrow Then the user returns to the previous screen Scenario: Header still renders correctly on Android Given the user is on Android When the user opens any flow that pushes Routes.WEBVIEW.MAIN / Routes.WEBVIEW.SIMPLE Then the header is rendered with the correct top safe-area inset And the share button on the right works And the back button on the left works Scenario: Header still renders correctly when the webview is presented as a modal during onboarding Given the user is in the onboarding flow on iOS When a screen pushes Routes.WEBVIEW.MAIN (modal presentation) Then the in-app webview opens with the header below the status bar And the back arrow is tappable and returns to the onboarding screen \`\`\` ## **Screenshots/Recordings** https://github.com/user-attachments/assets/78c9e271-6e58-4b9f-862a-b6b123df2915 ### **Before** iOS: back arrow rendered behind the status bar / Dynamic Island and not tappable — user is trapped on the webview screen (see Slack screenshot from Heyse Li, 2026-04-28). ### **After** iOS: header renders below the status bar with proper safe-area inset; back arrow is fully visible and tappable. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [\`trace()\`](/app/util/trace.ts) for usage and [\`addToken\`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. Made with [Cursor](https://cursor.com) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk UI/navigation change that primarily adjusts header rendering for `SimpleWebview`; main risk is unintended header/spacing differences or missing headers in the webview stacks. > > **Overview** > Fixes an iOS layout regression where the in-app `SimpleWebview` back button could be obscured by the status bar/Dynamic Island by **rendering `HeaderCompactStandard` directly inside `SimpleWebview`** (always `includesTopInset`) instead of injecting a custom header via `navigation.setOptions()`. > > Updates the webview stack wrappers in `MainNavigator` and `App` to **hide the React Navigation header** (`screenOptions={{ headerShown: false }}`) to avoid double headers, and adjusts tests to validate the new header rendering and callbacks (back + share). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 972444a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Andre Pimenta <andrepimenta7@gmail.com>
1 parent 0b8d638 commit ee27c28

4 files changed

Lines changed: 38 additions & 76 deletions

File tree

app/components/Nav/App/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ const OnboardingNav = () => {
318318
* child OnboardingNav navigator to push modals on top of it
319319
*/
320320
const SimpleWebviewScreen = () => (
321-
<Stack.Navigator>
321+
<Stack.Navigator screenOptions={{ headerShown: false }}>
322322
<Stack.Screen name={Routes.WEBVIEW.SIMPLE} component={SimpleWebview} />
323323
</Stack.Navigator>
324324
);

app/components/Nav/Main/MainNavigator.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -891,7 +891,7 @@ const HomeTabs = () => {
891891
};
892892

893893
const Webview = () => (
894-
<Stack.Navigator>
894+
<Stack.Navigator screenOptions={{ headerShown: false }}>
895895
<Stack.Screen name="SimpleWebview" component={SimpleWebview} />
896896
</Stack.Navigator>
897897
);

app/components/Views/SimpleWebview/index.test.tsx

Lines changed: 19 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,20 @@ import SimpleWebview from './';
44
import { useNavigation } from '@react-navigation/native';
55
import Share from 'react-native-share';
66
import Logger from '../../../util/Logger';
7-
import getHeaderCompactStandardNavbarOptions from '../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions';
8-
import Device from '../../../util/device';
7+
import HeaderCompactStandard from '../../../component-library/components-temp/HeaderCompactStandard';
98

109
jest.mock(
11-
'../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions',
10+
'../../../component-library/components-temp/HeaderCompactStandard',
1211
() => ({
1312
__esModule: true,
14-
default: jest.fn(() => ({
15-
header: () => null,
16-
})),
13+
default: jest.fn(() => null),
1714
}),
1815
);
1916

20-
jest.mock('../../../util/device', () => ({
21-
__esModule: true,
22-
default: {
23-
isAndroid: jest.fn(() => false),
24-
},
25-
}));
26-
27-
const mockSetOptions = jest.fn();
17+
const mockGoBack = jest.fn();
2818
const mockNavigation = {
29-
goBack: jest.fn(),
30-
setOptions: mockSetOptions,
19+
goBack: mockGoBack,
20+
setOptions: jest.fn(),
3121
};
3222

3323
jest.mock('@react-navigation/native', () => ({
@@ -48,64 +38,38 @@ describe('SimpleWebview', () => {
4838
(Share.open as jest.Mock).mockImplementation(() => Promise.resolve());
4939
});
5040

51-
it('renders correctly', () => {
41+
it('renders the HeaderCompactStandard with safe area top inset', () => {
5242
render(<SimpleWebview />);
5343

54-
expect(getHeaderCompactStandardNavbarOptions).toHaveBeenCalled();
55-
});
56-
57-
it('sets header options from HeaderCompactStandard and Device.isAndroid() for includesTopInset', () => {
58-
render(<SimpleWebview />);
59-
60-
expect(getHeaderCompactStandardNavbarOptions).toHaveBeenCalledWith(
44+
expect(HeaderCompactStandard).toHaveBeenCalledWith(
6145
expect.objectContaining({
6246
title: '',
63-
includesTopInset: false,
64-
twClassName: 'bg-default rounded-t-2xl',
47+
includesTopInset: true,
6548
onBack: expect.any(Function),
6649
endButtonIconProps: expect.arrayContaining([
6750
expect.objectContaining({ onPress: expect.any(Function) }),
6851
]),
6952
}),
70-
);
71-
expect(mockSetOptions).toHaveBeenCalledWith(
72-
expect.objectContaining({
73-
header: expect.any(Function),
74-
}),
75-
);
76-
expect(mockSetOptions.mock.calls[0][0]).not.toHaveProperty('headerStyle');
77-
});
78-
79-
it('passes includesTopInset true when Device.isAndroid() is true', () => {
80-
jest.mocked(Device.isAndroid).mockReturnValueOnce(true);
81-
render(<SimpleWebview />);
82-
83-
expect(getHeaderCompactStandardNavbarOptions).toHaveBeenCalledWith(
84-
expect.objectContaining({
85-
includesTopInset: true,
86-
}),
53+
expect.anything(),
8754
);
8855
});
8956

9057
it('calls navigation.goBack when header onBack is invoked', () => {
9158
render(<SimpleWebview />);
9259

93-
const { onBack } = (getHeaderCompactStandardNavbarOptions as jest.Mock).mock
60+
const headerProps = (HeaderCompactStandard as unknown as jest.Mock).mock
9461
.calls[0][0] as { onBack: () => void };
95-
onBack();
62+
headerProps.onBack();
9663

97-
expect(mockNavigation.goBack).toHaveBeenCalled();
64+
expect(mockGoBack).toHaveBeenCalled();
9865
});
9966

10067
it('calls Share.open when share button onPress is invoked', () => {
10168
render(<SimpleWebview />);
10269

103-
const { endButtonIconProps } = (
104-
getHeaderCompactStandardNavbarOptions as jest.Mock
105-
).mock.calls[0][0] as {
106-
endButtonIconProps: { onPress: () => void }[];
107-
};
108-
endButtonIconProps[0].onPress();
70+
const headerProps = (HeaderCompactStandard as unknown as jest.Mock).mock
71+
.calls[0][0] as { endButtonIconProps: { onPress: () => void }[] };
72+
headerProps.endButtonIconProps[0].onPress();
10973

11074
expect(Share.open).toHaveBeenCalledWith({ url: 'https://etherscan.io' });
11175
});
@@ -116,12 +80,9 @@ describe('SimpleWebview', () => {
11680

11781
render(<SimpleWebview />);
11882

119-
const { endButtonIconProps } = (
120-
getHeaderCompactStandardNavbarOptions as jest.Mock
121-
).mock.calls[0][0] as {
122-
endButtonIconProps: { onPress: () => void }[];
123-
};
124-
endButtonIconProps[0].onPress();
83+
const headerProps = (HeaderCompactStandard as unknown as jest.Mock).mock
84+
.calls[0][0] as { endButtonIconProps: { onPress: () => void }[] };
85+
headerProps.endButtonIconProps[0].onPress();
12586

12687
await waitFor(() => {
12788
expect(log).toHaveBeenCalledWith(

app/components/Views/SimpleWebview/index.tsx

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
/* eslint-disable @typescript-eslint/consistent-type-definitions */
2-
import React, { useCallback, useEffect } from 'react';
2+
import React, { useCallback } from 'react';
3+
import { View } from 'react-native';
34
import { WebView } from '@metamask/react-native-webview';
4-
import getHeaderCompactStandardNavbarOptions from '../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions';
5+
import HeaderCompactStandard from '../../../component-library/components-temp/HeaderCompactStandard';
56
import { IconName } from '@metamask/design-system-react-native';
7+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
68
import Share from 'react-native-share'; // eslint-disable-line import-x/default
79
import Logger from '../../../util/Logger';
810
import { baseStyles } from '../../../styles/common';
9-
import Device from '../../../util/device';
1011
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
1112

1213
// TODO: This will be replaced with the actual route params type once navigation is refactored
1314
type RouteParams = {
1415
SimpleWebView: {
1516
url: string;
17+
title?: string;
1618
};
1719
};
1820

1921
const SimpleWebView = () => {
22+
const tw = useTailwind();
2023
const navigation = useNavigation();
2124
const route = useRoute<RouteProp<RouteParams, 'SimpleWebView'>>();
2225
const url = route.params.url;
@@ -32,19 +35,17 @@ const SimpleWebView = () => {
3235
}
3336
}, [url]);
3437

35-
useEffect(() => {
36-
navigation.setOptions({
37-
...getHeaderCompactStandardNavbarOptions({
38-
title,
39-
onBack: () => navigation.goBack(),
40-
includesTopInset: Device.isAndroid(),
41-
twClassName: 'bg-default rounded-t-2xl',
42-
endButtonIconProps: [{ iconName: IconName.Share, onPress: share }],
43-
}),
44-
});
45-
}, [navigation, share, title]);
46-
47-
return <WebView containerStyle={baseStyles.flexGrow} source={{ uri: url }} />;
38+
return (
39+
<View style={tw.style('flex-1 bg-default')}>
40+
<HeaderCompactStandard
41+
title={title}
42+
onBack={() => navigation.goBack()}
43+
includesTopInset
44+
endButtonIconProps={[{ iconName: IconName.Share, onPress: share }]}
45+
/>
46+
<WebView containerStyle={baseStyles.flexGrow} source={{ uri: url }} />
47+
</View>
48+
);
4849
};
4950

5051
export default SimpleWebView;

0 commit comments

Comments
 (0)