diff --git a/CHANGELOG.md b/CHANGELOG.md index 338a78eb9..a557dce2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Only normalize top-level `and`, `or`, and `not` keywords in `supports-[…]` variants (e.g. `selector(a: not (.foo))` → `selector(a:not(.foo))`) ([#20420](https://github.com/tailwindlabs/tailwindcss/pull/20420)) - Don't warn about Angular's `::ng-deep` and `:host-context()` when optimizing CSS ([#20434](https://github.com/tailwindlabs/tailwindcss/pull/20434)) - Don't generate CSS for candidates containing an empty additional modifier (e.g. `bg-red-500/50/` and `group-hover/foo//bar:flex`) ([#20466](https://github.com/tailwindlabs/tailwindcss/pull/20466)) +- `@tailwindcss/vite` no longer keeps a finished build's plugin context alive, which could retain multiple GB of memory in long-lived build processes (e.g. Astro static builds) ([#20503](https://github.com/tailwindlabs/tailwindcss/pull/20503)) ## [4.3.3] - 2026-07-16 diff --git a/integrations/vite/plugin-context-leak.test.ts b/integrations/vite/plugin-context-leak.test.ts new file mode 100644 index 000000000..64d36e0d5 --- /dev/null +++ b/integrations/vite/plugin-context-leak.test.ts @@ -0,0 +1,82 @@ +import { css, js, json, test } from '../utils' + +// The build plugin used to cache a `Root` (and its compiler) for the +// lifetime of the plugin instance without ever releasing it, which kept the +// `PluginContext` of the `transform` call that created it (and everything +// reachable through it, e.g. a finished Rolldown bundle) alive long after +// the build finished. Only observable with Vite 8's Rolldown bundler, not +// with Rollup. +// +// https://github.com/tailwindlabs/tailwindcss/issues/20501 +test( + 'does not keep the transform PluginContext alive after a build finishes', + { + fs: { + 'package.json': json` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + "vite": "^8" + } + } + `, + 'src/app.css': css`@import 'tailwindcss';`, + 'src/main.js': js`import './app.css'`, + 'probe.mjs': js` + import { build } from 'vite' + import tailwindcss from '@tailwindcss/vite' + + const NAME = '@tailwindcss/vite:generate:build' + + let ctxRef = null + let probeRan = false + + function instrument(plugins) { + return plugins.map((plugin) => { + if (plugin.name !== NAME) return plugin + let original = plugin.transform.handler + return { + ...plugin, + transform: { + ...plugin.transform, + handler(...args) { + ctxRef ??= new WeakRef(this) + probeRan = true + return original.apply(this, args) + }, + }, + } + }) + } + + await build({ + root: import.meta.dirname, + logLevel: 'error', + configFile: false, + build: { + write: false, + lib: { entry: 'src/main.js', formats: ['es'], fileName: 'out' }, + }, + plugins: [instrument(tailwindcss())], + }) + + for (let i = 0; i < 5; i++) { + global.gc() + await new Promise((resolve) => setImmediate(resolve)) + } + + console.log('probeRan=' + probeRan) + console.log('pluginContextAlive=' + (ctxRef?.deref() !== undefined)) + `, + }, + }, + async ({ exec, expect }) => { + let output = await exec('node --expose-gc probe.mjs') + expect(output).toContain('probeRan=true') + expect(output).toContain('pluginContextAlive=false') + }, +) diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 8fc660eaa..a05e58a51 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -282,6 +282,22 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { return result }, }, + + closeBundle() { + // In `--watch` mode, `closeBundle` fires after every rebuild, not + // just once at the end — clearing the roots here would throw away + // the mtime-based rebuild cache `Root` relies on and force a full + // recompile on every change. Only clear once the build itself is + // done for good. + if (config?.build.watch) return + + // Roots hold on to the compiler (and, through it, the `PluginContext` + // of whichever transform call created it) for the lifetime of the + // plugin instance. Drop them once the bundle is done so a long-lived + // process (e.g. Astro's SSR build followed by static route + // generation) doesn't keep the finished build's context alive. + rootsByEnv.get(this.environment?.name ?? 'default').clear() + }, }, ] satisfies Plugin[] }