From 841595526174df82de60a1048ae4c32e7e21ebf4 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 2 Mar 2026 15:24:58 +0900 Subject: [PATCH 1/4] fix(vite): skip full reload for server only modules watched by client --- packages/@tailwindcss-vite/src/index.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 3ab7c3a0f2f8..42b32099f5d6 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -208,9 +208,22 @@ 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. + for (const environment of Object.values(server.environments)) { + if (environment.name === this.environment.name) continue + + const modules = environment.moduleGraph.getModulesByFile(file) + if (modules && [...modules].some(m => m.type !== 'asset')) { + return; + } + } + for (let env of new Set([this.environment.name, 'client'])) { let roots = rootsByEnv.get(env) if (roots.size === 0) continue From b37c59367e69ae1cbc2b43a3bcc8e9444c335e98 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 2 Mar 2026 17:06:54 +0900 Subject: [PATCH 2/4] test: add e2e --- integrations/vite/react-router.test.ts | 95 ++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/integrations/vite/react-router.test.ts b/integrations/vite/react-router.test.ts index 5b9574fd658b..4ea92a613021 100644 --- a/integrations/vite/react-router.test.ts +++ b/integrations/vite/react-router.test.ts @@ -102,6 +102,101 @@ 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') From b42e65cf9a88de29ce5f4136e4cb2cb86bbd9395 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 2 Mar 2026 17:08:39 +0900 Subject: [PATCH 3/4] chore: lint --- integrations/vite/react-router.test.ts | 11 ++--------- packages/@tailwindcss-vite/src/index.ts | 8 +++++--- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/integrations/vite/react-router.test.ts b/integrations/vite/react-router.test.ts index 4ea92a613021..9d8fae9b8d5e 100644 --- a/integrations/vite/react-router.test.ts +++ b/integrations/vite/react-router.test.ts @@ -145,9 +145,7 @@ test( ) } `, - 'app/direct-hdr-dep.ts': ts` - export const direct = 'HDR: 0' - `, + 'app/direct-hdr-dep.ts': ts` export const direct = 'HDR: 0' `, }, }, async ({ fs, spawn, expect }) => { @@ -176,12 +174,7 @@ test( // 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' - `, - ) + await fs.write('app/direct-hdr-dep.ts', ts` export const direct = 'HDR: 1' `) // check update await retryAssertion(async () => { diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 42b32099f5d6..3273ea9609a7 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -210,7 +210,9 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { // do a full page reload already. // // 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) + 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. @@ -219,8 +221,8 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { if (environment.name === this.environment.name) continue const modules = environment.moduleGraph.getModulesByFile(file) - if (modules && [...modules].some(m => m.type !== 'asset')) { - return; + if (modules && [...modules].some((m) => m.type !== 'asset')) { + return } } From 93ca94ca679739dd2fc03d40a9acb2bcf551d214 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 2 Mar 2026 17:15:28 +0900 Subject: [PATCH 4/4] chore: comment --- packages/@tailwindcss-vite/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 3273ea9609a7..e3ed13dd1d2f 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -217,6 +217,8 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { // 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 (const environment of Object.values(server.environments)) { if (environment.name === this.environment.name) continue