fix(vite): skip full reload for server only modules scanned by client css (#19745)

<!--

👋 Hey, thanks for your interest in contributing to Tailwind!

**Please ask first before starting work on any significant new
features.**

It's never a fun experience to have your pull request declined after
investing a lot of time and effort into a new feature. To avoid this
from happening, we request that contributors create a discussion to
first discuss any significant new features.

For more info, check out the contributing guide:


https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md

-->

## Summary

- Closes https://github.com/tailwindlabs/tailwindcss/issues/19744
- Closes https://github.com/vitejs/vite-plugin-react/issues/1118
- Closes https://github.com/wakujs/waku/issues/1963

The change in https://github.com/tailwindlabs/tailwindcss/pull/19670
didn't take account for server only modules managed by SSR framework.
Forcing full reload for this path breaks server HMR. This PR added a
check to determine whether the same modified file has associated modules
in a different environment module graph to avoid this.

## Test plan

<!--

Explain how you tested your changes. Include the exact commands that you
used to verify the change works and include screenshots/screen
recordings of the update behavior in the browser if applicable.

-->

Added an integration test for React router HDR (server loader hmr). This
test fails on main.

Also the local build is tested on `@vitejs/plugin-rsc` CI and confirmed
the fix https://github.com/vitejs/vite-plugin-react/pull/1132

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Hiroshi Ogawa 2026-03-13 03:49:41 +09:00 • committed by GitHub
parent 6b54dd8630
commit bf441a799f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 111 additions and 1 deletions

View file

@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Guard object lookups against inherited prototype properties ([#19725](https://github.com/tailwindlabs/tailwindcss/pull/19725))
- Canonicalize `calc(var(--spacing)*…)` expressions into `--spacing(…)` ([#19769](https://github.com/tailwindlabs/tailwindcss/pull/19769))
- Fix crash in canonicalization step when handling utilities with empty property maps ([#19727](https://github.com/tailwindlabs/tailwindcss/pull/19727))
- Skip full reload for server only modules when using `@tailwindcss/vite` ([#19745](https://github.com/tailwindlabs/tailwindcss/pull/19745))
## [4.2.1] - 2026-02-23

View file

@ -102,6 +102,94 @@ test('dev mode', { fs: WORKSPACE }, async ({ fs, spawn, expect }) => {
})
})
test(
// cf. https://github.com/remix-run/react-router/blob/00cb4d7b310663b2e84152700c05d3b503005e83/integration/vite-hmr-hdr-test.ts#L311-L318
'dev mode, editing a server-only loader dependency triggers HDR instead of a full reload',
{
fs: {
...WORKSPACE,
'package.json': json`
{
"type": "module",
"dependencies": {
"@react-router/dev": "^7",
"@react-router/node": "^7",
"@react-router/serve": "^7",
"@tailwindcss/vite": "workspace:^",
"@types/node": "^20",
"@types/react-dom": "^19",
"@types/react": "^19",
"isbot": "^5",
"react-dom": "^19",
"react-router": "^7",
"react": "^19",
"tailwindcss": "workspace:^",
"vite": "^7"
}
}
`,
'app/routes/home.tsx': ts`
import type { Route } from './+types/home'
import { direct } from '../direct-hdr-dep'
export async function loader() {
return { message: direct }
}
export default function Home({ loaderData }: Route.ComponentProps) {
return (
<div>
<h1 className="font-bold">{loaderData.message}</h1>
<input data-testinput />
</div>
)
}
`,
'app/direct-hdr-dep.ts': ts` export const direct = 'HDR: 0' `,
},
},
async ({ fs, spawn, expect }) => {
let process = await spawn('pnpm react-router dev')
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
// check initial state
await retryAssertion(async () => {
let html = await (await fetch(url)).text()
expect(html).toContain('HDR: 0')
let css = await fetchStyles(url)
expect(css).toContain(candidate`font-bold`)
})
// Flush stdout so we only see messages triggered by the edit below.
process.flush()
// Edit the server-only module. The client environment watches this file
// but it only exists in the server module graph. Without the fix, the
// Tailwind CSS plugin would trigger a full page reload on the client
// instead of letting react-router handle HDR.
await fs.write('app/direct-hdr-dep.ts', ts` export const direct = 'HDR: 1' `)
// check update
await retryAssertion(async () => {
let html = await (await fetch(url)).text()
expect(html).toContain('HDR: 1')
let css = await fetchStyles(url)
expect(css).toContain(candidate`font-bold`)
})
// Assert the client receives an HMR update (not a full page reload).
await process.onStdout((m) => m.includes('(client) hmr update'))
},
)
test('build mode', { fs: WORKSPACE }, async ({ spawn, exec, expect }) => {
await exec('pnpm react-router build')
let process = await spawn('pnpm react-router-serve ./build/server/index.js')

View file

@ -208,9 +208,30 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
// 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)
//
// Empty modules can be skipped since it means it's not `addWatchFile`d and thus irrelevant to Tailwind.
let isExternalFile =
modules.length > 0 &&
modules.every((mod) => mod.type === 'asset' || mod.id === undefined)
if (!isExternalFile) return
// 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
// > Updates to an SSR-only module no longer triggers a full page reload in the client. ...
for (let environment of Object.values(server.environments)) {
if (environment.name === this.environment.name) continue
let modules = environment.moduleGraph.getModulesByFile(file)
if (modules) {
for (let module of modules) {
if (module.type !== 'asset') {
return
}
}
}
}
for (let env of new Set([this.environment.name, 'client'])) {
let roots = rootsByEnv.get(env)
if (roots.size === 0) continue