diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d7e57da1da4..29bb63b49c55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/integrations/vite/resolvers.test.ts b/integrations/vite/resolvers.test.ts index 5ff5df5b174b..416737960eb1 100644 --- a/integrations/vite/resolvers.test.ts +++ b/integrations/vite/resolvers.test.ts @@ -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` + + + + + + + `, + '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, '-.css')) + .toMatchInlineSnapshot(` + " + --- ./dist/assets/index-.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` + + + + + + + `, + '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, '-.css')) + .toMatchInlineSnapshot(` + " + --- ./dist/assets/index-.css --- + @layer base { + .do-include-me { + color: green; + } + } + " + `) + }, +) + describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { test( 'resolves aliases in production build', diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index b0c25b5d7fe6..c6b2786f7bf1 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -33,6 +33,44 @@ export type PluginOptions = { optimize?: boolean | { minify?: boolean } } +function createCustomResolver( + resolvers: ((id: string, importer: string) => Promise)[], + 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 @@ -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, @@ -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(