From ce98e523f1965062c8f1ace097fe389ac8fcdd92 Mon Sep 17 00:00:00 2001 From: Boris Yankov Date: Tue, 22 Sep 2026 22:29:06 +0300 Subject: [PATCH 1/4] fix(postcss): remove the styles of deleted files 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. --- .../__tests__/index-test.js | 71 +++++++++++++++++++ .../postcss-react-strict-dom/src/builder.js | 47 +++++++++--- 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/packages/postcss-react-strict-dom/__tests__/index-test.js b/packages/postcss-react-strict-dom/__tests__/index-test.js index 99936520..5837e584 100644 --- a/packages/postcss-react-strict-dom/__tests__/index-test.js +++ b/packages/postcss-react-strict-dom/__tests__/index-test.js @@ -7,6 +7,8 @@ 'use strict'; +const fs = require('fs'); +const os = require('os'); const path = require('path'); const postcss = require('postcss'); const createPlugin = require('../src/plugin'); @@ -176,4 +178,73 @@ describe('postcss-react-strict-dom', () => { }" `); }); + + describe('incremental builds', () => { + const RED = `import { css } from 'react-strict-dom'; +export const styles = css.create({ box: { color: 'red' } }); +`; + + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'postcss-rsd-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function writeFile(name, contents) { + fs.writeFileSync(path.join(tempDir, name), contents); + } + + // Returns a function that runs the same plugin instance on each call, + // like a bundler in watch mode. + function createWatcher() { + const plugin = createPlugin()({ + cwd: tempDir, + include: ['*.js'], + babelConfig: { + configFile: path.join(fixturesDir, '.babelrc.js') + } + }); + const processor = postcss([plugin]); + return async () => { + const result = await processor.process('@react-strict-dom;', { + from: path.join(tempDir, 'input.css') + }); + return result.css; + }; + } + + test('removes the styles of deleted files', async () => { + writeFile('a.js', RED); + const build = createWatcher(); + expect(await build()).toContain('color:red'); + + fs.rmSync(path.join(tempDir, 'a.js')); + expect(await build()).not.toContain('color:red'); + }); + + test('handles a file that is deleted during a build', async () => { + writeFile('a.js', RED); + const file = path.join(tempDir, 'a.js'); + const { readFileSync } = fs; + // Delete the file after the glob finds it, but before the builder + // reads it + const spy = jest + .spyOn(fs, 'readFileSync') + .mockImplementation((name, ...args) => { + if (name === file) { + fs.rmSync(file, { force: true }); + } + return readFileSync(name, ...args); + }); + try { + expect(await createWatcher()()).not.toContain('color:red'); + } finally { + spy.mockRestore(); + } + }); + }); }); diff --git a/packages/postcss-react-strict-dom/src/builder.js b/packages/postcss-react-strict-dom/src/builder.js index 02fcaf79..bc33ad1b 100644 --- a/packages/postcss-react-strict-dom/src/builder.js +++ b/packages/postcss-react-strict-dom/src/builder.js @@ -60,6 +60,27 @@ function parseDependency(fileOrGlob) { return message; } +// Returns the mtime of a file, or null if the file does not exist. +function getMtime(file) { + try { + return fs.statSync(file).mtimeMs; + } catch { + return null; + } +} + +// Returns the contents of a file, or null if the file does not exist. +function readFile(file) { + try { + return fs.readFileSync(file, 'utf-8'); + } catch (error) { + if (error.code === 'ENOENT') { + return null; + } + throw error; + } +} + // Creates a builder for transforming files and bundling styles function createBuilder() { let config = null; @@ -102,26 +123,31 @@ function createBuilder() { }); } + // Forgets a file and removes its stored styles. + function removeFile(file) { + const { cwd } = getConfig(); + fileModifiedMap.delete(file); + // The bundler stores rules by absolute path + bundler.remove(path.resolve(cwd, file)); + } + // Transforms the included files, bundles the CSS, and returns the result. async function build({ shouldSkipTransformError }) { const { cwd, babelConfig, useCSSLayers, isDev } = getConfig(); const files = getFiles(); + const fileSet = new Set(files); const filesToTransform = []; // Remove deleted files since the last build for (const file of fileModifiedMap.keys()) { - if (!files.includes(file)) { - fileModifiedMap.delete(file); - bundler.remove(file); + if (!fileSet.has(file)) { + removeFile(file); } } for (const file of files) { - const filePath = path.resolve(cwd, file); - const mtimeMs = fs.existsSync(filePath) - ? fs.statSync(filePath).mtimeMs - : -Infinity; + const mtimeMs = getMtime(path.resolve(cwd, file)); // Skip files that have not been modified since the last build // On first run, all files will be transformed @@ -139,7 +165,12 @@ function createBuilder() { await Promise.all( filesToTransform.map((file) => { const filePath = path.resolve(cwd, file); - const contents = fs.readFileSync(filePath, 'utf-8'); + const contents = readFile(filePath); + if (contents == null) { + // The file was deleted after the glob found it + removeFile(file); + return; + } if (!bundler.shouldTransform(contents)) { return; } From 0bfbe0467477058989b416cd13835feaa252cc7a Mon Sep 17 00:00:00 2001 From: Boris Yankov Date: Tue, 22 Sep 2026 22:29:37 +0300 Subject: [PATCH 2/4] fix(postcss): remove old styles when a file stops creating them 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. --- .../__tests__/index-test.js | 58 ++++++++++++++++++- .../postcss-react-strict-dom/src/builder.js | 2 + .../postcss-react-strict-dom/src/bundler.js | 37 ++++++++---- 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/packages/postcss-react-strict-dom/__tests__/index-test.js b/packages/postcss-react-strict-dom/__tests__/index-test.js index 5837e584..53d5dc8b 100644 --- a/packages/postcss-react-strict-dom/__tests__/index-test.js +++ b/packages/postcss-react-strict-dom/__tests__/index-test.js @@ -179,23 +179,46 @@ describe('postcss-react-strict-dom', () => { `); }); + test('skips files that Babel ignores', async () => { + const result = await runPlugin({ + babelConfig: { + configFile: path.join(fixturesDir, '.babelrc.js'), + ignore: [path.join(fixturesDir, 'styles-second.js')] + } + }); + + expect(result.css).toContain('red'); + expect(result.css).not.toContain('green'); + }); + describe('incremental builds', () => { const RED = `import { css } from 'react-strict-dom'; export const styles = css.create({ box: { color: 'red' } }); +`; + + const NO_STYLES = `import { css } from 'react-strict-dom'; +export const styles = {}; `; let tempDir; + let mtime; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'postcss-rsd-')); + mtime = Date.now() / 1000; }); afterEach(() => { fs.rmSync(tempDir, { recursive: true, force: true }); }); + // Gives each write a different mtime, because the builder uses the mtime + // to find changed files. function writeFile(name, contents) { - fs.writeFileSync(path.join(tempDir, name), contents); + const file = path.join(tempDir, name); + fs.writeFileSync(file, contents); + mtime += 1; + fs.utimesSync(file, mtime, mtime); } // Returns a function that runs the same plugin instance on each call, @@ -246,5 +269,38 @@ export const styles = css.create({ box: { color: 'red' } }); spy.mockRestore(); } }); + + test('removes the styles of a file that no longer creates styles', async () => { + writeFile('a.js', RED); + const build = createWatcher(); + expect(await build()).toContain('color:red'); + + writeFile('a.js', NO_STYLES); + expect(await build()).not.toContain('color:red'); + }); + + test('removes the styles of a file that no longer uses react-strict-dom', async () => { + writeFile('a.js', RED); + const build = createWatcher(); + expect(await build()).toContain('color:red'); + + writeFile('a.js', 'export const styles = {};\n'); + expect(await build()).not.toContain('color:red'); + }); + + test('keeps the styles of a file that fails to transform', async () => { + writeFile('a.js', RED); + const build = createWatcher(); + expect(await build()).toContain('color:red'); + + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + writeFile('a.js', `${RED}export const broken = ;\n`); + expect(await build()).toContain('color:red'); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); }); }); diff --git a/packages/postcss-react-strict-dom/src/builder.js b/packages/postcss-react-strict-dom/src/builder.js index bc33ad1b..0bf179c2 100644 --- a/packages/postcss-react-strict-dom/src/builder.js +++ b/packages/postcss-react-strict-dom/src/builder.js @@ -172,6 +172,8 @@ function createBuilder() { return; } if (!bundler.shouldTransform(contents)) { + // The file no longer uses React Strict DOM; remove its old styles + bundler.remove(filePath); return; } return bundler.transform(filePath, contents, babelConfig, { diff --git a/packages/postcss-react-strict-dom/src/bundler.js b/packages/postcss-react-strict-dom/src/bundler.js index 73ed1b1e..3557c119 100644 --- a/packages/postcss-react-strict-dom/src/bundler.js +++ b/packages/postcss-react-strict-dom/src/bundler.js @@ -20,8 +20,9 @@ module.exports = function createBundler() { // Transforms the source code using Babel, extracting styles and storing them. async function transform(id, sourceCode, babelConfig, options) { const { isDev, shouldSkipTransformError } = options; - const { code, map, metadata } = await babel - .transformAsync(sourceCode, { + let result; + try { + result = await babel.transformAsync(sourceCode, { filename: id, caller: { name: 'postcss-react-strict-dom', @@ -29,21 +30,33 @@ module.exports = function createBundler() { isDev }, ...babelConfig - }) - .catch((error) => { - if (shouldSkipTransformError) { - console.warn( - `[postcss-react-strict-dom] Failed to transform "${id}": ${error.message}` - ); - - return { code: sourceCode, map: null, metadata: {} }; - } - throw error; }); + } catch (error) { + if (shouldSkipTransformError) { + console.warn( + `[postcss-react-strict-dom] Failed to transform "${id}": ${error.message}` + ); + + // Keep the old styles of the file. The error is often a temporary + // syntax error during an edit. + return { code: sourceCode, map: null, metadata: {} }; + } + throw error; + } + + if (result == null) { + // Babel ignores the file (for example, with the `ignore` option), so + // the file creates no styles + result = { code: sourceCode, map: null, metadata: {} }; + } + const { code, map, metadata } = result; const stylex = metadata.stylex; if (stylex != null && stylex.length > 0) { styleXRulesMap.set(id, stylex); + } else { + // The file no longer creates styles; remove its old styles + styleXRulesMap.delete(id); } return { code, map, metadata }; From de934b2344b9c3ab2c3f5654504ebb9336aa87a7 Mon Sep 17 00:00:00 2001 From: Boris Yankov Date: Tue, 22 Sep 2026 22:31:16 +0300 Subject: [PATCH 3/4] feat(postcss): keep extracted styles in a disk cache with Turbopack 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 #520 --- .../__tests__/index-test.js | 271 +++++++++++++- .../postcss-react-strict-dom/src/builder.js | 353 +++++++++++++++--- .../postcss-react-strict-dom/src/bundler.js | 31 +- .../postcss-react-strict-dom/src/plugin.js | 46 ++- .../docs/learn/environment-setup/02-next.md | 2 + 5 files changed, 640 insertions(+), 63 deletions(-) diff --git a/packages/postcss-react-strict-dom/__tests__/index-test.js b/packages/postcss-react-strict-dom/__tests__/index-test.js index 53d5dc8b..c755f32f 100644 --- a/packages/postcss-react-strict-dom/__tests__/index-test.js +++ b/packages/postcss-react-strict-dom/__tests__/index-test.js @@ -222,14 +222,16 @@ export const styles = {}; } // Returns a function that runs the same plugin instance on each call, - // like a bundler in watch mode. - function createWatcher() { - const plugin = createPlugin()({ + // like a bundler in watch mode. Watchers that get the same postcssPlugin + // share its builders. + function createWatcher(options = {}, postcssPlugin = createPlugin()) { + const plugin = postcssPlugin({ cwd: tempDir, include: ['*.js'], babelConfig: { configFile: path.join(fixturesDir, '.babelrc.js') - } + }, + ...options }); const processor = postcss([plugin]); return async () => { @@ -302,5 +304,266 @@ export const styles = {}; warn.mockRestore(); } }); + + test('does not transform a file that failed to transform until it changes', async () => { + writeFile('a.js', RED); + const build = createWatcher(); + expect(await build()).toContain('color:red'); + + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + writeFile('a.js', `${RED}export const broken = ;\n`); + await build(); + await build(); + expect(warn).toHaveBeenCalledTimes(1); + + writeFile('a.js', NO_STYLES); + expect(await build()).not.toContain('color:red'); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); + + test('builds with other options in the same process do not share state', async () => { + const postcssPlugin = createPlugin(); + writeFile('a.js', RED); + writeFile('b.js', RED.replace('red', 'blue')); + const buildA = createWatcher({ include: ['a.js'] }, postcssPlugin); + const buildB = createWatcher({ include: ['b.js'] }, postcssPlugin); + + for (let i = 0; i < 2; i++) { + const [cssA, cssB] = await Promise.all([buildA(), buildB()]); + expect(cssA).toContain('color:red'); + expect(cssA).not.toContain('color:blue'); + expect(cssB).toContain('color:blue'); + expect(cssB).not.toContain('color:red'); + } + }); + + test('concurrent builds transform a file once', async () => { + const transformedFiles = []; + const options = { + babelConfig: { + configFile: path.join(fixturesDir, '.babelrc.js'), + plugins: [ + () => ({ + visitor: { + Program(_, state) { + transformedFiles.push(path.basename(state.filename)); + } + } + }) + ] + } + }; + const postcssPlugin = createPlugin(); + writeFile('a.js', RED); + // The same options give the same builder + const build1 = createWatcher(options, postcssPlugin); + const build2 = createWatcher(options, postcssPlugin); + + const [css1, css2] = await Promise.all([build1(), build2()]); + expect(css1).toContain('color:red'); + expect(css2).toContain('color:red'); + expect(transformedFiles.filter((file) => file === 'a.js')).toHaveLength( + 1 + ); + }); + + describe('disk cache', () => { + const BLUE = RED.replace('red', 'blue'); + const cacheDir = () => + path.join( + tempDir, + 'node_modules', + '.cache', + 'postcss-react-strict-dom' + ); + + let env; + let ppid; + + beforeEach(() => { + env = process.env; + ppid = process.ppid; + // The plugin reads NODE_ENV and TURBOPACK when it is created + process.env = { ...env, NODE_ENV: 'development', TURBOPACK: '1' }; + }); + + afterEach(() => { + process.env = env; + process.ppid = ppid; + }); + + // Changes the contents of a file, but keeps its mtime. The builder + // transforms the file again only if it has no cache entry for it. + function replaceContents(name, contents) { + const file = path.join(tempDir, name); + const { atime, mtime } = fs.statSync(file); + fs.writeFileSync(file, contents); + fs.utimesSync(file, atime, mtime); + } + + test('a new plugin instance uses the styles in the cache', async () => { + writeFile('a.js', RED); + expect(await createWatcher()()).toContain('color:red'); + + replaceContents('a.js', BLUE); + expect(await createWatcher()()).toContain('color:red'); + + writeFile('a.js', BLUE); + const css = await createWatcher()(); + expect(css).toContain('color:blue'); + expect(css).not.toContain('color:red'); + }); + + test('a new plugin instance removes the styles of deleted files', async () => { + writeFile('a.js', RED); + writeFile('b.js', BLUE); + expect(await createWatcher()()).toContain('color:red'); + + fs.rmSync(path.join(tempDir, 'a.js')); + expect(await createWatcher()()).not.toContain('color:red'); + }); + + test('a new dev server session does not use the old cache', async () => { + writeFile('a.js', RED); + expect(await createWatcher()()).toContain('color:red'); + + replaceContents('a.js', BLUE); + process.ppid = ppid + 1; + expect(await createWatcher()()).toContain('color:blue'); + }); + + test('removes the cache files of dev server sessions that ended', async () => { + // A process id that no process has + const endedSession = `cache-${2 ** 30}-0123456789abcdef.json`; + // The test process runs + const runningSession = `cache-${process.pid}-0123456789abcdef.json`; + fs.mkdirSync(cacheDir(), { recursive: true }); + fs.writeFileSync(path.join(cacheDir(), endedSession), '{}'); + fs.writeFileSync(path.join(cacheDir(), runningSession), '{}'); + + writeFile('a.js', RED); + await createWatcher()(); + const files = fs.readdirSync(cacheDir()); + expect(files).toHaveLength(2); + expect(files).not.toContain(endedSession); + expect(files).toContain(runningSession); + }); + + test('a change to the Babel options does not use the old cache', async () => { + const babelConfig = (pattern) => ({ + configFile: path.join(fixturesDir, '.babelrc.js'), + ignore: [pattern] + }); + writeFile('a.js', RED); + expect( + await createWatcher({ babelConfig: babelConfig(/first/) })() + ).toContain('color:red'); + + // JSON.stringify gives the same value for both patterns + replaceContents('a.js', BLUE); + expect( + await createWatcher({ babelConfig: babelConfig(/second/) })() + ).toContain('color:blue'); + }); + + test('a change to the include or exclude patterns uses the same cache', async () => { + writeFile('a.js', RED); + expect(await createWatcher()()).toContain('color:red'); + + replaceContents('a.js', BLUE); + expect(await createWatcher({ exclude: ['b.js'] })()).toContain( + 'color:red' + ); + }); + + test('a change to a Babel config file does not use the old cache', async () => { + // Babel finds babel.config.json in its cwd without a configFile option + const babelConfigJson = JSON.stringify({ + extends: path.join(fixturesDir, '.babelrc.js') + }); + const options = { babelConfig: { cwd: tempDir } }; + writeFile('babel.config.json', babelConfigJson); + writeFile('a.js', RED); + expect(await createWatcher(options)()).toContain('color:red'); + + replaceContents('a.js', BLUE); + writeFile('babel.config.json', babelConfigJson); + expect(await createWatcher(options)()).toContain('color:blue'); + }); + + test('a new plugin instance shows the error of a file that failed to transform', async () => { + writeFile('a.js', RED); + const build = createWatcher(); + expect(await build()).toContain('color:red'); + + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + writeFile('a.js', `${RED}export const broken = ;\n`); + expect(await build()).toContain('color:red'); + } finally { + warn.mockRestore(); + } + + await expect(createWatcher()()).rejects.toThrow('Unexpected token'); + }); + + test('writes the cache only when the state changes', async () => { + writeFile('a.js', RED); + const build = createWatcher(); + await build(); + fs.rmSync(cacheDir(), { recursive: true }); + + await build(); + expect(fs.existsSync(cacheDir())).toBe(false); + + writeFile('a.js', BLUE); + await build(); + expect(fs.existsSync(cacheDir())).toBe(true); + }); + + test('is not used when the Babel options contain a function', async () => { + const options = { + babelConfig: { + configFile: path.join(fixturesDir, '.babelrc.js'), + plugins: [() => ({})] + } + }; + writeFile('a.js', RED); + expect(await createWatcher(options)()).toContain('color:red'); + expect(fs.existsSync(cacheDir())).toBe(false); + }); + + test('is not used without Turbopack', async () => { + delete process.env.TURBOPACK; + writeFile('a.js', RED); + expect(await createWatcher()()).toContain('color:red'); + expect(fs.existsSync(cacheDir())).toBe(false); + + replaceContents('a.js', BLUE); + expect(await createWatcher()()).toContain('color:blue'); + }); + + test('is not used outside development', async () => { + process.env.NODE_ENV = 'production'; + writeFile('a.js', RED); + expect(await createWatcher()()).toContain('color:red'); + expect(fs.existsSync(cacheDir())).toBe(false); + + replaceContents('a.js', BLUE); + expect(await createWatcher()()).toContain('color:blue'); + }); + + test('leaves no temporary files', async () => { + writeFile('a.js', RED); + await createWatcher()(); + const files = fs.readdirSync(cacheDir()); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/^cache-\d+-[0-9a-f]{16}\.json$/); + }); + }); }); }); diff --git a/packages/postcss-react-strict-dom/src/builder.js b/packages/postcss-react-strict-dom/src/builder.js index 0bf179c2..c5c936f3 100644 --- a/packages/postcss-react-strict-dom/src/builder.js +++ b/packages/postcss-react-strict-dom/src/builder.js @@ -7,11 +7,15 @@ const path = require('node:path'); const fs = require('node:fs'); +const crypto = require('node:crypto'); +const { threadId } = require('node:worker_threads'); const { normalize, resolve } = require('path'); +const babel = require('@babel/core'); const { globSync } = require('fast-glob'); const isGlob = require('is-glob'); const globParent = require('glob-parent'); const createBundler = require('./bundler'); +const { version } = require('../package.json'); // Parses a glob pattern and extracts its base directory and pattern. // Returns an object with `base` and `glob` properties. @@ -81,25 +85,234 @@ function readFile(file) { } } -// Creates a builder for transforming files and bundling styles -function createBuilder() { - let config = null; +// Gives each function in a configuration a number, which is the same for the +// same function in this process +const functionIds = new WeakMap(); +let nextFunctionId = 0; + +// Serializes a configuration for a key. JSON.stringify drops functions and +// turns a RegExp into {}, so two different configurations could get the same +// key. Here a RegExp keeps its source and flags. A function has no stable +// form, so it becomes a number that is valid only in this process. Returns +// null if the value cannot be serialized, or if it has a function and +// `allowFunctions` is false. +function serialize(value, { allowFunctions }) { + try { + return JSON.stringify(value, (key, item) => { + if (typeof item === 'function') { + if (!allowFunctions) { + throw new Error('The configuration has a function'); + } + if (!functionIds.has(item)) { + functionIds.set(item, nextFunctionId++); + } + return { function: functionIds.get(item) }; + } + if (item instanceof RegExp) { + return { regexp: String(item) }; + } + return item; + }); + } catch { + return null; + } +} + +// Returns a key for the builder configuration, or null if the configuration +// cannot be serialized. Equal configurations can share one builder. +function getBuilderKey(config) { + return serialize(config, { allowFunctions: true }); +} + +// Turbopack runs PostCSS in short-lived worker processes. Each new process +// loses the in-memory state of the builder and must transform all files +// again. The disk cache below keeps that state between processes. +const CACHE_VERSION = 1; + +// Returns the path of the package.json of a package, or null if it cannot be +// found. +function findPackageJson(name, paths) { + try { + return require.resolve(`${name}/package.json`, { paths }); + } catch { + return null; + } +} + +// Returns the versions of the packages that create the styles. The versions +// come from the copies that this process loads. Babel loads the React Strict +// DOM preset from the project, and the preset loads the StyleX Babel plugin. +function getVersions(cwd) { + const reactStrictDom = findPackageJson('react-strict-dom', [cwd, __dirname]); + const stylex = + reactStrictDom != null + ? findPackageJson('@stylexjs/babel-plugin', [ + path.dirname(reactStrictDom) + ]) + : null; + return { + 'postcss-react-strict-dom': version, + '@babel/core': babel.version, + 'react-strict-dom': + reactStrictDom != null ? require(reactStrictDom).version : null, + '@stylexjs/babel-plugin': stylex != null ? require(stylex).version : null + }; +} + +// Returns the id of the dev server session. Turbopack starts the PostCSS +// workers of a session from the dev server process, so all these workers have +// the same parent process. +function getSessionId() { + return process.ppid; +} + +// Returns the disk cache file for the configuration, or null if the +// configuration cannot be a key. The file name contains: +// - The dev server session. 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 does not find, for example a new Babel config +// file or a change to a file that another file imports. +// - A hash of the inputs that change the styles of an unchanged file. The +// include and exclude patterns are not in the hash, because the builder +// removes the files that they no longer match. The Babel config files are +// not in the hash. The cache keeps their mtimes (see loadCache). +function getCacheFile(config) { + const { cwd, babelConfig } = config; + const key = serialize( + { cacheVersion: CACHE_VERSION, versions: getVersions(cwd), babelConfig }, + // Other processes cannot find a change to a function + { allowFunctions: false } + ); + if (key == null) { + return null; + } + const hash = crypto.createHash('sha1').update(key).digest('hex').slice(0, 16); + return path.join( + cwd, + 'node_modules', + '.cache', + 'postcss-react-strict-dom', + `cache-${getSessionId()}-${hash}.json` + ); +} + +// Returns true if a process with the id runs. +function isRunning(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // The process runs, but belongs to another user + return error.code === 'EPERM'; + } +} + +// Removes the cache files of dev server sessions that ended. Each session +// writes its own cache files, so old files stay until they are removed. +function removeOldCacheFiles(cacheDir) { + try { + for (const name of fs.readdirSync(cacheDir)) { + const match = /^cache-(\d+)-/.exec(name); + const sessionId = match != null ? Number(match[1]) : null; + if ( + sessionId !== getSessionId() && + (sessionId == null || !isRunning(sessionId)) + ) { + fs.rmSync(path.join(cacheDir, name), { force: true }); + } + } + } catch {} +} + +function readCache(cacheFile) { + try { + const data = JSON.parse(fs.readFileSync(cacheFile, 'utf-8')); + if ( + data.version === CACHE_VERSION && + Array.isArray(data.babelConfigFiles) && + Array.isArray(data.fileModified) && + Array.isArray(data.rules) + ) { + return data; + } + } catch {} + return null; +} + +function writeCache(cacheFile, data) { + try { + fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); + // Write to a temporary file, then rename it, so that other workers never + // read a partial file. Worker threads share a process id, so the name + // also contains the thread id and a random part. + const tmpFile = `${cacheFile}.${process.pid}.${threadId}.${crypto + .randomBytes(4) + .toString('hex')}.tmp`; + try { + fs.writeFileSync(tmpFile, JSON.stringify(data)); + fs.renameSync(tmpFile, cacheFile); + } finally { + fs.rmSync(tmpFile, { force: true }); + } + } catch {} +} + +// Creates a builder for transforming files and bundling styles. Each builder +// has one configuration, so that builds with other options do not change its +// state. +function createBuilder(config) { + const { cwd, include, exclude, babelConfig, isDev, useDiskCache } = config; const bundler = createBundler(); + // The mtime of each file whose styles the bundler keeps const fileModifiedMap = new Map(); - // Configures the builder with the provided options. - function configure(options) { - config = options; + // The mtime of each file that failed to transform. The builder does not + // transform such a file again until it changes. The disk cache does not + // keep these mtimes, so that a new process shows the error. + const failedFileMap = new Map(); + + // The transform in progress for each file, with the mtime of the file. + // Concurrent builds wait for the same transform. + const pendingTransformMap = new Map(); + + // The Babel config files of the stored styles, with their mtimes + const babelConfigFileMap = new Map(); + + // The disk cache file, or null if the builder does not use a disk cache + const cacheFile = useDiskCache ? getCacheFile(config) : null; + + // Loads the state from the disk cache. + function loadCache() { + const cached = readCache(cacheFile); + if (cached == null) { + return; + } + // Do not use the cache if a Babel config file changed + for (const [file, mtimeMs] of cached.babelConfigFiles) { + if (getMtime(file) !== mtimeMs) { + return; + } + } + for (const [file, mtimeMs] of cached.babelConfigFiles) { + babelConfigFileMap.set(file, mtimeMs); + } + for (const [file, mtimeMs] of cached.fileModified) { + fileModifiedMap.set(file, mtimeMs); + } + bundler.restore(cached.rules); } - /// Retrieves the current configuration. - function getConfig() { - if (config == null) { - throw new Error('Builder not configured'); + // Keeps the mtimes of the Babel config files of a transformed file. If one + // of these files changes later, a new process does not use the cache. The + // builder keeps the first mtime of each file. + function addBabelConfigFiles(files) { + for (const file of files) { + if (!babelConfigFileMap.has(file)) { + babelConfigFileMap.set(file, getMtime(file)); + } } - return config; } // Finds the @-rule in the provided PostCSS root. @@ -115,7 +328,6 @@ function createBuilder() { // Retrieves all files that match the include and exclude patterns. function getFiles() { - const { cwd, include, exclude } = getConfig(); return globSync(include, { onlyFiles: true, ignore: exclude, @@ -125,71 +337,124 @@ function createBuilder() { // Forgets a file and removes its stored styles. function removeFile(file) { - const { cwd } = getConfig(); fileModifiedMap.delete(file); + failedFileMap.delete(file); // The bundler stores rules by absolute path bundler.remove(path.resolve(cwd, file)); } - // Transforms the included files, bundles the CSS, and returns the result. - async function build({ shouldSkipTransformError }) { - const { cwd, babelConfig, useCSSLayers, isDev } = getConfig(); + // Transforms a file and stores its styles. Returns true if the state of the + // builder changed. + async function transformFile(file, mtimeMs, shouldSkipTransformError) { + const filePath = path.resolve(cwd, file); + const contents = readFile(filePath); + if (contents == null) { + // The file was deleted after the glob found it + removeFile(file); + return true; + } + if (!bundler.shouldTransform(contents)) { + // The file no longer uses React Strict DOM; remove its old styles + bundler.remove(filePath); + } else { + const result = await bundler.transform(filePath, contents, babelConfig, { + isDev, + shouldSkipTransformError + }); + if (result == null) { + // The transform failed. Keep the old mtime, so that a new process + // transforms the file again and shows the error. + failedFileMap.set(file, mtimeMs); + return false; + } + addBabelConfigFiles(result.configFiles); + } + failedFileMap.delete(file); + fileModifiedMap.set(file, mtimeMs); + return true; + } + // Transforms a file, or waits for the transform of the same version of the + // file that another build started. Returns true if the state of the builder + // changed. + function transformFileOnce(file, mtimeMs, shouldSkipTransformError) { + const pending = pendingTransformMap.get(file); + if (pending != null && pending.mtimeMs === mtimeMs) { + return pending.promise; + } + const promise = transformFile(file, mtimeMs, shouldSkipTransformError); + const entry = { mtimeMs, promise }; + pendingTransformMap.set(file, entry); + const clear = () => { + if (pendingTransformMap.get(file) === entry) { + pendingTransformMap.delete(file); + } + }; + promise.then(clear, clear); + return promise; + } + + // Transforms the included files, bundles the CSS, and returns the result. + async function build({ shouldSkipTransformError, useCSSLayers }) { const files = getFiles(); const fileSet = new Set(files); const filesToTransform = []; + let hasDeletedFiles = false; // Remove deleted files since the last build for (const file of fileModifiedMap.keys()) { if (!fileSet.has(file)) { removeFile(file); + hasDeletedFiles = true; + } + } + for (const file of failedFileMap.keys()) { + if (!fileSet.has(file)) { + failedFileMap.delete(file); } } for (const file of files) { const mtimeMs = getMtime(path.resolve(cwd, file)); - // Skip files that have not been modified since the last build + // Skip files that have not been modified since the last build, and + // files that failed to transform and did not change since then. // On first run, all files will be transformed const shouldSkip = - fileModifiedMap.has(file) && mtimeMs === fileModifiedMap.get(file); + mtimeMs != null && + (fileModifiedMap.get(file) === mtimeMs || + failedFileMap.get(file) === mtimeMs); if (shouldSkip) { continue; } - fileModifiedMap.set(file, mtimeMs); - filesToTransform.push(file); + filesToTransform.push({ file, mtimeMs }); } - await Promise.all( - filesToTransform.map((file) => { - const filePath = path.resolve(cwd, file); - const contents = readFile(filePath); - if (contents == null) { - // The file was deleted after the glob found it - removeFile(file); - return; - } - if (!bundler.shouldTransform(contents)) { - // The file no longer uses React Strict DOM; remove its old styles - bundler.remove(filePath); - return; - } - return bundler.transform(filePath, contents, babelConfig, { - isDev, - shouldSkipTransformError - }); - }) + const changes = await Promise.all( + filesToTransform.map(({ file, mtimeMs }) => + transformFileOnce(file, mtimeMs, shouldSkipTransformError) + ) ); + // Write the cache only if the state changed. A file that failed to + // transform does not change the state. + if (cacheFile != null && (hasDeletedFiles || changes.includes(true))) { + writeCache(cacheFile, { + version: CACHE_VERSION, + babelConfigFiles: Array.from(babelConfigFileMap.entries()), + fileModified: Array.from(fileModifiedMap.entries()), + rules: bundler.getRules() + }); + } + const css = bundler.bundle({ useCSSLayers }); return css; } // Retrieves the dependencies that PostCSS should watch. function getDependencies() { - const { include } = getConfig(); const dependencies = []; for (const fileOrGlob of include) { @@ -202,12 +467,16 @@ function createBuilder() { return dependencies; } + if (cacheFile != null) { + removeOldCacheFiles(path.dirname(cacheFile)); + loadCache(); + } + return { findAtRule, - configure, build, getDependencies }; } -module.exports = createBuilder; +module.exports = { createBuilder, getBuilderKey }; diff --git a/packages/postcss-react-strict-dom/src/bundler.js b/packages/postcss-react-strict-dom/src/bundler.js index 3557c119..72f280f6 100644 --- a/packages/postcss-react-strict-dom/src/bundler.js +++ b/packages/postcss-react-strict-dom/src/bundler.js @@ -18,11 +18,16 @@ module.exports = function createBundler() { } // Transforms the source code using Babel, extracting styles and storing them. + // Returns the result with the Babel config files that Babel loaded, or null + // if the transform fails and the error is skipped. async function transform(id, sourceCode, babelConfig, options) { const { isDev, shouldSkipTransformError } = options; - let result; + let result = null; + let configFiles = []; try { - result = await babel.transformAsync(sourceCode, { + // Load the config once, for the transform and for the list of config + // files + const partialConfig = await babel.loadPartialConfigAsync({ filename: id, caller: { name: 'postcss-react-strict-dom', @@ -31,6 +36,10 @@ module.exports = function createBundler() { }, ...babelConfig }); + if (partialConfig != null) { + configFiles = Array.from(partialConfig.files); + result = await babel.transformAsync(sourceCode, partialConfig.options); + } } catch (error) { if (shouldSkipTransformError) { console.warn( @@ -39,7 +48,7 @@ module.exports = function createBundler() { // Keep the old styles of the file. The error is often a temporary // syntax error during an edit. - return { code: sourceCode, map: null, metadata: {} }; + return null; } throw error; } @@ -59,7 +68,7 @@ module.exports = function createBundler() { styleXRulesMap.delete(id); } - return { code, map, metadata }; + return { code, map, metadata, configFiles }; } // Removes the stored styles for the specified file. @@ -67,6 +76,18 @@ module.exports = function createBundler() { styleXRulesMap.delete(id); } + // Returns all stored styles, so that they can be kept in a cache. + function getRules() { + return Array.from(styleXRulesMap.entries()); + } + + // Adds styles from a cache. + function restore(entries) { + for (const [id, rules] of entries) { + styleXRulesMap.set(id, rules); + } + } + // Bundles all collected styles into a single CSS string. function bundle({ useCSSLayers }) { const rules = Array.from(styleXRulesMap.values()).flat(); @@ -82,6 +103,8 @@ module.exports = function createBundler() { shouldTransform, transform, remove, + getRules, + restore, bundle }; }; diff --git a/packages/postcss-react-strict-dom/src/plugin.js b/packages/postcss-react-strict-dom/src/plugin.js index fa1aea55..9d3b14cc 100644 --- a/packages/postcss-react-strict-dom/src/plugin.js +++ b/packages/postcss-react-strict-dom/src/plugin.js @@ -5,15 +5,35 @@ * LICENSE file in the root directory of this source tree. */ const postcss = require('postcss'); -const createBuilder = require('./builder'); +const { createBuilder, getBuilderKey } = require('./builder'); module.exports = function createPlugin() { const PLUGIN_NAME = 'postcss-react-strict-dom'; - const builder = createBuilder(); + // The builder of each configuration. Some bundlers create the plugin again + // for each build, so equal configurations share one builder and its state. + const builderMap = new Map(); const isDev = process.env.NODE_ENV === 'development'; + // Only Turbopack needs the disk cache, because it runs PostCSS in + // short-lived worker processes. Next.js sets TURBOPACK for Turbopack. + const useDiskCache = isDev && Boolean(process.env.TURBOPACK); + + // Returns the builder for the configuration. + function getBuilder(config) { + const key = getBuilderKey(config); + if (key == null) { + return createBuilder(config); + } + let builder = builderMap.get(key); + if (builder == null) { + builder = createBuilder(config); + builderMap.set(key, builder); + } + return builder; + } + const plugin = ({ cwd = process.cwd(), // By default reuses the Babel configuration from the project root. @@ -37,6 +57,15 @@ module.exports = function createPlugin() { ...(exclude ?? []) ]; + const builder = getBuilder({ + include, + exclude, + cwd, + babelConfig, + isDev, + useDiskCache + }); + // Whether to skip the error when transforming styles. // Useful in watch mode where Fast Refresh can recover from errors. // Initial transform will still throw errors in watch mode to surface issues early. @@ -49,16 +78,6 @@ module.exports = function createPlugin() { async function (root, result) { const fileName = result.opts.from; - // Configure the builder with the provided options - await builder.configure({ - include, - exclude, - cwd, - babelConfig, - useCSSLayers, - isDev - }); - // Find the @-rule const atRule = builder.findAtRule(root); if (atRule == null) { @@ -81,7 +100,8 @@ module.exports = function createPlugin() { // Build and parse the CSS from collected styles const css = await builder.build({ - shouldSkipTransformError + shouldSkipTransformError, + useCSSLayers }); const parsed = await postcss.parse(css, { from: fileName diff --git a/packages/website/docs/learn/environment-setup/02-next.md b/packages/website/docs/learn/environment-setup/02-next.md index eaf79257..5e6d5985 100644 --- a/packages/website/docs/learn/environment-setup/02-next.md +++ b/packages/website/docs/learn/environment-setup/02-next.md @@ -72,6 +72,8 @@ const config = { export default config; ``` +In development with Turbopack, the plugin keeps a cache of extracted styles in `node_modules/.cache/postcss-react-strict-dom`. With the cache, Turbopack's short-lived PostCSS workers do not transform all files again on each rebuild. Each dev server session has its own cache, so a restart of the dev server transforms all files again. If the generated CSS is out of date, for example after a change to values from `css.defineConsts` that other files use, restart the dev server. The plugin does not use the cache if `babelConfig` contains functions, because it cannot find changes to them. + ## Next.js configuration Create or edit the `next.config.js` file as follows. Note that below you will find config for both turbopack or webpack. From 61ac9acaf6be69ca4ef81eecbd8c8a5f4d877834 Mon Sep 17 00:00:00 2001 From: Boris Yankov Date: Tue, 22 Sep 2026 22:31:31 +0300 Subject: [PATCH 4/4] docs(postcss): use the correct name of the CSS layers option 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. --- apps/vite-app/postcss.config.js | 2 +- packages/website/docs/learn/environment-setup/02-next.md | 2 +- packages/website/docs/learn/environment-setup/03-vite.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/vite-app/postcss.config.js b/apps/vite-app/postcss.config.js index be5e15e2..c07a2116 100644 --- a/apps/vite-app/postcss.config.js +++ b/apps/vite-app/postcss.config.js @@ -8,7 +8,7 @@ export default { '../../node_modules/example-ui/**/*.{js,jsx,mjs}' ], babelConfig, - useLayers: true + useCSSLayers: true }, autoprefixer: {} } diff --git a/packages/website/docs/learn/environment-setup/02-next.md b/packages/website/docs/learn/environment-setup/02-next.md index 5e6d5985..1a0258fc 100644 --- a/packages/website/docs/learn/environment-setup/02-next.md +++ b/packages/website/docs/learn/environment-setup/02-next.md @@ -64,7 +64,7 @@ const config = { 'node_modules//*.js' ], babelConfig: babelLoader, - useLayers: true, + useCSSLayers: true, } }, }; diff --git a/packages/website/docs/learn/environment-setup/03-vite.md b/packages/website/docs/learn/environment-setup/03-vite.md index 699d40d0..5e76c5ab 100644 --- a/packages/website/docs/learn/environment-setup/03-vite.md +++ b/packages/website/docs/learn/environment-setup/03-vite.md @@ -130,7 +130,7 @@ export default { "node_modules//**/*.{js,mjs}", ], babelConfig, - useLayers: true, + useCSSLayers: true, }, }, };