Turbopack: CSS HMR sometimes lags one revision behind edits
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 142k
- Forks
- 32.5k
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 351
Description
Link to the code that reproduces this issue
https://github.com/Dougg11/nextjs-bug-repro
To Reproduce
npm installandnpm run dev(Turbopack default).- Open
http://localhost:3000in a browser and let the page render once. Inspect the served CSS chunk and note the value of any Tailwind-driven property (e.g.border-radiusof an element). - Edit
app/globals.css(or whichever CSS file the page loads) and change a single property — for example, changerounded-lg(8px) torounded-2xl(16px) on the demo element, or directly modify aborder-radiusvalue inside@layer base. - Save the file.
- Reload the page without making any further edits and re-inspect the CSS chunk.
Observed: the served CSS reflects the previous edit, not the one you just saved.
If you save again with no changes (or make a second small edit), the previously-saved value finally shows up — but the new edit is now itself one revision behind.
A scripted version of this loop that triggers the bug deterministically in ~1.5s is in the repro repo under scripts/repro_css_hmr.py.
Current vs. Expected behavior
Expected: after a save to a CSS file, the next browser reload (or HMR push) serves CSS that reflects the saved content.
Current: the served CSS reflects the previous version of the file. Every subsequent save makes the previous edit visible while leaving the latest edit one revision behind. With next dev --webpack the same project behaves correctly.
Underlying cause (verified): for each single save of a CSS file that has any PostCSS plugin reporting at least one file dependency, Turbopack invokes the PostCSS transform twice:
- Invocation 1 receives an out-of-date
contentsnapshot via the worker IPC, but the livefs.statSyncof the input path inside the worker returns the new mtime. - Invocation 2 (a few ms later) receives the up-to-date
content, butfs.statSyncreturns the same mtime as invocation 1.
Plugins like @tailwindcss/postcss cache compiled output keyed on mtimeMs. Invocation 1 caches the stale output against the new mtime; invocation 2 sees mtime unchanged, hits the cache, and returns the stale output to Turbopack. The committed/served CSS chunk is therefore always one revision behind.
Reproduces with isolated edits ≥30s apart, so this is not a debounce/coalescing issue with rapid edits — it's a per-save invariant violation.
Provide environment information
Tested on:
- `next@16.x` (canary), Turbopack default in dev.
- macOS 25.0.0 (darwin) on APFS.
- Node 20.x.
- Reproduced regardless of editor (VS Code, vim) and write strategy (atomic rename, truncate-then-write, single `writeFileSync`).
- Does **not** reproduce with `next dev --webpack`.
Which area(s) are affected? (Select all that apply)
Turbopack, CSS
Which stage(s) are affected? (Select all that apply)
next dev (local)
Additional context
The bug is in Turbopack's PostCSS pipeline; Tailwind is just a high-profile victim. Below is a no-op PostCSS plugin that exhibits the double-invocation directly. It does no transformation — it only logs what input it received and reports a single neighboring file as a dependency.
postcss-noop.cjs:
const fs = require('fs');
const path = require('path');
module.exports = () => ({
postcssPlugin: 'noop-with-deps',
Once(root, { result }) {
const from = result.opts.from || '';
const inputText = root.toString();
const liveDisk = fs.readFileSync(from, 'utf8');
const liveMtime = fs.statSync(from).mtimeMs;
fs.appendFileSync(
'/tmp/noop.log',
`[${Date.now()}] from=${path.basename(from)} ` +
`match=${inputText === liveDisk} mtime=${liveMtime} ` +
`input_len=${inputText.length} disk_len=${liveDisk.length}\n`
);
const sibling = path.join(path.dirname(from), 'page.tsx');
if (fs.existsSync(sibling)) {
result.messages.push({
type: 'dependency',
plugin: 'noop-with-deps',
file: sibling,
parent: from,
});
}
},
});
module.exports.postcss = true;
postcss.config.mjs:
export default { plugins: { '/abs/path/to/postcss-noop.cjs': {} } };
Steps:
npm run dev, load/once, then wait ≥30s.- Edit
app/globals.cssand save once. cat /tmp/noop.log.
Observed: two log lines per single save. The first has match=false (the content the worker received does not equal the disk content) with the new mtime. The second has match=true with the same mtime.
Expected: one log line per save with match=true.
Removing the result.messages.push(...) block produces exactly one invocation per save with match=true. The second invocation only appears when at least one dependency is reported.
Components and their roles in the race
| Component | Role |
|---|---|
| Turbopack file watcher | Detects writes, invalidates the Vc<FileContent> reactive cell. |
transform_postcss task (turbopack-node/src/transforms/postcss.rs) |
Reads source.content(), spawns the Node worker, passes (content_string, css_path, source_map_flag) as args. |
Node worker (turbopack-node/js/src/transforms/postcss.ts) |
Receives args, runs the PostCSS pipeline against content with from: cssPath. |
Downstream PostCSS plugin (e.g. @tailwindcss/postcss) |
Caches compiled output keyed on fs.statSync(cssPath).mtimeMs. |
The worker passes the content snapshot via IPC but the path is a real path. Plugins reasonably assume mtime(path) corresponds to the content they were just given. Turbopack does not enforce or signal that invariant, and per-save scheduling can produce two invocations where invocation 1 violates it.
Possible fixes (happy to PR whichever direction maintainers prefer)
Ranked by feasibility and blast radius:
- Pass a content version to the worker. Add a 4th arg to the
argsvec inprocess()(e.g.xxh3_64(content)), surface it onresult.opts.contentVersionin the worker. Plugins that want to be correct under Turbopack can key their cache on it instead of mtime. Strictly additive API. Companion change intailwindlabs/tailwindcssto opt in. - Cancel in-flight transform tasks when their inputs invalidate. Structurally the cleanest fix — the stale invocation never completes — but requires task-cancellation support in
turbo-tasks. - Coalesce invalidations within a small window (~16ms). Quick win for fast-edit case, but does not fix the 30s-isolated-edit case, so partial only.
- Read-set verification. After the worker returns, re-read
source.content()and re-run if it differs. Correct but doubles work and ping-pongs on bursty saves. - Virtualize
fsin the worker so live disk state is inaccessible. Most semantically correct, very large change.
Plugin-side workaround available today
For users hitting this with @tailwindcss/postcss, a patch-package patch that adds a content-equality check inside the existing mtime-loop in the plugin's Once handler restores correct behavior under Turbopack with no Turbopack changes required. Conceptually:
const currentInput = root.toString();
if (cache.lastInput !== currentInput) {
rebuild = 'full';
cache.lastInput = currentInput;
}
The full patch is included in the linked repro repo under patches/@tailwindcss+postcss+4.2.2.patch and applied automatically via a postinstall hook.
Related
tailwindlabs/tailwindcss#17554— earlier Tailwind-side patch that excluded the input CSS file from self-reported deps. Mitigates one path to this bug; does not fix the general case.tailwindlabs/tailwindcss#17612— follow-up.vercel/next.js#87884— user-facing symptom report.
Out of scope (flagging for separate investigation)
Under very rapid saves it is plausible that Turbopack drops invalidations entirely, causing edits to be permanently lost rather than merely delayed by one revision. We did not characterize that behavior here. The mechanism above explains the "one revision behind" pattern observed even at 30-second isolation, which is sufficient for this issue.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with turbopack-node/src/transforms/postcss.rs and turbopack-node/js/src/transforms/postcss.ts, then run scripts/repro_css_hmr.py from the linked reproduction. Trace the two PostCSS invocations when a dependency is reported and verify that a saved CSS revision, including Tailwind-driven output, is served on the next reload without regressing dependency handling.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, next.js, rust, tailwindcss
- Domain
- build-system, devtools, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100