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`] = ` + + + + + `;