diff --git a/src/ui/components/SecondaryToolbox/ReactDevTools.tsx b/src/ui/components/SecondaryToolbox/ReactDevTools.tsx index 3f064a0096c..d43a1c3e453 100644 --- a/src/ui/components/SecondaryToolbox/ReactDevTools.tsx +++ b/src/ui/components/SecondaryToolbox/ReactDevTools.tsx @@ -1,305 +1,14 @@ -import { ObjectId, Object as ProtocolObject } from "@replayio/protocol"; -import { createBridge, createStore, initialize } from "@replayio/react-devtools-inline/frontend"; -import { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useImperativeCacheValue } from "suspense"; - -import { selectLocation } from "devtools/client/debugger/src/actions/sources"; -import { getExecutionPoint, getThreadContext } from "devtools/client/debugger/src/reducers/pause"; -import { highlightNode, unhighlightNode } from "devtools/client/inspector/markup/actions/markup"; import { InlineErrorBoundary } from "replay-next/components/errors/InlineErrorBoundary"; -import { useIsPointWithinFocusWindow } from "replay-next/src/hooks/useIsPointWithinFocusWindow"; import { useMostRecentLoadedPause } from "replay-next/src/hooks/useMostRecentLoadedPause"; -import { useNag } from "replay-next/src/hooks/useNag"; -import { recordingCapabilitiesCache } from "replay-next/src/suspense/BuildIdCache"; -import { isExecutionPointsLessThan } from "replay-next/src/utils/time"; -import { ReplayClientContext } from "shared/client/ReplayClientContext"; -import { ReplayClientInterface } from "shared/client/types"; -import { Nag } from "shared/graphql/types"; -import { useTheme } from "shared/theme/useTheme"; -import { UIThunkAction } from "ui/actions"; -import { enterFocusMode } from "ui/actions/timeline"; -import { NodePickerContext, NodePickerContextType } from "ui/components/NodePickerContext"; -import { nodesToFiberIdsCache } from "ui/components/SecondaryToolbox/react-devtools/injectReactDevtoolsBackend"; -import { - ReplayWall, - StoreWithInternals, -} from "ui/components/SecondaryToolbox/react-devtools/ReplayWall"; -import { getPreferredLocation } from "ui/reducers/sources"; -import { getRecordingTooLongToSupportRoutines } from "ui/reducers/timeline"; -import { useAppDispatch, useAppSelector } from "ui/setup/hooks"; -import { - ParsedReactDevToolsAnnotation, - reactDevToolsAnnotationsCache, -} from "ui/suspense/annotationsCaches"; - -import { ReactDevToolsPanel as NewReactDevtoolsPanel } from "./react-devtools/components/ReactDevToolsPanel"; -import { generateTreeResetOpsForPoint } from "./react-devtools/rdtProcessing"; -import styles from "./react-devtools/components/ReactDevToolsPanel.module.css"; - -function jumpToComponentPreferredSource(componentPreview: ProtocolObject): UIThunkAction { - return (dispatch, getState) => { - const state = getState(); - const cx = getThreadContext(state); - const location = getPreferredLocation( - state.sources, - componentPreview.preview!.functionLocation! - ); - dispatch(selectLocation(cx, location, true)); - }; -} - -function createReactDevTools( - enableNodePicker: NodePickerContextType["enable"], - disableNodePicker: NodePickerContextType["disable"], - highlightNode: (nodeId: string) => void, - unhighlightNode: () => void, - setProtocolCheckFailed: (failed: boolean) => void, - replayClient: ReplayClientInterface, - dismissInspectComponentNag: () => void -) { - const target = { postMessage() {} } as unknown as Window; - const wall = new ReplayWall({ - disableNodePicker, - dismissInspectComponentNag, - enableNodePicker, - highlightNode, - replayClient, - setProtocolCheckFailed, - unhighlightNode, - }); - - const bridge = createBridge(target, wall); - - // Override shutdown behavior to avoid the RDT UI from closing the bridge connection - bridge.shutdown = function () { - // no-op - }; - - const store = createStore(bridge, { - checkBridgeProtocolCompatibility: false, - supportsNativeInspection: true, - }); - - wall.store = store as StoreWithInternals; - - const ReactDevTools = initialize(target, { bridge, store }); - - return [ReactDevTools, wall, bridge] as const; -} - -const EMPTY_ANNOTATIONS: ParsedReactDevToolsAnnotation[] = []; - -export function ReactDevtoolsPanel() { - const dispatch = useAppDispatch(); - const theme = useTheme(); - const replayClient = useContext(ReplayClientContext); - const currentPoint = useAppSelector(getExecutionPoint); - const previousPointRef = useRef(currentPoint); - const isFirstAnnotationsInjection = useRef(true); - const showRecordingTooLongWarning = useAppSelector(getRecordingTooLongToSupportRoutines); - - const { - disable: disableNodePicker, - enable: enableNodePicker, - status: nodePickerStatus, - type: nodePickerType, - } = useContext(NodePickerContext); - - const nodePickerActive = - (nodePickerStatus === "initializing" || nodePickerStatus === "active") && - nodePickerType === "reactComponent"; - - // Disable node picker when this component unmounts - // It doesn't matter if it's enabled or not (or even if this is the current tool) - useLayoutEffect(() => () => disableNodePicker(), [disableNodePicker]); - - const isPointWithinFocusWindow = useIsPointWithinFocusWindow(currentPoint); - const pauseId = useAppSelector(state => state.pause.id); - - const [, dismissInspectComponentNag] = useNag(Nag.INSPECT_COMPONENT); - const [protocolCheckFailed, setProtocolCheckFailed] = useState(false); - const { status: annotationsStatus, value: parsedAnnotations } = useImperativeCacheValue( - reactDevToolsAnnotationsCache, - replayClient - ); - - const annotations: ParsedReactDevToolsAnnotation[] = - annotationsStatus === "resolved" ? parsedAnnotations : EMPTY_ANNOTATIONS; - const [ReactDevTools, wall] = useMemo(() => { - return createReactDevTools( - enableNodePicker, - disableNodePicker, - (nodeId: ObjectId) => dispatch(highlightNode(nodeId)), - () => dispatch(unhighlightNode()), - setProtocolCheckFailed, - replayClient, - dismissInspectComponentNag - ); - }, [disableNodePicker, dispatch, enableNodePicker, replayClient, dismissInspectComponentNag]); - - useLayoutEffect(() => { - if ( - !ReactDevTools || - !wall || - !currentPoint || - !pauseId || - !annotations || - !annotations.length - ) { - return; - } - - wall.setPauseId(pauseId); - - if (previousPointRef.current && previousPointRef.current !== currentPoint) { - // We keep the one RDT UI component instance alive, but operations are additive over time. - // In order to reset the displayed component tree, we first need to generate a set of fake - // "remove this React root" operations based on where we _were_ paused, and inject those. - const clearTreeOperations = generateTreeResetOpsForPoint( - previousPointRef.current, - annotations - ); - - for (const rootRemovalOp of clearTreeOperations) { - wall.sendAnnotation({ event: "operations", payload: rootRemovalOp }); - } - } - - if (previousPointRef.current !== currentPoint || isFirstAnnotationsInjection.current) { - isFirstAnnotationsInjection.current = false; - - // Now that the displayed tree is empty, we can inject all operations up to the _current_ point in time. - for (const { contents, point } of annotations) { - if (contents.event === "operations" && isExecutionPointsLessThan(point, currentPoint)) { - wall.sendAnnotation(contents); - } - } - } - - previousPointRef.current = currentPoint; - }, [ReactDevTools, wall, currentPoint, annotations, pauseId]); - - useEffect(() => { - if (pauseId) { - // Speed up node picker initialization - nodesToFiberIdsCache.prefetch(replayClient, pauseId); - } - }, [pauseId, replayClient, wall]); - - if (currentPoint === null) { - return null; - } - - if (showRecordingTooLongWarning) { - return ( -