From e8e1178a5a917cbb5131c0286c981f8ebbc318c5 Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Thu, 1 Oct 2026 01:36:22 +0000 Subject: [PATCH 1/3] Release loader callbacks after compilation 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. Add a regression that keeps the compiler usable after both loader callbacks are collected. --- .../postcss/compiler-lifetime.test.ts | 52 +++++++++++++ .../src/compat/apply-compat-hooks.ts | 74 +++++++++++-------- packages/tailwindcss/src/index.ts | 16 ++-- 3 files changed, 105 insertions(+), 37 deletions(-) create mode 100644 integrations/postcss/compiler-lifetime.test.ts diff --git a/integrations/postcss/compiler-lifetime.test.ts b/integrations/postcss/compiler-lifetime.test.ts new file mode 100644 index 000000000000..617421881cb9 --- /dev/null +++ b/integrations/postcss/compiler-lifetime.test.ts @@ -0,0 +1,52 @@ +import { js, json, test } from '../utils' + +test( + 'compiler releases loader callbacks after compilation', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^" + } + } + `, + 'index.mjs': js` + import assert from 'node:assert/strict' + import { setImmediate } from 'node:timers/promises' + import { compile } from 'tailwindcss' + + 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() + globalThis.gc() + + assert.deepEqual( + callbacks.map((callback) => callback.deref()), + [undefined, undefined], + ) + assert.ok(compiler.build(['flex']).includes('.flex {')) + `, + }, + }, + async ({ exec }) => { + await exec('node --expose-gc index.mjs') + }, +) diff --git a/packages/tailwindcss/src/compat/apply-compat-hooks.ts b/packages/tailwindcss/src/compat/apply-compat-hooks.ts index 036b9d88ed0a..5cfc29d563b2 100644 --- a/packages/tailwindcss/src/compat/apply-compat-hooks.ts +++ b/packages/tailwindcss/src/compat/apply-compat-hooks.ts @@ -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({ diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index bc279e624258..b90d8a6e7b43 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -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 From 24f6c56218a17efa095675019c88ec1234acb9e9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 9 Oct 2026 23:01:56 +0200 Subject: [PATCH 2/3] Move the loader callback regression to a unit test The integration test lived in `integrations/postcss` but does not use PostCSS, and it needed a full fixture install to run. The unit test gets `gc()` through `v8.setFlagsFromString` instead of the `--expose-gc` flag, so it runs with the rest of the `tailwindcss` tests. --- .../postcss/compiler-lifetime.test.ts | 52 ------------------- .../tailwindcss/src/compiler-lifetime.test.ts | 38 ++++++++++++++ 2 files changed, 38 insertions(+), 52 deletions(-) delete mode 100644 integrations/postcss/compiler-lifetime.test.ts create mode 100644 packages/tailwindcss/src/compiler-lifetime.test.ts diff --git a/integrations/postcss/compiler-lifetime.test.ts b/integrations/postcss/compiler-lifetime.test.ts deleted file mode 100644 index 617421881cb9..000000000000 --- a/integrations/postcss/compiler-lifetime.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { js, json, test } from '../utils' - -test( - 'compiler releases loader callbacks after compilation', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "workspace:^" - } - } - `, - 'index.mjs': js` - import assert from 'node:assert/strict' - import { setImmediate } from 'node:timers/promises' - import { compile } from 'tailwindcss' - - 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() - globalThis.gc() - - assert.deepEqual( - callbacks.map((callback) => callback.deref()), - [undefined, undefined], - ) - assert.ok(compiler.build(['flex']).includes('.flex {')) - `, - }, - }, - async ({ exec }) => { - await exec('node --expose-gc index.mjs') - }, -) diff --git a/packages/tailwindcss/src/compiler-lifetime.test.ts b/packages/tailwindcss/src/compiler-lifetime.test.ts new file mode 100644 index 000000000000..63a8905327e0 --- /dev/null +++ b/packages/tailwindcss/src/compiler-lifetime.test.ts @@ -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 {') +}) From 2a6cfedf6b6bbcb45c17e948773cce5fa4f049bc Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 9 Oct 2026 23:04:37 +0200 Subject: [PATCH 3/3] Add CHANGELOG entry for #20531 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5e6f6f89805..ff86a64b508c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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