Skip to content
Merged
112 changes: 112 additions & 0 deletions integrations/vite/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
html,
js,
json,
jsx,
retryAssertion,
test,
ts,
Expand Down Expand Up @@ -472,6 +473,117 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
},
)

describe.sequential.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1']])(
'Using Vite %s',
(version) => {
test(
'external source file changes trigger a full reload',
{
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": "${version}"
}
}
`,
'project-a/vite.config.ts': ts`
import fs from 'node:fs'
import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'

export default defineConfig({
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
build: { cssMinify: false },
plugins: [tailwindcss()],
logLevel: 'info',
})
`,
'project-a/index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'project-a/src/main.ts': jsx`import { classes } from './app'`,
'project-a/src/app.ts': jsx`export let classes = "content-['project-a/src/app.ts']"`,
'project-a/src/index.css': css`
@import 'tailwindcss';
@source '../../project-b/**/*.php';
`,
'project-b/src/index.php': html`
<div
class="content-['project-b/src/index.php']"
></div>
`,
},
},
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`content-['project-b/src/index.php']`)
})

// Flush all messages so that we can be sure the next messages are from
// the file changes we're about to make
process.flush()

// Changing an external .php file should trigger a full reload
{
await fs.write(
'project-b/src/index.php',
txt`<div class="content-['updated:project-b/src/index.php']"></div>`,
)

// Ensure the page reloaded
if (version === '^6' || version === '7.0.8') {
await process.onStdout((m) => m.includes('page reload') && m.includes('index.php'))
} else {
await process.onStderr(
(m) => m.includes('vite:hmr (client)') && m.includes('index.php'),
)
}
await process.onStderr((m) => m.includes('vite:hmr (ssr)') && m.includes('index.php'))

// Ensure the styles were regenerated with the new content
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:project-b/src/index.php']`)
}
},
)
},
)

test(
`source(none) disables looking at the module graph`,
{
Expand Down
110 changes: 110 additions & 0 deletions packages/@tailwindcss-vite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner } from '@tailwindcss/oxide'
import { realpathSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
import type { Environment, Plugin, ResolvedConfig, ViteDevServer } from 'vite'
Expand Down Expand Up @@ -151,6 +152,64 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
return result
},
},

hotUpdate({ file, modules, timestamp, server }) {
// Ensure full-reloads are triggered for files that are being watched by
// Tailwind but aren't part of the module graph (like PHP or HTML
// files). If we don't do this, then changes to those files won't
// trigger a reload at all since Vite doesn't know about them.
{
// It's a little bit confusing, because due to the `addWatchFile`
// calls, it _is_ part of the module graph but nothing is really
// handling those files. These modules typically have an id of
// undefined and/or have a type of 'asset'.
//
// If we call `addWatchFile` on a file that is part of the actual
// module graph, then we will see a module for it with a type of `js`
// and a type of `asset`. We are only interested if _all_ of them are
// missing an id and/or have a type of 'asset', which is a strong
// signal that the changed file is not being handled by Vite or any of
// the plugins.
//
// Note: in Vite v7.0.6 the modules here will have a type of `js`, not
// 'asset'. But it will also have a `HARD_INVALIDATED` state and will
// do a full page reload already.
let isExternalFile = modules.every((mod) => mod.type === 'asset' || mod.id === undefined)
if (!isExternalFile) return

for (let env of new Set([this.environment.name, 'client'])) {
let roots = rootsByEnv.get(env)
if (roots.size === 0) continue

// If the file is not being watched by any of the roots, then we can
// skip the reload since it's not relevant to Tailwind CSS.
if (!isScannedFile(file, modules, roots)) {
continue
}

// https://vite.dev/changes/hotupdate-hook#migration-guide
let invalidatedModules = new Set<vite.EnvironmentModuleNode>()
for (let mod of modules) {
this.environment.moduleGraph.invalidateModule(
mod,
invalidatedModules,
timestamp,
true,
)
}

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

return []
}
}
},
},

{
Expand Down Expand Up @@ -271,6 +330,10 @@ class Root {
private customJsResolver: (id: string, base: string) => Promise<string | false | undefined>,
) {}

get scannedFiles() {
return this.scanner?.files ?? []
}

// Generate the CSS for the root file. This can return false if the file is
// not considered a Tailwind root. When this happened, the root can be GCed.
public async generate(
Expand Down Expand Up @@ -452,3 +515,50 @@ class Root {
return false
}
}

function isScannedFile(
file: string,
modules: vite.EnvironmentModuleNode[],
roots: Map<string, Root>,
) {
let seen = new Set()
let q = [...modules]
let checks = {
file,
get realpath() {
let realpath = realpathSync(file)
Object.defineProperty(checks, 'realpath', { value: realpath })
return realpath
},
}
Comment thread
RobinMalfait marked this conversation as resolved.

while (q.length > 0) {
let module = q.shift()!
if (seen.has(module)) continue
seen.add(module)

if (module.id) {
let root = roots.get(module.id)

if (root) {
// If the file is part of the scanned files for this root, then we know
// for sure that it's being watched by any of the Tailwind CSS roots. It
// doesn't matter which root it is since it's only used to know whether
// we should trigger a full reload or not.
if (
root.scannedFiles.includes(checks.file) ||
root.scannedFiles.includes(checks.realpath)
) {
return true
}
}
}

// Keep walking up the tree until we find a root.
for (let importer of module.importers) {
q.push(importer)
}
}

return false
}
Loading