From b6984e6fc9c61eb57e8db182e1daabc14786cb60 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 1 Oct 2026 01:50:40 +0900 Subject: [PATCH 01/14] fix(bun-plugin): emit the stylesheet in Bun.build and compile alias and StyleX imports The generated stylesheet always resolved to an empty module, so Bun.build dropped every style, and the pre-parse gate only accepted import declarations of @devup-ui/react, so files using the alias packages, StyleX, subpaths or re-exports ran the runtime stubs. DevupUI() now serves the complete sheet to the CSS loader after defer(), and the gate reads the real imports with Bun.Transpiler.scanImports. Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_bun_plugin_build.json | 7 ++ packages/bun-plugin/README.md | 26 ++++- .../bun-plugin/__regression__/css-emit.bun.ts | 91 +++++++++++++++ packages/bun-plugin/src/plugin.ts | 109 +++++++++++++----- packages/bun-plugin/src/register.ts | 2 +- 5 files changed, 200 insertions(+), 35 deletions(-) create mode 100644 .changepacks/changepack_log_bun_plugin_build.json diff --git a/.changepacks/changepack_log_bun_plugin_build.json b/.changepacks/changepack_log_bun_plugin_build.json new file mode 100644 index 000000000..a461c80bf --- /dev/null +++ b/.changepacks/changepack_log_bun_plugin_build.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/bun-plugin/package.json": "Patch" + }, + "note": "Bun plugin works with Bun.build: `DevupUI()` (from `@devup-ui/bun-plugin/register`) serves the generated stylesheet to the bundler's CSS loader once every other module is loaded, so the build emits a CSS output with the styles of the whole bundle instead of an empty module; class names are short there and readable under the runtime (`debug` option). Files importing the packages Devup UI takes the place of (`@emotion/react`, `@emotion/styled`, `styled-components`, `@vanilla-extract/css`), `@stylexjs/stylex`, or a subpath or re-export of these or `@devup-ui/react`, are compiled instead of skipped", + "date": "2026-09-30T00:00:00.000Z" +} diff --git a/packages/bun-plugin/README.md b/packages/bun-plugin/README.md index c23855214..289863c3e 100644 --- a/packages/bun-plugin/README.md +++ b/packages/bun-plugin/README.md @@ -40,9 +40,29 @@ Add the zero-config entry to Bun's preload list: preload = ["@devup-ui/bun-plugin"] ``` -## Custom Shorthands - -To configure custom shorthands, preload a local module instead of the +## Bundling with `Bun.build` + +```ts +import { DevupUI } from '@devup-ui/bun-plugin/register' + +await Bun.build({ + entrypoints: ['./src/index.tsx'], + outdir: './dist', + plugins: [DevupUI()], +}) +``` + +The build emits the stylesheet as a CSS output holding the styles of every +module in the bundle. Imports of the packages Devup UI takes the place of +(`@emotion/react`, `@emotion/styled`, `styled-components`, +`@vanilla-extract/css`) and of `@stylexjs/stylex` are compiled too. + +Class names are short in `Bun.build` and readable under the runtime; pass +`debug` to choose. + +## Custom Shorthands + +To configure custom shorthands, preload a local module instead of the zero-config entry: ```toml diff --git a/packages/bun-plugin/__regression__/css-emit.bun.ts b/packages/bun-plugin/__regression__/css-emit.bun.ts index 7637396be..7a39652cd 100644 --- a/packages/bun-plugin/__regression__/css-emit.bun.ts +++ b/packages/bun-plugin/__regression__/css-emit.bun.ts @@ -69,6 +69,97 @@ await import(cssPath) }, ) +const registerEntry = resolve(import.meta.dir, '..', 'dist', 'register.mjs') + +function run(cwd: string, script: string) { + writeFileSync(join(cwd, 'bunfig.toml'), '') + writeFileSync(join(cwd, 'check.ts'), script) + const result = Bun.spawnSync([process.execPath, 'run', 'check.ts'], { + cwd, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, + }) + expect( + result.exitCode, + result.stdout.toString() + result.stderr.toString(), + ).toBe(0) +} + +it('bundles the styles of every module into the Bun.build stylesheet', () => { + const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) + try { + const widths = Array.from({ length: 8 }, (_, i) => 301 + i) + for (const width of widths) { + writeFileSync( + join(cwd, `fixture-${width}.ts`), + `import { css } from '@devup-ui/react' +export const cls = css({ width: '${width}px' }) +`, + ) + } + writeFileSync( + join(cwd, 'entry.ts'), + widths + .map((width) => `export { cls as c${width} } from './fixture-${width}'`) + .join('\n'), + ) + run( + cwd, + `import { expect } from 'bun:test' +import { DevupUI } from ${JSON.stringify(registerEntry.replaceAll('\\', '/'))} + +const result = await Bun.build({ + entrypoints: ['./entry.ts'], + outdir: './out', + plugins: [DevupUI()], +}) +expect(result.success).toBe(true) +const stylesheets = result.outputs.filter((output) => output.path.endsWith('.css')) +expect(stylesheets).toHaveLength(1) +const css = (await stylesheets[0].text()).replace(/\\s+/g, '') +for (const width of ${JSON.stringify(widths)}) expect(css).toContain('width:' + width + 'px') +`, + ) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) + +it('compiles the packages Devup UI takes the place of, and StyleX', () => { + const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) + try { + writeFileSync( + join(cwd, 'emotion.ts'), + `import { css } from '@emotion/react' +export const cls = css({ width: '201px' }) +`, + ) + writeFileSync( + join(cwd, 'stylex.ts'), + `import * as stylex from '@stylexjs/stylex' +export const styles = stylex.create({ box: { width: '203px' } }) +`, + ) + run( + cwd, + `import { readFileSync } from 'node:fs' +import { expect } from 'bun:test' + +await import(${JSON.stringify(pluginEntry.replaceAll('\\', '/'))}) +const emotion = await import('./emotion.ts') +const stylex = await import('./stylex.ts') +expect(emotion.cls).toBeTruthy() +expect(stylex.styles).toBeTruthy() +const css = readFileSync('./df/devup-ui/devup-ui.css', 'utf-8') +for (const width of [201, 203]) expect(css).toContain('width:' + width + 'px') +`, + ) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) + it('emits vanilla-extract styles through the WASM engine', () => { const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) try { diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 52591c9ee..5fc4b01c9 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -14,13 +14,12 @@ import { codeExtract, getCss, getThemeInterface, - hasDevupUI, registerShorthands, registerTheme, setDebug, setModuleResolver, } from '@devup-ui/wasm' -import { plugin } from 'bun' +import { type BunPlugin, plugin, type PluginBuilder } from 'bun' import { cssDirName, cssNamespace, resolveCssId } from './css-id' @@ -30,11 +29,25 @@ const distDir = 'df' const cssDir = resolve(distDir, cssDirName) const singleCss = true const importAliases = mergeImportAliases() +// The packages whose imports the extractor compiles: Devup UI, the packages it +// takes the place of, and StyleX +const compiledPackages = [ + libPackage, + '@stylexjs/stylex', + ...Object.keys(importAliases), +] export interface DevupUIBunPluginOptions { shorthands?: CustomShorthands + /** + * Readable class names. Defaults to `true` under the Bun runtime (tests) and + * `false` in `Bun.build`. + */ + debug?: boolean } +type SourceLoader = 'tsx' | 'ts' | 'jsx' | 'js' + async function writeDataFiles() { let theme = {} try { @@ -71,16 +84,31 @@ async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { await writeDataFiles() } -// Devup UI is a preprocessor: the stylesheet is a build artifact consumed by a -// bundler, and Bun's runtime has no CSS loader (`onLoad` only accepts the -// script/data loaders). The injected import exists so bundlers pick the -// stylesheet up, so under the Bun runtime it resolves to an empty module. -function loadCssModule() { - return { contents: '', loader: 'js' as const } +const scanners = new Map() + +/** Whether `contents` imports a package the extractor compiles */ +function importsCompiledPackage(contents: string, loader: SourceLoader) { + let scanner = scanners.get(loader) + if (!scanner) { + scanner = new Bun.Transpiler({ loader }) + scanners.set(loader, scanner) + } + try { + return scanner + .scanImports(contents) + .some(({ path }) => + compiledPackages.some( + (name) => path === name || path.startsWith(`${name}/`), + ), + ) + } catch { + // Bun reports the syntax error when it loads the untouched source + return false + } } -async function loadSourceFile(filePath: string) { - const loader: 'tsx' | 'ts' | 'jsx' | 'js' = filePath.endsWith('.tsx') +async function loadSourceFile(filePath: string, bundling: boolean) { + const loader: SourceLoader = filePath.endsWith('.tsx') ? 'tsx' : filePath.endsWith('.ts') ? 'ts' @@ -89,7 +117,7 @@ async function loadSourceFile(filePath: string) { : 'js' const contents = await Bun.file(filePath).text() - if (hasDevupUI(filePath, contents, libPackage)) { + if (importsCompiledPackage(contents, loader)) { const code = codeExtract( filePath, contents, @@ -100,29 +128,29 @@ async function loadSourceFile(filePath: string) { false, importAliases, ) - // singleCss stores every extracted style in the base sheet. Finish the - // write before returning the injected import; synchronous writes also keep + // Under the runtime the stylesheet is read from disk. singleCss stores + // every extracted style in the base sheet; synchronous writes keep // concurrent source loads from overwriting a newer sheet with an older one. - writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') + if (!bundling) + writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') return { contents: code.code, loader } } return { contents, loader } } -// Registers the Bun plugin. Returns the promise produced by `plugin()` (its -// `setup` is async), so callers MUST `await` it. Bun's preload mechanism waits -// for an awaited module evaluation to settle; awaiting this guarantees the -// `onLoad` hook is installed before any source file is loaded. Without the -// await, preload-driven `bun test` users race the async setup and load sources -// against the @devup-ui/react runtime stubs (throwing "Cannot run on the -// runtime"). -function register(options: DevupUIBunPluginOptions = {}) { - return plugin({ +/** + * The Devup UI plugin, for `Bun.build` (`plugins: [DevupUI()]`) as well as the + * Bun runtime ({@link register}). + */ +function DevupUI(options: DevupUIBunPluginOptions = {}) { + return { name: 'devup-ui', - async setup(build) { + async setup(build: PluginBuilder) { + // `Bun.build` hands its config to plugins; the runtime has none + const bundling = build.config !== undefined await initialize(options) - setDebug(true) + setDebug(options.debug ?? !bundling) // Resolve devup-ui CSS files onto a path-free virtual id, so nothing // derived from this checkout's cwd can be baked into Bun's shared, @@ -132,9 +160,17 @@ function register(options: DevupUIBunPluginOptions = {}) { ({ path, importer }) => resolveCssId(path, importer, distDir), ) - // Serve the virtual stylesheet resolved above - build.onLoad({ filter: /.*/, namespace: cssNamespace }, () => - loadCssModule(), + // The bundler takes the stylesheet once every other module is loaded, + // so it holds the styles of all of them. The Bun runtime has no CSS + // loader (`onLoad` only accepts the script/data loaders), so there the + // injected import resolves to an empty module. + build.onLoad( + { filter: /.*/, namespace: cssNamespace }, + async ({ defer }) => { + if (!bundling) return { contents: '', loader: 'js' } + await defer() + return { contents: getCss(null, false), loader: 'css' } + }, ) // Load source files from packages directory (file namespace) @@ -142,10 +178,21 @@ function register(options: DevupUIBunPluginOptions = {}) { { filter: /\.(?:tsx?|jsx|mjs)$|[\\/]@devup-ui[\\/].*\.js$/, }, - ({ path }) => loadSourceFile(path), + ({ path }) => loadSourceFile(path, bundling), ) }, - }) + } satisfies BunPlugin +} + +// Registers the Bun runtime plugin. Returns the promise produced by `plugin()` +// (its `setup` is async), so callers MUST `await` it. Bun's preload mechanism +// waits for an awaited module evaluation to settle; awaiting this guarantees +// the `onLoad` hook is installed before any source file is loaded. Without the +// await, preload-driven `bun test` users race the async setup and load sources +// against the @devup-ui/react runtime stubs (throwing "Cannot run on the +// runtime"). +function register(options: DevupUIBunPluginOptions = {}) { + return plugin(DevupUI(options)) } -export { plugin, register } +export { DevupUI, plugin, register } diff --git a/packages/bun-plugin/src/register.ts b/packages/bun-plugin/src/register.ts index eb32ee74c..3f4240ff6 100644 --- a/packages/bun-plugin/src/register.ts +++ b/packages/bun-plugin/src/register.ts @@ -1,2 +1,2 @@ export type { DevupUIBunPluginOptions } from './plugin' -export { register } from './plugin' +export { DevupUI, register } from './plugin' From 71a59846aeaf0d8f297f3db687a8bddc07da0ffa Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 1 Oct 2026 02:44:05 +0900 Subject: [PATCH 02/14] fix(webpack-plugin,rsbuild-plugin): keep every extracted style in the built stylesheets A stylesheet module is built on its first import, and the shared base is read from disk past the plugin, so styles extracted later were missing from webpack and Rsbuild output; Rsbuild also served a module-wide string kept only when it grew. The files the entries reach are now extracted before the build, the base on disk is kept current, and a compilation whose stylesheets changed is run once more, writing only the complete result. Rsbuild serves each stylesheet from the current sheet. Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_css_snapshot.json | 9 + .../plugin-utils/src/import-graph.test.ts | 51 +++++ packages/plugin-utils/src/import-graph.ts | 37 ++++ packages/plugin-utils/src/index.ts | 2 + .../src/__tests__/checkout-isolation.test.ts | 2 + .../src/__tests__/plugin.test.ts | 194 +++++++++++++++++- packages/rsbuild-plugin/src/plugin.ts | 143 +++++++++++-- .../src/__tests__/css-loader.test.ts | 22 ++ .../src/__tests__/plugin.test.ts | 178 +++++++++++++--- packages/webpack-plugin/src/css-loader.ts | 10 +- packages/webpack-plugin/src/plugin.ts | 120 ++++++++--- packages/webpack-plugin/src/served-css.ts | 18 ++ 12 files changed, 709 insertions(+), 77 deletions(-) create mode 100644 .changepacks/changepack_log_css_snapshot.json create mode 100644 packages/webpack-plugin/src/served-css.ts diff --git a/.changepacks/changepack_log_css_snapshot.json b/.changepacks/changepack_log_css_snapshot.json new file mode 100644 index 000000000..a1c52eeec --- /dev/null +++ b/.changepacks/changepack_log_css_snapshot.json @@ -0,0 +1,9 @@ +{ + "changes": { + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch" + }, + "note": "Webpack and Rsbuild builds hold every style in their stylesheets: a stylesheet module is built on its first import, so styles extracted later (the global styles of files webpack or Rsbuild reached late, often outside `src`) were missing from the output. The files the entries reach under `src` are now extracted before the build, the shared base on disk is kept current, and when a stylesheet still changed during the build it is compiled once more, writing only the complete result. Rsbuild serves each stylesheet from the current sheet instead of a module-wide string kept only when it grew, which left edits that shortened the CSS and later builds in the same process stale. plugin-utils adds computeReachableFiles", + "date": "2026-09-30T00:00:00.000Z" +} diff --git a/packages/plugin-utils/src/import-graph.test.ts b/packages/plugin-utils/src/import-graph.test.ts index cc15f2346..d99a4fca4 100644 --- a/packages/plugin-utils/src/import-graph.test.ts +++ b/packages/plugin-utils/src/import-graph.test.ts @@ -17,11 +17,62 @@ import { computeCompiledFiles, computeFileReach, computeFileRoutes, + computeReachableFiles, createModuleResolver, planAtomHoist, runImportGraphCli, } from './import-graph' +describe('computeReachableFiles', () => { + let tempRoot: string + let srcDir: string + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'devup-ui-reachable-files-')) + srcDir = join(tempRoot, 'src') + }) + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }) + }) + + function writeFixture(path: string, code: string): void { + const filePath = join(tempRoot, path) + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, code) + } + + it('follows static and dynamic imports from the entries only', () => { + writeFixture('src/index.tsx', "import './a'\nimport('./lazy')\n") + writeFixture('src/a.tsx', "import './b'\n") + writeFixture('src/b.tsx', 'export const b = 1\n') + writeFixture('src/lazy.tsx', "import './b'\n") + writeFixture('src/unused.stories.tsx', "import './a'\n") + + expect( + computeReachableFiles({ srcDir, entries: [join(srcDir, 'index')] }), + ).toEqual( + ['a.tsx', 'b.tsx', 'index.tsx', 'lazy.tsx'].map((file) => + join(srcDir, file), + ), + ) + }) + + it('reaches nothing from entries outside the source directory', () => { + writeFixture('src/a.tsx', 'export const a = 1\n') + writeFixture('other/entry.tsx', "import '../src/a'\n") + const graph = buildStaticImportGraph(srcDir) + + expect( + computeReachableFiles({ + srcDir, + entries: [join(tempRoot, 'other/entry.tsx'), join(srcDir, 'missing')], + graph, + }), + ).toEqual([]) + }) +}) + describe('buildCanonicalMap', () => { let tempRoot: string let cwd: string diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index aa55a9bc8..b221bbc1e 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -336,6 +336,43 @@ export function computeCompiledFiles( return [...compiled].map((file) => toPosixRelative(cwd, file)).sort() } +export interface ComputeReachableFilesOptions { + srcDir: string + tsconfigPath?: string + /** The bundler's entry modules, as absolute paths with or without extension. */ + entries: string[] + /** pre-built graph from `buildStaticImportGraph` to skip the file scan. */ + graph?: StaticImportGraph +} + +/** + * The source files under `srcDir` a bundler compiles from `entries`: their + * closure over static and dynamic `import()` edges, as absolute paths in the + * graph's order. Extracting them before bundling fills the shared stylesheet + * without the styles of files no entry imports. + */ +export function computeReachableFiles( + opts: ComputeReachableFilesOptions, +): string[] { + const { files, fileSet, staticImports, dynamicImports } = + opts.graph ?? buildStaticImportGraph(opts.srcDir, opts.tsconfigPath) + const queue = opts.entries + .map((entry) => resolveFile(resolve(entry))) + .filter( + (entry): entry is string => entry !== undefined && fileSet.has(entry), + ) + const reached = new Set() + for (let index = 0; index < queue.length; index += 1) { + const file = queue[index] + if (reached.has(file)) continue + reached.add(file) + for (const imports of [staticImports, dynamicImports]) { + for (const target of imports.get(file) ?? []) queue.push(target) + } + } + return files.filter((file) => reached.has(file)) +} + export interface ComputeFileReachOptions { srcDir: string tsconfigPath?: string diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index b66b07abb..385a2dc2b 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -9,6 +9,8 @@ export { type ComputeFileReachOptions, computeFileRoutes, type ComputeFileRoutesOptions, + computeReachableFiles, + type ComputeReachableFilesOptions, createModuleResolver, type CreateModuleResolverOptions, listSourceFiles, diff --git a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts index b57d9ef2a..7c61a4591 100644 --- a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts @@ -69,6 +69,8 @@ async function extractedCssDirIn(checkout: string) { await plugin.setup({ transform, modifyRsbuildConfig: mock(), + modifyRspackConfig: mock(), + onBeforeBuild: mock(), } as unknown as RsbuildSetupContext) codeExtractSpy.mockClear() diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 3fa5b9fd4..1a4393a97 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -42,6 +42,9 @@ function createSetupContext( return { transform: mock(), modifyRsbuildConfig: mock(), + modifyRspackConfig: mock(), + onBeforeBuild: mock(), + context: { rootPath: process.cwd() }, renderChunk: mock(), generateBundle: mock(), closeBundle: mock(), @@ -261,15 +264,17 @@ describe('DevupUIRsbuildPlugin', () => { expect.any(Function), ) + const getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('file css') expect( transform.mock.calls[0][1]({ - code: ` - .devup-ui-1 { - color: red; - } - `, + code: '/* placeholder */', + resourcePath: resolve('df', 'devup-ui', 'devup-ui-1.css'), + environment: { name: 'web' }, }), - ).toBe('') + ).toBe('file css') + // A file's stylesheet imports the shared base + expect(getCssSpy).toHaveBeenCalledWith(1, true) + getCssSpy.mockRestore() }) it('should transform code', async () => { const plugin = DevupUI() @@ -291,11 +296,6 @@ describe('DevupUIRsbuildPlugin', () => { expect.any(Function), ) - expect( - transform.mock.calls[0][1]({ - code: ``, - }), - ).toBe('') codeExtractSpy.mockReturnValue( createCodeExtractResult({ code: '
', @@ -607,12 +607,14 @@ const App = () => `, const servedChunk = transform.mock.calls[0][1]({ code: '', resourcePath: resolve('df', 'devup-ui', 'devup-ui-3.css'), + environment: { name: 'web' }, }) expect(servedChunk).toBe('CSS_FOR_3') expect(getCssSpy).toHaveBeenCalledWith(3, false) const servedBase = transform.mock.calls[0][1]({ code: '', resourcePath: resolve('df', 'devup-ui', 'devup-ui.css'), + environment: { name: 'web' }, }) expect(servedBase).toBe('CSS_FOR_null') expect(getCssSpy).toHaveBeenCalledWith(null, false) @@ -710,4 +712,174 @@ const App = () => `, expect((cfgArr.tools.rspack as unknown[]).length).toBe(2) }) }) + + describe('stylesheets built too early', () => { + let getCssSpy: ReturnType + let readFileSyncSpy: ReturnType + let computeReachableFilesSpy: ReturnType + + afterEach(() => { + getCssSpy.mockRestore() + readFileSyncSpy.mockRestore() + computeReachableFilesSpy.mockRestore() + existsSyncSpy.mockReturnValue(false) + }) + + async function setup(options: Parameters[0] = {}) { + getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('before') + readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue('source') + computeReachableFilesSpy = spyOn( + pluginUtils, + 'computeReachableFiles', + ).mockReturnValue([resolve('src', 'App.tsx')]) + codeExtractSpy.mockReturnValue(createCodeExtractResult()) + const transform = mock() + const onBeforeBuild = mock() + const modifyRspackConfig = mock() + await DevupUI(options).setup( + createSetupContext({ transform, onBeforeBuild, modifyRspackConfig }), + ) + const config: { plugins?: { apply(compiler: unknown): void }[] } = {} + modifyRspackConfig.mock.calls[0][0](config, { + environment: { name: 'web' }, + }) + const taps: Record unknown> = {} + const tap = + (hook: string) => (_: unknown, fn: (...args: unknown[]) => unknown) => { + taps[hook] = fn + } + const compiler = { + watchMode: false, + rspack: { Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 } }, + hooks: { + run: { tap: tap('run') }, + thisCompilation: { tap: tap('start') }, + }, + } + config.plugins![0]!.apply(compiler) + const compilation = { + assets: { 'index.js': {} }, + deleteAsset: mock(), + hooks: { + finishModules: { tap: tap('finishModules') }, + processAssets: { tap: tap('processAssets') }, + needAdditionalPass: { tap: tap('needAdditionalPass') }, + }, + } + const serve = (resourcePath: string) => + transform.mock.calls[0][1]({ + resourcePath, + environment: { name: 'web' }, + }) + return { onBeforeBuild, compiler, compilation, taps, serve } + } + + it('extracts the files the entries reach before building', async () => { + const { onBeforeBuild } = await setup({ atomHoist: undefined }) + codeExtractSpy.mockClear() + onBeforeBuild.mock.calls[0][0]({ + environments: { + web: { + entry: { + a: './src/a.tsx', + b: ['./src/b.tsx'], + c: { import: './src/c.tsx' }, + d: { import: ['./src/d.tsx'] }, + }, + }, + }, + }) + expect(computeReachableFilesSpy).toHaveBeenCalledWith({ + srcDir: resolve(process.cwd(), 'src'), + tsconfigPath: resolve(process.cwd(), 'tsconfig.json'), + entries: ['a', 'b', 'c', 'd'].map((name) => + resolve(process.cwd(), `./src/${name}.tsx`), + ), + }) + expect(codeExtractSpy).toHaveBeenCalledWith( + resolve('src', 'App.tsx'), + 'source', + '@devup-ui/react', + expect.stringMatching(/^\.\//), + false, + false, + true, + expect.anything(), + ) + + // an extraction error is reported by the transform of that file + codeExtractSpy.mockImplementation(() => { + throw new Error('boom') + }) + expect(() => + onBeforeBuild.mock.calls[0][0]({ environments: {} }), + ).not.toThrow() + }) + + it('extracts under posix names in atom mode', async () => { + const { onBeforeBuild } = await setup({ atomHoist: 2 }) + codeExtractSpy.mockClear() + onBeforeBuild.mock.calls[0][0]({ environments: {} }) + expect(codeExtractSpy.mock.calls[0]![0]).toBe( + resolve('src', 'App.tsx').replaceAll('\\', '/'), + ) + }) + + it('compiles once more, writing no file, when a stylesheet changed', async () => { + const { compiler, compilation, taps, serve } = await setup() + writeFileSyncSpy.mockClear() + taps.start!(compilation) + // the shared base is written first, as the CSS loaders read it from disk + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui.css'), + 'before', + 'utf-8', + ) + serve(resolve('df', 'devup-ui', 'devup-ui-1.css')) + getCssSpy.mockReturnValue('after') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).toHaveBeenCalledWith('index.js') + writeFileSyncSpy.mockClear() + expect(taps.needAdditionalPass!()).toBe(true) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui-1.css'), + 'after', + 'utf-8', + ) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui.css'), + 'after', + 'utf-8', + ) + + // one more pass per run at most + compilation.deleteAsset.mockClear() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBe(false) + taps.run!() + expect(taps.needAdditionalPass!()).toBe(true) + + // the dev server rebuilds through the files the transforms write + compiler.watchMode = true + taps.start!(compilation) + taps.finishModules!() + expect(taps.needAdditionalPass!()).toBe(false) + }) + + it('keeps the pass when every stylesheet is current', async () => { + const { compilation, taps, serve } = await setup() + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('before') + writeFileSyncSpy.mockClear() + taps.start!(compilation) + expect(writeFileSyncSpy).not.toHaveBeenCalled() + serve(resolve('df', 'devup-ui', 'devup-ui-1.css')) + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBe(false) + }) + }) }) diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 990a4cd2a..47d4f2f3f 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -1,10 +1,11 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { buildCanonicalMap, computeFileReach, + computeReachableFiles, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, @@ -30,7 +31,9 @@ import { setModuleResolver, setPrefix, } from '@devup-ui/wasm' -import type { RsbuildPlugin } from '@rsbuild/core' +import type { RsbuildPlugin, Rspack } from '@rsbuild/core' + +const PLUGIN_NAME = 'devup-ui-rsbuild-plugin' export interface DevupUIRsbuildPluginOptions { package: string @@ -65,8 +68,6 @@ export interface DevupUIRsbuildPluginOptions { importAliases?: ImportAliases } -let globalCss = '' - async function writeDataFiles( options: Omit< DevupUIRsbuildPluginOptions, @@ -103,6 +104,16 @@ async function writeDataFiles( ]) } +/** + * Write `css` to the stylesheet file at `path` unless it holds it already. The + * CSS loaders read the shared base from disk when a file's stylesheet imports + * it, so it must hold every style extracted so far. + */ +function writeChanged(path: string, css: string) { + if (!existsSync(path) || readFileSync(path, 'utf-8') !== css) + writeFileSync(path, css, 'utf-8') +} + export const DevupUI = ({ include = [], package: libPackage = '@devup-ui/react', @@ -121,7 +132,7 @@ export const DevupUI = ({ const importAliases = mergeImportAliases(userImportAliases) return { - name: 'devup-ui-rsbuild-plugin', + name: PLUGIN_NAME, async setup(api) { setDebug(debug) if (prefix) { @@ -190,23 +201,123 @@ export const DevupUI = ({ } } + // Extract the source files under `src` that the entries reach, in path + // order, the same way the transform does, so that a stylesheet built on + // its first import already holds the styles of every one. Best-effort: + // a stylesheet still missing styles is rebuilt by another pass. + api.onBeforeBuild(({ environments }) => { + try { + const root = api.context.rootPath + const entries = Object.values(environments).flatMap(({ entry }) => + Object.values(entry).flatMap((value) => + (typeof value === 'object' && !Array.isArray(value) + ? [value.import].flat() + : [value].flat() + ).map((request) => resolve(root, request)), + ), + ) + for (const file of computeReachableFiles({ + srcDir: resolve(root, 'src'), + tsconfigPath: resolve(root, 'tsconfig.json'), + entries, + })) { + let extractCssDir = relative(dirname(file), cssDir).replaceAll( + '\\', + '/', + ) + if (!extractCssDir.startsWith('./')) + extractCssDir = `./${extractCssDir}` + codeExtract( + atomMode ? file.replaceAll('\\', '/') : file, + readFileSync(file, 'utf-8'), + libPackage, + extractCssDir, + singleCss, + atomMode, + !atomMode, + importAliases, + ) + } + } catch { + // The transform reports the error of the file it cannot extract + } + }) + + const servedCss = new Map>() + const stylesheet = (resourcePath: string) => + // A file's stylesheet imports the shared base, except in atom mode, + // where the entry code imports the base itself so that hoisted atoms + // load once (the injected splitChunks cacheGroup, see + // modifyRsbuildConfig, emits the base once) + getCss(getFileNumByFilename(basename(resourcePath)), !atomMode) + api.transform( { test: cssDir, }, - ({ resourcePath }) => { - // Non-atom: keep the existing single-string behavior (no regression). - if (!atomMode) return globalCss - // Atom mode: serve the route-specific chunk and have it @import the - // shared base (devup-ui.css) so hoisted atoms load ONCE and are not - // inlined per chunk. The base file itself imports nothing. - // Route chunk and base are SEPARATE modules (the transformed entry - // code imports both via import_main_css); the injected splitChunks - // cacheGroup (see modifyRsbuildConfig) emits the base once. - return getCss(getFileNumByFilename(basename(resourcePath)), false) + ({ resourcePath, environment }) => { + const css = stylesheet(resourcePath) + servedCss.get(environment.name)?.set(resourcePath, css) + return css }, ) + // A stylesheet module is built on its first import, which can come before + // the modules whose styles it holds are extracted. When one was, compile + // once more: every module is extracted by then. The dev server rebuilds it + // through the stylesheet files the transforms write instead. + api.modifyRspackConfig((config, { environment }) => { + config.plugins ??= [] + config.plugins.push({ + apply(compiler: Rspack.Compiler) { + let passes = 0 + compiler.hooks.run.tap(PLUGIN_NAME, () => { + passes = 0 + }) + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + const served = new Map() + servedCss.set(environment.name, served) + const basePath = join(cssDir, 'devup-ui.css') + const base = stylesheet(basePath) + writeChanged(basePath, base) + let stale: string[] = [] + compilation.hooks.finishModules.tap(PLUGIN_NAME, () => { + if (compiler.watchMode) return + const changed = new Set( + [...served] + .filter(([path, css]) => stylesheet(path) !== css) + .map(([path]) => path), + ) + // A file's stylesheet `@import`s the shared base, which the CSS + // loaders read from disk without passing through this plugin + if (stylesheet(basePath) !== base) changed.add(basePath) + stale = [...changed] + }) + // The next pass writes the build; this one writes none of its files + compilation.hooks.processAssets.tap( + { + name: PLUGIN_NAME, + stage: + compiler.rspack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + if (stale.length === 0 || passes > 0) return + for (const name of Object.keys(compilation.assets)) + compilation.deleteAsset(name) + }, + ) + compilation.hooks.needAdditionalPass.tap(PLUGIN_NAME, () => { + if (stale.length === 0 || passes > 0) return false + passes += 1 + for (const path of stale) + writeFileSync(path, stylesheet(path), 'utf-8') + return true + }) + }) + }, + }) + }) + api.modifyRsbuildConfig((config) => { const theme = getDefaultTheme() if (theme) { @@ -283,7 +394,6 @@ export const DevupUI = ({ : resourcePath const { code: retCode, - css = '', map, cssFile, updatedBaseStyle, @@ -312,7 +422,6 @@ export const DevupUI = ({ } if (cssFile) { - if (globalCss.length < css.length) globalCss = css promises.push( writeFile( join(cssDir, basename(cssFile)), diff --git a/packages/webpack-plugin/src/__tests__/css-loader.test.ts b/packages/webpack-plugin/src/__tests__/css-loader.test.ts index 5e5f80fee..cff741693 100644 --- a/packages/webpack-plugin/src/__tests__/css-loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/css-loader.test.ts @@ -12,6 +12,7 @@ import { } from 'bun:test' import devupUICssLoader from '../css-loader' +import { servedCss } from '../served-css' type CodeExtractResult = ReturnType type CssLoaderThis = ThisParameterType @@ -34,12 +35,15 @@ function createCodeExtractResult( function createCssLoaderContext( resourcePath: string, callback: ReturnType, + compilation?: object, ): CssLoaderThis { return { callback, + cacheable: mock(), addContextDependency: mock(), resourcePath, getOptions: () => ({ watch: resourcePath.includes('devup-ui') }), + _compilation: compilation, } as unknown as CssLoaderThis } @@ -108,4 +112,22 @@ describe('devupUICssLoader', () => { expect(getCssSpy).toBeCalledTimes(1) }) + + it('records the stylesheet it serves in its compilation, uncached', () => { + const callback = mock() + const compilation = {} + getCssSpy.mockReturnValue('base css') + const context = createCssLoaderContext( + '/df/devup-ui/devup-ui.css', + callback, + compilation, + ) + devupUICssLoader.bind(context)(Buffer.from(''), '') + + expect(context.cacheable).toHaveBeenCalledWith(false) + expect(servedCss(compilation)).toEqual( + new Map([['/df/devup-ui/devup-ui.css', 'base css']]), + ) + expect(servedCss(compilation)).toBe(servedCss(compilation)) + }) }) diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 2692ead30..524e755bd 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -17,19 +17,28 @@ import { import type { Compiler } from 'webpack' import { DevupUIWebpackPlugin } from '../plugin' +import { servedCss } from '../served-css' type CodeExtractResult = ReturnType interface MockCompiler { options: { module: { rules: unknown[] } plugins: unknown[] + entry?: unknown + context?: string + } + watchMode?: boolean + webpack: { + DefinePlugin: ReturnType + Compilation: { PROCESS_ASSETS_STAGE_REPORT: number } } - webpack: { DefinePlugin: ReturnType } hooks: { watchRun: { tapPromise: ReturnType } beforeRun: { tapPromise: ReturnType } done: { tapPromise: ReturnType } afterCompile: { tap: ReturnType } + run: { tap: ReturnType } + thisCompilation: { tap: ReturnType } } } @@ -136,6 +145,7 @@ function createCompiler(): MockCompiler { }, webpack: { DefinePlugin: mock(), + Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 }, }, hooks: { watchRun: { @@ -150,6 +160,12 @@ function createCompiler(): MockCompiler { afterCompile: { tap: mock(), }, + run: { + tap: mock(), + }, + thisCompilation: { + tap: mock(), + }, }, } } @@ -459,7 +475,7 @@ describe('devupUIWebpackPlugin', () => { let importCanonicalMapSpy: ReturnType let importFileRoutesSpy: ReturnType let setAtomHoistSpy: ReturnType - let listSourceFilesSpy: ReturnType + let computeReachableFilesSpy: ReturnType beforeEach(() => { buildCanonicalMapSpy = spyOn( @@ -478,9 +494,9 @@ describe('devupUIWebpackPlugin', () => { ) setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue(undefined) // Default: no source files so pre-warm is a no-op unless a test opts in. - listSourceFilesSpy = spyOn( + computeReachableFilesSpy = spyOn( pluginUtils, - 'listSourceFiles', + 'computeReachableFiles', ).mockReturnValue([]) }) @@ -490,7 +506,7 @@ describe('devupUIWebpackPlugin', () => { importCanonicalMapSpy.mockRestore() importFileRoutesSpy.mockRestore() setAtomHoistSpy.mockRestore() - listSourceFilesSpy.mockRestore() + computeReachableFilesSpy.mockRestore() }) it('runs single-importer collapse even when atomHoist is unset (always-on), without hoisting', () => { @@ -511,15 +527,15 @@ describe('devupUIWebpackPlugin', () => { expect(importFileRoutesSpy).not.toHaveBeenCalled() }) - it('pre-warms the extractor over all source files in build mode when collapse is active', () => { - // Without pre-warm, webpack builds each shared devup-ui-N.css ONCE at - // first import — before later bucket members are extracted — so their - // atoms are dropped. Pre-warming the sheet makes getCss(N) complete from - // the first css-loader call. + it('pre-warms the extractor over the files the entries reach in build mode', () => { + // Without pre-warm, webpack builds each shared stylesheet ONCE at first + // import — before later files are extracted — so their styles are + // dropped. Pre-warming the sheet makes getCss complete from the first + // css-loader call. buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), resolve(process.cwd(), 'src', 'child.tsx'), ]) @@ -532,8 +548,21 @@ describe('devupUIWebpackPlugin', () => { package: '@devup-ui/react', singleCss: true, }) - plugin.apply(asCompiler(createCompiler())) - expect(listSourceFilesSpy).toHaveBeenCalled() + const compiler = createCompiler() + compiler.options.entry = { + main: { import: ['./src/parent', './src/other'] }, + worker: {}, + } + compiler.options.context = '/project' + plugin.apply(asCompiler(compiler)) + expect(computeReachableFilesSpy).toHaveBeenCalledWith({ + srcDir: resolve(process.cwd(), 'src'), + tsconfigPath: resolve(process.cwd(), 'tsconfig.json'), + entries: [ + resolve('/project', './src/parent'), + resolve('/project', './src/other'), + ], + }) // The loader's resolver is registered before the first extraction, so // imported constants inline as they do in the loader expect(setModuleResolverSpy.mock.invocationCallOrder[0]).toBeLessThan( @@ -570,22 +599,21 @@ describe('devupUIWebpackPlugin', () => { ) }) - it('skips pre-warm when the canonical map is empty (no collapse, no race)', () => { + it('pre-warms without collapse, from no entries when they are a function', () => { buildCanonicalMapSpy.mockReturnValue({}) - listSourceFilesSpy.mockReturnValue([ - resolve(process.cwd(), 'src', 'a.tsx'), - ]) - readFileSyncSpy.mockReturnValue('source') - const plugin = new DevupUIWebpackPlugin({}) - plugin.apply(asCompiler(createCompiler())) - expect(codeExtractSpy).not.toHaveBeenCalled() + const compiler = createCompiler() + compiler.options.entry = () => ({}) + new DevupUIWebpackPlugin({}).apply(asCompiler(compiler)) + expect(computeReachableFilesSpy).toHaveBeenCalledWith( + expect.objectContaining({ entries: [] }), + ) }) it('skips pre-warm in watch mode (race only affects one-shot builds)', () => { buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), ]) readFileSyncSpy.mockReturnValue('source') @@ -598,7 +626,7 @@ describe('devupUIWebpackPlugin', () => { buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), ]) readFileSyncSpy.mockReturnValue('source') @@ -682,4 +710,108 @@ describe('devupUIWebpackPlugin', () => { expect(compiler.options.module.rules.length).toBeGreaterThan(0) }) }) + + describe('another pass for stylesheets built too early', () => { + function compile(watch = false) { + const compiler = createCompiler() + const plugin = new DevupUIWebpackPlugin({ watch }) + plugin.apply(asCompiler(compiler)) + const taps: Record unknown> = {} + const tap = + (hook: string) => (_: unknown, fn: (...args: unknown[]) => unknown) => { + taps[hook] = fn + } + const compilation = { + assets: { 'main.js': {}, 'main.css': {} }, + deleteAsset: mock(), + hooks: { + finishModules: { tap: tap('finishModules') }, + processAssets: { tap: tap('processAssets') }, + needAdditionalPass: { tap: tap('needAdditionalPass') }, + }, + } + const run = compiler.hooks.run.tap.mock.calls[0]?.[1] as () => void + const start = () => { + ;( + compiler.hooks.thisCompilation.tap.mock.calls[0]![1] as ( + compilation: object, + ) => void + )(compilation) + } + return { compiler, plugin, compilation, taps, run, start } + } + + it('taps nothing in watch mode', () => { + const { compiler } = compile(true) + expect(compiler.hooks.run.tap).not.toHaveBeenCalled() + expect(compiler.hooks.thisCompilation.tap).not.toHaveBeenCalled() + }) + + it('writes the shared base to disk when it does not hold it yet', () => { + getCssSpy.mockReturnValue('base') + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('base') + const { plugin, start } = compile() + writeFileSyncSpy.mockClear() + start() + expect(writeFileSyncSpy).not.toHaveBeenCalled() + + readFileSyncSpy.mockReturnValue('older') + start() + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(plugin.options.cssDir, 'devup-ui.css'), + 'base', + 'utf-8', + ) + }) + + it('compiles once more, writing no file, when a stylesheet changed', () => { + getCssSpy.mockReturnValue('before') + const { compiler, compilation, plugin, taps, run, start } = compile() + start() + servedCss(compilation).set('/df/devup-ui/devup-ui-1.css', 'before') + getCssSpy.mockReturnValue('after') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).toHaveBeenCalledWith('main.js') + expect(compilation.deleteAsset).toHaveBeenCalledWith('main.css') + writeFileSyncSpy.mockClear() + expect(taps.needAdditionalPass!()).toBe(true) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + '/df/devup-ui/devup-ui-1.css', + 'after', + 'utf-8', + ) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(plugin.options.cssDir, 'devup-ui.css'), + 'after', + 'utf-8', + ) + + // one more pass per run at most + compilation.deleteAsset.mockClear() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBeUndefined() + run() + expect(taps.needAdditionalPass!()).toBe(true) + + // a watching compiler rebuilds through the files the loaders write + compiler.watchMode = true + start() + taps.finishModules!() + expect(taps.needAdditionalPass!()).toBeUndefined() + }) + + it('keeps the pass when every stylesheet is current', () => { + getCssSpy.mockReturnValue('same') + const { compilation, taps, start } = compile() + start() + servedCss(compilation).set('/df/devup-ui/devup-ui-1.css', 'same') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBeUndefined() + }) + }) }) diff --git a/packages/webpack-plugin/src/css-loader.ts b/packages/webpack-plugin/src/css-loader.ts index bc6344070..450d9e671 100644 --- a/packages/webpack-plugin/src/css-loader.ts +++ b/packages/webpack-plugin/src/css-loader.ts @@ -2,8 +2,14 @@ import { getFileNumByFilename } from '@devup-ui/plugin-utils' import { getCss } from '@devup-ui/wasm' import type { RawLoaderDefinitionFunction } from 'webpack' +import { servedCss } from './served-css' + const devupUICssLoader: RawLoaderDefinitionFunction = function (_, map, meta) { - const fileNum = getFileNumByFilename(this.resourcePath) - this.callback(null, getCss(fileNum, true), map, meta) + // The stylesheet comes from every extracted module, not from this file + this.cacheable(false) + const css = getCss(getFileNumByFilename(this.resourcePath), true) + if (this._compilation) + servedCss(this._compilation).set(this.resourcePath, css) + this.callback(null, css, map, meta) } export default devupUICssLoader diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 54d0713a9..83b2c2264 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -6,13 +6,14 @@ import { dirname, join, relative, resolve } from 'node:path' import { buildCanonicalMap, computeFileReach, + computeReachableFiles, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, createThemeInterfaceArgs, type CustomShorthands, + getFileNumByFilename, type ImportAliases, - listSourceFiles, loadDevupConfigSync, mergeImportAliases, planAtomHoist, @@ -37,6 +38,8 @@ import { } from '@devup-ui/wasm' import { type Compiler } from 'webpack' +import { servedCss } from './served-css' + export interface DevupUIWebpackPluginOptions { package: string cssDir: string @@ -144,14 +147,16 @@ export class DevupUIWebpackPlugin { } /** - * Extract every source file under `src` into the shared WASM sheet so that a - * later `getCss(fileNum)` call returns the COMPLETE bucket (all collapsed - * members), not just the first member webpack happened to build. Mirrors the - * loader's `codeExtract` call (same filename keying + options) so re-extraction - * during compilation is idempotent. Best-effort: extraction errors are - * swallowed so a single bad file never breaks the build. + * Extract the source files under `src` that `entries` reach into the shared + * WASM sheet, in path order, so that a stylesheet built on its first import + * holds the styles of every one (all collapsed members of a bucket, and the + * shared base), not just those of the modules webpack happened to build + * first. Mirrors the loader's `codeExtract` call (same filename keying + + * options) so re-extraction during compilation is idempotent. Best-effort: + * extraction errors are swallowed so a single bad file never breaks the + * build, and a stylesheet still missing styles is rebuilt by another pass. */ - private prewarmExtractor() { + private prewarmExtractor(entries: string[]) { try { const cwd = process.cwd() // The same resolver as the loader's, so imported constants and @@ -161,8 +166,11 @@ export class DevupUIWebpackPlugin { toId: (path) => relative(cwd, path).replaceAll('\\', '/'), }), ) - const srcDir = resolve(cwd, 'src') - for (const file of listSourceFiles(srcDir)) { + for (const file of computeReachableFiles({ + srcDir: resolve(cwd, 'src'), + tsconfigPath: resolve(cwd, 'tsconfig.json'), + entries, + })) { const relativePath = relative(cwd, file).replaceAll('\\', '/') let relCssDir = relative(dirname(file), this.options.cssDir).replaceAll( '\\', @@ -234,12 +242,11 @@ export class DevupUIWebpackPlugin { // merge into that importer's bucket, deduplicating their identical atoms. // The canonical map is built + imported unconditionally; only atom HOISTING // composes on top when `atomHoist` is set. Mirrors next-plugin's pre-pass. - let canonicalMap: Record = {} try { const srcDir = resolve(process.cwd(), 'src') const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') const cwd = process.cwd() - canonicalMap = buildCanonicalMap({ + const canonicalMap = buildCanonicalMap({ srcDir, tsconfigPath, cwd, @@ -269,19 +276,24 @@ export class DevupUIWebpackPlugin { // hoisting stays off. } - // Pre-warm the extractor so the css-loader serves COMPLETE bucket CSS. + // Pre-warm the extractor so the css-loader serves COMPLETE CSS. // - // Under collapse, several source files share ONE devup-ui-N.css (the - // importer's bucket). The css-loader serves `getCss(N, true)`, but webpack - // builds that shared .css module ONCE — at the FIRST import resolution, - // before the bucket's other members have been extracted — so their atoms - // would be dropped. Turbopack avoids this via its idle coordinator; webpack - // has no such re-serve, so we extract every source file up front (single - // shared WASM instance) to populate the bucket fully BEFORE any css-loader - // runs. Re-extraction by the per-file loader is then idempotent (set-based - // atom dedup). Only needed for one-shot builds when collapse is active. - if (!this.options.watch && Object.keys(canonicalMap).length > 0) { - this.prewarmExtractor() + // Webpack builds a stylesheet module ONCE, at its FIRST import: the shared + // base (global styles of every file) and, under collapse, a bucket's + // devup-ui-N.css shared by several source files. Files extracted after that + // would be missing, so the files the entries reach are extracted up front + // (single shared WASM instance). Re-extraction by the per-file loader is + // then idempotent (set-based atom dedup). Watch mode rebuilds stylesheets + // through the files the loaders write instead. + if (!this.options.watch) { + const { entry, context = process.cwd() } = compiler.options + this.prewarmExtractor( + typeof entry === 'function' + ? [] + : Object.values(entry ?? {}).flatMap(({ import: requests = [] }) => + requests.map((request) => resolve(context, request)), + ), + ) } if (this.options.watch) { @@ -309,6 +321,66 @@ export class DevupUIWebpackPlugin { }), ) if (!this.options.watch) { + // A stylesheet module is built on its first import, which can come before + // the modules whose styles it holds are extracted. When one was, compile + // once more: every module is extracted by then. Watch mode rebuilds it + // through the stylesheet files the loaders write instead. + let passes = 0 + compiler.hooks.run.tap('DevupUIWebpackPlugin', () => { + passes = 0 + }) + compiler.hooks.thisCompilation.tap( + 'DevupUIWebpackPlugin', + (compilation) => { + const basePath = join(this.options.cssDir, 'devup-ui.css') + const base = getCss(null, true) + // A file's stylesheet `@import`s the shared base, which the CSS + // loaders read from disk without passing through this plugin, so it + // must hold every style extracted so far + if (!existsSync(basePath) || readFileSync(basePath, 'utf-8') !== base) + writeFileSync(basePath, base, 'utf-8') + let stale: string[] = [] + compilation.hooks.finishModules.tap('DevupUIWebpackPlugin', () => { + if (compiler.watchMode) return + const changed = new Set( + [...servedCss(compilation)] + .filter( + ([path, css]) => + getCss(getFileNumByFilename(path), true) !== css, + ) + .map(([path]) => path), + ) + if (getCss(null, true) !== base) changed.add(basePath) + stale = [...changed] + }) + // The next pass writes the build; this one writes none of its files + compilation.hooks.processAssets.tap( + { + name: 'DevupUIWebpackPlugin', + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + if (stale.length === 0 || passes > 0) return + for (const name of Object.keys(compilation.assets)) + compilation.deleteAsset(name) + }, + ) + compilation.hooks.needAdditionalPass.tap( + 'DevupUIWebpackPlugin', + () => { + if (stale.length === 0 || passes > 0) return undefined + passes += 1 + for (const path of stale) + writeFileSync( + path, + getCss(getFileNumByFilename(path), true), + 'utf-8', + ) + return true + }, + ) + }, + ) compiler.hooks.done.tapPromise('DevupUIWebpackPlugin', async (stats) => { if (!stats.hasErrors()) { // write css file diff --git a/packages/webpack-plugin/src/served-css.ts b/packages/webpack-plugin/src/served-css.ts new file mode 100644 index 000000000..c04ca03fd --- /dev/null +++ b/packages/webpack-plugin/src/served-css.ts @@ -0,0 +1,18 @@ +declare global { + var __devupUiServedCss: WeakMap> | undefined +} + +/** + * The stylesheet each devup-ui CSS module of `compilation` was built with, by + * resource path. The plugin and its loaders load as separate modules, so the + * record lives on `globalThis`. + */ +export function servedCss(compilation: object): Map { + globalThis.__devupUiServedCss ??= new WeakMap() + let served = globalThis.__devupUiServedCss.get(compilation) + if (!served) { + served = new Map() + globalThis.__devupUiServedCss.set(compilation, served) + } + return served +} From 383ca0543b32ade26dc963ba8fdd8a85ab62c082 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 21:43:01 +0900 Subject: [PATCH 03/14] fix(next-plugin): isolate coordinator port file and explain unreachable coordinators --- ...gepack_log_coordinator_port_isolation.json | 7 + .../src/__tests__/coordinator-port.test.ts | 92 ++++++++++++ .../src/__tests__/css-loader.test.ts | 5 +- .../next-plugin/src/__tests__/loader.test.ts | 6 +- packages/next-plugin/src/coordinator-port.ts | 133 ++++++++++++++++++ packages/next-plugin/src/coordinator.ts | 11 +- packages/next-plugin/src/css-loader.ts | 22 ++- packages/next-plugin/src/loader.ts | 22 ++- packages/next-plugin/src/plugin.ts | 14 +- 9 files changed, 291 insertions(+), 21 deletions(-) create mode 100644 .changepacks/changepack_log_coordinator_port_isolation.json create mode 100644 packages/next-plugin/src/__tests__/coordinator-port.test.ts create mode 100644 packages/next-plugin/src/coordinator-port.ts diff --git a/.changepacks/changepack_log_coordinator_port_isolation.json b/.changepacks/changepack_log_coordinator_port_isolation.json new file mode 100644 index 000000000..667a75271 --- /dev/null +++ b/.changepacks/changepack_log_coordinator_port_isolation.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/next-plugin/package.json": "Patch" + }, + "note": "Isolate the coordinator port file per owning process when another live next dev/build holds it, record the owner pid, and report port, pid and recovery when the coordinator is missing or unreachable", + "date": "2026-10-02T12:41:12.3693972Z" +} diff --git a/packages/next-plugin/src/__tests__/coordinator-port.test.ts b/packages/next-plugin/src/__tests__/coordinator-port.test.ts new file mode 100644 index 000000000..b934cf06c --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-port.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + formatPortFile, + isConnectionError, + parsePortFile, + removeOwnPortFile, + removeStalePortFile, + resolveCoordinatorPortFile, + unreachableCoordinatorError, +} from '../coordinator-port' + +const dirs: string[] = [] +function tmp(): string { + const dir = mkdtempSync(join(tmpdir(), 'devup-port-')) + dirs.push(dir) + return dir +} +// A pid that cannot be alive: far above any OS limit. +const DEAD_PID = 2 ** 31 - 2 +// The parent process is alive and is not this one. +const LIVE_FOREIGN_PID = process.ppid + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +describe('coordinator port file', () => { + it('keeps the port on the first line so older readers still parse it', () => { + const text = formatPortFile(4321, 99) + expect(Number.parseInt(text, 10)).toBe(4321) + expect(parsePortFile(text)).toEqual({ port: 4321, pid: 99 }) + }) + + it('reads a bare port written by an older release', () => { + expect(parsePortFile('4321\n')).toEqual({ port: 4321, pid: undefined }) + }) + + it('uses the shared name unless another live process owns it', () => { + const dir = tmp() + const shared = join(dir, 'coordinator.port') + expect(resolveCoordinatorPortFile(dir)).toBe(shared) + + writeFileSync(shared, formatPortFile(1, DEAD_PID)) + expect(resolveCoordinatorPortFile(dir)).toBe(shared) + + writeFileSync(shared, formatPortFile(1, process.pid)) + expect(resolveCoordinatorPortFile(dir)).toBe(shared) + + writeFileSync(shared, formatPortFile(1, LIVE_FOREIGN_PID)) + expect(resolveCoordinatorPortFile(dir)).toBe( + join(dir, `coordinator.${process.pid}.port`), + ) + }) + + it('never deletes a live foreign owner file, but clears a dead one', () => { + const dir = tmp() + const file = join(dir, 'coordinator.port') + writeFileSync(file, formatPortFile(1, LIVE_FOREIGN_PID)) + removeStalePortFile(file) + removeOwnPortFile(file) + expect(Bun.file(file).size).toBeGreaterThan(0) + + writeFileSync(file, formatPortFile(1, DEAD_PID)) + removeStalePortFile(file) + expect(Bun.file(file).size).toBe(0) + }) + + it('names port, owner pid, state and recovery when the coordinator is unreachable', () => { + const dir = tmp() + const file = join(dir, 'coordinator.port') + writeFileSync(file, formatPortFile(4321, DEAD_PID)) + const message = unreachableCoordinatorError( + file, + Object.assign(new Error('connect ETIMEDOUT 127.0.0.1:4321'), { + code: 'ETIMEDOUT', + }), + ).message + expect(message).toContain('127.0.0.1:4321') + expect(message).toContain(`owner pid ${DEAD_PID} (not running)`) + expect(message).toContain('restart the dev server or build') + }) + + it('recognises connection failures, including aggregated ones', () => { + expect(isConnectionError({ code: 'ECONNREFUSED' })).toBe(true) + expect(isConnectionError({ errors: [{ code: 'ETIMEDOUT' }] })).toBe(true) + expect(isConnectionError(new Error('boom'))).toBe(false) + }) +}) diff --git a/packages/next-plugin/src/__tests__/css-loader.test.ts b/packages/next-plugin/src/__tests__/css-loader.test.ts index 1ff6f7187..af08e5a64 100644 --- a/packages/next-plugin/src/__tests__/css-loader.test.ts +++ b/packages/next-plugin/src/__tests__/css-loader.test.ts @@ -358,7 +358,10 @@ describe('devupUICssLoader', () => { } as unknown as CssLoaderThis)(Buffer.from(''), '', '') }) - expect(error.message).toBe('Coordinator port file not found') + expect(error.message).toContain( + 'Coordinator port file not found: nonexistent.port', + ) + expect(error.message).toContain('Restart the dev server or build') }) it('should error when coordinator returns non-200 status', async () => { diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index c7f33b496..7f885888b 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -850,7 +850,11 @@ describe('devupUILoader', () => { // Retries 20 times × 50ms = 1s max, then calls back with error await waitFor(() => { expect(asyncCallback).toHaveBeenCalledWith( - new Error('Coordinator port file not found'), + expect.objectContaining({ + message: expect.stringContaining( + 'Coordinator port file not found: nonexistent.port', + ), + }), ) }, 3000) diff --git a/packages/next-plugin/src/coordinator-port.ts b/packages/next-plugin/src/coordinator-port.ts new file mode 100644 index 000000000..1335a4c36 --- /dev/null +++ b/packages/next-plugin/src/coordinator-port.ts @@ -0,0 +1,133 @@ +import { existsSync, readFileSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' + +/** + * What a coordinator writes to its port file: the port on the first line (all + * an older reader looks at, via `parseInt`) and the owning pid on the second. + */ +export interface CoordinatorPortInfo { + port: number + /** Process that owns the coordinator, when the file says. */ + pid?: number +} + +export function formatPortFile(port: number, pid = process.pid): string { + return `${port}\n${pid}` +} + +export function parsePortFile(text: string): CoordinatorPortInfo { + const [portLine = '', pidLine = ''] = text.trim().split(/\r?\n/) + const port = Number.parseInt(portLine.trim(), 10) + const pid = Number.parseInt(pidLine.trim(), 10) + return { port, pid: Number.isInteger(pid) && pid > 0 ? pid : undefined } +} + +/** Whether a process exists. EPERM means it exists but is not ours. */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +function readOwner(portFile: string): CoordinatorPortInfo | undefined { + try { + return parsePortFile(readFileSync(portFile, 'utf-8')) + } catch { + return undefined + } +} + +/** A live coordinator owned by some other process, if the file names one. */ +export function foreignLiveOwner(portFile: string): number | undefined { + const pid = readOwner(portFile)?.pid + return pid !== undefined && pid !== process.pid && isProcessAlive(pid) + ? pid + : undefined +} + +/** + * The port file this process should use in `distDir`. + * + * `next dev` and `next build` in one directory used to share one file: the + * second one deleted the first one's port and overwrote it, and the first's + * loaders then talked to a dead port or the wrong coordinator. The shared name + * is kept when nobody else holds it (so a cache keyed on loader options stays + * stable in the common case); when a different live process owns it, this + * process gets its own `coordinator..port`. + */ +export function resolveCoordinatorPortFile(distDir: string): string { + const shared = join(distDir, 'coordinator.port') + return foreignLiveOwner(shared) === undefined + ? shared + : join(distDir, `coordinator.${process.pid}.port`) +} + +/** Remove the file unless a different live process owns it. */ +export function removeStalePortFile(portFile: string): void { + if (foreignLiveOwner(portFile) !== undefined) return + try { + unlinkSync(portFile) + } catch { + // Nothing to remove (first run). + } +} + +/** Remove the file only if this process wrote it. */ +export function removeOwnPortFile(portFile: string): void { + const owner = readOwner(portFile) + if (owner?.pid !== undefined && owner.pid !== process.pid) return + try { + unlinkSync(portFile) + } catch { + // Already gone. + } +} + +const CONNECTION_CODES = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'ETIMEDOUT', + 'EPIPE', + 'EHOSTUNREACH', +]) + +export function isConnectionError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code !== undefined && CONNECTION_CODES.has(code)) return true + // An AggregateError from `localhost` resolution carries its codes inside. + const inner = (error as { errors?: unknown[] } | undefined)?.errors + return Array.isArray(inner) && inner.some(isConnectionError) +} + +/** The port file is missing: say whose it should be and how to recover. */ +export function missingPortFileError(portFile: string): Error { + return new Error( + `Coordinator port file not found: ${portFile}. ` + + 'The devup-ui coordinator for this build is not running (or exited). ' + + 'Restart the dev server or build; if another `next dev`/`next build` ' + + 'uses the same distDir, stop it or give this one its own distDir.', + ) +} + +/** A coordinator that was running is not answering. */ +export function unreachableCoordinatorError( + portFile: string, + cause: unknown, +): Error { + const info = existsSync(portFile) ? readOwner(portFile) : undefined + const owner = + info?.pid === undefined + ? 'unknown owner' + : `owner pid ${info.pid} (${isProcessAlive(info.pid) ? 'running' : 'not running'})` + const detail = cause instanceof Error ? cause.message : String(cause) + const next = + info?.pid !== undefined && !isProcessAlive(info.pid) + ? 'The coordinator process has exited: restart the dev server or build.' + : 'Another process may have replaced the coordinator: restart the dev server or build, and avoid running `next dev` and `next build` in one distDir at the same time.' + return new Error( + `Coordinator unreachable at 127.0.0.1:${info?.port ?? '?'} (${owner}, port file ${portFile}): ${detail}. ${next}`, + ) +} diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 71bf53476..830c65201 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -1,9 +1,10 @@ -import { unlinkSync, writeFile, writeFileSync } from 'node:fs' +import { writeFile, writeFileSync } from 'node:fs' import { createServer, type IncomingMessage, type Server } from 'node:http' import { basename, dirname, join, relative } from 'node:path' import { getFileNumByFilename } from '@devup-ui/plugin-utils' +import { formatPortFile, removeOwnPortFile } from './coordinator-port' import { elapsedMs, profileStart, reportProfile } from './profile' import { transformStaticVanillaExtract } from './static-vanilla' import type { DevupWasm } from './wasm' @@ -668,7 +669,7 @@ export function startCoordinator(options: CoordinatorOptions): { coordinatorServer.listen(0, '127.0.0.1', () => { const addr = coordinatorServer.address() if (addr && typeof addr !== 'string') { - writeFileSync(coordinatorPortFile, String(addr.port), 'utf-8') + writeFileSync(coordinatorPortFile, formatPortFile(addr.port), 'utf-8') } }) @@ -682,11 +683,7 @@ export function startCoordinator(options: CoordinatorOptions): { coordinatorServer.close() if (server === coordinatorServer) { server = null - try { - unlinkSync(coordinatorPortFile) - } catch { - // ignore if already deleted - } + removeOwnPortFile(coordinatorPortFile) } }, } diff --git a/packages/next-plugin/src/css-loader.ts b/packages/next-plugin/src/css-loader.ts index a404c2772..9dec9da21 100644 --- a/packages/next-plugin/src/css-loader.ts +++ b/packages/next-plugin/src/css-loader.ts @@ -4,6 +4,12 @@ import { Agent, request } from 'node:http' import { getFileNumByFilename } from '@devup-ui/plugin-utils' import type { RawLoaderDefinitionFunction } from 'webpack' +import { + isConnectionError, + missingPortFileError, + parsePortFile, + unreachableCoordinatorError, +} from './coordinator-port' import { loadWasm } from './wasm' export interface DevupUICssLoaderOptions { @@ -26,7 +32,7 @@ const keepAliveAgent = new Agent({ keepAlive: true }) function readCoordinatorPort(portFile: string): number { if (cachedPort !== null) return cachedPort - cachedPort = parseInt(readFileSync(portFile, 'utf-8').trim()) + cachedPort = parsePortFile(readFileSync(portFile, 'utf-8')).port return cachedPort } @@ -96,7 +102,7 @@ const devupUICssLoader: RawLoaderDefinitionFunction = setTimeout(() => tryFetch(retries - 1), 50) return } - callback(new Error('Coordinator port file not found')) + callback(missingPortFileError(coordinatorPortFile)) return } try { @@ -107,7 +113,17 @@ const devupUICssLoader: RawLoaderDefinitionFunction = importMainCss, !watch, (err, css) => { - if (err) return callback(err) + if (err) { + if (isConnectionError(err)) { + // The port may belong to a coordinator that has since been + // replaced; forget it so the next call re-reads the file. + cachedPort = null + return callback( + unreachableCoordinatorError(coordinatorPortFile, err), + ) + } + return callback(err) + } callback(null, css) }, ) diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index de9f1515a..8699814ee 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -5,6 +5,12 @@ import { basename, dirname, join, relative } from 'node:path' import type { RawLoaderDefinitionFunction } from 'webpack' +import { + isConnectionError, + missingPortFileError, + parsePortFile, + unreachableCoordinatorError, +} from './coordinator-port' import { loadWasm } from './wasm' export interface DevupUILoaderOptions { @@ -43,7 +49,7 @@ function readCoordinatorPort(portFile: string): number { const cachedPort = cachedPorts.get(portFile) if (cachedPort !== undefined) return cachedPort - const port = Number.parseInt(readFileSync(portFile, 'utf-8').trim(), 10) + const port = parsePortFile(readFileSync(portFile, 'utf-8')).port cachedPorts.set(portFile, port) return port } @@ -146,7 +152,7 @@ const devupUILoader: RawLoaderDefinitionFunction = return } // Port file never appeared — fall through to error - callback(new Error('Coordinator port file not found')) + callback(missingPortFileError(coordinatorPortFile)) return } try { @@ -164,7 +170,17 @@ const devupUILoader: RawLoaderDefinitionFunction = resourcePath: this.resourcePath, }) coordinatorExtract(port, body, (err, content, sourceMap) => { - if (err) return callback(err) + if (err) { + if (isConnectionError(err)) { + // Forget a port that no longer answers so the next file + // re-reads the port file instead of repeating the timeout. + cachedPorts.delete(coordinatorPortFile) + return callback( + unreachableCoordinatorError(coordinatorPortFile, err), + ) + } + return callback(err) + } callback(null, content, sourceMap as Parameters[2]) }) } catch (error) { diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index b520692ec..6ef697acd 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -29,6 +29,10 @@ import { startCoordinator, takeExtractOutput, } from './coordinator' +import { + removeStalePortFile, + resolveCoordinatorPortFile, +} from './coordinator-port' import { collectProductionPrewarmFiles } from './prewarm' import { elapsedMs, profileStart, reportProfile } from './profile' import { transformStaticVanillaExtract } from './static-vanilla' @@ -331,7 +335,7 @@ export function DevupUI( // disable turbo parallel const excludeRegex = createNodeModulesExcludeRegex(include, '.mdx.[tj]sx?$') - const coordinatorPortFile = join(distDir, 'coordinator.port') + const coordinatorPortFile = resolveCoordinatorPortFile(distDir) // Pre-pass: single-importer collapse ALWAYS runs (files with exactly one // importer merge into that importer's bucket, so their identical atoms share @@ -523,11 +527,9 @@ export function DevupUI( // Delete stale port file from previous session so loaders don't connect // to a dead coordinator port. The new coordinator writes a fresh port file // once it starts listening. - try { - unlinkSync(coordinatorPortFile) - } catch { - // Port file doesn't exist (first run) — safe to ignore - } + // A live coordinator owned by another process is left alone (see + // resolveCoordinatorPortFile). + removeStalePortFile(coordinatorPortFile) const coordinator = startCoordinator({ wasm, From 1d6a14a4b1c614f20fcbc61b0f55d167150db424 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 21:16:46 +0900 Subject: [PATCH 04/14] feat(plugins): number files and name classes independently of processing order Refs #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...hangepack_log_deterministic_numbering.json | 13 ++ bindings/devup-ui-wasm/src/lib.rs | 111 ++++++++++++- classMapFile | 1 + cssDir/devup-ui-1.css | 1 + cssDir/devup-ui.css | 1 + cssFile/cssFile | 1 + cssFile/devup-ui.css | 1 + fileMapFile | 1 + foo/cssFile | 1 + libs/css/src/file_map.rs | 34 ++++ libs/css/src/theme_tokens.rs | 2 +- libs/extractor/src/lib.rs | 32 +++- libs/extractor/src/module_loader.rs | 15 ++ libs/extractor/src/tailwind.rs | 4 +- libs/extractor/src/vanilla_extract.rs | 3 + libs/sheet/src/lib.rs | 44 ++++- packages/bun-plugin/src/plugin.ts | 24 +++ .../__tests__/deterministic-numbering.test.ts | 144 ++++++++++++++++ .../next-plugin/src/__tests__/loader.test.ts | 61 +++++++ .../next-plugin/src/__tests__/plugin.test.ts | 26 +++ packages/next-plugin/src/loader.ts | 19 ++- packages/next-plugin/src/plugin.ts | 25 ++- .../src/__tests__/numbering.test.ts | 85 ++++++++++ .../src/__tests__/state-writer.test.ts | 106 ++++++++++++ packages/plugin-utils/src/build-session.ts | 23 +++ packages/plugin-utils/src/import-graph.ts | 13 +- packages/plugin-utils/src/index.ts | 12 ++ packages/plugin-utils/src/numbering.ts | 66 ++++++++ packages/plugin-utils/src/state-writer.ts | 70 ++++++++ .../src/__tests__/plugin.test.ts | 48 +++++- packages/rsbuild-plugin/src/plugin.ts | 35 +++- .../vite-plugin/src/__tests__/plugin.test.ts | 157 ++++++++++-------- packages/vite-plugin/src/plugin.ts | 103 +++++------- .../src/__tests__/plugin.test.ts | 36 ++++ packages/webpack-plugin/src/loader.ts | 19 ++- packages/webpack-plugin/src/plugin.ts | 30 +++- sheetFile | 1 + 37 files changed, 1188 insertions(+), 180 deletions(-) create mode 100644 .changepacks/changepack_log_deterministic_numbering.json create mode 100644 classMapFile create mode 100644 cssDir/devup-ui-1.css create mode 100644 cssDir/devup-ui.css create mode 100644 cssFile/cssFile create mode 100644 cssFile/devup-ui.css create mode 100644 fileMapFile create mode 100644 foo/cssFile create mode 100644 packages/next-plugin/src/__tests__/deterministic-numbering.test.ts create mode 100644 packages/plugin-utils/src/__tests__/numbering.test.ts create mode 100644 packages/plugin-utils/src/__tests__/state-writer.test.ts create mode 100644 packages/plugin-utils/src/build-session.ts create mode 100644 packages/plugin-utils/src/numbering.ts create mode 100644 packages/plugin-utils/src/state-writer.ts create mode 100644 sheetFile diff --git a/.changepacks/changepack_log_deterministic_numbering.json b/.changepacks/changepack_log_deterministic_numbering.json new file mode 100644 index 000000000..27507cb8a --- /dev/null +++ b/.changepacks/changepack_log_deterministic_numbering.json @@ -0,0 +1,13 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch" + }, + "note": "Class names and file numbers no longer depend on the order files are processed in. Every plugin walks the files it can extract (the project's source and the included packages) before the first transform and numbers them in path order through the new seedFileMap, so identical builds give identical class prefixes, CSS file names and output hashes; in development, files that appear later are numbered after the existing ones. Styles are given class names in a fixed order, not the order of a hash set. The exported class, file and canonical maps are canonical JSON (sorted keys). Stylesheet evaluation (vanilla-extract) refuses Math.random(), Date.now(), new Date() and Date(), which differ between builds, with a build error; fixed dates such as new Date(0) are unchanged. resetBuildState starts a build from its own options: the prefix, atom hoisting, routes, buckets, names, numbers and styles an earlier build in the process left are cleared, and the prefix is set on every setup even when it is absent. Engine snapshots from concurrent transforms are written one after another per file, so an older snapshot is never written last. Vite removes the CSS the client already emits from the server bundle's forwarding set whichever environment finishes first. File lists are ordered by code point rather than the locale", + "date": "2026-10-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f3..d6175ba39 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -243,7 +243,14 @@ pub fn export_sheet() -> Result { /// Internal function to export class map as JSON string (testable without `JsValue`) pub fn export_class_map_internal() -> Result { - with_class_map(serde_json::to_string).map_err(|e| e.to_string()) + with_class_map(|map| { + let sorted: BTreeMap<&String, BTreeMap<&String, &usize>> = map + .iter() + .map(|(file, classes)| (file, classes.iter().collect())) + .collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importClassMap")] @@ -261,7 +268,11 @@ pub fn export_class_map() -> Result { /// Internal function to export file map as JSON string (testable without `JsValue`) pub fn export_file_map_internal() -> Result { - with_file_map(serde_json::to_string).map_err(|e| e.to_string()) + with_file_map(|map| { + let sorted: BTreeMap<&String, &usize> = map.iter().collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importFileMap")] @@ -284,7 +295,11 @@ pub fn import_canonical_map_internal(map: HashMap) { /// Internal function to export the canonical map as JSON string (testable without `JsValue`) pub fn export_canonical_map_internal() -> Result { - with_canonical_map(serde_json::to_string).map_err(|e| e.to_string()) + with_canonical_map(|map| { + let sorted: BTreeMap<&String, &String> = map.iter().collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importCanonicalMap")] @@ -300,6 +315,34 @@ pub fn export_canonical_map() -> Result { export_canonical_map_internal().map_err(js_error) } +/// Number every file in `files` now, in path order, keeping numbers files +/// already hold, so class prefixes depend on the paths and not on the order +/// workers reach files in. +#[wasm_bindgen(js_name = "seedFileMap")] +pub fn seed_file_map(files: Vec) { + css::file_map::seed_file_numbers(&files); +} + +/// Forget everything one build left in the engine. +/// +/// That is names, numbers, styles, buckets, routes, the prefix, atom hoisting +/// and the module resolver, so the next build starts from its own options +/// alone. Theme, shorthands and debug mode are set by every build, and stay. +pub fn reset_build_state_internal() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::file_routes::set_file_routes(HashMap::new()); + css::atom_hoist::set_atom_hoist(None); + css::set_prefix(None); + with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); + MODULE_RESOLVER.with_borrow_mut(|current| *current = None); +} + +#[wasm_bindgen(js_name = "resetBuildState")] +pub fn reset_build_state() { + reset_build_state_internal(); +} /// Set the atom-level hoist threshold. /// /// When set to `Some(n)`, a style atom whose content is used by `>= n` distinct @@ -1551,7 +1594,7 @@ mod tests { ); // Test getters - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string())); assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string())); assert!(output.css().is_some()); @@ -1929,6 +1972,60 @@ mod tests { assert!(json_str.starts_with('{') || json_str.starts_with("[]")); } + #[test] + #[serial] + fn test_exported_maps_are_canonical_json() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::class_map::set_class_map(HashMap::from([ + ( + "b.tsx".to_string(), + HashMap::from([("z".to_string(), 1), ("a".to_string(), 0)]), + ), + ("a.tsx".to_string(), HashMap::from([("k".to_string(), 0)])), + ])); + assert_eq!( + export_class_map_internal().unwrap(), + r#"{"a.tsx":{"k":0},"b.tsx":{"a":0,"z":1}}"# + ); + seed_file_map(vec!["b.tsx".to_string(), "a.tsx".to_string()]); + assert_eq!( + export_file_map_internal().unwrap(), + r#"{"a.tsx":0,"b.tsx":1}"# + ); + import_canonical_map_internal(HashMap::from([ + ("y".to_string(), "b".to_string()), + ("x".to_string(), "a".to_string()), + ])); + assert_eq!( + export_canonical_map_internal().unwrap(), + r#"{"x":"a","y":"b"}"# + ); + reset_build_state_internal(); + assert_eq!(export_class_map_internal().unwrap(), "{}"); + assert_eq!(export_file_map_internal().unwrap(), "{}"); + assert_eq!(export_canonical_map_internal().unwrap(), "{}"); + assert_eq!(get_prefix(), None); + } + + #[test] + #[serial] + fn test_numbers_do_not_depend_on_the_order_files_are_seen() { + let files = ["src/b.tsx", "src/a.tsx", "src/c.tsx"]; + let mut maps = Vec::new(); + for order in [[0, 1, 2], [2, 1, 0], [1, 2, 0]] { + reset_build_state_internal(); + seed_file_map(files.iter().map(ToString::to_string).collect()); + for index in order { + let _ = css::file_map::get_file_num_by_filename(files[index]); + } + maps.push(export_file_map_internal().unwrap()); + } + assert_eq!(maps[0], r#"{"src/a.tsx":0,"src/b.tsx":1,"src/c.tsx":2}"#); + assert_eq!(maps[0], maps[1]); + assert_eq!(maps[0], maps[2]); + } #[test] #[serial] fn test_code_extract_internal_success() { @@ -1951,7 +2048,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_some()); } @@ -1975,7 +2072,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_none()); } @@ -2000,7 +2097,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/classMapFile b/classMapFile new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/classMapFile @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/cssDir/devup-ui-1.css b/cssDir/devup-ui-1.css new file mode 100644 index 000000000..c848720f3 --- /dev/null +++ b/cssDir/devup-ui-1.css @@ -0,0 +1 @@ +/* write-error.tsx 0 */ \ No newline at end of file diff --git a/cssDir/devup-ui.css b/cssDir/devup-ui.css new file mode 100644 index 000000000..788cdbf9d --- /dev/null +++ b/cssDir/devup-ui.css @@ -0,0 +1 @@ +base-css \ No newline at end of file diff --git a/cssFile/cssFile b/cssFile/cssFile new file mode 100644 index 000000000..493ec68a9 --- /dev/null +++ b/cssFile/cssFile @@ -0,0 +1 @@ +css \ No newline at end of file diff --git a/cssFile/devup-ui.css b/cssFile/devup-ui.css new file mode 100644 index 000000000..493ec68a9 --- /dev/null +++ b/cssFile/devup-ui.css @@ -0,0 +1 @@ +css \ No newline at end of file diff --git a/fileMapFile b/fileMapFile new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/fileMapFile @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/foo/cssFile b/foo/cssFile new file mode 100644 index 000000000..493ec68a9 --- /dev/null +++ b/foo/cssFile @@ -0,0 +1 @@ +css \ No newline at end of file diff --git a/libs/css/src/file_map.rs b/libs/css/src/file_map.rs index 2a8c86a94..258b2558b 100644 --- a/libs/css/src/file_map.rs +++ b/libs/css/src/file_map.rs @@ -93,6 +93,19 @@ pub fn get_file_num_by_filename(filename: &str) -> usize { }) } +/// Give every file in `files` a number now, in path order. +/// +/// Numbers files already hold are kept. The numbers files get then depend on the paths alone, +/// not on which file a worker reaches first; files that appear later (in +/// development) are numbered after the existing ones. +pub fn seed_file_numbers(files: &[String]) { + let mut sorted: Vec<&String> = files.iter().collect(); + sorted.sort_unstable(); + sorted.dedup(); + for file in sorted { + let _ = get_file_num_by_filename(file); + } +} #[must_use] pub fn get_filename_by_file_num(file_num: usize) -> String { with_file_map(|map| { @@ -196,6 +209,27 @@ mod tests { use super::*; + #[test] + #[serial] + fn test_seed_file_numbers_is_independent_of_order() { + reset_file_map(); + seed_file_numbers(&[ + "b.tsx".to_string(), + "a.tsx".to_string(), + "b.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("b.tsx"), 1); + seed_file_numbers(&[ + "c.tsx".to_string(), + "0.tsx".to_string(), + "a.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("0.tsx"), 2); + assert_eq!(get_file_num_by_filename("c.tsx"), 3); + reset_file_map(); + } #[test] #[serial] fn test_set_and_get_file_map() { diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index d8d8fb021..3b2346aed 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -119,7 +119,7 @@ mod tests { set_typography_keys(vec!["body".to_string(), "title".to_string()]); assert_eq!(get_typography_keys(), vec!["body", "title"]); set_typography_keys(vec![]); - assert!(get_typography_keys().is_empty()); + assert_eq!(get_typography_keys(), Vec::::new()); } #[test] diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36c..4d126e120 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -836,8 +836,8 @@ mod tests { alternate: None, }; - assert!(empty.extract().is_empty()); - assert!(empty.into_extract().is_empty()); + assert_eq!(empty.extract(), vec![]); + assert_eq!(empty.into_extract(), vec![]); } #[test] @@ -13598,7 +13598,7 @@ globalCss({ ); assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code.is_empty()); + assert_ne!(output.code, ""); } #[test] @@ -13631,6 +13631,30 @@ globalCss({ assert!(!output.code.contains("css("), "{}", output.code); } + #[test] + #[serial] + fn test_stylesheets_read_nothing_that_differs_between_builds() { + for (read, fails) in [ + ("Math.random()", true), + ("Date.now()", true), + ("new Date()", true), + ("Date()", true), + ("new Date(0).getTime()", false), + ("Date.UTC(2020, 0, 1)", false), + ] { + reset_class_map(); + reset_file_map(); + let result = extract( + "when.css.ts", + &format!( + "import {{ style }} from '@devup-ui/react';\nconst n = {read};\nexport const a = style({{ opacity: String(n) }});" + ), + ExtractOption::default(), + ); + assert_eq!(result.is_err(), fails, "{read}"); + } + } + #[test] #[serial] fn test_vanilla_extract_execution_fallback() { @@ -18778,7 +18802,7 @@ export const k = styled('div')({ color: SIZE });", &memory_resolver(CONSTANT_MODULES), ) .unwrap(); - assert!(without_imports.dependencies.is_empty()); + assert_eq!(without_imports.dependencies.len(), 0); let without_constants = extract_with_modules( "/src/Handler.tsx", "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;", diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index 4082ff395..f64390a8f 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -21,6 +21,21 @@ pub(crate) const PACKAGE_BINDING: &str = "__vanilla_extract__"; /// A console whose calls do nothing, as what a module logs while it runs /// changes no value +/// Run before a stylesheet: what differs between builds fails the evaluation, +/// so a stylesheet never gives different rules for the same source. `new +/// Date(0)` and other fixed dates stay. +pub(crate) const DETERMINISM: &str = r#"(() => { + const refuse = (name) => () => { throw new Error(`${name} gives a different value each build: a stylesheet cannot read it`); }; + Math.random = refuse("Math.random"); + const RealDate = Date; + globalThis.Date = new Proxy(RealDate, { + construct: (target, args, newTarget) => args.length === 0 ? refuse("new Date()")() : Reflect.construct(target, args, newTarget), + apply: refuse("Date()"), + get: (target, key, receiver) => key === "now" ? refuse("Date.now") : Reflect.get(target, key, receiver), + }); +})(); +"#; + pub(crate) const CONSOLE: &str = "if (typeof console === \"undefined\") globalThis.console = new Proxy({}, { get: () => () => undefined });\n"; /// Loop iterations an evaluation may run before it fails instead of hanging diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 87e9f5178..70643aa7c 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -284,7 +284,7 @@ pub struct TailwindClass { /// non-overlapping) but mutates the existing buffer instead of allocating a new /// `String`. `needle` must be non-empty. fn remove_all_substr(haystack: &mut String, needle: &str) { - debug_assert!(!needle.is_empty()); + debug_assert_ne!(needle, ""); let mut search_from = 0; while let Some(rel) = haystack[search_from..].find(needle) { let at = search_from + rel; @@ -3934,7 +3934,7 @@ mod tests { #[test] fn test_empty_string() { let styles = parse_tailwind_to_styles(""); - assert!(styles.is_empty()); + assert_eq!(styles, vec![]); } #[test] diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 9e736b09a..169996fd7 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -276,6 +276,9 @@ pub fn execute_stylesheet( context .eval(Source::from_bytes(crate::module_loader::CONSOLE)) .map_err(|e| format!("JS execution error: {e}"))?; + context + .eval(Source::from_bytes(crate::module_loader::DETERMINISM)) + .map_err(|e| format!("JS execution error: {e}"))?; context .eval(Source::from_bytes(run.as_bytes())) diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 9a989df8f..fb99cef61 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -502,7 +502,11 @@ impl StyleSheet { Some(filename) }; let bucket_scope = if single_css { None } else { Some(filename) }; - for style in styles { + // Names are handed out in the order styles are first seen, so the + // set is walked in a fixed order, not the hash order. + let mut ordered: Vec<&ExtractStyleValue> = styles.iter().collect(); + ordered.sort_unstable(); + for style in ordered { match style { // A conditional `typography` preset: its class is the atom, and the // preset's declarations are emitted under the atom's selector. @@ -2909,6 +2913,44 @@ mod tests { assert_debug_snapshot!(sheet.create_css(None, true).split("*/").nth(1).unwrap()); } + #[test] + #[serial] + fn test_class_names_do_not_depend_on_the_order_styles_are_inserted() { + use extractor::extract_style::extract_static_style::ExtractStaticStyle; + use extractor::extract_style::extract_style_value::ExtractStyleValue; + + let values: Vec = (0..40) + .map(|index| { + ExtractStyleValue::Static(ExtractStaticStyle::new( + ["color", "margin", "padding", "width"][index % 4], + &format!("{index}px"), + 0, + None, + )) + }) + .collect(); + let mut outputs = Vec::new(); + for reverse in [false, true] { + css::class_map::reset_class_map(); + let mut styles = FxHashSet::default(); + if reverse { + values.iter().rev().for_each(|value| { + styles.insert(value.clone()); + }); + } else { + values.iter().for_each(|value| { + styles.insert(value.clone()); + }); + } + let mut sheet = StyleSheet::default(); + sheet.update_styles(&styles, "index.tsx", true); + outputs.push(( + sheet.create_css(None, true), + css::class_map::get_class_map(), + )); + } + assert_eq!(outputs[0], outputs[1]); + } #[test] #[serial] fn test_update_styles_with_typography() { diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 5fc4b01c9..7ae81ab9a 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -3,12 +3,15 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join, relative, resolve } from 'node:path' import { + beginBuild, + collectNumberedFiles, createCompatTypes, createModuleResolver, createThemeInterfaceArgs, type CustomShorthands, loadDevupConfig, mergeImportAliases, + seedFileNumbers, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -16,8 +19,11 @@ import { getThemeInterface, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setDebug, setModuleResolver, + setPrefix, } from '@devup-ui/wasm' import { type BunPlugin, plugin, type PluginBuilder } from 'bun' @@ -73,7 +79,21 @@ async function writeDataFiles() { async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { registerShorthands(shorthands ?? {}) + setPrefix(null) setModuleResolver(createModuleResolver()) + // Number every source file in path order, so class prefixes do not depend + // on the order Bun loads files in + try { + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve('src')], + toId: (path) => path, + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') await writeFile( @@ -149,6 +169,10 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { async setup(build: PluginBuilder) { // `Bun.build` hands its config to plugins; the runtime has none const bundling = build.config !== undefined + // A build starts from its own options, not from what an earlier build in + // this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + build.onEnd?.(endBuild) await initialize(options) setDebug(options.debug ?? !bundling) diff --git a/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts new file mode 100644 index 000000000..e0afcf1ff --- /dev/null +++ b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts @@ -0,0 +1,144 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' + +import { collectNumberedFiles, seedFileNumbers } from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' + +const sources: Record = { + 'src/a.tsx': `import { Box } from '@devup-ui/react'\nexport const A = () => `, + 'src/b.tsx': `import { Box } from '@devup-ui/react'\nexport const B = () => `, + 'src/nested/c.tsx': `import { Box } from '@devup-ui/react'\nexport const C = () => `, + 'src/nested/d.tsx': `import { Box, Flex } from '@devup-ui/react'\nexport const D = () => `, + 'node_modules/@acme/ui/e.tsx': `import { Box } from '@devup-ui/react'\nexport const E = () => `, +} + +/** How each plugin names the files it extracts */ +const schemes: Record (path: string) => string> = { + 'vite (absolute, posix)': () => (path) => path.replaceAll('\\', '/'), + 'webpack and next (relative to cwd)': (root) => (path) => + relative(root, path).replaceAll('\\', '/'), + 'rsbuild and bun (absolute, as found)': () => (path) => path, +} + +describe('class and file numbers do not depend on the order files are seen', () => { + let root: string + const original = process.cwd() + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'devup-ui-numbering-order-')) + for (const [path, code] of Object.entries(sources)) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), code) + } + process.chdir(root) + }) + + afterAll(() => { + wasm.resetBuildState() + process.chdir(original) + rmSync(root, { recursive: true, force: true }) + }) + + function build( + scheme: (path: string) => string, + order: string[], + parallel: boolean, + ) { + wasm.resetBuildState() + seedFileNumbers( + wasm, + collectNumberedFiles({ + roots: [join(root, 'src')], + include: ['@acme/ui'], + cwd: root, + toId: scheme, + }), + ) + const extract = (file: string) => { + const output = wasm.codeExtractWithoutSourceMap( + scheme(join(root, file)), + readFileSync(join(root, file), 'utf-8'), + '@devup-ui/react', + '../css', + false, + false, + true, + {}, + ) + return { file, code: output.code, cssFile: output.cssFile } + } + const outputs = parallel + ? Promise.all(order.map(async (file) => extract(file))) + : Promise.resolve(order.map(extract)) + return outputs.then((results) => { + const byFile = Object.fromEntries( + results + .sort((a, b) => a.file.localeCompare(b.file)) + .map((r) => [r.file, r]), + ) + const fileMap = JSON.parse(wasm.exportFileMap()) as Record + const sheets = Object.values(fileMap) + .sort((a, b) => a - b) + .map((number) => wasm.getCss(number, false)) + return { + byFile, + fileMap: wasm.exportFileMap(), + classMap: wasm.exportClassMap(), + sheets, + base: wasm.getCss(null, false), + } + }) + } + + const files = Object.keys(sources) + const orders = [ + files, + [...files].reverse(), + [files[2], files[4], files[0], files[3], files[1]], + ] + + it.each(Object.entries(schemes))( + 'gives the same output for any order, one at a time or at once: %s', + async (_name, makeScheme) => { + const scheme = makeScheme(root) + const results = [] + for (const order of orders) { + results.push(await build(scheme, order, false)) + results.push(await build(scheme, order, true)) + } + for (const result of results.slice(1)) { + expect(result.fileMap).toBe(results[0].fileMap) + expect(result.byFile).toEqual(results[0].byFile) + expect(result.sheets).toEqual(results[0].sheets) + expect(result.base).toBe(results[0].base) + } + const numbers = Object.values( + JSON.parse(results[0].fileMap) as Record, + ).sort((a, b) => a - b) + expect(numbers).toEqual([0, 1, 2, 3, 4]) + }, + ) + + it('numbers the files in path order, and files that appear later after them', () => { + wasm.resetBuildState() + seedFileNumbers(wasm, ['b.tsx', 'a.tsx']) + seedFileNumbers(wasm, ['0.tsx', 'a.tsx', 'c.tsx']) + expect(JSON.parse(wasm.exportFileMap())).toEqual({ + 'a.tsx': 0, + 'b.tsx': 1, + '0.tsx': 2, + 'c.tsx': 3, + }) + expect(wasm.exportFileMap()).toBe( + '{"0.tsx":2,"a.tsx":0,"b.tsx":1,"c.tsx":3}', + ) + }) +}) diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index cab12c692..3ac31da70 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -603,6 +603,67 @@ describe('devupUILoader', () => { requestSpy.mockRestore() }) + it('should report a coordinator that no longer answers', async () => { + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('12346') + + const requestSpy = spyOn(http, 'request').mockImplementation( + (_options: any, _callback?: any) => { + const fakeReq = { + on: mock((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'error') { + setTimeout( + () => + handler( + Object.assign(new Error('refused'), { + code: 'ECONNREFUSED', + }), + ), + 0, + ) + } + return fakeReq + }), + write: mock(), + end: mock(), + } + return asClientRequest(fakeReq) + }, + ) + + const asyncCallback = mock() + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssDir', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + coordinatorPortFile: 'coordinator.port', + }), + async: mock().mockReturnValue(asyncCallback), + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + addDependency: mock(), + } + + devupUILoader.bind(asLoaderContext(t))( + Buffer.from('source code'), + 'src/App.tsx', + ) + + await waitFor(() => { + expect(asyncCallback).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('coordinator.port'), + }), + ) + }) + + requestSpy.mockRestore() + }) it('should handle coordinator non-200 response', async () => { existsSyncSpy.mockReturnValue(true) readFileSyncSpy.mockReturnValue('12345') diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 8bc599b7b..dce4cd7b7 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -784,6 +784,32 @@ export const box = style({ color: 'red' })` DevupUI({}, { prefix: 'my-prefix' }) expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') }) + it('numbers the files the scan finds, and survives a failing scan', () => { + process.env.TURBOPACK = '1' + const collectSpy = spyOn(importGraphModule, 'collectNumberedFiles') + const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) + try { + collectSpy.mockImplementation((options: any) => { + expect(options.roots).toEqual( + ['src', 'app', 'pages'].map((dir) => resolve(dir)), + ) + expect(options.toId(resolve('src', 'a.tsx'))).toBe('src/a.tsx') + return ['src/a.tsx'] + }) + DevupUI({}, { include: ['@acme/ui'] }) + expect(seedSpy).toHaveBeenCalledWith(['src/a.tsx']) + expect(collectSpy.mock.calls[0][0]).toMatchObject({ + include: ['@acme/ui'], + }) + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) + DevupUI({}, {}) + } finally { + collectSpy.mockRestore() + seedSpy.mockRestore() + } + }) it('should import previous session state on restart', () => { process.env.TURBOPACK = '1' existsSyncSpy diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index 2270af5a6..4fd488d00 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -3,6 +3,7 @@ import { writeFile } from 'node:fs/promises' import { Agent, request } from 'node:http' import { basename, dirname, join, relative, resolve } from 'node:path' +import { createStateWriter } from '@devup-ui/plugin-utils' import type { RawLoaderDefinitionFunction } from 'webpack' import { @@ -13,6 +14,10 @@ import { } from './coordinator-port' import { loadWasm } from './wasm' +const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), +) + export interface DevupUILoaderOptions { package: string cssDir: string @@ -282,19 +287,23 @@ const devupUILoader: RawLoaderDefinitionFunction = if (updatedBaseStyle && watch) { // update base style promises.push( - writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'), + stateWriter.write( + join(cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + ), ) } if (cssFile && watch) { // don't write file when build promises.push( - writeFile( + stateWriter.write( join(cssDir, basename(cssFile)), `/* ${this.resourcePath} ${Date.now()} */`, ), - writeFile(sheetFile, exportSheet()), - writeFile(classMapFile, exportClassMap()), - writeFile(fileMapFile, exportFileMap()), + stateWriter.write(sheetFile, exportSheet()), + stateWriter.write(classMapFile, exportClassMap()), + stateWriter.write(fileMapFile, exportFileMap()), ) } Promise.all(promises).then( diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index d083d4b6a..9a7100022 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -11,6 +11,7 @@ import { deserialize, serialize } from 'node:v8' import { buildCanonicalMap, buildStaticImportGraph, + collectNumberedFiles, computeCompiledFiles, computeFileRoutes, createCompatTypes, @@ -20,6 +21,7 @@ import { loadDevupConfigSync, mergeImportAliases, planAtomHoist, + seedFileNumbers, type StaticImportGraph, } from '@devup-ui/plugin-utils' import { type NextConfig } from 'next' @@ -233,9 +235,7 @@ export function DevupUI( registerShorthands(shorthands ?? {}) - if (prefix) { - setPrefix(prefix) - } + setPrefix(prefix ?? null) writeFileSync( join(distDir, 'compat.d.ts'), @@ -250,7 +250,24 @@ export function DevupUI( importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8'))) importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8'))) } catch { - // No previous session state (first run) or corrupt files — start fresh + // No previous session state (first run) or corrupt files, start fresh + } + // Number every file the build can extract in path order, so class prefixes + // do not depend on the order modules reach a loader. Numbers restored above + // stay; files that appear later get the numbers after them. + try { + const cwd = process.cwd() + seedFileNumbers( + { seedFileMap: wasm.seedFileMap }, + collectNumberedFiles({ + roots: ['src', 'app', 'pages'].map((dir) => resolve(cwd, dir)), + include, + cwd, + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. } const devupConfig = loadDevupConfigSync(devupFile) diff --git a/packages/plugin-utils/src/__tests__/numbering.test.ts b/packages/plugin-utils/src/__tests__/numbering.test.ts new file mode 100644 index 000000000..8a284b47e --- /dev/null +++ b/packages/plugin-utils/src/__tests__/numbering.test.ts @@ -0,0 +1,85 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' + +import { collectNumberedFiles, seedFileNumbers } from '../numbering' + +describe('collectNumberedFiles', () => { + let root: string + + const write = (path: string) => { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), 'export {}') + } + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'devup-ui-numbering-')) + write('src/b.tsx') + write('src/a.tsx') + write('src/nested/c.ts') + write('src/readme.md') + write('node_modules/@acme/ui/Button.tsx') + write('node_modules/@acme/ui/node_modules/dep/skipped.tsx') + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('lists the project source and the included packages in path order', () => { + const files = collectNumberedFiles({ + roots: [join(root, 'src'), join(root, 'missing')], + include: ['@acme/ui', 'not-installed'], + cwd: root, + }) + expect( + files.map((file) => file.slice(file.indexOf('devup-ui-numbering-'))), + ).toEqual( + [...files] + .sort() + .map((file) => file.slice(file.indexOf('devup-ui-numbering-'))), + ) + expect(files.filter((file) => file.includes('/src/'))).toHaveLength(3) + expect(files.some((file) => file.endsWith('/@acme/ui/Button.tsx'))).toBe( + true, + ) + expect(files.some((file) => file.includes('skipped'))).toBe(false) + }) + + it('does not depend on the order of the roots', () => { + const first = collectNumberedFiles({ + roots: [join(root, 'src'), join(root, 'node_modules/@acme/ui')], + cwd: root, + }) + const second = collectNumberedFiles({ + roots: [join(root, 'node_modules/@acme/ui'), join(root, 'src')], + cwd: root, + }) + expect(first).toEqual(second) + }) + + it('names files the way the plugin extracts them', () => { + const files = collectNumberedFiles({ + roots: [join(root, 'src')], + cwd: root, + toId: (path) => `id:${path.slice(root.length).replaceAll('\\', '/')}`, + }) + expect(files).toEqual([ + 'id:/src/a.tsx', + 'id:/src/b.tsx', + 'id:/src/nested/c.ts', + ]) + }) +}) + +describe('seedFileNumbers', () => { + it('seeds files, and nothing when there are none', () => { + const seedFileMap = mock() + seedFileNumbers({ seedFileMap }, []) + expect(seedFileMap).not.toHaveBeenCalled() + seedFileNumbers({ seedFileMap }, ['a']) + expect(seedFileMap).toHaveBeenCalledWith(['a']) + }) +}) diff --git a/packages/plugin-utils/src/__tests__/state-writer.test.ts b/packages/plugin-utils/src/__tests__/state-writer.test.ts new file mode 100644 index 000000000..661189cf2 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/state-writer.test.ts @@ -0,0 +1,106 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { beginBuild } from '../build-session' +import { createStateWriter, writeFileAtomically } from '../state-writer' + +describe('createStateWriter', () => { + it('leaves the newest snapshot, whichever write would finish last', async () => { + const written: string[] = [] + const release: Array<() => void> = [] + const writer = createStateWriter( + (_path, content) => + new Promise((done) => { + release.push(() => { + written.push(content) + done() + }) + }), + ) + const first = writer.write('state', 'one') + const second = writer.write('state', 'two') + const third = writer.write('state', 'three', 'utf-8') + release[0]() + await first + await new Promise((resolve) => setTimeout(resolve, 0)) + release[1]() + await Promise.all([second, third]) + // the second snapshot was overtaken before it started + expect(written).toEqual(['one', 'three']) + expect(release).toHaveLength(2) + }) + + it('keeps writing after a write fails, and keeps paths apart', async () => { + const calls: string[] = [] + const writer = createStateWriter(async (path, content) => { + calls.push(`${path}=${content}`) + if (content === 'bad') throw new Error('disk full') + }) + const bad = writer.write('a', 'bad') + const good = writer.write('a', 'good') + await expect(bad).rejects.toThrow('disk full') + await good + await writer.write('b', 'other') + expect(calls).toEqual(['a=bad', 'a=good', 'b=other']) + }) +}) + +describe('writeFileAtomically', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'devup-ui-writer-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('replaces the file whole and leaves no temporary file', async () => { + const path = join(dir, 'deep', 'state.json') + await writeFileAtomically(path, '1') + await writeFileAtomically(path, '22') + expect(readFileSync(path, 'utf-8')).toBe('22') + expect(readdirSync(join(dir, 'deep'))).toEqual(['state.json']) + }) + + it('is what the writer uses unless told otherwise', async () => { + const path = join(dir, 'state.json') + await createStateWriter().write(path, 'first') + await createStateWriter().write(path, 'second', 'utf-8') + expect(readFileSync(path, 'utf-8')).toBe('second') + }) + + it('cleans up when the file cannot be replaced', async () => { + const path = join(dir, 'target') + await writeFileAtomically(join(path, 'inner'), 'x') + await expect(writeFileAtomically(path, 'y')).rejects.toBeDefined() + expect(readdirSync(dir)).toEqual(['target']) + }) +}) + +describe('beginBuild', () => { + it('resets the engine only when no other build is running', () => { + let resets = 0 + const engine = { + resetBuildState: () => { + resets += 1 + }, + } + const first = beginBuild(engine) + expect(resets).toBeLessThanOrEqual(1) + const before = resets + const second = beginBuild(engine) + expect(resets).toBe(before) + second() + second() + first() + beginBuild({})() + const third = beginBuild(engine) + expect(resets).toBe(before + 1) + third() + }) +}) diff --git a/packages/plugin-utils/src/build-session.ts b/packages/plugin-utils/src/build-session.ts new file mode 100644 index 000000000..bd16e68f3 --- /dev/null +++ b/packages/plugin-utils/src/build-session.ts @@ -0,0 +1,23 @@ +export interface ResettableEngine { + resetBuildState(): void +} + +let running = 0 + +/** + * Start a build in this process. The engine is shared by every plugin instance + * in the process, so what an earlier build left in it (prefix, atom hoisting, + * routes, buckets, names, numbers, styles) would otherwise leak into this one: + * it is reset unless another build is still running, whose state it would + * wipe. Returns the function that ends the build. + */ +export function beginBuild(engine: Partial): () => void { + if (running === 0) engine.resetBuildState?.() + running += 1 + let ended = false + return () => { + if (ended) return + ended = true + running -= 1 + } +} diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index b221bbc1e..1602513f7 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -257,7 +257,7 @@ export function computeFileRoutes( const leafRoutes = files .filter((file) => leafRouteFileRegex.test(toPosixRelative(srcDir, file))) .sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) const routeShellFilesByDir = getRouteShellFilesByDir(files, srcDir) @@ -426,7 +426,7 @@ export function computeFileReach( ) } entries = [...new Set(entries)].sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) const toKey = makeToKey(cwd, opts.keyBy ?? 'cwd-relative') @@ -599,6 +599,11 @@ function getStaticClosure( return closure } +/** Order that is the same on every machine, unlike localeCompare. */ +export function compareCodePoints(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + /** * Enumerate every extractable source file under `srcDir`, sorted by POSIX path * (deterministic order). Skips `node_modules`, test/spec files, and non-JS/TS @@ -612,7 +617,7 @@ export function listSourceFiles(srcDir: string): string[] { function visit(dir: string): void { if (!existsSync(dir)) return const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => - a.name.localeCompare(b.name), + compareCodePoints(a.name, b.name), ) for (const entry of entries) { const entryPath = join(dir, entry.name) @@ -630,7 +635,7 @@ export function listSourceFiles(srcDir: string): string[] { visit(srcDir) return files.sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) } diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 385a2dc2b..f33dbc98d 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -1,3 +1,4 @@ +export { beginBuild, type ResettableEngine } from './build-session' export { type AtomHoistPlan, buildCanonicalMap, @@ -19,6 +20,12 @@ export { type StaticImportGraph, } from './import-graph' export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' +export { + collectNumberedFiles, + type CollectNumberedFilesOptions, + type FileNumbering, + seedFileNumbers, +} from './numbering' export { createNodeModulesExcludeRegex, createThemeInterfaceArgs, @@ -27,6 +34,11 @@ export { type DevupUIBasePluginOptions, getFileNumByFilename, } from './shared' +export { + createStateWriter, + type StateWriter, + writeFileAtomically, +} from './state-writer' export type { CustomShorthands, DevupConfig, diff --git a/packages/plugin-utils/src/numbering.ts b/packages/plugin-utils/src/numbering.ts new file mode 100644 index 000000000..b7d95f872 --- /dev/null +++ b/packages/plugin-utils/src/numbering.ts @@ -0,0 +1,66 @@ +import { existsSync, realpathSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { listSourceFiles } from './import-graph' + +export interface CollectNumberedFilesOptions { + /** Directories holding the project's own source files */ + roots: string[] + /** Packages whose source the build extracts too (the `include` option) */ + include?: string[] + cwd?: string + /** The name the plugin extracts a file under, given its absolute path */ + toId?: (path: string) => string +} + +/** Where an included package lives, found the way the bundler finds it. */ +function findPackageDir(cwd: string, name: string): string | undefined { + let dir = resolve(cwd) + for (;;) { + const candidate = join(dir, 'node_modules', name) + if (existsSync(candidate)) return realpathSync(candidate) + const parent = resolve(dir, '..') + if (parent === dir) return undefined + dir = parent + } +} + +/** + * Every file the build can extract, as the names the plugin extracts them + * under: the project's source, then the source of each included package. The + * list is the same on every run for the same files, and the engine numbers it + * in path order, so file numbers (and the class prefixes they make) do not + * depend on which file a bundler worker reaches first. + */ +export function collectNumberedFiles({ + roots, + include = [], + cwd = process.cwd(), + toId = (path) => path.replaceAll('\\', '/'), +}: CollectNumberedFilesOptions): string[] { + const directories = [ + ...roots, + ...include + .map((name) => findPackageDir(cwd, name)) + .filter((dir): dir is string => dir !== undefined), + ] + return [ + ...new Set(directories.flatMap((dir) => listSourceFiles(dir).map(toId))), + ].sort() +} + +export interface FileNumbering { + seedFileMap(files: string[]): void +} + +/** + * Number `files` in path order. Files numbered before keep their numbers, so + * running it again as files appear (in development) numbers only the new ones, + * after the existing ones. + */ +export function seedFileNumbers( + engine: FileNumbering, + files: Parameters[0], +): void { + if (files.length > 0) engine.seedFileMap(files) +} diff --git a/packages/plugin-utils/src/state-writer.ts b/packages/plugin-utils/src/state-writer.ts new file mode 100644 index 000000000..45a21ad49 --- /dev/null +++ b/packages/plugin-utils/src/state-writer.ts @@ -0,0 +1,70 @@ +import { mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +let temporaryFiles = 0 + +/** Write `content` so a reader sees the old file or the new one, never half. */ +export async function writeFileAtomically( + path: string, + content: string, +): Promise { + const temporary = `${path}.${process.pid}.${++temporaryFiles}.tmp` + await mkdir(dirname(path), { recursive: true }) + try { + await writeFile(temporary, content, 'utf-8') + await rename(temporary, path) + } catch (error) { + await rm(temporary, { force: true }) + throw error + } +} + +export type WriteOne = ( + path: string, + content: string, + encoding?: 'utf-8', +) => Promise + +export interface StateWriter { + /** + * Write `content` to `path` after every earlier write to it. A write that a + * newer one for the same path overtakes before it starts is dropped, so the + * file never ends up holding an older snapshot than one already handed in. + */ + write(path: string, content: string, encoding?: 'utf-8'): Promise +} + +/** + * Persist engine snapshots from concurrent transforms. Each call hands in the + * state as it was when the call was made, and the last call made decides what + * the file holds, whichever write the operating system finishes last. + */ +export function createStateWriter( + writeOne: WriteOne = (path, content) => writeFileAtomically(path, content), +): StateWriter { + const revisions = new Map() + const tails = new Map>() + return { + write(path, content, encoding) { + const revision = (revisions.get(path) ?? 0) + 1 + revisions.set(path, revision) + const start = async () => { + if (revisions.get(path) !== revision) return + await (encoding + ? writeOne(path, content, encoding) + : writeOne(path, content)) + } + const previous = tails.get(path) + // With nothing in flight the write starts at once + const run = previous + ? previous.catch(() => undefined).then(start) + : start() + tails.set(path, run) + const done = () => { + if (tails.get(path) === run) tails.delete(path) + } + run.then(done, done) + return run + }, + } +} diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 1a4393a97..b674ede74 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -367,13 +367,6 @@ const App = () => `, map: undefined, }) - if (options.updatedBaseStyle) { - expect(writeFileSpy).toHaveBeenCalledWith( - resolve('df', 'devup-ui', 'devup-ui.css'), - expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), - 'utf-8', - ) - } expect(writeFileSpy).toHaveBeenCalledWith( resolve('df', 'devup-ui', 'devup-ui.css'), expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), @@ -485,6 +478,47 @@ const App = () => `, expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') }) + describe('deterministic file numbering', () => { + it.each([ + ['relative', false], + ['posix', true], + ])('numbers the files the scan finds (%s ids)', async (_name, atomMode) => { + const collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) + const closeBuild = mock() + try { + collectSpy.mockImplementation((options: any) => { + expect(options.toId('C:\\p\\a.tsx')).toBe( + atomMode ? 'C:/p/a.tsx' : 'C:\\p\\a.tsx', + ) + return ['/p/a.tsx'] + }) + await DevupUI({ + include: ['@acme/ui'], + atomHoist: atomMode ? 2 : undefined, + }).setup(createSetupContext({ onCloseBuild: closeBuild })) + expect(seedSpy).toHaveBeenCalledWith(['/p/a.tsx']) + expect(collectSpy.mock.calls[0][0]).toMatchObject({ + roots: [resolve('src')], + include: ['@acme/ui'], + }) + closeBuild.mock.calls[0][0]() + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) + await DevupUI().setup(createSetupContext()) + } finally { + collectSpy.mockRestore() + seedSpy.mockRestore() + } + }) + + it('sets the prefix every time, even without one', async () => { + setPrefixSpy.mockClear() + await DevupUI().setup(createSetupContext()) + expect(setPrefixSpy).toHaveBeenCalledWith(null) + }) + }) describe('atomHoist pre-pass', () => { let buildCanonicalMapSpy: ReturnType let computeFileReachSpy: ReturnType diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 47d4f2f3f..8f8f82390 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -3,12 +3,15 @@ import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + collectNumberedFiles, computeFileReach, computeReachableFiles, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, + createStateWriter, createThemeInterfaceArgs, type CustomShorthands, getFileNumByFilename, @@ -16,6 +19,7 @@ import { loadDevupConfig, mergeImportAliases, planAtomHoist, + seedFileNumbers, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -26,6 +30,8 @@ import { importFileRoutes, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -130,14 +136,19 @@ export const DevupUI = ({ }: Partial = {}): RsbuildPlugin => { registerShorthands(shorthands ?? {}) const importAliases = mergeImportAliases(userImportAliases) + const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), + ) return { name: PLUGIN_NAME, async setup(api) { + // A build starts from its own options, not from what an earlier build + // in this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + api.onCloseBuild?.(endBuild) setDebug(debug) - if (prefix) { - setPrefix(prefix) - } + setPrefix(prefix ?? null) if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') @@ -169,6 +180,20 @@ export const DevupUI = ({ toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), }), ) + try { + // Number every file the build can extract in path order, so class + // prefixes do not depend on the order modules reach the transform + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve(process.cwd(), 'src')], + include, + toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } if (atomMode) { try { const root = process.cwd() @@ -413,7 +438,7 @@ export const DevupUI = ({ if (updatedBaseStyle) { // update base style promises.push( - writeFile( + stateWriter.write( join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8', @@ -423,7 +448,7 @@ export const DevupUI = ({ if (cssFile) { promises.push( - writeFile( + stateWriter.write( join(cssDir, basename(cssFile)), `/* ${resourcePath} ${Date.now()} */`, 'utf-8', diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 2c910f406..f7f6bee61 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -214,6 +214,7 @@ describe('devupUIVitePlugin', () => { expect(plugin).toEqual({ name: 'devup-ui', sharedDuringBuild: true, + closeBundle: expect.any(Function), config: expect.any(Function), load: expect.any(Function), watchChange: expect.any(Function), @@ -434,20 +435,17 @@ describe('devupUIVitePlugin', () => { }) describe('deterministic file numbering', () => { - let listSourceFilesSpy: ReturnType - let importFileMapSpy: ReturnType - let exportFileMapSpy: ReturnType + let collectSpy: ReturnType + let seedFileMapSpy: ReturnType beforeEach(() => { - listSourceFilesSpy = spyOn(pluginUtils, 'listSourceFiles') - importFileMapSpy = spyOn(wasm, 'importFileMap').mockReturnValue(undefined) - exportFileMapSpy = spyOn(wasm, 'exportFileMap').mockReturnValue('{}') + collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + seedFileMapSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) }) afterEach(() => { - listSourceFilesSpy.mockRestore() - importFileMapSpy.mockRestore() - exportFileMapSpy.mockRestore() + collectSpy.mockRestore() + seedFileMapSpy.mockRestore() }) function onlyDirs(...dirs: string[]) { @@ -455,90 +453,54 @@ describe('devupUIVitePlugin', () => { existsSyncSpy.mockImplementation((path: string) => wanted.has(path)) } - it('numbers files by sorted path, not by transform arrival order', async () => { - onlyDirs('src') - // returned out of order on purpose: arrival order must not leak through - listSourceFilesSpy.mockReturnValue([ - '/p/src/z.tsx', - '/p/src/a.tsx', - '/p/src/m.tsx', - ]) - - await createPlugin({}).configResolved({ root: '/p' }) + it('numbers the files the scan finds, in the order it returns them', async () => { + onlyDirs('src', 'app') + collectSpy.mockReturnValue(['/p/app/page.tsx', '/p/src/b.tsx']) - expect(importFileMapSpy).toHaveBeenCalledWith({ - '/p/src/a.tsx': 0, - '/p/src/m.tsx': 1, - '/p/src/z.tsx': 2, + await createPlugin({ include: ['@acme/ui'] }).configResolved({ + root: '/p', }) - }) - - it('normalizes windows separators to match vite module ids', async () => { - onlyDirs('src') - listSourceFilesSpy.mockReturnValue(['C:\\p\\src\\a.tsx']) - await createPlugin({}).configResolved({ root: '/p' }) - - expect(importFileMapSpy).toHaveBeenCalledWith({ 'C:/p/src/a.tsx': 0 }) + expect(collectSpy).toHaveBeenCalledWith({ + roots: [resolve('/p', 'src'), resolve('/p', 'app')], + include: ['@acme/ui'], + cwd: '/p', + }) + expect(seedFileMapSpy).toHaveBeenCalledWith([ + '/p/app/page.tsx', + '/p/src/b.tsx', + ]) }) - // A framework plugin resolves the config once per environment. Re-seeding - // drops the numbers already given to files outside src/ and app/, and since - // the sheet does not reset with the map, the next such file reuses a live - // number and its atoms overwrite the previous owner's. - it('leaves an already-populated map alone on a second configResolved', async () => { + // A framework plugin resolves the config once per environment. Seeding + // keeps the numbers files hold, so a second pass numbers only new files. + it('seeds again on a second configResolved', async () => { onlyDirs('src') - listSourceFilesSpy.mockReturnValue(['/p/src/a.tsx']) - exportFileMapSpy.mockReturnValue( - '{"/p/src/a.tsx":0,"/monorepo/packages/ui/X.tsx":1}', - ) - - await createPlugin({}).configResolved({ root: '/p' }) - - expect(importFileMapSpy).not.toHaveBeenCalled() - }) - - it('scans app/ for App Router projects and dedupes across roots', async () => { - onlyDirs('src', 'app') - listSourceFilesSpy.mockImplementation((dir: string) => - dir === resolve('/p', 'app') - ? ['/p/app/page.tsx', '/p/shared.tsx'] - : ['/p/src/b.tsx', '/p/shared.tsx'], - ) + collectSpy.mockReturnValue(['/p/src/a.tsx']) + const plugin = createPlugin({}) - await createPlugin({}).configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) - expect(importFileMapSpy).toHaveBeenCalledWith({ - '/p/app/page.tsx': 0, - '/p/shared.tsx': 1, - '/p/src/b.tsx': 2, - }) + expect(seedFileMapSpy).toHaveBeenCalledTimes(2) }) - it.each([ - ['no conventional source dir exists', () => onlyDirs()], - [ - 'the source dir is empty', - () => { - onlyDirs('src') - listSourceFilesSpy.mockReturnValue([]) - }, - ], - ])('leaves numbering alone when %s', async (_name, setup) => { - setup() + it('leaves numbering alone when there is nothing to number', async () => { + onlyDirs() + collectSpy.mockReturnValue([]) await createPlugin({}).configResolved({ root: '/p' }) - expect(importFileMapSpy).not.toHaveBeenCalled() + expect(seedFileMapSpy).not.toHaveBeenCalled() }) it('keeps building when the scan fails', async () => { onlyDirs('src') - listSourceFilesSpy.mockImplementation(() => { + collectSpy.mockImplementation(() => { throw new Error('scan boom') }) await createPlugin({}).configResolved({ root: '/p' }) - expect(importFileMapSpy).not.toHaveBeenCalled() + expect(seedFileMapSpy).not.toHaveBeenCalled() }) }) @@ -653,6 +615,55 @@ describe('devupUIVitePlugin', () => { ) }) + it('does not forward server css the client emits, whichever environment finishes first', async () => { + const plugin = createPlugin({}) + getCssSpy.mockImplementation((fileNum: number | null) => + fileNum === null ? 'base sheet' : 'file sheet', + ) + const serverBundle = { + 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, + 'entry.js': { + name: 'entry', + viteMetadata: { + importedCss: new Set(['file.css', 'server-only.css']), + }, + }, + } + const clientBundle = { + 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, + } + + await plugin.generateBundle.call( + { environment: { name: 'client', config: { consumer: 'client' } } }, + {}, + clientBundle, + ) + await plugin.generateBundle.call( + { environment: { name: 'rsc', config: { consumer: 'server' } } }, + {}, + serverBundle, + ) + + expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual( + new Set(['server-only.css']), + ) + }) + + it('starts a build from its own options and ends it at closeBundle', async () => { + const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue(undefined) + try { + const first = createPlugin({}) + resetSpy.mockClear() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + first.closeBundle() + first.closeBundle() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + } finally { + resetSpy.mockRestore() + } + }) it('ignores no-write analysis bundles when tracking server css', async () => { const plugin = createPlugin({}) const serverBundle = { diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 9d0b77613..ea28ed9f4 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -3,31 +3,34 @@ import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + collectNumberedFiles, computeFileReach, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, + createStateWriter, createThemeInterfaceArgs, type CustomShorthands, getFileNumByFilename, type ImportAliases, - listSourceFiles, loadDevupConfig, mergeImportAliases, planAtomHoist, + seedFileNumbers, } from '@devup-ui/plugin-utils' import { codeExtract, - exportFileMap, getCss, getDefaultTheme, getThemeInterface, importCanonicalMap, - importFileMap, importFileRoutes, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -63,45 +66,6 @@ function resolveSourceDirs(root: string): string[] { ) } -/** - * Assigns each source file its devup file number up front, ordered by path. - * - * The engine otherwise hands numbers out on first sight, and the bundler - * transforms in parallel, so two identical builds produce different per-file - * class prefixes and therefore different CSS *and* JS asset hashes. Seeding - * from a sorted scan makes the numbering a pure function of the file paths. - * Every source file now holds a slot, where before only the ones that emitted - * styles consumed a number. Prefix length is a step function of the highest - * number handed out (1 char up to 26, 2 up to 1025, 3 beyond), so this is free - * until a project passes 1026 files under the scanned roots, at which point - * prefixes that used to be 2 chars become 3. - * - * Vite reports module ids as absolute POSIX-style paths even on Windows, so the - * scanned paths are normalized to match the keys `codeExtract` will look up. - * - * `importFileMap` REPLACES the engine's map, and a framework plugin resolves the - * config once per environment, so seeding unconditionally would wipe the numbers - * already handed to files outside `sourceDirs`: a monorepo sibling, or anything - * reached through `include`. The style sheet does not reset with the map, so the - * next such file reuses a live number and its atoms overwrite the previous - * owner's. Seeding only into an empty map keeps numbering deterministic on the - * first pass and stable for every later one. - */ -function seedFileMap(sourceDirs: string[]): void { - if (Object.keys(JSON.parse(exportFileMap())).length > 0) return - const sorted = [ - ...new Set( - sourceDirs - .flatMap((dir) => listSourceFiles(dir)) - .map((file) => file.replaceAll('\\', '/')), - ), - ].sort() - if (sorted.length === 0) return - const fileMap: Record = {} - for (const [index, file] of sorted.entries()) fileMap[file] = index - importFileMap(fileMap) -} - /** * Names each devup CSS module after its own file, so every module is emitted * once and shared by all of its importers. @@ -269,23 +233,29 @@ export function DevupUI({ atomHoist, importAliases: userImportAliases, }: Partial = {}): PluginOption { + // A build starts from its own options: whatever an earlier build in this + // process left in the engine (prefix, hoisting, routes, buckets, numbers, + // styles) is gone unless another build is still running. + const endBuild = beginBuild({ resetBuildState }) registerShorthands(shorthands ?? {}) setDebug(debug) - if (prefix) { - setPrefix(prefix) - } + setPrefix(prefix ?? null) const importAliases = mergeImportAliases(userImportAliases) const cssMap = new Map() let serverBundleToForward: Record | undefined + const clientCssFiles = new Set() let isServe = false // The dev server watches cssDir, so every write is an update signal. A // module transformed again writes its sheet again, and the reload that // signal causes transforms it once more: signal only a changed sheet. const writtenCss = new Map() + const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), + ) function writeCssFile(fileName: string, css: string): Promise { if (writtenCss.get(fileName) === css) return Promise.resolve() writtenCss.set(fileName, css) - return writeFile(join(cssDir, fileName), css, 'utf-8') + return stateWriter.write(join(cssDir, fileName), css, 'utf-8') } return { name: 'devup-ui', @@ -305,7 +275,18 @@ export function DevupUI({ ) const sourceDirs = resolveSourceDirs(projectRoot) try { - seedFileMap(sourceDirs) + // Numbers come from the sorted paths of every file the build can + // extract (source and included packages), not from arrival order. + // Files numbered before keep their numbers, so a later pass in the + // dev server only numbers new files after the existing ones. + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: sourceDirs, + include, + cwd: projectRoot, + }), + ) } catch { // Best-effort; on failure numbering falls back to arrival order. } @@ -411,6 +392,9 @@ export function DevupUI({ apply() { return true }, + closeBundle() { + endBuild() + }, async watchChange(id) { if (resolve(id) === resolve(devupFile) && existsSync(devupFile)) { try { @@ -596,23 +580,26 @@ export function DevupUI({ const environment = this.environment if (!environment || !writesOutput) return - if (environment.config.consumer === 'client' && serverBundleToForward) { - // @vitejs/plugin-rsc forwards every CSS file referenced by the RSC - // bundle into the client bundle. Files the client already emitted are - // registered twice and trigger FILE_NAME_CONFLICT. Keep both bundles' - // imports and client metadata intact, but remove overlaps from the RSC - // forwarding set before its later generateBundle hook reads it. - for (const output of Object.values(serverBundleToForward)) { - for (const file of cssFiles) { - output.viteMetadata?.importedCss?.delete(file) - } - } + // @vitejs/plugin-rsc forwards every CSS file referenced by the RSC + // bundle into the client bundle. Files the client already emitted are + // registered twice and trigger FILE_NAME_CONFLICT. Keep both bundles' + // imports and client metadata intact, but remove overlaps from the RSC + // forwarding set before its later generateBundle hook reads it. Each + // environment records what it finished, and whichever finishes second + // removes the overlap, so the result does not depend on their order. + if (environment.config.consumer === 'client') { + for (const file of cssFiles) clientCssFiles.add(file) } else if (environment.config.consumer === 'server') { serverBundleToForward = bundle as unknown as Record< string, ViteOutputWithMetadata > } + for (const output of Object.values(serverBundleToForward ?? {})) { + for (const file of clientCssFiles) { + output.viteMetadata?.importedCss?.delete(file) + } + } }, } } diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 524e755bd..f0568339e 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -39,6 +39,7 @@ interface MockCompiler { afterCompile: { tap: ReturnType } run: { tap: ReturnType } thisCompilation: { tap: ReturnType } + shutdown: { tap: ReturnType } } } @@ -166,6 +167,9 @@ function createCompiler(): MockCompiler { thisCompilation: { tap: mock(), }, + shutdown: { + tap: mock(), + }, }, } } @@ -173,6 +177,38 @@ function createCompiler(): MockCompiler { describe('devupUIWebpackPlugin', () => { console.error = mock() + describe('deterministic file numbering', () => { + it('numbers the files the scan finds and ends the build at shutdown', () => { + const collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) + const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue(undefined) + try { + collectSpy.mockImplementation((options: any) => { + expect(options.toId(resolve('src', 'a.tsx'))).toBe('src/a.tsx') + return ['src/a.tsx', 'src/b.tsx'] + }) + const compiler = createCompiler() + new DevupUIWebpackPlugin({ include: ['@acme/ui'] }).apply( + asCompiler(compiler), + ) + expect(seedSpy).toHaveBeenCalledWith(['src/a.tsx', 'src/b.tsx']) + expect(collectSpy.mock.calls[0][0]).toMatchObject({ + roots: [resolve('src')], + include: ['@acme/ui'], + }) + compiler.hooks.shutdown.tap.mock.calls[0][1]() + compiler.hooks.shutdown.tap.mock.calls[0][1]() + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) + new DevupUIWebpackPlugin({}).apply(asCompiler(createCompiler())) + } finally { + collectSpy.mockRestore() + seedSpy.mockRestore() + resetSpy.mockRestore() + } + }) + }) it('should apply default options', () => { expect(new DevupUIWebpackPlugin({}).options).toEqual({ include: [], diff --git a/packages/webpack-plugin/src/loader.ts b/packages/webpack-plugin/src/loader.ts index 7b7f5e758..0c88b8737 100644 --- a/packages/webpack-plugin/src/loader.ts +++ b/packages/webpack-plugin/src/loader.ts @@ -1,7 +1,7 @@ import { writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' -import { createModuleResolver } from '@devup-ui/plugin-utils' +import { createModuleResolver, createStateWriter } from '@devup-ui/plugin-utils' import { codeExtract, exportClassMap, @@ -34,6 +34,9 @@ function parseSourceMap(sourceMap: string | undefined): string | null { return sourceMap } +const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), +) let moduleResolver: ReturnType | undefined /** Resolve imports to the cwd-relative ids this loader extracts files under */ @@ -101,23 +104,27 @@ const devupUILoader: RawLoaderDefinitionFunction = if (updatedBaseStyle) { // update base style promises.push( - writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'), + stateWriter.write( + join(cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + ), ) } if (cssFile) { const content = `${this.resourcePath} ${Date.now()}` // should be reset css promises.push( - writeFile( + stateWriter.write( join(cssDir, basename(cssFile)), watch ? `/* ${content} */` : css, ), ) if (watch) { promises.push( - writeFile(sheetFile, exportSheet()), - writeFile(classMapFile, exportClassMap()), - writeFile(fileMapFile, exportFileMap()), + stateWriter.write(sheetFile, exportSheet()), + stateWriter.write(classMapFile, exportClassMap()), + stateWriter.write(fileMapFile, exportFileMap()), ) } } diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 83b2c2264..711a85eb9 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -4,7 +4,9 @@ import { createRequire } from 'node:module' import { dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + collectNumberedFiles, computeFileReach, computeReachableFiles, createCompatTypes, @@ -17,6 +19,7 @@ import { loadDevupConfigSync, mergeImportAliases, planAtomHoist, + seedFileNumbers, type WasmImportAliases, } from '@devup-ui/plugin-utils' import { @@ -31,6 +34,8 @@ import { importSheet, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -195,10 +200,12 @@ export class DevupUIWebpackPlugin { } apply(compiler: Compiler) { + // A build starts from its own options, not from what an earlier build in + // this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + compiler.hooks.shutdown?.tap('DevupUIWebpackPlugin', endBuild) setDebug(this.options.debug) - if (this.options.prefix) { - setPrefix(this.options.prefix) - } + setPrefix(this.options.prefix ?? null) const existsDevup = existsSync(this.options.devupFile) // read devup.json if (!existsSync(this.options.distDir)) @@ -226,6 +233,23 @@ export class DevupUIWebpackPlugin { importFileMap({}) } } + // Number every file the build can extract in path order, so class + // prefixes do not depend on which file a worker reaches first. Numbers + // already handed out (a restored map in watch mode) stay. + try { + const cwd = process.cwd() + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve(cwd, 'src')], + include: this.options.include, + cwd, + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } this.writeDataFiles() // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any loader diff --git a/sheetFile b/sheetFile new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/sheetFile @@ -0,0 +1 @@ +{} \ No newline at end of file From 1fc57442d426211e50546f1647ea7e43b57c205c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 21:38:15 +0900 Subject: [PATCH 05/14] chore: remove files test runs left behind Refs #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- classMapFile | 1 - cssDir/devup-ui-1.css | 1 - cssDir/devup-ui.css | 1 - cssFile/cssFile | 1 - cssFile/devup-ui.css | 1 - fileMapFile | 1 - foo/cssFile | 1 - sheetFile | 1 - 8 files changed, 8 deletions(-) delete mode 100644 classMapFile delete mode 100644 cssDir/devup-ui-1.css delete mode 100644 cssDir/devup-ui.css delete mode 100644 cssFile/cssFile delete mode 100644 cssFile/devup-ui.css delete mode 100644 fileMapFile delete mode 100644 foo/cssFile delete mode 100644 sheetFile diff --git a/classMapFile b/classMapFile deleted file mode 100644 index 9e26dfeeb..000000000 --- a/classMapFile +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/cssDir/devup-ui-1.css b/cssDir/devup-ui-1.css deleted file mode 100644 index c848720f3..000000000 --- a/cssDir/devup-ui-1.css +++ /dev/null @@ -1 +0,0 @@ -/* write-error.tsx 0 */ \ No newline at end of file diff --git a/cssDir/devup-ui.css b/cssDir/devup-ui.css deleted file mode 100644 index 788cdbf9d..000000000 --- a/cssDir/devup-ui.css +++ /dev/null @@ -1 +0,0 @@ -base-css \ No newline at end of file diff --git a/cssFile/cssFile b/cssFile/cssFile deleted file mode 100644 index 493ec68a9..000000000 --- a/cssFile/cssFile +++ /dev/null @@ -1 +0,0 @@ -css \ No newline at end of file diff --git a/cssFile/devup-ui.css b/cssFile/devup-ui.css deleted file mode 100644 index 493ec68a9..000000000 --- a/cssFile/devup-ui.css +++ /dev/null @@ -1 +0,0 @@ -css \ No newline at end of file diff --git a/fileMapFile b/fileMapFile deleted file mode 100644 index 9e26dfeeb..000000000 --- a/fileMapFile +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/foo/cssFile b/foo/cssFile deleted file mode 100644 index 493ec68a9..000000000 --- a/foo/cssFile +++ /dev/null @@ -1 +0,0 @@ -css \ No newline at end of file diff --git a/sheetFile b/sheetFile deleted file mode 100644 index 9e26dfeeb..000000000 --- a/sheetFile +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file From ffd7dc7941fbc3bfa8663b886f2c80235535aa0d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 22:27:43 +0900 Subject: [PATCH 06/14] fix(plugins): number buckets and extracted files only, leave vite css forwarding alone Refs #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...hangepack_log_deterministic_numbering.json | 2 +- bindings/devup-ui-wasm/src/lib.rs | 2 +- libs/css/src/file_map.rs | 26 +++++++- packages/bun-plugin/src/plugin.ts | 1 + .../__tests__/deterministic-numbering.test.ts | 1 + packages/next-plugin/src/plugin.ts | 36 +++++------ .../src/__tests__/numbering.test.ts | 24 +++++++- packages/plugin-utils/src/index.ts | 1 + packages/plugin-utils/src/numbering.ts | 30 +++++++++- packages/rsbuild-plugin/src/plugin.ts | 30 +++++----- .../vite-plugin/src/__tests__/plugin.test.ts | 38 ++---------- packages/vite-plugin/src/plugin.ts | 60 +++++++++---------- packages/webpack-plugin/src/plugin.ts | 36 +++++------ 13 files changed, 166 insertions(+), 121 deletions(-) diff --git a/.changepacks/changepack_log_deterministic_numbering.json b/.changepacks/changepack_log_deterministic_numbering.json index 27507cb8a..053739d3d 100644 --- a/.changepacks/changepack_log_deterministic_numbering.json +++ b/.changepacks/changepack_log_deterministic_numbering.json @@ -8,6 +8,6 @@ "packages/next-plugin/package.json": "Patch", "packages/bun-plugin/package.json": "Patch" }, - "note": "Class names and file numbers no longer depend on the order files are processed in. Every plugin walks the files it can extract (the project's source and the included packages) before the first transform and numbers them in path order through the new seedFileMap, so identical builds give identical class prefixes, CSS file names and output hashes; in development, files that appear later are numbered after the existing ones. Styles are given class names in a fixed order, not the order of a hash set. The exported class, file and canonical maps are canonical JSON (sorted keys). Stylesheet evaluation (vanilla-extract) refuses Math.random(), Date.now(), new Date() and Date(), which differ between builds, with a build error; fixed dates such as new Date(0) are unchanged. resetBuildState starts a build from its own options: the prefix, atom hoisting, routes, buckets, names, numbers and styles an earlier build in the process left are cleared, and the prefix is set on every setup even when it is absent. Engine snapshots from concurrent transforms are written one after another per file, so an older snapshot is never written last. Vite removes the CSS the client already emits from the server bundle's forwarding set whichever environment finishes first. File lists are ordered by code point rather than the locale", + "note": "Class names and file numbers no longer depend on the order files are processed in. Every plugin walks the files it can extract (the project's source and the included packages) before the first transform and numbers them in path order (a file collapsed into a bucket is numbered as the bucket, and only files that mention the package, StyleX or an aliased package take numbers) through the new seedFileMap, so identical builds give identical class prefixes, CSS file names and output hashes; in development, files that appear later are numbered after the existing ones. Styles are given class names in a fixed order, not the order of a hash set. The exported class, file and canonical maps are canonical JSON (sorted keys). Stylesheet evaluation (vanilla-extract) refuses Math.random(), Date.now(), new Date() and Date(), which differ between builds, with a build error; fixed dates such as new Date(0) are unchanged. resetBuildState starts a build from its own options: the prefix, atom hoisting, routes, buckets, names, numbers and styles an earlier build in the process left are cleared, and the prefix is set on every setup even when it is absent. Engine snapshots from concurrent transforms are written one after another per file, so an older snapshot is never written last. File lists are ordered by code point rather than the locale", "date": "2026-10-01T00:00:00.000Z" } \ No newline at end of file diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index d6175ba39..685af49aa 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -2002,7 +2002,7 @@ mod tests { export_canonical_map_internal().unwrap(), r#"{"x":"a","y":"b"}"# ); - reset_build_state_internal(); + reset_build_state(); assert_eq!(export_class_map_internal().unwrap(), "{}"); assert_eq!(export_file_map_internal().unwrap(), "{}"); assert_eq!(export_canonical_map_internal().unwrap(), "{}"); diff --git a/libs/css/src/file_map.rs b/libs/css/src/file_map.rs index 258b2558b..f0a20a939 100644 --- a/libs/css/src/file_map.rs +++ b/libs/css/src/file_map.rs @@ -95,15 +95,15 @@ pub fn get_file_num_by_filename(filename: &str) -> usize { /// Give every file in `files` a number now, in path order. /// -/// Numbers files already hold are kept. The numbers files get then depend on the paths alone, +/// Numbers files already hold are kept, and a file collapsed into a bucket is numbered as the bucket. The numbers files get then depend on the paths alone, /// not on which file a worker reaches first; files that appear later (in /// development) are numbered after the existing ones. pub fn seed_file_numbers(files: &[String]) { - let mut sorted: Vec<&String> = files.iter().collect(); + let mut sorted: Vec = files.iter().map(|file| canonical(file)).collect(); sorted.sort_unstable(); sorted.dedup(); for file in sorted { - let _ = get_file_num_by_filename(file); + let _ = get_file_num_by_filename(&file); } } #[must_use] @@ -209,6 +209,26 @@ mod tests { use super::*; + #[test] + #[serial] + fn test_seed_file_numbers_numbers_buckets_not_their_members() { + reset_file_map(); + set_canonical_map(std::collections::HashMap::from([( + "x.tsx".to_string(), + "a.tsx".to_string(), + )])); + seed_file_numbers(&[ + "x.tsx".to_string(), + "b.tsx".to_string(), + "a.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("b.tsx"), 1); + assert_eq!(get_file_map().len(), 2); + reset_canonical_map(); + reset_file_map(); + } + #[test] #[serial] fn test_seed_file_numbers_is_independent_of_order() { diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 7ae81ab9a..912874799 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -88,6 +88,7 @@ async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { { seedFileMap }, collectNumberedFiles({ roots: [resolve('src')], + needles: compiledPackages, toId: (path) => path, }), ) diff --git a/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts index e0afcf1ff..b18a334c8 100644 --- a/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts +++ b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts @@ -58,6 +58,7 @@ describe('class and file numbers do not depend on the order files are seen', () collectNumberedFiles({ roots: [join(root, 'src')], include: ['@acme/ui'], + needles: ['@devup-ui/react'], cwd: root, toId: scheme, }), diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 9a7100022..542c00e74 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -18,6 +18,7 @@ import { createNodeModulesExcludeRegex, createThemeInterfaceArgs, type DevupUIBasePluginOptions, + extractedNeedles, loadDevupConfigSync, mergeImportAliases, planAtomHoist, @@ -252,23 +253,6 @@ export function DevupUI( } catch { // No previous session state (first run) or corrupt files, start fresh } - // Number every file the build can extract in path order, so class prefixes - // do not depend on the order modules reach a loader. Numbers restored above - // stay; files that appear later get the numbers after them. - try { - const cwd = process.cwd() - seedFileNumbers( - { seedFileMap: wasm.seedFileMap }, - collectNumberedFiles({ - roots: ['src', 'app', 'pages'].map((dir) => resolve(cwd, dir)), - include, - cwd, - toId: (path) => relative(cwd, path).replaceAll('\\', '/'), - }), - ) - } catch { - // Best-effort; numbering falls back to arrival order. - } const devupConfig = loadDevupConfigSync(devupFile) @@ -379,6 +363,24 @@ export function DevupUI( }) } + // Number every file the build can extract in path order, so class prefixes + // do not depend on the order modules reach a loader. Numbers restored above + // stay; files that appear later get the numbers after them. + try { + const cwd = process.cwd() + seedFileNumbers( + { seedFileMap: wasm.seedFileMap }, + collectNumberedFiles({ + roots: ['src', 'app', 'pages'].map((dir) => resolve(cwd, dir)), + include, + cwd, + needles: extractedNeedles(libPackage, importAliases), + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } // Turbopack can request a CSS module before it has scheduled every source // loader. Waiting for a quiet window is not a compilation-complete signal: // a CSS request can itself hold up the next extraction wave. In one-shot diff --git a/packages/plugin-utils/src/__tests__/numbering.test.ts b/packages/plugin-utils/src/__tests__/numbering.test.ts index 8a284b47e..e64ad5716 100644 --- a/packages/plugin-utils/src/__tests__/numbering.test.ts +++ b/packages/plugin-utils/src/__tests__/numbering.test.ts @@ -4,7 +4,11 @@ import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' -import { collectNumberedFiles, seedFileNumbers } from '../numbering' +import { + collectNumberedFiles, + extractedNeedles, + seedFileNumbers, +} from '../numbering' describe('collectNumberedFiles', () => { let root: string @@ -48,6 +52,24 @@ describe('collectNumberedFiles', () => { expect(files.some((file) => file.includes('skipped'))).toBe(false) }) + it('numbers only the files that mention what the build extracts', () => { + writeFileSync(join(root, 'src/a.tsx'), "import '@devup-ui/react'") + const files = collectNumberedFiles({ + roots: [join(root, 'src')], + cwd: root, + needles: extractedNeedles('@devup-ui/react', { + '@emotion/styled': 'styled', + }), + toId: (path) => path.slice(root.length).replaceAll('\\', '/'), + }) + expect(files).toEqual(['/src/a.tsx']) + expect(extractedNeedles('p', { a: 1, b: 2 })).toEqual([ + 'p', + '@stylexjs/stylex', + 'a', + 'b', + ]) + }) it('does not depend on the order of the roots', () => { const first = collectNumberedFiles({ roots: [join(root, 'src'), join(root, 'node_modules/@acme/ui')], diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index f33dbc98d..ccbb23860 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -23,6 +23,7 @@ export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' export { collectNumberedFiles, type CollectNumberedFilesOptions, + extractedNeedles, type FileNumbering, seedFileNumbers, } from './numbering' diff --git a/packages/plugin-utils/src/numbering.ts b/packages/plugin-utils/src/numbering.ts index b7d95f872..213bbf9c7 100644 --- a/packages/plugin-utils/src/numbering.ts +++ b/packages/plugin-utils/src/numbering.ts @@ -1,4 +1,4 @@ -import { existsSync, realpathSync } from 'node:fs' +import { existsSync, readFileSync, realpathSync } from 'node:fs' import { join, resolve } from 'node:path' import { listSourceFiles } from './import-graph' @@ -9,10 +9,21 @@ export interface CollectNumberedFilesOptions { /** Packages whose source the build extracts too (the `include` option) */ include?: string[] cwd?: string + /** + * Only files whose text contains one of these are numbered, so files the + * build never extracts do not take numbers (and lengthen class prefixes) + */ + needles?: string[] /** The name the plugin extracts a file under, given its absolute path */ toId?: (path: string) => string } +/** Whether the text of the file contains one of the needles */ +function usesAny(file: string, needles: string[]): boolean { + const text = readFileSync(file, 'utf-8') + return needles.some((needle) => text.includes(needle)) +} + /** Where an included package lives, found the way the bundler finds it. */ function findPackageDir(cwd: string, name: string): string | undefined { let dir = resolve(cwd) @@ -36,6 +47,7 @@ export function collectNumberedFiles({ roots, include = [], cwd = process.cwd(), + needles, toId = (path) => path.replaceAll('\\', '/'), }: CollectNumberedFilesOptions): string[] { const directories = [ @@ -45,7 +57,13 @@ export function collectNumberedFiles({ .filter((dir): dir is string => dir !== undefined), ] return [ - ...new Set(directories.flatMap((dir) => listSourceFiles(dir).map(toId))), + ...new Set( + directories.flatMap((dir) => + listSourceFiles(dir) + .filter((file) => !needles || usesAny(file, needles)) + .map(toId), + ), + ), ].sort() } @@ -64,3 +82,11 @@ export function seedFileNumbers( ): void { if (files.length > 0) engine.seedFileMap(files) } + +/** What a file mentions when the build extracts it: the package, StyleX, and the packages Devup UI takes the place of */ +export function extractedNeedles( + libPackage: string, + importAliases: object, +): string[] { + return [libPackage, '@stylexjs/stylex', ...Object.keys(importAliases)] +} diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 8f8f82390..ad2793ece 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -14,6 +14,7 @@ import { createStateWriter, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, loadDevupConfig, @@ -180,20 +181,6 @@ export const DevupUI = ({ toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), }), ) - try { - // Number every file the build can extract in path order, so class - // prefixes do not depend on the order modules reach the transform - seedFileNumbers( - { seedFileMap }, - collectNumberedFiles({ - roots: [resolve(process.cwd(), 'src')], - include, - toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), - }), - ) - } catch { - // Best-effort; numbering falls back to arrival order. - } if (atomMode) { try { const root = process.cwd() @@ -226,6 +213,21 @@ export const DevupUI = ({ } } + try { + // Number every file the build can extract in path order, so class + // prefixes do not depend on the order modules reach the transform + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve(process.cwd(), 'src')], + include, + needles: extractedNeedles(libPackage, importAliases), + toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } // Extract the source files under `src` that the entries reach, in path // order, the same way the transform does, so that a stylesheet built on // its first import already holds the styles of every one. Best-effort: diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index f7f6bee61..26027dcec 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -465,6 +465,10 @@ describe('devupUIVitePlugin', () => { roots: [resolve('/p', 'src'), resolve('/p', 'app')], include: ['@acme/ui'], cwd: '/p', + needles: expect.arrayContaining([ + '@devup-ui/react', + '@stylexjs/stylex', + ]), }) expect(seedFileMapSpy).toHaveBeenCalledWith([ '/p/app/page.tsx', @@ -615,40 +619,6 @@ describe('devupUIVitePlugin', () => { ) }) - it('does not forward server css the client emits, whichever environment finishes first', async () => { - const plugin = createPlugin({}) - getCssSpy.mockImplementation((fileNum: number | null) => - fileNum === null ? 'base sheet' : 'file sheet', - ) - const serverBundle = { - 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, - 'entry.js': { - name: 'entry', - viteMetadata: { - importedCss: new Set(['file.css', 'server-only.css']), - }, - }, - } - const clientBundle = { - 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, - } - - await plugin.generateBundle.call( - { environment: { name: 'client', config: { consumer: 'client' } } }, - {}, - clientBundle, - ) - await plugin.generateBundle.call( - { environment: { name: 'rsc', config: { consumer: 'server' } } }, - {}, - serverBundle, - ) - - expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual( - new Set(['server-only.css']), - ) - }) - it('starts a build from its own options and ends it at closeBundle', async () => { const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue(undefined) try { diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index ea28ed9f4..cb10d6fea 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -13,6 +13,7 @@ import { createStateWriter, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, loadDevupConfig, @@ -243,7 +244,6 @@ export function DevupUI({ const importAliases = mergeImportAliases(userImportAliases) const cssMap = new Map() let serverBundleToForward: Record | undefined - const clientCssFiles = new Set() let isServe = false // The dev server watches cssDir, so every write is an update signal. A // module transformed again writes its sheet again, and the reload that @@ -274,22 +274,6 @@ export function DevupUI({ }), ) const sourceDirs = resolveSourceDirs(projectRoot) - try { - // Numbers come from the sorted paths of every file the build can - // extract (source and included packages), not from arrival order. - // Files numbered before keep their numbers, so a later pass in the - // dev server only numbers new files after the existing ones. - seedFileNumbers( - { seedFileMap }, - collectNumberedFiles({ - roots: sourceDirs, - include, - cwd: projectRoot, - }), - ) - } catch { - // Best-effort; on failure numbering falls back to arrival order. - } if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') await writeFile( @@ -363,6 +347,23 @@ export function DevupUI({ // Best-effort; on failure atom hoisting stays off (identity). } } + try { + // Numbers come from the sorted paths of every file the build can + // extract (source and included packages), not from arrival order. + // Files numbered before keep their numbers, so a later pass in the + // dev server only numbers new files after the existing ones. + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: sourceDirs, + include, + cwd: projectRoot, + needles: extractedNeedles(libPackage, importAliases), + }), + ) + } catch { + // Best-effort; on failure numbering falls back to arrival order. + } }, config(this: { meta?: ConfigHookMeta } | void, userConfig: UserConfig) { const theme = getDefaultTheme() @@ -580,26 +581,23 @@ export function DevupUI({ const environment = this.environment if (!environment || !writesOutput) return - // @vitejs/plugin-rsc forwards every CSS file referenced by the RSC - // bundle into the client bundle. Files the client already emitted are - // registered twice and trigger FILE_NAME_CONFLICT. Keep both bundles' - // imports and client metadata intact, but remove overlaps from the RSC - // forwarding set before its later generateBundle hook reads it. Each - // environment records what it finished, and whichever finishes second - // removes the overlap, so the result does not depend on their order. - if (environment.config.consumer === 'client') { - for (const file of cssFiles) clientCssFiles.add(file) + if (environment.config.consumer === 'client' && serverBundleToForward) { + // @vitejs/plugin-rsc forwards every CSS file referenced by the RSC + // bundle into the client bundle. Files the client already emitted are + // registered twice and trigger FILE_NAME_CONFLICT. Keep both bundles' + // imports and client metadata intact, but remove overlaps from the RSC + // forwarding set before its later generateBundle hook reads it. + for (const output of Object.values(serverBundleToForward)) { + for (const file of cssFiles) { + output.viteMetadata?.importedCss?.delete(file) + } + } } else if (environment.config.consumer === 'server') { serverBundleToForward = bundle as unknown as Record< string, ViteOutputWithMetadata > } - for (const output of Object.values(serverBundleToForward ?? {})) { - for (const file of clientCssFiles) { - output.viteMetadata?.importedCss?.delete(file) - } - } }, } } diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 711a85eb9..0c5fd3082 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -14,6 +14,7 @@ import { createNodeModulesExcludeRegex, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, loadDevupConfigSync, @@ -233,23 +234,6 @@ export class DevupUIWebpackPlugin { importFileMap({}) } } - // Number every file the build can extract in path order, so class - // prefixes do not depend on which file a worker reaches first. Numbers - // already handed out (a restored map in watch mode) stay. - try { - const cwd = process.cwd() - seedFileNumbers( - { seedFileMap }, - collectNumberedFiles({ - roots: [resolve(cwd, 'src')], - include: this.options.include, - cwd, - toId: (path) => relative(cwd, path).replaceAll('\\', '/'), - }), - ) - } catch { - // Best-effort; numbering falls back to arrival order. - } this.writeDataFiles() // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any loader @@ -300,6 +284,24 @@ export class DevupUIWebpackPlugin { // hoisting stays off. } + // Number every file the build can extract in path order, so class + // prefixes do not depend on which file a worker reaches first. Numbers + // already handed out (a restored map in watch mode) stay. + try { + const cwd = process.cwd() + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve(cwd, 'src')], + include: this.options.include, + cwd, + needles: extractedNeedles(this.options.package, this.importAliases), + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } // Pre-warm the extractor so the css-loader serves COMPLETE CSS. // // Webpack builds a stylesheet module ONCE, at its FIRST import: the shared From a5cd7e5f07f8cb1679eb790d0962915851915c81 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 09:38:16 +0900 Subject: [PATCH 07/14] fix(wasm): align deterministic atom names and persisted hoisting Refs #694, #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/atom_hoist_measurement.rs | 244 +++++++++++ .../src/atom_hoist_measurement/error_tests.rs | 30 ++ .../src/atom_hoist_measurement/report.rs | 119 ++++++ .../src/atom_hoist_measurement/snapshot.rs | 87 ++++ bindings/devup-ui-wasm/src/atom_tests.rs | 214 ++++++++++ .../devup-ui-wasm/src/atom_tests/lifecycle.rs | 213 ++++++++++ .../devup-ui-wasm/src/cache_protocol_tests.rs | 97 +++++ bindings/devup-ui-wasm/src/lib.rs | 378 +++--------------- libs/css/src/atom_hoist.rs | 59 ++- libs/css/src/atom_name.rs | 58 +++ libs/css/src/lib.rs | 33 ++ libs/css/src/theme_tokens.rs | 96 +++++ .../extract_style/extract_dynamic_style.rs | 32 +- .../src/extract_style/extract_keyframes.rs | 15 + .../src/extract_style/extract_static_style.rs | 17 +- libs/extractor/src/extract_style/mod.rs | 3 + libs/extractor/src/lib.rs | 1 + libs/sheet/src/atom_identity_tests.rs | 231 +++++++++++ .../src/atom_identity_tests/first_value.rs | 147 +++++++ libs/sheet/src/lib.rs | 296 +++++++------- .../snapshots/sheet__tests__deserialize.snap | 2 + 21 files changed, 1882 insertions(+), 490 deletions(-) create mode 100644 bindings/devup-ui-wasm/src/atom_hoist_measurement.rs create mode 100644 bindings/devup-ui-wasm/src/atom_hoist_measurement/error_tests.rs create mode 100644 bindings/devup-ui-wasm/src/atom_hoist_measurement/report.rs create mode 100644 bindings/devup-ui-wasm/src/atom_hoist_measurement/snapshot.rs create mode 100644 bindings/devup-ui-wasm/src/atom_tests.rs create mode 100644 bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs create mode 100644 bindings/devup-ui-wasm/src/cache_protocol_tests.rs create mode 100644 libs/css/src/atom_name.rs create mode 100644 libs/sheet/src/atom_identity_tests.rs create mode 100644 libs/sheet/src/atom_identity_tests/first_value.rs diff --git a/bindings/devup-ui-wasm/src/atom_hoist_measurement.rs b/bindings/devup-ui-wasm/src/atom_hoist_measurement.rs new file mode 100644 index 000000000..64038e6e2 --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_hoist_measurement.rs @@ -0,0 +1,244 @@ +//! Test-only measurements under immutable, predeclared module reach. + +mod error_tests; +mod report; +mod snapshot; + +use snapshot::Snapshot; + +use super::{ + code_extract_internal, register_theme_internal, reset_build_state_internal, seed_file_map, + set_atom_hoist, with_style_sheet, +}; +use std::collections::{HashMap, HashSet}; + +const ROUTE_IDS: [u32; 8] = [0, 1, 2, 3, 4, 5, 6, 7]; +const ROUTES: usize = ROUTE_IDS.len(); +const SHARED_MODULES: usize = 32; +const SHARED_ATOMS: usize = 80; +const PRIVATE_ATOMS: usize = 25; +const PROPS: [(&str, &str); 8] = [ + ("w", "width"), + ("h", "height"), + ("p", "padding"), + ("m", "margin"), + ("minW", "min-width"), + ("minH", "min-height"), + ("maxW", "max-width"), + ("maxH", "max-height"), +]; + +struct Module { + path: String, + atoms: Vec, +} + +struct Atom { + property: (&'static str, &'static str), + value: String, +} + +impl Module { + fn source(&self) -> String { + let mut elements = String::new(); + for atom in &self.atoms { + elements.push_str(""); + } + format!("import {{ Box }} from '@devup-ui/react'; const x = <>{elements};") + } + + fn declarations(&self) -> Vec { + self.atoms + .iter() + .map(|atom| format!("{}:{}", atom.property.1, atom.value)) + .collect() + } +} + +fn fixture() -> Vec { + let atoms = |count, start| { + (0..count) + .map(|i| Atom { + property: PROPS[i % PROPS.len()], + value: format!("{}px", start + i), + }) + .collect() + }; + let mut modules: Vec = (0..SHARED_MODULES) + .map(|i| Module { + path: format!("src/shared/s{i:02}.tsx"), + atoms: atoms(SHARED_ATOMS, 100_000), + }) + .collect(); + modules.extend((0..ROUTES).map(|route| Module { + path: format!("src/routes/r{route}.tsx"), + atoms: atoms(PRIVATE_ATOMS, 1_000_000 + route * PRIVATE_ATOMS), + })); + modules.push(Module { + path: "src/unknown.tsx".to_string(), + atoms: atoms(1, 2_000_000), + }); + modules +} + +#[derive(Debug)] +pub(super) struct ExtractionError { + file: String, + message: String, +} + +impl std::fmt::Display for ExtractionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}: {}", self.file, self.message) + } +} + +impl std::error::Error for ExtractionError {} + +struct BuildState; + +impl Drop for BuildState { + fn drop(&mut self) { + reset_build_state_internal(); + } +} + +struct Measurement { + before: Snapshot, + after: Snapshot, +} + +fn build(modules: &[Module], hoist: bool) -> Result { + reset_build_state_internal(); + let _state = BuildState; + register_theme_internal(sheet::theme::Theme::default()); + seed_file_map(modules.iter().map(|m| m.path.clone()).collect()); + let all_routes: HashSet = ROUTE_IDS.into_iter().collect(); + let mut reach: HashMap> = modules[..SHARED_MODULES] + .iter() + .map(|m| (m.path.clone(), all_routes.clone())) + .collect(); + reach.extend(ROUTE_IDS.iter().enumerate().map(|(r, id)| { + ( + modules[SHARED_MODULES + r].path.clone(), + HashSet::from([*id]), + ) + })); + super::import_file_routes_internal(reach); + set_atom_hoist(hoist.then_some(2)); + let code = modules + .iter() + .map(|module| { + code_extract_internal( + &module.path, + &module.source(), + "@devup-ui/react", + "df".to_string(), + false, + false, + true, + HashMap::new(), + ) + .map(|output| output.code()) + .map_err(|message| ExtractionError { + file: module.path.clone(), + message, + }) + }) + .collect::, _>>()?; + Ok(Snapshot::capture(modules, &code)) +} + +fn measure(hoist: bool) -> Result { + // Given: every shared module reaches all routes before any extraction. + let mut modules = fixture(); + let before = build(&modules, hoist)?; + before.assert_loaded_definitions(&modules); + let copies = if hoist { 1 } else { SHARED_MODULES }; + for declaration in modules[0].declarations() { + let needle = format!("{{{declaration}}}"); + assert_eq!( + before + .sheets + .iter() + .map(|s| s.matches(&needle).count()) + .sum::(), + copies + ); + assert_eq!( + before.sheets[0].matches(&needle).count(), + usize::from(hoist) + ); + } + // When: deploy a fresh complete build after changing one upstream constant. + for module in &mut modules[..SHARED_MODULES] { + module.atoms[0].value = "900000px".to_string(); + } + let after = build(&modules, hoist)?; + // Then: fresh transformed code still loads the exact new declarations. + after.assert_loaded_definitions(&modules); + assert_eq!(after.changed(&before).0, copies); + assert_eq!(after.sheets[0] != before.sheets[0], hoist); + assert_eq!( + after + .sheets + .iter() + .map(|s| s.matches("{width:900000px}").count()) + .sum::(), + copies + ); + assert_eq!( + after + .sheets + .iter() + .map(|s| s.matches("{width:100000px}").count()) + .sum::(), + 0 + ); + for (i, module) in modules.iter().enumerate().skip(SHARED_MODULES) { + for declaration in module.declarations() { + assert!(after.sheets[i + 1].contains(&format!("{{{declaration}}}"))); + assert!(!after.sheets[0].contains(&format!("{{{declaration}}}"))); + } + } + Ok(Measurement { before, after }) +} + +fn checked_measurements() -> Result<(Measurement, Measurement), ExtractionError> { + let baseline = measure(false)?; + let atom = measure(true)?; + let redundant_payload = (SHARED_MODULES - 1) + * fixture()[0] + .declarations() + .iter() + .map(String::len) + .sum::(); + // Retain at least one route's share of eliminated declaration payload after + // paying the ACTUAL lossless-name/header/import costs. No fixed percentage. + assert!(baseline.before.raw_bytes() >= atom.before.raw_bytes() + redundant_payload / ROUTES); + let baseline_changed = baseline.after.changed(&baseline.before); + let atom_changed = atom.after.changed(&atom.before); + assert_eq!(baseline_changed.0 - atom_changed.0, SHARED_MODULES - 1); + assert!(baseline_changed.1 >= atom_changed.1 + (SHARED_MODULES - 1) * "width:900000px".len()); + Ok((baseline, atom)) +} + +/// Callable replacement body; the caller must retain its serial-test guard. +pub(super) fn assert_predeclared_shared_savings() -> Result<(), ExtractionError> { + let artifacts = build_predeclared_measurement_artifacts()?; + report::assert_artifacts(&artifacts); + println!("{}", artifacts["report"]); + Ok(()) +} + +/// Build complete artifacts independently of the caller's stdout/env policy. +/// The caller must retain its serial-test guard. +pub(super) fn build_predeclared_measurement_artifacts() -> Result +{ + let (baseline, atom) = checked_measurements()?; + Ok(report::artifacts(&baseline, &atom)) +} diff --git a/bindings/devup-ui-wasm/src/atom_hoist_measurement/error_tests.rs b/bindings/devup-ui-wasm/src/atom_hoist_measurement/error_tests.rs new file mode 100644 index 000000000..00a1410cc --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_hoist_measurement/error_tests.rs @@ -0,0 +1,30 @@ +use super::{ExtractionError, build, fixture}; +use serial_test::serial; + +#[test] +#[serial] +fn build_reports_failing_file_when_fixture_contains_malformed_jsx() { + // Given: a malformed first atom in the otherwise unchanged real fixture. + let mut modules = fixture(); + modules[0].atoms[0].value = "\"".to_string(); + // When: extraction fails through the measurement build boundary. + let error: ExtractionError = match build(&modules, true) { + Ok(_) => panic!("malformed JSX must fail extraction"), + Err(error) => error, + }; + // Then: the typed error retains its failing file and parser context. + assert_eq!(error.file, "src/shared/s00.tsx"); + assert_eq!(error.message, "Parser panicked"); + assert_eq!(error.to_string(), "src/shared/s00.tsx: Parser panicked"); + assert_eq!(css::atom_hoist::atom_hoist_threshold(), None); + assert_eq!(css::file_routes::get_file_routes().len(), 0); + + // Given: a valid fixture after the failed build has released its state. + let valid = fixture(); + // When: a subsequent build extracts that fixture without hoisting. + let snapshot = build(&valid, false).unwrap_or_else(|error| panic!("{error}")); + // Then: every emitted class loads the valid fixture's exact declaration. + snapshot.assert_loaded_definitions(&valid); + assert!(!snapshot.sheets[0].contains("{width:100000px}")); + assert!(snapshot.sheets[1].contains("{width:100000px}")); +} diff --git a/bindings/devup-ui-wasm/src/atom_hoist_measurement/report.rs b/bindings/devup-ui-wasm/src/atom_hoist_measurement/report.rs new file mode 100644 index 000000000..505f7e38e --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_hoist_measurement/report.rs @@ -0,0 +1,119 @@ +use super::{Measurement, PRIVATE_ATOMS, ROUTES, SHARED_ATOMS, SHARED_MODULES, Snapshot, fixture}; +use serde_json::{Value, json}; + +fn report(baseline: &Measurement, atom: &Measurement) -> Value { + json!({ + "invalidationModel": "fresh deployment builds; exact stylesheet content comparison", + "fixture": {"routes": ROUTES, "sharedModules": SHARED_MODULES, + "sharedAtoms": SHARED_ATOMS, "privateAtomsPerRoute": PRIVATE_ATOMS, + "unknownLocalModules": 1, "threshold": 2}, + "sessionRaw": {"baseline": baseline.before.raw_bytes(), "atom": atom.before.raw_bytes()}, + "invalidationRaw": {"baseline": baseline.after.changed(&baseline.before).1, + "atom": atom.after.changed(&atom.before).1}, + "changedSheets": {"baseline": baseline.after.changed(&baseline.before).0, + "atom": atom.after.changed(&atom.before).0}, + "sheetCountSavings": {"numerator": SHARED_MODULES - 1, "denominator": SHARED_MODULES}, + "jsxClassNameRawBytes": {"baseline": baseline.before.name_bytes(), "atom": atom.before.name_bytes()}, + "cssSelectorNameRawBytes": {"baseline": baseline.before.selector_name_bytes(), + "atom": atom.before.selector_name_bytes()}, + "gzip": "external measurement required; no compression dependency added" + }) +} + +pub(super) fn artifacts(baseline: &Measurement, atom: &Measurement) -> Value { + json!({ + "report": report(baseline, atom), + "baseline": {"beforeSheets": baseline.before.sheets, "afterSheets": baseline.after.sheets, + "beforeCode": baseline.before.code, "afterCode": baseline.after.code}, + "atom": {"beforeSheets": atom.before.sheets, "afterSheets": atom.after.sheets, + "beforeCode": atom.before.code, "afterCode": atom.after.code}, + "paths": fixture().iter().map(|m| &m.path).collect::>() + }) +} + +pub(super) fn assert_artifacts(artifacts: &Value) { + // Given: the original fixture, independently of the exported JSON. + let modules = fixture(); + // When: consume the complete stdout representation as an external tool does. + let serialized = artifacts.to_string(); + let decoded: Value = + serde_json::from_str(&serialized).unwrap_or_else(|error| panic!("{error}")); + // Then: paths, code and sheets retain their fixture identity and deployment. + let paths: Vec = + serde_json::from_value(decoded["paths"].clone()).unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + paths, + modules.iter().map(|m| m.path.clone()).collect::>() + ); + let summary = &decoded["report"]; + assert_eq!( + summary["fixture"], + json!({"routes": ROUTES, "sharedModules": SHARED_MODULES, + "sharedAtoms": SHARED_ATOMS, "privateAtomsPerRoute": PRIVATE_ATOMS, + "unknownLocalModules": 1, "threshold": 2}) + ); + assert_eq!( + summary["sheetCountSavings"], + json!({"numerator": SHARED_MODULES - 1, + "denominator": SHARED_MODULES}) + ); + for (name, copies) in [("baseline", SHARED_MODULES), ("atom", 1)] { + let mut modules = fixture(); + let before = Snapshot { + sheets: serde_json::from_value(decoded[name]["beforeSheets"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + code: serde_json::from_value(decoded[name]["beforeCode"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + }; + let after = Snapshot { + sheets: serde_json::from_value(decoded[name]["afterSheets"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + code: serde_json::from_value(decoded[name]["afterCode"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + }; + assert_eq!(before.sheets.len(), modules.len() + 1); + assert_eq!(before.code.len(), modules.len()); + before.assert_loaded_definitions(&modules); + for module in &mut modules[..SHARED_MODULES] { + module.atoms[0].value = "900000px".to_string(); + } + assert_eq!(after.sheets.len(), modules.len() + 1); + assert_eq!(after.code.len(), modules.len()); + after.assert_loaded_definitions(&modules); + assert_eq!( + before.sheets[0].contains("{width:100000px}"), + name == "atom" + ); + assert_eq!( + after + .sheets + .iter() + .map(|s| s.matches("{width:900000px}").count()) + .sum::(), + copies + ); + assert_eq!( + after + .sheets + .iter() + .map(|s| s.matches("{width:100000px}").count()) + .sum::(), + 0 + ); + assert_eq!(after.changed(&before).0, copies); + assert_eq!(summary["sessionRaw"][name], json!(before.raw_bytes())); + assert_eq!( + summary["invalidationRaw"][name], + json!(after.changed(&before).1) + ); + assert_eq!(summary["changedSheets"][name], json!(copies)); + assert_eq!( + summary["jsxClassNameRawBytes"][name], + json!(before.name_bytes()) + ); + assert_eq!( + summary["cssSelectorNameRawBytes"][name], + json!(before.selector_name_bytes()) + ); + } +} diff --git a/bindings/devup-ui-wasm/src/atom_hoist_measurement/snapshot.rs b/bindings/devup-ui-wasm/src/atom_hoist_measurement/snapshot.rs new file mode 100644 index 000000000..4bcb098df --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_hoist_measurement/snapshot.rs @@ -0,0 +1,87 @@ +use super::{Module, with_style_sheet}; +use std::collections::HashSet; + +fn classes(code: &str) -> Vec<&str> { + code.split("className=\"") + .skip(1) + .flat_map(|part| part.split('"').take(1).flat_map(str::split_whitespace)) + .collect() +} + +pub(super) struct Snapshot { + pub(super) sheets: Vec, + pub(super) code: Vec, +} + +impl Snapshot { + pub(super) fn capture(modules: &[Module], code: &[String]) -> Self { + let mut sheets = vec![with_style_sheet(|s| s.create_css(None, false))]; + sheets.extend( + modules + .iter() + .map(|m| with_style_sheet(|s| s.create_css(Some(&m.path), true))), + ); + Self { + sheets, + code: code.to_vec(), + } + } + + pub(super) fn assert_loaded_definitions(&self, modules: &[Module]) { + for (i, module) in modules.iter().enumerate() { + let names = classes(&self.code[i]); + let expected = module.declarations(); + assert_eq!( + names.len(), + expected.len(), + "{}: static JSX classes", + module.path + ); + assert!(!self.code[i].contains(" usize { + self.sheets.iter().map(String::len).sum() + } + + pub(super) fn name_bytes(&self) -> usize { + self.code + .iter() + .flat_map(|code| classes(code)) + .map(str::len) + .sum() + } + + pub(super) fn selector_name_bytes(&self) -> usize { + let names: HashSet<&str> = self.code.iter().flat_map(|code| classes(code)).collect(); + names + .iter() + .map(|name| { + name.len() + * self + .sheets + .iter() + .map(|sheet| sheet.matches(&format!(".{name}{{")).count()) + .sum::() + }) + .sum() + } + + pub(super) fn changed(&self, before: &Self) -> (usize, usize) { + let changed: Vec<&String> = self + .sheets + .iter() + .zip(&before.sheets) + .filter_map(|(after, before)| (after != before).then_some(after)) + .collect(); + (changed.len(), changed.iter().map(|sheet| sheet.len()).sum()) + } +} diff --git a/bindings/devup-ui-wasm/src/atom_tests.rs b/bindings/devup-ui-wasm/src/atom_tests.rs new file mode 100644 index 000000000..278eb4ef6 --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_tests.rs @@ -0,0 +1,214 @@ +use super::*; +use serial_test::serial; +use std::collections::HashSet; + +mod lifecycle; + +fn setup() { + reset_build_state_internal(); + css::debug::set_debug(false); + register_theme_internal(sheet::theme::Theme::default()); + css::atom_hoist::set_atom_hoist(Some(2)); + css::file_routes::set_file_routes(HashMap::from([ + ("a.tsx".to_string(), HashSet::from([0, 1])), + ("b.tsx".to_string(), HashSet::from([0, 1])), + ("private.tsx".to_string(), HashSet::from([0])), + ("styles.css.ts".to_string(), HashSet::from([0, 1])), + ])); + seed_file_map(vec!["a.tsx".into(), "b.tsx".into(), "private.tsx".into()]); +} + +fn compile(file: &str, source: &str) -> Output { + code_extract_internal( + file, + source, + "@devup-ui/react", + "df".to_string(), + false, + false, + false, + HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn emitted(file: &str) -> String { + with_style_sheet(|sheet| { + format!( + "{}{}", + sheet.create_css(None, false), + sheet.create_css(Some(file), false) + ) + }) +} + +fn assert_references_resolve(code: &str, css: &str) { + let mut classes = 0; + for token in code.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')) { + if token.starts_with("a1-") || token.starts_with("du-a1-") { + assert!( + css.contains(&format!(".{token}")), + "unemitted class {token}\n{css}" + ); + classes += 1; + } else if token.starts_with("k1-") || token.starts_with("du-k1-") { + assert!( + css.contains(&format!("@keyframes {token}{{")), + "unemitted keyframes {token}" + ); + } else if token.starts_with("--v1-") || token.starts_with("--du-v1-") { + assert!( + css.contains(&format!("var({token})")), + "unemitted variable {token}" + ); + } + } + assert!(classes > 0, "fixture must generate classes: {code}"); +} + +const SOURCE_A: &str = r#" +import { Box, css, styled, keyframes } from '@devup-ui/react'; +const fade = keyframes({from:{opacity:0,transform:'scale(0)'},to:{opacity:1}}); +const cls = css({color:'red', _hover:{color:'blue'}, p:['1px',null,'2px']}); +const Card = styled('div', {display:'flex', color:'red'}); +export const view = <>; +"#; + +const SOURCE_B: &str = r#" +import { Box, css, keyframes } from '@devup-ui/react'; +const fade = keyframes({from:{opacity:0,transform:'scale(0)'},to:{opacity:1}}); +const cls = css({color:'red', _hover:{color:'blue'}, p:['1px',null,'2px']}); +export const view = ; +"#; + +#[test] +#[serial] +fn atom_references_and_output_are_stable_when_extraction_order_changes() { + // Given + let build = |reverse: bool| { + setup(); + let mut sources = [("a.tsx", SOURCE_A), ("b.tsx", SOURCE_B)]; + if reverse { + sources.reverse(); + } + let mut output = BTreeMap::new(); + // When + for (file, source) in sources { + let result = compile(file, source); + assert!(result.updated_base_style); + output.insert(file, result.code); + } + // Then + for (file, code) in &output { + assert_references_resolve(code, &emitted(file)); + } + let css = with_style_sheet(|sheet| sheet.create_css(None, false)); + assert!(css.contains("@layer o1,o2"), "{css}"); + assert!(css.contains("!important")); + assert!(!css.contains("differentRuntimeCode")); + assert!( + !with_style_sheet(|sheet| sheet.create_css(Some("a.tsx"), false)) + .contains("background:red") + ); + (output, css, emitted("a.tsx"), emitted("b.tsx")) + }; + assert_eq!(build(false), build(true)); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn atom_names_and_emission_are_stable_when_debug_changes_with_prefix() { + // Given + let build = |debug| { + setup(); + set_prefix(Some("du-".to_string())); + css::debug::set_debug(debug); + // When + let output = compile("a.tsx", SOURCE_A); + let css = emitted("a.tsx"); + // Then + assert_references_resolve(&output.code, &css); + (output.code, css) + }; + assert_eq!(build(false), build(true)); + reset_build_state_internal(); + css::debug::set_debug(false); +} + +#[test] +#[serial] +fn same_atom_stays_local_when_a_private_bucket_also_uses_it() { + // Given + setup(); + let source = r#"import {Box} from '@devup-ui/react'; export const x=;"#; + // When + let shared = compile("a.tsx", source); + let private = compile("private.tsx", source); + // Then + assert_references_resolve(&shared.code, &emitted("a.tsx")); + assert_references_resolve(&private.code, &emitted("private.tsx")); + let global = with_style_sheet(|sheet| sheet.create_css(None, false)); + let local = with_style_sheet(|sheet| sheet.create_css(Some("private.tsx"), false)); + assert_eq!(global.matches("color:red").count(), 1); + assert_eq!(local.matches("color:red").count(), 1); + assert_ne!(shared.code, private.code); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn compat_apis_reference_emitted_atoms_when_bucket_is_predeclared() { + // Given + setup(); + let sources = [ + ( + "a.tsx", + r"import * as stylex from '@stylexjs/stylex'; + const s=stylex.create({root:{color:'red',':hover':{color:'blue'}}}); + export const x=
;", + ), + ( + "styles.css.ts", + r"import {style,layer,keyframes} from '@vanilla-extract/css'; + const l=layer('cards'); const fade=keyframes({from:{opacity:0},to:{opacity:1}}); + export const card=style({'@layer':{[l]:{color:'red',animation:`${fade} 1s`}}, + selectors:{'&:hover':{color:'blue'}}});", + ), + ]; + // When + for (file, source) in sources { + let output = compile(file, source); + // Then + assert_references_resolve(&output.code, &emitted(file)); + } + reset_build_state_internal(); +} + +#[test] +#[serial] +fn local_ordered_styles_signal_shared_layer_order_changes() { + // Given + setup(); + let plain = compile( + "private.tsx", + r#"import {Box} from '@devup-ui/react'; export const x=;"#, + ); + assert!(!plain.updated_base_style); + let source = r#"import {Box} from '@devup-ui/react'; export const x=;"#; + // When + let output = compile("private.tsx", source); + // Then + assert!(output.updated_base_style); + assert_references_resolve(&output.code, &emitted("private.tsx")); + assert!(with_style_sheet(|sheet| sheet.create_css(None, false)).contains("@layer o1;")); + assert!(!compile("private.tsx", source).updated_base_style); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs b/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs new file mode 100644 index 000000000..630234139 --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs @@ -0,0 +1,213 @@ +use super::*; + +const RED: &str = "import {Box} from '@devup-ui/react'; export const x=;"; + +#[test] +#[serial] +fn unknown_and_late_reach_stay_local_until_reset() { + // Given + setup(); + compile("a.tsx", RED); + let mut routes = css::file_routes::get_file_routes(); + routes.insert("unknown.tsx".into(), HashSet::from([0, 1])); + routes.insert("private.tsx".into(), HashSet::from([0, 1])); + css::file_routes::set_file_routes(routes.clone()); + css::atom_hoist::set_atom_hoist(Some(1)); + // When + for file in ["unknown.tsx", "private.tsx"] { + let output = compile(file, RED); + // Then + assert_references_resolve(&output.code, &emitted(file)); + assert!( + with_style_sheet(|sheet| sheet.create_css(Some(file), false)).contains("color:red") + ); + } + reset_build_state_internal(); + css::atom_hoist::set_atom_hoist(Some(2)); + css::file_routes::set_file_routes(routes); + let output = compile("unknown.tsx", RED); + assert_references_resolve(&output.code, &emitted("unknown.tsx")); + assert!( + !with_style_sheet(|sheet| sheet.create_css(Some("unknown.tsx"), false)) + .contains("color:red") + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn imported_sheet_keeps_recorded_placement_after_configuration_reset() { + // Given + setup(); + let shared = compile("a.tsx", RED); + let private = compile("private.tsx", RED); + let global = with_style_sheet(|sheet| sheet.create_css(None, false)); + let local = with_style_sheet(|sheet| sheet.create_css(Some("private.tsx"), false)); + let serialized = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + reset_build_state_internal(); + let imported = serde_json::from_str(&serialized).unwrap_or_else(|error| panic!("{error}")); + // When + import_sheet_internal(imported); + seed_file_map(vec!["a.tsx".into(), "b.tsx".into(), "private.tsx".into()]); + // Then + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + global + ); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("private.tsx"), false)), + local + ); + assert_references_resolve(&shared.code, &emitted("a.tsx")); + assert_references_resolve(&private.code, &emitted("private.tsx")); + css::atom_hoist::set_atom_hoist(Some(2)); + css::file_routes::set_file_routes(HashMap::from([( + "private.tsx".into(), + HashSet::from([0, 1]), + )])); + let resumed = compile("private.tsx", RED); + assert_eq!(private.code, resumed.code); + let shared_again = compile("b.tsx", RED); + assert_references_resolve(&shared_again.code, &emitted("b.tsx")); + assert!( + !with_style_sheet(|sheet| sheet.create_css(Some("b.tsx"), false)).contains("color:red") + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn canonical_bucket_reach_is_frozen_before_imported_styles_are_processed() { + // Given + setup(); + import_canonical_map_internal(HashMap::from([ + ("member.tsx".into(), "bucket.tsx".into()), + ("peer.tsx".into(), "bucket.tsx".into()), + ])); + css::file_routes::set_file_routes(HashMap::from([ + ("member.tsx".into(), HashSet::from([0])), + ("peer.tsx".into(), HashSet::from([1])), + ])); + let resolver = |specifier: &str, _importer: &str| { + (specifier == "./tokens").then(|| ResolvedModule { + path: "tokens.ts".to_string(), + code: "export const styles={color:'red',_hover:{color:'blue'}};".to_string(), + }) + }; + // When + let output = code_extract_with_modules_internal( + "member.tsx", + "import {Box} from '@devup-ui/react'; import {styles} from './tokens'; export const x=;", + "@devup-ui/react", + "df".to_string(), + false, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert_references_resolve(&output.code, &emitted("bucket.tsx")); + assert!(with_style_sheet(|sheet| sheet.create_css(None, false)).contains("color:red")); + assert!( + !with_style_sheet(|sheet| sheet.create_css(Some("bucket.tsx"), false)) + .contains("color:red") + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn important_and_plain_dynamic_values_emit_distinct_rules_in_every_mode() { + // Given + let source = r"import {Box} from '@devup-ui/react'; + export const x=<>;"; + for atom in [false, true] { + setup(); + if !atom { + css::atom_hoist::set_atom_hoist(None); + } + // When + let output = compile("private.tsx", source); + // Then + let css = emitted("private.tsx"); + let rules = with_style_sheet(|sheet| { + sheet.properties["private.tsx"] + .values() + .flat_map(BTreeMap::values) + .flatten() + .map(|prop| (prop.class_name.clone(), prop.value.clone())) + .collect::>() + }); + assert_eq!(rules.len(), 2); + assert_ne!(rules[0].0, rules[1].0); + for (name, value) in rules { + assert!(output.code.contains(&name)); + assert!(css.contains(&format!(".{name}{{color:{value}}}")), "{css}"); + } + if atom { + assert_references_resolve(&output.code, &css); + } + } + reset_build_state_internal(); +} + +#[test] +#[serial] +fn keyframe_content_and_scope_resolve_when_using_single_css() { + // Given + setup(); + let source = r"import {Box,keyframes} from '@devup-ui/react'; + const a=keyframes({from:{opacity:0,transform:'scale(0)'},to:{opacity:1}}); + const b=keyframes({from:{transform:'scale(0)',opacity:0},to:{opacity:1}}); + const c=keyframes({from:{opacity:0,transform:'scale(0)'},to:{opacity:0.5}}); + export const x=<>;"; + // When + let output = code_extract_internal( + "a.tsx", + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + let css = with_style_sheet(|sheet| sheet.create_css(None, false)); + assert_references_resolve(&output.code, &css); + with_style_sheet(|sheet| { + assert_eq!(sheet.keyframes[""].len(), 2); + for name in sheet.keyframes[""].keys() { + assert!(output.code.contains(name)); + assert!(css.contains(&format!("@keyframes {name}{{"))); + } + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn global_css_keyframes_use_the_same_scope_as_their_declaration() { + // Given + setup(); + let source = r"import {Box,keyframes,globalCss} from '@devup-ui/react'; + const fade=keyframes({from:{opacity:0},to:{opacity:1}}); + globalCss({body:{animation:`${fade} 1s`}}); export const x=;"; + // When + let output = compile("a.tsx", source); + // Then + assert_references_resolve(&output.code, &emitted("a.tsx")); + let global = with_style_sheet(|sheet| sheet.create_css(None, false)); + let local = with_style_sheet(|sheet| sheet.create_css(Some("a.tsx"), false)); + with_style_sheet(|sheet| { + for name in sheet.keyframes["a.tsx"].keys() { + assert!(global.contains(&format!("animation:{name} 1s")), "{global}"); + assert!(local.contains(&format!("@keyframes {name}{{"))); + assert!(!global.contains(&format!("@keyframes {name}{{"))); + } + }); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/cache_protocol_tests.rs b/bindings/devup-ui-wasm/src/cache_protocol_tests.rs new file mode 100644 index 000000000..f43294945 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_protocol_tests.rs @@ -0,0 +1,97 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; +use std::collections::HashSet; + +#[rstest] +#[case(false, false)] +#[case(false, true)] +#[case(true, true)] +#[serial] +fn legacy_import_changes_cache_generation_when_exported( + #[case] with_styles: bool, + #[case] atom_mode: bool, +) { + // Given + reset_build_state_internal(); + let mut legacy = StyleSheet::default(); + if with_styles { + legacy.add_property("old", "color", 0, "red", None, None, Some("private.tsx")); + } + let legacy_json = serde_json::to_string(&legacy).unwrap_or_else(|error| panic!("{error}")); + let local = legacy.create_css(Some("private.tsx"), false); + let global = legacy.create_css(None, false); + css::atom_hoist::set_atom_hoist(atom_mode.then_some(2)); + let imported = serde_json::from_str(&legacy_json).unwrap_or_else(|error| panic!("{error}")); + import_sheet_internal(imported); + // When + let exported = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + // Then + assert_ne!(exported, legacy_json); + let json: serde_json::Value = + serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + assert_eq!(json["atomNamingVersion"], 1); + let restored: StyleSheet = + serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + assert_eq!(restored.atom_plan, None); + assert_eq!(restored.create_css(Some("private.tsx"), false), local); + assert_eq!(restored.create_css(None, false), global); + import_sheet_internal(restored); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + exported + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn exported_generation_roundtrips_frozen_atom_placement() { + // Given + reset_build_state_internal(); + css::atom_hoist::set_atom_hoist(Some(2)); + css::file_routes::set_file_routes(HashMap::from([ + ("shared.tsx".into(), HashSet::from([0, 1])), + ("private.tsx".into(), HashSet::from([0])), + ])); + for file in ["shared.tsx", "private.tsx"] { + code_extract_internal( + file, + "import {Box} from '@devup-ui/react'; export const x=;", + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + } + let exported = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let global = with_style_sheet(|sheet| sheet.create_css(None, false)); + let local = with_style_sheet(|sheet| sheet.create_css(Some("private.tsx"), false)); + let imported = serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + reset_build_state_internal(); + // When + import_sheet_internal(imported); + // Then + assert_eq!(global.matches("color:red").count(), 1); + assert_eq!(local.matches("color:red").count(), 1); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + global + ); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("private.tsx"), false)), + local + ); + assert!( + !with_style_sheet(|sheet| sheet.create_css(Some("shared.tsx"), false)) + .contains("color:red") + ); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + exported + ); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 685af49aa..b9fbe78fe 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -219,6 +219,7 @@ pub fn get_prefix() -> Option { /// Internal function to import a `StyleSheet` (testable without `JsValue`) pub fn import_sheet_internal(sheet: StyleSheet) { + css::atom_hoist::restore_atom_plan(sheet.atom_plan.clone()); with_style_sheet_mut(|global_sheet| *global_sheet = sheet); } @@ -232,7 +233,8 @@ pub fn import_sheet(sheet_object: JsValue) -> Result<(), JsValue> { /// Internal function to export `StyleSheet` as JSON string (testable without `JsValue`) pub fn export_sheet_internal() -> Result { - with_style_sheet(serde_json::to_string).map_err(|e| e.to_string()) + with_style_sheet(|sheet| serde_json::to_string(&sheet.export_snapshot())) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "exportSheet")] @@ -334,6 +336,7 @@ pub fn reset_build_state_internal() { css::file_map::reset_canonical_map(); css::file_routes::set_file_routes(HashMap::new()); css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); css::set_prefix(None); with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); MODULE_RESOLVER.with_borrow_mut(|current| *current = None); @@ -345,13 +348,12 @@ pub fn reset_build_state() { } /// Set the atom-level hoist threshold. /// -/// When set to `Some(n)`, a style atom whose content is used by `>= n` distinct -/// routes is emitted into the shared global `devup-ui.css` (shipped once) instead -/// of duplicated into each per-route chunk. `None` (the default) disables atom -/// hoisting entirely (identity behavior). +/// When set to `Some(n)`, buckets with predeclared reach of `>= n` routes emit +/// their atoms into shared CSS. Eligibility freezes before the first source; +/// unknown or late reach stays local until `resetBuildState`. +/// `None` (the default) preserves ordinary per-file naming and placement. /// -/// MUST be called BEFORE `codeExtract` so atoms receive global (shared) class -/// names; enabling it afterwards leaves per-file names and nothing hoists. +/// Set this before `codeExtract` so extraction and emission share one plan. /// Pair with `importFileRoutes` to provide the file -> routes mapping. #[wasm_bindgen(js_name = "setAtomHoist")] pub fn set_atom_hoist(threshold: Option) { @@ -717,6 +719,15 @@ pub fn has_devup_ui_wasm(filename: &str, code: &str, package: &str) -> bool { has_devup_ui(filename, code, package) } +#[cfg(test)] +mod atom_tests; + +#[cfg(test)] +mod cache_protocol_tests; + +#[cfg(test)] +mod atom_hoist_measurement; + #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { @@ -750,16 +761,30 @@ mod tests { reset_file_routes(); register_theme_internal(sheet::theme::Theme::default()); - // a.tsx -> route 0, b.tsx -> route 1. bg:red is in BOTH (routes {0,1}, count 2 => HOIST). - // width:11px only in a (route {0}, private). width:22px only in b (private). + css::atom_hoist::restore_atom_plan(None); let mut fr = HashMap::new(); fr.insert("a.tsx".to_string(), HashSet::from([0u32])); fr.insert("b.tsx".to_string(), HashSet::from([1u32])); + fr.insert("shared-a.tsx".to_string(), HashSet::from([0u32, 1])); + fr.insert("shared-b.tsx".to_string(), HashSet::from([0u32, 1])); set_file_routes(fr); set_atom_hoist(Some(2)); - let srca = r#"import { Box } from "@devup-ui/react"; const x = ;"#; - let srcb = r#"import { Box } from "@devup-ui/react"; const x = ;"#; + for file in ["shared-a.tsx", "shared-b.tsx"] { + code_extract_internal( + file, + r#"import { Box } from "@devup-ui/react"; const x = ;"#, + "@devup-ui/react", + "df".to_string(), + false, + false, + false, + HashMap::new(), + ) + .unwrap(); + } + let srca = r#"import { Box } from "@devup-ui/react"; const x = ;"#; + let srcb = r#"import { Box } from "@devup-ui/react"; const x = ;"#; code_extract_internal( "a.tsx", srca, @@ -814,337 +839,28 @@ mod tests { set_atom_hoist(None); reset_file_routes(); + css::atom_hoist::restore_atom_plan(None); } - /// Env-gated artifact emitter for split-native measurement. Writes REAL - /// devup CSS output (header, `@layer`, naming, dedup all authentic) for three - /// delivery models across several workloads, so an external script can - /// measure gzip/brotli + multi-route session + incremental-invalidation - /// bytes. Set `DEVUP_EMIT_MEASURE=1` to run; no-op (and zero cost) otherwise - /// so the normal test suite stays clean. + /// Export real before/after sheets and code for external gzip measurement. + /// `DEVUP_EMIT_MEASURE=1` enables structured stdout without shared temp files. #[test] #[serial] - #[allow(clippy::items_after_statements, clippy::format_push_string)] fn emit_split_measurement_artifacts() { - use css::atom_hoist::set_atom_hoist; - use css::class_map::reset_class_map; - use css::file_map::reset_file_map; - use css::file_routes::{reset_file_routes, set_file_routes}; - use std::collections::{HashMap, HashSet}; - use std::fs; - - if std::env::var("DEVUP_EMIT_MEASURE").is_err() { - return; + if std::env::var_os("DEVUP_EMIT_MEASURE").is_some() { + let artifacts = + super::atom_hoist_measurement::build_predeclared_measurement_artifacts() + .unwrap_or_else(|error| panic!("{error}")); + println!("{artifacts}"); } - - let props = [ - "w", - "h", - "p", - "m", - "minW", - "minH", - "maxW", - "maxH", - "fontSize", - "lineHeight", - "borderRadius", - "gap", - ]; - let atom = |key: &str, px: usize| format!(""); - let build = |els: &[String]| { - format!( - "import {{ Box }} from \"@devup-ui/react\"; const x = <>{};", - els.join("") - ) - }; - let reset = || { - { - let mut s = GLOBAL_STYLE_SHEET.lock().unwrap(); - *s = StyleSheet::default(); - } - reset_class_map(); - reset_file_map(); - reset_file_routes(); - register_theme_internal(sheet::theme::Theme::default()); - }; - - let out = std::env::temp_dir().join("devup-split-measure"); - let _ = fs::remove_dir_all(&out); - fs::create_dir_all(&out).unwrap(); - - // (name, routes, universal atoms, private atoms/route) - let workloads = [ - ("shared_heavy", 8usize, 80usize, 25usize), - ("balanced", 8usize, 50usize, 50usize), - ("disjoint", 8usize, 20usize, 60usize), - ]; - let mut manifest = String::from("["); - for (wi, (name, n, u, p)) in workloads.iter().enumerate() { - let (n, u, p) = (*n, *u, *p); - let universal: Vec = (0..u) - .map(|i| atom(props[i % props.len()], 100_000 + i)) - .collect(); - let make_priv = |r: usize| -> Vec { - (0..p) - .map(|i| { - atom( - props[i % props.len()], - 1_000_000 + wi * 1_000_000 + r * p + i, - ) - }) - .collect() - }; - let sources: Vec = (0..n) - .map(|r| { - let mut e = universal.clone(); - e.extend(make_priv(r)); - build(&e) - }) - .collect(); - let run = |single: bool| { - reset(); - for (r, src) in sources.iter().enumerate() { - code_extract_internal( - &format!("r{r}.tsx"), - src, - "@devup-ui/react", - "df".to_string(), - single, - false, - false, - HashMap::new(), - ) - .unwrap(); - } - }; - - // single-css: one shared file with every atom. - run(true); - fs::write( - out.join(format!("{name}_single.css")), - with_style_sheet(|s| s.create_css(None, false)), - ) - .unwrap(); - - // per-file: shared base (theme/base only) + one full chunk per route. - run(false); - fs::write( - out.join(format!("{name}_perfile_base.css")), - with_style_sheet(|s| s.create_css(None, false)), - ) - .unwrap(); - for r in 0..n { - fs::write( - out.join(format!("{name}_perfile_r{r}.css")), - with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)), - ) - .unwrap(); - } - - // atom-B: hoisted shared base (universal atoms) + per-route delta. - // CRITICAL: atom_hoist must be enabled BEFORE extraction so atoms get - // GLOBAL names (shared identity across files). Enabling it only at - // create_css time leaves per-file names, so the same universal atom - // looks like N distinct atoms (one per file) and never hoists. - reset(); - let mut fr = HashMap::new(); - for r in 0..n { - fr.insert(format!("r{r}.tsx"), HashSet::from([r as u32])); - } - set_file_routes(fr); - set_atom_hoist(Some(n)); - for (r, src) in sources.iter().enumerate() { - code_extract_internal( - &format!("r{r}.tsx"), - src, - "@devup-ui/react", - "df".to_string(), - false, - false, - false, - HashMap::new(), - ) - .unwrap(); - } - fs::write( - out.join(format!("{name}_atomb_base.css")), - with_style_sheet(|s| s.create_css(None, false)), - ) - .unwrap(); - for r in 0..n { - fs::write( - out.join(format!("{name}_atomb_r{r}.css")), - with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)), - ) - .unwrap(); - } - set_atom_hoist(None); - reset_file_routes(); - - manifest.push_str(&format!( - "{}{{\"name\":\"{name}\",\"n\":{n},\"u\":{u},\"p\":{p}}}", - if wi > 0 { "," } else { "" } - )); - } - manifest.push(']'); - fs::write(out.join("manifest.json"), manifest).unwrap(); - reset(); - set_atom_hoist(None); - println!("[EMIT] artifacts -> {}", out.display()); } - /// SPLIT-NATIVE LOCK: atom-level route-aware hoisting (global-named - /// shared-base + per-route delta) is a STRICT upgrade over the per-file mode - /// on the metrics that split actually competes on -- multi-route SESSION - /// bytes and incremental-deploy INVALIDATION bytes -- NOT on fresh-single- - /// route bytes (where per-file already hits the theoretical floor). - /// - /// This test supersedes an earlier "no win" lock that was built on a - /// measurement bug: enabling atom_hoist AFTER extraction left per-file class - /// names, so the same universal atom looked like N distinct atoms and never - /// hoisted -- making atom-B byte-identical to per-file (a no-op, not a - /// truth). The fix, asserted here, is that atom_hoist MUST be enabled BEFORE - /// extraction so atoms get GLOBAL (shared) names. + /// Compare usable sheets with predeclared shared-module reach and count + /// deployment invalidations after changing a common upstream declaration. #[test] #[serial] - // byte sizes are tiny so ratios are exact; doc prose names models literally - #[allow(clippy::cast_precision_loss, clippy::doc_markdown)] fn atom_b_beats_per_file_on_session_and_invalidation() { - use css::atom_hoist::set_atom_hoist; - use css::class_map::reset_class_map; - use css::file_map::reset_file_map; - use css::file_routes::{reset_file_routes, set_file_routes}; - use std::collections::{HashMap, HashSet}; - - // Realistic design-system workload: many shared primitives, fewer - // route-private atoms. Routes are disjoint on private atoms. - const ROUTES: usize = 8; - const UNIVERSAL: usize = 80; - const PRIVATE: usize = 25; - - let props = ["w", "h", "p", "m", "minW", "minH", "maxW", "maxH"]; - let atom = |key: &str, px: usize| format!(""); - let build_source = |elements: &[String]| -> String { - let body = elements.join(""); - format!("import {{ Box }} from \"@devup-ui/react\"; const x = <>{body};") - }; - let reset_engine = || { - { - let mut s = GLOBAL_STYLE_SHEET.lock().unwrap(); - *s = StyleSheet::default(); - } - reset_class_map(); - reset_file_map(); - reset_file_routes(); - register_theme_internal(sheet::theme::Theme::default()); - }; - - let universal_atoms: Vec = (0..UNIVERSAL) - .map(|i| atom(props[i % props.len()], 100_000 + i)) - .collect(); - let make_private = |route: usize| -> Vec { - (0..PRIVATE) - .map(|i| atom(props[i % props.len()], 1_000_000 + route * PRIVATE + i)) - .collect() - }; - let sources: Vec = (0..ROUTES) - .map(|r| { - let mut e = universal_atoms.clone(); - e.extend(make_private(r)); - build_source(&e) - }) - .collect(); - let extract_all = |single_css: bool| { - for (r, src) in sources.iter().enumerate() { - code_extract_internal( - &format!("r{r}.tsx"), - src, - "@devup-ui/react", - "df".to_string(), - single_css, - false, - false, - HashMap::new(), - ) - .unwrap(); - } - }; - - // ---- per-file: atom_hoist OFF, multi-css. Each chunk carries all of - // its route's atoms (universals duplicated into every chunk). ---- - reset_engine(); - extract_all(false); - let pf_base = with_style_sheet(|s| s.create_css(None, false)).len(); - let pf_chunks: Vec = (0..ROUTES) - .map(|r| with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)).len()) - .collect(); - - // ---- atom-B: enable hoist + routes BEFORE extraction so atoms get - // GLOBAL names; universals (used by all ROUTES) hoist into the base, - // privates stay in their per-route delta. ---- - reset_engine(); - let mut fr = HashMap::new(); - for r in 0..ROUTES { - fr.insert(format!("r{r}.tsx"), HashSet::from([r as u32])); - } - set_file_routes(fr); - set_atom_hoist(Some(ROUTES)); - extract_all(false); - let ab_base = with_style_sheet(|s| s.create_css(None, false)).len(); - let ab_deltas: Vec = (0..ROUTES) - .map(|r| with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)).len()) - .collect(); - set_atom_hoist(None); - reset_file_routes(); - reset_engine(); - - // Session = visit every route once (base cached after the first route). - let pf_session = pf_base + pf_chunks.iter().sum::(); - let ab_session = ab_base + ab_deltas.iter().sum::(); - // Invalidation = one route's styles change; returning user re-downloads - // only the file(s) whose hash changed. - let pf_invalidation = pf_chunks[0]; - let ab_invalidation = ab_deltas[0]; - - let session_margin = (pf_session as f64 - ab_session as f64) / pf_session as f64 * 100.0; - let invalidation_margin = - (pf_invalidation as f64 - ab_invalidation as f64) / pf_invalidation as f64 * 100.0; - println!( - "[SPLIT] base: per-file={pf_base}B atom-B={ab_base}B | chunk: per-file={}B atom-B-delta={}B", - pf_chunks[0], ab_deltas[0] - ); - println!( - "[SPLIT] session: per-file={pf_session}B atom-B={ab_session}B ({session_margin:.1}% smaller) | invalidation: per-file={pf_invalidation}B atom-B={ab_invalidation}B ({invalidation_margin:.1}% smaller)" - ); - - // Regression guard against the no-op-hoist bug: hoisting MUST have moved - // the universal atoms into the base, so the base is large and the delta - // is much smaller than a full per-file chunk. - assert!( - ab_base > pf_base + 500, - "hoist no-op: atom-B base ({ab_base}B) should hold the universal atoms, \ - but is barely larger than the empty per-file base ({pf_base}B). \ - atom_hoist was likely enabled AFTER extraction." - ); - assert!( - (ab_deltas[0] as f64) < (pf_chunks[0] as f64) * 0.6, - "hoist no-op: atom-B delta ({}B) should be far smaller than the full \ - per-file chunk ({}B) once universals are hoisted out", - ab_deltas[0], - pf_chunks[0] - ); - // The split-native wins this whole investigation hinges on. - assert!( - session_margin >= 15.0, - "atom-B should beat per-file on multi-route session bytes by >=15% \ - (got {session_margin:.1}%)" - ); - assert!( - invalidation_margin >= 30.0, - "atom-B should beat per-file on incremental-deploy invalidation by \ - >=30% (got {invalidation_margin:.1}%)" - ); + super::atom_hoist_measurement::assert_predeclared_shared_savings().unwrap(); } #[test] diff --git a/libs/css/src/atom_hoist.rs b/libs/css/src/atom_hoist.rs index 259e4a6a7..4ba6182b4 100644 --- a/libs/css/src/atom_hoist.rs +++ b/libs/css/src/atom_hoist.rs @@ -1,8 +1,61 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{LazyLock, Mutex}; -// Atom-level hoist threshold. 0 = disabled (default). N = a style atom whose -// content is used by >= N distinct routes is emitted into the shared global -// devup-ui.css (shipped once) instead of duplicated across per-route chunks. +use crate::{file_map::canonical, file_routes::with_file_routes}; + +static FROZEN_PLAN: LazyLock>>> = LazyLock::new(|| Mutex::new(None)); + +/// Freeze every eligible canonical bucket before any source is processed. +pub fn freeze_atom_plan() { + if let Some(threshold) = atom_hoist_threshold() { + let mut plan = FROZEN_PLAN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if plan.is_none() { + let buckets = with_file_routes(|routes| { + let mut buckets: HashMap> = HashMap::new(); + for (file, reach) in routes { + buckets.entry(canonical(file)).or_default().extend(reach); + } + buckets + }); + *plan = Some( + buckets + .into_iter() + .filter_map(|(bucket, routes)| (routes.len() >= threshold).then_some(bucket)) + .collect(), + ); + } + } +} + +/// Snapshot the immutable build plan for sheet persistence. +pub fn atom_plan() -> Option> { + FROZEN_PLAN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() +} + +/// Restore a cached plan, or clear it when starting a new build. +pub fn restore_atom_plan(plan: Option>) { + *FROZEN_PLAN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = plan; +} + +/// Whether this bucket was eligible at the build's extraction boundary. +pub fn is_hoisted_bucket(filename: &str) -> bool { + freeze_atom_plan(); + FROZEN_PLAN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|plan| plan.contains(&canonical(filename))) +} + +// Zero disables atom mode; eligible bucket reach is frozen before extraction. static ATOM_HOIST_THRESHOLD: AtomicUsize = AtomicUsize::new(0); #[inline(always)] diff --git a/libs/css/src/atom_name.rs b/libs/css/src/atom_name.rs new file mode 100644 index 000000000..bd117f079 --- /dev/null +++ b/libs/css/src/atom_name.rs @@ -0,0 +1,58 @@ +use crate::{atom_hoist::is_hoisted_bucket, file_map::canonical, style_selector::StyleSelector}; +use std::fmt::Write; + +/// Lossless byte encoding with no delimiter or CSS identifier ambiguity. +pub fn hex(value: &str) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut result = String::with_capacity(value.len() * 2); + for byte in value.bytes() { + result.push(char::from(DIGITS[usize::from(byte >> 4)])); + result.push(char::from(DIGITS[usize::from(byte & 15)])); + } + result +} + +/// Names identify placement, never the order a source was encountered. +pub fn scope(filename: Option<&str>) -> String { + match filename { + None => "g".to_string(), + Some(file) if is_hoisted_bucket(file) => "h".to_string(), + Some(file) => format!("l-{}", hex(&canonical(file))), + } +} + +/// Structural selector identity excludes the source owner used only for cleanup. +pub fn selector_key(selector: Option<&StyleSelector>, layer: Option<&str>) -> String { + let mut key = match selector { + None => "n".to_string(), + Some(StyleSelector::Selector(text)) => format!("s-{}", hex(text)), + Some(StyleSelector::Global(text, _)) => format!("g-{}", hex(text)), + Some(StyleSelector::At { + kind, + query, + selector, + outer, + file: _, + }) => { + let mut key = String::from("a"); + for rule in outer { + let _ = write!(key, "-{}-{}", rule.kind, hex(&rule.query)); + } + let _ = write!(key, "-end-{kind}-{}-", hex(query)); + match selector { + None => key.push('n'), + Some(text) => { + let _ = write!(key, "s-{}", hex(text)); + } + } + key + } + }; + match layer { + None => key.push_str("-n"), + Some(layer) => { + let _ = write!(key, "-s-{}", hex(layer)); + } + } + key +} diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 576ba53b9..d0e82a66a 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -1,5 +1,6 @@ pub mod at_rule; pub mod atom_hoist; +pub mod atom_name; pub mod class_map; mod constant; pub mod debug; @@ -396,6 +397,14 @@ fn class_num_for_key(filename_key: &str, build_key: impl FnOnce(&mut String)) -> #[must_use] pub fn keyframes_to_keyframes_name(keyframes: &str, filename: Option<&str>) -> String { + if atom_hoist::is_atom_hoist() { + // Keyframes remain in their file chunk, even when declarations are hoisted. + let scope = filename.map_or_else( + || "g".to_string(), + |file| format!("l-{}", atom_name::hex(&file_map::canonical(file))), + ); + return with_prefix(|prefix| format!("{prefix}k1-{scope}-{}", atom_name::hex(keyframes))); + } with_prefix(|prefix| { if is_debug() { let mut result = String::with_capacity(prefix.len() + 2 + keyframes.len()); @@ -505,6 +514,21 @@ pub fn sheet_to_classname( // `$primary`) borrow straight from `value` with zero allocation, and the // key/result builders below only ever read `&optimized` as `&str`. let optimized = value.map_or(Cow::Borrowed(""), optimize_value); + if atom_hoist::is_atom_hoist() { + let scope = atom_name::scope(filename); + let value = match value { + None => "n".to_string(), + Some(_) => format!("s-{}", atom_name::hex(&optimized)), + }; + return with_prefix(|prefix| { + format!( + "{prefix}a1-{scope}-{}-{level}-{value}-{}-{}", + atom_name::hex(property.trim()), + atom_name::hex(selector.unwrap_or_default()), + style_order.unwrap_or(255), + ) + }); + } if is_debug() { let selector = selector.unwrap_or_default().trim(); let encoded = if selector.is_empty() { @@ -597,6 +621,15 @@ fn write_u8(s: &mut String, v: u8) { #[must_use] pub fn sheet_to_variable_name(property: &str, level: u8, selector: Option<&str>) -> String { + if atom_hoist::is_atom_hoist() { + return with_prefix(|prefix| { + format!( + "--{prefix}v1-{}-{level}-{}", + atom_name::hex(property.trim()), + atom_name::hex(selector.unwrap_or_default()) + ) + }); + } if is_debug() { let selector = selector.unwrap_or_default().trim(); let encoded = if selector.is_empty() { diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index 3b2346aed..c7e4050b9 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -5,6 +5,8 @@ use std::sync::{LazyLock, RwLock}; struct ThemeTokenRegistry { length: BTreeMap>, shadow: BTreeMap>, + first_length: BTreeMap, + first_shadow: BTreeMap, typography: Vec, } @@ -18,9 +20,42 @@ pub fn set_theme_token_levels( if let Ok(mut registry) = TOKEN_REGISTRY.write() { registry.length = length; registry.shadow = shadow; + registry.first_length.clear(); + registry.first_shadow.clear(); } } +/// Register effective default literals separately from responsive variable levels. +pub fn set_theme_token_values(length: BTreeMap, shadow: BTreeMap) { + if let Ok(mut registry) = TOKEN_REGISTRY.write() { + let normalize = |values: BTreeMap| { + values + .into_iter() + .map(|(token, value)| { + ( + token, + crate::optimize_value::optimize_value(&value).into_owned(), + ) + }) + .collect() + }; + registry.first_length = normalize(length); + registry.first_shadow = normalize(shadow); + } +} + +/// Resolve the first default literal in the property's token namespace. +pub fn get_first_theme_token_value(property: &str, value: &str) -> Option { + let token = value.strip_prefix('$')?; + let registry = TOKEN_REGISTRY.read().ok()?; + let values = if property == "box-shadow" { + ®istry.first_shadow + } else { + ®istry.first_length + }; + values.get(token).cloned() +} + pub fn set_typography_keys(keys: Vec) { if let Ok(mut registry) = TOKEN_REGISTRY.write() { registry.typography = keys; @@ -80,6 +115,67 @@ mod tests { use super::*; use serial_test::serial; + #[test] + #[serial] + fn theme_token_values_resolve_in_the_property_namespace() { + // Given + set_theme_token_values( + BTreeMap::from([("shared".into(), "8px".into())]), + BTreeMap::from([("shared".into(), "0 1px 2px black".into())]), + ); + // When / Then + assert_eq!( + get_first_theme_token_value("width", "$shared"), + Some("8px".into()) + ); + assert_eq!( + get_first_theme_token_value("box-shadow", "$shared"), + Some("0 1px 2px black".into()) + ); + assert_eq!(get_first_theme_token_value("width", "$missing"), None); + assert_eq!(get_first_theme_token_value("width", "shared"), None); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + } + + #[test] + #[serial] + fn theme_token_values_normalize_zero_literals() { + // Given + let zero = BTreeMap::from([("zero".into(), "0px".into())]); + // When + set_theme_token_values(zero.clone(), zero); + // Then + assert_eq!( + get_first_theme_token_value("width", "$zero"), + Some("0".into()) + ); + assert_eq!( + get_first_theme_token_value("box-shadow", "$zero"), + Some("0".into()) + ); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + } + + #[test] + #[serial] + fn theme_token_values_reset_when_levels_are_replaced() { + // Given + set_theme_token_values( + BTreeMap::from([("shared".into(), "8px".into())]), + BTreeMap::from([("shared".into(), "0 1px 2px black".into())]), + ); + // When + set_theme_token_levels( + BTreeMap::from([("shared".into(), vec![0, 2])]), + BTreeMap::from([("shared".into(), vec![0, 3])]), + ); + // Then + assert_eq!(get_first_theme_token_value("width", "$shared"), None); + assert_eq!(get_first_theme_token_value("box-shadow", "$shared"), None); + assert_eq!(get_responsive_theme_token("$shared"), Some(vec![0, 2])); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + } + #[test] #[serial] fn test_get_responsive_theme_token() { diff --git a/libs/extractor/src/extract_style/extract_dynamic_style.rs b/libs/extractor/src/extract_style/extract_dynamic_style.rs index f09b03b07..8d75636f5 100644 --- a/libs/extractor/src/extract_style/extract_dynamic_style.rs +++ b/libs/extractor/src/extract_style/extract_dynamic_style.rs @@ -153,20 +153,34 @@ impl ExtractDynamicStyle { impl ExtractStyleProperty for ExtractDynamicStyle { fn extract(&self, filename: Option<&str>) -> StyleProperty { let selector = super::class_selector(self.selector.as_ref(), self.layer()); - StyleProperty::Variable { - class_name: sheet_to_classname( + let ordinary_class = (!css::atom_hoist::is_atom_hoist()).then(|| { + sheet_to_classname( self.property.as_str(), self.level, - None, + self.important.then_some("!important"), selector.as_deref(), self.style_order, filename, - ), - variable_name: sheet_to_variable_name( - self.property.as_str(), - self.level, - selector.as_deref(), - ), + ) + }); + let variable_name = + sheet_to_variable_name(self.property.as_str(), self.level, selector.as_deref()); + let declaration = format!( + "var({variable_name}){}", + if self.important { " !important" } else { "" } + ); + StyleProperty::Variable { + class_name: ordinary_class.unwrap_or_else(|| { + sheet_to_classname( + self.property.as_str(), + self.level, + Some(&declaration), + selector.as_deref(), + self.style_order, + filename, + ) + }), + variable_name, identifier: self.identifier.clone(), } } diff --git a/libs/extractor/src/extract_style/extract_keyframes.rs b/libs/extractor/src/extract_style/extract_keyframes.rs index b62eeab9d..1d28e6be5 100644 --- a/libs/extractor/src/extract_style/extract_keyframes.rs +++ b/libs/extractor/src/extract_style/extract_keyframes.rs @@ -16,6 +16,21 @@ pub struct ExtractKeyframes { impl ExtractStyleProperty for ExtractKeyframes { fn extract(&self, filename: Option<&str>) -> StyleProperty { + if css::atom_hoist::is_atom_hoist() { + let mut content = String::new(); + for (step, styles) in &self.keyframes { + content.push_str(&css::atom_name::hex(step)); + content.push('{'); + for style in styles { + content.push_str(&css::atom_name::hex(style.property())); + content.push(':'); + content.push_str(&css::atom_name::hex(style.value())); + content.push(';'); + } + content.push('}'); + } + return StyleProperty::ClassName(keyframes_to_keyframes_name(&content, filename)); + } let mut hasher = DefaultHasher::new(); self.keyframes.hash(&mut hasher); // Format the u64 hash into a stack buffer instead of a throwaway heap diff --git a/libs/extractor/src/extract_style/extract_static_style.rs b/libs/extractor/src/extract_style/extract_static_style.rs index 1adc78be6..2a5b62775 100644 --- a/libs/extractor/src/extract_style/extract_static_style.rs +++ b/libs/extractor/src/extract_style/extract_static_style.rs @@ -6,6 +6,7 @@ use css::{ optimize_value::optimize_value, sheet_to_classname, style_selector::{StyleSelector, optimize_selector}, + theme_tokens::get_first_theme_token_value, }; use crate::{ @@ -188,18 +189,30 @@ impl ExtractStaticStyle { pub const fn theme_token_resolution(&self) -> ThemeTokenResolution { self.theme_token_resolution } + + /// Effective value shared by generated class identity and emitted declaration. + pub fn resolved_value(&self) -> Cow<'_, str> { + match self.theme_token_resolution() { + ThemeTokenResolution::CssVariable => Cow::Borrowed(&self.value), + ThemeTokenResolution::FirstValue => { + get_first_theme_token_value(&self.property, &self.value) + .map_or(Cow::Borrowed(&self.value), Cow::Owned) + } + } + } } impl ExtractStyleProperty for ExtractStaticStyle { fn extract(&self, filename: Option<&str>) -> StyleProperty { let s = self.class_selector(); + let value = self.resolved_value(); // `self.value` is already the result of `optimize_value(convert_value(..))` // (computed in the constructors), so re-running convert_value + optimize_value // here is redundant. Only the multi-css optimization is not applied at construction. let v = if check_multi_css_optimize(&self.property) { - optimize_multi_css_value(&self.value) + optimize_multi_css_value(&value) } else { - std::borrow::Cow::Borrowed(self.value.as_str()) + Cow::Borrowed(value.as_ref()) }; StyleProperty::ClassName(sheet_to_classname( &self.property, diff --git a/libs/extractor/src/extract_style/mod.rs b/libs/extractor/src/extract_style/mod.rs index e101f8bbb..b0bca5be0 100644 --- a/libs/extractor/src/extract_style/mod.rs +++ b/libs/extractor/src/extract_style/mod.rs @@ -16,6 +16,9 @@ fn class_selector<'a>( selector: Option<&'a StyleSelector>, layer: Option<&str>, ) -> Option> { + if css::atom_hoist::is_atom_hoist() { + return Some(Cow::Owned(css::atom_name::selector_key(selector, layer))); + } let selector = selector.map(StyleSelector::as_class_str); match layer { Some(layer) => Some(Cow::Owned(format!( diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 4d126e120..ec87d82b4 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -295,6 +295,7 @@ fn extract_source( resolver: Option<&ModuleResolver>, ) -> Result> { // Step 1: Transform import aliases + css::atom_hoist::freeze_atom_plan(); // e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'` // e.g., `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` let (transformed_code, alias_edits) = import_alias_visit::transform_import_aliases_with_edits( diff --git a/libs/sheet/src/atom_identity_tests.rs b/libs/sheet/src/atom_identity_tests.rs new file mode 100644 index 000000000..be0434229 --- /dev/null +++ b/libs/sheet/src/atom_identity_tests.rs @@ -0,0 +1,231 @@ +use super::*; +use css::sheet_to_classname; +use css::style_selector::{AtRule, AtRuleKind}; +use serial_test::serial; + +mod first_value; + +#[test] +#[serial] +fn nested_at_rule_pseudo_classes_reference_the_emitted_selector() { + // Given + css::atom_hoist::restore_atom_plan(None); + css::atom_hoist::set_atom_hoist(Some(2)); + css::file_routes::reset_file_routes(); + css::file_map::reset_canonical_map(); + css::set_prefix(None); + let mut sheet = StyleSheet::default(); + let source = r"import {Box} from '@devup-ui/react'; + export const x=;"; + // When + let output = extractor::extract("private.tsx", source, extractor::ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")); + sheet.update_styles(&output.styles, "private.tsx", false); + // Then + let css = sheet.create_css(Some("private.tsx"), false); + let classes: Vec<&str> = output + .code + .split("className=\"") + .nth(1) + .and_then(|code| code.split('"').next()) + .unwrap_or_else(|| panic!("{}", output.code)) + .split_whitespace() + .collect(); + assert_eq!(classes.len(), 2); + assert_ne!(classes[0], classes[1]); + assert!( + css.contains("@supports(display:grid){@media(hover:hover){"), + "{css}" + ); + for (selector, color) in [("hover", "red"), ("focus", "blue")] { + assert!( + classes + .iter() + .any(|name| css.contains(&format!(".{name}:{selector}{{color:{color}}}"))), + "{css}" + ); + } + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); +} + +#[test] +#[serial] +fn content_identities_emit_distinct_rules_for_structural_boundaries() { + // Given + css::atom_hoist::restore_atom_plan(None); + css::atom_hoist::set_atom_hoist(Some(2)); + css::file_routes::reset_file_routes(); + css::file_map::reset_canonical_map(); + css::set_prefix(None); + let at = |kind, outer| { + Some(StyleSelector::At { + kind, + query: "(width:10px)".to_string(), + selector: None, + outer, + file: None, + }) + }; + let cases = [ + (None, None, 0, None, None), + (None, None, 0, Some(""), None), + (None, None, 0, Some("red"), None), + (None, None, 1, Some("red"), None), + (None, None, 0, Some("red"), Some(1)), + (None, Some(""), 0, Some("red"), None), + (None, Some("cards"), 0, Some("red"), None), + ( + Some(StyleSelector::Selector("&:hover".into())), + None, + 0, + Some("red"), + None, + ), + (at(AtRuleKind::Media, vec![]), None, 0, Some("red"), None), + (at(AtRuleKind::Supports, vec![]), None, 0, Some("red"), None), + ( + at(AtRuleKind::Container, vec![]), + None, + 0, + Some("red"), + None, + ), + ( + at( + AtRuleKind::Media, + vec![AtRule { + kind: AtRuleKind::Supports, + query: "(display:grid)".into(), + }], + ), + None, + 0, + Some("red"), + None, + ), + ( + at( + AtRuleKind::Media, + vec![AtRule { + kind: AtRuleKind::Container, + query: "(display:grid)".into(), + }], + ), + None, + 0, + Some("red"), + None, + ), + ]; + let mut sheet = StyleSheet::default(); + let mut names = BTreeSet::new(); + // When + for (selector, layer, level, value, order) in cases { + let key = css::atom_name::selector_key(selector.as_ref(), layer); + let name = sheet_to_classname( + "color", + level, + value, + Some(&key), + order, + Some("private.tsx"), + ); + assert!(names.insert(name.clone()), "colliding identity {name}"); + sheet.add_property_with_layer( + &name, + "color", + level, + value.unwrap_or("var(--x)"), + selector.as_ref(), + order, + Some("private.tsx"), + layer, + ); + } + // Then + let css = sheet.create_css(Some("private.tsx"), false); + for name in names { + assert!( + css.contains(&format!(".{name}")), + "missing rule {name}\n{css}" + ); + } + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); +} + +#[test] +#[serial] +fn global_selector_owner_is_cleanup_only_not_content_identity() { + // Given + css::atom_hoist::restore_atom_plan(None); + css::atom_hoist::set_atom_hoist(Some(2)); + css::file_routes::set_file_routes(std::collections::HashMap::from([ + ("a.tsx".to_string(), std::collections::HashSet::from([0, 1])), + ("b.tsx".to_string(), std::collections::HashSet::from([0, 1])), + ])); + let mut sheet = StyleSheet::default(); + let source = "import {globalCss} from '@devup-ui/react'; globalCss({body:{color:'red'}});"; + let mut identities = BTreeSet::new(); + // When + for file in ["a.tsx", "b.tsx"] { + let output = extractor::extract(file, source, extractor::ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")); + sheet.update_styles(&output.styles, file, false); + for levels in sheet.properties[file].values() { + for prop in levels.values().flatten() { + identities.insert(prop.class_name.clone()); + } + } + } + // Then + assert_eq!(identities.len(), 1); + let emitted = sheet.create_css(None, false); + assert!( + emitted.contains(concat!("body{", "color:red}")), + "{emitted}" + ); + sheet.rm_global_css("a.tsx", false); + assert!( + sheet + .create_css(None, false) + .contains(concat!("body{", "color:red}")) + ); + let global_key = css::atom_name::selector_key( + Some(&StyleSelector::Global("body".into(), "b.tsx".into())), + None, + ); + let local_key = + css::atom_name::selector_key(Some(&StyleSelector::Selector("body".into())), None); + assert_ne!(global_key, local_key); + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + css::file_routes::reset_file_routes(); +} + +#[test] +#[serial] +fn shared_imports_and_fonts_signal_changes_only_when_added() { + // Given + css::atom_hoist::restore_atom_plan(None); + css::atom_hoist::set_atom_hoist(Some(2)); + let source = "import {globalCss} from '@devup-ui/react'; globalCss({imports:['base.css'],fontFaces:[{fontFamily:'Fixture',src:'url(font.woff2)'}]});"; + let output = extractor::extract("test.tsx", source, extractor::ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")); + let mut sheet = StyleSheet::default(); + // When + let first = sheet.update_styles(&output.styles, "test.tsx", false); + let repeated = sheet.update_styles(&output.styles, "test.tsx", false); + // Then + assert!(first.1); + assert!(!repeated.1); + let emitted = sheet.create_css(None, false); + assert!(emitted.contains("@import \"base.css\";")); + assert!(emitted.contains("font-family:Fixture")); + assert!(emitted.contains("src:url(font.woff2)")); + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); +} diff --git a/libs/sheet/src/atom_identity_tests/first_value.rs b/libs/sheet/src/atom_identity_tests/first_value.rs new file mode 100644 index 000000000..3f7ca0281 --- /dev/null +++ b/libs/sheet/src/atom_identity_tests/first_value.rs @@ -0,0 +1,147 @@ +use super::*; + +fn token_theme(first: &str) -> Theme { + serde_json::from_value(serde_json::json!({ + "length": {"default": {"space": [first, "2px"], "zero": ["0px", "4px"], "nullable": [null, "3px", "4px"]}}, + "shadows": {"default": {"card": ["0 1px 2px black", "0 2px 4px black"], "space": ["0 3px 4px black", "0 4px 8px black"]}} + })) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn token_sheet(atom: bool) -> StyleSheet { + css::atom_hoist::restore_atom_plan(None); + css::atom_hoist::set_atom_hoist(atom.then_some(2)); + css::file_routes::reset_file_routes(); + css::file_map::reset_canonical_map(); + css::file_map::reset_file_map(); + css::class_map::reset_class_map(); + css::set_prefix(None); + css::debug::set_debug(false); + let mut sheet = StyleSheet::default(); + sheet.set_theme(token_theme("1px")); + sheet +} + +#[test] +#[serial] +fn first_value_refs_resolve_without_aliasing_responsive_variables() { + // Given + let source = r#"import {Box} from '@devup-ui/react'; export const A=<> + + + ;"#; + for atom in [false, true] { + let mut sheet = token_sheet(atom); + // When + let output = extractor::extract("test.tsx", source, extractor::ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")); + sheet.update_styles(&output.styles, "test.tsx", false); + // Then + let css = sheet.create_css(Some("test.tsx"), false); + let classes: Vec> = output + .code + .split("className=\"") + .skip(1) + .map(|code| { + code.split('"') + .next() + .unwrap_or_default() + .split_whitespace() + .collect() + }) + .collect(); + assert_eq!(classes.len(), 8, "{}", output.code); + assert_eq!(classes[0].len(), 2); + for refs in &classes[1..] { + assert_eq!(refs.len(), 1); + } + for (refs, declaration) in classes.iter().zip([ + "width:var(--space)", + "width:1px", + "box-shadow:0 1px 2px black", + "box-shadow:0 3px 4px black", + "width:0", + "width:0", + "width:var(--missing)", + "width:var(--nullable)", + ]) { + for name in refs { + assert!( + css.contains(&format!(".{name}{{{declaration}}}")), + "unresolved {name} -> {declaration}\n{}\n{css}", + output.code + ); + } + } + assert_ne!(classes[0][0], classes[1][0]); + assert_eq!(classes[4], classes[5]); + } + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + StyleSheet::default().set_theme(Theme::default()); +} + +fn extract_first_value(sheet: &mut StyleSheet) -> String { + let output = extractor::extract( + "test.tsx", + r#"import {Box} from '@devup-ui/react'; export const x=;"#, + extractor::ExtractOption::default(), + ) + .unwrap_or_else(|error| panic!("{error}")); + sheet.update_styles(&output.styles, "test.tsx", false); + output + .code + .split("className=\"") + .nth(1) + .and_then(|code| code.split('"').next()) + .unwrap_or_else(|| panic!("{}", output.code)) + .to_string() +} + +#[test] +#[serial] +fn first_value_names_track_theme_updates_without_aliasing_stale_literals() { + // Given + for atom in [false, true] { + let mut sheet = token_sheet(atom); + let old_name = extract_first_value(&mut sheet); + // When + sheet.set_theme(token_theme("8px")); + let updated_name = extract_first_value(&mut sheet); + // Then + assert_ne!(old_name, updated_name); + let css = sheet.create_css(Some("test.tsx"), false); + assert!(css.contains(&format!(".{old_name}{{width:1px}}")), "{css}"); + assert!( + css.contains(&format!(".{updated_name}{{width:8px}}")), + "{css}" + ); + } + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + StyleSheet::default().set_theme(Theme::default()); +} + +#[test] +#[serial] +fn first_value_rebuild_references_the_new_default_instead_of_cached_literal() { + // Given + for atom in [false, true] { + let mut old = token_sheet(atom); + let old_name = extract_first_value(&mut old); + let mut rebuilt = token_sheet(atom); + rebuilt.set_theme(token_theme("8px")); + // When + let new_name = extract_first_value(&mut rebuilt); + // Then + let css = rebuilt.create_css(Some("test.tsx"), false); + assert!(css.contains(&format!(".{new_name}{{width:8px}}")), "{css}"); + assert!(!css.contains("width:1px"), "{css}"); + if atom { + assert_ne!(old_name, new_name); + } + } + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + StyleSheet::default().set_theme(Theme::default()); +} diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index fb99cef61..330574c92 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -1,25 +1,26 @@ pub mod theme; +#[cfg(test)] +mod atom_identity_tests; + use crate::theme::Theme; use css::{ at_rule::{MediaCombination, combine_media_queries, query_order}, - atom_hoist::{atom_hoist_threshold, is_atom_hoist}, + atom_hoist::{atom_plan, freeze_atom_plan, is_atom_hoist, is_hoisted_bucket}, file_map::canonical, - file_routes::route_count_for_files, - get_custom_shorthand_names, sheet_to_classname, + get_custom_shorthand_names, style_selector::{ AtRule, AtRuleKind, StyleSelector, get_selector_order, global_selector_order, write_at_rule, }, - theme_tokens::{set_theme_token_levels, set_typography_keys}, + theme_tokens::{set_theme_token_levels, set_theme_token_values, set_typography_keys}, utils::compile_regex, write_merge_selector, }; use extractor::extract_style::ExtractStyleProperty; -use extractor::extract_style::extract_static_style::ThemeTokenResolution; use extractor::extract_style::extract_style_value::ExtractStyleValue; use extractor::extract_style::style_property::StyleProperty; use regex_lite::Regex; -use rustc_hash::{FxHashMap, FxHashSet}; +use rustc_hash::FxHashSet; use serde::de::Error; use serde::{Deserialize, Deserializer, Serialize}; use std::borrow::Cow; @@ -97,6 +98,9 @@ pub struct StyleSheetProperty { /// Declaration expanded from a conditional `typography` preset #[serde(rename = "t", default, skip_serializing_if = "std::ops::Not::not")] pub typography: bool, + /// Placement decided before extraction, independent of later configuration. + #[serde(rename = "h", default, skip_serializing_if = "std::ops::Not::not")] + pub hoisted: bool, } #[derive(Debug, Hash, Eq, PartialEq, Deserialize, Serialize)] @@ -132,6 +136,16 @@ impl Ord for StyleSheetProperty { } impl StyleSheetProperty { + fn emission_identity(&self) -> Self { + let mut emitted = self.clone(); + match emitted.selector.as_mut() { + Some(StyleSelector::Global(_, owner)) => owner.clear(), + Some(StyleSelector::At { file, .. }) => *file = None, + Some(StyleSelector::Selector(_)) | None => {} + } + emitted + } + fn same_rule(&self, other: &Self) -> bool { self.class_name == other.class_name && self.selector == other.selector } @@ -256,6 +270,8 @@ where } #[derive(Default, Deserialize, Serialize, Debug)] pub struct StyleSheet { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub atom_plan: Option>, #[serde(deserialize_with = "deserialize_btree_map_u8", default)] pub properties: BTreeMap, #[serde(default)] @@ -273,6 +289,22 @@ pub struct StyleSheet { } impl StyleSheet { + /// Borrow the sheet with its naming generation for build-cache fingerprints. + pub fn export_snapshot(&self) -> impl Serialize + '_ { + #[derive(Serialize)] + struct Export<'a> { + #[serde(flatten)] + sheet: &'a StyleSheet, + #[serde(rename = "atomNamingVersion")] + atom_naming_version: u8, + } + + Export { + sheet: self, + atom_naming_version: 1, + } + } + #[allow(clippy::too_many_arguments)] pub fn add_property( &mut self, @@ -319,6 +351,7 @@ impl StyleSheet { selector: selector.cloned(), layer: layer.map(ToString::to_string), typography: false, + hoisted: false, }, ) } @@ -328,8 +361,14 @@ impl StyleSheet { level: u8, style_order: Option, filename: Option<&str>, - prop: StyleSheetProperty, + mut prop: StyleSheetProperty, ) -> bool { + freeze_atom_plan(); + if self.atom_plan.is_none() { + self.atom_plan = atom_plan(); + } + prop.hoisted = + is_atom_hoist() && style_order != Some(0) && filename.is_some_and(is_hoisted_bucket); // register global css file for cache if let Some( StyleSelector::Global(_, file) @@ -476,10 +515,26 @@ impl StyleSheet { } pub fn set_theme(&mut self, theme: Theme) { - set_theme_token_levels( - theme.get_length_token_levels(), - theme.get_shadow_token_levels(), - ); + let length = theme.get_length_token_levels(); + let shadow = theme.get_shadow_token_levels(); + let first_length = length + .keys() + .filter_map(|token| { + theme + .get_default_length_value(token) + .map(|value| (token.clone(), value.to_string())) + }) + .collect(); + let first_shadow = shadow + .keys() + .filter_map(|token| { + theme + .get_default_shadow_value(token) + .map(|value| (token.clone(), value.to_string())) + }) + .collect(); + set_theme_token_levels(length, shadow); + set_theme_token_values(first_length, first_shadow); set_typography_keys(theme.typography.keys().cloned().collect()); self.theme = theme; } @@ -492,16 +547,18 @@ impl StyleSheet { ) -> (bool, bool) { let mut collected = false; let mut updated_base_style = false; - // Decouple class NAMING from property BUCKETING. atom_hoist uses GLOBAL - // (prefix-less, shared-registry) names like single_css, but still keeps - // per-file property buckets so create_css can route each atom to the - // global chunk or a per-route chunk based on its route usage. - let name_scope = if single_css || is_atom_hoist() { - None - } else { - Some(filename) - }; + freeze_atom_plan(); + if self.atom_plan.is_none() { + self.atom_plan = atom_plan(); + } + let name_scope = if single_css { None } else { Some(filename) }; let bucket_scope = if single_css { None } else { Some(filename) }; + let atom_mode = is_atom_hoist(); + let shared_bucket = single_css || (atom_mode && is_hoisted_bucket(filename)); + let updates_shared = |order: Option| { + order == Some(0) + || (atom_mode && (shared_bucket || order.is_some_and(|order| order != 255))) + }; // Names are handed out in the order styles are first seen, so the // set is walked in a fixed order, not the hash order. let mut ordered: Vec<&ExtractStyleValue> = styles.iter().collect(); @@ -552,52 +609,23 @@ impl StyleSheet { |layer| format!("{layer}.{TYPOGRAPHY_LAYER}"), )), typography: true, + hoisted: false, }, ) { collected = true; - if st.style_order() == Some(0) { + if updates_shared(st.style_order()) { updated_base_style = true; } } } } ExtractStyleValue::Static(st) => { - let is_first_value = - st.theme_token_resolution() == ThemeTokenResolution::FirstValue; - let resolved_value: Cow<'_, str> = if is_first_value { - if let Some(token) = st.value().strip_prefix('$') { - match st.property() { - "box-shadow" => self.theme.get_default_shadow_value(token), - _ => self.theme.get_default_length_value(token), - } - .map_or_else( - || Cow::Borrowed(st.value()), - |v| Cow::Owned(v.to_string()), - ) - } else { - Cow::Borrowed(st.value()) - } - } else { - Cow::Borrowed(st.value()) - }; - - let class_name = if is_first_value { - let selector = st.class_selector(); - sheet_to_classname( - st.property(), - st.level(), - Some(&resolved_value), - selector.as_deref(), - st.style_order(), - name_scope, - ) - } else { - match st.extract(name_scope) { - StyleProperty::ClassName(cls) - | StyleProperty::Variable { - class_name: cls, .. - } => cls, - } + let resolved_value = st.resolved_value(); + let class_name = match st.extract(name_scope) { + StyleProperty::ClassName(cls) + | StyleProperty::Variable { + class_name: cls, .. + } => cls, }; if self.add_property_with_layer( @@ -611,7 +639,7 @@ impl StyleSheet { st.layer(), ) { collected = true; - if st.style_order() == Some(0) { + if updates_shared(st.style_order()) { updated_base_style = true; } } @@ -652,7 +680,7 @@ impl StyleSheet { } { collected = true; - if dy.style_order() == Some(0) { + if updates_shared(dy.style_order()) { updated_base_style = true; } } @@ -686,6 +714,7 @@ impl StyleSheet { bucket_scope, ) { collected = true; + updated_base_style |= atom_mode && single_css; } } ExtractStyleValue::Css(cs) => { @@ -696,10 +725,20 @@ impl StyleSheet { } ExtractStyleValue::Typography(_) => {} ExtractStyleValue::Import(st) => { + let added = self + .imports + .get(st.file.as_str()) + .is_none_or(|imports| !imports.contains(st.url.as_str())); self.add_import(&st.file, &st.url); + updated_base_style |= atom_mode && added; } ExtractStyleValue::FontFace(font) => { + let added = self + .font_faces + .get(font.file.as_str()) + .is_none_or(|fonts| !fonts.contains(&font.properties)); self.add_font_face(&font.file, &font.properties); + updated_base_style |= atom_mode && added; } } } @@ -1085,38 +1124,6 @@ impl StyleSheet { &HEADER } - /// Compute the set of atom class names that should be hoisted into the - /// global stylesheet under atom-level hoisting. - /// - /// An atom (uniquely identified by its `class_name` under global naming) is - /// hoisted when the number of routes that transitively use it reaches the - /// configured threshold. Base styles (`style_order == 0`) are excluded - /// because they are already emitted globally and shared by every chunk. - fn compute_hoisted_atoms(&self, threshold: usize) -> FxHashSet { - // atom class_name -> set of files that reference it (order != 0) - let mut atom_files: FxHashMap<&str, FxHashSet<&str>> = FxHashMap::default(); - for (filename, property_map) in &self.properties { - for (style_order, level_map) in property_map { - if *style_order == 0 { - continue; - } - for props in level_map.values() { - for prop in props { - atom_files - .entry(prop.class_name.as_str()) - .or_default() - .insert(filename.as_str()); - } - } - } - } - atom_files - .into_iter() - .filter(|(_, files)| route_count_for_files(files.iter().copied()) >= threshold) - .map(|(class_name, _)| class_name.to_string()) - .collect() - } - #[must_use] pub fn create_css(&self, filename: Option<&str>, import_main_css: bool) -> String { let mut css = String::with_capacity(4096); @@ -1131,11 +1138,6 @@ impl StyleSheet { let write_global = filename.is_none(); - // Under atom-level hoisting, decide which atoms (order != 0) live in the - // shared global stylesheet vs. their per-route chunk. - let hoisted_atoms: Option> = - atom_hoist_threshold().map(|threshold| self.compute_hoisted_atoms(threshold)); - if write_global { let mut style_orders: BTreeSet = BTreeSet::new(); // Aggregate the `order == 0` base props by BORROWED reference rather than @@ -1229,7 +1231,20 @@ impl StyleSheet { // Collect layered styles while creating base CSS let mut layered_styles: LayeredStyles = BTreeMap::new(); - let base_css = self.create_style_with_layers(&base_styles, Some(&mut layered_styles)); + let base_css = if self.atom_plan.is_some() { + let base_styles: BTreeMap> = base_styles + .iter() + .map(|(level, props)| { + ( + *level, + props.iter().map(|prop| prop.emission_identity()).collect(), + ) + }) + .collect(); + self.create_style_with_layers(&base_styles, Some(&mut layered_styles)) + } else { + self.create_style_with_layers(&base_styles, Some(&mut layered_styles)) + }; if !base_css.is_empty() { push_fmt!(&mut css, "@layer b{{{base_css}}}"); } @@ -1255,7 +1270,7 @@ impl StyleSheet { // Atom hoisting: emit shared (hoisted) order!=0 atoms into the global // stylesheet, aggregated across every file and deduplicated by atom // identity (class_name). - if let Some(hoisted) = &hoisted_atoms { + { let mut aggregated: BTreeMap>> = BTreeMap::new(); for property_map in self.properties.values() { @@ -1265,13 +1280,13 @@ impl StyleSheet { } for (level, props) in level_map { for prop in props { - if hoisted.contains(&prop.class_name) { + if prop.hoisted { aggregated .entry(*style_order) .or_default() .entry(*level) .or_default() - .insert(prop.clone()); + .insert(prop.emission_identity()); } } } @@ -1327,23 +1342,17 @@ impl StyleSheet { } // Under atom hoisting, hoisted atoms were emitted globally; the // per-route chunk keeps only its route-private atoms. - let current_css = if let Some(hoisted) = &hoisted_atoms { + let current_css = { // Common case: none of this map's atoms were hoisted, so the // filtered map would equal the original — skip the clone-collect // entirely and borrow `map` directly. - let any_hoisted = map - .values() - .flatten() - .any(|prop| hoisted.contains(&prop.class_name)); + let any_hoisted = map.values().flatten().any(|prop| prop.hoisted); if any_hoisted { let filtered: BTreeMap> = map .iter() .filter_map(|(level, props)| { - let kept: FxHashSet = props - .iter() - .filter(|prop| !hoisted.contains(&prop.class_name)) - .cloned() - .collect(); + let kept: FxHashSet = + props.iter().filter(|prop| !prop.hoisted).cloned().collect(); (!kept.is_empty()).then_some((*level, kept)) }) .collect(); @@ -1354,8 +1363,6 @@ impl StyleSheet { } else { self.create_style(map) } - } else { - self.create_style(map) }; if !current_css.is_empty() { @@ -1423,12 +1430,6 @@ mod tests { assert_debug_snapshot!(sheet.create_css(None, false).split("*/").nth(1).unwrap()); } - // Atom-level hoisting emission. Without an atom-hoist test these branches in - // compute_hoisted_atoms / create_css were uncovered: - // * compute_hoisted_atoms skips style_order 0 - // * the global hoist emission skips style_order 0 - // * the global hoist emission wraps a hoisted order != 255 in `@layer o{N}` - // * the per-route emission skips a chunk whose atoms were all hoisted away #[test] #[serial] fn create_css_atom_hoisting_emission() { @@ -1439,12 +1440,11 @@ mod tests { reset_class_map(); reset_file_map(); reset_file_routes(); - - // a.tsx and b.tsx each own one distinct route, so an atom referenced by - // BOTH is reached by 2 routes (>= threshold) and gets hoisted. + css::atom_hoist::restore_atom_plan(None); let mut routes = HashMap::new(); - routes.insert("a.tsx".to_string(), HashSet::from([0u32])); - routes.insert("b.tsx".to_string(), HashSet::from([1u32])); + routes.insert("a.tsx".to_string(), HashSet::from([0u32, 1])); + routes.insert("b.tsx".to_string(), HashSet::from([0u32, 1])); + routes.insert("private.tsx".to_string(), HashSet::from([0u32])); set_file_routes(routes); set_atom_hoist(Some(2)); @@ -1481,8 +1481,6 @@ mod tests { Some("b.tsx"), Some("lyr"), ); - // Non-hoisted responsive at-rule atom (only a.tsx): emitted in a.tsx's - // chunk via the break-point at-rule path (level != 0 -> break_point set). let at = StyleSelector::At { kind: AtRuleKind::Media, query: "(hover:hover)".to_string(), @@ -1497,10 +1495,9 @@ mod tests { "blue", Some(&at), Some(255), - Some("a.tsx"), + Some("private.tsx"), ); - // Global stylesheet: runs compute_hoisted_atoms + the hoist emission. let global_css = sheet.create_css(None, false); assert!( global_css.contains("@layer o1"), @@ -1518,6 +1515,7 @@ mod tests { !chunk_css.contains("padding:1px"), "hoisted padding atom must not be in the chunk: {chunk_css}" ); + let chunk_css = sheet.create_css(Some("private.tsx"), false); // The responsive at-rule wrapper AND its property must both be emitted // (exercises the break-point at-rule path). assert!( @@ -1531,6 +1529,7 @@ mod tests { set_atom_hoist(None); reset_file_routes(); + css::atom_hoist::restore_atom_plan(None); } #[test] @@ -1540,7 +1539,9 @@ mod tests { use css::file_routes::{get_file_routes, set_file_routes}; use std::collections::{HashMap, HashSet}; - let previous_threshold = atom_hoist_threshold(); + let previous_threshold = css::atom_hoist::atom_hoist_threshold(); + let previous_plan = atom_plan(); + css::atom_hoist::restore_atom_plan(None); let previous_routes = get_file_routes(); set_atom_hoist(Some(1)); set_file_routes(HashMap::from([( @@ -1566,6 +1567,7 @@ mod tests { let css = sheet.create_css(None, false); set_atom_hoist(previous_threshold); set_file_routes(previous_routes); + css::atom_hoist::restore_atom_plan(previous_plan); assert!( css.contains("@layer o2{@layer components{div{border-radius:9px}}}"), @@ -2908,7 +2910,12 @@ mod tests { ); let mut sheet = StyleSheet::default(); - let output = extract("index.tsx", "import {Box,globalCss,keyframes,Flex} from '@devup-ui/core';;keyframes({from:{opacity:0},to:{opacity:1}});;globalCss`div{color:red}`;globalCss({div:{display:'flex'},imports:['https://test.com/a.css'],fontFaces:[{fontFamily:'Roboto',src:'url(/fonts/Roboto-Regular.ttf)'}]})", ExtractOption { package: "@devup-ui/core".to_string(), css_dir: "@devup-ui/core".to_string(), single_css: true, import_main_css: false, import_aliases: std::collections::HashMap::new() }).unwrap(); + let output = extract( + "index.tsx", + "import {Box,globalCss,keyframes,Flex} from '@devup-ui/core';;keyframes({from:{opacity:0},to:{opacity:1}});;globalCss`div{color:red}`;globalCss({div:{display:'flex'},imports:['https://test.com/a.css'],fontFaces:[{fontFamily:'Roboto',src:'url(/fonts/Roboto-Regular.ttf)'}]})", + ExtractOption { package: "@devup-ui/core".to_string(), css_dir: "@devup-ui/core".to_string(), single_css: true, import_main_css: false, import_aliases: std::collections::HashMap::new() }, + ) + .unwrap(); sheet.update_styles(&output.styles, "index.tsx", true); assert_debug_snapshot!(sheet.create_css(None, true).split("*/").nth(1).unwrap()); } @@ -3164,6 +3171,7 @@ mod tests { selector: None, layer: None, typography: false, + hoisted: false, }; assert_eq!(make("color", "red").cmp(&make("color", "red")), Equal); assert!(make("color", "red") < make("color", "white")); @@ -3182,6 +3190,7 @@ mod tests { selector, layer: None, typography: false, + hoisted: false, }; let hover = || Some(StyleSelector::Selector("&:hover".to_string())); @@ -3233,11 +3242,20 @@ mod tests { #[test] #[serial] - fn test_compute_hoisted_atoms_skips_base_style_order() { + fn base_styles_emit_globally_without_atom_promotion() { let mut sheet = StyleSheet::default(); sheet.add_property("base", "color", 0, "red", None, Some(0), Some("test.tsx")); - assert!(sheet.compute_hoisted_atoms(0).is_empty()); + assert!( + sheet + .create_css(None, false) + .contains(concat!(".base{", "color:red}")) + ); + assert!( + !sheet + .create_css(Some("test.tsx"), false) + .contains("color:red") + ); } #[test] @@ -3680,13 +3698,7 @@ mod tests { let output = extract( "global.tsx", "import {globalCss} from '@devup-ui/core';globalCss({ body: { color: '$text', border: '1px solid $line.100' } })", - ExtractOption { - package: "@devup-ui/core".to_string(), - css_dir: "@devup-ui/core".to_string(), - single_css: true, - import_main_css: false, - import_aliases: std::collections::HashMap::new(), - }, + ExtractOption { package: "@devup-ui/core".to_string(), css_dir: "@devup-ui/core".to_string(), single_css: true, import_main_css: false, import_aliases: std::collections::HashMap::new() }, ) .unwrap(); sheet.update_styles(&output.styles, "global.tsx", true); @@ -3704,13 +3716,7 @@ mod tests { let output = extract( "global.tsx", "import {globalCss} from '@devup-ui/core';globalCss({ body: { color: 'red', _motionReduce: { transition: 'none' } } })", - ExtractOption { - package: "@devup-ui/core".to_string(), - css_dir: "@devup-ui/core".to_string(), - single_css: true, - import_main_css: false, - import_aliases: std::collections::HashMap::new(), - }, + ExtractOption { package: "@devup-ui/core".to_string(), css_dir: "@devup-ui/core".to_string(), single_css: true, import_main_css: false, import_aliases: std::collections::HashMap::new() }, ) .unwrap(); sheet.update_styles(&output.styles, "global.tsx", true); diff --git a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap index bc58b6e61..c0d70f0ab 100644 --- a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap +++ b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap @@ -3,6 +3,7 @@ source: libs/sheet/src/lib.rs expression: sheet --- StyleSheet { + atom_plan: None, properties: { "": { 255: { @@ -14,6 +15,7 @@ StyleSheet { selector: None, layer: None, typography: false, + hoisted: false, }, }, }, From 818de80af54172ee6f6cb97907a6eba24af56f39 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 09:41:43 +0900 Subject: [PATCH 08/14] test(next-plugin): verify real atom hoist loaders across builds Refs #694, #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/atom-hoist-harness.ts | 122 ++++++++++++++++++ .../src/__tests__/atom-hoist.test.ts | 39 ++++++ 2 files changed, 161 insertions(+) create mode 100644 packages/next-plugin/src/__tests__/atom-hoist-harness.ts create mode 100644 packages/next-plugin/src/__tests__/atom-hoist.test.ts diff --git a/packages/next-plugin/src/__tests__/atom-hoist-harness.ts b/packages/next-plugin/src/__tests__/atom-hoist-harness.ts new file mode 100644 index 000000000..5414aae4a --- /dev/null +++ b/packages/next-plugin/src/__tests__/atom-hoist-harness.ts @@ -0,0 +1,122 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +export function builtPluginEntry(packageName: string, entry = 'index.mjs') { + return JSON.stringify( + pathToFileURL( + resolve(import.meta.dir, '../../../', packageName, 'dist', entry), + ).href, + ) +} + +// A fresh process keeps bun:test's package mocks and WASM globals out of the +// integration. The plugin, not the harness, configures route reach and hoisting. +export function runAtomHoistIntegration(setup: string) { + const root = mkdtempSync(join(tmpdir(), 'devup-atom-hoist-')) + const run = (reverse: boolean) => + execFileSync( + process.execPath, + [ + '--eval', + String.raw` +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +const root = ${JSON.stringify(root)}; +const original = process.cwd(); +const sources = { + 'src/shared.tsx': 'import { Box } from "@devup-ui/react"; export const Shared = () => ;', + 'src/app/a/page.tsx': 'import { Box } from "@devup-ui/react"; import { Shared } from "../../shared"; export default function A() { return <>; }', + 'src/app/b/page.tsx': 'import { Box } from "@devup-ui/react"; import { Shared } from "../../shared"; export default function B() { return <>; }', +}; +const files = Object.keys(sources); +const cssDir = join(root, 'df/devup-ui'); +rmSync(join(root, 'df'), { recursive: true, force: true }); +for (const [file, code] of Object.entries(sources)) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), code); +} +mkdirSync(cssDir, { recursive: true }); +writeFileSync(join(root, 'devup.json'), '{}'); +writeFileSync(join(root, 'tsconfig.json'), '{}'); +writeFileSync(join(root, 'package.json'), '{"name":"atom-hoist-fixture","private":true}'); +process.chdir(root); +try { +${setup} + const outputs = {}; + for (const file of ${reverse} ? [...files].reverse() : files) { + outputs[file] = await transform(file, sources[file]); + if (file === 'src/shared.tsx') { + const writtenBase = readFileSync(join(cssDir, 'devup-ui.css'), 'utf8'); + assert.match(writtenBase, /color:red(?:;|})/, 'shared CSS dirty signal must reach disk'); + assert.match(writtenBase, /padding:16px(?:;|})/); + } + } + const base = await loadCss('devup-ui.css'); + const sheets = {}; + const classes = code => [...code.matchAll(/className\s*=\s*(?:\{\s*)?["']([^"']+)["']/g)] + .flatMap(match => match[1].trim().split(/\s+/)); + const declarations = css => [...css.matchAll(/\.([^\s.{:]+)\{([^{}]+)\}/g)] + .map(match => [match[1], match[2].replace(/;$/, '')]); + const sharedClasses = classes(outputs['src/shared.tsx']); + assert.equal(sharedClasses.length, 2, 'shared JSX must really be transformed'); + for (const name of sharedClasses) { + assert.equal(declarations(base).filter(([key]) => key === name).length, 1, + 'shared atom must be defined exactly once'); + } + assert.deepEqual(declarations(base).map(([, value]) => value).sort(), ['color:red', 'padding:16px']); + for (const file of files) { + const code = outputs[file]; + const imports = [...code.matchAll(/["']([^"']*devup-ui(?:-\d+)?\.css(?:\?fileNum=\d+)?)["']/g)] + .map(match => match[1].split('/').pop()); + assert.ok(imports.length > 0, 'transformed JSX must import its CSS'); + const loaded = []; + for (const name of imports) { + sheets[name] = await loadCss(name); + if (name !== 'devup-ui.css') { + assert.ok(declarations(sheets[name]).every(([, value]) => + value !== 'color:red' && value !== 'padding:16px'), + 'hoisted declarations must not be duplicated in local sheets'); + } + loaded.push(sheets[name]); + } + const local = loaded.join('\n'); + const expected = file === 'src/shared.tsx' ? ['color:red', 'padding:16px'] + : [file.includes('/a/') ? 'opacity:.25' : 'opacity:.75']; + const generated = classes(code); + assert.equal(generated.length, expected.length); + const values = generated.flatMap(name => { + const matches = declarations(base + '\n' + local).filter(([key]) => key === name); + assert.ok(matches.length > 0, 'every generated JSX class must exist in loaded CSS: ' + name); + return [...new Set(matches.map(([, value]) => value))]; + }); + assert.deepEqual(values.sort(), expected.sort()); + if (file !== 'src/shared.tsx') { + assert.ok(generated.every(name => !sharedClasses.includes(name))); + assert.ok(declarations(local).some(([, value]) => value === expected[0]), 'private styles stay local'); + } + } + const clean = css => css.replace(/\/\*[\s\S]*?\*\//g, '').trim(); + console.log(JSON.stringify({ + classes: files.map(file => classes(outputs[file])), + base: clean(base), + sheets: Object.fromEntries(Object.entries(sheets).sort().map(([name, css]) => [name, clean(css)])), + })); + await finish(); +} finally { + process.chdir(original); +} +process.exit(0); +`, + ], + { encoding: 'utf8', timeout: 30_000, env: process.env }, + ).trim() + try { + return [run(false), run(true), run(false)] as const + } finally { + rmSync(root, { recursive: true, force: true }) + } +} diff --git a/packages/next-plugin/src/__tests__/atom-hoist.test.ts b/packages/next-plugin/src/__tests__/atom-hoist.test.ts new file mode 100644 index 000000000..758b21fdc --- /dev/null +++ b/packages/next-plugin/src/__tests__/atom-hoist.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from 'bun:test' + +import { builtPluginEntry, runAtomHoistIntegration } from './atom-hoist-harness' + +it('loads real hoisted JSX atoms and local CSS in either loader order and fresh builds', () => { + // Given: two App Router pages reach the same styled module. + const setup = ` +process.env.TURBOPACK = '1'; +process.env.NODE_ENV = 'development'; +const { existsSync, symlinkSync } = await import('node:fs'); +const { fileURLToPath } = await import('node:url'); +mkdirSync(join(root, 'node_modules/@devup-ui'), { recursive: true }); +const installedPlugin = join(root, 'node_modules/@devup-ui/next-plugin'); +if (!existsSync(installedPlugin)) { + symlinkSync(dirname(dirname(fileURLToPath(${builtPluginEntry('next-plugin')}))), + installedPlugin, 'junction'); +} +const { DevupUI } = await import(${builtPluginEntry('next-plugin')}); +const { default: loader } = await import(${builtPluginEntry('next-plugin', 'loader.mjs')}); +const { default: cssLoader } = await import(${builtPluginEntry('next-plugin', 'css-loader.mjs')}); +const config = DevupUI({}, { atomHoist: 2, cssDir, distDir: join(root, 'df') }); +const rules = config.turbopack.rules; +const jsOptions = rules['*.{tsx,ts,jsx,js,mjs}'].loaders[0].options; +const cssOptions = rules['./df/devup-ui/*.css'][0].options; +const invoke = (fn, options, request) => new Promise((resolve, reject) => { + fn.call({ resourcePath: request.resourcePath, getOptions: () => options, addDependency() {}, + async: () => (error, result) => error ? reject(error) : resolve(result), + }, request.source); +}); +const transform = (file, source) => invoke(loader, jsOptions, { resourcePath: join(root, file), source }); +const loadCss = name => invoke(cssLoader, cssOptions, { resourcePath: join(cssDir, name), source: '' }); +const finish = () => {}; +` + // When: real plugin configuration and loaders run in isolated processes. + const [forward, backward, rebuilt] = runAtomHoistIntegration(setup) + // Then: declarations match JSX; build ordering and fresh state change nothing. + expect(backward).toBe(forward) + expect(rebuilt).toBe(forward) +}, 120_000) From 8689baae4bce2197a7a67ac39618bf346e4bc7bc Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 09:44:41 +0900 Subject: [PATCH 09/14] test(rsbuild-plugin): verify real atom hoist hooks across builds Refs #694, #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/atom-hoist.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 packages/rsbuild-plugin/src/__tests__/atom-hoist.test.ts diff --git a/packages/rsbuild-plugin/src/__tests__/atom-hoist.test.ts b/packages/rsbuild-plugin/src/__tests__/atom-hoist.test.ts new file mode 100644 index 000000000..c9d09df3f --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/atom-hoist.test.ts @@ -0,0 +1,44 @@ +import { expect, it } from 'bun:test' + +import { + builtPluginEntry, + runAtomHoistIntegration, +} from '../../../next-plugin/src/__tests__/atom-hoist-harness' + +it('serves real hoisted JSX atoms and private sheets in either transform order and fresh builds', () => { + // Given: two entries reach shared atoms, while their opacity stays private. + const setup = ` +const { DevupUI } = await import(${builtPluginEntry('rsbuild-plugin')}); +const transforms = []; +let closeBuild; +let beforeBuild; +await DevupUI({ atomHoist: 2, cssDir, distDir: join(root, 'df') }).setup({ + context: { rootPath: root }, + onCloseBuild(fn) { closeBuild = fn; }, + onBeforeBuild(fn) { beforeBuild = fn; }, + transform(options, handler) { transforms.push({ options, handler }); }, + modifyRspackConfig() {}, + modifyRsbuildConfig() {}, +}); +const jsTransform = transforms.find(item => item.options.test instanceof RegExp).handler; +const cssTransform = transforms.find(item => item.options.test === cssDir).handler; +const transform = async (file, code) => { + const result = await jsTransform({ code, resourcePath: join(root, file), addDependency() {} }); + return result.code; +}; +const loadCss = name => cssTransform({ resourcePath: join(cssDir, name), environment: { name: 'web' } }); +const finish = async () => { + const initial = await loadCss('devup-ui.css'); + beforeBuild({ environments: { web: { entry: { + a: join(root, files[1]), b: join(root, files[2]), + } } } }); + assert.equal(await loadCss('devup-ui.css'), initial, 'prewarm must not duplicate shared atoms'); + closeBuild(); +}; +` + // When: real setup registers and executes source/CSS transforms, then prewarm. + const [forward, backward, rebuilt] = runAtomHoistIntegration(setup) + // Then: loaded declarations match JSX regardless of ordering or fresh state. + expect(backward).toBe(forward) + expect(rebuilt).toBe(forward) +}, 120_000) From d859fb4017ef31c461e1af857aee6516d1ab7cb4 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 09:47:33 +0900 Subject: [PATCH 10/14] test(vite-plugin): verify real atom hoist transforms across builds Refs #694, #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/atom-hoist.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 packages/vite-plugin/src/__tests__/atom-hoist.test.ts diff --git a/packages/vite-plugin/src/__tests__/atom-hoist.test.ts b/packages/vite-plugin/src/__tests__/atom-hoist.test.ts new file mode 100644 index 000000000..fb46fcb35 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/atom-hoist.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from 'bun:test' + +import { + builtPluginEntry, + runAtomHoistIntegration, +} from '../../../next-plugin/src/__tests__/atom-hoist-harness' + +it('emits real hoisted JSX atoms and private CSS in either transform order and fresh builds', () => { + // Given: two explicit entries reach a shared styled module. + const setup = ` +const { DevupUI } = await import(${builtPluginEntry('vite-plugin')}); +const plugin = DevupUI({ atomHoist: 2, cssDir, distDir: join(root, 'df') }); +await plugin.configResolved({ root, command: 'build', build: { + rollupOptions: { input: [join(root, files[1]), join(root, files[2])] }, +} }); +const context = { addWatchFile() {} }; +const transform = async (file, code) => { + const result = await plugin.transform.call(context, code, join(root, file).replaceAll('\\\\', '/')); + return result.code; +}; +const loadCss = name => plugin.load(join(cssDir, name)); +const finish = async () => { + const asset = { type: 'asset', name: 'devup-ui.css', source: 'stale', fileName: 'shared.css' }; + await plugin.generateBundle.call({}, {}, { 'shared.css': asset }); + assert.equal(asset.source, await loadCss('devup-ui.css'), 'emitted asset must contain the finished shared sheet'); + plugin.closeBundle(); +}; +` + // When: real configResolved, transform, load, and generateBundle hooks run. + const [forward, backward, rebuilt] = runAtomHoistIntegration(setup) + // Then: disk writes consume shared dirtiness and all loaded classes agree. + expect(backward).toBe(forward) + expect(rebuilt).toBe(forward) +}, 120_000) From 1999ddef714bd02f0380852bb4efc4fc5f8085ab Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 09:50:29 +0900 Subject: [PATCH 11/14] docs(plugins): explain frozen canonical bucket hoisting Refs #694, #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/next-plugin/src/plugin.ts | 19 ++++++++++++------- packages/rsbuild-plugin/src/plugin.ts | 10 +++++++--- packages/vite-plugin/src/plugin.ts | 10 +++++++--- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 542c00e74..f5514c85c 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -42,7 +42,10 @@ import { loadWasm, loadWebpackPlugin } from './wasm' /** Options accepted by the Next.js integration. */ export type DevupUINextPluginOptions = Partial & { - /** Share atoms reached by at least this many routes. */ + /** + * Share atoms of canonical buckets whose predeclared route reach is at least + * this many routes (clamped to >= 2). Other buckets keep per-file names. + */ atomHoist?: number } @@ -273,15 +276,17 @@ export function DevupUI( // Pre-pass: single-importer collapse ALWAYS runs (files with exactly one // importer merge into that importer's bucket, so their identical atoms share - // one class). Atom-level hoisting COMPOSES on top: an atom reached by - // >= atomHoist distinct routes is emitted once into the shared devup-ui.css. + // one class). Atom-level hoisting COMPOSES on top: a canonical bucket whose + // predeclared route reach is >= atomHoist is eligible, and only its atoms + // get shared content names and land in the shared devup-ui.css. // // The two compose because both are keyed by the canonical bucket: the engine // keys property buckets by canonical(filename), and the route-reach map below - // is folded onto the SAME canonical bucket — so route_count_for_files() looks - // atoms up by bucket and the lookup hits. `atomHoist` must be configured - // BEFORE any extraction so atoms receive global (shared) class names; the - // coordinator shares this WASM instance, so it applies to every /extract. + // is folded onto the SAME canonical bucket. Import routes, then set the + // threshold, BEFORE any extraction: eligibility freezes there. Private or + // unmapped buckets keep per-file names, and reach seen later cannot promote + // or rename them until resetBuildState. The coordinator shares this WASM + // instance, so it applies to every /extract. const atomMode = atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 const extract = sourceMap ? codeExtract : codeExtractWithoutSourceMap diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index ad2793ece..0d0499b26 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -54,8 +54,10 @@ export interface DevupUIRsbuildPluginOptions { prefix?: string shorthands?: CustomShorthands /** - * Atom-level route-aware hoisting threshold (min routes sharing an atom for it - * to hoist into the shared devup-ui.css; clamped to >= 2; omit to disable). + * Atom-level route-aware hoisting threshold (min predeclared routes reaching a + * canonical bucket for its atoms to get shared names in the shared + * devup-ui.css; clamped to >= 2; omit to disable). Atoms of other buckets + * keep per-file names, even when identical atoms appear in several files. * Opt-in: when set, single-importer collapse + atom hoisting are enabled and * per-route CSS is served via getCss(fileNum). "Routes" are inferred from the * import graph (entry points and dynamic-import targets). For a single-entry @@ -169,7 +171,9 @@ export const DevupUI = ({ if (!extractCss) return // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any - // transform so atoms receive global (shared) class names. Composes with + // transform: routes are imported, then the threshold is set, and bucket + // eligibility freezes at the first extraction. Late reach cannot promote + // or rename a bucket until resetBuildState. Composes with // single-importer collapse (both keyed by the canonical bucket). rsbuild // passes the ABSOLUTE resourcePath to codeExtract, so the graph maps use // absolute keys (keyBy: 'absolute') and the extraction filename is diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index cb10d6fea..f40c257f1 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -172,8 +172,10 @@ export interface DevupUIPluginOptions { prefix?: string shorthands?: CustomShorthands /** - * Atom-level route-aware hoisting threshold (min routes sharing an atom for - * it to hoist into the shared devup-ui.css; clamped to >= 2; omit to disable). + * Atom-level route-aware hoisting threshold (min predeclared routes reaching + * a canonical bucket for its atoms to get shared names in the shared + * devup-ui.css; clamped to >= 2; omit to disable). Atoms of other buckets + * keep per-file names, even when identical atoms appear in several files. * Opt-in: when set, single-importer collapse + atom hoisting are enabled for * this build. "Routes" are inferred from the import graph (entry points and * dynamic-import targets). @@ -290,7 +292,9 @@ export function DevupUI({ }) // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any - // transform so atoms receive global (shared) class names. Composes with + // transform: routes are imported, then the threshold is set, and bucket + // eligibility freezes at the first extraction. Late reach cannot promote + // or rename a bucket until resetBuildState. Composes with // single-importer collapse: both are keyed by the canonical bucket. Vite // passes the ABSOLUTE module id to codeExtract, so the graph maps use // absolute keys (keyBy: 'absolute') to match the engine's bucket keys. From ada56b56e67d9d7b23063e90a993ca40f6f360c6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 09:53:23 +0900 Subject: [PATCH 12/14] chore(changepack): patch atom hoist naming and plugin contracts Refs #694, #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_atom_hoist_names.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changepacks/changepack_log_atom_hoist_names.json diff --git a/.changepacks/changepack_log_atom_hoist_names.json b/.changepacks/changepack_log_atom_hoist_names.json new file mode 100644 index 000000000..83747a572 --- /dev/null +++ b/.changepacks/changepack_log_atom_hoist_names.json @@ -0,0 +1,10 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch" + }, + "note": "Make atomHoist class names deterministic and match generated references to emitted CSS, freeze hoisting from predeclared route reach, and preserve dynamic !important, keyframes, and first-value responsive-token identities. No new build errors.", + "date": "2026-10-01T00:00:00.000Z" +} From 13ccaea56557f17a3ca0dd5e6283a40347393462 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 12:38:15 +0900 Subject: [PATCH 13/14] test(vite-plugin): follow array and restore plugin contract Refs #694, #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/vite-plugin/src/__tests__/atom-hoist.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vite-plugin/src/__tests__/atom-hoist.test.ts b/packages/vite-plugin/src/__tests__/atom-hoist.test.ts index fb46fcb35..dda580812 100644 --- a/packages/vite-plugin/src/__tests__/atom-hoist.test.ts +++ b/packages/vite-plugin/src/__tests__/atom-hoist.test.ts @@ -9,7 +9,7 @@ it('emits real hoisted JSX atoms and private CSS in either transform order and f // Given: two explicit entries reach a shared styled module. const setup = ` const { DevupUI } = await import(${builtPluginEntry('vite-plugin')}); -const plugin = DevupUI({ atomHoist: 2, cssDir, distDir: join(root, 'df') }); +const [plugin, restorePlugin] = DevupUI({ atomHoist: 2, cssDir, distDir: join(root, 'df') }); await plugin.configResolved({ root, command: 'build', build: { rollupOptions: { input: [join(root, files[1]), join(root, files[2])] }, } }); @@ -22,6 +22,7 @@ const loadCss = name => plugin.load(join(cssDir, name)); const finish = async () => { const asset = { type: 'asset', name: 'devup-ui.css', source: 'stale', fileName: 'shared.css' }; await plugin.generateBundle.call({}, {}, { 'shared.css': asset }); + restorePlugin.generateBundle.handler({}, { 'shared.css': asset }); assert.equal(asset.source, await loadCss('devup-ui.css'), 'emitted asset must contain the finished shared sheet'); plugin.closeBundle(); }; From aaab75652615aedae65ec102e3576ea05b3c3c33 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 13:20:32 +0900 Subject: [PATCH 14/14] test(css): cover route unions and restored mixed atom placement Refs #694, #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/css/src/file_routes.rs | 21 +++++ libs/sheet/src/atom_identity_tests.rs | 1 + .../atom_identity_tests/mixed_placement.rs | 76 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 libs/sheet/src/atom_identity_tests/mixed_placement.rs diff --git a/libs/css/src/file_routes.rs b/libs/css/src/file_routes.rs index 341660f3a..073bb7327 100644 --- a/libs/css/src/file_routes.rs +++ b/libs/css/src/file_routes.rs @@ -135,6 +135,27 @@ mod tests { reset_file_routes(); } + #[test] + #[serial] + fn route_count_includes_remaining_sets_when_three_registered_files_overlap() { + // Given + set_file_routes(HashMap::from([ + ("a.tsx".to_string(), HashSet::from([0, 1])), + ("b.tsx".to_string(), HashSet::from([1, 2])), + ("c.tsx".to_string(), HashSet::from([2, 3])), + ])); + + // When / Then + for files in [ + ["a.tsx", "b.tsx", "c.tsx", "unknown.tsx"], + ["unknown.tsx", "c.tsx", "b.tsx", "a.tsx"], + ] { + assert_eq!(route_count_for_files(files), 4); + } + + reset_file_routes(); + } + #[test] #[serial] fn test_route_count_for_files_without_registered_routes() { diff --git a/libs/sheet/src/atom_identity_tests.rs b/libs/sheet/src/atom_identity_tests.rs index be0434229..45cb4b9ce 100644 --- a/libs/sheet/src/atom_identity_tests.rs +++ b/libs/sheet/src/atom_identity_tests.rs @@ -4,6 +4,7 @@ use css::style_selector::{AtRule, AtRuleKind}; use serial_test::serial; mod first_value; +mod mixed_placement; #[test] #[serial] diff --git a/libs/sheet/src/atom_identity_tests/mixed_placement.rs b/libs/sheet/src/atom_identity_tests/mixed_placement.rs new file mode 100644 index 000000000..af3615b71 --- /dev/null +++ b/libs/sheet/src/atom_identity_tests/mixed_placement.rs @@ -0,0 +1,76 @@ +use super::*; +use std::collections::{HashMap, HashSet}; + +#[test] +#[serial] +fn restored_legacy_rule_stays_local_when_new_rule_is_hoisted_in_same_bucket() { + // Given + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + css::file_routes::reset_file_routes(); + css::file_map::reset_canonical_map(); + css::file_map::reset_file_map(); + css::class_map::reset_class_map(); + css::set_prefix(None); + css::debug::set_debug(false); + let mut legacy = StyleSheet::default(); + legacy.set_theme(Theme::default()); + legacy.add_property("legacy", "color", 0, "red", None, None, Some("mixed.tsx")); + let persisted = serde_json::to_string(&legacy).unwrap_or_else(|error| panic!("{error}")); + assert!(!persisted.contains("\"h\":"), "{persisted}"); + let mut sheet: StyleSheet = + serde_json::from_str(&persisted).unwrap_or_else(|error| panic!("{error}")); + css::file_routes::set_file_routes(HashMap::from([( + "mixed.tsx".to_string(), + HashSet::from([0, 1]), + )])); + css::atom_hoist::set_atom_hoist(Some(2)); + + // When + let output = extractor::extract( + "mixed.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + extractor::ExtractOption::default(), + ) + .unwrap_or_else(|error| panic!("{error}")); + sheet.update_styles(&output.styles, "mixed.tsx", false); + let shared = sheet.create_css(None, false); + let local = sheet.create_css(Some("mixed.tsx"), false); + + // Then + let class_name = output + .code + .split("className=\"") + .nth(1) + .and_then(|code| code.split('"').next()) + .unwrap_or_else(|| panic!("{}", output.code)); + assert_eq!(class_name.split_whitespace().count(), 1, "{}", output.code); + assert_ne!(class_name, "legacy"); + let new_rule = format!(".{class_name}{{background:blue}}"); + assert_eq!(shared.matches(&new_rule).count(), 1, "{shared}"); + assert!(!shared.contains(".legacy"), "{shared}"); + assert_eq!( + local, + format!("{}.legacy{{color:red}}", StyleSheet::create_header()) + ); + assert!(!local.contains(&format!(".{class_name}")), "{local}"); + let bucket = &sheet.properties["mixed.tsx"][&255][&0]; + let dispositions: BTreeSet<_> = bucket + .iter() + .map(|prop| (prop.class_name.as_str(), prop.hoisted)) + .collect(); + assert_eq!( + dispositions, + BTreeSet::from([("legacy", false), (class_name, true)]) + ); + + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + css::file_routes::reset_file_routes(); + css::file_map::reset_canonical_map(); + css::file_map::reset_file_map(); + css::class_map::reset_class_map(); + css::set_prefix(None); + css::debug::set_debug(false); + StyleSheet::default().set_theme(Theme::default()); +}