diff --git a/.changeset/inject-source-order-pre.md b/.changeset/inject-source-order-pre.md new file mode 100644 index 000000000..372a1f6fd --- /dev/null +++ b/.changeset/inject-source-order-pre.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools-vite': patch +--- + +Run source injection before the transforms of other plugins (unless they also use `order: 'pre'`), also when `devtools()` is not first in the plugins array. Before this fix, a framework plugin that changed the code of only one environment shifted the line numbers, so `data-tsd-source` differed between SSR and client and caused hydration warnings. diff --git a/packages/devtools-vite/src/plugin.ts b/packages/devtools-vite/src/plugin.ts index 8c7acdd65..fd7260187 100644 --- a/packages/devtools-vite/src/plugin.ts +++ b/packages/devtools-vite/src/plugin.ts @@ -60,6 +60,12 @@ export const devtools = (args?: TanStackDevtoolsViteConfig): Array => { return config.mode === 'development' && injectSourceConfig.enabled }, transform: { + // Run before the transforms of plugins without `order: 'pre'`, also when + // devtools() is not first in the plugins array. A plugin that changes + // the code of one environment only would shift the lines and make the + // SSR and client locations differ, which breaks hydration. An earlier + // plugin whose transform also has `order: 'pre'` still runs first. + order: 'pre', filter: { id: { exclude: [/node_modules/, /\?raw/, /\/dist\//, /\/build\//], diff --git a/packages/devtools-vite/tests/inject-source-order.test.ts b/packages/devtools-vite/tests/inject-source-order.test.ts new file mode 100644 index 000000000..67dce0913 --- /dev/null +++ b/packages/devtools-vite/tests/inject-source-order.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment node +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createServer } from 'vite' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { devtools } from '../src/plugin' +import type { ViteDevServer } from 'vite' + +describe('inject-source order', () => { + let root: string + let server: ViteDevServer + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), 'tsd-inject-order-')) + writeFileSync( + join(root, 'App.tsx'), + 'export function App() {\n return
hi
\n}\n', + ) + server = await createServer({ + root, + mode: 'development', + configFile: false, + logLevel: 'silent', + server: { middlewareMode: true, ws: false }, + // The classic runtime needs no react import, so the fixture needs no + // node_modules. + oxc: { jsx: { runtime: 'classic' } }, + plugins: [ + // A framework plugin listed before devtools() that changes the code of + // one environment only, like a server-function compiler. + { + name: 'shift-ssr-lines', + enforce: 'pre', + transform(code, id, options) { + return options?.ssr && id.endsWith('.tsx') + ? `\n\n\n${code}` + : undefined + }, + }, + devtools({ + eventBusConfig: { enabled: false }, + consolePiping: { enabled: false }, + }), + ], + }) + }) + + afterAll(async () => { + await server?.close() + rmSync(root, { recursive: true, force: true }) + }) + + it('injects the same source location on the client and the server', async () => { + const client = await server.transformRequest('/App.tsx') + const ssr = await server.transformRequest('/App.tsx', { ssr: true }) + const sourceOf = (code = '') => code.match(/App\.tsx:\d+:\d+/)?.[0] + + expect(sourceOf(client?.code)).toBe('App.tsx:2:10') + expect(sourceOf(ssr?.code)).toBe('App.tsx:2:10') + }) +})