diff --git a/integrations/vite/yarn-pnp.test.ts b/integrations/vite/yarn-pnp.test.ts new file mode 100644 index 000000000..b4076bae1 --- /dev/null +++ b/integrations/vite/yarn-pnp.test.ts @@ -0,0 +1,137 @@ +import fs from 'node:fs' +import path from 'node:path' +import { candidate, css, html, js, test, ts, yaml } from '../utils' + +const DIST = path.resolve(import.meta.dirname, '../../dist') + +const resolutions = Object.fromEntries( + fs + .readdirSync(DIST) + .filter((filename) => filename.endsWith('.tgz')) + .map((filename) => { + let name = + filename === 'tailwindcss.tgz' + ? 'tailwindcss' + : `@tailwindcss/${filename.slice('tailwindcss-'.length, -'.tgz'.length)}` + + let tarball = path.join(DIST, filename).replaceAll('\\', '/') + return [name, `file:${tarball}`] + }), +) + +test( + 'Yarn PnP scans a physical workspace package through its virtual peer-dependency path', + { + installDependencies: false, + timeout: 240_000, + fs: { + 'package.json': JSON.stringify( + { + private: true, + packageManager: 'yarn@4.18.0', + workspaces: ['packages/*'], + resolutions, + }, + null, + 2, + ), + '.yarnrc.yml': yaml` + # + nodeLinker: pnp + enableGlobalCache: false + `, + 'packages/app/package.json': JSON.stringify( + { + name: 'app', + private: true, + type: 'module', + dependencies: { + dep: 'workspace:*', + react: '19.2.0', + }, + devDependencies: { + '@tailwindcss/vite': 'workspace:^', + tailwindcss: 'workspace:^', + vite: '^7', + }, + }, + null, + 2, + ), + 'packages/app/index.html': html` +
+ + + + + + `, + 'packages/app/src/index.css': css` @import 'dep/style.css'; `, + 'packages/app/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'packages/dep/package.json': JSON.stringify( + { + name: 'dep', + type: 'module', + exports: { + './style.css': './style.css', + }, + devDependencies: { + tailwindcss: 'workspace:^', + }, + peerDependencies: { + react: '>=18', + }, + }, + null, + 2, + ), + 'packages/dep/style.css': css` + @import 'tailwindcss' prefix(twp) source(none); + @plugin './plugin.js'; + @source './src'; + @source not './src/excluded.ts'; + `, + 'packages/dep/plugin.js': js` + export default function ({ addUtilities }) { + addUtilities({ + '.from-virtual-plugin': { + display: 'block', + }, + }) + } + `, + 'packages/dep/src/index.ts': ts` export let classes = 'twp:flex twp:from-virtual-plugin' `, + 'packages/dep/src/excluded.ts': ts` export let classes = 'twp:hidden' `, + }, + }, + async ({ root, exec, fs, expect }) => { + await exec('corepack yarn install') + + let resolution = await exec( + `corepack yarn workspace app node -e "const p=require('pnpapi');const v=p.resolveToUnqualified('dep',process.cwd()+'/');console.log('VIRTUAL='+JSON.stringify({virtual:v.includes('__virtual__'),resolved:p.resolveVirtual(v)}))"`, + ) + let virtual = JSON.parse(resolution.match(/VIRTUAL=(.*)/)![1]) + expect(virtual.virtual).toBe(true) + expect(virtual.resolved).toBe(path.join(root, 'packages/dep') + path.sep) + + await exec('corepack yarn workspace app vite build') + + let files = await fs.glob('packages/app/dist/**/*.css') + expect(files).toHaveLength(1) + let [filename] = files[0] + + await fs.expectFileToContain(filename, [ + candidate`twp:flex`, + candidate`twp:from-virtual-plugin`, + ]) + await fs.expectFileNotToContain(filename, [candidate`twp:hidden`, candidate`twp:p-10`]) + }, +) diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 8fc660eaa..3f39d90b6 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -10,6 +10,7 @@ import { import { clearRequireCache } from '@tailwindcss/node/require-cache' import { Scanner } from '@tailwindcss/oxide' import fs from 'node:fs/promises' +import Module from 'node:module' import path from 'node:path' import type { Environment, InternalResolveOptions, Plugin, ResolvedConfig } from 'vite' import * as vite from 'vite' @@ -308,6 +309,28 @@ function idToPath(id: string) { return path.resolve(id.replace(/\?.*$/, '')) } +type PnpApi = { + resolveVirtual?: (path: string) => string | null +} + +function devirtualizeScannerSourceBase(base: string) { + let findPnpApi = ( + Module as typeof Module & { + findPnpApi?: (lookupSource: string) => PnpApi | null + } + ).findPnpApi + + let resolved = findPnpApi?.(base)?.resolveVirtual?.(base) + + // Yarn's JavaScript filesystem layer can access virtual package paths, but + // the native scanner cannot. Keep the original path everywhere else and + // only devirtualize the base handed to the filesystem scanner. Paths inside + // ZIP archives are still virtual filesystems, so leave those untouched. + return resolved && !resolved.split(/[\\/]/).some((segment) => segment.endsWith('.zip')) + ? resolved + : base +} + /** * A Map that can generate default values for keys that don't exist. * Generated default values are added to the map to avoid recomputation. @@ -431,7 +454,12 @@ class Root { return [{ ...this.compiler.root, negated: false }] })().concat(this.compiler.sources) - this.scanner = new Scanner({ sources }) + let scannerSources = sources.map((source) => { + let base = devirtualizeScannerSourceBase(source.base) + return { ...source, base } + }) + + this.scanner = new Scanner({ sources: scannerSources }) DEBUG && I.end('Setup scanner') } else { for (let buildDependency of this.buildDependencies.keys()) {