Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Ensure `@plugin` resolves package JavaScript entries instead of browser CSS entries when using `@tailwindcss/vite` ([#19949](https://github.com/tailwindlabs/tailwindcss/pull/19949))
- Fix relative `@import` and `@plugin` paths resolving from the wrong directory when using `@tailwindcss/vite` ([#19965](https://github.com/tailwindlabs/tailwindcss/pull/19965))

## [4.2.4] - 2026-04-21

Expand Down
142 changes: 142 additions & 0 deletions integrations/vite/resolvers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,148 @@ test(
},
)

test(
'resolve relative CSS files correctly',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'

export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body></body>
</html>
`,
'src/index.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
@import './themes/glow.css';
`,
// References a file in the current folder, which names happens to match a
// file in the parent folder as well.
'src/themes/glow.css': css`@import './entry.css';`,
'src/themes/entry.css': css`
.do-include-me {
color: green;
}
`,

// Never rerefenced, so should not be included
'src/entry.css': css`
.do-not-include-me {
color: red;
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('pnpm vite build')

expect((await fs.dumpFiles('./dist/**/*.css')).replace(/-([a-zA-Z0-9]*?)\.css/g, '-<hash>.css'))
.toMatchInlineSnapshot(`
"
--- ./dist/assets/index-<hash>.css ---
.do-include-me {
color: green;
}
"
`)
},
)

test(
'resolve relative JS files correctly',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'

export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body></body>
</html>
`,
'src/index.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
@import './themes/glow.css';
`,
// References a file in the current folder, which names happens to match a
// file in the parent folder as well.
'src/themes/glow.css': css`@plugin "./my-plugin.js";`,
'src/themes/my-plugin.js': ts`
export default function ({ addBase }) {
addBase({ '.do-include-me': { color: 'green' } })
}
`,

// Never rerefenced, so should not be included
'src/my-plugin.js': css`
export default function ({ addBase }) {
addBase({ '.do-not-include-me': { 'color': 'red' } })
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('pnpm vite build')

expect((await fs.dumpFiles('./dist/**/*.css')).replace(/-([a-zA-Z0-9]*?)\.css/g, '-<hash>.css'))
.toMatchInlineSnapshot(`
"
--- ./dist/assets/index-<hash>.css ---
@layer base {
.do-include-me {
color: green;
}
}
"
`)
},
)

describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
test(
'resolves aliases in production build',
Expand Down
118 changes: 66 additions & 52 deletions packages/@tailwindcss-vite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,44 @@ export type PluginOptions = {
optimize?: boolean | { minify?: boolean }
}

function createCustomResolver(
resolvers: ((id: string, importer: string) => Promise<string | undefined>)[],
filter = (_path: string) => true,
) {
return async (id: string, base: string) => {
// The resolver expects an `importer` file. We don't really know where the
// current `id` was imported from, but Vite will essentially do a
// `path.dirname(importer)` so it doesn't really matter.
//
// It does matter that this is a file, otherwise we would go up a directory,
// which means that we would be resolving files from a parent folder first,
// instead of the current folder we are in.
let importer = path.resolve(base, '__placeholder__.css')

for (let resolver of resolvers) {
let resolved = await resolver(id, importer)

// If we didn't resolve, we don't have to bail immediately, but we can try
// the next resolver
if (!resolved) continue

if (resolved === id) continue

// Looks like a relative file, let's resolve it to an absolute path
if (resolved[0] === '.') resolved = path.resolve(base, resolved)

// Must adhere to additional filters (e.g.: must be a .css file)
if (!filter(resolved)) continue

// If it's not an absolute path, then we don't really know how to read
// the file from disk.
if (!path.isAbsolute(resolved)) continue

return resolved
}
}
}

export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
let servers: ViteDevServer[] = []
let config: ResolvedConfig | null = null
Expand Down Expand Up @@ -62,32 +100,20 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {

let jsResolver = config!.createResolver(config!.resolve)

customCssResolver = async (id: string, base: string) => {
let resolved = await cssResolver(id, base, false, isSSR)
if (!resolved) return
if (resolved === id) return
if (!path.isAbsolute(resolved)) return
if (!resolved.endsWith('.css')) return
return resolved
}
customJsResolver = async (id: string, base: string) => {
// Resolve Vite aliases first so `@plugin "@/foo"` keeps working, but
// let bare package specifiers fall through to Node-style resolution.
let resolved = await jsResolver(id, base, true, isSSR)
if (resolved && resolved !== id) {
if (path.isAbsolute(resolved)) return resolved
if (resolved[0] === '.') return path.resolve(base, resolved)
}

// Fall back to Vite's full resolver for features like tsconfigPaths,
// but reject CSS results since plugins must resolve to executable code.
resolved = await jsResolver(id, base, false, isSSR)
if (!resolved) return
if (resolved === id) return
if (!path.isAbsolute(resolved)) return
if (resolved.endsWith('.css')) return
return resolved
}
customCssResolver = createCustomResolver(
[
(id, importer) => cssResolver(id, importer, true, isSSR),
(id, importer) => cssResolver(id, importer, false, isSSR),
],
(path) => path.endsWith('.css'),
)
customJsResolver = createCustomResolver(
[
(id, importer) => jsResolver(id, importer, true, isSSR),
(id, importer) => jsResolver(id, importer, false, isSSR),
],
(path) => !path.endsWith('.css'),
)
} else {
type ResolveIdFn = (
environment: Environment,
Expand Down Expand Up @@ -129,32 +155,20 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {

let jsResolver = createBackCompatIdResolver(env.config, env.config.resolve)

customCssResolver = async (id: string, base: string) => {
let resolved = await cssResolver(env, id, base, false)
if (!resolved) return
if (resolved === id) return
if (!path.isAbsolute(resolved)) return
if (!resolved.endsWith('.css')) return
return resolved
}
customJsResolver = async (id: string, base: string) => {
// Resolve Vite aliases first so `@plugin "@/foo"` keeps working, but
// let bare package specifiers fall through to Node-style resolution.
let resolved = await jsResolver(env, id, base, true)
if (resolved && resolved !== id) {
if (path.isAbsolute(resolved)) return resolved
if (resolved[0] === '.') return path.resolve(base, resolved)
}

// Fall back to Vite's full resolver for features like tsconfigPaths,
// but reject CSS results since plugins must resolve to executable code.
resolved = await jsResolver(env, id, base, false)
if (!resolved) return
if (resolved === id) return
if (!path.isAbsolute(resolved)) return
if (resolved.endsWith('.css')) return
return resolved
}
customCssResolver = createCustomResolver(
[
(id, importer) => cssResolver(env, id, importer, true),
(id, importer) => cssResolver(env, id, importer, false),
],
(path) => path.endsWith('.css'),
)
customJsResolver = createCustomResolver(
[
(id, importer) => jsResolver(env, id, importer, true),
(id, importer) => jsResolver(env, id, importer, false),
],
(path) => !path.endsWith('.css'),
)
}

return new Root(
Expand Down
Loading