From 0dd6923d5ef43c472ad6dff4dc6af2eed6567299 Mon Sep 17 00:00:00 2001 From: lazerg Date: Mon, 21 Sep 2026 08:41:58 +0500 Subject: [PATCH 1/3] Do not retain the finished build's plugin context in `@tailwindcss/vite` --- integrations/vite/plugin-context-leak.test.ts | 78 +++++++++++++++++++ packages/@tailwindcss-vite/src/index.ts | 9 +++ 2 files changed, 87 insertions(+) create mode 100644 integrations/vite/plugin-context-leak.test.ts diff --git a/integrations/vite/plugin-context-leak.test.ts b/integrations/vite/plugin-context-leak.test.ts new file mode 100644 index 000000000..56eb05957 --- /dev/null +++ b/integrations/vite/plugin-context-leak.test.ts @@ -0,0 +1,78 @@ +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 + + 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) + 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('pluginContextAlive=' + (ctxRef?.deref() !== undefined)) + `, + }, + }, + async ({ exec, expect }) => { + let output = await exec('node --expose-gc probe.mjs') + expect(output).toContain('pluginContextAlive=false') + }, +) diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 8fc660eaa..96d184033 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -282,6 +282,15 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { return result }, }, + + closeBundle() { + // 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[] } From 44eca1b2dab1b53c8fd6c17e7811ea8ebc437c74 Mon Sep 17 00:00:00 2001 From: lazerg Date: Mon, 21 Sep 2026 08:42:30 +0500 Subject: [PATCH 2/3] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From ff8a6f67e878960d90953e481d49de83465fc9a4 Mon Sep 17 00:00:00 2001 From: lazerg Date: Mon, 21 Sep 2026 08:58:57 +0500 Subject: [PATCH 3/3] Don't clear the build plugin's Root cache during watch rebuilds --- integrations/vite/plugin-context-leak.test.ts | 4 ++++ packages/@tailwindcss-vite/src/index.ts | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/integrations/vite/plugin-context-leak.test.ts b/integrations/vite/plugin-context-leak.test.ts index 56eb05957..64d36e0d5 100644 --- a/integrations/vite/plugin-context-leak.test.ts +++ b/integrations/vite/plugin-context-leak.test.ts @@ -33,6 +33,7 @@ test( const NAME = '@tailwindcss/vite:generate:build' let ctxRef = null + let probeRan = false function instrument(plugins) { return plugins.map((plugin) => { @@ -44,6 +45,7 @@ test( ...plugin.transform, handler(...args) { ctxRef ??= new WeakRef(this) + probeRan = true return original.apply(this, args) }, }, @@ -67,12 +69,14 @@ test( 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 96d184033..a05e58a51 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -284,6 +284,13 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { }, 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