tailwindcss/integrations/vite/react-router.test.ts
Hiroshi Ogawa bf441a799f
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>
2026-03-12 18:49:41 +00:00

266 lines
7.6 KiB
TypeScript

import { candidate, css, fetchStyles, json, retryAssertion, test, ts, txt } from '../utils'
const 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": "^5"
}
}
`,
'react-router.config.ts': ts`
import type { Config } from '@react-router/dev/config'
export default { ssr: true } satisfies Config
`,
'vite.config.ts': ts`
import { defineConfig } from 'vite'
import { reactRouter } from '@react-router/dev/vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss(), reactRouter()],
})
`,
'app/routes/home.tsx': ts`
export default function Home() {
return <h1 className="font-bold">Welcome to React Router</h1>
}
`,
'app/app.css': css`@import 'tailwindcss';`,
'app/routes.ts': ts`
import { type RouteConfig, index } from '@react-router/dev/routes'
export default [index('routes/home.tsx')] satisfies RouteConfig
`,
'app/root.tsx': ts`
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'
import './app.css'
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export default function App() {
return <Outlet />
}
`,
}
test('dev mode', { fs: WORKSPACE }, 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)
})
await retryAssertion(async () => {
let css = await fetchStyles(url)
expect(css).toContain(candidate`font-bold`)
})
await retryAssertion(async () => {
await fs.write(
'app/routes/home.tsx',
ts`
export default function Home() {
return <h1 className="font-bold underline">Welcome to React Router</h1>
}
`,
)
let css = await fetchStyles(url)
expect(css).toContain(candidate`underline`)
expect(css).toContain(candidate`font-bold`)
})
})
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')
let url = ''
await process.onStdout((m) => {
let match = /\[react-router-serve\]\s*(http.*)\ \/?/.exec(m)
if (match) url = match[1]
return url != ''
})
await retryAssertion(async () => {
let css = await fetchStyles(url)
expect(css).toContain(candidate`font-bold`)
})
})
test(
'build mode using ?url stylesheet imports should only build one stylesheet (requires `file-system` scanner)',
{
fs: {
...WORKSPACE,
'app/root.tsx': ts`
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'
import styles from './app.css?url'
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: styles }]
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body class="dark">
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export default function App() {
return <Outlet />
}
`,
'vite.config.ts': ts`
import { defineConfig } from 'vite'
import { reactRouter } from '@react-router/dev/vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss(), reactRouter()],
})
`,
'.gitignore': txt`
node_modules/
build/
`,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm react-router build')
let files = await fs.glob('build/client/assets/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
await fs.expectFileToContain(filename, [candidate`font-bold`])
},
)