Skip to content

Commit 7979474

Browse files
Resolve @import in core (#14446)
This PR brings `@import` resolution into Tailwind CSS core. This means that our clients (PostCSS, Vite, and CLI) no longer need to depend on `postcss` and `postcss-import` to resolve `@import`. Furthermore this simplifies the handling of relative paths for `@source`, `@plugin`, or `@config` in transitive CSS files (where the relative root should always be relative to the CSS file that contains the directive). This PR also fixes a plugin resolution bug where non-relative imports (e.g. directly importing node modules like `@plugin '@tailwindcss/typography';`) would not work in CSS files that are based in a different npm package. ### Resolving `@import` The core of the `@import` resolution is inside `packages/tailwindcss/src/at-import.ts`. There, to keep things performant, we do a two-step process to resolve imports. Imagine the following input CSS file: ```css @import "tailwindcss/theme.css"; @import "tailwindcss/utilities.css"; ``` Since our AST walks are synchronous, we will do a first traversal where we start a loading request for each `@import` directive. Once all loads are started, we will await the promise and do a second walk where we actually replace the AST nodes with their resolved stylesheets. All of this is recursive, so that `@import`-ed files can again `@import` other files. The core `@import` resolver also includes extensive test cases for [various combinations of media query and supports conditionals as well als layered imports](https://developer.mozilla.org/en-US/docs/Web/CSS/@import). When the same file is imported multiple times, the AST nodes are duplicated but duplicate I/O is avoided on a per-file basis, so this will only load one file, but include the `@theme` rules twice: ```css @import "tailwindcss/theme.css"; @import "tailwindcss/theme.css"; ``` ### Adding a new `context` node to the AST One limitation we had when working with the `postcss-import` plugin was the need to do an additional traversal to rewrite relative `@source`, `@plugin`, and `@config` directives. This was needed because we want these paths to be relative to the CSS file that defines the directive but when flattening a CSS file, this information is no longer part of the stringifed CSS representation. We worked around this by rewriting the content of these directives to be relative to the input CSS file, which resulted in added complexity and caused a lot of issues with Windows paths in the beginning. Now that we are doing the `@import` resolution in core, we can use a different data structure to persist this information. This PR adds a new `context` node so that we can store arbitrary context like this inside the Ast directly. This allows us to share information with the sub tree _while doing the Ast walk_. Here's an example of how the new `context` node can be used to share information with subtrees: ```ts const ast = [ rule('.foo', [decl('color', 'red')]), context({ value: 'a' }, [ rule('.bar', [ decl('color', 'blue'), context({ value: 'b' }, [ rule('.baz', [decl('color', 'green')]), ]), ]), ]), ] walk(ast, (node, { context }) => { if (node.kind !== 'declaration') return switch (node.value) { case 'red': assert(context.value === undefined) case 'blue': assert(context.value === 'a') case 'green': assert(context.value === 'b') } }) ``` In core, we use this new Ast node specifically to persist the `base` path of the current CSS file. We put the input CSS file `base` at the root of the Ast and then overwrite the `base` on every `@import` substitution. ### Removing the dependency on `postcss-import` Now that we support `@import` resolution in core, our clients no longer need a dependency on `postcss-import`. Furthermore, most dependencies also don't need to know about `postcss` at all anymore (except the PostCSS client, of course!). This also means that our workaround for rewriting `@source`, the `postcss-fix-relative-paths` plugin, can now go away as a shared dependency between all of our clients. Note that we still have it for the PostCSS plugin only, where it's possible that users already have `postcss-import` running _before_ the `@tailwindcss/postcss` plugin. Here's an example of the changes to the dependencies for our Vite client ✨ : <img width="854" alt="Screenshot 2024-09-19 at 16 59 45" src="https://github.com/user-attachments/assets/ae1f9d5f-d93a-4de9-9244-61af3aff1237"> ### Performance Since our Vite and CLI clients now no longer need to use `postcss` at all, we have also measured a significant improvement to the initial build times. For a small test setup that contains only a hand full of files (nothing super-complex), we measured an improvement in the **3.5x** range: <img width="1334" alt="Screenshot 2024-09-19 at 14 52 49" src="https://github.com/user-attachments/assets/06071fb0-7f2a-4de6-8ec8-f202d2cc78e5"> The code for this is in the commit history if you want to reproduce the results. The test was based on the Vite client. ### Caveats One thing to note is that we previously relied on finding specific symbols in the input CSS to _bail out of Tailwind processing completely_. E.g. if a file does not contain a `@tailwind` or `@apply` directive, it can never be a Tailwind file. Since we no longer have a string representation of the flattened CSS file, we can no longer do this check. However, the current implementation was already inconsistent with differences on the allowed symbol list between our clients. Ideally, Tailwind CSS should figure out wether a CSS file is a Tailwind CSS file. This, however, is left as an improvement for a future API since it goes hand-in-hand with our planned API changes for the core `tailwindcss` package. --------- Co-authored-by: Jordan Pittman <jordan@cryptica.me>
1 parent 6d43a8b commit 7979474

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+2698
-1565
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3333
- Disallow negative bare values in core utilities and variants ([#14453](https://github.com/tailwindlabs/tailwindcss/pull/14453))
3434
- Preserve explicit shadow color when overriding shadow size ([#14458](https://github.com/tailwindlabs/tailwindcss/pull/14458))
3535
- Preserve explicit transition duration and timing function when overriding transition property ([#14490](https://github.com/tailwindlabs/tailwindcss/pull/14490))
36+
- Change the implementation for `@import` resolution to speed up initial builds ([#14446](https://github.com/tailwindlabs/tailwindcss/pull/14446))
3637

3738
## [4.0.0-alpha.24] - 2024-09-11
3839

integrations/postcss/index.test.ts

+80
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,86 @@ test(
7979
},
8080
)
8181

82+
test(
83+
'production build with `postcss-import` (string)',
84+
{
85+
fs: {
86+
'package.json': json`{}`,
87+
'pnpm-workspace.yaml': yaml`
88+
#
89+
packages:
90+
- project-a
91+
`,
92+
'project-a/package.json': json`
93+
{
94+
"dependencies": {
95+
"postcss": "^8",
96+
"postcss-cli": "^10",
97+
"postcss-import": "^16",
98+
"tailwindcss": "workspace:^",
99+
"@tailwindcss/postcss": "workspace:^"
100+
}
101+
}
102+
`,
103+
'project-a/postcss.config.js': js`
104+
module.exports = {
105+
plugins: {
106+
'postcss-import': {},
107+
'@tailwindcss/postcss': {},
108+
},
109+
}
110+
`,
111+
'project-a/index.html': html`
112+
<div
113+
class="underline 2xl:font-bold hocus:underline inverted:flex"
114+
></div>
115+
`,
116+
'project-a/plugin.js': js`
117+
module.exports = function ({ addVariant }) {
118+
addVariant('inverted', '@media (inverted-colors: inverted)')
119+
addVariant('hocus', ['&:focus', '&:hover'])
120+
}
121+
`,
122+
'project-a/tailwind.config.js': js`
123+
module.exports = {
124+
content: ['../project-b/src/**/*.js'],
125+
}
126+
`,
127+
'project-a/src/index.css': css`
128+
@import 'tailwindcss/utilities';
129+
@config '../tailwind.config.js';
130+
@source '../../project-b/src/**/*.html';
131+
@plugin '../plugin.js';
132+
`,
133+
'project-a/src/index.js': js`
134+
const className = "content-['a/src/index.js']"
135+
module.exports = { className }
136+
`,
137+
'project-b/src/index.html': html`
138+
<div class="flex" />
139+
`,
140+
'project-b/src/index.js': js`
141+
const className = "content-['b/src/index.js']"
142+
module.exports = { className }
143+
`,
144+
},
145+
},
146+
async ({ root, fs, exec }) => {
147+
await exec('pnpm postcss src/index.css --output dist/out.css', {
148+
cwd: path.join(root, 'project-a'),
149+
})
150+
151+
await fs.expectFileToContain('project-a/dist/out.css', [
152+
candidate`underline`,
153+
candidate`flex`,
154+
candidate`content-['a/src/index.js']`,
155+
candidate`content-['b/src/index.js']`,
156+
candidate`inverted:flex`,
157+
candidate`hocus:underline`,
158+
])
159+
},
160+
)
161+
82162
test(
83163
'production build (ESM)',
84164
{

packages/@tailwindcss-cli/package.json

-6
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,6 @@
3636
"lightningcss": "catalog:",
3737
"mri": "^1.2.0",
3838
"picocolors": "^1.0.1",
39-
"postcss-import": "^16.1.0",
40-
"postcss": "^8.4.41",
4139
"tailwindcss": "workspace:^"
42-
},
43-
"devDependencies": {
44-
"@types/postcss-import": "^14.0.3",
45-
"internal-postcss-fix-relative-paths": "workspace:^"
4640
}
4741
}

packages/@tailwindcss-cli/src/commands/build/index.ts

+20-84
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,10 @@ import watcher from '@parcel/watcher'
22
import { compile } from '@tailwindcss/node'
33
import { clearRequireCache } from '@tailwindcss/node/require-cache'
44
import { Scanner, type ChangedContent } from '@tailwindcss/oxide'
5-
import fixRelativePathsPlugin from 'internal-postcss-fix-relative-paths'
65
import { Features, transform } from 'lightningcss'
7-
import { existsSync, readFileSync } from 'node:fs'
6+
import { existsSync } from 'node:fs'
87
import fs from 'node:fs/promises'
98
import path from 'node:path'
10-
import postcss from 'postcss'
11-
import atImport from 'postcss-import'
129
import type { Arg, Result } from '../../utils/args'
1310
import { Disposables } from '../../utils/disposables'
1411
import {
@@ -19,7 +16,6 @@ import {
1916
println,
2017
relative,
2118
} from '../../utils/renderer'
22-
import { resolveCssId } from '../../utils/resolve'
2319
import { drainStdin, outputFile } from './utils'
2420

2521
const css = String.raw
@@ -83,17 +79,13 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
8379

8480
let start = process.hrtime.bigint()
8581

86-
// Resolve the input
87-
let [input, cssImportPaths] = await handleImports(
88-
args['--input']
89-
? args['--input'] === '-'
90-
? await drainStdin()
91-
: await fs.readFile(args['--input'], 'utf-8')
92-
: css`
93-
@import 'tailwindcss';
94-
`,
95-
args['--input'] ?? base,
96-
)
82+
let input = args['--input']
83+
? args['--input'] === '-'
84+
? await drainStdin()
85+
: await fs.readFile(args['--input'], 'utf-8')
86+
: css`
87+
@import 'tailwindcss';
88+
`
9789

9890
let previous = {
9991
css: '',
@@ -128,7 +120,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
128120

129121
let inputFile = args['--input'] && args['--input'] !== '-' ? args['--input'] : process.cwd()
130122
let inputBasePath = path.dirname(path.resolve(inputFile))
131-
let fullRebuildPaths: string[] = cssImportPaths.slice()
123+
let fullRebuildPaths: string[] = []
132124

133125
function createCompiler(css: string) {
134126
return compile(css, {
@@ -143,12 +135,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
143135
let compiler = await createCompiler(input)
144136
let scanner = new Scanner({
145137
detectSources: { base },
146-
sources: compiler.globs.map(({ origin, pattern }) => ({
147-
// Ensure the glob is relative to the input CSS file or the config file
148-
// where it is specified.
149-
base: origin ? path.dirname(path.resolve(inputBasePath, origin)) : inputBasePath,
150-
pattern,
151-
})),
138+
sources: compiler.globs,
152139
})
153140

154141
// Watch for changes
@@ -196,30 +183,24 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
196183
// Clear all watchers
197184
cleanupWatchers()
198185

199-
// Collect the new `input` and `cssImportPaths`.
200-
;[input, cssImportPaths] = await handleImports(
201-
args['--input']
202-
? await fs.readFile(args['--input'], 'utf-8')
203-
: css`
204-
@import 'tailwindcss';
205-
`,
206-
args['--input'] ?? base,
207-
)
186+
// Read the new `input`.
187+
let input = args['--input']
188+
? args['--input'] === '-'
189+
? await drainStdin()
190+
: await fs.readFile(args['--input'], 'utf-8')
191+
: css`
192+
@import 'tailwindcss';
193+
`
208194
clearRequireCache(resolvedFullRebuildPaths)
209-
fullRebuildPaths = cssImportPaths.slice()
195+
fullRebuildPaths = []
210196

211197
// Create a new compiler, given the new `input`
212198
compiler = await createCompiler(input)
213199

214200
// Re-scan the directory to get the new `candidates`
215201
scanner = new Scanner({
216202
detectSources: { base },
217-
sources: compiler.globs.map(({ origin, pattern }) => ({
218-
// Ensure the glob is relative to the input CSS file or the
219-
// config file where it is specified.
220-
base: origin ? path.dirname(path.resolve(inputBasePath, origin)) : inputBasePath,
221-
pattern,
222-
})),
203+
sources: compiler.globs,
223204
})
224205

225206
// Scan the directory for candidates
@@ -367,51 +348,6 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
367348
}
368349
}
369350

370-
function handleImports(
371-
input: string,
372-
file: string,
373-
): [css: string, paths: string[]] | Promise<[css: string, paths: string[]]> {
374-
// TODO: Should we implement this ourselves instead of relying on PostCSS?
375-
//
376-
// Relevant specification:
377-
// - CSS Import Resolve: https://csstools.github.io/css-import-resolve/
378-
379-
if (!input.includes('@import')) {
380-
return [input, [file]]
381-
}
382-
383-
return postcss()
384-
.use(
385-
atImport({
386-
resolve(id, basedir) {
387-
let resolved = resolveCssId(id, basedir)
388-
if (!resolved) {
389-
throw new Error(`Could not resolve ${id} from ${basedir}`)
390-
}
391-
return resolved
392-
},
393-
load(id) {
394-
// We need to synchronously read the file here because when bundled
395-
// with bun, some of the ids might resolve to files inside the bun
396-
// embedded files root which can only be read by `node:fs` and not
397-
// `node:fs/promises`.
398-
return readFileSync(id, 'utf-8')
399-
},
400-
}),
401-
)
402-
.use(fixRelativePathsPlugin())
403-
.process(input, { from: file })
404-
.then((result) => [
405-
result.css,
406-
407-
// Use `result.messages` to get the imported files. This also includes the
408-
// current file itself.
409-
[file].concat(
410-
result.messages.filter((msg) => msg.type === 'dependency').map((msg) => msg.file),
411-
),
412-
])
413-
}
414-
415351
function optimizeCss(
416352
input: string,
417353
{ file = 'input.css', minify = false }: { file?: string; minify?: boolean } = {},

packages/@tailwindcss-cli/tsup.config.ts

-1
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,4 @@ export default defineConfig({
55
clean: true,
66
minify: true,
77
entry: ['src/index.ts'],
8-
noExternal: ['internal-postcss-fix-relative-paths'],
98
})

packages/@tailwindcss-node/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"tailwindcss": "workspace:^"
4141
},
4242
"dependencies": {
43+
"enhanced-resolve": "^5.17.1",
4344
"jiti": "^2.0.0-beta.3"
4445
}
4546
}

0 commit comments

Comments
 (0)