diff --git a/packages/react-native/Libraries/Pressability/PressabilityDebug.js b/packages/react-native/Libraries/Pressability/PressabilityDebug.js index 90b83e49e51c..842844133f4d 100644 --- a/packages/react-native/Libraries/Pressability/PressabilityDebug.js +++ b/packages/react-native/Libraries/Pressability/PressabilityDebug.js @@ -36,8 +36,9 @@ type Props = Readonly<{ * */ export function PressabilityDebugView(props: Props): React.Node { + const enabled = useIsEnabled(); if (__DEV__) { - if (isEnabled()) { + if (enabled) { const normalizedColor = normalizeColor(props.color); if (typeof normalizedColor !== 'number') { return null; @@ -70,6 +71,7 @@ export function PressabilityDebugView(props: Props): React.Node { } let isDebugEnabled = false; +const listeners: Set<() => void> = new Set(); export function isEnabled(): boolean { if (__DEV__) { @@ -80,6 +82,30 @@ export function isEnabled(): boolean { export function setEnabled(value: boolean): void { if (__DEV__) { + if (isDebugEnabled === value) { + return; + } isDebugEnabled = value; + listeners.forEach(listener => listener()); } } + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function useIsEnabledDev(): boolean { + return React.useSyncExternalStore(subscribe, isEnabled); +} + +/** + * Like `isEnabled`, but re-renders the calling component when the value + * changes, so toggling it does not require remounting the app. Outside of + * `__DEV__` it always returns `false` and uses no hooks. + */ +export const useIsEnabled: () => boolean = __DEV__ + ? useIsEnabledDev + : () => false; diff --git a/packages/react-native/Libraries/Pressability/__tests__/PressabilityDebug-itest.js b/packages/react-native/Libraries/Pressability/__tests__/PressabilityDebug-itest.js new file mode 100644 index 000000000000..25bc6a3aa1e5 --- /dev/null +++ b/packages/react-native/Libraries/Pressability/__tests__/PressabilityDebug-itest.js @@ -0,0 +1,137 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import * as PressabilityDebug from '../PressabilityDebug'; +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {useEffect} from 'react'; +import {Pressable, Text} from 'react-native'; + +describe('PressabilityDebug', () => { + beforeEach(() => { + Fantom.runTask(() => { + PressabilityDebug.setEnabled(false); + }); + }); + + afterEach(() => { + Fantom.runTask(() => { + PressabilityDebug.setEnabled(false); + }); + }); + + it('shows press targets when enabled, without remounting', () => { + const root = Fantom.createRoot(); + let mountCount = 0; + + function Screen() { + useEffect(() => { + mountCount++; + }, []); + return ; + } + + Fantom.runTask(() => { + root.render(); + }); + + expect(root.getRenderedOutput({props: []}).toJSX()).toEqual(); + + Fantom.runTask(() => { + PressabilityDebug.setEnabled(true); + }); + + expect(root.getRenderedOutput({props: []}).toJSX()).toEqual( + + + , + ); + + Fantom.runTask(() => { + PressabilityDebug.setEnabled(false); + }); + + expect(root.getRenderedOutput({props: []}).toJSX()).toEqual(); + expect(mountCount).toBe(1); + }); + + it('colors pressable text when enabled', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( {}}>text); + }); + + expect( + root.getRenderedOutput({props: ['foregroundColor']}).toJSX(), + ).toEqual( + text, + ); + + Fantom.runTask(() => { + PressabilityDebug.setEnabled(true); + }); + + expect( + root.getRenderedOutput({props: ['foregroundColor']}).toJSX(), + ).toEqual( + text, + ); + }); + + it('colors nested pressable text when enabled', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + {}}>nested + , + ); + }); + + Fantom.runTask(() => { + PressabilityDebug.setEnabled(true); + }); + + expect( + root.getRenderedOutput({props: ['foregroundColor']}).toJSX(), + ).toEqual( + + nested + , + ); + }); + + it('does not color disabled text', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + PressabilityDebug.setEnabled(true); + }); + + Fantom.runTask(() => { + root.render( + {}}> + text + , + ); + }); + + expect( + root.getRenderedOutput({props: ['foregroundColor']}).toJSX(), + ).toEqual( + text, + ); + }); +}); diff --git a/packages/react-native/Libraries/ReactNative/AppContainer-dev.js b/packages/react-native/Libraries/ReactNative/AppContainer-dev.js index a0abc38caec4..a9457cf09b36 100644 --- a/packages/react-native/Libraries/ReactNative/AppContainer-dev.js +++ b/packages/react-native/Libraries/ReactNative/AppContainer-dev.js @@ -27,7 +27,7 @@ import {RootTagContext, createRootTag} from './RootTag'; import * as React from 'react'; import {useRef} from 'react'; -const {useEffect, useState, useCallback} = React; +const {useEffect, useState} = React; const reactDevToolsHook: ReactDevToolsGlobalHook = (window as $FlowFixMe) .__REACT_DEVTOOLS_GLOBAL_HOOK__; @@ -51,7 +51,6 @@ type ExternalInspection = { type InspectorDeferredProps = { inspectedViewRef: InspectedViewRef, - onInspectedViewRerenderRequest: () => void, reactDevToolsAgent?: ReactDevToolsAgent, devMenuInspectorOpen: boolean, externalInspection: ExternalInspection, @@ -59,7 +58,6 @@ type InspectorDeferredProps = { const InspectorDeferred = ({ inspectedViewRef, - onInspectedViewRerenderRequest, reactDevToolsAgent, devMenuInspectorOpen, externalInspection, @@ -72,7 +70,6 @@ const InspectorDeferred = ({ return ( (reactDevToolsHook?.reactDevtoolsAgent); @@ -157,7 +153,6 @@ const AppContainer = ({ {children} @@ -172,11 +167,6 @@ const AppContainer = ({ ); } - const onInspectedViewRerenderRequest = useCallback( - () => setKey(k => k + 1), - [], - ); - return ( = 0)) { @@ -471,6 +466,24 @@ function useTextPressability({ ); } +function usePressabilityDebugStyleDev( + style: ?TextStyleProp, + onPress: ?(event: GestureResponderEvent) => unknown, +): ?TextStyleProp { + const isDebugEnabled = PressabilityDebug.useIsEnabled(); + return isDebugEnabled && onPress != null + ? [style, {color: 'magenta'}] + : style; +} + +/** + * Colors pressable text when press targets are shown by the Inspector. + * Outside of `__DEV__` it returns the style unchanged and uses no hooks. + */ +const usePressabilityDebugStyle: typeof usePressabilityDebugStyleDev = __DEV__ + ? usePressabilityDebugStyleDev + : style => style; + /** * Wrap the NativeVirtualText component and initialize pressability. * @@ -485,10 +498,15 @@ component PressableVirtualText( const [isHighlighted, eventHandlersForText] = useTextPressability( textPressabilityProps, ); + const style = usePressabilityDebugStyle( + textProps.style, + textPressabilityProps.onPress, + ); return ( void, reactDevToolsAgent?: ReactDevToolsAgent, devMenuInspectorOpen: boolean, externalInspection: ExternalInspection, @@ -64,7 +63,6 @@ type Props = { function Inspector({ inspectedViewRef, - onRequestRerenderApp, reactDevToolsAgent, devMenuInspectorOpen, externalInspection, @@ -79,6 +77,7 @@ function Inspector({ const [selectionIndex, setSelectionIndex] = useState(null); const [elementsHierarchy, setElementsHierarchy] = useState(null); + const touchTargeting = PressabilityDebug.useIsEnabled(); // Derive inspecting state: external inspection forces it on, otherwise use local state const isInspecting = externalInspectingEnabled || inspectingEnabled; @@ -160,7 +159,6 @@ function Inspector({ const setTouchTargeting = (val: boolean) => { PressabilityDebug.setEnabled(val); - onRequestRerenderApp(); }; const panelContainerStyle = @@ -188,7 +186,7 @@ function Inspector({ hierarchy={elementsHierarchy} selection={selectionIndex} setSelection={setSelection} - touchTargeting={PressabilityDebug.isEnabled()} + touchTargeting={touchTargeting} setTouchTargeting={setTouchTargeting} />