From 36a526a34d331c2686da43a861c51d0c8155adc5 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Fri, 17 Apr 2026 09:19:38 -0700 Subject: [PATCH] Add BackHandler to dismiss LogBox toasts on back press (#56474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56474 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 --- .../LogBox/LogBoxInspectorContainer.js | 22 ++ .../LogBox/LogBoxNotificationContainer.js | 27 +- .../Libraries/LogBox/UI/LogBoxButton.js | 71 ++-- .../Libraries/LogBox/UI/LogBoxInspector.js | 5 +- .../LogBox/UI/LogBoxInspectorStackFrame.js | 4 +- .../Libraries/LogBox/UI/LogBoxNotification.js | 2 + .../LogBox/UI/__tests__/LogBoxButton-test.js | 10 +- .../__snapshots__/LogBoxButton-test.js.snap | 26 +- .../LogBoxInspectorStackFrame-test.js.snap | 1 + .../LogBoxInspectorStackFrames-test.js.snap | 40 +++ .../LogBoxInspectorContainer-test.js | 168 ++++----- .../LogBoxNotificationContainer-test.js | 320 +++++++++++++++++- .../LogBoxInspectorContainer-test.js.snap | 185 +--------- .../LogBoxNotificationContainer-test.js.snap | 189 ++++++++++- 14 files changed, 717 insertions(+), 353 deletions(-) diff --git a/packages/react-native/Libraries/LogBox/LogBoxInspectorContainer.js b/packages/react-native/Libraries/LogBox/LogBoxInspectorContainer.js index a28b0fec30c..fc44431fa8e 100644 --- a/packages/react-native/Libraries/LogBox/LogBoxInspectorContainer.js +++ b/packages/react-native/Libraries/LogBox/LogBoxInspectorContainer.js @@ -12,6 +12,7 @@ import type LogBoxLog from './Data/LogBoxLog'; import View from '../Components/View/View'; import StyleSheet from '../StyleSheet/StyleSheet'; +import BackHandler from '../Utilities/BackHandler'; import * as LogBoxData from './Data/LogBoxData'; import LogBoxInspector from './UI/LogBoxInspector'; import * as React from 'react'; @@ -23,6 +24,27 @@ type Props = Readonly<{ }>; export class _LogBoxInspectorContainer extends React.Component { + _backHandler: ?{remove: () => void, ...} = null; + + componentDidMount() { + this._backHandler = BackHandler.addEventListener( + 'hardwareBackPress', + () => { + if (this.props.selectedLogIndex < 0) { + return false; + } + this._handleMinimize(); + return true; + }, + ); + } + + componentWillUnmount() { + if (this._backHandler) { + this._backHandler.remove(); + } + } + render(): React.Node { return ( diff --git a/packages/react-native/Libraries/LogBox/LogBoxNotificationContainer.js b/packages/react-native/Libraries/LogBox/LogBoxNotificationContainer.js index 972df20dd73..33593b2a2af 100644 --- a/packages/react-native/Libraries/LogBox/LogBoxNotificationContainer.js +++ b/packages/react-native/Libraries/LogBox/LogBoxNotificationContainer.js @@ -11,6 +11,7 @@ import SafeAreaView from '../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; import View from '../Components/View/View'; import StyleSheet from '../StyleSheet/StyleSheet'; +import BackHandler from '../Utilities/BackHandler'; import * as LogBoxData from './Data/LogBoxData'; import LogBoxLog from './Data/LogBoxLog'; import LogBoxLogNotification from './UI/LogBoxNotification'; @@ -22,8 +23,28 @@ type Props = Readonly<{ isDisabled?: ?boolean, }>; -export function _LogBoxNotificationContainer(props: Props): React.Node { +function useLogBoxBackHandler(focused: boolean, logCount: number): void { + React.useEffect(() => { + if (!focused || logCount === 0) { + return; + } + const subscription = BackHandler.addEventListener( + 'hardwareBackPress', + () => { + LogBoxData.clearWarnings(); + LogBoxData.clearErrors(); + return true; + }, + ); + return () => subscription.remove(); + }, [focused, logCount]); +} + +export function LogBoxNotificationContainer(props: Props): React.Node { const {logs} = props; + const [focused, setFocused] = React.useState(false); + + useLogBoxBackHandler(focused, logs.length); const onDismissWarns = () => { LogBoxData.clearWarnings(); @@ -68,6 +89,7 @@ export function _LogBoxNotificationContainer(props: Props): React.Node { totalLogCount={warnings.length} onPressOpen={() => openLog(warnings[warnings.length - 1])} onPressDismiss={onDismissWarns} + onFocusChange={setFocused} /> )} @@ -79,6 +101,7 @@ export function _LogBoxNotificationContainer(props: Props): React.Node { totalLogCount={errors.length} onPressOpen={() => openLog(errors[errors.length - 1])} onPressDismiss={onDismissErrors} + onFocusChange={setFocused} /> )} @@ -101,5 +124,5 @@ const styles = StyleSheet.create({ }); export default LogBoxData.withSubscription( - _LogBoxNotificationContainer, + LogBoxNotificationContainer, ) as React.ComponentType<{}>; diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxButton.js b/packages/react-native/Libraries/LogBox/UI/LogBoxButton.js index e897e13d0b9..d86a7290c7f 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxButton.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxButton.js @@ -12,7 +12,7 @@ import type {EdgeInsetsProp} from '../../StyleSheet/EdgeInsetsPropType'; import type {ViewStyleProp} from '../../StyleSheet/StyleSheet'; import type {GestureResponderEvent} from '../../Types/CoreEventTypes'; -import TouchableWithoutFeedback from '../../Components/Touchable/TouchableWithoutFeedback'; +import Pressable from '../../Components/Pressable/Pressable'; import View from '../../Components/View/View'; import StyleSheet from '../../StyleSheet/StyleSheet'; import * as LogBoxStyle from './LogBoxStyle'; @@ -28,9 +28,10 @@ component LogBoxButton( children?: React.Node, hitSlop?: ?EdgeInsetsProp, onPress?: ?(event: GestureResponderEvent) => void, + onFocusChange?: ?(focused: boolean) => void, style?: ViewStyleProp, ) { - const [pressed, setPressed] = useState(false); + const [focused, setFocused] = useState(false); let resolvedBackgroundColor = backgroundColor; if (!resolvedBackgroundColor) { @@ -40,32 +41,54 @@ component LogBoxButton( }; } - const content = ( - - {children} - - ); + if (onPress == null) { + return ( + + {children} + + ); + } - return onPress == null ? ( - content - ) : ( - setPressed(true)} - onPressOut={() => setPressed(false)}> - {content} - + onFocus={() => { + setFocused(true); + onFocusChange?.(true); + }} + onBlur={() => { + setFocused(false); + onFocusChange?.(false); + }} + style={({pressed}) => + StyleSheet.compose( + { + backgroundColor: pressed + ? resolvedBackgroundColor.pressed + : resolvedBackgroundColor.default, + }, + focused ? StyleSheet.compose(style, styles.focusRing) : style, + ) + }> + {children} + ); } +const styles = StyleSheet.create({ + focusRing: { + borderWidth: 2, + borderColor: LogBoxStyle.getTextColor(0.6), + borderRadius: 4, + }, +}); + export default LogBoxButton; diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxInspector.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspector.js index 4c854f539c9..d9db48f89bc 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspector.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspector.js @@ -57,6 +57,9 @@ export default function LogBoxInspector(props: Props): React.Node { }, []); useEffect(() => { + if (log == null) { + return; + } const subscription = BackHandler.addEventListener( 'hardwareBackPress', () => { @@ -65,7 +68,7 @@ export default function LogBoxInspector(props: Props): React.Node { }, ); return () => subscription.remove(); - }, [onMinimize]); + }, [log, onMinimize]); function _handleRetry() { LogBoxData.retrySymbolicateLogNow(log); diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorStackFrame.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorStackFrame.js index 644b28ce371..d33b7bf8ca5 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorStackFrame.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorStackFrame.js @@ -19,6 +19,8 @@ import LogBoxButton from './LogBoxButton'; import * as LogBoxStyle from './LogBoxStyle'; import * as React from 'react'; +const noop = () => {}; + component LogBoxInspectorStackFrame( frame: StackFrame, onPress?: ?(event: GestureResponderEvent) => void, @@ -38,7 +40,7 @@ component LogBoxInspectorStackFrame( default: 'transparent', pressed: onPress ? LogBoxStyle.getBackgroundColor(1) : 'transparent', }} - onPress={onPress} + onPress={onPress ?? noop} style={styles.frame}> void, onPressDismiss: () => void, + onFocusChange?: ?(focused: boolean) => void, }>; export default function LogBoxNotification(props: Props): React.Node { @@ -41,6 +42,7 @@ export default function LogBoxNotification(props: Props): React.Node { ({ +// Mock `Pressable` because we are interested in snapshotting the +// behavior of `LogBoxButton`, not `Pressable`. +jest.mock('../../../Components/Pressable/Pressable', () => ({ __esModule: true, - default: 'TouchableWithoutFeedback', + default: 'Pressable', })); describe('LogBoxButton', () => { @@ -36,7 +36,7 @@ describe('LogBoxButton', () => { expect(output).toMatchSnapshot(); }); - it('should render TouchableWithoutFeedback and pass through props', async () => { + it('should render Pressable and pass through props', async () => { const output = await render.create( - - - Press me - - - + + Press me + + `; exports[`LogBoxButton should render only a view without an onPress 1`] = ` diff --git a/packages/react-native/Libraries/LogBox/UI/__tests__/__snapshots__/LogBoxInspectorStackFrame-test.js.snap b/packages/react-native/Libraries/LogBox/UI/__tests__/__snapshots__/LogBoxInspectorStackFrame-test.js.snap index 33663cbbaa7..57ab537dc8c 100644 --- a/packages/react-native/Libraries/LogBox/UI/__tests__/__snapshots__/LogBoxInspectorStackFrame-test.js.snap +++ b/packages/react-native/Libraries/LogBox/UI/__tests__/__snapshots__/LogBoxInspectorStackFrame-test.js.snap @@ -156,6 +156,7 @@ exports[`LogBoxInspectorStackFrame should render stack frame without press feedb "pressed": "transparent", } } + onPress={[Function]} style={ Object { "borderRadius": 5, diff --git a/packages/react-native/Libraries/LogBox/UI/__tests__/__snapshots__/LogBoxInspectorStackFrames-test.js.snap b/packages/react-native/Libraries/LogBox/UI/__tests__/__snapshots__/LogBoxInspectorStackFrames-test.js.snap index 831c0e692e7..1f2d479e266 100644 --- a/packages/react-native/Libraries/LogBox/UI/__tests__/__snapshots__/LogBoxInspectorStackFrames-test.js.snap +++ b/packages/react-native/Libraries/LogBox/UI/__tests__/__snapshots__/LogBoxInspectorStackFrames-test.js.snap @@ -78,6 +78,35 @@ exports[`LogBoxInspectorStackFrames should render stack frames with 1 frame coll } > ({ +// Mock `LogBoxInspector` because we are interested in snapshotting the behavior +// of `LogBoxInspectorContainer`, not `LogBoxInspector`. +jest.mock('../UI/LogBoxInspector', () => ({ __esModule: true, - default: 'LogBoxLogNotification', + default: 'LogBoxInspector', })); -describe('LogBoxNotificationContainer', () => { - it('should render null with no logs', async () => { - const output = await render.create( - , - ); - - expect(output).toMatchSnapshot(); - }); +jest.mock('../../Utilities/BackHandler', () => + require('../../Utilities/__mocks__/BackHandler'), +); - it('should render null with no selected log and disabled', async () => { - const output = await render.create( - , - ); +describe('LogBoxInspectorContainer', () => { + let output; - expect(output).toMatchSnapshot(); + afterEach(async () => { + if (output) { + await render.unmount(output); + output = null; + } }); - it('should render the latest warning notification', async () => { - const output = await render.create( - { + output = await render.create( + { componentStack: [], }), new LogBoxLog({ - level: 'warn', + level: 'error', isComponentError: false, message: { content: 'Some kind of message (latest)', @@ -91,13 +75,15 @@ describe('LogBoxNotificationContainer', () => { expect(output).toMatchSnapshot(); }); - it('should render the latest error notification', async () => { - const output = await render.create( - { + const spy = jest.spyOn(LogBoxData, 'setSelectedLog'); + + output = await render.create( + { category: 'Some kind of message', componentStack: [], }), - new LogBoxLog({ - level: 'error', - isComponentError: false, - message: { - content: 'Some kind of message (latest)', - substitutions: [], - }, - stack: [], - category: 'Some kind of message (latest)', - componentStack: [], - }), ]} />, ); - expect(output).toMatchSnapshot(); + BackHandler.mockPressBack(); + + expect(spy).toHaveBeenCalledWith(-1); + expect(BackHandler.exitApp).not.toHaveBeenCalled(); + + spy.mockRestore(); }); - it('should render both an error and warning notification', async () => { - const output = await render.create( - { + const spy = jest.spyOn(LogBoxData, 'setSelectedLog'); + + output = await render.create( + { category: 'Some kind of message', componentStack: [], }), - new LogBoxLog({ - level: 'error', - isComponentError: false, - message: { - content: 'Some kind of message (latest)', - substitutions: [], - }, - stack: [], - category: 'Some kind of message (latest)', - componentStack: [], - }), ]} />, ); - expect(output).toMatchSnapshot(); + BackHandler.mockPressBack(); + + expect(spy).not.toHaveBeenCalled(); + + spy.mockRestore(); }); - it('should render selected fatal error even when disabled', async () => { - const output = await render.create( - { + const spy = jest.spyOn(LogBoxData, 'setSelectedLog'); + + output = await render.create( + { />, ); - expect(output).toMatchSnapshot(); - }); + await render.unmount(output); + output = null; - it('should render selected syntax error even when disabled', async () => { - const output = await render.create( - 199 | export default CrashReactApp; - | ^ - 200 |`, - }, - }), - ]} - />, - ); + BackHandler.mockPressBack(); - expect(output).toMatchSnapshot(); + expect(spy).not.toHaveBeenCalled(); + + spy.mockRestore(); }); }); diff --git a/packages/react-native/Libraries/LogBox/__tests__/LogBoxNotificationContainer-test.js b/packages/react-native/Libraries/LogBox/__tests__/LogBoxNotificationContainer-test.js index bd25d66c6d8..16d3292fa2b 100644 --- a/packages/react-native/Libraries/LogBox/__tests__/LogBoxNotificationContainer-test.js +++ b/packages/react-native/Libraries/LogBox/__tests__/LogBoxNotificationContainer-test.js @@ -10,25 +10,137 @@ 'use strict'; +const BackHandler: $FlowFixMe = require('../../Utilities/BackHandler').default; +const LogBoxData = require('../Data/LogBoxData'); const LogBoxLog = require('../Data/LogBoxLog').default; -const { - _LogBoxInspectorContainer: LogBoxInspectorContainer, -} = require('../LogBoxInspectorContainer'); +const {LogBoxNotificationContainer} = require('../LogBoxNotificationContainer'); const render = require('@react-native/jest-preset/jest/renderer'); const React = require('react'); -// Mock `LogBoxInspector` because we are interested in snapshotting the behavior -// of `LogBoxNotificationContainer`, not `LogBoxInspector`. -jest.mock('../UI/LogBoxInspector', () => ({ +// Mock `LogBoxLogNotification` because we are interested in snapshotting the +// behavior of `LogBoxNotificationContainer`, not `LogBoxLogNotification`. +jest.mock('../UI/LogBoxNotification', () => ({ __esModule: true, - default: 'LogBoxInspector', + default: 'LogBoxLogNotification', })); +jest.mock('../../Utilities/BackHandler', () => + require('../../Utilities/__mocks__/BackHandler'), +); + describe('LogBoxNotificationContainer', () => { - it('should render inspector with logs, even when disabled', async () => { - const output = await render.create( - { + if (output) { + await render.unmount(output); + output = null; + } + }); + + it('should render null with no logs', async () => { + output = await render.create( + , + ); + + expect(output).toMatchSnapshot(); + }); + + it('should render null with no selected log and disabled', async () => { + output = await render.create( + , + ); + + expect(output).toMatchSnapshot(); + }); + + it('should render the latest warning notification', async () => { + output = await render.create( + , + ); + + expect(output).toMatchSnapshot(); + }); + + it('should render the latest error notification', async () => { + output = await render.create( + , + ); + + expect(output).toMatchSnapshot(); + }); + + it('should render both an error and warning notification', async () => { + output = await render.create( + { expect(output).toMatchSnapshot(); }); + + it('should render selected fatal error even when disabled', async () => { + output = await render.create( + , + ); + + expect(output).toMatchSnapshot(); + }); + + it('should render selected syntax error even when disabled', async () => { + output = await render.create( + 199 | export default CrashReactApp; + | ^ + 200 |`, + }, + }), + ]} + />, + ); + + expect(output).toMatchSnapshot(); + }); + + it('should clear warnings and errors on back press when focused', async () => { + const clearWarningsSpy = jest.spyOn(LogBoxData, 'clearWarnings'); + const clearErrorsSpy = jest.spyOn(LogBoxData, 'clearErrors'); + + output = await render.create( + , + ); + + // Simulate focus on the notification toast + const notification = output.root.findByProps({level: 'warn'}); + await require('react-test-renderer').act(() => { + notification.props.onFocusChange(true); + }); + + BackHandler.mockPressBack(); + + expect(clearWarningsSpy).toHaveBeenCalled(); + expect(clearErrorsSpy).toHaveBeenCalled(); + expect(BackHandler.exitApp).not.toHaveBeenCalled(); + + clearWarningsSpy.mockRestore(); + clearErrorsSpy.mockRestore(); + }); + + it('should not intercept back press when not focused', async () => { + const clearWarningsSpy = jest.spyOn(LogBoxData, 'clearWarnings'); + const clearErrorsSpy = jest.spyOn(LogBoxData, 'clearErrors'); + + output = await render.create( + , + ); + + BackHandler.mockPressBack(); + + expect(clearWarningsSpy).not.toHaveBeenCalled(); + expect(clearErrorsSpy).not.toHaveBeenCalled(); + + clearWarningsSpy.mockRestore(); + clearErrorsSpy.mockRestore(); + }); + + it('should not intercept back press when no logs exist', async () => { + const clearWarningsSpy = jest.spyOn(LogBoxData, 'clearWarnings'); + const clearErrorsSpy = jest.spyOn(LogBoxData, 'clearErrors'); + + output = await render.create( + , + ); + + BackHandler.mockPressBack(); + + expect(clearWarningsSpy).not.toHaveBeenCalled(); + expect(clearErrorsSpy).not.toHaveBeenCalled(); + + clearWarningsSpy.mockRestore(); + clearErrorsSpy.mockRestore(); + }); + + it('should remove back handler on unmount', async () => { + const clearWarningsSpy = jest.spyOn(LogBoxData, 'clearWarnings'); + const clearErrorsSpy = jest.spyOn(LogBoxData, 'clearErrors'); + + output = await render.create( + , + ); + + // Focus then unmount + const notification = output.root.findByProps({level: 'warn'}); + await require('react-test-renderer').act(() => { + notification.props.onFocusChange(true); + }); + + if (output != null) { + await render.unmount(output); + output = null; + } + + BackHandler.mockPressBack(); + + expect(clearWarningsSpy).not.toHaveBeenCalled(); + expect(clearErrorsSpy).not.toHaveBeenCalled(); + + clearWarningsSpy.mockRestore(); + clearErrorsSpy.mockRestore(); + }); }); diff --git a/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxInspectorContainer-test.js.snap b/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxInspectorContainer-test.js.snap index a7717078b1d..54ef5817ff1 100644 --- a/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxInspectorContainer-test.js.snap +++ b/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxInspectorContainer-test.js.snap @@ -1,28 +1,20 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`LogBoxNotificationContainer should render both an error and warning notification 1`] = ` - - - - - - - - -`; - -exports[`LogBoxNotificationContainer should render null with no logs 1`] = `null`; - -exports[`LogBoxNotificationContainer should render null with no selected log and disabled 1`] = `null`; - -exports[`LogBoxNotificationContainer should render selected fatal error even when disabled 1`] = `null`; - -exports[`LogBoxNotificationContainer should render selected syntax error even when disabled 1`] = `null`; - -exports[`LogBoxNotificationContainer should render the latest error notification 1`] = ` - - - - - -`; - -exports[`LogBoxNotificationContainer should render the latest warning notification 1`] = ` - - - - - + onChangeSelectedIndex={[Function]} + onDismiss={[Function]} + onMinimize={[Function]} + selectedIndex={-1} + /> + `; diff --git a/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxNotificationContainer-test.js.snap b/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxNotificationContainer-test.js.snap index 3f28e4a45d9..82a662152af 100644 --- a/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxNotificationContainer-test.js.snap +++ b/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxNotificationContainer-test.js.snap @@ -1,20 +1,28 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`LogBoxNotificationContainer should render inspector with logs, even when disabled 1`] = ` - - + + + + + + +`; + +exports[`LogBoxNotificationContainer should render null with no logs 1`] = `null`; + +exports[`LogBoxNotificationContainer should render null with no selected log and disabled 1`] = `null`; + +exports[`LogBoxNotificationContainer should render selected fatal error even when disabled 1`] = `null`; + +exports[`LogBoxNotificationContainer should render selected syntax error even when disabled 1`] = `null`; + +exports[`LogBoxNotificationContainer should render the latest error notification 1`] = ` + - + } +> + + + + +`; + +exports[`LogBoxNotificationContainer should render the latest warning notification 1`] = ` + + + + + `;