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 @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Sort `min-*`, `max-*`, and container query variants with decimal values numerically (e.g. `min-[40.25rem]` before `min-[40.5rem]`) ([#20512](https://github.com/tailwindlabs/tailwindcss/pull/20512))
- Ensure CSS comments ending with `\*/` are closed correctly instead of swallowing the CSS that follows (e.g. `/* C:\temp\*/`) ([#20508](https://github.com/tailwindlabs/tailwindcss/pull/20508))
- Improve style invalidation performance of `group-*` and `peer-*` variants ([#20513](https://github.com/tailwindlabs/tailwindcss/pull/20513))
- Ensure cached compilers don't keep `loadModule` and `loadStylesheet` callbacks alive after compilation ([#20531](https://github.com/tailwindlabs/tailwindcss/pull/20531))

## [4.3.3] - 2026-07-16

Expand Down
74 changes: 43 additions & 31 deletions packages/tailwindcss/src/compat/apply-compat-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@ import { registerThemeVariantOverrides } from './theme-variants'

const IS_VALID_PREFIX = /^[a-z]+$/

type LoadModule = (
path: string,
base: string,
resourceHint: 'plugin' | 'config',
) => Promise<{
path: string
base: string
module: any
}>

type ModulePath = {
id: string
base: string
reference: boolean
src: SourceLocation | undefined
}

type PluginPath = [ModulePath, CssPluginOptions | null]

export async function applyCompatibilityHooks({
designSystem,
base,
Expand All @@ -28,28 +47,12 @@ export async function applyCompatibilityHooks({
designSystem: DesignSystem
base: string
ast: AstNode[]
loadModule: (
path: string,
base: string,
resourceHint: 'plugin' | 'config',
) => Promise<{
path: string
base: string
module: any
}>
loadModule: LoadModule
sources: { base: string; pattern: string; negated: boolean }[]
}) {
let features = Features.None
let pluginPaths: [
{ id: string; base: string; reference: boolean; src: SourceLocation | undefined },
CssPluginOptions | null,
][] = []
let configPaths: {
id: string
base: string
reference: boolean
src: SourceLocation | undefined
}[] = []
let pluginPaths: PluginPath[] = []
let configPaths: ModulePath[] = []

walk(ast, (node, _ctx) => {
if (node.kind !== 'at-rule') return
Expand Down Expand Up @@ -174,7 +177,27 @@ export async function applyCompatibilityHooks({
// any additional backwards compatibility hooks.
if (!pluginPaths.length && !configPaths.length) return Features.None

let [configs, pluginDetails] = await Promise.all([
// Keep loader callbacks out of the scope retained by the theme resolver.
let [configs, pluginDetails] = await loadConfigsAndPlugins(loadModule, configPaths, pluginPaths)

features |= upgradeToFullPluginSupport({
designSystem,
base,
ast,
sources,
configs,
pluginDetails,
})

return features
}

function loadConfigsAndPlugins(
loadModule: LoadModule,
configPaths: ModulePath[],
pluginPaths: PluginPath[],
) {
return Promise.all([
Promise.all(
configPaths.map(async ({ id, base, reference, src }) => {
let loaded = await loadModule(id, base, 'config')
Expand All @@ -201,17 +224,6 @@ export async function applyCompatibilityHooks({
}),
),
])

features |= upgradeToFullPluginSupport({
designSystem,
base,
ast,
sources,
configs,
pluginDetails,
})

return features
}

function upgradeToFullPluginSupport({
Expand Down
38 changes: 38 additions & 0 deletions packages/tailwindcss/src/compiler-lifetime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { setImmediate } from 'node:timers/promises'
import { setFlagsFromString } from 'node:v8'
import { runInNewContext } from 'node:vm'
import { expect, test } from 'vitest'
import { compile } from './index'

// Expose `gc()` without requiring the `--expose-gc` Node.js flag. The flag is
// process-wide, so we turn it off again once we have a reference to `gc()`.
setFlagsFromString('--expose-gc')
const gc: () => void = runInNewContext('gc')
setFlagsFromString('--no-expose-gc')

test('compiler releases loader callbacks after compilation', async () => {
let plugin = () => {}

async function createCompiler() {
let loadStylesheet = async () => ({
path: 'utilities.css',
base: '.',
content: '@tailwind utilities;',
})
let loadModule = async () => ({ path: 'plugin.js', base: '.', module: plugin })
let callbacks = [new WeakRef(loadStylesheet), new WeakRef(loadModule)]
let compiler = await compile('@import "utilities"; @plugin "fixture";', {
from: 'input.css',
loadStylesheet,
loadModule,
})
return { compiler, callbacks }
}

let { compiler, callbacks } = await createCompiler()
await setImmediate()
gc()

expect(callbacks.map((callback) => callback.deref())).toEqual([undefined, undefined])
expect(compiler.build(['flex'])).toContain('.flex {')
})
16 changes: 10 additions & 6 deletions packages/tailwindcss/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,9 @@ export async function compileAst(
}
}

// Avoid retaining loader callbacks, which may keep build tool state alive.
let { from, polyfills } = opts

return {
sources,
root,
Expand All @@ -757,7 +760,7 @@ export async function compileAst(
}

if (!utilitiesNode) {
compiled ??= optimizeAst(ast, designSystem, opts.polyfills)
compiled ??= optimizeAst(ast, designSystem, polyfills)
return compiled
}

Expand All @@ -783,15 +786,15 @@ export async function compileAst(
// If no new candidates were added, we can return the original CSS. This
// currently assumes that we only add new candidates and never remove any.
if (!didChange) {
compiled ??= optimizeAst(ast, designSystem, opts.polyfills)
compiled ??= optimizeAst(ast, designSystem, polyfills)
return compiled
}

let newNodes = compileCandidates(allValidCandidates, designSystem, {
onInvalidCandidate,
}).astNodes

if (opts.from) {
if (from) {
walk(newNodes, (node) => {
// We do this conditionally to preserve source locations from both
// `@utility` and `@custom-variant`. Even though generated nodes are
Expand All @@ -805,15 +808,15 @@ export async function compileAst(
// CSS. This currently assumes that we only add new ast nodes and never
// remove any.
if (!didAddExternalVariable && previousAstNodeCount === newNodes.length) {
compiled ??= optimizeAst(ast, designSystem, opts.polyfills)
compiled ??= optimizeAst(ast, designSystem, polyfills)
return compiled
}

previousAstNodeCount = newNodes.length

utilitiesNode.nodes = newNodes

compiled = optimizeAst(ast, designSystem, opts.polyfills)
compiled = optimizeAst(ast, designSystem, polyfills)
return compiled
},
}
Expand All @@ -835,6 +838,7 @@ export async function compile(
let api = await compileAst(ast, opts)
let compiledAst = ast
let compiledCss = css
let from = opts.from

return {
...api,
Expand All @@ -845,7 +849,7 @@ export async function compile(
return compiledCss
}

compiledCss = toCss(newAst, !!opts.from)
compiledCss = toCss(newAst, !!from)
compiledAst = newAst

return compiledCss
Expand Down
Loading