diff --git a/CHANGELOG.md b/CHANGELOG.md
index c30de00ef425..38cb0e3a74d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/integrations/vite/react-router.test.ts b/integrations/vite/react-router.test.ts
index 5b9574fd658b..9d8fae9b8d5e 100644
--- a/integrations/vite/react-router.test.ts
+++ b/integrations/vite/react-router.test.ts
@@ -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 (
+
+
{loaderData.message}
+
+
+ )
+ }
+ `,
+ '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')
diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts
index 3ab7c3a0f2f8..6dfc7b227832 100644
--- a/packages/@tailwindcss-vite/src/index.ts
+++ b/packages/@tailwindcss-vite/src/index.ts
@@ -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