Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions integrations/vite/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,174 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
},
)

test(
'css-like scanned file changes do not force a full reload when another plugin handles CSS HMR',
{
Comment on lines +587 to +589
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test skipped for lightningcss without documented justification

The production fix in index.ts is transformer-agnostic: the isPotentialCssRootFile check runs identically regardless of whether PostCSS or LightningCSS processes the CSS. Skipping the test for the lightningcss variant leaves an untested path — if the fix ever regresses for LightningCSS users, this test would not catch it. If the Analog repro is genuinely PostCSS-only (e.g., because componentStylePlugin interacts with the PostCSS pipeline specifically), a brief comment explaining why lightningcss is excluded would help future reviewers.

fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''}
"vite": "^8"
}
}
`,
'project-a/vite.config.ts': ts`
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig, normalizePath } from 'vite'

function appendLog(file, payload) {
fs.appendFileSync(file, JSON.stringify(payload) + '\\n', 'utf8')
}

function hmrWiretap(logFile) {
return {
name: 'hmr-wiretap',
configureServer(server) {
fs.writeFileSync(logFile, '', 'utf8')

const originalWsSend = server.ws.send.bind(server.ws)
server.ws.send = ((payload, ...args) => {
appendLog(logFile, { source: 'server.ws.send', payload })
return originalWsSend(payload, ...args)
}) as typeof server.ws.send

for (const [environmentName, environment] of Object.entries(server.environments)) {
const originalHotSend = environment.hot.send.bind(environment.hot)
environment.hot.send = ((payload) => {
Comment on lines +629 to +636
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 server.hot.send path not captured by the wiretap

The hmrWiretap plugin patches server.ws.send and each environment.hot.send, but does not patch server.hot.send. In @tailwindcss/vite's hotUpdate handler, when env !== this.environment.name the code falls through to server.hot.send({ type: 'full-reload' }). If a full-reload were emitted via that branch it would never appear in hmr.log, causing the not.toContain('"type":"full-reload"') assertion to pass silently. In practice the common path uses this.environment.hot.send (when the client env matches), so the risk is low — but adding server.hot.send to the wiretap would make the assertion airtight.

appendLog(logFile, {
source: 'environment.hot.send',
environmentName,
payload,
})
return originalHotSend(payload)
}) as typeof environment.hot.send
}
},
}
}

function componentStylePlugin() {
let probeFile = ''
let wrapperFile = ''

return {
name: 'component-style-plugin',
enforce: 'post',
configResolved(config) {
probeFile = normalizePath(path.resolve(config.root, 'src/probe.component.css'))
wrapperFile = normalizePath(path.resolve(config.root, 'src/component-wrapper.css'))
},
async transform(_, id) {
if (normalizePath(id.split('?')[0]) !== wrapperFile) return

this.addWatchFile(probeFile)
const content = await fsp.readFile(probeFile, 'utf8')
return [
"@import 'tailwindcss';",
"@source './probe.component.css';",
content,
].join('\\n')
},
hotUpdate({ file }) {
if (normalizePath(file) !== probeFile) return

const modules = this.environment.moduleGraph.getModulesByFile(wrapperFile)
if (!modules) return []

return [...modules]
},
}
}

export default defineConfig({
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
build: { cssMinify: false },
logLevel: 'info',
plugins: [
tailwindcss(),
componentStylePlugin(),
hmrWiretap(path.resolve(__dirname, 'hmr.log')),
],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
`,
'project-a/index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/component-wrapper.css" />
</head>
<body>
<div class="probe font-bold">Hello</div>
</body>
</html>
`,
'project-a/src/component-wrapper.css': css`
/* transformed by componentStylePlugin */
`,
'project-a/src/probe.component.css': css`
.probe {
@apply bg-blue-500;
}
`,
},
},
async ({ root, spawn, fs, expect }) => {
let process = await spawn('pnpm vite dev --debug hmr', {
cwd: path.join(root, 'project-a'),
})
await process.onStdout((m) => m.includes('ready in'))

let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})

await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`bg-blue-500`)
expect(styles).toContain(candidate`font-bold`)
})

await fs.write('project-a/hmr.log', '')
await fs.write(
'project-a/src/probe.component.css',
css`
.probe {
@apply bg-red-500;
}
`,
)

await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`bg-red-500`)
expect(styles).toContain(candidate`font-bold`)
})

await retryAssertion(async () => {
let log = await fs.read('project-a/hmr.log')
expect(log).toContain('"type":"update"')
expect(log).not.toContain('"type":"full-reload"')
})
},
)

test(
`source(none) disables looking at the module graph`,
{
Expand Down
7 changes: 7 additions & 0 deletions packages/@tailwindcss-vite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,13 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
modules.every((mod) => mod.type === 'asset' || mod.id === undefined)
if (!isExternalFile) return

// CSS-like files may still be handled by another plugin's stylesheet
// HMR pipeline even when the module graph only exposes asset-like
// placeholder modules during this pass. In that case, forcing a full
// reload here is too aggressive and can race against a later
// targeted CSS update.
if (isPotentialCssRootFile(file)) return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Skip if the module exists in other environments. SSR framework has
// its own server side hmr/reload mechanism when handling server
// only modules. See https://v6.vite.dev/guide/migration.html
Expand Down