Skip to content

Commit f3fdda2

Browse files
fix(vite): avoid resolving JS plugins to browser CSS entries (#19949)
Edit: some edits by @RobinMalfait --- ## Summary Fix a regression in `@tailwindcss/vite` introduced by `#19803` where JS plugin resolution could incorrectly resolve a package to its `browser` CSS entry. In cases like `daisyui`, Vite can resolve `@plugin "daisyui"` to `daisyui.css` instead of the package's JS entry, which causes Tailwind to try to load a CSS file as a JS plugin and fail with: ```txt Unknown file extension ".css" ``` This change keeps the `aliasOnly: false` behavior from `#19803` so tsconfig path resolution still works, but adds a JS-entry guard to `customJsResolver` in `@tailwindcss/vite`. If Vite resolves a plugin request to a non-JS file like `.css`, the custom resolver now returns `undefined` so Tailwind's internal fallback resolver can resolve the package as a JS plugin entry instead. I also added integration coverage for a package whose `main`/`module` points to JS while `browser` points to CSS, and verified that `@plugin "pkg"` still resolves to the JS entry in both build and dev mode. ## Test plan Added new integration tests in `integrations/vite/resolvers.test.ts` covering a package with: - `main` / `module` -> JS - `browser` -> CSS - `@plugin "pkg"` -> should resolve to JS, not CSS Verified with: ```sh pnpm test:integrations vite/resolvers.test.ts -t "browser points to CSS" pnpm test:integrations vite/resolvers.test.ts -t "resolves tsconfig paths" ``` These verify that: - `@plugin` no longer resolves to a CSS browser entry - the original tsconfig paths fix from `#19803` still works in both build and dev mode --- Maintainer edits: Instead of hardcoding file extensions, first try to resolve aliases and then fallback to the default resolving system we had before. We still check for a `.css` extension, even in the JS resolver because some dependencies (like `daisyUI`) put the CSS file there instead of in an `exports.style`. If we detect that, we still fallback to the default resolving logic. This should be compatible with the original issue we were trying to fix where we wanted to make Vite aliases work. Fixes: #19950 [ci-all] --------- Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
1 parent 69ad7cc commit f3fdda2

3 files changed

Lines changed: 253 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- _Experimental_: add `@container-size` utility ([#18901](https://github.com/tailwindlabs/tailwindcss/pull/18901))
1313

14+
### Fixed
15+
16+
- Ensure `@plugin` resolves package JavaScript entries instead of browser CSS entries when using `@tailwindcss/vite` ([#19949](https://github.com/tailwindlabs/tailwindcss/pull/19949))
17+
1418
## [4.2.4] - 2026-04-21
1519

1620
### Fixed

integrations/vite/resolvers.test.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
test,
1111
ts,
1212
txt,
13+
yaml,
1314
} from '../utils'
1415

1516
test(
@@ -288,6 +289,230 @@ test(
288289
},
289290
)
290291

292+
test(
293+
'resolves package plugins to JS entries in production build when browser points to CSS',
294+
{
295+
fs: {
296+
'package.json': json`
297+
{
298+
"type": "module",
299+
"dependencies": {
300+
"@tailwindcss/vite": "workspace:^",
301+
"plugin-browser-css": "workspace:*",
302+
"tailwindcss": "workspace:^"
303+
},
304+
"devDependencies": {
305+
"vite": "^8"
306+
}
307+
}
308+
`,
309+
'pnpm-workspace.yaml': yaml`
310+
#
311+
packages:
312+
- packages/*
313+
`,
314+
'packages/plugin-browser-css/package.json': json`
315+
{
316+
"name": "plugin-browser-css",
317+
"version": "1.0.0",
318+
"type": "module",
319+
"main": "./index.js",
320+
"module": "./index.js",
321+
"browser": "./browser.css"
322+
}
323+
`,
324+
'packages/plugin-browser-css/index.js': js`
325+
export default function ({ addUtilities }) {
326+
addUtilities({ '.browser-css-plugin': { 'border-bottom': '1px solid green' } })
327+
}
328+
`,
329+
'packages/plugin-browser-css/browser.css': css`
330+
.should-not-be-loaded-as-a-plugin {
331+
display: none;
332+
}
333+
`,
334+
'vite.config.ts': ts`
335+
import tailwindcss from '@tailwindcss/vite'
336+
import { defineConfig } from 'vite'
337+
338+
export default defineConfig({
339+
build: { cssMinify: false },
340+
plugins: [tailwindcss()],
341+
})
342+
`,
343+
'index.html': html`
344+
<html>
345+
<head>
346+
<link rel="stylesheet" href="./src/index.css" />
347+
</head>
348+
<body>
349+
<div class="browser-css-plugin">Hello, world!</div>
350+
</body>
351+
</html>
352+
`,
353+
'src/index.css': css`
354+
@import 'tailwindcss';
355+
@plugin 'plugin-browser-css';
356+
`,
357+
},
358+
},
359+
async ({ fs, exec, expect }) => {
360+
await exec('pnpm vite build')
361+
362+
let files = await fs.glob('dist/**/*.css')
363+
expect(files).toHaveLength(1)
364+
let [filename] = files[0]
365+
366+
await fs.expectFileToContain(filename, [candidate`browser-css-plugin`])
367+
},
368+
)
369+
370+
test(
371+
'resolves package plugins to JS entries in dev mode when browser points to CSS',
372+
{
373+
fs: {
374+
'package.json': json`
375+
{
376+
"type": "module",
377+
"dependencies": {
378+
"@tailwindcss/vite": "workspace:^",
379+
"plugin-browser-css": "workspace:*",
380+
"tailwindcss": "workspace:^"
381+
},
382+
"devDependencies": {
383+
"vite": "^8"
384+
}
385+
}
386+
`,
387+
'pnpm-workspace.yaml': yaml`
388+
#
389+
packages:
390+
- packages/*
391+
`,
392+
'packages/plugin-browser-css/package.json': json`
393+
{
394+
"name": "plugin-browser-css",
395+
"version": "1.0.0",
396+
"type": "module",
397+
"main": "./index.js",
398+
"module": "./index.js",
399+
"browser": "./browser.css"
400+
}
401+
`,
402+
'packages/plugin-browser-css/index.js': js`
403+
export default function ({ addUtilities }) {
404+
addUtilities({ '.browser-css-plugin': { 'border-bottom': '1px solid green' } })
405+
}
406+
`,
407+
'packages/plugin-browser-css/browser.css': css`
408+
.should-not-be-loaded-as-a-plugin {
409+
display: none;
410+
}
411+
`,
412+
'vite.config.ts': ts`
413+
import tailwindcss from '@tailwindcss/vite'
414+
import { defineConfig } from 'vite'
415+
416+
export default defineConfig({
417+
build: { cssMinify: false },
418+
plugins: [tailwindcss()],
419+
})
420+
`,
421+
'index.html': html`
422+
<html>
423+
<head>
424+
<link rel="stylesheet" href="./src/index.css" />
425+
</head>
426+
<body>
427+
<div class="browser-css-plugin">Hello, world!</div>
428+
</body>
429+
</html>
430+
`,
431+
'src/index.css': css`
432+
@import 'tailwindcss';
433+
@plugin 'plugin-browser-css';
434+
`,
435+
},
436+
},
437+
async ({ spawn, expect }) => {
438+
let process = await spawn('pnpm vite dev')
439+
await process.onStdout((m) => m.includes('ready in'))
440+
441+
let url = ''
442+
await process.onStdout((m) => {
443+
let match = /Local:\s*(http.*)\//.exec(m)
444+
if (match) url = match[1]
445+
return Boolean(url)
446+
})
447+
448+
await retryAssertion(async () => {
449+
let styles = await fetchStyles(url, '/index.html')
450+
expect(styles).toContain(candidate`browser-css-plugin`)
451+
})
452+
},
453+
)
454+
455+
test(
456+
'resolves package plugins in dev mode when package exports CSS files',
457+
{
458+
fs: {
459+
'package.json': json`
460+
{
461+
"type": "module",
462+
"dependencies": {
463+
"@tailwindcss/vite": "workspace:^",
464+
"daisyui": "^5",
465+
"tailwindcss": "workspace:^"
466+
},
467+
"devDependencies": {
468+
"vite": "^8"
469+
}
470+
}
471+
`,
472+
'vite.config.ts': ts`
473+
import tailwindcss from '@tailwindcss/vite'
474+
import { defineConfig } from 'vite'
475+
476+
export default defineConfig({
477+
build: { cssMinify: false },
478+
plugins: [tailwindcss()],
479+
})
480+
`,
481+
'index.html': html`
482+
<html>
483+
<head>
484+
<link rel="stylesheet" href="./src/index.css" />
485+
</head>
486+
<body>
487+
<button class="btn btn-primary">Hello, world!</button>
488+
</body>
489+
</html>
490+
`,
491+
'src/index.css': css`
492+
@import 'tailwindcss';
493+
@plugin 'daisyui';
494+
`,
495+
},
496+
},
497+
async ({ spawn, expect }) => {
498+
let process = await spawn('pnpm vite dev')
499+
await process.onStdout((m) => m.includes('ready in'))
500+
501+
let url = ''
502+
await process.onStdout((m) => {
503+
let match = /Local:\s*(http.*)\//.exec(m)
504+
if (match) url = match[1]
505+
return Boolean(url)
506+
})
507+
508+
await retryAssertion(async () => {
509+
let styles = await fetchStyles(url, '/index.html')
510+
expect(styles).toContain('.btn')
511+
expect(styles).toContain('.btn-primary')
512+
})
513+
},
514+
)
515+
291516
describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
292517
test(
293518
'resolves aliases in production build',

packages/@tailwindcss-vite/src/index.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,21 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
7171
return resolved
7272
}
7373
customJsResolver = async (id: string, base: string) => {
74-
let resolved = await jsResolver(id, base, false, isSSR)
74+
// Resolve Vite aliases first so `@plugin "@/foo"` keeps working, but
75+
// let bare package specifiers fall through to Node-style resolution.
76+
let resolved = await jsResolver(id, base, true, isSSR)
77+
if (resolved && resolved !== id) {
78+
if (path.isAbsolute(resolved)) return resolved
79+
if (resolved[0] === '.') return path.resolve(base, resolved)
80+
}
81+
82+
// Fall back to Vite's full resolver for features like tsconfigPaths,
83+
// but reject CSS results since plugins must resolve to executable code.
84+
resolved = await jsResolver(id, base, false, isSSR)
7585
if (!resolved) return
7686
if (resolved === id) return
7787
if (!path.isAbsolute(resolved)) return
88+
if (resolved.endsWith('.css')) return
7889
return resolved
7990
}
8091
} else {
@@ -127,10 +138,21 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
127138
return resolved
128139
}
129140
customJsResolver = async (id: string, base: string) => {
130-
let resolved = await jsResolver(env, id, base, false)
141+
// Resolve Vite aliases first so `@plugin "@/foo"` keeps working, but
142+
// let bare package specifiers fall through to Node-style resolution.
143+
let resolved = await jsResolver(env, id, base, true)
144+
if (resolved && resolved !== id) {
145+
if (path.isAbsolute(resolved)) return resolved
146+
if (resolved[0] === '.') return path.resolve(base, resolved)
147+
}
148+
149+
// Fall back to Vite's full resolver for features like tsconfigPaths,
150+
// but reject CSS results since plugins must resolve to executable code.
151+
resolved = await jsResolver(env, id, base, false)
131152
if (!resolved) return
132153
if (resolved === id) return
133154
if (!path.isAbsolute(resolved)) return
155+
if (resolved.endsWith('.css')) return
134156
return resolved
135157
}
136158
}

0 commit comments

Comments
 (0)