diff --git a/.changeset/common-bottles-move.md b/.changeset/common-bottles-move.md new file mode 100644 index 00000000..cb82a681 --- /dev/null +++ b/.changeset/common-bottles-move.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools-vite': minor +--- + +migrate from Babel to oxc-parser + MagicString diff --git a/_artifacts/domain_map.yaml b/_artifacts/domain_map.yaml index c92dedf9..c75d6c57 100644 --- a/_artifacts/domain_map.yaml +++ b/_artifacts/domain_map.yaml @@ -231,7 +231,7 @@ skills: - mistake: 'Source injection on spread props elements' mechanism: > - The Babel transform skips elements with {...props} spread to avoid + The AST transform skips elements with {...props} spread to avoid overwriting dynamic attributes. Agent might not realize source inspector wont work on those elements. source: 'packages/devtools-vite/src/inject-source.ts' diff --git a/docs/architecture.md b/docs/architecture.md index c9065d87..e9a79802 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -194,7 +194,7 @@ Adapters do not re-implement the devtools UI, manage settings, handle events, or `@tanstack/devtools-vite` is a collection of Vite plugins that enhance the development experience and clean up production builds. It returns an array of Vite plugins, each handling a specific concern: ### Source injection (`@tanstack/devtools:inject-source`) -Uses Babel to parse JSX/TSX files and injects `data-tsd-source` attributes on every JSX element. These attributes encode the file path, line number, and column number of each element in source code, which the source inspector feature uses to implement click-to-open-in-editor. +Parses JSX/TSX files with oxc-parser and injects `data-tsd-source` attributes on every JSX element via MagicString. These attributes encode the file path, line number, and column number of each element in source code, which the source inspector feature uses to implement click-to-open-in-editor. ### Server event bus (`@tanstack/devtools:custom-server`) Starts a `ServerEventBus` on the Vite dev server. Also sets up middleware for the go-to-source editor integration and bidirectional console piping (client logs appear in the terminal, server logs appear in the browser). diff --git a/docs/source-inspector.md b/docs/source-inspector.md index e49535db..eea30815 100644 --- a/docs/source-inspector.md +++ b/docs/source-inspector.md @@ -18,13 +18,13 @@ The feature only works in development. In production builds, source attributes a ```mermaid flowchart LR - A["Your JSX/TSX files"] -- "Babel transform" --> B["data-tsd-source
attributes injected"] + A["Your JSX/TSX files"] -- "AST transform" --> B["data-tsd-source
attributes injected"] B -- "Hold inspect hotkey
+ click element" --> C["Devtools reads
data-tsd-source"] C -- "HTTP request" --> D["Vite dev server"] D -- "launch-editor" --> E["Opens file in editor
at exact line"] ``` -The Vite plugin uses Babel to parse your JSX/TSX files during development. It adds a `data-tsd-source="filepath:line:column"` attribute to every JSX element. When you activate the source inspector and click an element, the devtools reads this attribute and sends a request to the Vite dev server. The server then launches your editor at the specified file and line using `launch-editor`. +The Vite plugin uses oxc-parser to parse your JSX/TSX files during development. It adds a `data-tsd-source="filepath:line:column"` attribute to every JSX element via MagicString. When you activate the source inspector and click an element, the devtools reads this attribute and sends a request to the Vite dev server. The server then launches your editor at the specified file and line using `launch-editor`. ## Activating the Inspector diff --git a/docs/vite-plugin.md b/docs/vite-plugin.md index fa9a4ecb..4fefa774 100644 --- a/docs/vite-plugin.md +++ b/docs/vite-plugin.md @@ -209,7 +209,7 @@ The Vite plugin is composed of several sub-plugins, each handling a specific con ```mermaid graph TD vite["@tanstack/devtools-vite"] - vite --> source["Source Injection
Babel → data-tsd-source attrs"] + vite --> source["Source Injection
AST → data-tsd-source attrs"] vite --> server["Server Event Bus
WebSocket + SSE transport"] vite --> strip["Production Stripping
Remove devtools on build"] vite --> pipe["Console Piping
Client ↔ Server logs"] @@ -220,7 +220,7 @@ graph TD ### Go to Source -The "Go to Source" feature lets you click on any element in your browser and open its source file in your editor at the exact line where it's defined. It works by injecting `data-tsd-source` attributes into your components via a Babel transformation during development. These attributes encode the file path and line number of each element. +The "Go to Source" feature lets you click on any element in your browser and open its source file in your editor at the exact line where it's defined. It works by injecting `data-tsd-source` attributes into your components via an AST transformation during development. These attributes encode the file path and line number of each element. To use it, activate the source inspector by holding the inspect hotkey (default: Shift+Alt+Ctrl/Meta). An overlay will highlight elements under your cursor and display their source location. Clicking on a highlighted element opens the corresponding file in your editor at the exact line, powered by `launch-editor` under the hood. 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/skills/devtools-vite-plugin/SKILL.md b/packages/devtools-vite/skills/devtools-vite-plugin/SKILL.md index a31b9c65..4b8ce868 100644 --- a/packages/devtools-vite/skills/devtools-vite-plugin/SKILL.md +++ b/packages/devtools-vite/skills/devtools-vite-plugin/SKILL.md @@ -67,13 +67,13 @@ From `packages/devtools-vite/src/index.ts`: | Sub-plugin name | What it does | When active | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | -| `@tanstack/devtools:inject-source` | Babel transform adding `data-tsd-source` attrs to JSX | dev mode + `injectSource.enabled` | +| `@tanstack/devtools:inject-source` | AST transform adding `data-tsd-source` attrs to JSX | dev mode + `injectSource.enabled` | | `@tanstack/devtools:config` | Reserved for future config modifications | serve command only | | `@tanstack/devtools:custom-server` | Starts ServerEventBus, registers middleware for open-source/console-pipe endpoints | dev mode | | `@tanstack/devtools:remove-devtools-on-build` | Strips devtools imports/JSX from production bundles | build command or production mode + `removeDevtoolsOnBuild` | | `@tanstack/devtools:event-client-setup` | Marketplace: listens for install/add-plugin events via devtoolsEventClient | dev mode + serve + not CI | | `@tanstack/devtools:console-pipe-transform` | Injects runtime console-pipe code into entry files | dev mode + serve + `consolePiping.enabled` | -| `@tanstack/devtools:better-console-logs` | Babel transform prepending source location to `console.log`/`console.error` | dev mode + `enhancedLogs.enabled` | +| `@tanstack/devtools:better-console-logs` | AST transform prepending source location to `console.log`/`console.error` | dev mode + `enhancedLogs.enabled` | | `@tanstack/devtools:inject-plugin` | Detects which file imports TanStackDevtools (for marketplace injection) | dev mode + serve | | `@tanstack/devtools:connection-injection` | Replaces `__TANSTACK_DEVTOOLS_PORT__`, `__TANSTACK_DEVTOOLS_HOST__`, `__TANSTACK_DEVTOOLS_PROTOCOL__` placeholders | dev mode + serve | @@ -81,7 +81,7 @@ From `packages/devtools-vite/src/index.ts`: ### Source Injection -Adds `data-tsd-source="::"` attributes to every JSX opening element via Babel. This powers the "Go to Source" feature -- hold the inspect hotkey (default: Shift+Alt+Ctrl/Meta), hover over elements, click to open in editor. +Adds `data-tsd-source="::"` attributes to every JSX opening element via oxc-parser + MagicString. This powers the "Go to Source" feature -- hold the inspect hotkey (default: Shift+Alt+Ctrl/Meta), hover over elements, click to open in editor. **Key behaviors:** @@ -137,7 +137,7 @@ devtools({ ### Enhanced Logging -Babel transform that prepends source location info to `console.log()` and `console.error()` calls. In the browser, this renders as a clickable "Go to Source" link. On the server, it shows `LOG ::` in chalk colors. +AST transform that prepends source location info to `console.log()` and `console.error()` calls. In the browser, this renders as a clickable "Go to Source" link. On the server, it shows `LOG ::` in chalk colors. The transform inserts a spread of a conditional expression: `...(typeof window === 'undefined' ? serverLogMessage : browserLogMessage)` as the first argument of the console call. @@ -261,7 +261,7 @@ Source injection, console piping, enhanced logging, the server event bus, and th ### 4. Source injection on spread-props elements (MEDIUM) -The Babel transform in `inject-source.ts` explicitly skips any JSX element that has a `{...props}` spread where `props` is the component's parameter name. This is intentional -- the spread would overwrite the injected `data-tsd-source` attribute. If source inspection doesn't work for a specific component, check if it spreads its props parameter. +The AST transform in `inject-source.ts` explicitly skips any JSX element that has a `{...props}` spread where `props` is the component's parameter name. This is intentional -- the spread would overwrite the injected `data-tsd-source` attribute. If source inspection doesn't work for a specific component, check if it spreads its props parameter. ```tsx // data-tsd-source will NOT be injected on
here @@ -301,8 +301,8 @@ These are registered on the Vite dev server (not the event bus server): ## Key Source Files - `packages/devtools-vite/src/plugin.ts` -- Main plugin factory with all sub-plugins and config type -- `packages/devtools-vite/src/inject-source.ts` -- Babel transform for data-tsd-source injection -- `packages/devtools-vite/src/enhance-logs.ts` -- Babel transform for enhanced console logs +- `packages/devtools-vite/src/inject-source.ts` -- AST transform for data-tsd-source injection +- `packages/devtools-vite/src/enhance-logs.ts` -- AST transform for enhanced console logs - `packages/devtools-vite/src/remove-devtools.ts` -- Production stripping transform - `packages/devtools-vite/src/virtual-console.ts` -- Console pipe runtime code generator - `packages/devtools-vite/src/editor.ts` -- Editor config type and launch-editor integration diff --git a/packages/devtools-vite/skills/devtools-vite-plugin/references/vite-options.md b/packages/devtools-vite/skills/devtools-vite-plugin/references/vite-options.md index 09fd7062..90fd0247 100644 --- a/packages/devtools-vite/skills/devtools-vite-plugin/references/vite-options.md +++ b/packages/devtools-vite/skills/devtools-vite-plugin/references/vite-options.md @@ -43,7 +43,7 @@ declare function defineDevtoolsConfig( ## `injectSource` -Controls source injection -- the Babel transform that adds `data-tsd-source` attributes to JSX elements for the "Go to Source" feature. +Controls source injection -- the AST transform that adds `data-tsd-source` attributes to JSX elements for the "Go to Source" feature. | Field | Type | Default | Description | | ------------------- | ------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -117,7 +117,7 @@ devtools({ ## `enhancedLogs` -Controls the Babel transform that prepends source location information to `console.log()` and `console.error()` calls. +Controls the AST transform that prepends source location information to `console.log()` and `console.error()` calls. | Field | Type | Default | Description | | --------- | --------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/packages/devtools-vite/src/ast-utils.ts b/packages/devtools-vite/src/ast-utils.ts new file mode 100644 index 00000000..e6a55f03 --- /dev/null +++ b/packages/devtools-vite/src/ast-utils.ts @@ -0,0 +1,54 @@ +import type { Node } from 'oxc-parser' + +/** + * 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