diff --git a/package.json b/package.json index 37071305..b26819fe 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": "5.0.7", + "version": "5.1.0", "license": "MIT", "keywords": [ "react-router", diff --git a/src/client/hooks/useOpenElementSource.ts b/src/client/hooks/useOpenElementSource.ts index 29490be7..df1c735c 100644 --- a/src/client/hooks/useOpenElementSource.ts +++ b/src/client/hooks/useOpenElementSource.ts @@ -12,10 +12,10 @@ const useOpenElementSource = () => { e.stopPropagation() e.preventDefault() const target = e.target as HTMLElement - const rdtSource = target?.getAttribute("data-source") + const rdtSource = target?.getAttribute("data-rrdt-source") if (rdtSource) { - const [source, line, column] = rdtSource.split(":::") + const [source, line, column] = rdtSource.split(":") return sendJsonMessage({ type: "open-source", data: { source, line, column }, diff --git a/src/client/hooks/useReactTreeListeners.ts b/src/client/hooks/useReactTreeListeners.ts index d21468cb..059fcd71 100644 --- a/src/client/hooks/useReactTreeListeners.ts +++ b/src/client/hooks/useReactTreeListeners.ts @@ -6,18 +6,6 @@ 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() @@ -155,18 +143,6 @@ export function useReactTreeListeners() { 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}` // - ) - } - if (fiberNode?.stateNode && fiberNode?.elementType === "form") { findIncorrectHtml(fiberNode.child, fiberNode, "form") } diff --git a/src/client/tabs/PageTab.tsx b/src/client/tabs/PageTab.tsx index daa8bf43..743b6bb5 100644 --- a/src/client/tabs/PageTab.tsx +++ b/src/client/tabs/PageTab.tsx @@ -1,12 +1,10 @@ import clsx from "clsx" -import { useMemo } from "react" import { useMatches, useRevalidator } from "react-router" import { RouteSegmentInfo } from "../components/RouteSegmentInfo.js" const PageTab = () => { const routes = useMatches() - const reversed = useMemo(() => routes.reverse(), [routes]) const { revalidate, state } = useRevalidator() return ( @@ -32,7 +30,7 @@ const PageTab = () => {
    - {reversed.map((route, i) => ( + {routes.map((route, i) => ( ))}
diff --git a/src/vite/editor.ts b/src/vite/editor.ts index 6af0a4fe..06c8bfe1 100644 --- a/src/vite/editor.ts +++ b/src/vite/editor.ts @@ -17,14 +17,16 @@ export type OpenSourceData = { export type EditorConfig = { name: string - open(path: string, lineNumber: string | undefined): void + open(path: string, lineNumber: string | undefined, columnNumber: string | undefined): void } export const DEFAULT_EDITOR_CONFIG: EditorConfig = { name: "VSCode", - open: async (path, lineNumber) => { + open: async (path, lineNumber, columnNumber) => { const { exec } = await import("node:child_process") - exec(`code -g "${normalizePath(path).replaceAll("$", "\\$")}${lineNumber ? `:${lineNumber}` : ""}"`) + exec( + `code -g "${normalizePath(path).replaceAll("$", "\\$")}${lineNumber ? `:${lineNumber}` : ""}${columnNumber ? `:${columnNumber}` : ""}"` + ) }, } @@ -35,14 +37,16 @@ export const handleOpenSource = async ({ }: { data: OpenSourceData appDir: string - openInEditor: (path: string, lineNum: string | undefined) => Promise + openInEditor: (path: string, lineNum: string | undefined, columnNum: string | undefined) => Promise }) => { - const { source, line, routeID } = data.data + const { source, line, column, routeID } = data.data + const lineNum = line ? `${line}` : undefined + const columnNum = column ? `${column}` : undefined const fs = await import("node:fs") const path = await import("node:path") if (source) { - return openInEditor(source, lineNum) + return openInEditor(source, lineNum, columnNum) } if (routeID) { @@ -64,7 +68,7 @@ export const handleOpenSource = async ({ if (!fs.existsSync(appDir)) return const filesInReactRouterPath = fs.readdirSync(appDir) const rootFile = findFileByExtension("root", filesInReactRouterPath) - rootFile && openInEditor(path.join(appDir, rootFile), lineNum) + rootFile && openInEditor(path.join(appDir, rootFile), lineNum, columnNum) return } @@ -74,9 +78,9 @@ export const handleOpenSource = async ({ if (type === "directory") { const filesInFolderRoute = fs.readdirSync(validPath) const routeFile = findFileByExtension("route", filesInFolderRoute) - routeFile && openInEditor(path.join(appDir, routeID, routeFile), lineNum) + routeFile && openInEditor(path.join(appDir, routeID, routeFile), lineNum, columnNum) return } - return openInEditor(validPath, lineNum) + return openInEditor(validPath, lineNum, columnNum) } } diff --git a/src/vite/file.ts b/src/vite/file.ts index bfd9a476..bab13b93 100644 --- a/src/vite/file.ts +++ b/src/vite/file.ts @@ -5,7 +5,7 @@ import { GENERATORS, type Generator } from "./generators" export type WriteFileData = { type: "write-file" path: string - openInEditor: (path: string, lineNum: string | undefined) => void + openInEditor: (path: string, lineNum: string | undefined, columnNum: string | undefined) => void options: { loader: boolean clientLoader: boolean @@ -66,5 +66,5 @@ export const handleWriteFile = async ({ path, options, openInEditor, appDir }: W .filter(Boolean) .join("\n\n") await writeFile(outputFile, fileContent) - openInEditor(outputFile, undefined) + openInEditor(outputFile, undefined, undefined) } diff --git a/src/vite/plugin.tsx b/src/vite/plugin.tsx index 2eb2e72d..a04a762a 100644 --- a/src/vite/plugin.tsx +++ b/src/vite/plugin.tsx @@ -12,6 +12,7 @@ import { handleDevToolsViteRequest, processPlugins } from "./utils.js" import { augmentDataFetchingFunctions } from "./utils/data-functions-augment.js" import { injectRdtClient } from "./utils/inject-client.js" import { injectContext } from "./utils/inject-context.js" +import { addSourceToJsx } from "./utils/inject-source.js" // this should mirror the types in server/config.ts as well as they are bundled separately. declare global { interface Window { @@ -98,6 +99,19 @@ export const reactRouterDevTools: (args?: ReactRouterViteConfig) => Plugin[] = ( process.rdt_config = serverConfig } return [ + { + enforce: "pre", + name: "react-router-devtools:inject-source", + apply(config) { + return config.mode === "development" + }, + transform(code, id) { + if (id.includes("node_modules") || id.includes("?raw") || id.includes("dist") || id.includes("build")) + return code + + return addSourceToJsx(code, id) + }, + }, { name: "react-router-devtools", apply(config) { @@ -259,11 +273,15 @@ export const reactRouterDevTools: (args?: ReactRouterViteConfig) => Plugin[] = ( }) const channel = server.hot const editor = args?.editor ?? DEFAULT_EDITOR_CONFIG - const openInEditor = async (path: string | undefined, lineNum: string | undefined) => { + const openInEditor = async ( + path: string | undefined, + lineNum: string | undefined, + columnNum: string | undefined + ) => { if (!path) { return } - editor.open(path, lineNum) + editor.open(path, lineNum, columnNum) } server.middlewares.use((req, res, next) => handleDevToolsViteRequest(req, res, next, (parsedData) => { @@ -343,7 +361,19 @@ export const reactRouterDevTools: (args?: ReactRouterViteConfig) => Plugin[] = ( ) }) - server.hot.on("open-source", (data: OpenSourceData) => handleOpenSource({ data, openInEditor, appDir })) + server.hot.on("open-source", (data: OpenSourceData) => + handleOpenSource({ + data: { + ...data, + data: { + ...data.data, + source: data.data.source ? normalizePath(`${process.cwd()}/${data.data.source}`) : undefined, + }, + }, + openInEditor, + appDir, + }) + ) server.hot.on("add-route", (data: WriteFileData) => handleWriteFile({ ...data, openInEditor, appDir })) } }, diff --git a/src/vite/utils/inject-source.test.ts b/src/vite/utils/inject-source.test.ts new file mode 100644 index 00000000..3451f129 --- /dev/null +++ b/src/vite/utils/inject-source.test.ts @@ -0,0 +1,959 @@ +import { describe, expect, it } from "vitest" +import { addSourceToJsx } from "./inject-source" + +const removeEmptySpace = (str: string) => { + return str.replace(/\s/g, "").trim() +} + +describe("inject source", () => { + describe("FunctionExpression", () => { + it("should work with deeply nested custom JSX syntax", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + export const Route = createFileRoute("/test")({ + component: function() { return
Hello World
}, + }) + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + export const Route = createFileRoute("/test")({ + component: function() { return
Hello World
; } + }); + `) + ) + }) + + it("should work with props not destructured and spread", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + export const Route = createFileRoute("/test")({ + component: function(props) { return
Hello World
}, + }) + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + export const Route = createFileRoute("/test")({ + component: function(props) { return
Hello World
}, + }) + `) + ) + }) + + it("should work with props destructured and spread", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + export const Route = createFileRoute("/test")({ + component: function({...props}) { return
Hello World
}, + }) + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + export const Route = createFileRoute("/test")({ + component: function({...props}) { return
Hello World
}, + }) + `) + ) + }) + + it("should work with props destructured and spread with a different name", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + export const Route = createFileRoute("/test")({ + component: function({...rest}) { return
Hello World
}, + }) + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + export const Route = createFileRoute("/test")({ + component: function({...rest}) { return
Hello World
}, + }) + `) + ) + }) + + it("should work with props spread and other normal elements", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + export const Route = createFileRoute("/test")({ + component: function({...rest}) { return
Hello World
} + }) + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + export const Route = createFileRoute("/test")({ + component: function({...rest}) { return
Hello World
; } + }); + `) + ) + }) + }) + + describe("ArrowFunctionExpression", () => { + it("should work with deeply nested custom JSX syntax", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + export const Route = createFileRoute("/test")({ + component: () =>
Hello World
, + }) + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + export const Route = createFileRoute("/test")({ + component: () =>
Hello World
+ }); + `) + ) + }) + + it("should work with props not destructured and spread", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + export const Route = createFileRoute("/test")({ + component: (props) =>
Hello World
, + }) + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + export const Route = createFileRoute("/test")({ + component: (props) =>
Hello World
, + }) + `) + ) + }) + + it("should work with props destructured and spread", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + export const Route = createFileRoute("/test")({ + component: ({...props}) =>
Hello World
, + }) + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + export const Route = createFileRoute("/test")({ + component: ({...props}) =>
Hello World
, + }) + `) + ) + }) + + it("should work with props destructured and spread with a different name", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + export const Route = createFileRoute("/test")({ + component: ({...rest}) =>
Hello World
, + }) + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + export const Route = createFileRoute("/test")({ + component: ({...rest}) =>
Hello World
, + }) + `) + ) + }) + + it("should work with props spread and other normal elements", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + export const Route = createFileRoute("/test")({ + component: ({...rest}) =>
Hello World
, + }) + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + export const Route = createFileRoute("/test")({ + component: ({...rest}) =>
Hello World
+ }); + `) + ) + }) + }) + describe("function declarations", () => { + it("should not duplicate the same property if there are nested functions", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + function Parent({ ...props }) { + function Child({ ...props }) { + return
+ } + return + } + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + function Parent({ ...props }) { + function Child({ ...props }) { + return
; + } + return ; + } + `) + ) + }) + it("should apply data-rrdt-source from parent props if an external import", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + + import Custom from "external"; + +function test({...props }) { + return +} + `, + "test.tsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + import Custom from "external"; + +function test({...props }) { + return ; +}`) + ) + }) + it(" props not destructured", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + function test(props){ + return
) + } + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` +function test(props) { + return
+
; + } +`) + ) + }) + + it("doesn't transform when props are spread across the element but applies to other elements without any props even when props are destructured", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + function test({...props}) { + return (
+
) + } + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` +function test({...props}) { + return
+
; + } +`) + ) + }) + + it("doesn't transform when props are spread across the element but applies to other elements without any props even when props are destructured and name is different", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + function test({...rest}) { + return (
+
) + } + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` +function test({...rest}) { + return
+
; + } +`) + ) + }) + + it(" props destructured and collected with a different name", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + function test({ children, ...rest }) { + return
) + } + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + const ButtonWithProps = function test(props) { + return
+
; + }; +`) + ) + }) + + it("doesn't transform when props are spread across the element but applies to other elements without any props even when props are destructured", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + const ButtonWithProps = function test({...props}) { + return (
+
) + } + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + const ButtonWithProps = function test({...props}) { + return
+
; + }; +`) + ) + }) + + it("doesn't transform when props are spread across the element but applies to other elements without any props even when props are destructured and name is different", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + const ButtonWithProps = function test({...rest}) { + return (
+
) + } + `, + "test.jsx" + ).code + ) + expect(output).toBe( + removeEmptySpace(` + const ButtonWithProps = function test({...rest}) { + return
+
; + }; +`) + ) + }) + + it(" props destructured and collected with a different name", () => { + const output = removeEmptySpace( + addSourceToJsx( + ` + const ButtonWithProps = function test({ children, ...rest }) { + return - - - - - - - - ); -} - +import { + ActionFunctionArgs, + data, + Form, + Links, + LoaderFunctionArgs, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "react-router"; +import { userSomething } from "./modules/user.server"; + + +export const links = () => []; + +export const loader = ({context, devTools }: LoaderFunctionArgs) => { + userSomething(); + const mainPromise = new Promise((resolve, reject) => { + setTimeout(() => { + const subPromise = new Promise((resolve, reject) => { + setTimeout(() => { + resolve("test"); + }, 2000); + }); + resolve({ test: "test", subPromise}); + }, 2000); + }); + console.log("loader called"); + const start =devTools?.tracing.start("test")!; + devTools?.tracing.end("test", start); + return data({ message: "Hello World", mainPromise, bigInt: BigInt(10) }, ); +} + +export const action =async ({devTools}: ActionFunctionArgs) => { + const start = devTools?.tracing.start("action submission") + await new Promise((resolve, reject) => { + setTimeout(() => { + resolve("test"); + }, 2000); + }); + devTools?.tracing.end("action submission", start!) + console.log("action called"); + return ({ message: "Hello World", bigInt: BigInt(10) }); +} + +function App() { + return ( + + + + + + + + +
+ + +
+ + + + + + + ); +} + export { App as default } \ No newline at end of file