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/postcss-react-strict-dom/__tests__/index-test.js b/packages/postcss-react-strict-dom/__tests__/index-test.js index 99936520..c755f32f 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,392 @@ 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) { + 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, + // 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 () => { + 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(); + } + }); + + 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(); + } + }); + + 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 02fcaf79..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. @@ -60,25 +64,255 @@ function parseDependency(fileOrGlob) { return message; } -// Creates a builder for transforming files and bundling styles -function createBuilder() { - let config = null; +// 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; + } +} + +// 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. @@ -94,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, @@ -102,61 +335,126 @@ function createBuilder() { }); } - // Transforms the included files, bundles the CSS, and returns the result. - async function build({ shouldSkipTransformError }) { - const { cwd, babelConfig, useCSSLayers, isDev } = getConfig(); + // Forgets a file and removes its stored styles. + function removeFile(file) { + fileModifiedMap.delete(file); + failedFileMap.delete(file); + // The bundler stores rules by absolute path + bundler.remove(path.resolve(cwd, file)); + } + + // 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 (!files.includes(file)) { - fileModifiedMap.delete(file); - bundler.remove(file); + 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 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 + // 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 = fs.readFileSync(filePath, 'utf-8'); - if (!bundler.shouldTransform(contents)) { - 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) { @@ -169,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 73ed1b1e..72f280f6 100644 --- a/packages/postcss-react-strict-dom/src/bundler.js +++ b/packages/postcss-react-strict-dom/src/bundler.js @@ -18,10 +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; - const { code, map, metadata } = await babel - .transformAsync(sourceCode, { + let result = null; + let configFiles = []; + try { + // 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', @@ -29,24 +35,40 @@ 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; }); + if (partialConfig != null) { + configFiles = Array.from(partialConfig.files); + result = await babel.transformAsync(sourceCode, partialConfig.options); + } + } 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 null; + } + 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 }; + return { code, map, metadata, configFiles }; } // Removes the stored styles for the specified file. @@ -54,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(); @@ -69,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..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, } }, }; @@ -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. 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, }, }, };