@tailwindcss/vite: cached Root.compiler pins the build's plugin context, keeping the finished bundle alive (multi-GB RSS in Astro SSG)
I maintainer di solito rispondono entro 1 giorno
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 68/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Attiva
- Stack tecnologico
- node.js, tailwindcss, typescript, vite
- Ambito
- build-system, performance, tooling
Direzione di ricerca
Start in packages/@tailwindcss-vite/src/index.ts, then trace Root.generate and its compiler setup. Run the supplied probe with --expose-gc to reproduce the retained PluginContext, and sample RSS for the Astro-shaped case. Done means the context and attached object are collectible after the build while emitted output remains unchanged.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
Verified against: @tailwindcss/vite 4.3.3, vite 8.3.0 (bundling with rolldown 1.2.9),
astro 7.3.3, Node 24.18.0
Summary
The build plugin caches a Root per (environment, css id) for the lifetime of the plugin
instance. The Root's compiler is created inside the transform hook and its onDependency
option closes over the addWatchFile callback that the hook was called with, which is
(file) => this.addWatchFile(file), i.e. a closure over the build's PluginContext.
Nothing releases that closure when the bundle is done, so the plugin context of that build
stays reachable for as long as the plugin instance lives. On Vite 8 the bundler is Rolldown,
so the context is a thin JS handle onto a native bundler arena: holding it keeps the finished
bundle alive in Rust memory rather than on the JS heap. In a one-shot vite build this is
invisible because the process exits right after. In any framework that keeps doing work inside
the same Vite build session, it is a multi-GB retainer.
Astro's static build is the clearest case: builder.buildApp() runs the SSR/prerender build
and then generatePages() inside the same call, so the prerender PluginContext is still
strongly reachable through rootsByEnv while every page is rendered.
Retainer chain
module-scope (per plugin instance)
rootsByEnv: DefaultMap<string, Map<string, Root>> // never cleared
-> Map<cssId, Root>
-> Root.compiler // set in Root.generate()
-> compile(..., { onDependency }) // @tailwindcss/node holds the option
-> onDependency closure
-> addWatchFile === (file) => this.addWatchFile(file)
-> PluginContext of the prerender build
-> native Rolldown bundler arena for that build
Because the retained bytes sit in the native arena and not in V8, heap-after-GC does not move
at all. The cost is only visible in RSS. That is worth stating up front, since a heap-only
investigation concludes there is no leak.
The relevant code in packages/@tailwindcss-vite/src/index.ts:
let rootsByEnv = new DefaultMap<string, Map<string, Root>>((env: string) => new Map())
// ...
{
name: '@tailwindcss/vite:generate:build',
apply: 'build',
enforce: 'pre',
transform: {
filter: { /* ... */ },
async handler(src, id) {
// ...
let roots = rootsByEnv.get(this.environment?.name ?? 'default')
let root = roots.get(id)
if (!root) {
root ??= createRoot(this.environment ?? null, id)
roots.set(id, root)
}
let result = await root.generate(src, (file) => this.addWatchFile(file), I)
// ...
},
},
}
and in Root.generate:
this.compiler = await compile(content, {
from: this.enableSourceMaps ? this.id : undefined,
base: inputBase,
shouldRewriteUrls: true,
onDependency: (path) => {
addWatchFile(path) // <- captured PluginContext
addBuildDependenciesPromises.push(this.addBuildDependency(path))
},
customCssResolver: this.customCssResolver,
customJsResolver: this.customJsResolver,
})
Note that addWatchFile is captured from the first transform call only. Later calls pass a
fresh callback that is used for the else branch, but the compiler keeps the original one
forever.
Impact (measured)
Astro 7.3.3 static site, 3,864 pages, one @tailwindcss/vite root CSS file, Node 24.18.0,
macOS 26.6 / M-series, 18 cores, astro build with build.concurrency: 4.
| wall time | peak RSS | peak heap after forced GC | |
|---|---|---|---|
| as shipped | 224 s | 16,404 MB | 1,166 MB |
| context detached | 172 s | 9,782 MB | 1,104 MB |
Same pair with a global.gc() probe running every 3 s (so the numbers are directly
comparable to each other, not to the row above):
| wall time | peak RSS | peak heap after forced GC | |
|---|---|---|---|
| as shipped | 180 s | 21,332 MB | 1,166 MB |
| context detached | 174 s | 7,593 MB | 1,104 MB |
Two things are worth calling out, because they make the bug easy to miss:
- The live heap does not grow. Heap-after-full-GC sits flat at ~905 MB through the whole
of route generation in both variants. Looking only at heap-after-GC, the retention is
invisible. - The cost is a transient RSS explosion at the build/route-generation boundary. In the
as-shipped run, RSS goes from 6.1 GB at t=54 s to 21.0 GB at t=68 s and then decays back to
7.3 GB. With the context detached it never exceeds 7.5 GB. The retained context is what
stops the bundler arena being released before the next phase starts allocating, so the two
working sets overlap instead of succeeding one another.
Output is unaffected: identical emitted file set (6,309 files), all 2,445 non-HTML files
byte-identical, and 0 of 3,864 HTML pages differ once per-render random element ids are
normalised. The emitted CSS is byte-identical.
Linux CI for the same project (GitHub Actions, 16 GB runner, Node 24) reports peak RSS of
8,192 / 8,293 / 8,357 / 8,407 MB across four consecutive as-shipped runs. A 7 GB runner OOM'd
on this build, which is why the larger runner is in use at all.
Repro
No Astro needed. Wrap the build plugin's transform so it records a WeakRef to the
PluginContext Tailwind is called with, run a build, force a GC, then ask whether the context
is still alive.
// probe.mjs -> node --expose-gc probe.mjs and node --expose-gc probe.mjs --fix
import { build } from 'vite'
import tailwindcss from '@tailwindcss/vite'
const APPLY_FIX = process.argv.includes('--fix')
const NAME = '@tailwindcss/vite:generate:build'
let ctxRef = null
let attachmentRef = null
function instrument(plugins) {
return plugins.map((plugin) => {
if (plugin.name !== NAME) return plugin
const original = plugin.transform.handler
return {
...plugin,
transform: {
...plugin.transform,
handler(...args) {
if (!ctxRef) {
const hangingOffTheContext = { marker: 'reachable-through-plugin-context' }
this.probeAttachment = hangingOffTheContext
ctxRef = new WeakRef(this)
attachmentRef = new WeakRef(hangingOffTheContext)
}
const self = APPLY_FIX ? { environment: this.environment, addWatchFile() {} } : this
return original.apply(self, 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(
`mode=${APPLY_FIX ? 'fixed' : 'as-shipped'} ` +
`pluginContextAlive=${ctxRef?.deref() !== undefined} ` +
`attachmentAlive=${attachmentRef?.deref() !== undefined}`,
)
src/app.css is just @import "tailwindcss"; and src/main.js is import "./app.css".
Output on @tailwindcss/vite 4.3.3, vite 8.3.0, Node 24.18.0:
mode=as-shipped pluginContextAlive=true attachmentAlive=true
mode=fixed pluginContextAlive=false attachmentAlive=false
The build has fully completed and five major GCs have run, and the PluginContext plus
everything hanging off it is still strongly reachable.
For the real-world shape, use any Astro output: 'static' site with @tailwindcss/vite and a
few thousand pages, and sample process.memoryUsage().rss during the build. The spike sits
right at the handover from the bundler to route generation. Sample RSS, not heapUsed: the
retained bytes are in the native arena, so a heap-only probe shows nothing.
Suggested fixes (either is enough)
-
Do not capture the context. Store
addWatchFileon theRootand haveonDependency
readthis.currentAddWatchFileat call time, so it always uses the callback of the transform
call that is actually running, instead of pinning the first one. -
Clear the cache when the bundle is done. Add to the build plugin:
closeBundle() { rootsByEnv.clear() }This keeps the caching benefit within a build and drops the retention afterwards.
(1) is the more complete fix: it also prevents a stale context being used for watch
registration when several environments share a Root.
Workaround in userland
Wrapping the build plugin's transform.handler so Tailwind sees a stub this with the real
environment and an inert addWatchFile removes the retention entirely and produces
byte-identical output. addWatchFile is a no-op outside watch mode anyway, so nothing is lost
for a one-shot build.
🤖 Generated with Claude Code
- Lingua principale
- TypeScript
- Stelle
- 97.9k
- Fork
- 8.9k
- Merge medio
- 2g 19h
- PR unite (30g)
- 24
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di tailwindlabs/tailwindcss
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 88/100
tailwindlabs/tailwindcss#20491 · 1 reazione ·
I maintainer di solito rispondono entro 1 giorno
-
Scanner `globs` walks gitignored directories (files pass respects .gitignore, globs pass does not)Forse di nuovo libera @akasakariko l’ha presa 19 giorni fa e non c’è nessuna pull request aperta. Aperta
Difficoltà 3/5 1-2 giorni Idoneità per principianti 58/100
tailwindlabs/tailwindcss#20533 · 3 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
Source detection ignores `core.excludesFile`, so globally-ignored directories are scanned (reproduction for #19801)Forse già presa @Lukecele l’ha presa 24 giorni fa. Aperta
Difficoltà 3/5 1-2 giorni Idoneità per principianti 65/100
tailwindlabs/tailwindcss#20509 ·
I maintainer di solito rispondono entro 1 giorno
-
Oxide 4.3.3: Windows access violation after scanning in a Node worker and unloading the addonForse già presa @bompus l’ha presa 43 giorni fa. Aperta
Difficoltà 4/5 3-5 giorni Idoneità per principianti 25/100
tailwindlabs/tailwindcss#20470 ·
I maintainer di solito rispondono entro 1 giorno
-
needs reproduction
Difficoltà 4/5 3-5 giorni Idoneità per principianti 45/100
tailwindlabs/tailwindcss#20464 · 9 commenti ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di tailwindlabs/tailwindcss
Issue simili
-
ready-for-agent
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
xavxyz/bei-shan-lin-shui#57 ·
I maintainer di solito rispondono entro 1 giorno
-
[BUG] rag: sentence content can cross the reported sentence boundariesForse già presa @LJK-ikun l’ha presa oggi. Apertabug
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
I maintainer di solito rispondono entro 1 giorno
-
bug needs-triage
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
I maintainer di solito rispondono entro 1 giorno
-
README: the Resource URI Schemas example's file://documents/* pattern only matches that literal URIAperta
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 88/100
platformatic/mcp#361 ·
I maintainer di solito rispondono entro 1 giorno
-
最近文档这个弹窗,左右宽度没有最小宽度限制Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 65/100
siyuan-note/siyuan#20799 · 7 commenti ·
I maintainer di solito rispondono entro 1 giorno