From 5ea481731097c4cb193d5a8721f061eef087aec9 Mon Sep 17 00:00:00 2001 From: Keven Arroyo Date: Tue, 17 Mar 2026 23:01:13 -0700 Subject: [PATCH 1/3] refactor(devtools-vite): migrate from Babel to oxc-parser + MagicString --- packages/devtools-vite/package.json | 10 +- packages/devtools-vite/src/ast-utils.ts | 58 +++ packages/devtools-vite/src/babel.ts | 18 - packages/devtools-vite/src/enhance-logs.ts | 129 +++--- .../devtools-vite/src/inject-plugin.test.ts | 295 ++++++------ packages/devtools-vite/src/inject-plugin.ts | 423 ++++++++---------- .../devtools-vite/src/inject-source.test.ts | 232 +++++----- packages/devtools-vite/src/inject-source.ts | 402 +++++++---------- packages/devtools-vite/src/offset-to-loc.ts | 39 ++ .../devtools-vite/src/remove-devtools.test.ts | 109 +++-- packages/devtools-vite/src/remove-devtools.ts | 320 +++++++------ pnpm-lock.yaml | 185 +++++++- 12 files changed, 1141 insertions(+), 1079 deletions(-) create mode 100644 packages/devtools-vite/src/ast-utils.ts delete mode 100644 packages/devtools-vite/src/babel.ts create mode 100644 packages/devtools-vite/src/offset-to-loc.ts diff --git a/packages/devtools-vite/package.json b/packages/devtools-vite/package.json index 83f4a6f6..a2507246 100644 --- a/packages/devtools-vite/package.json +++ b/packages/devtools-vite/package.json @@ -56,21 +56,15 @@ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "dependencies": { - "@babel/core": "^7.28.4", - "@babel/generator": "^7.28.3", - "@babel/parser": "^7.28.4", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", "@tanstack/devtools-client": "workspace:*", "@tanstack/devtools-event-bus": "workspace:*", "chalk": "^5.6.2", "launch-editor": "^2.11.1", + "magic-string": "^0.30.0", + "oxc-parser": "^0.72.0", "picomatch": "^4.0.3" }, "devDependencies": { - "@types/babel__core": "^7.20.5", - "@types/babel__generator": "^7.27.0", - "@types/babel__traverse": "^7.28.0", "@types/picomatch": "^4.0.2", "happy-dom": "^18.0.1" } diff --git a/packages/devtools-vite/src/ast-utils.ts b/packages/devtools-vite/src/ast-utils.ts new file mode 100644 index 00000000..34b754b8 --- /dev/null +++ b/packages/devtools-vite/src/ast-utils.ts @@ -0,0 +1,58 @@ +import type { Node } from 'oxc-parser' + +export function isNode(value: unknown): value is Node { + return typeof value === 'object' && value !== null && 'type' in value +} + +/** + * Cache of keys that hold child nodes (objects/arrays) per AST node type. + * Since oxc-parser produces AST via JSON.parse, every instance of a given + * node type has the same set of keys, so we only need to discover them once. + */ +const childKeysCache = new Map>() + +function getChildKeys(node: Node): Array { + let keys = childKeysCache.get(node.type) + if (keys) return keys + + keys = [] + for (const key in node) { + if (key === 'type' || key === 'start' || key === 'end') continue + // typeof null === 'object', so nullable node fields get cached too + if (typeof (node as any)[key] === 'object') { + keys.push(key) + } + } + childKeysCache.set(node.type, keys) + return keys +} + +/** + * Iterate over the direct child nodes of an AST node. + * Uses a per-type cache of which keys hold child nodes to avoid + * allocating Object.entries() arrays on every call. + */ +export function forEachChild(node: Node, callback: (child: Node) => void) { + const keys = getChildKeys(node) + for (const key of keys) { + const value = (node as any)[key] + if (value === null) continue + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item === 'object' && item !== null && 'type' in item) { + callback(item) + } + } + } else if ('type' in value) { + callback(value as Node) + } + } +} + +/** + * Recursively walk AST nodes, calling `visitor` for each node with a `type`. + */ +export function walk(node: Node, visitor: (node: Node) => void) { + visitor(node) + forEachChild(node, (child) => walk(child, visitor)) +} diff --git a/packages/devtools-vite/src/babel.ts b/packages/devtools-vite/src/babel.ts deleted file mode 100644 index 7a17645f..00000000 --- a/packages/devtools-vite/src/babel.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { parse } from '@babel/parser' -import * as t from '@babel/types' -import generate from '@babel/generator' -import traverse from '@babel/traverse' - -export { parse, t } - -export const trav = - typeof (traverse as any).default !== 'undefined' - ? // eslint-disable-next-line @typescript-eslint/consistent-type-imports - ((traverse as any).default as typeof import('@babel/traverse').default) - : traverse - -export const gen = - typeof (generate as any).default !== 'undefined' - ? // eslint-disable-next-line @typescript-eslint/consistent-type-imports - ((generate as any).default as typeof import('@babel/generator').default) - : generate diff --git a/packages/devtools-vite/src/enhance-logs.ts b/packages/devtools-vite/src/enhance-logs.ts index 8c6d3670..a4584515 100644 --- a/packages/devtools-vite/src/enhance-logs.ts +++ b/packages/devtools-vite/src/enhance-logs.ts @@ -1,100 +1,73 @@ import chalk from 'chalk' import { normalizePath } from 'vite' -import { gen, parse, t, trav } from './babel' -import type { types as Babel } from '@babel/core' -import type { ParseResult } from '@babel/parser' +import MagicString from 'magic-string' +import { parseSync } from 'oxc-parser' +import { createLocMapper } from './offset-to-loc' +import { walk } from './ast-utils' -const transform = ( - ast: ParseResult, - filePath: string, - port: number, -) => { - let didTransform = false +function escapeForStringLiteral(str: string): string { + return str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') +} + +export function enhanceConsoleLog(code: string, id: string, port: number) { + const filePath = id.split('?')[0]! + const location = filePath.replace(normalizePath(process.cwd()), '') + + try { + const result = parseSync(filePath, code, { + sourceType: 'module', + lang: 'tsx', + }) + if (result.errors.length > 0) return + + const offsetToLoc = createLocMapper(code) + const s = new MagicString(code) - trav(ast, { - CallExpression(path) { - const callee = path.node.callee - // Match console.log(...) or console.error(...) + walk(result.program, (node) => { + if (node.type !== 'CallExpression') return + + const callee = node.callee if ( callee.type === 'MemberExpression' && + !callee.computed && callee.object.type === 'Identifier' && callee.object.name === 'console' && callee.property.type === 'Identifier' && (callee.property.name === 'log' || callee.property.name === 'error') ) { - const location = path.node.loc - if (!location) { - return - } - const [lineNumber, column] = [ - location.start.line, - location.start.column, - ] - const finalPath = `${filePath}:${lineNumber}:${column + 1}` + const loc = offsetToLoc(node.start) + const [lineNumber, column] = [loc.line, loc.column] + const finalPath = `${location}:${lineNumber}:${column + 1}` const logMessage = `${chalk.magenta('LOG')} ${chalk.blueBright(`${finalPath}`)}\n → ` - const serverLogMessage = t.arrayExpression([ - t.stringLiteral(logMessage), - ]) - const browserLogMessage = t.arrayExpression([ - // LOG with css formatting specifiers: %c - t.stringLiteral( - `%c${'LOG'}%c %c${`Go to Source: http://localhost:${port}/__tsd/open-source?source=${encodeURIComponent(finalPath)}`}%c \n → `, - ), - // magenta - t.stringLiteral('color:#A0A'), - t.stringLiteral('color:#FFF'), - // blueBright - t.stringLiteral('color:#55F'), - t.stringLiteral('color:#FFF'), - ]) + const serverLogMessage = `["${escapeForStringLiteral(logMessage)}"]` + const browserLogMessage = `["%c${'LOG'}%c %c${`Go to Source: http://localhost:${port}/__tsd/open-source?source=${encodeURIComponent(finalPath)}`}%c \\n \\u2192 ","color:#A0A","color:#FFF","color:#55F","color:#FFF"]` - // typeof window === "undefined" - const checkServerCondition = t.binaryExpression( - '===', - t.unaryExpression('typeof', t.identifier('window')), - t.stringLiteral('undefined'), - ) + const spreadStr = `...(typeof window === "undefined" ? ${serverLogMessage} : ${browserLogMessage}), ` - // ...(isServer ? serverLogMessage : browserLogMessage) - path.node.arguments.unshift( - t.spreadElement( - t.conditionalExpression( - checkServerCondition, - serverLogMessage, - browserLogMessage, - ), - ), - ) - - didTransform = true + // Find the opening '(' of the call by scanning forward from callee end + let parenOffset = callee.end + while (parenOffset < code.length && code[parenOffset] !== '(') { + parenOffset++ + } + // Insert right after '(' + s.appendRight(parenOffset + 1, spreadStr) } - }, - }) - - return didTransform -} + }) -export function enhanceConsoleLog(code: string, id: string, port: number) { - const [filePath] = id.split('?') - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - const location = filePath?.replace(normalizePath(process.cwd()), '')! + if (!s.hasChanged()) return - try { - const ast = parse(code, { - sourceType: 'module', - plugins: ['jsx', 'typescript'], - }) - const didTransform = transform(ast, location, port) - if (!didTransform) { - return + return { + code: s.toString(), + map: s.generateMap({ + source: filePath, + file: id, + includeContent: true, + }), } - return gen(ast, { - sourceMaps: true, - retainLines: true, - filename: id, - sourceFileName: filePath, - }) } catch (e) { return } diff --git a/packages/devtools-vite/src/inject-plugin.test.ts b/packages/devtools-vite/src/inject-plugin.test.ts index 933b98d4..1457c72f 100644 --- a/packages/devtools-vite/src/inject-plugin.test.ts +++ b/packages/devtools-vite/src/inject-plugin.test.ts @@ -4,7 +4,6 @@ import { findDevtoolsComponentName, transformAndInject, } from './inject-plugin' -import { gen, parse } from './babel' const removeEmptySpace = (str: string) => { return str.replace(/\s/g, '').trim() @@ -20,27 +19,21 @@ const testTransform = ( type: 'jsx' | 'function' }, ) => { - const ast = parse(code, { - sourceType: 'module', - plugins: ['jsx', 'typescript'], - }) - - const devtoolsComponentName = findDevtoolsComponentName(ast) + const devtoolsComponentName = findDevtoolsComponentName(code) if (!devtoolsComponentName) { return { transformed: false, code } } - const didTransform = transformAndInject( - ast, + const result = transformAndInject( + code, { packageName, pluginName, pluginImport }, devtoolsComponentName, ) - if (!didTransform) { + if (!result?.transformed) { return { transformed: false, code } } - const result = gen(ast, { sourceMaps: false, retainLines: false }) return { transformed: true, code: result.code } } @@ -103,7 +96,7 @@ describe('inject-plugin', () => { test('should add plugin to existing empty plugins array', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' - + function App() { return } @@ -122,14 +115,14 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/react-devtools'; + import { TanStackDevtools } from '@tanstack/react-devtools' import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; - + function App() { return - }]} />; + }]} /> } `), ) @@ -139,7 +132,7 @@ describe('inject-plugin', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' import { OtherPlugin } from '@tanstack/other-plugin' - + function App() { return } @@ -160,10 +153,10 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/react-devtools'; - import { OtherPlugin } from '@tanstack/other-plugin'; + import { TanStackDevtools } from '@tanstack/react-devtools' + import { OtherPlugin } from '@tanstack/other-plugin' import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; - + function App() { return }, @@ -171,7 +164,7 @@ describe('inject-plugin', () => { name: "TanStack Query", render: } - ]} />; + ]} /> } `), ) @@ -180,7 +173,7 @@ describe('inject-plugin', () => { test('should create plugins prop if it does not exist', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' - + function App() { return } @@ -199,14 +192,14 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/react-devtools'; + import { TanStackDevtools } from '@tanstack/react-devtools' import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; - + function App() { return - }]} />; + }]} /> } `), ) @@ -215,7 +208,7 @@ describe('inject-plugin', () => { test('should create plugins prop with other existing props', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' - + function App() { return } @@ -234,14 +227,14 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/react-devtools'; + import { TanStackDevtools } from '@tanstack/react-devtools' import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; - + function App() { return - }]} />; + }]} /> } `), ) @@ -251,7 +244,7 @@ describe('inject-plugin', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools' - + function App() { return } @@ -277,7 +270,7 @@ describe('inject-plugin', () => { test('should handle renamed named import', () => { const code = ` import { TanStackDevtools as DevtoolsPanel } from '@tanstack/react-devtools' - + function App() { return } @@ -296,14 +289,14 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools as DevtoolsPanel } from '@tanstack/react-devtools'; + import { TanStackDevtools as DevtoolsPanel } from '@tanstack/react-devtools' import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; - + function App() { return - }]} />; + }]} /> } `), ) @@ -312,7 +305,7 @@ describe('inject-plugin', () => { test('should handle renamed import without plugins prop', () => { const code = ` import { TanStackDevtools as MyDevtools } from '@tanstack/solid-devtools' - + function App() { return } @@ -331,14 +324,14 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools as MyDevtools } from '@tanstack/solid-devtools'; + import { TanStackDevtools as MyDevtools } from '@tanstack/solid-devtools' import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; - + function App() { return - }]} />; + }]} /> } `), ) @@ -349,7 +342,7 @@ describe('inject-plugin', () => { test('should handle namespace import', () => { const code = ` import * as DevtoolsModule from '@tanstack/react-devtools' - + function App() { return } @@ -368,14 +361,14 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import * as DevtoolsModule from '@tanstack/react-devtools'; + import * as DevtoolsModule from '@tanstack/react-devtools' import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; - + function App() { return - }]} />; + }]} /> } `), ) @@ -384,7 +377,7 @@ describe('inject-plugin', () => { test('should handle namespace import without plugins prop', () => { const code = ` import * as TSD from '@tanstack/solid-devtools' - + function App() { return } @@ -403,14 +396,14 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import * as TSD from '@tanstack/solid-devtools'; + import * as TSD from '@tanstack/solid-devtools' import { SolidRouterDevtoolsPanel } from "@tanstack/solid-router-devtools"; - + function App() { return - }]} />; + }]} /> } `), ) @@ -421,7 +414,7 @@ describe('inject-plugin', () => { test('should handle router devtools', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' - + function App() { return } @@ -440,14 +433,14 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/react-devtools'; + import { TanStackDevtools } from '@tanstack/react-devtools' import { ReactRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; - + function App() { return - }]} />; + }]} /> } `), ) @@ -456,7 +449,7 @@ describe('inject-plugin', () => { test('should handle form devtools', () => { const code = ` import { TanStackDevtools } from '@tanstack/solid-devtools' - + function App() { return } @@ -475,14 +468,14 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/solid-devtools'; + import { TanStackDevtools } from '@tanstack/solid-devtools' import { ReactFormDevtoolsPanel } from "@tanstack/react-form-devtools"; - + function App() { return - }]} />; + }]} /> } `), ) @@ -491,7 +484,7 @@ describe('inject-plugin', () => { test('should handle query devtools', () => { const code = ` import { TanStackDevtools } from '@tanstack/vue-devtools' - + function App() { return } @@ -510,14 +503,14 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/vue-devtools'; + import { TanStackDevtools } from '@tanstack/vue-devtools' import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; - + function App() { return - }]} />; + }]} /> } `), ) @@ -528,7 +521,7 @@ describe('inject-plugin', () => { test('should not transform files without TanStackDevtools component', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' - + function App() { return
Hello World
} @@ -550,7 +543,7 @@ describe('inject-plugin', () => { test('should handle TanStackDevtools with children', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' - + function App() { return ( @@ -573,16 +566,18 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/react-devtools'; + import { TanStackDevtools } from '@tanstack/react-devtools' import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; - + function App() { - return - }]}> -
Custom content
-
; + return ( + + }]}> +
Custom content
+
+ ) } `), ) @@ -591,7 +586,7 @@ describe('inject-plugin', () => { test('should handle multiple TanStackDevtools in same file', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' - + function App() { return ( <> @@ -615,20 +610,22 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/react-devtools'; + import { TanStackDevtools } from '@tanstack/react-devtools' import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; - + function App() { - return <> - - }]} /> - - }]} /> - ; + return ( + <> + + }]} /> + + }]} /> + + ) } `), ) @@ -637,7 +634,7 @@ describe('inject-plugin', () => { test('should handle TanStackDevtools deeply nested', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' - + function App() { return (
@@ -664,20 +661,22 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/react-devtools'; + import { TanStackDevtools } from '@tanstack/react-devtools' import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; - + function App() { - return
-
- -
-
; + return ( +
+
+ +
+
+ ) } `), ) @@ -687,10 +686,10 @@ describe('inject-plugin', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' import { useState } from 'react' - + function App() { const [count, setCount] = useState(0) - + return (
- - }]} /> -
; + const [count, setCount] = useState(0) + return ( +
+ + + }]} /> +
+ ) } `), ) @@ -739,7 +740,7 @@ describe('inject-plugin', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' import type { FC } from 'react' - + const App: FC = () => { return } @@ -758,16 +759,16 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/react-devtools'; - import type { FC } from 'react'; + import { TanStackDevtools } from '@tanstack/react-devtools' + import type { FC } from 'react' import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; - + const App: FC = () => { return - }]} />; - }; + }]} /> + } `), ) }) @@ -775,7 +776,7 @@ describe('inject-plugin', () => { test('should handle plugins array with trailing comma', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' - + function App() { return }, @@ -796,9 +797,9 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/react-devtools'; + import { TanStackDevtools } from '@tanstack/react-devtools' import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; - + function App() { return }, @@ -806,7 +807,7 @@ describe('inject-plugin', () => { name: "TanStack Query", render: } - ]} />; + ]} /> } `), ) @@ -815,7 +816,7 @@ describe('inject-plugin', () => { test('should not transform if devtools import not found', () => { const code = ` import { SomeOtherComponent } from 'some-package' - + function App() { return } @@ -839,7 +840,7 @@ describe('inject-plugin', () => { test('should add function plugin to empty plugins array', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' - + function App() { return } @@ -858,11 +859,11 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/react-devtools'; + import { TanStackDevtools } from '@tanstack/react-devtools' import { FormDevtoolsPlugin } from "@tanstack/react-form-devtools"; - + function App() { - return ; + return } `), ) @@ -872,7 +873,7 @@ describe('inject-plugin', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools' - + function App() { return } @@ -893,15 +894,15 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/react-devtools'; - import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'; + import { TanStackDevtools } from '@tanstack/react-devtools' + import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools' import { FormDevtoolsPlugin } from "@tanstack/react-form-devtools"; - + function App() { return }, FormDevtoolsPlugin() - ]} />; + ]} /> } `), ) @@ -910,7 +911,7 @@ describe('inject-plugin', () => { test('should create plugins prop with function plugin when it does not exist', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' - + function App() { return } @@ -929,11 +930,11 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/react-devtools'; + import { TanStackDevtools } from '@tanstack/react-devtools' import { FormDevtoolsPlugin } from "@tanstack/react-form-devtools"; - + function App() { - return ; + return } `), ) @@ -943,7 +944,7 @@ describe('inject-plugin', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' import { FormDevtoolsPlugin } from '@tanstack/react-form-devtools' - + function App() { return } @@ -965,7 +966,7 @@ describe('inject-plugin', () => { test('should handle function plugin with renamed devtools import', () => { const code = ` import { TanStackDevtools as DevtoolsPanel } from '@tanstack/react-devtools' - + function App() { return } @@ -984,11 +985,11 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools as DevtoolsPanel } from '@tanstack/react-devtools'; + import { TanStackDevtools as DevtoolsPanel } from '@tanstack/react-devtools' import { FormDevtoolsPlugin } from "@tanstack/react-form-devtools"; - + function App() { - return ; + return } `), ) @@ -997,7 +998,7 @@ describe('inject-plugin', () => { test('should handle function plugin with namespace import', () => { const code = ` import * as DevtoolsModule from '@tanstack/solid-devtools' - + function App() { return } @@ -1016,11 +1017,11 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import * as DevtoolsModule from '@tanstack/solid-devtools'; + import * as DevtoolsModule from '@tanstack/solid-devtools' import { FormDevtoolsPlugin } from "@tanstack/solid-form-devtools"; - + function App() { - return ; + return } `), ) @@ -1030,7 +1031,7 @@ describe('inject-plugin', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' import { FormDevtoolsPlugin } from '@tanstack/react-form-devtools' - + function App() { return } @@ -1049,12 +1050,12 @@ describe('inject-plugin', () => { expect(result.transformed).toBe(true) expect(removeEmptySpace(result.code)).toBe( removeEmptySpace(` - import { TanStackDevtools } from '@tanstack/react-devtools'; - import { FormDevtoolsPlugin } from '@tanstack/react-form-devtools'; + import { TanStackDevtools } from '@tanstack/react-devtools' + import { FormDevtoolsPlugin } from '@tanstack/react-form-devtools' import { RouterDevtoolsPlugin } from "@tanstack/react-router-devtools"; - + function App() { - return ; + return } `), ) @@ -1063,7 +1064,7 @@ describe('inject-plugin', () => { test('should not transform when pluginImport is not provided', () => { const code = ` import { TanStackDevtools } from '@tanstack/react-devtools' - + function App() { return } diff --git a/packages/devtools-vite/src/inject-plugin.ts b/packages/devtools-vite/src/inject-plugin.ts index 570ce667..797182f4 100644 --- a/packages/devtools-vite/src/inject-plugin.ts +++ b/packages/devtools-vite/src/inject-plugin.ts @@ -1,40 +1,39 @@ import { readFileSync, writeFileSync } from 'node:fs' -import { gen, parse, t, trav } from './babel' +import MagicString from 'magic-string' +import { parseSync } from 'oxc-parser' +import { walk } from './ast-utils' +import type { Node } from 'oxc-parser' import type { PluginInjection } from '@tanstack/devtools-client' -import type { types as Babel } from '@babel/core' -import type { ParseResult } from '@babel/parser' + +const devtoolsPackages = [ + '@tanstack/react-devtools', + '@tanstack/solid-devtools', + '@tanstack/vue-devtools', + '@tanstack/svelte-devtools', + '@tanstack/angular-devtools', +] /** * Detects if a file imports TanStack devtools packages - * Handles: import X from '@tanstack/react-devtools' - * import * as X from '@tanstack/react-devtools' - * import { TanStackDevtools } from '@tanstack/react-devtools' */ const detectDevtoolsImport = (code: string): boolean => { - const devtoolsPackages = [ - '@tanstack/react-devtools', - '@tanstack/solid-devtools', - '@tanstack/vue-devtools', - '@tanstack/svelte-devtools', - '@tanstack/angular-devtools', - ] - try { - const ast = parse(code, { + const result = parseSync('input.tsx', code, { sourceType: 'module', - plugins: ['jsx', 'typescript'], + lang: 'tsx', }) + if (result.errors.length > 0) return false let hasDevtoolsImport = false - trav(ast, { - ImportDeclaration(path) { - const importSource = path.node.source.value - if (devtoolsPackages.includes(importSource)) { - hasDevtoolsImport = true - path.stop() - } - }, + walk(result.program, (node) => { + if (hasDevtoolsImport) return + if ( + node.type === 'ImportDeclaration' && + devtoolsPackages.includes(node.source.value) + ) { + hasDevtoolsImport = true + } }) return hasDevtoolsImport @@ -47,232 +46,200 @@ const detectDevtoolsImport = (code: string): boolean => { * Finds the TanStackDevtools component name in the file * Handles renamed imports and namespace imports */ -export const findDevtoolsComponentName = ( - ast: ParseResult, -): string | null => { - let componentName: string | null = null - const devtoolsPackages = [ - '@tanstack/react-devtools', - '@tanstack/solid-devtools', - '@tanstack/vue-devtools', - '@tanstack/svelte-devtools', - '@tanstack/angular-devtools', - ] - - trav(ast, { - ImportDeclaration(path) { - const importSource = path.node.source.value - if (devtoolsPackages.includes(importSource)) { - // Check for: import { TanStackDevtools } from '@tanstack/...' - const namedImport = path.node.specifiers.find( - (spec) => - t.isImportSpecifier(spec) && - t.isIdentifier(spec.imported) && - spec.imported.name === 'TanStackDevtools', - ) - if (namedImport && t.isImportSpecifier(namedImport)) { - componentName = namedImport.local.name - path.stop() +export const findDevtoolsComponentName = (code: string): string | null => { + try { + const result = parseSync('input.tsx', code, { + sourceType: 'module', + lang: 'tsx', + }) + if (result.errors.length > 0) return null + + let componentName: string | null = null + + walk(result.program, (node) => { + if (componentName) return + if (node.type !== 'ImportDeclaration') return + if (!devtoolsPackages.includes(node.source.value)) return + + for (const spec of node.specifiers) { + // import { TanStackDevtools } or import { TanStackDevtools as X } + if ( + spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'TanStackDevtools' + ) { + componentName = spec.local.name return } - - // Check for: import * as DevtoolsName from '@tanstack/...' - const namespaceImport = path.node.specifiers.find((spec) => - t.isImportNamespaceSpecifier(spec), - ) - if (namespaceImport && t.isImportNamespaceSpecifier(namespaceImport)) { - // For namespace imports, we need to look for DevtoolsName.TanStackDevtools - componentName = `${namespaceImport.local.name}.TanStackDevtools` - path.stop() + // import * as X from '...' + if (spec.type === 'ImportNamespaceSpecifier') { + componentName = `${spec.local.name}.TanStackDevtools` return } } - }, - }) + }) - return componentName + return componentName + } catch (e) { + return null + } +} + +/** + * Check if a plugin already exists in the array expression + */ +function pluginExists( + code: string, + node: Node, + importName: string, + displayName: string, + pluginType: string, +): boolean { + if (node.type !== 'ArrayExpression') return false + + for (const element of node.elements) { + if (!element) continue + + if (pluginType === 'function') { + if ( + element.type === 'CallExpression' && + element.callee.type === 'Identifier' && + element.callee.name === importName + ) { + return true + } + } else { + if (element.type !== 'ObjectExpression') continue + for (const prop of element.properties) { + if ( + prop.type === 'Property' && + prop.key.type === 'Identifier' && + prop.key.name === 'name' && + prop.value.type === 'Literal' && + code.slice(prop.value.start + 1, prop.value.end - 1) === displayName + ) { + return true + } + } + } + } + + return false +} + +function buildPluginString( + importName: string, + displayName: string, + pluginType: string, +): string { + if (pluginType === 'function') { + return `${importName}()` + } + return `{ name: "${displayName}", render: <${importName} /> }` } export const transformAndInject = ( - ast: ParseResult, + code: string, injection: PluginInjection, devtoolsComponentName: string, -) => { - let didTransform = false - - // Use pluginImport if provided, otherwise generate from package name +): { code: string; transformed: boolean } | null => { const importName = injection.pluginImport?.importName const pluginType = injection.pluginImport?.type || 'jsx' const displayName = injection.pluginName if (!importName) { - return false + return null } - // Handle namespace imports like DevtoolsModule.TanStackDevtools - const isNamespaceImport = devtoolsComponentName.includes('.') - // Find and modify the TanStackDevtools JSX element - trav(ast, { - JSXOpeningElement(path) { - const elementName = path.node.name - let matches = false + try { + const result = parseSync('input.tsx', code, { + sourceType: 'module', + lang: 'tsx', + }) + if (result.errors.length > 0) return null + + const s = new MagicString(code) + const isNamespaceImport = devtoolsComponentName.includes('.') + walk(result.program, (node) => { + if (node.type !== 'JSXOpeningElement') return + + let matches = false if (isNamespaceImport) { - // Handle - if (t.isJSXMemberExpression(elementName)) { - const fullName = `${t.isJSXIdentifier(elementName.object) ? elementName.object.name : ''}.${t.isJSXIdentifier(elementName.property) ? elementName.property.name : ''}` + if (node.name.type === 'JSXMemberExpression') { + const fullName = `${node.name.object.type === 'JSXIdentifier' ? node.name.object.name : ''}.${node.name.property.name}` matches = fullName === devtoolsComponentName } } else { - // Handle or matches = - t.isJSXIdentifier(elementName) && - elementName.name === devtoolsComponentName + node.name.type === 'JSXIdentifier' && + node.name.name === devtoolsComponentName } - if (matches) { - // Find the plugins prop - const pluginsProp = path.node.attributes.find( - (attr) => - t.isJSXAttribute(attr) && - t.isJSXIdentifier(attr.name) && - attr.name.name === 'plugins', - ) - // plugins found - if (pluginsProp && t.isJSXAttribute(pluginsProp)) { - // Check if plugins prop has a value - if ( - pluginsProp.value && - t.isJSXExpressionContainer(pluginsProp.value) - ) { - const expression = pluginsProp.value.expression - - // If it's an array expression, add our plugin to it - if (t.isArrayExpression(expression)) { - // Check if plugin already exists - const pluginExists = expression.elements.some((element) => { - if (!element) return false - - // For function-based plugins, check if the function call exists - if (pluginType === 'function') { - return ( - t.isCallExpression(element) && - t.isIdentifier(element.callee) && - element.callee.name === importName - ) - } - - // For JSX plugins, check object with name property - if (!t.isObjectExpression(element)) return false - - return element.properties.some((prop) => { - if ( - !t.isObjectProperty(prop) || - !t.isIdentifier(prop.key) || - prop.key.name !== 'name' - ) { - return false - } - - return ( - t.isStringLiteral(prop.value) && - prop.value.value === displayName - ) - }) - }) - - if (!pluginExists) { - // For function-based plugins, add them directly as function calls - // For JSX plugins, wrap them in objects with name and render - if (pluginType === 'function') { - // Add directly: FormDevtoolsPlugin() - expression.elements.push( - t.callExpression(t.identifier(importName), []), - ) - } else { - // Add as object: { name: "...", render: } - const renderValue = t.jsxElement( - t.jsxOpeningElement(t.jsxIdentifier(importName), [], true), - null, - [], - true, - ) - - expression.elements.push( - t.objectExpression([ - t.objectProperty( - t.identifier('name'), - t.stringLiteral(displayName), - ), - t.objectProperty(t.identifier('render'), renderValue), - ]), - ) - } - - didTransform = true + if (!matches) return + + // Find the plugins prop + const pluginsProp = node.attributes.find( + (attr) => + attr.type === 'JSXAttribute' && + attr.name.type === 'JSXIdentifier' && + attr.name.name === 'plugins', + ) + + if (pluginsProp && pluginsProp.type === 'JSXAttribute') { + if ( + pluginsProp.value && + pluginsProp.value.type === 'JSXExpressionContainer' + ) { + const expression = pluginsProp.value.expression + if (expression.type === 'ArrayExpression') { + if ( + !pluginExists(code, expression, importName, displayName, pluginType) + ) { + const pluginStr = buildPluginString( + importName, + displayName, + pluginType, + ) + // Insert before closing ']' + const arrayEnd = expression.end - 1 + let prefix = '' + if (expression.elements.length > 0) { + const arrayContent = code.slice(expression.start + 1, arrayEnd) + prefix = arrayContent.trimEnd().endsWith(',') ? ' ' : ', ' } + s.appendLeft(arrayEnd, prefix + pluginStr) } } + } + } else { + // No plugins prop — create one + const pluginStr = buildPluginString(importName, displayName, pluginType) + const attrStr = ` plugins={[${pluginStr}]}` + if (node.selfClosing) { + s.appendLeft(node.end - 2, attrStr) } else { - // No plugins prop exists, create one with our plugin - // For function-based plugins, add them directly as function calls - // For JSX plugins, wrap them in objects with name and render - let pluginElement - if (pluginType === 'function') { - // Add directly: plugins={[FormDevtoolsPlugin()]} - pluginElement = t.callExpression(t.identifier(importName), []) - } else { - // Add as object: plugins={[{ name: "...", render: }]} - const renderValue = t.jsxElement( - t.jsxOpeningElement(t.jsxIdentifier(importName), [], true), - null, - [], - true, - ) - - pluginElement = t.objectExpression([ - t.objectProperty( - t.identifier('name'), - t.stringLiteral(displayName), - ), - t.objectProperty(t.identifier('render'), renderValue), - ]) - } - - path.node.attributes.push( - t.jsxAttribute( - t.jsxIdentifier('plugins'), - t.jsxExpressionContainer(t.arrayExpression([pluginElement])), - ), - ) - - didTransform = true + s.appendLeft(node.end - 1, attrStr) } } - }, - }) - - // Add import at the top of the file if transform happened - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (didTransform) { - const importDeclaration = t.importDeclaration( - [t.importSpecifier(t.identifier(importName), t.identifier(importName))], - t.stringLiteral(injection.packageName), - ) - - // Find the last import declaration - let lastImportIndex = -1 - ast.program.body.forEach((node, index) => { - if (t.isImportDeclaration(node)) { - lastImportIndex = index - } }) - // Insert after the last import or at the beginning - ast.program.body.splice(lastImportIndex + 1, 0, importDeclaration) - } + // Add import at the top of the file if transform happened + if (s.hasChanged()) { + const importStr = `\nimport { ${importName} } from "${injection.packageName}";` + let lastImportEnd = 0 + walk(result.program, (n) => { + if (n.type === 'ImportDeclaration' && n.end > lastImportEnd) { + lastImportEnd = n.end + } + }) + s.appendRight(lastImportEnd, importStr) + } - return didTransform + return { code: s.toString(), transformed: s.hasChanged() } + } catch (e) { + return null + } } /** @@ -291,17 +258,9 @@ export function injectPluginIntoFile( injection: PluginInjection, ): { success: boolean; error?: string } { try { - // Read the file const code = readFileSync(filePath, 'utf-8') - // Parse the code - const ast = parse(code, { - sourceType: 'module', - plugins: ['jsx', 'typescript'], - }) - - // Find the devtools component name (handles renamed imports) - const devtoolsComponentName = findDevtoolsComponentName(ast) + const devtoolsComponentName = findDevtoolsComponentName(code) if (!devtoolsComponentName) { return { success: false, @@ -309,27 +268,15 @@ export function injectPluginIntoFile( } } - // Transform and inject - const didTransform = transformAndInject( - ast, - injection, - devtoolsComponentName, - ) + const result = transformAndInject(code, injection, devtoolsComponentName) - if (!didTransform) { + if (!result?.transformed) { return { success: false, error: 'Plugin already exists or no TanStackDevtools component found', } } - // Generate the new code - const result = gen(ast, { - sourceMaps: false, - retainLines: false, - }) - - // Write back to file writeFileSync(filePath, result.code, 'utf-8') return { success: true } diff --git a/packages/devtools-vite/src/inject-source.test.ts b/packages/devtools-vite/src/inject-source.test.ts index f2a344aa..68f67e1b 100644 --- a/packages/devtools-vite/src/inject-source.test.ts +++ b/packages/devtools-vite/src/inject-source.test.ts @@ -54,9 +54,9 @@ describe('inject source', () => { ) expect(output).toBe( removeEmptySpace(` - export const Route = createFileRoute("/test")({ - component: function() { return
Hello World
; } - }); + export const Route = createFileRoute("/test")({ + component: function() { return
Hello World
}, + }) `), ) }) @@ -102,7 +102,7 @@ describe('inject source', () => { addSourceToJsx( ` export const Route = createFileRoute("/test")({ - component: function({...rest}) { return
Hello World
} + component: function({...rest}) { return
Hello World
} }) `, 'test.jsx', @@ -110,9 +110,9 @@ describe('inject source', () => { ) expect(output).toBe( removeEmptySpace(` - export const Route = createFileRoute("/test")({ - component: function({...rest}) { return
Hello World
; } - }); + export const Route = createFileRoute("/test")({ + component: function({...rest}) { return
Hello World
} + }) `), ) }) @@ -132,9 +132,9 @@ describe('inject source', () => { ) expect(output).toBe( removeEmptySpace(` - export const Route = createFileRoute("/test")({ - component: () =>
Hello World
- }); + export const Route = createFileRoute("/test")({ + component: () =>
Hello World
, + }) `), ) }) @@ -167,7 +167,7 @@ describe('inject source', () => { const output = addSourceToJsx( ` export const Route = createFileRoute("/test")({ - component: ({...rest}) =>
Hello World
, + component: ({...rest}) =>
Hello World
, }) `, 'test.jsx', @@ -180,7 +180,7 @@ describe('inject source', () => { addSourceToJsx( ` export const Route = createFileRoute("/test")({ - component: ({...rest}) =>
Hello World
, + component: ({...rest}) =>
Hello World
, }) `, 'test.jsx', @@ -188,9 +188,9 @@ describe('inject source', () => { ) expect(output).toBe( removeEmptySpace(` - export const Route = createFileRoute("/test")({ - component: ({...rest}) =>
Hello World
- }); + export const Route = createFileRoute("/test")({ + component: ({...rest}) =>
Hello World
, + }) `), ) }) @@ -212,12 +212,12 @@ describe('inject source', () => { ) expect(output).toBe( removeEmptySpace(` - function Parent({ ...props }) { - function Child({ ...props }) { - return
; - } - return ; - } + function Parent({ ...props }) { + function Child({ ...props }) { + return
+ } + return + } `), ) }) @@ -225,7 +225,7 @@ describe('inject source', () => { const output = removeEmptySpace( addSourceToJsx( ` - + import Custom from "external"; function test({...props }) { @@ -237,11 +237,13 @@ function test({...props }) { ) expect(output).toBe( removeEmptySpace(` - import Custom from "external"; + + import Custom from "external"; function test({...props }) { - return ; -}`), + return +} + `), ) }) it(' props not destructured', () => { @@ -257,10 +259,10 @@ function test({...props }) { ) expect(output).toBe( removeEmptySpace(` -function test(props) { - return
; + function test(props) { + return (
+
) } -`), + `), ) }) @@ -315,12 +317,12 @@ function test(props) { ) expect(output).toBe( removeEmptySpace(` -function test({...props}) { - return
-
; + function test({...props}) { + return (
+
) } -`), + `), ) }) @@ -339,12 +341,12 @@ function test({...props}) { ) expect(output).toBe( removeEmptySpace(` -function test({...rest}) { - return
-
; + function test({...rest}) { + return (
+
) } -`), + `), ) }) @@ -374,9 +376,9 @@ function test({...rest}) { expect(output).toBe( removeEmptySpace(` function test({ ...props }) { - return
; - }; -`), + const ButtonWithProps = function test(props) { + return (
+
) + } + `), ) }) @@ -516,12 +518,12 @@ function test({...rest}) { ) expect(output).toBe( removeEmptySpace(` - const ButtonWithProps = function test({...props}) { - return
-
; - }; -`), + const ButtonWithProps = function test({...props}) { + return (
+
) + } + `), ) }) @@ -540,12 +542,12 @@ function test({...rest}) { ) expect(output).toBe( removeEmptySpace(` - const ButtonWithProps = function test({...rest}) { - return
-
; - }; -`), + const ButtonWithProps = function test({...rest}) { + return (
+
) + } + `), ) }) @@ -575,9 +577,9 @@ function test({...rest}) { expect(output).toBe( removeEmptySpace(` const ButtonWithProps = function test({ ...props }) { - return
; - }; -`), + const ButtonWithProps = (props) => { + return (
+
) + } + `), ) }) @@ -717,12 +719,12 @@ function test({...rest}) { ) expect(output).toBe( removeEmptySpace(` - const ButtonWithProps = ({...props}) => { - return
-
; - }; -`), + const ButtonWithProps = ({...props}) => { + return (
+
) + } + `), ) }) @@ -741,12 +743,12 @@ function test({...rest}) { ) expect(output).toBe( removeEmptySpace(` - const ButtonWithProps = ({...rest}) => { - return
-
; - }; -`), + const ButtonWithProps = ({...rest}) => { + return (
+
) + } + `), ) }) @@ -763,10 +765,10 @@ function test({...rest}) { ) expect(output).toBe( removeEmptySpace(` - const ButtonWithProps = ({ children, ...rest }) => { - return