Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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) => {
},
)

;(transformer === 'postcss' ? test : test.skip)(
'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 { fileURLToPath } from 'node:url'
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: 'pre',
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, timestamp }) {
if (normalizePath(file) !== probeFile) return

this.environment.hot.send({
type: 'update',
updates: [
{
type: 'css-update',
path: '/src/component-wrapper.css',
acceptedPath: '/src/component-wrapper.css',
timestamp,
},
],
})

return []
},
}
}

export default defineConfig({
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
build: { cssMinify: false },
logLevel: 'info',
plugins: [
tailwindcss(),
componentStylePlugin(),
hmrWiretap(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '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 fetchStyles(url, '/index.html')

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 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
10 changes: 10 additions & 0 deletions packages/@tailwindcss-vite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,16 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
)
}

// 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. We still need to invalidate
// the watched modules so Tailwind rebuilds, but we should not force
// a full page reload that can race against a later targeted
// css-update payload.
if (isPotentialCssRootFile(file)) {
return []
}

Comment on lines +318 to +327
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The test is passing (good!), but getting rid of this code doesn't fail the integration test, so either the test is not testing what it's supposed to test, or this isn't the actual fix.

if (env === this.environment.name) {
this.environment.hot.send({ type: 'full-reload' })
} else if (server.hot.send) {
Expand Down