Skip to content

Commit 88c77b3

Browse files
Abbondanzofacebook-github-bot
authored andcommitted
Add BackHandler to dismiss LogBox toasts on back press (#56474)
Summary: On Android, pressing the hardware back button while LogBox notification toasts or the full inspector overlay are visible has no effect on the JS side. The only way to dismiss notifications is the on-screen X button, and the only way to close the inspector is via Minimize/Dismiss. This adds `BackHandler` listeners to both JS containers: **Notification toasts**: A new `LogBoxNotificationBackHandler` component mounts alongside the toasts and registers a `hardwareBackPress` listener that calls `clearWarnings()` + `clearErrors()`, equivalent to pressing X on every visible toast. The component returns null and auto-cleans the listener on unmount. **Inspector overlay**: `LogBoxInspectorContainer` registers a `hardwareBackPress` listener in `componentDidMount` that calls `_handleMinimize()` (`setSelectedLog(-1)`), closing the overlay non-destructively — same as pressing the Minimize button. Changelog: [Android][Added] - Allow LogBox notification toasts and inspector overlay to be dismissed via Android back button Differential Revision: D101178179
1 parent f8fa76f commit 88c77b3

6 files changed

Lines changed: 558 additions & 303 deletions

File tree

packages/react-native/Libraries/LogBox/LogBoxInspectorContainer.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type LogBoxLog from './Data/LogBoxLog';
1212

1313
import View from '../Components/View/View';
1414
import StyleSheet from '../StyleSheet/StyleSheet';
15+
import BackHandler from '../Utilities/BackHandler';
1516
import * as LogBoxData from './Data/LogBoxData';
1617
import LogBoxInspector from './UI/LogBoxInspector';
1718
import * as React from 'react';
@@ -23,6 +24,27 @@ type Props = Readonly<{
2324
}>;
2425

2526
export class _LogBoxInspectorContainer extends React.Component<Props> {
27+
_backHandler: ?{remove: () => void, ...} = null;
28+
29+
componentDidMount() {
30+
this._backHandler = BackHandler.addEventListener(
31+
'hardwareBackPress',
32+
() => {
33+
if (this.props.selectedLogIndex < 0) {
34+
return false;
35+
}
36+
this._handleMinimize();
37+
return true;
38+
},
39+
);
40+
}
41+
42+
componentWillUnmount() {
43+
if (this._backHandler) {
44+
this._backHandler.remove();
45+
}
46+
}
47+
2648
render(): React.Node {
2749
return (
2850
<View style={StyleSheet.absoluteFill}>

packages/react-native/Libraries/LogBox/LogBoxNotificationContainer.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import SafeAreaView from '../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE';
1212
import View from '../Components/View/View';
1313
import StyleSheet from '../StyleSheet/StyleSheet';
14+
import BackHandler from '../Utilities/BackHandler';
1415
import * as LogBoxData from './Data/LogBoxData';
1516
import LogBoxLog from './Data/LogBoxLog';
1617
import LogBoxLogNotification from './UI/LogBoxNotification';
@@ -22,6 +23,31 @@ type Props = Readonly<{
2223
isDisabled?: ?boolean,
2324
}>;
2425

26+
function LogBoxNotificationBackHandler({
27+
logs,
28+
}: {
29+
logs: ReadonlyArray<LogBoxLog>,
30+
}): React.Node {
31+
const logsRef = React.useRef(logs);
32+
logsRef.current = logs;
33+
34+
React.useEffect(() => {
35+
const subscription = BackHandler.addEventListener(
36+
'hardwareBackPress',
37+
() => {
38+
if (logsRef.current.length === 0) {
39+
return false;
40+
}
41+
LogBoxData.clearWarnings();
42+
LogBoxData.clearErrors();
43+
return true;
44+
},
45+
);
46+
return () => subscription.remove();
47+
}, []);
48+
return null;
49+
}
50+
2551
export function _LogBoxNotificationContainer(props: Props): React.Node {
2652
const {logs} = props;
2753

@@ -60,6 +86,7 @@ export function _LogBoxNotificationContainer(props: Props): React.Node {
6086
);
6187
return (
6288
<SafeAreaView style={styles.list}>
89+
<LogBoxNotificationBackHandler logs={logs} />
6390
{warnings.length > 0 && (
6491
<View style={styles.toast}>
6592
<LogBoxLogNotification

packages/react-native/Libraries/LogBox/__tests__/LogBoxInspectorContainer-test.js

Lines changed: 59 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -10,56 +10,40 @@
1010

1111
'use strict';
1212

13+
const BackHandler = require('../../Utilities/BackHandler').default;
14+
const LogBoxData = require('../Data/LogBoxData');
1315
const LogBoxLog = require('../Data/LogBoxLog').default;
1416
const {
15-
_LogBoxNotificationContainer: LogBoxNotificationContainer,
16-
} = require('../LogBoxNotificationContainer');
17+
_LogBoxInspectorContainer: LogBoxInspectorContainer,
18+
} = require('../LogBoxInspectorContainer');
1719
const render = require('@react-native/jest-preset/jest/renderer');
1820
const React = require('react');
1921

20-
// Mock `LogBoxLogNotification` because we are interested in snapshotting the
21-
// behavior of `LogBoxNotificationContainer`, not `LogBoxLogNotification`.
22-
jest.mock('../UI/LogBoxNotification', () => ({
22+
// Mock `LogBoxInspector` because we are interested in snapshotting the behavior
23+
// of `LogBoxInspectorContainer`, not `LogBoxInspector`.
24+
jest.mock('../UI/LogBoxInspector', () => ({
2325
__esModule: true,
24-
default: 'LogBoxLogNotification',
26+
default: 'LogBoxInspector',
2527
}));
2628

27-
describe('LogBoxNotificationContainer', () => {
28-
it('should render null with no logs', async () => {
29-
const output = await render.create(
30-
<LogBoxNotificationContainer selectedLogIndex={-1} logs={[]} />,
31-
);
32-
33-
expect(output).toMatchSnapshot();
34-
});
29+
jest.mock('../../Utilities/BackHandler', () =>
30+
require('../../Utilities/__mocks__/BackHandler'),
31+
);
3532

36-
it('should render null with no selected log and disabled', async () => {
37-
const output = await render.create(
38-
<LogBoxNotificationContainer
39-
isDisabled
40-
selectedLogIndex={-1}
41-
logs={[
42-
new LogBoxLog({
43-
level: 'warn',
44-
isComponentError: false,
45-
message: {
46-
content: 'Some kind of message',
47-
substitutions: [],
48-
},
49-
stack: [],
50-
category: 'Some kind of message',
51-
componentStack: [],
52-
}),
53-
]}
54-
/>,
55-
);
33+
describe('LogBoxInspectorContainer', () => {
34+
let output;
5635

57-
expect(output).toMatchSnapshot();
36+
afterEach(async () => {
37+
if (output) {
38+
await render.unmount(output);
39+
output = null;
40+
}
5841
});
5942

60-
it('should render the latest warning notification', async () => {
61-
const output = await render.create(
62-
<LogBoxNotificationContainer
43+
it('should render inspector with logs, even when disabled', async () => {
44+
output = await render.create(
45+
<LogBoxInspectorContainer
46+
isDisabled
6347
selectedLogIndex={-1}
6448
logs={[
6549
new LogBoxLog({
@@ -74,7 +58,7 @@ describe('LogBoxNotificationContainer', () => {
7458
componentStack: [],
7559
}),
7660
new LogBoxLog({
77-
level: 'warn',
61+
level: 'error',
7862
isComponentError: false,
7963
message: {
8064
content: 'Some kind of message (latest)',
@@ -91,13 +75,15 @@ describe('LogBoxNotificationContainer', () => {
9175
expect(output).toMatchSnapshot();
9276
});
9377

94-
it('should render the latest error notification', async () => {
95-
const output = await render.create(
96-
<LogBoxNotificationContainer
97-
selectedLogIndex={-1}
78+
it('should minimize inspector on back press when a log is selected', async () => {
79+
const spy = jest.spyOn(LogBoxData, 'setSelectedLog');
80+
81+
output = await render.create(
82+
<LogBoxInspectorContainer
83+
selectedLogIndex={0}
9884
logs={[
9985
new LogBoxLog({
100-
level: 'error',
86+
level: 'warn',
10187
isComponentError: false,
10288
message: {
10389
content: 'Some kind of message',
@@ -107,27 +93,23 @@ describe('LogBoxNotificationContainer', () => {
10793
category: 'Some kind of message',
10894
componentStack: [],
10995
}),
110-
new LogBoxLog({
111-
level: 'error',
112-
isComponentError: false,
113-
message: {
114-
content: 'Some kind of message (latest)',
115-
substitutions: [],
116-
},
117-
stack: [],
118-
category: 'Some kind of message (latest)',
119-
componentStack: [],
120-
}),
12196
]}
12297
/>,
12398
);
12499

125-
expect(output).toMatchSnapshot();
100+
BackHandler.mockPressBack();
101+
102+
expect(spy).toHaveBeenCalledWith(-1);
103+
expect(BackHandler.exitApp).not.toHaveBeenCalled();
104+
105+
spy.mockRestore();
126106
});
127107

128-
it('should render both an error and warning notification', async () => {
129-
const output = await render.create(
130-
<LogBoxNotificationContainer
108+
it('should not intercept back press when no log is selected', async () => {
109+
const spy = jest.spyOn(LogBoxData, 'setSelectedLog');
110+
111+
output = await render.create(
112+
<LogBoxInspectorContainer
131113
selectedLogIndex={-1}
132114
logs={[
133115
new LogBoxLog({
@@ -141,35 +123,29 @@ describe('LogBoxNotificationContainer', () => {
141123
category: 'Some kind of message',
142124
componentStack: [],
143125
}),
144-
new LogBoxLog({
145-
level: 'error',
146-
isComponentError: false,
147-
message: {
148-
content: 'Some kind of message (latest)',
149-
substitutions: [],
150-
},
151-
stack: [],
152-
category: 'Some kind of message (latest)',
153-
componentStack: [],
154-
}),
155126
]}
156127
/>,
157128
);
158129

159-
expect(output).toMatchSnapshot();
130+
BackHandler.mockPressBack();
131+
132+
expect(spy).not.toHaveBeenCalled();
133+
134+
spy.mockRestore();
160135
});
161136

162-
it('should render selected fatal error even when disabled', async () => {
163-
const output = await render.create(
164-
<LogBoxNotificationContainer
165-
isDisabled
137+
it('should remove back handler on unmount', async () => {
138+
const spy = jest.spyOn(LogBoxData, 'setSelectedLog');
139+
140+
output = await render.create(
141+
<LogBoxInspectorContainer
166142
selectedLogIndex={0}
167143
logs={[
168144
new LogBoxLog({
169-
level: 'fatal',
145+
level: 'warn',
170146
isComponentError: false,
171147
message: {
172-
content: 'Should be selected',
148+
content: 'Some kind of message',
173149
substitutions: [],
174150
},
175151
stack: [],
@@ -180,39 +156,13 @@ describe('LogBoxNotificationContainer', () => {
180156
/>,
181157
);
182158

183-
expect(output).toMatchSnapshot();
184-
});
159+
await render.unmount(output);
160+
output = null;
185161

186-
it('should render selected syntax error even when disabled', async () => {
187-
const output = await render.create(
188-
<LogBoxNotificationContainer
189-
isDisabled
190-
selectedLogIndex={0}
191-
logs={[
192-
new LogBoxLog({
193-
level: 'syntax',
194-
isComponentError: false,
195-
message: {
196-
content: 'Should be selected',
197-
substitutions: [],
198-
},
199-
stack: [],
200-
category: 'Some kind of syntax error message',
201-
componentStack: [],
202-
codeFrame: {
203-
fileName: '/path/to/RKJSModules/Apps/CrashReact/CrashReactApp.js',
204-
location: {row: 199, column: 0},
205-
content: ` 197 | });
206-
198 |
207-
> 199 | export default CrashReactApp;
208-
| ^
209-
200 |`,
210-
},
211-
}),
212-
]}
213-
/>,
214-
);
162+
BackHandler.mockPressBack();
215163

216-
expect(output).toMatchSnapshot();
164+
expect(spy).not.toHaveBeenCalled();
165+
166+
spy.mockRestore();
217167
});
218168
});

0 commit comments

Comments
 (0)