From 0ca795e08fdcd46f581927b9c6b66a1f3ac33760 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 9 Jan 2025 19:47:06 +0100 Subject: [PATCH] Add bippy to the project (#175) * Add bippy to the project * bippy added inspector * version bump --- package-lock.json | 11 +- package.json | 3 +- src/client/context/RDTContext.tsx | 1 + src/client/context/rdtReducer.ts | 5 + src/client/embedded-dev-tools.tsx | 4 +- src/client/hooks/useBorderedRoutes.ts | 213 --------------------- src/client/hooks/useReactTreeListeners.ts | 202 +++++++++++++++++++ src/client/hooks/useSetRouteBoundaries.ts | 2 +- src/client/react-router-dev-tools.tsx | 7 +- src/client/tabs/SettingsTab.tsx | 8 + test-apps/react-router-vite/vite.config.ts | 1 + 11 files changed, 235 insertions(+), 222 deletions(-) delete mode 100644 src/client/hooks/useBorderedRoutes.ts create mode 100644 src/client/hooks/useReactTreeListeners.ts diff --git a/package-lock.json b/package-lock.json index c01aea4..0e70c95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-router-devtools", - "version": "1.0.5", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "react-router-devtools", - "version": "1.0.5", + "version": "1.1.0", "license": "MIT", "workspaces": [ ".", @@ -45,6 +45,7 @@ "@vitest/coverage-v8": "^2.1.5", "@vitest/ui": "^2.1.5", "autoprefixer": "^10.4.20", + "bippy": "^0.2.2", "glob": "^11.0.0", "happy-dom": "^15.7.4", "jest-preview": "^0.3.1", @@ -3398,6 +3399,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bippy": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/bippy/-/bippy-0.2.2.tgz", + "integrity": "sha512-qhQkXgOXsZLnoQsxUjLRX8x9GHA36w2LlQHGzrPW7AuPc9ZLeE8ybthEYL3TpwF8jD40VZqXNXIupfOJ2ue+MQ==", + "dev": true + }, "node_modules/body-parser": { "version": "1.20.3", "license": "MIT", diff --git a/package.json b/package.json index 7a120f3..7460ef7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "react-router-devtools", "description": "Devtools for React Router - debug, trace, find hydration errors, catch bugs and inspect server/client data with react-router-devtools", "author": "Alem Tuzlak", - "version": "1.0.6", + "version": "1.1.0", "license": "MIT", "keywords": [ "react-router", @@ -123,6 +123,7 @@ "@vitest/coverage-v8": "^2.1.5", "@vitest/ui": "^2.1.5", "autoprefixer": "^10.4.20", + "bippy": "^0.2.2", "glob": "^11.0.0", "happy-dom": "^15.7.4", "jest-preview": "^0.3.1", diff --git a/src/client/context/RDTContext.tsx b/src/client/context/RDTContext.tsx index d821fd5..b1b21d3 100644 --- a/src/client/context/RDTContext.tsx +++ b/src/client/context/RDTContext.tsx @@ -110,6 +110,7 @@ export type RdtClientConfig = Pick< | "requireUrlFlag" | "openHotkey" | "urlFlag" + | "enableInspector" | "routeBoundaryGradient" > diff --git a/src/client/context/rdtReducer.ts b/src/client/context/rdtReducer.ts index 36ab4f0..497aff2 100644 --- a/src/client/context/rdtReducer.ts +++ b/src/client/context/rdtReducer.ts @@ -63,6 +63,10 @@ export type ReactRouterDevtoolsState = { timeline: TimelineEvent[] terminals: Terminal[] settings: { + /** + * Enables the bippy inspector to inspect react components + */ + enableInspector: boolean /** * The breakpoints to show in the corner so you can see the current breakpoint that you defined */ @@ -165,6 +169,7 @@ export const initialState: ReactRouterDevtoolsState = { terminals: [{ id: 0, locked: false, output: [], history: [] }], server: undefined, settings: { + enableInspector: false, showRouteBoundariesOn: "click", breakpoints: [ { name: "", min: 0, max: 639 }, diff --git a/src/client/embedded-dev-tools.tsx b/src/client/embedded-dev-tools.tsx index 3db177f..29c9dfe 100644 --- a/src/client/embedded-dev-tools.tsx +++ b/src/client/embedded-dev-tools.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useLocation } from "react-router" import { RDTContextProvider } from "./context/RDTContext.js" import { useSettingsContext } from "./context/useRDTContext.js" -import { useBorderedRoutes } from "./hooks/useBorderedRoutes.js" +import { useReactTreeListeners } from "./hooks/useReactTreeListeners.js" import { useSetRouteBoundaries } from "./hooks/useSetRouteBoundaries.js" import { useTimelineHandler } from "./hooks/useTimelineHandler.js" import { ContentPanel } from "./layout/ContentPanel.js" @@ -18,7 +18,7 @@ export interface EmbeddedDevToolsProps extends ReactRouterDevtoolsProps { } const Embedded = ({ plugins: pluginArray, mainPanelClassName, className }: EmbeddedDevToolsProps) => { useTimelineHandler() - useBorderedRoutes() + useReactTreeListeners() useSetRouteBoundaries() const { settings } = useSettingsContext() const { position } = settings diff --git a/src/client/hooks/useBorderedRoutes.ts b/src/client/hooks/useBorderedRoutes.ts deleted file mode 100644 index fe5c2d9..0000000 --- a/src/client/hooks/useBorderedRoutes.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { useCallback, useEffect, useRef } from "react" -import { useNavigation } from "react-router" -import type { HTMLError } from "../context/rdtReducer.js" -import { useHtmlErrors } from "../context/useRDTContext.js" - -export const ROUTE_CLASS = "outlet-route" - -const isSourceElement = (fiberNode: any) => { - return ( - fiberNode?.elementType && - fiberNode?.stateNode && - fiberNode?._debugSource && - !fiberNode?.stateNode?.getAttribute?.("data-source") - ) -} - -const isJsxFile = (fiberNode: any) => - fiberNode?._debugSource?.fileName?.includes("tsx") || fiberNode?._debugSource?.fileName?.includes("jsx") - -export function useBorderedRoutes() { - const invalidHtmlCollection = useRef([]) - const { setHtmlErrors } = useHtmlErrors() - const addToInvalidCollection = (entry: HTMLError) => { - if (invalidHtmlCollection.current.find((item) => JSON.stringify(item) === JSON.stringify(entry))) return - invalidHtmlCollection.current.push(entry) - } - - const navigation = useNavigation() - const traverseComponentTree = useCallback((fiberNode: any, callback: any) => { - callback(fiberNode) - - let child = fiberNode?.child - while (child) { - traverseComponentTree(child, callback) - child = child?.sibling - } - }, []) - - const styleNearestElement = useCallback((fiberNode: any) => { - if (!fiberNode) return - - if (fiberNode.stateNode) { - return fiberNode.stateNode?.classList?.add(ROUTE_CLASS) - } - styleNearestElement(fiberNode?.child) - }, []) - - // biome-ignore lint/correctness/useExhaustiveDependencies: - const findIncorrectHtml = useCallback((fiberNode: any, originalFiberNode: any, originalTag: string) => { - if (!fiberNode) return - - const tag = fiberNode.elementType - const addInvalid = () => { - const parentSource = originalFiberNode?._debugOwner?._debugSource ?? originalFiberNode?._debugSource - const source = fiberNode?._debugOwner?._debugSource ?? fiberNode?._debugSource - addToInvalidCollection({ - child: { - file: parentSource?.fileName, - tag: tag, - }, - parent: { - file: source?.fileName, - tag: originalTag, - }, - }) - } - - if (originalTag === "a") { - const element = fiberNode.stateNode as HTMLElement - switch (tag) { - case "a": - case "button": - case "details": - case "embed": - case "iframe": - case "label": - case "select": - case "textarea": { - addInvalid() - break - } - case "audio": { - if (element.getAttribute("controls") !== null) { - addInvalid() - } - break - } - case "img": { - if (element.getAttribute("usemap") !== null) { - addInvalid() - } - break - } - case "input": { - if (element.getAttribute("type") !== "hidden") { - addInvalid() - } - break - } - case "object": { - if (element.getAttribute("usemap") !== null) { - addInvalid() - } - break - } - case "video": { - if (element.getAttribute("controls") !== null) { - addInvalid() - } - break - } - default: { - break - } - } - } - if (originalTag === "p") { - switch (tag) { - case "div": - case "h1": - case "h2": - case "h3": - case "h4": - case "h5": - case "h6": - case "main": - case "pre": - case "p": - case "section": - case "table": - case "ul": - case "ol": - case "li": { - addInvalid() - break - } - default: { - break - } - } - } - if (originalTag === "form") { - if (tag === "form") { - addInvalid() - } - } - if (["h1", "h2", "h3", "h4", "h5", "h6"].includes(originalTag)) { - if (tag === "h1" || tag === "h2" || tag === "h3" || tag === "h4" || tag === "h5" || tag === "h6") { - addInvalid() - } - } - findIncorrectHtml(fiberNode?.child, originalFiberNode, originalTag) - if (fiberNode?.sibling) { - findIncorrectHtml(fiberNode?.sibling, originalFiberNode, originalTag) - } - }, []) - - useEffect(() => { - if (navigation.state !== "idle") return - const devTools = (window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__ - if (!devTools) { - return - } - for (const [rendererID] of devTools.renderers) { - const fiberRoots = devTools.getFiberRoots(rendererID) - for (const rootFiber of fiberRoots) { - traverseComponentTree(rootFiber.current, (fiberNode: any) => { - // Vite implementation - if (isSourceElement(fiberNode) && typeof import.meta.hot !== "undefined") { - const originalSource = fiberNode?._debugSource - const source = fiberNode?._debugOwner?._debugSource ?? fiberNode?._debugSource - const line = source?.fileName?.startsWith("/") ? originalSource?.lineNumber : source?.lineNumber - const fileName = source?.fileName?.startsWith("/") ? originalSource?.fileName : source?.fileName - - fiberNode.stateNode?.setAttribute?.( - "data-source", - `${fileName}:::${line}` // - ) - } else if (isSourceElement(fiberNode)) { - const isJsx = isJsxFile(fiberNode) - - const originalSource = fiberNode?._debugSource - const source = fiberNode?._debugOwner?._debugSource ?? fiberNode?._debugSource - const line = source?.fileName?.startsWith("/") ? originalSource?.lineNumber : source?.lineNumber - const fileName = source?.fileName?.startsWith("/") ? originalSource?.fileName : source?.fileName - fiberNode.stateNode?.setAttribute?.( - "data-source", - `${fileName}:::${isJsx ? line - 20 : line}` // - ) - } - if (fiberNode?.stateNode && fiberNode?.elementType === "form") { - findIncorrectHtml(fiberNode.child, fiberNode, "form") - } - if (fiberNode?.stateNode && fiberNode?.elementType === "a") { - findIncorrectHtml(fiberNode.child, fiberNode, "a") - } - if (fiberNode?.stateNode && fiberNode?.elementType === "p") { - findIncorrectHtml(fiberNode.child, fiberNode, "p") - } - if (fiberNode?.stateNode && ["h1", "h2", "h3", "h4", "h5", "h6"].includes(fiberNode?.elementType)) { - findIncorrectHtml(fiberNode.child, fiberNode, fiberNode?.elementType) - } - if (fiberNode?.elementType?.name === "default" || fiberNode?.elementType?.name === "RenderedRoute") { - styleNearestElement(fiberNode) - } - }) - } - } - - setHtmlErrors(invalidHtmlCollection.current) - invalidHtmlCollection.current = [] - }, [navigation.state, styleNearestElement, traverseComponentTree, findIncorrectHtml, setHtmlErrors]) -} diff --git a/src/client/hooks/useReactTreeListeners.ts b/src/client/hooks/useReactTreeListeners.ts new file mode 100644 index 0000000..4d973e0 --- /dev/null +++ b/src/client/hooks/useReactTreeListeners.ts @@ -0,0 +1,202 @@ +import { type Fiber, onCommitFiberRoot, traverseFiber } from "bippy" +import { useCallback, useEffect, useRef } from "react" +import { useNavigation } from "react-router" +import type { HTMLError } from "../context/rdtReducer.js" +import { useHtmlErrors } from "../context/useRDTContext.js" + +export const ROUTE_CLASS = "outlet-route" + +const isSourceElement = (fiberNode: any) => { + return ( + fiberNode?.elementType && + fiberNode?.stateNode && + fiberNode?._debugSource && + !fiberNode?.stateNode?.getAttribute?.("data-source") + ) +} + +const isJsxFile = (fiberNode: Fiber) => + fiberNode?._debugSource?.fileName?.includes("tsx") || fiberNode?._debugSource?.fileName?.includes("jsx") + +export function useReactTreeListeners() { + const invalidHtmlCollection = useRef([]) + const { setHtmlErrors } = useHtmlErrors() + const addToInvalidCollection = (entry: HTMLError) => { + if (invalidHtmlCollection.current.find((item) => JSON.stringify(item) === JSON.stringify(entry))) return + invalidHtmlCollection.current.push(entry) + } + + const navigation = useNavigation() + + const styleNearestElement = useCallback((fiberNode: Fiber | null) => { + if (!fiberNode) return + + if (fiberNode.stateNode) { + return fiberNode.stateNode?.classList?.add(ROUTE_CLASS) + } + styleNearestElement(fiberNode?.child) + }, []) + + // biome-ignore lint/correctness/useExhaustiveDependencies: + const findIncorrectHtml = useCallback( + (fiberNode: Fiber | null, originalFiberNode: Fiber | null, originalTag: string) => { + if (!fiberNode) return + + const tag = fiberNode.elementType + const addInvalid = () => { + const parentSource = originalFiberNode?._debugOwner?._debugSource ?? originalFiberNode?._debugSource + const source = fiberNode?._debugOwner?._debugSource ?? fiberNode?._debugSource + addToInvalidCollection({ + child: { + file: parentSource?.fileName, + tag: tag, + }, + parent: { + file: source?.fileName, + tag: originalTag, + }, + }) + } + + if (originalTag === "a") { + const element = fiberNode.stateNode as HTMLElement + switch (tag) { + case "a": + case "button": + case "details": + case "embed": + case "iframe": + case "label": + case "select": + case "textarea": { + addInvalid() + break + } + case "audio": { + if (element.getAttribute("controls") !== null) { + addInvalid() + } + break + } + case "img": { + if (element.getAttribute("usemap") !== null) { + addInvalid() + } + break + } + case "input": { + if (element.getAttribute("type") !== "hidden") { + addInvalid() + } + break + } + case "object": { + if (element.getAttribute("usemap") !== null) { + addInvalid() + } + break + } + case "video": { + if (element.getAttribute("controls") !== null) { + addInvalid() + } + break + } + default: { + break + } + } + } + if (originalTag === "p") { + switch (tag) { + case "div": + case "h1": + case "h2": + case "h3": + case "h4": + case "h5": + case "h6": + case "main": + case "pre": + case "p": + case "section": + case "table": + case "ul": + case "ol": + case "li": { + addInvalid() + break + } + default: { + break + } + } + } + if (originalTag === "form") { + if (tag === "form") { + addInvalid() + } + } + if (["h1", "h2", "h3", "h4", "h5", "h6"].includes(originalTag)) { + if (tag === "h1" || tag === "h2" || tag === "h3" || tag === "h4" || tag === "h5" || tag === "h6") { + addInvalid() + } + } + findIncorrectHtml(fiberNode?.child, originalFiberNode, originalTag) + if (fiberNode?.sibling) { + findIncorrectHtml(fiberNode?.sibling, originalFiberNode, originalTag) + } + }, + [] + ) + + useEffect(() => { + if (navigation.state !== "idle") return + + onCommitFiberRoot((root) => + traverseFiber(root.current, (fiberNode) => { + if (isSourceElement(fiberNode) && typeof import.meta.hot !== "undefined") { + const originalSource = fiberNode?._debugSource + const source = fiberNode?._debugOwner?._debugSource ?? fiberNode?._debugSource + const line = source?.fileName?.startsWith("/") ? originalSource?.lineNumber : source?.lineNumber + const fileName = source?.fileName?.startsWith("/") ? originalSource?.fileName : source?.fileName + + fiberNode.stateNode?.setAttribute?.( + "data-source", + `${fileName}:::${line}` // + ) + } else if (isSourceElement(fiberNode)) { + const isJsx = isJsxFile(fiberNode) + + const originalSource = fiberNode?._debugSource + const source = fiberNode?._debugOwner?._debugSource ?? fiberNode?._debugSource + const line = source?.fileName?.startsWith("/") ? originalSource?.lineNumber : source?.lineNumber + const fileName = source?.fileName?.startsWith("/") ? originalSource?.fileName : source?.fileName + fiberNode.stateNode?.setAttribute?.( + "data-source", + `${fileName}:::${isJsx ? line - 20 : line}` // + ) + } + + if (fiberNode?.stateNode && fiberNode?.elementType === "form") { + findIncorrectHtml(fiberNode.child, fiberNode, "form") + } + if (fiberNode?.stateNode && fiberNode?.elementType === "a") { + findIncorrectHtml(fiberNode.child, fiberNode, "a") + } + if (fiberNode?.stateNode && fiberNode?.elementType === "p") { + findIncorrectHtml(fiberNode.child, fiberNode, "p") + } + if (fiberNode?.stateNode && ["h1", "h2", "h3", "h4", "h5", "h6"].includes(fiberNode?.elementType)) { + findIncorrectHtml(fiberNode.child, fiberNode, fiberNode?.elementType) + } + if (fiberNode?.elementType?.name === "default" || fiberNode?.elementType?.name === "RenderedRoute") { + styleNearestElement(fiberNode) + } + }) + ) + + setHtmlErrors(invalidHtmlCollection.current) + invalidHtmlCollection.current = [] + }, [navigation.state, styleNearestElement, findIncorrectHtml, setHtmlErrors]) +} diff --git a/src/client/hooks/useSetRouteBoundaries.ts b/src/client/hooks/useSetRouteBoundaries.ts index ce7d40c..b0ebd39 100644 --- a/src/client/hooks/useSetRouteBoundaries.ts +++ b/src/client/hooks/useSetRouteBoundaries.ts @@ -3,7 +3,7 @@ import { useMatches } from "react-router" import { ROUTE_BOUNDARY_GRADIENTS } from "../context/rdtReducer.js" import { useDetachedWindowControls, useSettingsContext } from "../context/useRDTContext.js" import { useAttachListener } from "./useAttachListener.js" -import { ROUTE_CLASS } from "./useBorderedRoutes.js" +import { ROUTE_CLASS } from "./useReactTreeListeners.js" export const useSetRouteBoundaries = () => { const matches = useMatches() diff --git a/src/client/react-router-dev-tools.tsx b/src/client/react-router-dev-tools.tsx index aebf478..50b3d97 100644 --- a/src/client/react-router-dev-tools.tsx +++ b/src/client/react-router-dev-tools.tsx @@ -5,7 +5,7 @@ import { RDTContextProvider, type RdtClientConfig } from "./context/RDTContext.j import { useDetachedWindowControls, usePersistOpen, useSettingsContext } from "./context/useRDTContext.js" import { useResetDetachmentCheck } from "./hooks/detached/useResetDetachmentCheck.js" import { useSyncStateWhenDetached } from "./hooks/detached/useSyncStateWhenDetached.js" -import { useBorderedRoutes } from "./hooks/useBorderedRoutes.js" +import { useReactTreeListeners } from "./hooks/useReactTreeListeners.js" import { useSetRouteBoundaries } from "./hooks/useSetRouteBoundaries.js" import { useTimelineHandler } from "./hooks/useTimelineHandler.js" import { ContentPanel } from "./layout/ContentPanel.js" @@ -20,6 +20,7 @@ import { setStorageItem, } from "./utils/storage.js" import "../input.css" +import { Inspector } from "bippy/inspect" import { useHotkeys } from "react-hotkeys-hook" import type { RdtPlugin } from "../index.js" import { Breakpoints } from "./components/Breakpoints.js" @@ -28,7 +29,6 @@ import { useListenToRouteChange } from "./hooks/detached/useListenToRouteChange. import { useDebounce } from "./hooks/useDebounce.js" import { useDevServerConnection } from "./hooks/useDevServerConnection.js" import { useOpenElementSource } from "./hooks/useOpenElementSource.js" - const recursivelyChangeTabIndex = (node: Element | HTMLElement, remove = true) => { if (remove) { node.setAttribute("tabIndex", "-1") @@ -43,7 +43,7 @@ const recursivelyChangeTabIndex = (node: Element | HTMLElement, remove = true) = const DevTools = ({ plugins: pluginArray }: ReactRouterDevtoolsProps) => { useTimelineHandler() useResetDetachmentCheck() - useBorderedRoutes() + useReactTreeListeners() useSetRouteBoundaries() useSyncStateWhenDetached() useDevServerConnection() @@ -102,6 +102,7 @@ const DevTools = ({ plugins: pluginArray }: ReactRouterDevtoolsProps) => { +
diff --git a/src/client/tabs/SettingsTab.tsx b/src/client/tabs/SettingsTab.tsx index e658ea7..9882ce1 100644 --- a/src/client/tabs/SettingsTab.tsx +++ b/src/client/tabs/SettingsTab.tsx @@ -52,6 +52,14 @@ export const SettingsTab = () => { > Show breakpoint indicator + setSettings({ enableInspector: !settings.enableInspector })} + value={settings.enableInspector} + > + Enable react component inspector +
diff --git a/test-apps/react-router-vite/vite.config.ts b/test-apps/react-router-vite/vite.config.ts index 08b4dde..e0905b6 100644 --- a/test-apps/react-router-vite/vite.config.ts +++ b/test-apps/react-router-vite/vite.config.ts @@ -6,6 +6,7 @@ import { reactRouterDevTools, defineRdtConfig } from "react-router-devtools" import inspect from "vite-plugin-inspect" const config = defineRdtConfig({ client: { + enableInspector: true, defaultOpen: false, position: "top-right", requireUrlFlag: false,