Skip to content

Cache extracted styles across Turbopack PostCSS workers - #521

Open
borisyankov wants to merge 4 commits into
react:mainfrom
borisyankov:postcss-incremental-cache
Open

borisyankov wants to merge 4 commits into
react:mainfrom
borisyankov:postcss-incremental-cache

Conversation

@borisyankov

Copy link
Copy Markdown

Fixes #520.

Turbopack runs PostCSS in short-lived worker processes. Each new worker loses the plugin's in-memory state (file mtimes and extracted styles), so every included file is transformed again on each change. In large projects this makes one style change take more than a minute. In development with Turbopack (found through TURBOPACK, which Next.js sets), the builder now keeps its state in node_modules/.cache/postcss-react-strict-dom and transforms only the files whose mtime changed. Webpack, Vite, and production builds keep the builder in memory and do not use the cache.

The cache belongs to one dev server session. Turbopack starts all PostCSS workers of a session from the dev server process (I checked this with Next.js 15.5.9 in apps/nextjs-app), so the cache file name contains process.ppid. A restart starts with an empty cache, the same as the in-memory state of other bundlers. A restart also fixes anything that the mtime check cannot find: a content change that keeps the mtime, a new Babel config file, a change to a file that another file imports, or an edit to a linked package. The builder removes the cache files of sessions whose process has exited.

In a session, the cache key also contains:

  • the versions of postcss-react-strict-dom, @babel/core, react-strict-dom, and @stylexjs/babel-plugin, from the copies that the process actually loads
  • the Babel options. A RegExp is kept by its source and flags. A function has no stable form, so options that contain one do not use the cache.
  • the mtimes of the Babel config files that each transform loaded

The include and exclude patterns are not in the key, because the builder removes the files that they no longer match.

Commits

Each commit passes the tests on its own.

  1. Remove the styles of deleted files. The builder called bundler.remove() with a relative path, but the bundler keys rules by absolute path, so the styles of a deleted file stayed until a restart. Also, a file that is deleted between the glob and the read no longer fails the build with ENOENT.
  2. Remove old styles when a file stops creating them. This covers a file that has no styles now, a file that no longer contains react-strict-dom, and a file that Babel ignores. Before, an ignored file caused a TypeError, because the code destructured the null result.
  3. Keep extracted styles in a disk cache with Turbopack. Each set of plugin options now gets its own builder. Before, one builder got the options of each build, so concurrent builds with other options changed each other's state. Equal options share one builder, because some bundlers create the plugin again for each build. The bundler loads the Babel config once for each file. Concurrent builds wait for the same transform of a file. A file that failed to transform is not transformed again until it changes. The cache is written only when the state changes.
  4. Docs. The setup guides and the Vite example set useLayers, but the option is useCSSLayers.

Testing

  • 26 tests in the package (22 new). eslint, prettier, and flow check pass at each commit. The new tests fail on the earlier version of this branch.
  • I ran apps/nextjs-app with next dev --turbopack. The cache file name contains the dev server's pid, the stale cache file was removed, and the styles of a newly added file were written to the cache.

The builder keeps file keys as paths relative to cwd. The bundler keeps
rules by absolute path. When a file was deleted, the builder gave the
relative path to bundler.remove(), so the rules stayed in the CSS output
until the process stopped.

Resolve the path before the call. Also use a Set for the lookup of
current files. The old Array.includes() lookup made each build O(n^2).

A file can also be deleted during a build, after the glob finds it. The
builder then could not read the file, and the build failed with ENOENT,
also in watch mode. Now the builder treats such a file as deleted.
In watch mode, the bundler replaced the styles of a changed file only when
the new transform created styles. The old styles stayed in the CSS output
in two cases:

- The file still imports react-strict-dom, but has no styles now.
- The file no longer contains "react-strict-dom", so the builder did not
  transform it.

Remove the stored styles in both cases. When a transform fails in watch
mode, keep the old styles as before. The error is often a temporary syntax
error during an edit.

Babel returns null for a file that it ignores (the `ignore` and `only`
options, or a .babelignore file). The bundler then failed with a
TypeError, also in watch mode. Now it treats such a file as a file with
no styles.
Turbopack runs PostCSS in short-lived worker processes. The builder loses
its in-memory state (file mtimes and extracted styles) after each rebuild,
so the plugin transforms all included files again on each change. In large
projects, one style change can take more than a minute.

In development with Turbopack, the builder now writes its state to
node_modules/.cache/postcss-react-strict-dom and loads it in a new process.
The builder does not transform files that did not change. The plugin finds
Turbopack from the TURBOPACK environment variable, which Next.js sets.
Other bundlers keep the builder in one process and do not use the cache.

Each dev server session has its own cache. Turbopack starts all PostCSS
workers of a session from the dev server process, so the cache file name
contains the parent process id. A restart of the dev server starts with an
empty cache, like the in-memory state of other bundlers. A restart then
also fixes changes that the cache cannot find: a content change that keeps
the mtime, a new Babel config file, a change to a file that another file
imports, or a change to a linked package. The builder removes the cache
files of sessions whose process no longer runs.

The cache file name also contains a hash of the inputs that change the
styles of an unchanged file:
- the versions of postcss-react-strict-dom, @babel/core, react-strict-dom,
  and @stylexjs/babel-plugin, from the copies that the process loads
- the Babel options

The include and exclude patterns are not in the hash, because the builder
removes the files that they no longer match. The hash keeps the source and
flags of a RegExp in the Babel options. A function has no stable form, so
the builder does not use the cache if the Babel options contain one.

The cache also keeps the mtimes of the Babel config files that Babel loads
for each transformed file. These include the babel.config.* and .babelrc
files that Babel finds without the configFile option. If one of these files
changes, the builder does not use the cache. The bundler loads the Babel
config once for each file, for the transform and for the list of config
files.

Each set of plugin options now has its own builder. Before, one builder
got the options of each build, so builds with other options in the same
process changed each other's state, also at the same time. Equal options
share one builder, because some bundlers create the plugin again for each
build.

The builder keeps the new mtime of a file only after a successful
transform. It does not transform a file that failed again until the file
changes. The cache does not keep the new mtime of such a file, so a new
process shows the error in its first build. Concurrent builds wait for the
same transform of a file, and do not transform it twice.

The builder writes the cache only when its state changes. It writes to a
temporary file and then renames it, so workers never read a partial file.
The temporary file name contains the process id, the thread id, and a
random part. The builder ignores a cache file that has missing fields.

Production builds do not use the cache.

Fixes react#520
The setup guides and the Vite example app set "useLayers", but the plugin
reads "useCSSLayers". The plugin ignored the option. It had no effect only
because the default value is also true.
@meta-cla meta-cla Bot added the cla signed label Sep 23, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

postcss plugin re-transforms every included file on each rebuild under Turbopack (minutes-long HMR in large projects)

1 participant