-
Notifications
You must be signed in to change notification settings - Fork 1
Description
Tailwind v4's Vite plugin doesn't natively work with Root.js on the Vite dev server, specifically with the asset graph. After performing some research, there are two changes we can make to Root to fix the Vite dev server compatibility.
I worked with an AI to monkey patch the changes and tested out the fix. Below is the summary of the work involved.
- Add a
css(or some other name, e.g.injectDevServerCss) option to the RootConfig to signal to Root that additional CSS entrypoints need to be bundled.
Root.js PR: Vite 7 compatibility fixes
This document describes two changes needed in Root.js to support Vite 7+ and to
enable explicit CSS injection in dev mode (e.g. for Tailwind CSS v4).
Change 1: Add css config option for dev-mode CSS injection
Problem
In Vite's dev server, plain CSS imports like import './tailwind.css' are
side-effect-only imports. Vite strips these from the SSR module graph, so
Root.js's DevServerAsset.collectCss() never discovers them. This means CSS
files that are only imported (not referenced by a component's module graph) are
missing from the rendered HTML — no <link> tag is generated.
This is particularly problematic for Tailwind CSS v4, which uses a CSS entry
point (@import "tailwindcss") compiled by a Vite plugin (@tailwindcss/vite)
rather than injecting styles via JS. In production builds Vite's build pipeline
captures these through the manifest, so the issue is dev-only.
Prior attempts to fix this with Vite plugins (transformIndexHtml) failed
because tags injected by transformIndexHtml are added after SSR rendering
and bypass Root.js's CSP nonce injection (the Preact vnode hook that adds nonces
only runs during renderToString).
Solution
Add a new css property to RootUserConfig that accepts an array of CSS file
paths relative to the project root. During dev-mode rendering, these paths are
injected as <link rel="stylesheet"> tags with ?direct appended (which tells
Vite to serve raw CSS instead of a JS module wrapper). The tags are generated
alongside other CSS dependencies, so they automatically receive CSP nonces via
the existing rendering pipeline.
This option is a no-op during production builds, where Vite's manifest-based
build correctly handles CSS.
Files to change
1. src/core/types.ts — Add css to RootUserConfig
Add the css field to the RootUserConfig interface, after plugins:
/**
* CSS files to inject as `<link>` tags during dev server rendering.
*
* In dev mode, Vite strips plain CSS imports (e.g. `import './foo.css'`)
* from the SSR module graph because they are side-effect-only imports.
* This means Root.js's `collectCss()` never discovers them. Use this
* option to explicitly list CSS entry points that should be injected.
*
* Paths should be relative to the project root (e.g. `styles/tailwind.css`).
* In dev mode, `?direct` is automatically appended so Vite serves the
* compiled CSS instead of a JS module wrapper.
*
* This option has no effect during production builds, where Vite's build
* pipeline correctly captures CSS imports through the manifest.
*/
css?: string[];2. src/render/render.tsx — Inject CSS deps in renderComponent()
In Renderer.renderComponent(), after the await this.collectElementDeps()
call (which populates jsDeps and cssDeps), add:
// Inject CSS files from rootConfig.css for dev mode.
// In dev, plain CSS imports are stripped from Vite's SSR module graph,
// so they need to be explicitly injected. The ?direct param tells Vite
// to serve raw CSS instead of a JS module wrapper.
if (this.rootConfig.css && this.assetMap.moduleGraph) {
for (const cssPath of this.rootConfig.css) {
const normalizedPath = cssPath.startsWith('/') ? cssPath : '/' + cssPath;
cssDeps.add(normalizedPath + '?direct');
}
}Why this.assetMap.moduleGraph? Only DevServerAssetMap has a
moduleGraph property. BuildAssetMap does not. This serves as a dev-mode
guard without requiring an explicit isDev flag.
Why ?direct? Without it, Vite serves CSS files as JavaScript modules
(wrapped in __vite__createHotContext + __vite__updateStyle for HMR). The
?direct query parameter tells Vite to respond with Content-Type: text/css
and the raw compiled CSS, which is what <link rel="stylesheet"> expects.
Why not use transformIndexHtml? Tags injected via Vite's
transformIndexHtml hook are added after Root.js's SSR rendering. The Preact
vnode hook that injects CSP nonces runs during renderToString(), so
post-render tags don't receive nonces. By adding CSS deps to the cssDeps set
before tag generation, the <link> tags are created via jsx("link", ...) and
automatically receive nonces through the existing code path.
Usage
// root.config.ts
export default defineConfig({
css: ['styles/tailwind.css'],
// ...
});How to verify
-
Configure
cssinroot.config.ts:export default defineConfig({ css: ['styles/tailwind.css'], });
-
Start the dev server and inspect the HTML:
<link rel="stylesheet" href="/styles/tailwind.css?direct" nonce="...">should appear in<head>/@vite/clientscript should have a nonce
-
Verify in the browser:
- Tailwind utility classes apply (check with DevTools)
- HMR works for component changes
Test matrix
| Scenario | Before fix | After fix |
|---|---|---|
CSS in <link> (dev) |
Missing for plain CSS imports | ✅ Present via css config |
| CSS nonce (dev) | N/A (tag missing) | ✅ Nonce injected |
| Production build CSS | ✅ Works (manifest) | ✅ No change (css is no-op) |
| Production build elements | ✅ Works | ✅ No change |
| Vite 5/6 compat | ✅ Works | ✅ No regression (asset-type modules don't exist) |