Release loader callbacks after compilation (#20531)

## Summary

Cached compilers retain the module and stylesheet loader callbacks after
compilation. Callbacks supplied by build tools can capture completed
build state, keeping it alive during later builds.

Capture the `from` and `polyfills` values used by subsequent builds and
isolate config and plugin loading from the compatibility theme
resolver's scope. This allows the loader callbacks and their captured
state to be collected after compilation.

## Test plan

The regression keeps the compiler alive across garbage collection,
verifies that both loader callbacks can be collected, and confirms that
a subsequent utility build still works.

- `corepack pnpm test:integrations postcss/compiler-lifetime.test.ts`
(Node.js 24.19.0)
- `corepack pnpm run test` (Node.js 24.19.0)
- `corepack pnpm build` (Node.js 22.23.3)

The complete integration suite remains unverified locally because
several framework fixtures require filesystem and network capabilities
unavailable in the sandbox. Lint stopped on generated files and embedded
templates; the scoped type check reports the same diagnostics on the
unchanged base.

---------

Co-authored-by: Robin Malfait <robin.malfait@shopify.com>
This commit is contained in:
Tamir DubersteinandRobin Malfait authored and GitHub committed 2026-10-09 21:24:23 +00:00
1 parent fa81d697fe
commit de2255ba3f
4 files changed
+92 -37

No files matched your search

+1
View File
@@ -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
@@ -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,
@@ -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
@@ -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')
@@ -201,17 +224,6 @@ export async function applyCompatibilityHooks({
}),
),
])
features |= upgradeToFullPluginSupport({
designSystem,
base,
ast,
sources,
configs,
pluginDetails,
})
return features
}
function upgradeToFullPluginSupport({
@@ -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 {')
})
+10 -6
View File
@@ -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,
@@ -757,7 +760,7 @@ export async function compileAst(
}
if (!utilitiesNode) {
compiled ??= optimizeAst(ast, designSystem, opts.polyfills)
compiled ??= optimizeAst(ast, designSystem, polyfills)
return compiled
}
@@ -783,7 +786,7 @@ 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
}
@@ -791,7 +794,7 @@ export async function compileAst(
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
@@ -805,7 +808,7 @@ 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
}
@@ -813,7 +816,7 @@ export async function compileAst(
utilitiesNode.nodes = newNodes
compiled = optimizeAst(ast, designSystem, opts.polyfills)
compiled = optimizeAst(ast, designSystem, polyfills)
return compiled
},
}
@@ -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,
@@ -845,7 +849,7 @@ export async function compile(
return compiledCss
}
compiledCss = toCss(newAst, !!opts.from)
compiledCss = toCss(newAst, !!from)
compiledAst = newAst
return compiledCss