Skip to content

Commit 6f749ab

Browse files
committed
refactor handling of full reload logic
When you use the `@source` directive in your CSS file, then internally we will call `addWatchFile` for each of the matching files. This way Vite will watch these files and trigger the `@tailwindcss/vite` plugin when things change. However, with external files we need to make sure that we trigger a full page reload to see any effect. For example when a .php file changed. The default behavior in Vite would be to trigger a hot update of the index.css file. Which is correct for updating the CSS, but since the .php file is just an asset it won't have any effect on the current page. The original logic assumed that "asset" modules (any module with type of asset and/or id=undefined) are not in the module graph, but in my testing it looks like they are part of the module graph (because we call `addWatchFile` on these files). This means that we need another way of knowing whether or not the module should result in a full page reload or not. To do this, the `handleHotUpdate` receives an array of modules which we need to analyze. For example a .js file will have a type of `js` in one module, but it will also have a type of `asset` in the other module. This is because of the `addWatchFile` that we added. Now, the `handleHotUpdate` is going to be deprecated soon, so we also updated to the `hotUpdate` API instead. If only some of the modules are asset modules, then we can let Vite do its job, aka do a hot module replacement. If all of the modules are assets, then we have to determine whether the file is part of any of the CSS file sources. We already have a concept of a "root" which maps to a CSS file handled by Tailwind CSS and we now also expose the scanned files so we can match these against the file that was updated. These roots are indexed by environment, which is one reason why we switched to the `hotUpdate` API because `this.environment` is available there. To be 100% sure, we check the current file and walk up the tree (importers) to see if any of its files is part of a Tailwind CSS root. Once we reach a root and if that root has a scanned file of the current file that changed, then we know to do a full page reload. I don't think it matters _which_ root it belonged to because we will perform a full reload anyway. I guess in theory if we changed a file where its contents is currently not on the page then maybe we shouldn't reload, but we can improve that if this is causing issues later.
1 parent e2cbf8b commit 6f749ab

File tree

1 file changed

+98
-23
lines changed

1 file changed

+98
-23
lines changed

packages/@tailwindcss-vite/src/index.ts

Lines changed: 98 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from '@tailwindcss/node'
1010
import { clearRequireCache } from '@tailwindcss/node/require-cache'
1111
import { Scanner } from '@tailwindcss/oxide'
12+
import { realpathSync } from 'node:fs'
1213
import fs from 'node:fs/promises'
1314
import path from 'node:path'
1415
import type { Environment, Plugin, ResolvedConfig, ViteDevServer } from 'vite'
@@ -95,29 +96,6 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
9596
servers.push(server)
9697
},
9798

98-
handleHotUpdate({ server, file }) {
99-
// If the changed file is being watched by Tailwind but isn't part of
100-
// the module graph (like a PHP or HTML file), we need to trigger a full
101-
// reload manually.
102-
if (
103-
!server.moduleGraph.getModulesByFile(file) &&
104-
Object.entries(server.watcher.getWatched()).some(([dir, files]) => {
105-
return (
106-
(file === dir || file.startsWith(dir + path.sep)) &&
107-
files.includes(path.basename(file))
108-
)
109-
})
110-
) {
111-
let payload = { type: 'full-reload' as const, path: '*' }
112-
if (server.hot) {
113-
server.hot.send(payload)
114-
} else {
115-
server.ws.send(payload)
116-
}
117-
return []
118-
}
119-
},
120-
12199
async configResolved(_config) {
122100
config = _config
123101
isSSR = config.build.ssr !== false && config.build.ssr !== undefined
@@ -174,6 +152,64 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
174152
return result
175153
},
176154
},
155+
156+
hotUpdate({ file, modules, timestamp, server }) {
157+
// Ensure full-reloads are triggered for files that are being watched by
158+
// Tailwind but aren't part of the module graph (like PHP or HTML
159+
// files). If we don't do this, then changes to those files won't
160+
// trigger a reload at all since Vite doesn't know about them.
161+
{
162+
// It's a little bit confusing, because due to the `addWatchFile`
163+
// calls, it _is_ part of the module graph but nothing is really
164+
// handling those files. These modules typically haven an id of
165+
// undefined and/or have a type of 'asset'.
166+
//
167+
// If we call `addWatchFile` on a file that is part of the actual
168+
// module graph, then we will see a module for it with a type of `js`
169+
// and a type of `asset`. We are only interested if _all_ of them are
170+
// missing an id and/or have a type of 'asset', which is a strong
171+
// signal that the changed file is not being handled by Vite or any of
172+
// the plugins.
173+
//
174+
// Note: in Vite v7.0.6 the modules here will have a type of `js`, not
175+
// 'asset'. But it will also have a `HARD_INVALIDATED` state and will
176+
// do a full page reload already.
177+
let isExternalFile = modules.every((mod) => mod.type === 'asset' || mod.id === undefined)
178+
if (!isExternalFile) return
179+
180+
for (let env of [this.environment.name, 'client']) {
181+
let roots = rootsByEnv.get(env)
182+
if (roots.size === 0) continue
183+
184+
// If the file is not being watched by any of the roots, then we can
185+
// skip the reload since it's not relevant to Tailwind CSS.
186+
if (!isScannedFile(file, modules, roots)) {
187+
continue
188+
}
189+
190+
// https://vite.dev/changes/hotupdate-hook#migration-guide
191+
let invalidatedModules = new Set<vite.EnvironmentModuleNode>()
192+
for (let mod of modules) {
193+
this.environment.moduleGraph.invalidateModule(
194+
mod,
195+
invalidatedModules,
196+
timestamp,
197+
true,
198+
)
199+
}
200+
201+
if (env === this.environment.name) {
202+
this.environment.hot.send({ type: 'full-reload' })
203+
} else if (server.hot.send) {
204+
server.hot.send({ type: 'full-reload' })
205+
} else if (server.ws.send) {
206+
server.ws.send({ type: 'full-reload' })
207+
}
208+
209+
return []
210+
}
211+
}
212+
},
177213
},
178214

179215
{
@@ -294,6 +330,10 @@ class Root {
294330
private customJsResolver: (id: string, base: string) => Promise<string | false | undefined>,
295331
) {}
296332

333+
get scannedFiles() {
334+
return this.scanner?.files ?? []
335+
}
336+
297337
// Generate the CSS for the root file. This can return false if the file is
298338
// not considered a Tailwind root. When this happened, the root can be GCed.
299339
public async generate(
@@ -475,3 +515,38 @@ class Root {
475515
return false
476516
}
477517
}
518+
519+
function isScannedFile(
520+
file: string,
521+
modules: vite.EnvironmentModuleNode[],
522+
roots: Map<string, Root>,
523+
) {
524+
let seen = new Set()
525+
let q = [...modules]
526+
while (q.length > 0) {
527+
let module = q.shift()!
528+
if (seen.has(module)) continue
529+
seen.add(module)
530+
531+
if (module.id) {
532+
let root = roots.get(module.id)
533+
534+
if (root) {
535+
// If the file is part of the scanned files for this root, then we know
536+
// for sure that it's being watched by any of the Tailwind CSS roots. It
537+
// doesn't matter which root it is since it's only used to know whether
538+
// we should trigger a full reload or not.
539+
if (root.scannedFiles.includes(file) || root.scannedFiles.includes(realpathSync(file))) {
540+
return true
541+
}
542+
}
543+
}
544+
545+
// Keep walking up the tree until we find a root.
546+
for (let importer of module.importers) {
547+
q.push(importer)
548+
}
549+
}
550+
551+
return false
552+
}

0 commit comments

Comments
 (0)