diff --git a/src/core/instrumentation/index.ts b/src/core/instrumentation/index.ts index db18b223..4268eabd 100644 --- a/src/core/instrumentation/index.ts +++ b/src/core/instrumentation/index.ts @@ -140,6 +140,7 @@ export const instrument = ({ onCommitStart(); const handleFiber = (fiber: Fiber, trigger: boolean) => { + // to-do, don't traverse fibers part from react-scan, get all components from no-traverse subtree and put it in a weakset const type = getType(fiber.type); if (!type) return null; if (!didFiberRender(fiber)) return null; diff --git a/src/core/native/index.tsx b/src/core/native/index.tsx index 6a908dc3..571d1888 100644 --- a/src/core/native/index.tsx +++ b/src/core/native/index.tsx @@ -15,16 +15,17 @@ import { Text, } from '@shopify/react-native-skia'; import React, { + createContext, + useContext, useEffect, useRef, useState, - useSyncExternalStore, } from 'react'; -// import { useDerivedValue, useSharedValue } from 'react-native-reanimated'; import { ReactScanInternals } from '..'; +import { useSharedValue, withTiming } from 'react-native-reanimated'; import { assertNative, instrumentNative } from './instrument'; -// can't use useSyncExternalStore for compat +// can't use useSyncExternalStore for back compat const useIsPaused = () => { const [isPaused, setIsPaused] = useState(ReactScanInternals.isPaused); useEffect(() => { @@ -36,18 +37,79 @@ const useIsPaused = () => { return isPaused; }; -export const ReactNativeScanEntryPoint = () => { - if (ReactScanInternals.isProd) { - return null; // todo: better no-op - } +interface Options { + /** + * Controls the animation of the re-render overlay. + * When set to "fade-out", the overlay will fade out after appearing. + * When false, no animation will be applied. + * Note: Enabling animations may impact performance. + * @default false */ + animationWhenFlashing?: 'fade-out' | false; +} +export type ReactNativeScanOptions =Options & + Omit< + typeof ReactScanInternals.options, + 'playSound' | 'runInProduction' | 'includeChildren' + >; + +const OptionsContext = createContext>({ + animationWhenFlashing: false, +}); + +export const ReactScan = ({ + children, + ...options +}: { + children: React.ReactNode; +} & ReactNativeScanOptions) => { useEffect(() => { - if (!ReactScanInternals.isProd) { - instrumentNative(); // cleanup? - } + ReactScanInternals.options = options; + instrumentNative(); }, []); const isPaused = useIsPaused(); + useEffect(() => { + const interval = setInterval(() => { + if (isPaused) return; + + const newActive = ReactScanInternals.activeOutlines.filter( + (x) => Date.now() - x.updatedAt < 500, + ); + if (newActive.length !== ReactScanInternals.activeOutlines.length) { + ReactScanInternals.set('activeOutlines', newActive); + } + }, 200); + return () => { + clearInterval(interval); + }; + }, [isPaused]); + + return ( + <> + {children} + + {!isPaused && } + {options.showToolbar && ( + + )} + + + ); +}; + +const ReactScanToolbar = ({ + isPaused, +}: { + isPaused: boolean; + scanTag: string; +}) => { const pan = useRef(new Animated.ValueXY()).current; const panResponder = useRef( @@ -70,77 +132,57 @@ export const ReactNativeScanEntryPoint = () => { }), ).current; - useEffect(() => { - const interval = setInterval(() => { - if (isPaused) return; - - const newActive = ReactScanInternals.activeOutlines.filter( - (x) => Date.now() - x.updatedAt < 500, - ); - if (newActive.length !== ReactScanInternals.activeOutlines.length) { - ReactScanInternals.set('activeOutlines', newActive); - } - }, 200); - return () => { - clearInterval(interval); - }; - }, [isPaused]); - return ( - <> - {!isPaused && } - - + + (ReactScanInternals.isPaused = !ReactScanInternals.isPaused) + } style={{ - position: 'absolute', - bottom: 20, - right: 20, - zIndex: 999999, - transform: pan.getTranslateTransform(), + backgroundColor: !isPaused + ? 'rgba(88, 82, 185, 0.9)' + : 'rgba(88, 82, 185, 0.5)', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 4, + flexDirection: 'row', + alignItems: 'center', + gap: 8, }} - {...panResponder.panHandlers} > - - (ReactScanInternals.isPaused = !ReactScanInternals.isPaused) - } + + - - - React Scan - - - - + React Scan + + + ); }; const dimensions = Dimensions.get('window'); @@ -155,24 +197,30 @@ const font = matchFont({ const getTextWidth = (text: string) => { return (text || 'unknown').length * 7; }; -const ReactNativeScan = ({ id: _ }: { id: string }) => { - // const opacity = useSharedValue(1); - // todo: polly fill - const outlines = useSyncExternalStore( - (listener) => - ReactScanInternals.subscribe('activeOutlines', (_) => { - // animations destroy UI thread on heavy updates, probably not worth it - // opacity.value = 1; - // opacity.value = withTiming(0, { - // duration: 500 - // }) - listener(); - }), - () => ReactScanInternals.activeOutlines, - ); - // ); - // const animatedOpacity = useDerivedValue(() => opacity.value); +const useOutlines = (opacity: { value: number }) => { + const [outlines, setOutlines] = useState< + (typeof ReactScanInternals)['activeOutlines'] + >([]); + const options = useContext(OptionsContext); + // cannot use useSyncExternalStore for back compat + useEffect(() => { + ReactScanInternals.subscribe('activeOutlines', (activeOutlines) => { + setOutlines(activeOutlines); + if (options.animationWhenFlashing !== false) { + // we only support fade-out for now + opacity.value = 1; + opacity.value = withTiming(0, { + duration: 500, + }); + } + }); + }, []); + return outlines; +}; +const ReactScanCanvas = (_: { scanTag: string }) => { + const opacity = useSharedValue(1); + const outlines = useOutlines(opacity); return ( { ); }; - - - - - diff --git a/src/core/native/plugins/metro.ts b/src/core/native/plugins/metro.ts index 7e2233a9..95e1a4e6 100644 --- a/src/core/native/plugins/metro.ts +++ b/src/core/native/plugins/metro.ts @@ -9,7 +9,6 @@ interface TransformResult { map?: any; } -// eslint-disable-next-line @typescript-eslint/no-var-requires const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); const defaultConfig = getDefaultConfig(__dirname); @@ -22,7 +21,7 @@ const config = { if (filename.includes('node_modules/react-scan/dist/core/native/')) { return { - code: `export const ReactNativeScanEntryPoint = () => null;`, + code: `export const ReactScan = () => null;`, }; }