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 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/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 {') +}) 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