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
Welcome to React Router
}
`,
'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 (
{children}
)
}
export default function App() {
return
}
`,
}
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 Welcome to React Router
}
`,
)
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 (
{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')
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 (
{children}
)
}
export default function App() {
return
}
`,
'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`])
},
)