From b6984e6fc9c61eb57e8db182e1daabc14786cb60 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 1 Oct 2026 01:50:40 +0900 Subject: [PATCH 01/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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()); +} From 8e31bbc1af0aefa94a93d9e631888e684827f020 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 04:22:33 +0900 Subject: [PATCH 15/20] fix(extractor): stabilize environment names and authored evaluation Refs #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...angepack_log_environment_stable_names.json | 12 + bindings/devup-ui-wasm/src/atom_tests.rs | 94 +++- .../devup-ui-wasm/src/atom_tests/lifecycle.rs | 94 +++- bindings/devup-ui-wasm/src/cache_names.rs | 99 ++++ .../devup-ui-wasm/src/cache_protocol_tests.rs | 59 +- .../src/content_location_contract_tests.rs | 264 +++++++++ .../devup-ui-wasm/src/content_name_tests.rs | 266 +++++++++ bindings/devup-ui-wasm/src/lib.rs | 63 ++- .../devup-ui-wasm/tests/content-names.mjs | 104 ++++ .../tests/site_variable_names.rs | 170 ++++++ .../tests/site_variable_names/sparse.rs | 261 +++++++++ bindings/devup-ui-wasm/tests/source_naming.rs | 169 ++++++ .../devup-ui-wasm/tests/value-evaluation.mjs | 318 +++++++++++ e2e/environment-dynamic-driver.ts | 63 +++ e2e/environment-dynamic-sites.spec.ts | 204 +++++++ e2e/landing-zero-runtime.spec.ts | 17 +- libs/css/src/class_map.rs | 78 +++ libs/css/src/content_hash.rs | 269 +++++++++ libs/css/src/content_name.rs | 207 +++++++ libs/css/src/content_name_tests.rs | 212 +++++++ libs/css/src/content_typography.rs | 71 +++ libs/css/src/content_typography_tests.rs | 53 ++ libs/css/src/content_value.rs | 30 + libs/css/src/file_map.rs | 6 + libs/css/src/legacy_variable_names.rs | 59 ++ libs/css/src/lib.rs | 528 ++++++------------ libs/css/src/naming.rs | 165 ++++++ libs/css/src/sparse_site.rs | 230 ++++++++ libs/css/src/sparse_site/source_ids.rs | 43 ++ libs/css/src/style_origin.rs | 112 ++++ .../src/assignment_blocker_support.rs | 44 ++ .../extractor/src/assignment_blocker_tests.rs | 150 +++++ libs/extractor/src/assignment_capture.rs | 262 +++++++++ libs/extractor/src/assignment_composition.rs | 199 +++++++ libs/extractor/src/assignment_consumers.rs | 90 +++ .../src/assignment_container_tests.rs | 145 +++++ libs/extractor/src/assignment_lowering.rs | 191 +++++++ .../src/assignment_lowering_tests.rs | 184 ++++++ libs/extractor/src/assignment_member.rs | 121 ++++ .../src/assignment_mixed_container_support.rs | 61 ++ .../src/assignment_mixed_container_tests.rs | 223 ++++++++ libs/extractor/src/assignment_object.rs | 83 +++ libs/extractor/src/assignment_owner.rs | 177 ++++++ .../src/assignment_residual_tests.rs | 218 ++++++++ libs/extractor/src/assignment_test_support.rs | 217 +++++++ libs/extractor/src/assignment_value.rs | 186 ++++++ libs/extractor/src/build_time_values.rs | 36 +- libs/extractor/src/class_evaluation.rs | 192 +++++++ libs/extractor/src/class_template.rs | 117 ++++ libs/extractor/src/css_utils.rs | 37 +- libs/extractor/src/element_capture.rs | 116 ++++ libs/extractor/src/element_capture_tests.rs | 282 ++++++++++ libs/extractor/src/element_evaluation.rs | 212 +++++++ libs/extractor/src/evaluation_location.rs | 199 +++++++ .../src/evaluation_location_tests.rs | 134 +++++ libs/extractor/src/evaluation_origin.rs | 272 +++++++++ .../extract_style/extract_dynamic_style.rs | 107 +++- .../src/extract_style/extract_keyframes.rs | 81 ++- .../src/extract_style/extract_static_style.rs | 95 +++- .../src/extract_style/extract_style_value.rs | 12 + .../extract_keyframes_from_expression.rs | 6 +- .../extract_style_from_expression.rs | 172 ++++-- .../boolean_expression.rs | 82 +++ .../boolean_or_tests.rs | 157 ++++++ .../extract_style_from_member_expression.rs | 36 +- .../extractor/extract_style_from_styled.rs | 41 +- .../extractor/extract_style_from_stylex.rs | 16 +- libs/extractor/src/gen_class_name.rs | 28 +- libs/extractor/src/gen_style.rs | 30 +- libs/extractor/src/imported_constants.rs | 202 +++++-- libs/extractor/src/lib.rs | 190 ++++++- libs/extractor/src/module_loader.rs | 36 +- .../src/named_capture_order_tests.rs | 148 +++++ libs/extractor/src/named_capture_support.rs | 16 + libs/extractor/src/named_capture_tests.rs | 97 ++++ libs/extractor/src/prop_modify_utils.rs | 62 +- libs/extractor/src/provenance.rs | 140 +++++ ...ractor__tests__apply_var_typography-2.snap | 4 +- ...ractor__tests__apply_var_typography-4.snap | 4 +- ...ractor__tests__apply_var_typography-5.snap | 2 +- ...ractor__tests__apply_var_typography-6.snap | 2 +- ...xtractor__tests__apply_var_typography.snap | 4 +- ...xtractor__tests__at_rules_at_prefix-2.snap | 4 +- ...xtractor__tests__at_rules_at_prefix-3.snap | 4 +- .../extractor__tests__at_rules_at_prefix.snap | 4 +- ...__tests__at_rules_underscore_prefix-2.snap | 4 +- ...__tests__at_rules_underscore_prefix-3.snap | 4 +- ...__tests__at_rules_underscore_prefix-4.snap | 4 +- ...or__tests__at_rules_underscore_prefix.snap | 4 +- ...tor__tests__avoid_same_name_component.snap | 4 +- .../extractor__tests__backtick_prop-2.snap | 4 +- .../extractor__tests__backtick_prop.snap | 4 +- .../extractor__tests__changed_constants.snap | 22 +- ...tests__compiled_bindings_never_dangle.snap | 8 +- .../extractor__tests__component_in_func.snap | 4 +- ...ts__compose_rules_the_module_computes.snap | 2 +- ...__composed_css_keeps_composed_classes.snap | 2 +- ...what_elements_and_styled_objects_take.snap | 3 +- ...actor__tests__conditional_composition.snap | 2 +- ...onditional_expression_with_selector-2.snap | 4 +- ..._conditional_expression_with_selector.snap | 4 +- .../extractor__tests__conditional_styles.snap | 4 +- ...tests__conditional_with_both_branches.snap | 2 +- ...ts__constant_objects_arrays_and_enums.snap | 7 +- ...or__tests__container_at_rule_in_css-2.snap | 4 +- ...ctor__tests__container_at_rule_in_css.snap | 4 +- .../extractor__tests__convert_tag-13.snap | 2 +- .../extractor__tests__convert_tag-14.snap | 2 +- .../extractor__tests__convert_tag-15.snap | 2 +- .../extractor__tests__convert_tag-16.snap | 2 +- .../extractor__tests__convert_tag-17.snap | 2 +- .../extractor__tests__convert_tag-18.snap | 2 +- .../extractor__tests__convert_tag-19.snap | 2 +- .../extractor__tests__convert_tag-20.snap | 2 +- .../extractor__tests__convert_tag-21.snap | 2 +- .../extractor__tests__convert_tag-7.snap | 2 +- ...l_style_collapses_to_a_null_component.snap | 2 +- ...ss_and_global_css_typescript_wrappers.snap | 2 +- ...__css_container_at_rule_with_selector.snap | 4 +- ..._css_props_destructuring_assignment-2.snap | 4 +- ...s__css_props_destructuring_assignment.snap | 4 +- ...tor__tests__css_variable_reassignment.snap | 2 +- ...tor__tests__css_with_template_literal.snap | 2 +- .../extractor__tests__custom_selector-2.snap | 4 +- .../extractor__tests__custom_selector-3.snap | 4 +- .../extractor__tests__custom_selector.snap | 4 +- ...xtractor__tests__default_import_usage.snap | 4 +- ..._tests__devup_props_optional_chaining.snap | 2 +- ...ests__devup_props_typescript_wrappers.snap | 2 +- ...tests__dot_notation_theme_variables-2.snap | 4 +- ...tests__dot_notation_theme_variables-3.snap | 4 +- ...tests__dot_notation_theme_variables-4.snap | 4 +- ...__tests__dot_notation_theme_variables.snap | 4 +- ...tractor__tests__duplicate_style_props.snap | 4 +- ...extractor__tests__dynamic_as_elements.snap | 12 +- ...__dynamic_as_where_the_element_stands.snap | 2 +- ...ctor__tests__dynamic_class_name_merge.snap | 4 +- ...__tests__dynamic_value_with_important.snap | 2 +- ...and_styled_components_object_styles-2.snap | 2 +- ...n_and_styled_components_object_styles.snap | 2 +- ...n_global_component_and_import_surface.snap | 2 +- .../extractor__tests__enum_style_prop.snap | 2 +- ...or__tests__evaluate_build_time_values.snap | 28 +- ..._every_runtime_value_an_element_holds.snap | 28 +- ...r__tests__extract_advenced_selector-2.snap | 4 +- ...r__tests__extract_advenced_selector-4.snap | 4 +- ...r__tests__extract_advenced_selector-5.snap | 4 +- ...r__tests__extract_advenced_selector-6.snap | 4 +- ...r__tests__extract_advenced_selector-8.snap | 4 +- ...tor__tests__extract_advenced_selector.snap | 4 +- ...ts__extract_class_name_from_component.snap | 4 +- ...tract_compound_responsive_style_props.snap | 4 +- ...tests__extract_conditional_selector-2.snap | 4 +- ...tests__extract_conditional_selector-3.snap | 4 +- ...tests__extract_conditional_selector-4.snap | 4 +- ...__tests__extract_conditional_selector.snap | 4 +- ...s__extract_conditional_style_props-10.snap | 4 +- ...s__extract_conditional_style_props-11.snap | 4 +- ...ts__extract_conditional_style_props-2.snap | 15 +- ...ts__extract_conditional_style_props-3.snap | 4 +- ...ts__extract_conditional_style_props-4.snap | 4 +- ...ts__extract_conditional_style_props-5.snap | 4 +- ...ts__extract_conditional_style_props-6.snap | 4 +- ...ts__extract_conditional_style_props-7.snap | 4 +- ...ts__extract_conditional_style_props-8.snap | 4 +- ...ts__extract_conditional_style_props-9.snap | 4 +- ...ests__extract_conditional_style_props.snap | 4 +- ...itional_style_props_with_class_name-2.snap | 4 +- ...nditional_style_props_with_class_name.snap | 4 +- ...tests__extract_dynamic_logical_case-2.snap | 4 +- ...tests__extract_dynamic_logical_case-3.snap | 15 +- ...tests__extract_dynamic_logical_case-4.snap | 4 +- ...__tests__extract_dynamic_logical_case.snap | 4 +- ...xtract_dynamic_responsive_style_props.snap | 4 +- ..._tests__extract_dynamic_style_props-2.snap | 3 +- ..._tests__extract_dynamic_style_props-3.snap | 2 +- ..._tests__extract_dynamic_style_props-4.snap | 3 +- ..._tests__extract_dynamic_style_props-5.snap | 3 +- ...r__tests__extract_dynamic_style_props.snap | 8 +- ..._style_props_binary_member_call_arm-2.snap | 3 +- ..._style_props_binary_member_call_arm-3.snap | 3 +- ...ic_style_props_binary_member_call_arm.snap | 3 +- ...tract_dynamic_style_props_with_type-2.snap | 6 +- ...tract_dynamic_style_props_with_type-3.snap | 4 +- ...extract_dynamic_style_props_with_type.snap | 4 +- ..._tests__extract_enum_style_property-3.snap | 4 +- ..._tests__extract_enum_style_property-4.snap | 4 +- ..._tests__extract_enum_style_property-5.snap | 4 +- ..._tests__extract_enum_style_property-6.snap | 4 +- ...r__tests__extract_enum_style_property.snap | 4 +- ..._extract_just_tsx_in_multiple_files-2.snap | 4 +- ..._extract_just_tsx_in_multiple_files-3.snap | 4 +- ..._extract_just_tsx_in_multiple_files-4.snap | 4 +- ...s__extract_just_tsx_in_multiple_files.snap | 4 +- ...extractor__tests__extract_keyframs-10.snap | 4 +- .../extractor__tests__extract_keyframs-2.snap | 4 +- .../extractor__tests__extract_keyframs-3.snap | 4 +- .../extractor__tests__extract_keyframs-4.snap | 4 +- .../extractor__tests__extract_keyframs-5.snap | 4 +- .../extractor__tests__extract_keyframs-6.snap | 4 +- .../extractor__tests__extract_keyframs-7.snap | 4 +- .../extractor__tests__extract_keyframs-8.snap | 4 +- .../extractor__tests__extract_keyframs-9.snap | 4 +- .../extractor__tests__extract_keyframs.snap | 4 +- ...or__tests__extract_keyframs_literal-2.snap | 4 +- ...ctor__tests__extract_keyframs_literal.snap | 4 +- ...ractor__tests__extract_logical_case-2.snap | 4 +- ...ractor__tests__extract_logical_case-3.snap | 4 +- ...ractor__tests__extract_logical_case-4.snap | 4 +- ...xtractor__tests__extract_logical_case.snap | 4 +- ...or__tests__extract_nested_selector-10.snap | 4 +- ...or__tests__extract_nested_selector-11.snap | 4 +- ...tor__tests__extract_nested_selector-2.snap | 4 +- ...tor__tests__extract_nested_selector-3.snap | 4 +- ...tor__tests__extract_nested_selector-4.snap | 4 +- ...tor__tests__extract_nested_selector-5.snap | 4 +- ...tor__tests__extract_nested_selector-6.snap | 4 +- ...tor__tests__extract_nested_selector-7.snap | 4 +- ...tor__tests__extract_nested_selector-8.snap | 4 +- ...tor__tests__extract_nested_selector-9.snap | 4 +- ...actor__tests__extract_nested_selector.snap | 4 +- ...udo_selector_with_identifier_snapshot.snap | 2 +- ..._responsive_conditional_style_props-2.snap | 4 +- ..._responsive_conditional_style_props-3.snap | 4 +- ..._responsive_conditional_style_props-4.snap | 4 +- ..._responsive_conditional_style_props-5.snap | 4 +- ..._responsive_conditional_style_props-6.snap | 4 +- ..._responsive_conditional_style_props-7.snap | 4 +- ..._responsive_conditional_style_props-8.snap | 4 +- ...ct_responsive_conditional_style_props.snap | 4 +- ...itional_style_props_with_class_name-2.snap | 4 +- ...nditional_style_props_with_class_name.snap | 4 +- ...sts__extract_responsive_style_props-2.snap | 4 +- ...tests__extract_responsive_style_props.snap | 4 +- ...namic_value_conditional_style_props-2.snap | 4 +- ...dynamic_value_conditional_style_props.snap | 6 +- ..._same_value_conditional_style_props-2.snap | 4 +- ..._same_value_conditional_style_props-3.snap | 4 +- ..._same_value_conditional_style_props-4.snap | 4 +- ..._same_value_conditional_style_props-5.snap | 4 +- ..._same_value_conditional_style_props-6.snap | 4 +- ...ct_same_value_conditional_style_props.snap | 4 +- ...extractor__tests__extract_selector-10.snap | 4 +- ...extractor__tests__extract_selector-11.snap | 4 +- ...extractor__tests__extract_selector-12.snap | 4 +- ...extractor__tests__extract_selector-13.snap | 4 +- ...extractor__tests__extract_selector-14.snap | 4 +- ...extractor__tests__extract_selector-15.snap | 4 +- ...extractor__tests__extract_selector-16.snap | 4 +- .../extractor__tests__extract_selector-2.snap | 4 +- .../extractor__tests__extract_selector-3.snap | 4 +- .../extractor__tests__extract_selector-4.snap | 4 +- .../extractor__tests__extract_selector-5.snap | 4 +- .../extractor__tests__extract_selector-6.snap | 4 +- .../extractor__tests__extract_selector-7.snap | 4 +- .../extractor__tests__extract_selector-8.snap | 4 +- .../extractor__tests__extract_selector-9.snap | 4 +- .../extractor__tests__extract_selector.snap | 4 +- ...tests__extract_selector_multibyte_key.snap | 2 +- ...ests__extract_selector_with_literal-2.snap | 4 +- ..._tests__extract_selector_with_literal.snap | 4 +- ...s__extract_selector_with_responsive-2.snap | 4 +- ...s__extract_selector_with_responsive-3.snap | 4 +- ...sts__extract_selector_with_responsive.snap | 4 +- ...xtract_static_css_class_name_props-10.snap | 4 +- ...xtract_static_css_class_name_props-14.snap | 4 +- ...xtract_static_css_class_name_props-16.snap | 4 +- ...extract_static_css_class_name_props-2.snap | 4 +- ...extract_static_css_class_name_props-3.snap | 4 +- ...extract_static_css_class_name_props-4.snap | 4 +- ...extract_static_css_class_name_props-5.snap | 4 +- ...extract_static_css_class_name_props-6.snap | 4 +- ...extract_static_css_class_name_props-9.snap | 4 +- ...__extract_static_css_class_name_props.snap | 4 +- ...extract_static_css_with_media_query-2.snap | 4 +- ...extract_static_css_with_media_query-3.snap | 4 +- ...__extract_static_css_with_media_query.snap | 4 +- ...ests__extract_static_css_with_theme-2.snap | 4 +- ...ests__extract_static_css_with_theme-3.snap | 4 +- ..._tests__extract_static_css_with_theme.snap | 4 +- ...tractor__tests__extract_style_props-2.snap | 4 +- ...tractor__tests__extract_style_props-3.snap | 4 +- ...tractor__tests__extract_style_props-4.snap | 4 +- ...tractor__tests__extract_style_props-5.snap | 4 +- ...tractor__tests__extract_style_props-6.snap | 4 +- ...extractor__tests__extract_style_props.snap | 4 +- ...extract_style_props_with_class_name-2.snap | 4 +- ...extract_style_props_with_class_name-3.snap | 4 +- ...extract_style_props_with_class_name-4.snap | 4 +- ...extract_style_props_with_class_name-5.snap | 4 +- ...extract_style_props_with_class_name-6.snap | 4 +- ...extract_style_props_with_class_name-7.snap | 4 +- ...extract_style_props_with_class_name-8.snap | 2 +- ...extract_style_props_with_class_name-9.snap | 4 +- ...__extract_style_props_with_class_name.snap | 4 +- ...tract_style_props_with_default_import.snap | 4 +- ...act_style_props_with_namespace_import.snap | 4 +- ...sts__extract_style_props_with_var_css.snap | 2 +- ...act_variable_style_props_with_style-2.snap | 6 +- ...tract_variable_style_props_with_style.snap | 6 +- ...ractor__tests__extract_wrong_keyframs.snap | 4 +- ...xtract_wrong_responsive_style_props-2.snap | 4 +- ..._extract_wrong_responsive_style_props.snap | 4 +- ...actor__tests__fold_math_at_build_time.snap | 2 +- ...xtractor__tests__group_selector_props.snap | 2 +- ...xtractor__tests__ignore_special_props.snap | 4 +- ...sts__import_alias_custom_named_export.snap | 4 +- ...r__tests__import_alias_emotion_styled.snap | 4 +- ..._tests__import_alias_multiple_aliases.snap | 4 +- ...r__tests__import_alias_renamed_import.snap | 4 +- ...tests__import_alias_styled_components.snap | 4 +- ...s__import_alias_vanilla_extract_named.snap | 4 +- .../extractor__tests__import_main_css.snap | 4 +- ...tor__tests__inline_imported_constants.snap | 98 +++- ...ractor__tests__inline_local_constants.snap | 12 +- .../extractor__tests__keyframes_function.snap | 4 +- ...layer_record_places_every_declaration.snap | 2 +- ...tor__tests__length_token_extraction-2.snap | 2 +- ...tor__tests__length_token_extraction-3.snap | 2 +- ...actor__tests__length_token_extraction.snap | 2 +- .../extractor__tests__maintain_value.snap | 4 +- ...r__tests__mask_properties_with_korean.snap | 4 +- ...actor__tests__media_query_selectors-2.snap | 4 +- ...actor__tests__media_query_selectors-3.snap | 4 +- ...actor__tests__media_query_selectors-4.snap | 4 +- ...actor__tests__media_query_selectors-5.snap | 4 +- ...tractor__tests__media_query_selectors.snap | 4 +- ...actor__tests__member_expression_style.snap | 2 +- ...member_expression_with_dynamic_values.snap | 2 +- ...ctor__tests__minus_zero_is_normalized.snap | 2 +- ...actor__tests__multiple_dynamic_values.snap | 8 +- ...xtractor__tests__namespace_import_css.snap | 4 +- ...t_of_a_default_only_package_redirects.snap | 2 +- ...ractor__tests__namespace_import_usage.snap | 4 +- .../extractor__tests__negative_props-2.snap | 4 +- .../extractor__tests__negative_props-3.snap | 4 +- .../extractor__tests__negative_props-4.snap | 4 +- .../extractor__tests__negative_props-5.snap | 4 +- .../extractor__tests__negative_props-6.snap | 4 +- .../extractor__tests__negative_props-7.snap | 4 +- .../extractor__tests__negative_props.snap | 4 +- .../extractor__tests__nested_conditional.snap | 2 +- .../extractor__tests__nested_theme_props.snap | 4 +- ...actor__tests__optimize_aspect_ratio-2.snap | 4 +- ...actor__tests__optimize_aspect_ratio-3.snap | 4 +- ...tractor__tests__optimize_aspect_ratio.snap | 4 +- .../extractor__tests__optimize_func-2.snap | 4 +- .../extractor__tests__optimize_func-3.snap | 4 +- .../extractor__tests__optimize_func-4.snap | 4 +- .../extractor__tests__optimize_func-5.snap | 4 +- .../extractor__tests__optimize_func-6.snap | 4 +- .../extractor__tests__optimize_func-7.snap | 4 +- .../extractor__tests__optimize_func.snap | 4 +- ...ctor__tests__optimize_multi_css_value.snap | 4 +- ...ractor__tests__package_imported_whole.snap | 6 +- ..._tests__parenthesized_string_literals.snap | 2 +- ...rops_direct_array_responsive_select-2.snap | 4 +- ..._props_direct_array_responsive_select.snap | 4 +- ...r__tests__props_direct_array_select-2.snap | 4 +- ...r__tests__props_direct_array_select-3.snap | 4 +- ...r__tests__props_direct_array_select-4.snap | 4 +- ...r__tests__props_direct_array_select-5.snap | 4 +- ...r__tests__props_direct_array_select-6.snap | 2 +- ...r__tests__props_direct_array_select-7.snap | 22 +- ...tor__tests__props_direct_array_select.snap | 4 +- ...props_direct_hybrid_responsive_select.snap | 4 +- ...ops_direct_object_responsive_select-2.snap | 4 +- ...props_direct_object_responsive_select.snap | 4 +- ...__tests__props_direct_object_select-2.snap | 2 +- ...__tests__props_direct_object_select-3.snap | 2 +- ...__tests__props_direct_object_select-4.snap | 4 +- ...or__tests__props_direct_object_select.snap | 4 +- ...ct_variable_array_responsive_select-2.snap | 4 +- ...rect_variable_array_responsive_select.snap | 4 +- ...ect_variable_object_responsive_select.snap | 4 +- ...props_direct_variable_object_select-2.snap | 4 +- ...__props_direct_variable_object_select.snap | 4 +- .../extractor__tests__props_direct_wrong.snap | 2 +- ...ractor__tests__props_multi_expression.snap | 4 +- .../extractor__tests__props_prop.snap | 4 +- ...__props_prop_becomes_spread_attribute.snap | 2 +- ...ts__props_wrong_direct_array_select-2.snap | 4 +- ...ts__props_wrong_direct_array_select-3.snap | 4 +- ...ts__props_wrong_direct_array_select-4.snap | 4 +- ...ts__props_wrong_direct_array_select-6.snap | 2 +- ...ts__props_wrong_direct_array_select-7.snap | 2 +- ...ts__props_wrong_direct_array_select-8.snap | 12 +- ...ests__props_wrong_direct_array_select.snap | 4 +- ...s__props_wrong_direct_object_select-2.snap | 4 +- ...s__props_wrong_direct_object_select-3.snap | 4 +- ...s__props_wrong_direct_object_select-4.snap | 4 +- ...s__props_wrong_direct_object_select-5.snap | 2 +- ...ests__raw_selector_key_without_parent.snap | 2 +- ...ctor__tests__read_once_in_every_place.snap | 2 +- .../extractor__tests__remove_semicolon-2.snap | 4 +- .../extractor__tests__remove_semicolon-3.snap | 4 +- .../extractor__tests__remove_semicolon-4.snap | 4 +- .../extractor__tests__remove_semicolon-5.snap | 4 +- .../extractor__tests__remove_semicolon.snap | 4 +- ...actor__tests__responsive_array_styles.snap | 4 +- ...nsive_length_token_literal_vs_array-2.snap | 3 +- ...nsive_length_token_literal_vs_array-3.snap | 3 +- ...nsive_length_token_literal_vs_array-4.snap | 3 +- ...ponsive_length_token_literal_vs_array.snap | 3 +- ...nsive_shadow_token_literal_vs_array-2.snap | 3 +- ...nsive_shadow_token_literal_vs_array-3.snap | 3 +- ...nsive_shadow_token_literal_vs_array-4.snap | 3 +- ...ponsive_shadow_token_literal_vs_array.snap | 3 +- .../extractor__tests__rest_props-2.snap | 4 +- .../extractor__tests__rest_props.snap | 4 +- ...selector_keys_computed_from_constants.snap | 6 +- ...ests__selector_refs_with_global_theme.snap | 4 +- ...tor__tests__shadow_token_extraction-2.snap | 2 +- ...actor__tests__shadow_token_extraction.snap | 2 +- .../extractor__tests__spread_props.snap | 4 +- ...tractor__tests__spreads_are_read_once.snap | 2 +- .../extractor__tests__style_order-2.snap | 4 +- .../extractor__tests__style_order-3.snap | 4 +- .../extractor__tests__style_order-4.snap | 4 +- .../extractor__tests__style_order-5.snap | 4 +- .../extractor__tests__style_order-6.snap | 4 +- .../extractor__tests__style_order-7.snap | 4 +- .../extractor__tests__style_order-8.snap | 4 +- .../extractor__tests__style_order.snap | 4 +- .../extractor__tests__style_order2-2.snap | 4 +- .../extractor__tests__style_order2-3.snap | 4 +- .../extractor__tests__style_order2.snap | 4 +- ...tor__tests__style_order_conditional-2.snap | 2 +- ...tor__tests__style_order_conditional-3.snap | 2 +- ...tor__tests__style_order_conditional-4.snap | 2 +- ...tor__tests__style_order_conditional-5.snap | 2 +- ...actor__tests__style_order_conditional.snap | 2 +- ...e_order_conditional_call_expression-2.snap | 2 +- ...e_order_conditional_call_expression-3.snap | 2 +- ...yle_order_conditional_call_expression.snap | 2 +- ...s__style_order_conditional_coverage-2.snap | 4 +- ...s__style_order_conditional_coverage-3.snap | 2 +- ...s__style_order_conditional_coverage-5.snap | 2 +- ...s__style_order_conditional_coverage-6.snap | 2 +- ...s__style_order_conditional_coverage-7.snap | 2 +- ...sts__style_order_conditional_coverage.snap | 4 +- ...ts__style_order_coverage_additional-2.snap | 2 +- ...ts__style_order_coverage_additional-3.snap | 2 +- ...ts__style_order_coverage_additional-4.snap | 2 +- ...ts__style_order_coverage_additional-5.snap | 2 +- ...ts__style_order_coverage_additional-6.snap | 2 +- ...tor__tests__style_order_logical_and-2.snap | 2 +- ...tor__tests__style_order_logical_and-3.snap | 2 +- ...actor__tests__style_order_logical_and.snap | 2 +- .../extractor__tests__style_order_prop.snap | 4 +- .../extractor__tests__style_prop_merge.snap | 4 +- ...__tests__style_results_read_as_values.snap | 38 +- .../extractor__tests__style_variables-6.snap | 4 +- .../extractor__tests__style_variables-7.snap | 6 +- .../extractor__tests__style_vars_prop.snap | 2 +- .../extractor__tests__styled-10.snap | 4 +- .../extractor__tests__styled-11.snap | 4 +- .../snapshots/extractor__tests__styled-2.snap | 4 +- .../snapshots/extractor__tests__styled-3.snap | 4 +- .../snapshots/extractor__tests__styled-4.snap | 4 +- .../snapshots/extractor__tests__styled-5.snap | 4 +- .../snapshots/extractor__tests__styled-6.snap | 4 +- .../snapshots/extractor__tests__styled-7.snap | 4 +- .../snapshots/extractor__tests__styled-8.snap | 4 +- .../snapshots/extractor__tests__styled-9.snap | 4 +- .../snapshots/extractor__tests__styled.snap | 4 +- ...tests__styled_accepts_both_call_forms.snap | 2 +- ...ponents_attrs_and_every_styled_import.snap | 2 +- ...onents_import_surface_fully_redirects.snap | 2 +- ..._tests__styled_components_of_any_base.snap | 10 +- ...nents_theme_resolves_to_css_variables.snap | 2 +- ...tyled_object_form_theme_interpolation.snap | 2 +- ...or__tests__styled_typescript_wrappers.snap | 2 +- .../extractor__tests__styled_with_spread.snap | 2 +- ...ractor__tests__styled_with_variable-2.snap | 4 +- ...ractor__tests__styled_with_variable-3.snap | 4 +- ...ractor__tests__styled_with_variable-4.snap | 4 +- ...ractor__tests__styled_with_variable-5.snap | 4 +- ...xtractor__tests__styled_with_variable.snap | 4 +- ...__styled_with_variable_like_emotion-2.snap | 2 +- ...__styled_with_variable_like_emotion-3.snap | 2 +- ...__styled_with_variable_like_emotion-4.snap | 2 +- ...__styled_with_variable_like_emotion-5.snap | 4 +- ...__styled_with_variable_like_emotion-6.snap | 4 +- ...__styled_with_variable_like_emotion-7.snap | 4 +- ...ts__styled_with_variable_like_emotion.snap | 2 +- ...ed_with_variable_like_emotion_props-2.snap | 2 +- ...ed_with_variable_like_emotion_props-3.snap | 2 +- ...ed_with_variable_like_emotion_props-4.snap | 2 +- ...yled_with_variable_like_emotion_props.snap | 4 +- ...__tests__styles_the_build_cannot_read.snap | 4 +- ...tests__styles_with_no_class_to_switch.snap | 2 +- ...or__tests__stylesheet_imports_modules.snap | 11 +- ...s__stylex_attrs_emits_class_attribute.snap | 2 +- ..._tests__stylex_attrs_via_named_import.snap | 2 +- ...actor__tests__stylex_coexist_with_box.snap | 2 +- ...actor__tests__stylex_coexist_with_css.snap | 4 +- ...__tests__stylex_conditional_variables.snap | 10 +- ...ts__stylex_create_multiple_namespaces.snap | 4 +- ...__tests__stylex_create_numeric_values.snap | 4 +- ...ests__stylex_create_numeric_with_unit.snap | 4 +- ...stylex_create_single_namespace_static.snap | 4 +- ...__stylex_define_vars_and_create_theme.snap | 10 +- ...xtractor__tests__stylex_dynamic_basic.snap | 4 +- ...r__tests__stylex_dynamic_empty_params.snap | 2 +- ...r__tests__stylex_dynamic_keyframe_ref.snap | 6 +- ...xtractor__tests__stylex_dynamic_mixed.snap | 4 +- ...or__tests__stylex_dynamic_multi_param.snap | 4 +- ...__tests__stylex_dynamic_numeric_value.snap | 4 +- ..._tests__stylex_first_that_works_basic.snap | 4 +- ..._stylex_first_that_works_in_condition.snap | 4 +- ...ests__stylex_first_that_works_numeric.snap | 4 +- ...s__stylex_first_that_works_two_values.snap | 4 +- ...tractor__tests__stylex_import_default.snap | 4 +- ...extractor__tests__stylex_import_named.snap | 4 +- ...actor__tests__stylex_import_namespace.snap | 4 +- ...r__tests__stylex_import_unknown_named.snap | 4 +- ...xtractor__tests__stylex_include_basic.snap | 4 +- ..._tests__stylex_include_dynamic_target.snap | 4 +- ...tests__stylex_include_empty_namespace.snap | 4 +- ...r__tests__stylex_include_named_import.snap | 4 +- ...ts__stylex_include_only_no_own_styles.snap | 4 +- ...tor__tests__stylex_include_with_props.snap | 4 +- ...or__tests__stylex_keyframes_in_create.snap | 6 +- ..._tests__stylex_keyframes_named_import.snap | 6 +- ...r__tests__stylex_keyframes_standalone.snap | 4 +- .../extractor__tests__stylex_media_query.snap | 4 +- ...or__tests__stylex_multiple_conditions.snap | 4 +- ..._tests__stylex_named_first_that_works.snap | 4 +- ...tor__tests__stylex_named_import_types.snap | 4 +- ...__tests__stylex_nested_hover_in_media.snap | 4 +- ...tests__stylex_nested_media_then_hover.snap | 4 +- ...ctor__tests__stylex_null_value_no_css.snap | 4 +- ...osition_try_and_view_transition_class.snap | 6 +- ...ts__stylex_props_computed_dynamic_key.snap | 2 +- ...ex_props_computed_key_empty_namespace.snap | 2 +- ...lex_props_computed_key_in_conditional.snap | 2 +- ...ps_computed_key_non_identifier_object.snap | 2 +- ..._computed_key_only_dynamic_namespaces.snap | 2 +- ...tylex_props_computed_key_unresolvable.snap | 2 +- ...ts__stylex_props_computed_literal_key.snap | 2 +- ...ylex_props_conditional_alternate_only.snap | 2 +- ...__tests__stylex_props_conditional_and.snap | 4 +- ...lex_props_conditional_consequent_only.snap | 2 +- ...s__stylex_props_conditional_none_none.snap | 2 +- ...sts__stylex_props_conditional_ternary.snap | 4 +- ...sts__stylex_props_dynamic_as_non_call.snap | 4 +- ...actor__tests__stylex_props_falsy_args.snap | 4 +- ...r__tests__stylex_props_falsy_literals.snap | 4 +- ..._props_join_styles_compiled_elsewhere.snap | 8 +- ...ylex_props_logical_unresolvable_right.snap | 2 +- ...__tests__stylex_props_multiple_static.snap | 4 +- ...tor__tests__stylex_props_named_import.snap | 4 +- ...ests__stylex_props_nonexistent_member.snap | 4 +- ...or__tests__stylex_props_single_static.snap | 4 +- ...or__tests__stylex_props_style_x_array.snap | 2 +- ...x_props_ts_wrapper_and_optional_chain.snap | 2 +- ..._tests__stylex_props_unresolvable_arg.snap | 2 +- ...__tests__stylex_props_with_conditions.snap | 4 +- ...tor__tests__stylex_pseudo_class_focus.snap | 4 +- ...tor__tests__stylex_pseudo_class_hover.snap | 4 +- ...__tests__stylex_pseudo_element_before.snap | 2 +- ...ts__stylex_pseudo_element_placeholder.snap | 4 +- ...tractor__tests__stylex_supports_query.snap | 4 +- ..._stylex_theme_apis_with_named_imports.snap | 8 +- ...__stylex_theme_contract_and_constants.snap | 6 +- ...ctor__tests__stylex_types_color_strip.snap | 4 +- ...tor__tests__stylex_types_in_condition.snap | 4 +- ...tor__tests__stylex_types_length_strip.snap | 4 +- ...xtractor__tests__stylex_types_numeric.snap | 4 +- ...tests__stylex_warn_shorthand_property.snap | 4 +- ...actor__tests__support_transpile_cjs-2.snap | 4 +- ...actor__tests__support_transpile_cjs-3.snap | 4 +- ...actor__tests__support_transpile_cjs-4.snap | 4 +- ...actor__tests__support_transpile_cjs-5.snap | 4 +- ...actor__tests__support_transpile_cjs-6.snap | 4 +- ...actor__tests__support_transpile_cjs-7.snap | 4 +- ...tractor__tests__support_transpile_cjs.snap | 4 +- ...actor__tests__support_transpile_mjs-2.snap | 4 +- ...actor__tests__support_transpile_mjs-3.snap | 4 +- ...actor__tests__support_transpile_mjs-4.snap | 6 +- ...actor__tests__support_transpile_mjs-5.snap | 6 +- ...actor__tests__support_transpile_mjs-6.snap | 6 +- ...actor__tests__support_transpile_mjs-7.snap | 6 +- ...actor__tests__support_transpile_mjs-8.snap | 6 +- ...tractor__tests__support_transpile_mjs.snap | 4 +- ...tor__tests__tailwind_arbitrary_values.snap | 2 +- ..._tests__tailwind_classname_extraction.snap | 2 +- ...__tailwind_classname_with_devup_props.snap | 2 +- ...sts__tailwind_classname_with_variants.snap | 2 +- ...ests__tailwind_conditional_class_name.snap | 2 +- ...tor__tests__tailwind_full_integration.snap | 2 +- ..._tailwind_mixed_with_devup_responsive.snap | 2 +- ...ctor__tests__tailwind_negative_values.snap | 2 +- ...literal_with_function_call_expression.snap | 4 +- ...plate_literal_with_logical_expression.snap | 4 +- ..._literal_with_nested_template_literal.snap | 4 +- ...literal_with_parenthesized_expression.snap | 4 +- ...ind_template_literal_with_style_order.snap | 4 +- ...late_literal_with_variable_expression.snap | 4 +- ...ests__tailwind_with_all_variant_types.snap | 2 +- ...tractor__tests__tailwind_with_as_prop.snap | 2 +- ...ind_with_conditional_template_literal.snap | 4 +- ...actor__tests__template_interpolations.snap | 2 +- ...ctor__tests__template_literal_props-2.snap | 4 +- ...ctor__tests__template_literal_props-3.snap | 4 +- ...ctor__tests__template_literal_props-4.snap | 4 +- ...ctor__tests__template_literal_props-5.snap | 4 +- ...ctor__tests__template_literal_props-6.snap | 4 +- ...ractor__tests__template_literal_props.snap | 4 +- ...tests__ternary_operator_in_selector-2.snap | 4 +- ...tests__ternary_operator_in_selector-3.snap | 4 +- ...__tests__ternary_operator_in_selector.snap | 4 +- .../extractor__tests__theme_props.snap | 4 +- .../extractor__tests__theme_selector-2.snap | 4 +- .../extractor__tests__theme_selector-3.snap | 4 +- .../extractor__tests__theme_selector.snap | 4 +- ...stantiation_expression_as_style_value.snap | 2 +- ...__tests__values_known_only_at_runtime.snap | 26 +- ..._vanilla_extract_all_exports_combined.snap | 4 +- ...r__tests__vanilla_extract_all_imports.snap | 4 +- ...r__tests__vanilla_extract_composition.snap | 4 +- ...sts__vanilla_extract_constant_exports.snap | 4 +- ...tor__tests__vanilla_extract_container.snap | 4 +- ...__tests__vanilla_extract_create_var-2.snap | 4 +- ...or__tests__vanilla_extract_create_var.snap | 4 +- ...ct_css_ts_with_vanilla_extract_import.snap | 4 +- ...r__tests__vanilla_extract_font_face-2.snap | 4 +- ...tor__tests__vanilla_extract_font_face.snap | 4 +- ...vanilla_extract_font_face_empty_props.snap | 4 +- ...vanilla_extract_font_face_placeholder.snap | 4 +- ..._vanilla_extract_font_face_with_style.snap | 4 +- ...nilla_extract_global_theme_empty_vars.snap | 4 +- ...illa_extract_global_theme_placeholder.snap | 4 +- ...anilla_extract_global_theme_with_vars.snap | 4 +- ...__vanilla_extract_imports_combination.snap | 6 +- ...sts__vanilla_extract_keyframes_export.snap | 6 +- ...__vanilla_extract_layer_and_container.snap | 4 +- ..._tests__vanilla_extract_layer_records.snap | 2 +- ..._tests__vanilla_extract_media_queries.snap | 4 +- ...mes_extract_without_source_dependency.snap | 2 +- ...__vanilla_extract_non_exported_styles.snap | 4 +- ...s__vanilla_extract_non_exported_theme.snap | 4 +- ...la_extract_selector_class_replacement.snap | 4 +- ...__vanilla_extract_selector_references.snap | 4 +- ...t_selector_refs_triggers_with_classes.snap | 4 +- ...r__tests__vanilla_extract_selectors-2.snap | 4 +- ...tor__tests__vanilla_extract_selectors.snap | 4 +- ...la_extract_style_composition_multiple.snap | 4 +- ...tests__vanilla_extract_style_css_js-2.snap | 4 +- ...__tests__vanilla_extract_style_css_js.snap | 4 +- ...__tests__vanilla_extract_style_css_ts.snap | 4 +- ...sts__vanilla_extract_style_variants-2.snap | 4 +- ...tests__vanilla_extract_style_variants.snap | 4 +- ..._vanilla_extract_style_variants_mixed.snap | 4 +- ...illa_extract_style_variants_with_base.snap | 4 +- ...ctor__tests__vanilla_extract_supports.snap | 4 +- ...tractor__tests__vanilla_extract_theme.snap | 4 +- ...nilla_extract_theme_array_destructure.snap | 4 +- ...nilla_extract_theme_export_variations.snap | 4 +- ...nilla_extract_theme_without_vars_json.snap | 4 +- ...tor__tests__vanilla_extract_vars_only.snap | 4 +- ...la_extract_with_at_container_selector.snap | 4 +- ..._tests__vanilla_extract_with_computed.snap | 4 +- ...lla_extract_with_keyframes_and_global.snap | 4 +- ..._vanilla_extract_with_pseudo_selector.snap | 4 +- ...vanilla_extract_with_responsive_array.snap | 4 +- ...r__tests__vanilla_extract_with_spread.snap | 4 +- ..._tests__vanilla_extract_with_variable.snap | 4 +- .../extractor__tests__with_prefix.snap | 4 +- ...ed_with_variable_like_emotion_props-2.snap | 2 +- ...yled_with_variable_like_emotion_props.snap | 2 +- libs/extractor/src/source_naming.rs | 191 +++++++ libs/extractor/src/source_naming_tests.rs | 120 ++++ libs/extractor/src/sparse_sites.rs | 291 ++++++++++ libs/extractor/src/style_export_locations.rs | 85 +++ libs/extractor/src/style_origin.rs | 296 ++++++++++ libs/extractor/src/style_origin_tests.rs | 145 +++++ libs/extractor/src/style_values.rs | 4 +- libs/extractor/src/styled_creation_support.rs | 97 ++++ libs/extractor/src/styled_creation_tests.rs | 192 +++++++ libs/extractor/src/utils.rs | 40 +- libs/extractor/src/vanilla_extract.rs | 245 +++++--- libs/extractor/src/visit.rs | 395 ++++++++----- libs/extractor/tests/boa_caller_probe.rs | 167 ++++++ libs/extractor/tests/conditional_site.rs | 177 ++++++ libs/extractor/tests/naming_eligibility.rs | 218 ++++++++ libs/extractor/tests/naming_import_prefix.rs | 47 ++ libs/extractor/tests/sparse_member_spans.rs | 139 +++++ libs/sheet/benches/my_benchmark.rs | 12 +- libs/sheet/src/atom_identity_tests.rs | 16 +- .../src/atom_identity_tests/first_value.rs | 43 +- .../atom_identity_tests/mixed_placement.rs | 4 +- libs/sheet/src/lib.rs | 401 +++++++++---- libs/sheet/src/name_registry.rs | 133 +++++ libs/sheet/src/name_registry_tests.rs | 85 +++ libs/sheet/src/owner_reset_tests.rs | 158 ++++++ libs/sheet/src/sheet_test_code.rs | 115 ++++ .../snapshots/sheet__tests__deserialize.snap | 3 + .../sheet__tests__update_styles-2.snap | 2 +- .../__snapshots__/index.browser.test.tsx.snap | 131 ++--- .../Button/__tests__/index.browser.test.tsx | 35 +- .../__snapshots__/index.browser.test.tsx.snap | 95 ++-- .../__snapshots__/index.browser.test.tsx.snap | 53 +- .../Input/__tests__/index.browser.test.tsx | 8 +- .../__snapshots__/index.browser.test.tsx.snap | 67 +-- .../__snapshots__/index.browser.test.tsx.snap | 141 ++--- .../__snapshots__/index.browser.test.tsx.snap | 53 +- .../Select/__tests__/index.browser.test.tsx | 12 +- .../__snapshots__/index.browser.test.tsx.snap | 23 +- .../Stepper/__tests__/index.browser.test.tsx | 8 +- .../__snapshots__/index.browser.test.tsx.snap | 23 +- .../Textarea/__tests__/index.browser.test.tsx | 4 +- .../__snapshots__/index.browser.test.tsx.snap | 49 +- .../src/__tests__/atom-hoist.test.ts | 68 +++ .../__tests__/environment-order-harness.ts | 160 ++++++ .../src/__tests__/content-name-wasm.test.ts | 24 + .../__tests__/value-evaluation-wasm.test.ts | 46 ++ .../components/__tests__/Box.browser.test.tsx | 2 +- .../src/__tests__/atom-hoist.test.ts | 34 ++ .../src/__tests__/atom-hoist.test.ts | 40 ++ 721 files changed, 14914 insertions(+), 2678 deletions(-) create mode 100644 .changepacks/changepack_log_environment_stable_names.json create mode 100644 bindings/devup-ui-wasm/src/cache_names.rs create mode 100644 bindings/devup-ui-wasm/src/content_location_contract_tests.rs create mode 100644 bindings/devup-ui-wasm/src/content_name_tests.rs create mode 100644 bindings/devup-ui-wasm/tests/content-names.mjs create mode 100644 bindings/devup-ui-wasm/tests/site_variable_names.rs create mode 100644 bindings/devup-ui-wasm/tests/site_variable_names/sparse.rs create mode 100644 bindings/devup-ui-wasm/tests/source_naming.rs create mode 100644 bindings/devup-ui-wasm/tests/value-evaluation.mjs create mode 100644 e2e/environment-dynamic-driver.ts create mode 100644 e2e/environment-dynamic-sites.spec.ts create mode 100644 libs/css/src/content_hash.rs create mode 100644 libs/css/src/content_name.rs create mode 100644 libs/css/src/content_name_tests.rs create mode 100644 libs/css/src/content_typography.rs create mode 100644 libs/css/src/content_typography_tests.rs create mode 100644 libs/css/src/content_value.rs create mode 100644 libs/css/src/legacy_variable_names.rs create mode 100644 libs/css/src/naming.rs create mode 100644 libs/css/src/sparse_site.rs create mode 100644 libs/css/src/sparse_site/source_ids.rs create mode 100644 libs/css/src/style_origin.rs create mode 100644 libs/extractor/src/assignment_blocker_support.rs create mode 100644 libs/extractor/src/assignment_blocker_tests.rs create mode 100644 libs/extractor/src/assignment_capture.rs create mode 100644 libs/extractor/src/assignment_composition.rs create mode 100644 libs/extractor/src/assignment_consumers.rs create mode 100644 libs/extractor/src/assignment_container_tests.rs create mode 100644 libs/extractor/src/assignment_lowering.rs create mode 100644 libs/extractor/src/assignment_lowering_tests.rs create mode 100644 libs/extractor/src/assignment_member.rs create mode 100644 libs/extractor/src/assignment_mixed_container_support.rs create mode 100644 libs/extractor/src/assignment_mixed_container_tests.rs create mode 100644 libs/extractor/src/assignment_object.rs create mode 100644 libs/extractor/src/assignment_owner.rs create mode 100644 libs/extractor/src/assignment_residual_tests.rs create mode 100644 libs/extractor/src/assignment_test_support.rs create mode 100644 libs/extractor/src/assignment_value.rs create mode 100644 libs/extractor/src/class_evaluation.rs create mode 100644 libs/extractor/src/class_template.rs create mode 100644 libs/extractor/src/element_capture.rs create mode 100644 libs/extractor/src/element_capture_tests.rs create mode 100644 libs/extractor/src/element_evaluation.rs create mode 100644 libs/extractor/src/evaluation_location.rs create mode 100644 libs/extractor/src/evaluation_location_tests.rs create mode 100644 libs/extractor/src/evaluation_origin.rs create mode 100644 libs/extractor/src/extractor/extract_style_from_expression/boolean_expression.rs create mode 100644 libs/extractor/src/extractor/extract_style_from_expression/boolean_or_tests.rs create mode 100644 libs/extractor/src/named_capture_order_tests.rs create mode 100644 libs/extractor/src/named_capture_support.rs create mode 100644 libs/extractor/src/named_capture_tests.rs create mode 100644 libs/extractor/src/provenance.rs create mode 100644 libs/extractor/src/source_naming.rs create mode 100644 libs/extractor/src/source_naming_tests.rs create mode 100644 libs/extractor/src/sparse_sites.rs create mode 100644 libs/extractor/src/style_export_locations.rs create mode 100644 libs/extractor/src/style_origin.rs create mode 100644 libs/extractor/src/style_origin_tests.rs create mode 100644 libs/extractor/src/styled_creation_support.rs create mode 100644 libs/extractor/src/styled_creation_tests.rs create mode 100644 libs/extractor/tests/boa_caller_probe.rs create mode 100644 libs/extractor/tests/conditional_site.rs create mode 100644 libs/extractor/tests/naming_eligibility.rs create mode 100644 libs/extractor/tests/naming_import_prefix.rs create mode 100644 libs/extractor/tests/sparse_member_spans.rs create mode 100644 libs/sheet/src/name_registry.rs create mode 100644 libs/sheet/src/name_registry_tests.rs create mode 100644 libs/sheet/src/owner_reset_tests.rs create mode 100644 libs/sheet/src/sheet_test_code.rs create mode 100644 packages/next-plugin/src/__tests__/environment-order-harness.ts create mode 100644 packages/react/src/__tests__/content-name-wasm.test.ts create mode 100644 packages/react/src/__tests__/value-evaluation-wasm.test.ts diff --git a/.changepacks/changepack_log_environment_stable_names.json b/.changepacks/changepack_log_environment_stable_names.json new file mode 100644 index 000000000..6babfca90 --- /dev/null +++ b/.changepacks/changepack_log_environment_stable_names.json @@ -0,0 +1,12 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/react/package.json": "Patch", + "packages/components/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch" + }, + "note": "Stabilize class and runtime-variable names across build environments, use compact deterministic content names, reset generated variables on their owning elements, and preserve authored evaluation order and lazy selection. Exact content-name collisions report both declarations and their best real source locations; conflicting source-variable roles and unprotected naming caches now fail closed.", + "date": "2026-10-05T00:00:00.000Z" +} diff --git a/bindings/devup-ui-wasm/src/atom_tests.rs b/bindings/devup-ui-wasm/src/atom_tests.rs index 278eb4ef6..662ef7c73 100644 --- a/bindings/devup-ui-wasm/src/atom_tests.rs +++ b/bindings/devup-ui-wasm/src/atom_tests.rs @@ -46,29 +46,89 @@ fn emitted(file: &str) -> String { } fn assert_references_resolve(code: &str, css: &str) { + // Read generated payloads, not import paths, identifiers or CSS property keys. + let literals = + css::utils::compile_regex(r#""(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`"#); + let class = css::utils::compile_regex( + r"^(?:du-)?(?:[OR](?:L[a-z0-9_-]+|H[a-z0-9_]{16})|(?:[a-z_][a-z0-9_]*|[a-z0-9_]*a-d)-(?:[a-z_][a-z0-9_]*|[a-z0-9_]*a-d))$", + ); + let animation = css::utils::compile_regex(r"^(?:du-)?K(?:L[a-z0-9_-]+|H[a-z0-9_]{16})$"); + let variable = css::utils::compile_regex( + r"^---(?:du-)?S(?:[a-z_][a-z0-9_]*|U[a-z0-9_]+)-[a-z_][a-z0-9_]*(?:-[a-z_][a-z0-9_]*)?$", + ); 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}" - ); + for literal in literals.find_iter(code) { + let before = code[..literal.start()].trim_end(); + if before.ends_with("import") || before.ends_with("from") { + continue; + } + let quoted = literal.as_str(); + let property_key = code[literal.end()..].trim_start().starts_with(':'); + for token in quoted[1..quoted.len() - 1].split_whitespace() { + if variable.is_match(token) { + assert!( + css.contains(&format!("var({token})")), + "unemitted variable {token}" + ); + } else if animation.is_match(token) { + assert!( + css.contains(&format!("@keyframes {token}{{")), + "unemitted keyframes {token}" + ); + } else if class.is_match(token) && !property_key { + let selector = format!(".{token}"); + assert!( + css.match_indices(&selector).any(|(at, _)| { + matches!( + css[at + selector.len()..].chars().next(), + Some('{' | ':' | '[' | '.' | '#' | ' ' | ',' | '>' | '+' | '~') + ) + }), + "unemitted class {token}\n{css}" + ); + classes += 1; + } } } assert!(classes > 0, "fixture must generate classes: {code}"); } +#[rstest::rstest] +#[case(r#"
"#)] +#[case(r#"
"#)] +#[case(r#"
"#)] +#[case(r#"
"#)] +#[case(r#"const fade="KHaaaaaaaaaaaaaaaa"; const cls="OLcolor-vred";"#)] +#[case(r#"const fade="du-KLsfrom-e-"; const cls="OLcolor-vred";"#)] +#[case(r#"
"#)] +#[should_panic(expected = "unemitted")] +fn reference_checker_rejects_dangling_payloads_when_another_class_resolves(#[case] code: &str) { + // Given / When / Then: a valid class cannot hide another unresolved reference. + assert_references_resolve(code, &format!(".{}{{color:red}}", "OLcolor-vred")); +} + +#[test] +fn reference_checker_ignores_nonpayload_names_when_valid_classes_are_present() { + // Given + let code = r#"import "devup-ui"; import "df/devup-ui/a.css"; + const OLmissing=()=>({"font-family":"Arial"}); + export const cls="du-c-a du-OLcolor-vred";"#; + // When / Then + assert_references_resolve( + code, + &format!( + ".{}{{padding:0}}.{}{{color:red}}", + "du-c-a", "du-OLcolor-vred" + ), + ); +} + +#[test] +#[should_panic(expected = "fixture must generate classes")] +fn reference_checker_rejects_fixtures_when_no_class_is_generated() { + assert_references_resolve("export const x=
;", ""); +} + 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}}); diff --git a/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs b/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs index 630234139..f2395b0f3 100644 --- a/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs +++ b/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs @@ -47,7 +47,7 @@ fn imported_sheet_keeps_recorded_placement_after_configuration_reset() { reset_build_state_internal(); let imported = serde_json::from_str(&serialized).unwrap_or_else(|error| panic!("{error}")); // When - import_sheet_internal(imported); + import_sheet_internal(imported).unwrap_or_else(|error| panic!("{error}")); seed_file_map(vec!["a.tsx".into(), "b.tsx".into(), "private.tsx".into()]); // Then assert_eq!( @@ -132,22 +132,94 @@ fn important_and_plain_dynamic_values_emit_distinct_rules_in_every_mode() { let output = compile("private.tsx", source); // Then let css = emitted("private.tsx"); - let rules = with_style_sheet(|sheet| { + let properties = with_style_sheet(|sheet| { sheet.properties["private.tsx"] .values() .flat_map(BTreeMap::values) .flatten() - .map(|prop| (prop.class_name.clone(), prop.value.clone())) + .cloned() .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); + let rules: Vec<_> = properties + .iter() + .filter(|prop| prop.property == "color") + .collect(); + let resets: Vec<_> = properties.iter().filter(|prop| prop.owner_reset).collect(); + assert_eq!(rules.len(), 3); + assert_eq!(resets.len(), 3); + assert_eq!( + rules + .iter() + .map(|prop| &prop.class_name) + .collect::>() + .len(), + 3 + ); + assert_eq!( + rules + .iter() + .map(|prop| &prop.value) + .collect::>() + .len(), + 3 + ); + let assignments = css::utils::compile_regex( + r#"["'](---(?:du-)?S[a-z0-9_U-]+)["']\s*:\s*(tone\b|differentCode\b|`\$\{tone\}`)"#, + ); + let sites: Vec<_> = assignments.captures_iter(&output.code).collect(); + assert_eq!(sites.len(), 3, "{}", output.code); + assert_eq!( + sites + .iter() + .map(|site| &site[1]) + .collect::>() + .len(), + 3 + ); + assert_eq!(&sites[0][2], "tone"); + assert_eq!(&sites[1][2], "differentCode"); + assert!(matches!(&sites[2][2], "tone" | "`${tone}`")); + assert_references_resolve(&output.code, &css); + for (index, site) in sites.iter().enumerate() { + let variable = &site[1]; + let value = format!( + "var({variable}){}", + if index == 2 { " !important" } else { "" } + ); + let rule = rules + .iter() + .find(|prop| prop.value == value) + .unwrap_or_else(|| panic!("missing site color rule for {value}\n{css}")); + assert!(!rule.owner_reset); + assert!(output.code.contains(&rule.class_name)); + let owned: Vec<_> = resets + .iter() + .filter(|prop| prop.class_name == rule.class_name) + .collect(); + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].property, variable); + assert_eq!(owned[0].value, "initial"); + assert_eq!(owned[0].selector, None); + assert_eq!(owned[0].layer, None); + let selector = format!(".{}{{", rule.class_name); + assert_eq!(css.matches(&selector).count(), 1, "{css}"); + let block = css + .split_once(&selector) + .unwrap_or_else(|| panic!("missing own class block {selector}\n{css}")) + .1; + let block = block + .split_once('}') + .unwrap_or_else(|| panic!("unclosed class block {selector}\n{css}")) + .0; + let declarations: HashSet<_> = + block.split(';').filter(|value| !value.is_empty()).collect(); + assert_eq!( + declarations, + HashSet::from([ + format!("{variable}:initial").as_str(), + format!("color:{value}").as_str(), + ]) + ); } } reset_build_state_internal(); diff --git a/bindings/devup-ui-wasm/src/cache_names.rs b/bindings/devup-ui-wasm/src/cache_names.rs new file mode 100644 index 000000000..0d1cf6218 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_names.rs @@ -0,0 +1,99 @@ +use sheet::StyleSheet; +use std::cell::RefCell; + +thread_local! { + static ERROR: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) fn check() -> Result<(), String> { + ERROR.with_borrow(|error| match error { + Some(error) => Err(error.clone()), + None => Ok(()), + }) +} + +pub(crate) fn record(result: &Result<(), String>) { + ERROR.with_borrow_mut(|error| *error = result.as_ref().err().cloned()); +} + +pub(crate) fn clear() { + ERROR.with_borrow_mut(|error| *error = None); +} + +fn content_name(name: &str) -> bool { + let Some(marker) = name.rfind(|character: char| character.is_ascii_uppercase()) else { + return false; + }; + let Some(domain) = marker + .checked_sub(1) + .and_then(|index| name.as_bytes().get(index)) + else { + return false; + }; + if !matches!(domain, b'O' | b'R' | b'K') { + return false; + } + let payload = &name[marker + 1..]; + match name.as_bytes()[marker] { + b'H' => { + payload.len() == 16 + && payload + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + } + b'L' => { + payload.len() <= 16 + && payload.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'_' | b'-') + }) + } + _ => false, + } +} + +pub(crate) fn validate(sheet: &StyleSheet) -> Result<(), String> { + let atoms = sheet + .properties + .values() + .flat_map(|orders| orders.values()) + .flat_map(|levels| levels.values()) + .flatten() + .map(|property| property.class_name.as_str()); + let frames = sheet + .keyframes + .values() + .flat_map(|frames| frames.keys().map(String::as_str)); + for name in atoms.chain(frames) { + if content_name(name) && !sheet.names.contains_key(name) { + return Err(format!( + "cached generated name `{name}` has no exact descriptor claim; rebuild all caches with the current naming contract" + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn current_content_grammar_does_not_confuse_legacy_counters_or_names() { + for name in ["OLcolor-vred", "prefix-RHaaaaaaaaaaaaaaaa", "KL"] { + assert!(content_name(name), "{name}"); + } + for name in [ + "a-a", + "Rcolor-vred", + "Ksfrom", + "a1-h-123", + "OHshort", + "OLlonger_than_sixteen_payload", + "prefix-L", + ] { + assert!(!content_name(name), "{name}"); + } + } +} diff --git a/bindings/devup-ui-wasm/src/cache_protocol_tests.rs b/bindings/devup-ui-wasm/src/cache_protocol_tests.rs index f43294945..2e281ab56 100644 --- a/bindings/devup-ui-wasm/src/cache_protocol_tests.rs +++ b/bindings/devup-ui-wasm/src/cache_protocol_tests.rs @@ -3,6 +3,57 @@ use rstest::rstest; use serial_test::serial; use std::collections::HashSet; +#[test] +#[serial] +fn source_ids_roundtrip_restores_dynamic_names_when_extracting_in_reverse_order() { + // Given + reset_build_state_internal(); + css::file_map::seed_file_numbers(&["a.tsx".into(), "z.tsx".into()]); + let source = "import {Box} from '@devup-ui/react'; export const View=({pad})=>;"; + let before = code_extract_internal( + "z.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + let ids = css::file_map::get_original_ids(); + assert_eq!( + ids, + BTreeMap::from([("a.tsx".into(), 0), ("z.tsx".into(), 1)]) + ); + let exported = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let files = css::file_map::get_file_map(); + let classes = with_class_map(Clone::clone); + let imported: StyleSheet = + serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + reset_build_state_internal(); + // When + import_sheet_internal(imported).unwrap_or_else(|error| panic!("{error}")); + set_file_map(files); + set_class_map(classes); + let restored_ids = css::file_map::get_original_ids(); + let after = code_extract_internal( + "z.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert_eq!(restored_ids, ids); + assert_eq!(after.code(), before.code()); + reset_build_state_internal(); +} + #[rstest] #[case(false, false)] #[case(false, true)] @@ -23,20 +74,20 @@ fn legacy_import_changes_cache_generation_when_exported( 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); + import_sheet_internal(imported).unwrap_or_else(|error| panic!("{error}")); // 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); + assert_eq!(json["atomNamingVersion"], 3); 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); + import_sheet_internal(restored).unwrap_or_else(|error| panic!("{error}")); assert_eq!( export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), exported @@ -73,7 +124,7 @@ fn exported_generation_roundtrips_frozen_atom_placement() { let imported = serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); reset_build_state_internal(); // When - import_sheet_internal(imported); + import_sheet_internal(imported).unwrap_or_else(|error| panic!("{error}")); // Then assert_eq!(global.matches("color:red").count(), 1); assert_eq!(local.matches("color:red").count(), 1); diff --git a/bindings/devup-ui-wasm/src/content_location_contract_tests.rs b/bindings/devup-ui-wasm/src/content_location_contract_tests.rs new file mode 100644 index 000000000..a90264052 --- /dev/null +++ b/bindings/devup-ui-wasm/src/content_location_contract_tests.rs @@ -0,0 +1,264 @@ +use super::*; +use css::{content_hash::FingerprintBits, style_origin::RealLocation}; +use serial_test::serial; +use std::fmt::Write; + +fn extract(file: &str, source: &str) -> extractor::ExtractOutput { + extractor::extract( + file, + source, + extractor::ExtractOption { + single_css: true, + ..Default::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +#[test] +#[serial] +fn pair_keeps_distinct_content_names_when_native_calls_share_one_boa_position() { + // Given + reset_build_state_internal(); + let source = "import {style} from '@devup-ui/react';\nfunction pair(a,b){return [a,b];}\nexport const pairResult=pair(style({color:'red'}),style({color:'blue'}));"; + // When + let output = code_extract_internal( + "pair.css.ts", + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert!(output.code().contains("OLcolor-vred"), "{}", output.code()); + assert!(output.code().contains("OLcolor-vblue"), "{}", output.code()); + with_style_sheet(|sheet| assert_eq!(sheet.names.len(), 2)); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn resolver_direct_and_opaque_factories_keep_identical_names_when_environment_order_reverses() { + // Given + let source = "import {style} from '@devup-ui/react';import {make} from './factory';export const cls=make({fontFamily:'abcdefghijklmnopqrstuvwx'});"; + let bodies = [ + "import {style} from '@devup-ui/react';export const make=(rule)=>style(rule);", + "import {style} from '@devup-ui/react';export const make=(rule)=>((factory)=>factory)(style)(rule);", + ]; + // When + let mut outputs = Vec::new(); + for order in [[0, 1], [1, 0]] { + reset_build_state_internal(); + let mut results = BTreeMap::new(); + for index in order { + let resolver = move |specifier: &str, _: &str| { + (specifier == "./factory").then(|| ResolvedModule { + path: "factory.ts".into(), + code: bodies[index].into(), + }) + }; + let output = code_extract_with_modules_internal( + "resolver.css.ts", + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + results.insert(index, output.code().clone()); + } + outputs.push(results); + } + // Then + assert_eq!(outputs[0], outputs[1]); + assert_eq!(outputs[0][&0], outputs[0][&1]); + with_style_sheet(|sheet| assert_eq!(sheet.names.len(), 1)); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn opaque_factory_preserves_this_arguments_and_direct_eval_when_called_through_a_helper() { + // Given + reset_build_state_internal(); + let source = "import {style} from '@devup-ui/react';const adapt=(factory)=>function(value){if(this.tag!==value||arguments.length!==1)throw new Error('changed call');return factory({fontFamily:eval('value')});};const make=adapt(style);export const cls=make.call({tag:'abcdefghijklmnopqrstuvwx'},'abcdefghijklmnopqrstuvwx');"; + // When + let output = code_extract_internal( + "semantics.css.ts", + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert!( + !output.code().contains("__devup_style_origin"), + "{}", + output.code() + ); + with_style_sheet(|sheet| { + assert!( + sheet + .names + .values() + .any(|claim| claim.content.contains("abcdefghijklmnopqrstuvwx")), + "{:?}", + sheet.names + ); + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn forced_fingerprint_collision_reports_each_real_location_tier_when_width_is_an_input() { + // Given + let bits = FingerprintBits::new(1).unwrap_or_else(|| panic!("valid width")); + let modes = [("style", "OH"), ("keyframes", "KH")] + .into_iter() + .flat_map(|(factory, domain)| { + ["exact", "call", "export"].map(|mode| (factory, domain, mode)) + }); + // When / Then + for (factory, domain, mode) in modes { + reset_build_state_internal(); + let mut source = format!( + "import {{{factory}}} from '@devup-ui/react';\nconst identity=(factory)=>factory;const native=identity({factory});\n" + ); + for index in 0..3 { + let declaration = format!("{{fontFamily:'abcdefghijklmnopqrstuvwx{index}'}}"); + let rule = if factory == "keyframes" { + format!("{{from:{declaration}}}") + } else { + declaration + }; + let call = match mode { + "exact" => format!("{factory}({rule})"), + "call" => format!("identity({factory})({rule})"), + "export" => format!( + "eval({})", + serde_json::to_string(&format!("native({rule})")) + .unwrap_or_else(|error| panic!("{error}")) + ), + _ => unreachable!(), + }; + writeln!( + source, + "const local{index}={call};export {{local{index} as actual{index}}};" + ) + .unwrap_or_else(|error| panic!("{error}")); + } + let output = extract("tiers.css.ts", &source); + let sheet = StyleSheet::default(); + let error = sheet + .preflight_styles_with_bits(&output.styles, ("tiers.css.ts", true), bits) + .err() + .unwrap_or_else(|| panic!("three distinct atoms must collide in one bit")); + assert_eq!(sheet.names.len(), 0); + assert_ne!(error.first.descriptor, error.second.descriptor); + assert!(error.name.starts_with(domain), "{error}"); + let text = error.to_string(); + for claim in [&error.first, &error.second] { + assert!(text.contains(&claim.content), "{text}"); + match (&claim.location, mode) { + (RealLocation::Exact(origin), "exact") + | (RealLocation::ProducedByCall(origin), "call") => { + let offset = source + .find(&origin.expression) + .unwrap_or_else(|| panic!("not authored: {origin:?}")); + let before = &source[..offset]; + assert_eq!(origin.file, "tiers.css.ts"); + assert_eq!( + origin.line, + before.bytes().filter(|byte| *byte == b'\n').count() + 1 + ); + assert_eq!( + origin.column, + before + .rsplit('\n') + .next() + .unwrap_or_default() + .chars() + .count() + + 1 + ); + } + ( + RealLocation::ModuleExport { + file, + binding: Some(binding), + }, + "export", + ) => { + assert_eq!(file, "tiers.css.ts"); + assert!(binding.starts_with("actual"), "{binding}"); + assert!(source.contains(&format!("as {binding}")), "{binding}"); + assert!( + text.contains("position unavailable (produced inside eval or a callback)"), + "{text}" + ); + } + _ => panic!("wrong tier for {mode}: {claim:?}"), + } + } + assert!(text.contains("change one declaration"), "{text}"); + if mode == "call" { + assert!( + text.contains("produced by the call at tiers.css.ts:"), + "{text}" + ); + } + } + reset_build_state_internal(); +} + +#[test] +#[serial] +fn cached_opaque_claims_still_reject_exact_collisions_when_no_witness_exists() { + // Given + reset_build_state_internal(); + let bits = FingerprintBits::new(1).unwrap_or_else(|| panic!("valid width")); + let mut sheet = StyleSheet::default(); + let mut collision = None; + // When + for index in 0..3 { + let file = format!("cached{index}.css.ts"); + let source = format!( + "import {{style}} from '@devup-ui/react';const native=style;export const actual=eval(\"native({{fontFamily:'abcdefghijklmnopqrstuvwx{index}'}})\");" + ); + let output = extract(&file, &source); + match sheet.preflight_styles_with_bits(&output.styles, (&file, true), bits) { + Ok(claims) => { + sheet.names.extend(claims); + let json = serde_json::to_string(&sheet).unwrap_or_else(|error| panic!("{error}")); + sheet = serde_json::from_str(&json).unwrap_or_else(|error| panic!("{error}")); + } + Err(error) => { + collision = Some(error); + break; + } + } + } + // Then + let error = collision.unwrap_or_else(|| panic!("one-bit collision was not detected")); + for claim in [&error.first, &error.second] { + assert_eq!(claim.origin, None); + assert!( + matches!(&claim.location, RealLocation::ModuleExport { binding: Some(binding), .. } if binding == "actual"), + "{claim:?}" + ); + } + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/content_name_tests.rs b/bindings/devup-ui-wasm/src/content_name_tests.rs new file mode 100644 index 000000000..41ede3885 --- /dev/null +++ b/bindings/devup-ui-wasm/src/content_name_tests.rs @@ -0,0 +1,266 @@ +use super::*; +use serial_test::serial; + +fn compile(file: &str, source: &str) -> Result { + code_extract_internal( + file, + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) +} + +#[test] +#[serial] +fn public_extraction_uses_fixed_short_and_first80_descriptor_goldens() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + let source = "import {css} from '@devup-ui/react'; export const x=css({color:'red',fontFamily:'abcdefghijklmnopqrstuvwx'});"; + let mut bytes = vec![1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 11]; + bytes.extend_from_slice(b"font-family"); + bytes.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0, 24]); + bytes.extend_from_slice(b"abcdefghijklmnopqrstuvwx"); + bytes.extend_from_slice(&[0, 255, 0, 0]); + // When + let output = compile("golden.tsx", source).unwrap_or_else(|error| panic!("{error}")); + // Then + let expected = format!("OH{}", css::content_hash::fingerprint(&bytes)); + assert!(output.code().contains("OLcolor-vred"), "{}", output.code()); + assert!(output.code().contains(&expected), "{}", output.code()); + with_style_sheet(|sheet| assert_eq!(sheet.names[&expected].descriptor, bytes)); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn restored_forced_collision_reports_both_actual_sites_before_global_css_removal() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + compile( + "a.tsx", + "import {css} from '@devup-ui/react';\nexport const x=css({color:'blue'});", + ) + .unwrap_or_else(|error| panic!("{error}")); + compile( + "b.tsx", + "import {globalCss} from '@devup-ui/react'; globalCss({body:{padding:'11px'}});", + ) + .unwrap_or_else(|error| panic!("{error}")); + let serialized = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let mut forced: StyleSheet = + serde_json::from_str(&serialized).unwrap_or_else(|error| panic!("{error}")); + let original = forced.names["OLcolor-vblue"].clone(); + forced.names.insert("OLcolor-vred".into(), original); + import_sheet_internal(forced).unwrap_or_else(|error| panic!("{error}")); + let before = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let css_before = with_style_sheet(|sheet| sheet.create_css(None, false)); + // When + let error = compile( + "b.tsx", + "import {css} from '@devup-ui/react';\nexport const x=css({color:'red'});", + ) + .err() + .unwrap_or_else(|| panic!("forced collision was accepted")); + // Then + assert!(error.contains("a.tsx:2:27:"), "{error}"); + assert!(error.contains("b.tsx:2:27:"), "{error}"); + assert!(error.contains("`'blue'`"), "{error}"); + assert!(error.contains("`'red'`"), "{error}"); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + before + ); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + css_before + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn sheet_import_merges_exact_claims_and_rejects_unequal_claims_atomically() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + compile( + "b.tsx", + "import {css} from '@devup-ui/react'; export const x=css({color:'red'});", + ) + .unwrap_or_else(|error| panic!("{error}")); + let before = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let original: StyleSheet = + serde_json::from_str(&before).unwrap_or_else(|error| panic!("{error}")); + let mut incoming = StyleSheet { + names: original.names, + ..StyleSheet::default() + }; + incoming + .names + .get_mut("OLcolor-vred") + .unwrap_or_else(|| panic!("missing claim")) + .descriptor + .push(99); + // When + let rejected = import_sheet_internal(incoming); + // Then + assert!(rejected.is_err()); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + before + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn rejected_cache_import_cannot_be_hidden_by_a_plugin_catch() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + compile( + "source.tsx", + "import {css} from '@devup-ui/react';export const x=css({color:'red'});", + ) + .unwrap_or_else(|error| panic!("{error}")); + let snapshot = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let mut incomplete: StyleSheet = + serde_json::from_str(&snapshot).unwrap_or_else(|error| panic!("{error}")); + incomplete.names.clear(); + let rejected = import_sheet_internal(incomplete) + .err() + .unwrap_or_else(|| panic!("unprotected cache was imported")); + // When + let next = compile( + "other.tsx", + "import {css} from '@devup-ui/react';export const x=css({padding:'4px'});", + ); + // Then + assert_eq!(next.err(), Some(rejected)); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + snapshot + ); + let valid = serde_json::from_str(&snapshot).unwrap_or_else(|error| panic!("{error}")); + import_sheet_internal(valid).unwrap_or_else(|error| panic!("{error}")); + assert!( + compile( + "other.tsx", + "import {css} from '@devup-ui/react';export const x=css({padding:'4px'});" + ) + .is_ok() + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn evaluated_helper_composition_and_keyframes_keep_factory_call_sites() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + let source = "import {style,keyframes} from '@devup-ui/react';\nconst make=()=>style({color:'red'});\nconst base=make();\nexport const combined=style([base,{padding:'4px'}]);\nexport const fade=keyframes({from:{opacity:0},to:{opacity:1}});"; + // When + let output = compile("actual.css.ts", source).unwrap_or_else(|error| panic!("{error}")); + // Then + assert!( + !output.code().contains("__devup_style_origin"), + "{}", + output.code() + ); + assert!(!output.code().contains("__origin"), "{}", output.code()); + with_style_sheet(|sheet| { + assert!( + sheet + .names + .values() + .any(|claim| claim + .origin + .as_ref() + .is_some_and(|origin| origin.file == "actual.css.ts" + && origin.line == 2 + && origin.expression == "style({color:'red'})")) + ); + assert!( + sheet + .names + .values() + .any(|claim| claim + .origin + .as_ref() + .is_some_and(|origin| origin.file == "actual.css.ts" + && origin.line == 5 + && origin.expression == "keyframes({from:{opacity:0},to:{opacity:1}})")) + ); + assert!(sheet.names.values().all(|claim| claim.origin.is_some())); + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn imported_stylesheet_helpers_keep_the_actual_foreign_factory_site() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + let source = "import {style} from '@devup-ui/react';import {make} from './helper';export const result=make();"; + let resolver = |specifier: &str, _: &str| { + (specifier == "./helper").then(|| ResolvedModule { + path: "helper.ts".into(), + code: "import {style} from '@devup-ui/react';\nexport const make=()=>style({color:'red'});".into(), + }) + }; + // When + let output = code_extract_with_modules_internal( + "root.css.ts", + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert!(!output.code().contains("__origin"), "{}", output.code()); + with_style_sheet(|sheet| { + assert!( + sheet + .names + .values() + .any(|claim| claim + .origin + .as_ref() + .is_some_and(|origin| origin.file == "helper.ts" + && origin.line == 2 + && origin.expression == "style({color:'red'})")) + ); + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn fingerprint_claim_has_a_real_producer_when_factory_flows_through_a_function() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + let source = "import {style} from '@devup-ui/react';const identity=(factory)=>factory;export const cls=identity(style)({fontFamily:'abcdefghijklmnopqrstuvwx'});"; + // When + compile("higher-order.css.ts", source).unwrap_or_else(|error| panic!("{error}")); + // Then + with_style_sheet(|sheet| { + assert!(sheet.names.values().all(|claim| matches!(&claim.location, + css::style_origin::RealLocation::ProducedByCall(origin) if origin.file == "higher-order.css.ts" + && origin.expression == "identity(style)({fontFamily:'abcdefghijklmnopqrstuvwx'})")), "{:?}", sheet.names); + }); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index b9fbe78fe..30b6c3155 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -14,6 +14,11 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt::Display; use std::sync::{LazyLock, Mutex}; use wasm_bindgen::prelude::*; +mod cache_names; +#[cfg(test)] +mod content_location_contract_tests; +#[cfg(test)] +mod content_name_tests; static GLOBAL_STYLE_SHEET: LazyLock> = LazyLock::new(|| Mutex::new(StyleSheet::default())); @@ -74,7 +79,7 @@ impl Output { css_file: Option, import_main_css: bool, dependencies: Vec, - ) -> Self { + ) -> Result { // Use the bucket identity (single-importer collapse) so the sheet's CSS // naming + property bucket + emitted chunk match the canonical class names // the extractor already baked into `code`. Identity when no map is loaded. @@ -83,14 +88,18 @@ impl Output { let canonical_filename = canonical(&filename); let global = single_css || is_global(&filename); with_style_sheet_mut(|sheet| { + sheet + .preflight_styles(&styles, &canonical_filename, global) + .map_err(|error| error.to_string())?; // globalCss (@font-face / global selectors) is per-SOURCE-file, never // collapsed. rm_global_css MUST use the RAW filename so a collapsed // member (sharing the bucket-root's canonical) never wipes the root's // globalCss. Atom property bucketing still uses canonical_filename. let default_collected = sheet.rm_global_css(&filename, global); - let (collected, updated_base_style) = - sheet.update_styles(&styles, &canonical_filename, global); - Self { + let (collected, updated_base_style) = sheet + .update_styles(&styles, &canonical_filename, global) + .map_err(|error| error.to_string())?; + Ok(Self { code, map, css_file, @@ -110,7 +119,7 @@ impl Output { )) } }, - } + }) }) } @@ -218,17 +227,30 @@ 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); +pub fn import_sheet_internal(mut sheet: StyleSheet) -> Result<(), String> { + let result = with_style_sheet_mut(|current| { + cache_names::validate(&sheet)?; + let incoming = sheet::name_registry::preflight(¤t.names, sheet.names.clone()) + .map_err(|error| error.to_string())?; + let mut merged = current.names.clone(); + merged.extend(incoming); + sheet.names = merged; + css::atom_hoist::restore_atom_plan(sheet.atom_plan.clone()); + if !sheet.source_ids.is_empty() { + css::file_map::set_original_ids(sheet.source_ids.clone()); + } + *current = sheet; + Ok(()) + }); + cache_names::record(&result); + result } #[wasm_bindgen(js_name = "importSheet")] #[cfg(not(tarpaulin_include))] pub fn import_sheet(sheet_object: JsValue) -> Result<(), JsValue> { let sheet: StyleSheet = serde_wasm_bindgen::from_value(sheet_object).map_err(js_error)?; - import_sheet_internal(sheet); - Ok(()) + import_sheet_internal(sheet).map_err(js_error) } /// Internal function to export `StyleSheet` as JSON string (testable without `JsValue`) @@ -331,6 +353,7 @@ pub fn seed_file_map(files: Vec) { /// 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() { + cache_names::clear(); css::class_map::reset_class_map(); css::file_map::reset_file_map(); css::file_map::reset_canonical_map(); @@ -471,6 +494,7 @@ fn code_extract_internal_impl( source_map: SourceMapMode, resolver: Option<&ModuleResolver>, ) -> Result { + cache_names::check()?; let option = ExtractOption { package: package.to_string(), css_dir, @@ -491,7 +515,7 @@ fn code_extract_internal_impl( }; match extracted { - Ok(output) => Ok(Output::new( + Ok(output) => Output::new( output.code, output.styles, output.map, @@ -500,7 +524,7 @@ fn code_extract_internal_impl( output.css_file, import_main_css_in_css, output.dependencies, - )), + ), Err(error) => Err(error.to_string()), } } @@ -676,6 +700,7 @@ pub fn get_default_theme() -> Result, JsValue> { #[wasm_bindgen(js_name = "getCss")] #[cfg(not(tarpaulin_include))] pub fn get_css(file_num: Option, import_main_css: bool) -> Result { + cache_names::check().map_err(js_error)?; Ok(with_style_sheet(|sheet| { if let Some(file_num) = file_num { with_file_map(|map| { @@ -1307,7 +1332,8 @@ mod tests { Some("devup-ui-0.css".to_string()), false, Vec::new(), - ); + ) + .unwrap_or_else(|error| panic!("{error}")); // Test getters assert_ne!(output.code(), ""); @@ -1334,7 +1360,8 @@ mod tests { None, false, Vec::new(), - ); + ) + .unwrap_or_else(|error| panic!("{error}")); // Test updated_base_style getter let _ = output.updated_base_style(); @@ -1397,7 +1424,8 @@ mod tests { Some("devup-ui.css".to_string()), true, // import_main_css = true Vec::new(), - ); + ) + .unwrap_or_else(|error| panic!("{error}")); assert!(output.css().is_some()); } @@ -1436,7 +1464,8 @@ mod tests { None, false, Vec::new(), - ); + ) + .unwrap_or_else(|error| panic!("{error}")); // The updated_base_style should be true because global CSS was removed assert!(output.updated_base_style()); @@ -1632,7 +1661,7 @@ mod tests { custom_sheet.add_property("custom.tsx", "color", 0, "red", None, Some(0), None); // Import the custom sheet - import_sheet_internal(custom_sheet); + import_sheet_internal(custom_sheet).unwrap_or_else(|error| panic!("{error}")); // Verify the sheet was imported by exporting it let result = export_sheet_internal(); diff --git a/bindings/devup-ui-wasm/tests/content-names.mjs b/bindings/devup-ui-wasm/tests/content-names.mjs new file mode 100644 index 000000000..d39ecc07d --- /dev/null +++ b/bindings/devup-ui-wasm/tests/content-names.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const wasm = require('../pkg/index.js') +const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789_' +const size = (value) => { + const bytes = Buffer.alloc(8) + bytes.writeBigUInt64BE(BigInt(value)) + return bytes +} +const text = (value) => + Buffer.concat([size(Buffer.byteLength(value)), Buffer.from(value)]) +const atom = (property, value) => + Buffer.concat([ + Buffer.from([1, 1, 0]), + text(property), + Buffer.from([1]), + text(value), + Buffer.from([0, 255, 0, 0]), + ]) +const fingerprint = (descriptor) => { + const first80 = createHash('sha256') + .update(descriptor) + .digest() + .subarray(0, 10) + let value = BigInt(`0x${first80.toString('hex')}`) + let result = '' + for (let digit = 0; digit < 16; digit++) { + result = alphabet[Number(value % 37n)] + result + value /= 37n + } + assert.equal(value, 0n) + return result +} +const compile = (file, code) => + wasm.codeExtract(file, code, '@devup-ui/react', 'df', true, false, false, {}) + +wasm.resetBuildState() +wasm.setDebug(false) +if (process.argv[2] === 'goldens') { + const source = + "import {css,keyframes} from '@devup-ui/react'; export const x=css({color:'red',fontFamily:'abcdefghijklmnopqrstuvwx'});export const k=keyframes({from:{opacity:0},to:{opacity:1}});" + const output = compile('golden.tsx', source) + const family = + 'OH' + fingerprint(atom('font-family', 'abcdefghijklmnopqrstuvwx')) + assert.ok(output.code.includes('OLcolor-vred')) + assert.ok(output.code.includes(family)) + const frames = Buffer.concat([ + Buffer.from([1, 3]), + size(2), + text('from'), + size(1), + text('opacity'), + text('0'), + text('to'), + size(1), + text('opacity'), + text('1'), + ]) + const animation = 'KH' + fingerprint(frames) + assert.ok(output.code.includes(animation)) + assert.ok(wasm.getCss(null, false).includes('@keyframes ' + animation)) + const sheet = JSON.parse(wasm.exportSheet()) + assert.deepEqual(sheet.names[family].descriptor, [ + ...atom('font-family', 'abcdefghijklmnopqrstuvwx'), + ]) + assert.deepEqual(sheet.names[animation].descriptor, [...frames]) + output.free() +} else if (process.argv[2] === 'cache-conflict') { + compile( + 'a.tsx', + "import {css} from '@devup-ui/react';\nexport const x=css({color:'blue'});", + ).free() + compile( + 'b.tsx', + "import {globalCss} from '@devup-ui/react'; globalCss({body:{padding:'11px'}});", + ).free() + const sheet = JSON.parse(wasm.exportSheet()) + sheet.names['OLcolor-vred'] = sheet.names['OLcolor-vblue'] + wasm.importSheet(sheet) + const before = wasm.exportSheet() + const css = wasm.getCss(null, false) + assert.throws( + () => + compile( + 'b.tsx', + "import {css} from '@devup-ui/react';\nexport const x=css({color:'red'});", + ), + (error) => { + assert.ok(error instanceof Error) + assert.ok(error.message.includes('a.tsx:2:27:')) + assert.ok(error.message.includes('b.tsx:2:27:')) + assert.ok(error.message.includes('OLcolor-vred')) + return true + }, + ) + assert.equal(wasm.exportSheet(), before) + assert.equal(wasm.getCss(null, false), css) +} else { + assert.fail('unknown fixture mode') +} +wasm.resetBuildState() diff --git a/bindings/devup-ui-wasm/tests/site_variable_names.rs b/bindings/devup-ui-wasm/tests/site_variable_names.rs new file mode 100644 index 000000000..3fa266ffc --- /dev/null +++ b/bindings/devup-ui-wasm/tests/site_variable_names.rs @@ -0,0 +1,170 @@ +#![cfg(not(target_arch = "wasm32"))] + +use std::collections::{BTreeMap, BTreeSet}; + +use extractor::extract_style::extract_style_value::ExtractStyleValue; +use extractor::{ExtractOption, ResolvedModule, extract, extract_with_modules}; +use rstest::rstest; +use serial_test::serial; +use sheet::StyleSheet; + +const FILE: &str = "/src/shared.tsx"; +const IMPORTED_SELECTOR: &str = "import {Box} from '@devup-ui/react';import {SEL} from 'conditional';export const View=({pad})=>;"; + +fn reset(debug: bool, atom: bool) { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::file_map::seed_file_numbers(&[FILE.to_string()]); + css::debug::set_debug(debug); + css::atom_hoist::set_atom_hoist(atom.then_some(1)); + css::atom_hoist::restore_atom_plan(None); + css::set_prefix(Some("test-".to_string())); +} + +fn imported_order( + order: [&'static str; 2], + mode: (bool, bool), +) -> (BTreeMap, String) { + reset(mode.0, mode.1); + let mut sheet = StyleSheet::default(); + let mut code = BTreeMap::new(); + let mut variables = BTreeSet::new(); + for selector in order { + let resolver = move |_: &str, _: &str| { + Some(ResolvedModule { + path: format!("/pkg/{}.ts", selector.trim_start_matches("&:")), + code: format!("export const SEL='{selector}';"), + }) + }; + let output = extract_with_modules( + FILE, + IMPORTED_SELECTOR, + ExtractOption::default(), + false, + &resolver, + ) + .unwrap_or_else(|error| panic!("imported selector extraction failed: {error}")); + for style in &output.styles { + if let ExtractStyleValue::Dynamic(style) = style { + variables.insert(style.variable_name()); + } + } + sheet + .update_styles(&output.styles, FILE, false) + .unwrap_or_else(|error| panic!("{error}")); + code.insert(selector.to_string(), output.code); + } + assert_eq!( + variables.len(), + 1, + "one original assignment must share one variable" + ); + let css = sheet.create_css(Some(FILE), false); + assert!(css.contains(":hover"), "missing hover rule: {css}"); + assert!(css.contains(":focus"), "missing focus rule: {css}"); + (code, css) +} + +#[rstest] +#[case(false, false)] +#[case(true, false)] +#[case(false, true)] +#[case(true, true)] +#[serial] +fn imported_selector_bytes_match_when_environment_order_reverses( + #[case] debug: bool, + #[case] atom: bool, +) { + // Given: the same source assignment, with an environment-dependent import. + let mode = (debug, atom); + // When: both environments extract into the real sheet in opposite orders. + let ab = imported_order(["&:hover", "&:focus"], mode); + let ba = imported_order(["&:focus", "&:hover"], mode); + // Then: every byte of both emitted JS modules and CSS agrees. + assert_eq!(ab, ba); +} + +#[rstest] +#[case( + "import {Box} from '@devup-ui/react';export const View=({pad,gap,left,right})=>;", + 6 +)] +#[case( + "import {Box} from '@devup-ui/react';export const View=({pad})=>;", + 2 +)] +#[case( + "import * as stylex from '@stylexjs/stylex';const styles=stylex.create({dynamic:(width,height)=>({width,height,paddingLeft:width,paddingRight:height})});export const View=({width,height})=>
;", + 4 +)] +#[case( + "import styled from '@emotion/styled';export const View=styled.div`padding: ${p=>p.pad}; margin: ${p=>p.gap};`;", + 2 +)] +#[case( + "import styled from '@emotion/styled';export const View=styled.div`padding: ${p=>p.pad}px; margin: ${p=>p.gap}em;`;", + 2 +)] +#[serial] +fn different_assignments_remain_distinct_when_only_site_names_variables( + #[case] source: &str, + #[case] minimum_styles: usize, +) { + for mode in [(false, false), (true, false), (false, true), (true, true)] { + // Given: real shorthand, responsive, multi-parameter or template syntax. + reset(mode.0, mode.1); + // When: the native extractor and sheet compile it. + let mut option = ExtractOption::default(); + option.import_aliases.insert( + "@emotion/styled".to_string(), + extractor::ImportAlias::DefaultToNamed("styled".to_string()), + ); + let output = extract(FILE, source, option) + .unwrap_or_else(|error| panic!("dynamic fixture extraction failed: {error}")); + let mut sheet = StyleSheet::default(); + sheet + .update_styles(&output.styles, FILE, false) + .unwrap_or_else(|error| panic!("{error}")); + let css = sheet.create_css(Some(FILE), false); + let mut assignments = BTreeMap::new(); + let mut count = 0; + for style in &output.styles { + if let ExtractStyleValue::Dynamic(style) = style { + count += 1; + let variable = style.variable_name(); + assert!(variable.starts_with("---test-S")); + assert!( + output.code.contains(&variable), + "missing JS assignment: {}", + output.code + ); + assert!( + css.contains(&format!("var({variable})")), + "missing CSS use: {css}" + ); + // Then: sharing is legal only for identical assignment values. + if let Some(previous) = assignments.insert(variable.clone(), style.identifier()) { + assert_eq!( + previous, + style.identifier(), + "different values alias {variable}: {}", + output.code + ); + } + } + } + assert!( + count >= minimum_styles, + "fixture did not exercise its dynamic styles: {}", + output.code + ); + assert!( + assignments.len() >= 2, + "fixture must preserve distinct values" + ); + } +} + +#[path = "site_variable_names/sparse.rs"] +mod sparse; diff --git a/bindings/devup-ui-wasm/tests/site_variable_names/sparse.rs b/bindings/devup-ui-wasm/tests/site_variable_names/sparse.rs new file mode 100644 index 000000000..07f65ab93 --- /dev/null +++ b/bindings/devup-ui-wasm/tests/site_variable_names/sparse.rs @@ -0,0 +1,261 @@ +use super::*; + +fn compiled(source: &str, mode: (bool, bool, bool)) -> (String, String, BTreeSet) { + reset(mode.0, mode.1); + let mut option = ExtractOption { + single_css: mode.2, + ..ExtractOption::default() + }; + option.import_aliases.insert( + "@emotion/styled".to_string(), + extractor::ImportAlias::DefaultToNamed("styled".to_string()), + ); + let output = extract(FILE, source, option) + .unwrap_or_else(|error| panic!("sparse site extraction failed: {error}")); + let variables = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style.variable_name()), + _ => None, + }) + .collect(); + let mut sheet = StyleSheet::default(); + sheet + .update_styles(&output.styles, FILE, mode.2) + .unwrap_or_else(|error| panic!("{error}")); + ( + output.code, + sheet.create_css((!mode.2).then_some(FILE), false), + variables, + ) +} + +#[rstest] +#[case(false, false, false)] +#[case(true, false, false)] +#[case(false, true, false)] +#[case(true, true, false)] +#[case(false, false, true)] +#[case(true, false, true)] +#[case(false, true, true)] +#[case(true, true, true)] +#[serial] +fn full_outputs_match_when_line_endings_and_leading_bom_change( + #[case] debug: bool, + #[case] atom: bool, + #[case] single: bool, +) { + // Given: alias edits and multiple composite values sharing a template span. + let source = "import styled from '@emotion/styled';\nexport const View=styled.div`\n padding: ${p=>p.pad}px;\n margin: ${p=>p.gap}em;\n`;\n"; + let expected = compiled(source, (debug, atom, single)); + // When: the same source is received with CRLF, with and without a BOM. + for input in [ + source.replace('\n', "\r\n"), + format!("\u{feff}{source}"), + format!("\u{feff}{}", source.replace('\n', "\r\n")), + ] { + let actual = compiled(&input, (debug, atom, single)); + // Then: full JS, CSS (including resets), and identities agree byte for byte. + assert_eq!(actual, expected); + } +} + +#[test] +#[serial] +fn golden_site_uses_d9_and_received_expression_offset_in_base37() { + // Given: D9 source 27 and an expression starting at received-source byte 52. + reset(false, false); + css::file_map::set_original_ids(BTreeMap::from([(FILE.to_string(), 27)])); + let source = "import {Box} from '@devup-ui/react';const V=;"; + // When: the real extractor chooses the sparse identity. + let output = extract(FILE, source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("golden extraction failed: {error}")); + let names: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style.variable_name()), + _ => None, + }) + .collect(); + // Then: both coordinates use base37, not decimal or extraction counters. + assert_eq!(names, ["---test-Saa-az"]); +} + +#[test] +#[serial] +fn stylex_site_uses_value_expression_offset_when_property_key_is_earlier() { + // Given: a StyleX property with distinct key and value-expression positions. + reset(false, false); + let source = "import * as stylex from '@stylexjs/stylex';const styles=stylex.create({dynamic:(amount)=>({width: /* value */ amount})});export const View=({amount})=>
;"; + let at = source + .find("amount})") + .unwrap_or_else(|| panic!("fixture must contain the value expression")); + let key_at = source + .find("width:") + .unwrap_or_else(|| panic!("fixture must contain the property key")); + assert!(key_at < at); + let expected = css::Site { + file: css::sparse_site::SourceFile::D9(0), + at, + role: 0, + } + .variable_name("test-"); + let key_name = css::Site { + file: css::sparse_site::SourceFile::D9(0), + at: key_at, + role: 0, + } + .variable_name("test-"); + // When: the extractor assigns a sparse identity to the dynamic StyleX value. + let output = extract(FILE, source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("StyleX position extraction failed: {error}")); + let names: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style.variable_name()), + _ => None, + }) + .collect(); + // Then: the source-derived golden uses the value offset, never the property key. + assert_eq!(names, [expected]); + assert_ne!(names, [key_name]); +} + +#[test] +#[serial] +fn unnumbered_names_use_source_alone_when_filenames_and_arrival_order_change() { + // Given: identical, distinct and BOM/CRLF-normalized sources without D9 numbers. + let source = "import {Box} from '@devup-ui/react';export const View=({pad})=>;"; + let different = format!("{source}// distinct received source"); + let normalized = format!("\u{feff}{}", source.replace(';', ";\r\n")); + let lf = source.replace(';', ";\n"); + let names = |reverse| { + reset(false, false); + css::file_map::set_original_ids(BTreeMap::new()); + let mut inputs = [ + ("/unseeded/first.tsx", source), + ("/different/root/second.tsx", source), + ("/unseeded/third.tsx", different.as_str()), + ("/unseeded/lf.tsx", lf.as_str()), + ("/unseeded/bom-crlf.tsx", normalized.as_str()), + ]; + if reverse { + inputs.reverse(); + } + inputs + .into_iter() + .map(|(filename, input)| { + let output = extract(filename, input, ExtractOption::default()) + .unwrap_or_else(|error| panic!("unnumbered extraction failed: {error}")); + let variables = output + .styles + .into_iter() + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style.variable_name()), + _ => None, + }) + .collect::>(); + (filename, variables) + }) + .collect::>() + }; + // When: every file is extracted in both arrival orders. + let first = names(false); + let later = names(true); + // Then: full normalized source, never filename or arrival order, determines each name. + assert_eq!(first, later); + let identical = &first["/unseeded/first.tsx"]; + assert_eq!(identical.len(), 1); + assert_eq!(identical, &first["/different/root/second.tsx"]); + assert_ne!(identical, &first["/unseeded/third.tsx"]); + assert_eq!(first["/unseeded/lf.tsx"], first["/unseeded/bom-crlf.tsx"]); + assert!(identical.iter().all(|name| name.starts_with("---test-SU"))); +} + +#[test] +#[serial] +fn later_template_role_stays_named_when_an_earlier_import_becomes_dynamic() { + // Given: two source interpolations; an import is static in only one environment. + let source = "import styled from '@emotion/styled';import {FIRST} from 'conditional';export const View=styled.div`padding:${FIRST}px;margin:${p=>p.gap}em;`;"; + let environment = |known: bool| { + reset(false, false); + let mut option = ExtractOption::default(); + option.import_aliases.insert( + "@emotion/styled".to_string(), + extractor::ImportAlias::DefaultToNamed("styled".to_string()), + ); + let resolver = move |_: &str, _: &str| { + known.then(|| ResolvedModule { + path: "/pkg/conditional.ts".to_string(), + code: "export const FIRST=1;".to_string(), + }) + }; + let output = extract_with_modules(FILE, source, option, false, &resolver) + .unwrap_or_else(|error| panic!("template role extraction failed: {error}")); + let dynamic: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style), + _ => None, + }) + .collect(); + let later: Vec<_> = dynamic + .iter() + .filter(|style| style.property() == "margin") + .map(|style| style.variable_name()) + .collect(); + (dynamic.len(), later) + }; + // When: the earlier interpolation changes its dynamic eligibility. + let known = environment(true); + let runtime = environment(false); + // Then: a source-order role cannot shift with the count of extracted dynamic values. + assert_eq!(known.0, 1); + assert_eq!(runtime.0, 2); + assert_eq!(known.1.len(), 1); + assert_eq!(known.1, runtime.1); +} + +#[test] +#[serial] +fn generated_namespace_stays_disjoint_when_theme_tokens_resemble_site_names() { + // Given: legitimate theme keys occupying the old and plausible generated grammars. + let theme: sheet::theme::Theme = serde_json::from_str(r##"{ + "colors":{"default":{"test-S0-121":"#f00","test-Sa-a":"#00f","test-Saa-ad-b":"#f0f","test.SUfoo.a":"#0f0","_token":"#000","1token":"#fff"}} + }"##).unwrap_or_else(|error| panic!("legitimate theme keys must remain accepted: {error}")); + reset(false, false); + let mut sheet = StyleSheet::default(); + sheet.set_theme(theme); + let theme_css = sheet.create_css(None, false); + let source = "import {Box} from '@devup-ui/react';export const View=({pad})=>;"; + // When: the real extractor names an owner variable alongside theme variables. + let output = extract(FILE, source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("theme extraction failed: {error}")); + let variables: BTreeSet<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style.variable_name()), + _ => None, + }) + .collect(); + // Then: the theme remains intact; generated and legacy namespaces cannot equal it. + assert!(theme_css.contains("--test-Sa-a:")); + assert_eq!(variables.len(), 1); + for variable in variables { + assert!(variable.starts_with("---test-S")); + assert!(!theme_css.contains(&format!("{variable}:"))); + for mode in [(false, false), (true, false), (false, true), (true, true)] { + css::debug::set_debug(mode.0); + css::atom_hoist::set_atom_hoist(mode.1.then_some(1)); + assert_ne!(variable, css::sheet_to_variable_name("padding", 0, None)); + } + } + css::debug::set_debug(false); + css::atom_hoist::set_atom_hoist(None); + sheet.set_theme(sheet::theme::Theme::default()); +} diff --git a/bindings/devup-ui-wasm/tests/source_naming.rs b/bindings/devup-ui-wasm/tests/source_naming.rs new file mode 100644 index 000000000..594607018 --- /dev/null +++ b/bindings/devup-ui-wasm/tests/source_naming.rs @@ -0,0 +1,169 @@ +use std::collections::BTreeMap; + +use extractor::{ExtractOption, ResolvedModule, extract_with_modules}; +use rstest::rstest; +use serial_test::serial; + +type EnvironmentOutputs = BTreeMap, String)>; + +fn environment_order( + source: &str, + values: [&str; 2], + order: [usize; 2], +) -> (EnvironmentOutputs, String) { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::debug::set_debug(false); + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + let stylesheet = source.contains("import {style}"); + let file = if stylesheet { + "/src/shared.css.ts" + } else { + "/src/shared.tsx" + }; + css::file_map::seed_file_numbers(&[file.to_string()]); + let mut outputs = BTreeMap::new(); + let mut final_sheet = sheet::StyleSheet::default(); + for environment in order { + let value = values[environment].to_string(); + let resolver = move |specifier: &str, _: &str| { + Some(ResolvedModule { + path: if specifier == "./barrel" { + "/src/barrel.ts".to_string() + } else if environment == 0 { + "/src/data.ts".to_string() + } else { + "/src/data.client.ts".to_string() + }, + code: if specifier == "./barrel" { + concat!("export {", "value", "} from './data';").to_string() + } else { + format!("export const value={value};") + }, + }) + }; + let output = extract_with_modules(file, source, ExtractOption::default(), true, &resolver) + .unwrap_or_else(|error| panic!("{error}")); + final_sheet + .update_styles(&output.styles, file, false) + .unwrap_or_else(|error| panic!("{error}")); + let mut sheet = sheet::StyleSheet::default(); + sheet + .update_styles(&output.styles, file, false) + .unwrap_or_else(|error| panic!("{error}")); + let css = sheet.create_css(Some(file), false); + assert!(css.contains("padding:16px"), "{css}"); + if stylesheet { + assert!(!css.contains(".a-a{"), "{css}"); + } else { + assert!(css.contains(".a-a{padding:16px}"), "{css}"); + assert!(output.code.contains("a-a"), "{}", output.code); + } + let expected_colors: &[&str] = match values[environment] { + "'red'" | "true" | "['red']" => &["red"], + "'blue'" | "false" => &["blue"], + "['red','blue']" => &["red", "blue"], + value => panic!("unexpected fixture value: {value}"), + }; + for color in expected_colors { + let declaration = format!("color:{color}"); + let identities: Vec<_> = css + .split('}') + .filter_map(|rule| rule.rsplit_once('{')) + .filter(|(_, body)| *body == declaration) + .map(|(selector, _)| { + let selector = selector + .rsplit_once('{') + .map_or(selector, |(_, selector)| selector); + selector + .rsplit_once("*/") + .map_or(selector, |(_, selector)| selector) + }) + .collect(); + assert_ne!(identities.len(), 0, "{css}"); + for identity in &identities { + assert_ne!(*identity, ".a-a", "{css}"); + assert!( + output.code.contains(identity.trim_start_matches('.')), + "{} / {css}", + output.code + ); + } + if source.contains("export const local") { + assert_eq!(identities.len(), 2, "{css}"); + assert_ne!(identities[0], identities[1], "{css}"); + assert!(identities.contains(&".a-b"), "{css}"); + } + } + assert!( + output + .map + .as_ref() + .unwrap_or_else(|| panic!("missing original-source map")) + .contains(&source.replace('"', "\\\"")) + ); + outputs.insert(environment, (output.code, output.map, css)); + } + (outputs, final_sheet.create_css(Some(file), false)) +} + +#[rstest] +#[case::relative("import {value} from './data';", "value", ["'red'", "'blue'"])] +#[case::bare("import {value} from 'data';", "value", ["'red'", "'blue'"])] +#[case::reexport("import {value} from './barrel';", "value", ["'red'", "'blue'"])] +#[case::local_chain( + "import {value} from './data';const a=value;const b=a;", + "b", + ["'red'", "'blue'"] +)] +#[case::control( + "import {value} from './data';", + "value ? 'red' : 'blue'", + ["true", "false"] +)] +#[case::responsive_shape( + "import {value} from './data';", + "value", + ["['red']", "['red','blue']"] +)] +#[case::equal_content("import {value} from './data';", "value", ["'red'", "'red'"])] +#[case::functions( + "import {value} from './data';function get(){return value;}const a=()=>get();", + "a()", + ["'red'", "'blue'"] +)] +#[serial] +fn names_when_environment_order_changes( + #[case] declarations: &str, + #[case] expression: &str, + #[case] values: [&str; 2], +) { + // Given: the same source resolves plain on the server and remapped on the client. + let local = if values == ["'red'", "'red'"] { + "export const local=css({color:'red'});" + } else { + "" + }; + let source = format!( + "import {{css}} from '@devup-ui/react';{declarations}export const cls=css({{color:{expression},padding:'16px'}});{local}" + ); + // When: both environments compile in opposite orders. + let forward = environment_order(&source, values, [0, 1]); + let reverse = environment_order(&source, values, [1, 0]); + // Then: full code/maps and materialized CSS agree, with no imported Own slots. + assert_eq!(forward, reverse); +} + +#[test] +#[serial] +fn stylesheet_names_when_environment_order_changes() { + // Given: executed styles depend on external module data. + let source = "import {style} from '@devup-ui/react';import {value} from './data';const choose=()=>value;export const cls=style({color:choose(),padding:'16px'});"; + // When: module loading runs under both environment orders. + let forward = environment_order(source, ["'red'", "'blue'"], [0, 1]); + let reverse = environment_order(source, ["'red'", "'blue'"], [1, 0]); + // Then: evaluated stylesheet code, original-source maps and CSS are identical. + assert_eq!(forward, reverse); +} diff --git a/bindings/devup-ui-wasm/tests/value-evaluation.mjs b/bindings/devup-ui-wasm/tests/value-evaluation.mjs new file mode 100644 index 000000000..2333cb549 --- /dev/null +++ b/bindings/devup-ui-wasm/tests/value-evaluation.mjs @@ -0,0 +1,318 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' + +const require = createRequire(import.meta.url) +const landing = createRequire( + new URL('../../../apps/landing/package.json', import.meta.url), +) +const React = landing('react') +assert.ok( + !process.argv[4] || process.env.VALUE_EVALUATION_QA === '1', + 'alternate WASM is private QA only', +) +const wasm = require(process.argv[4] ?? '../pkg/index.js') +const cases = Object.freeze({ + 'extract_conditional_style_props-2': ['Box', 'margin', 'a === b ? c : d'], + 'extract_dynamic_logical_case-3': ['Box', 'margin', 'a ?? b'], + 'extract_responsive_conditional_style_props-3': [ + 'Box', + 'margin', + 'a === b ? c : [d, e, f, "2px"]', + ], + 'extract_responsive_conditional_style_props-4': [ + 'Box', + 'margin', + 'a === b ? c : [d, e, f, x === y ? "4px" : "2px"]', + ], + 'extract_responsive_conditional_style_props-5': [ + 'Box', + 'margin', + 'a === b ? [d, e, f, x === y ? "4px" : "2px"] : c', + ], + member_expression_with_dynamic_values: [ + 'Box', + 'bg', + '({ a: first, b: second })[key]', + ], + props_direct_hybrid_responsive_select: [ + 'Flex', + 'gap', + '[[a, 1, c], [d, e, 2]][idx]', + ], + props_direct_variable_array_responsive_select: [ + 'Flex', + 'gap', + '[[a, b, c], [d, e, f]][idx]', + ], + 'props_direct_variable_array_responsive_select-2': [ + 'Flex', + 'gap', + '[, [d, e, f]][idx]', + ], + props_direct_variable_object_responsive_select: [ + 'Flex', + 'gap', + '{ 0: [a, b, c], "1": [d, e, f] }[idx]', + ], + logical_or: ['Box', 'margin', 'a || b'], + logical_and: ['Box', 'margin', 'a && b'], + getter_member: ['Box', 'margin', 'store.value'], + null_member: ['Box', 'margin', 'a === b ? c : store.value'], + index_side_effects: ['Flex', 'gap', '[[a, b, c], [d, e, f]][index()]'], +}) +for (const fixture of Object.values(cases)) Object.freeze(fixture) +const name = process.argv[2] +assert.ok(Object.hasOwn(cases, name), `unknown fixture ${name}`) +const [component, prop, expression] = cases[name] +const singleCss = process.argv[3] === 'single' +assert.ok(['single', 'ordinary'].includes(process.argv[3])) +wasm.resetBuildState() +wasm.setDebug(false) +wasm.registerTheme({ breakpoints: [0, 100, 200, 300, 400] }) +const source = `import { ${component} } from '@devup-ui/react'; globalThis.element = <${component} ${prop}={${expression}} />;` +const output = wasm.codeExtract( + 'value.tsx', + source, + '@devup-ui/react', + 'df', + singleCss, + false, + false, + {}, +) +const code = output.code +const css = + wasm.getCss(null, false) + (singleCss ? '' : '\n' + (output.css ?? '')) +output.free() +// Transpile only JSX; execute both programs in native Node VM contexts below. +const programs = JSON.parse( + execFileSync( + 'bun', + [ + '--eval', + ` +const input = await Bun.stdin.text(); +const transpiler = new Bun.Transpiler({loader:'tsx', tsconfig:{compilerOptions:{jsx:'react',jsxFactory:'React.createElement'}}}); +process.stdout.write(JSON.stringify(JSON.parse(input).map(code => transpiler.transformSync(code.replace(/import\\s*(?:\\{[^}]*\\}\\s*from\\s*)?["'][^"']+["'];?/g,''))))); +`, + ], + { + input: JSON.stringify([source, code]), + encoding: 'utf8', + timeout: 30_000, + cwd: fileURLToPath(new URL('../../../', import.meta.url)), + }, + ), +) + +// Fixtures are immutable; each execution owns its trace and read counters. +const scenarios = Object.freeze([ + ...[true, false].flatMap((equal) => + [0, 1].map((index) => Object.freeze({ equal, index })), + ), + ...[0, false, '', null, undefined].map((left) => + Object.freeze({ equal: true, index: 0, left, nullish: true }), + ), + ...[0, false, ''].map((left) => + Object.freeze({ equal: true, index: 0, left, nullish: true, tdz: 'b' }), + ), + Object.freeze({ equal: true, index: 0, throwing: 'd,e,f,x,y' }), + Object.freeze({ equal: false, index: 1, throwing: 'd,e,f,x,y' }), + Object.freeze({ equal: false, index: 1, throwing: 'c' }), + Object.freeze({ equal: true, index: 0, throwing: 'c' }), + Object.freeze({ equal: true, index: 0, tdz: 'd' }), + Object.freeze({ equal: false, index: 1, tdz: 'd' }), + Object.freeze({ equal: false, index: 1, tdz: 'c' }), + Object.freeze({ equal: true, index: 0, tdz: 'c' }), + Object.freeze({ + equal: true, + index: 0, + throwing: 'b', + left: '7px', + nullish: true, + }), + Object.freeze({ equal: true, index: 0, throwing: 'second' }), + Object.freeze({ equal: true, index: 0, changing: true }), + Object.freeze({ equal: true, index: 0, changingControl: true }), + Object.freeze({ equal: true, index: 0, nullMember: true }), + Object.freeze({ equal: false, index: 1, nullMember: true }), +]) +function evaluate(program, scenario) { + const trace = [] + const reads = new Map() + const values = { + a: scenario.equal ? 'same' : 'other', + b: 'same', + c: '11px', + d: '22px', + e: '33px', + f: '44px', + x: scenario.index, + y: 0, + first: 'red', + second: 'blue', + idx: scenario.index, + key: scenario.index ? 'b' : 'a', + } + if (prop === 'gap') Object.assign(values, { a: '11px', b: '12px', c: '13px' }) + if (scenario.nullish) Object.assign(values, { a: scenario.left, b: '55px' }) + const sandbox = { React, Box: component, Flex: component } + const read = (key) => { + trace.push(key) + const count = reads.get(key) ?? 0 + reads.set(key, count + 1) + if (scenario.throwing?.split(',').includes(key)) + throw new ReferenceError(key) + if (scenario.changingControl && key === 'a') return count ? 'other' : 'same' + if (scenario.changing && (key === 'idx' || key === 'key')) { + return key === 'idx' ? count % 2 : count % 2 ? 'b' : 'a' + } + return values[key] + } + for (const key of Object.keys(values)) + Object.defineProperty(sandbox, key, { get: () => read(key) }) + Object.assign(sandbox, { + store: scenario.nullMember + ? null + : { + get value() { + trace.push('getter') + return '66px' + }, + }, + index: () => { + trace.push('index') + return scenario.index + }, + }) + try { + runInNewContext( + `(() => { ${program}\n${scenario.tdz ? `let ${scenario.tdz};` : ''} })()`, + sandbox, + { timeout: 1000 }, + ) + return { trace, element: sandbox.element, error: null } + } catch (error) { + // Cross-realm errors are serialized at this execution boundary, never ignored. + assert.ok(error && typeof error.name === 'string') + return { trace, element: null, error: error.name } + } +} + +function selectedValues(element) { + const classes = new Set((element.props.className ?? '').split(/\s+/)) + const selected = new Map() + const property = prop === 'bg' ? 'background' : prop + // Parse nested rule blocks, carrying the actual media breakpoint into leaf rules. + function rules(text, level) { + let cursor = 0 + while (cursor < text.length) { + const open = text.indexOf('{', cursor) + if (open < 0) break + const header = text.slice(cursor, open).trim() + let end = open + 1 + let depth = 1 + while (depth && end < text.length) { + if (text[end] === '{') depth++ + if (text[end] === '}') depth-- + end++ + } + assert.equal(depth, 0, 'balanced emitted CSS') + const body = text.slice(open + 1, end - 1) + if (header.startsWith('@')) { + const minimum = /min-width:\s*(\d+)px/.exec(header) + rules(body, minimum ? Number(minimum[1]) / 100 : level) + } else { + const identifiers = [...header.matchAll(/\.([\w-]+)/g)].map( + (match) => match[1], + ) + if (identifiers.some((className) => classes.has(className))) { + for (const declaration of body.split(';')) { + const colon = declaration.indexOf(':') + if (declaration.slice(0, colon).trim() !== property) continue + const raw = declaration.slice(colon + 1).trim() + const variable = /^var\((--[^,)]+)\)$/.exec(raw) + const value = variable ? element.props.style?.[variable[1]] : raw + selected.set( + level, + value == null || value === false || value === '' + ? null + : String(value) === '0px' + ? '0' + : String(value), + ) + } + } + } + cursor = end + } + } + rules(css, 0) + return [...selected] + .filter(([, value]) => value !== null) + .sort(([a], [b]) => a - b) +} + +const failures = [] +for (const [index, scenario] of scenarios.entries()) { + // Given: the authored expression, with observable identifier/getter/index reads. + const authored = evaluate(programs[0], scenario) + // When: execute public-WASM output with exactly the same runtime bindings. + const compiled = evaluate(programs[1], scenario) + const value = authored.element?.props[prop] + // Devup's AND controller is a style guard, including JavaScript's numeric zero. + const guarded = name === 'logical_and' && scenario.nullish && !scenario.left + const expected = ( + guarded ? [] : Array.isArray(value) ? Array.from(value) : [value] + ).flatMap((entry, level) => + entry == null || entry === false || entry === '' + ? [] + : [ + [ + level, + prop === 'gap' && typeof entry === 'number' && entry !== 0 + ? `${entry * 4}px` + : String(entry) === '0px' + ? '0' + : String(entry), + ], + ], + ) + const actual = compiled.element ? selectedValues(compiled.element) : [] + const evidence = { + name, + mode: process.argv[3], + index, + scenario, + authored: { + trace: authored.trace, + error: authored.error, + values: expected, + }, + compiled: { trace: compiled.trace, error: compiled.error, values: actual }, + } + try { + // Then: selection, values, errors and exact source evaluation order/count agree. + assert.deepEqual(compiled.trace, authored.trace) + assert.equal(compiled.error, authored.error) + assert.deepEqual(actual, expected) + } catch (error) { + if (!(error instanceof assert.AssertionError)) throw error + failures.push(evidence) + } +} +if (process.env.VALUE_EVALUATION_QA === '1') { + process.stdout.write( + JSON.stringify({ + name, + mode: process.argv[3], + scenarios: scenarios.length, + failures, + }) + '\n', + ) +} else { + assert.deepEqual(failures, [], JSON.stringify(failures, null, 2)) +} diff --git a/e2e/environment-dynamic-driver.ts b/e2e/environment-dynamic-driver.ts new file mode 100644 index 000000000..457bff047 --- /dev/null +++ b/e2e/environment-dynamic-driver.ts @@ -0,0 +1,63 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const repo = fileURLToPath(new URL('../', import.meta.url)) + +export const dynamicModes = [ + { atomHoist: undefined, singleCss: false }, + { atomHoist: undefined, singleCss: true }, + { atomHoist: 2, singleCss: false }, + { atomHoist: 2, singleCss: true }, +] as const + +export function renderDynamicSite( + source: string, + render: string, + mode: (typeof dynamicModes)[number], +): string { + const root = mkdtempSync(join(tmpdir(), 'devup-dynamic-sites-')) + try { + return execFileSync( + 'bun', + [ + '--eval', + String.raw` +import { createRequire } from 'node:module'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +const require = createRequire(${JSON.stringify(join(repo, 'apps/landing/package.json'))}); +const React = require('react'); +const { renderToStaticMarkup } = require('react-dom/server'); +const wasm = (await import(${JSON.stringify(pathToFileURL(resolve(repo, 'bindings/devup-ui-wasm/pkg/index.js')).href)})).default; +const filename = 'src/view.tsx'; +const source = 'import React from ' + JSON.stringify(pathToFileURL(require.resolve('react')).href) + '; import { Box } from "@devup-ui/react"; ' + ${JSON.stringify(source)}; +wasm.setDebug(false); +wasm.seedFileMap([filename]); +wasm.setAtomHoist(${JSON.stringify(mode.atomHoist) ?? 'undefined'}); +wasm.importFileRoutes({ [filename]: [0, 1] }); +const output = wasm.codeExtract(filename, source, '@devup-ui/react', 'df/devup-ui', ${mode.singleCss}, false, false, {}); +try { + const css = wasm.getCss(null, false) + (${mode.singleCss} ? '' : '\n' + (output.css ?? '')); + // Only bundler-consumed CSS imports are removed; generated JSX executes intact. + const code = output.code.replace(/import\s*["'][^"']+\.css(?:\?[^"']*)?["'];?/g, ''); + const transpiler = new Bun.Transpiler({ loader: 'tsx', tsconfig: { compilerOptions: { jsx: 'react', jsxFactory: 'React.createElement' } } }); + const modulePath = join(${JSON.stringify(root)}, 'view.mjs'); + writeFileSync(modulePath, transpiler.transformSync(code)); + const { View } = await import(pathToFileURL(modulePath).href); + const markup = renderToStaticMarkup(${render}); + process.stdout.write('' + markup + ''); +} finally { + output.free(); +} +`, + ], + { cwd: root, encoding: 'utf8', timeout: 30_000 }, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} diff --git a/e2e/environment-dynamic-sites.spec.ts b/e2e/environment-dynamic-sites.spec.ts new file mode 100644 index 000000000..493883757 --- /dev/null +++ b/e2e/environment-dynamic-sites.spec.ts @@ -0,0 +1,204 @@ +import { expect, test } from '@playwright/test' + +import { dynamicModes, renderDynamicSite } from './environment-dynamic-driver' + +const parameters = `({ pad, id, children }: { readonly pad: string | undefined; readonly id: string; readonly children?: React.ReactNode })` +const nested = `React.createElement(View, { pad: '16px', id: 'parent' }, React.createElement(View, { pad: undefined, id: 'child' }))` + +for (const mode of dynamicModes) { + test.describe(`atomHoist=${mode.atomHoist ?? 'off'}, singleCss=${mode.singleCss}`, () => { + test('undefined child padding stays initial when parent padding is dynamic', async ({ + page, + }) => { + // Given: different source sites, real public WASM output and React SSR. + const html = renderDynamicSite( + `export function View({ outer, inner }: { readonly outer: string; readonly inner: string | undefined }) { return ; }`, + `React.createElement(View, { outer: '16px', inner: undefined })`, + mode, + ) + // When: Chromium applies the generated CSS to the generated markup. + await page.setContent(html) + // Then: an omitted child variable cannot inherit the parent's value. + expect( + await page + .getByTestId('parent') + .evaluate((el) => getComputedStyle(el).padding), + ).toBe('16px') + expect( + await page + .getByTestId('child') + .evaluate((el) => getComputedStyle(el).padding), + ).toBe('0px') + }) + + test('recursive same-site padding does not inherit a missing value', async ({ + page, + }) => { + // Given: one compiled View rendered twice, nested at the same dynamic site. + const html = renderDynamicSite( + `export function View${parameters} { return {children}; }`, + nested, + mode, + ) + // When: Chromium resolves the omitted inner value. + await page.setContent(html) + // Then: the parent's present inline value wins; the child starts at zero. + expect( + await page + .locator('#parent') + .evaluate((el) => getComputedStyle(el).padding), + ).toBe('16px') + expect( + await page + .locator('#child') + .evaluate((el) => getComputedStyle(el).padding), + ).toBe('0px') + }) + + for (const consumer of [ + { + name: 'before pseudo-element', + props: `_before={{ content: '""', p: pad }}`, + children: '', + pseudo: '::before', + }, + { + name: 'direct descendant', + props: `selectors={{ '& > span': { p: pad } }}`, + children: '', + pseudo: null, + }, + ] as const) { + test(`valid dynamic padding reaches the ${consumer.name} consumer`, async ({ + page, + }) => { + // Given: the value belongs to the owner, not the consumer subject. + const html = renderDynamicSite( + `export function View${parameters} { return ${consumer.children}; }`, + `React.createElement(View, { pad: '16px', id: 'parent' })`, + mode, + ) + // When: Chromium inherits the generated variable into the consumer. + await page.setContent(html) + // Then: resetting on the pseudo-element or span would incorrectly erase it. + expect( + await page + .locator(consumer.pseudo ? '#parent' : '#consumer') + .evaluate( + (el, pseudo) => getComputedStyle(el, pseudo).padding, + consumer.pseudo, + ), + ).toBe('16px') + }) + } + + test('undefined dynamic color retains normal computed parent inheritance', async ({ + page, + }) => { + // Given: a red dynamic value on an ancestor whose computed color is green. + const html = renderDynamicSite( + `export function View${parameters} { return {children}; }`, + `React.createElement(View, { pad: 'red', id: 'ancestor' }, React.createElement('section', { style: { color: 'green' } }, React.createElement(View, { pad: undefined, id: 'child' })))`, + mode, + ) + // When: Chromium resolves the missing child color. + await page.setContent(html) + // Then: color inherits the immediate parent's computed green, not stale red. + expect( + await page + .locator('#child') + .evaluate((el) => getComputedStyle(el).color), + ).toBe('rgb(0, 128, 0)') + }) + + test('theme and manual variables remain inherited beside dynamic resets', async ({ + page, + }) => { + // Given: authored variables and a generated dynamic value share an owner. + const html = renderDynamicSite( + `export function View${parameters} { return {children}; }`, + `React.createElement('section', { style: { '--brand': 'green', '--manual': 'rgb(1, 2, 3)' } }, ${nested})`, + mode, + ) + // When: the child omits only the generated variable. + await page.setContent(html) + // Then: neither theme nor manual custom properties are reset. + expect( + await page + .locator('#child') + .evaluate((el) => [ + getComputedStyle(el).color, + getComputedStyle(el).backgroundColor, + ]), + ).toEqual(['rgb(0, 128, 0)', 'rgb(1, 2, 3)']) + }) + + for (const condition of [ + { name: 'responsive', props: 'p={[null, pad]}', active: 'viewport' }, + { name: 'hover', props: '_hover={{ p: pad }}', active: 'hover' }, + ] as const) { + test(`${condition.name} padding applies when active`, async ({ + page, + }) => { + // Given: dynamic padding available only under the selected condition. + const html = renderDynamicSite( + `export function View${parameters} { return content; }`, + `React.createElement(View, { pad: '16px', id: 'parent' })`, + mode, + ) + await page.setViewportSize({ width: 1600, height: 900 }) + await page.setContent(html) + // When: the condition is active (desktop viewport or actual hover). + if (condition.active === 'hover') await page.locator('#parent').hover() + // Then: the inline value wins over the unconditional own reset. + expect( + await page + .locator('#parent') + .evaluate((el) => getComputedStyle(el).padding), + ).toBe('16px') + }) + + test(`${condition.name} missing value is reset on its own element outside the condition`, async ({ + page, + }) => { + // Given: same-site recursion while neither conditional consumer is active. + const html = renderDynamicSite( + `export function View${parameters} { return {children}; }`, + nested, + mode, + ) + await page.setViewportSize({ width: 320, height: 900 }) + await page.mouse.move(319, 899) + // When: Chromium computes variables before media/hover rules can apply. + await page.setContent(html) + // Then: every generated variable has exactly one unconditional OWN reset. + const resets = await page.locator('#parent').evaluate((parent) => { + const child = document.querySelector('#child') + if ( + !(parent instanceof HTMLElement) || + !(child instanceof HTMLElement) + ) + throw new TypeError('Expected rendered elements') + return Array.from(parent.style) + .filter((name) => name.startsWith('--')) + .map((name) => ({ + present: getComputedStyle(parent).getPropertyValue(name).trim(), + missing: getComputedStyle(child).getPropertyValue(name).trim(), + ownRules: Array.from(document.styleSheets) + .flatMap((sheet) => Array.from(sheet.cssRules)) + .filter( + (rule) => + rule instanceof CSSStyleRule && + rule.style.getPropertyValue(name).trim() === 'initial' && + /^\.[\w-]+(?:\.[\w-]+)*$/.test(rule.selectorText) && + parent.matches(rule.selectorText), + ).length, + })) + }) + expect(resets.length).toBeGreaterThan(0) + for (const reset of resets) + expect(reset).toEqual({ present: '16px', missing: '', ownRules: 1 }) + }) + } + }) +} diff --git a/e2e/landing-zero-runtime.spec.ts b/e2e/landing-zero-runtime.spec.ts index e0a69157b..e9c61d7c9 100644 --- a/e2e/landing-zero-runtime.spec.ts +++ b/e2e/landing-zero-runtime.spec.ts @@ -38,7 +38,7 @@ test.describe('Landing Page - Zero Runtime Validation', () => { test('no runtime CSS-in-JS globals are present', async ({ page }) => { const runtimeGlobals = await page.evaluate(() => { - const win = window as Record + const win = window return { // Common CSS-in-JS runtime indicators __devup__: '__devup__' in win, @@ -147,17 +147,20 @@ test.describe('Landing Page - Zero Runtime Validation', () => { .evaluate((element) => Array.from(element.classList)) expect(headingClasses.length).toBeGreaterThan(1) - const atomicClasses = headingClasses.filter((className) => - /^(?:[a-z]-)?[a-z0-9_]{1,3}$/i.test(className), - ) + const contentAtom = + /^[OR](?:H[a-z0-9_]{16}|L(?=[a-z0-9_-]{1,16}$)(?:d-)?[a-z0-9_]{1,16}(?:-[vlosgay0-3][a-z0-9_]{0,14}){0,7})$/ + const privateCounterAtom = + /^(?:(?![a-z0-9_]{0,5}ad-)[a-z_][a-z0-9_]{0,6}|[a-z_][a-z0-9_]{0,4}a-d|a-d)-(?:(?![a-z0-9_]{0,11}ad$)[a-z_][a-z0-9_]{0,12}|[a-z_][a-z0-9_]{0,10}a-d|a-d)$/ + const isCompiledAtom = (className: string) => + contentAtom.test(className) || privateCounterAtom.test(className) + const atomicClasses = headingClasses.filter(isCompiledAtom) expect(atomicClasses.length).toBeGreaterThan(1) expect( headingClasses.every( (className) => - /^(?:[a-z]-)?[a-z0-9_]{1,3}$/i.test(className) || - /^typo-[a-z0-9]+$/i.test(className), + isCompiledAtom(className) || /^typo-[a-z0-9]{1,59}$/i.test(className), ), - `Expected compact base-37 classes, received: ${headingClasses.join(' ')}`, + `Expected compiled content, private base-37 or typography classes, received: ${headingClasses.join(' ')}`, ).toBe(true) const generatedRuntimeClasses = await page.evaluate(() => diff --git a/libs/css/src/class_map.rs b/libs/css/src/class_map.rs index 010715047..382262310 100644 --- a/libs/css/src/class_map.rs +++ b/libs/css/src/class_map.rs @@ -69,6 +69,84 @@ pub fn get_class_map() -> HashMap> { with_class_map(Clone::clone) } +#[derive(Default)] +struct Journal { + attempts: usize, + inserted: Vec<(String, String)>, +} + +thread_local! { + static JOURNAL: std::cell::RefCell = std::cell::RefCell::new(Journal::default()); +} + +/// Remember a name handed out while an [`Attempt`] runs, so dropping the +/// attempt gives the slot back. +pub(crate) fn record_insert(filename_key: &str, key: &str) { + JOURNAL.with(|journal| { + let mut journal = journal.borrow_mut(); + if journal.attempts > 0 { + journal + .inserted + .push((filename_key.to_string(), key.to_string())); + } + }); +} + +/// A stretch of extraction whose names are kept only when it is committed. +/// +/// Names an attempt hands out that is then discarded (an evaluation retry or a +/// build error) would otherwise leave counter slots that a later environment +/// finds taken. +pub struct Attempt { + mark: usize, + committed: bool, +} + +impl Attempt { + #[must_use] + pub fn begin() -> Self { + JOURNAL.with(|journal| { + let mut journal = journal.borrow_mut(); + journal.attempts += 1; + Self { + mark: journal.inserted.len(), + committed: false, + } + }) + } + + pub fn commit(mut self) { + self.committed = true; + } +} + +impl Drop for Attempt { + fn drop(&mut self) { + let undone = JOURNAL.with(|journal| { + let mut journal = journal.borrow_mut(); + journal.attempts -= 1; + let undone = if self.committed { + Vec::new() + } else { + journal.inserted.split_off(self.mark) + }; + if journal.attempts == 0 { + journal.inserted.clear(); + } + undone + }); + if !undone.is_empty() { + with_class_map_mut(|map| { + for (file, key) in undone.iter().rev() { + if let Some(names) = map.get_mut(file) { + names.remove(key); + } + } + }); + } + } +} + #[cfg(test)] mod tests { use serial_test::serial; diff --git a/libs/css/src/content_hash.rs b/libs/css/src/content_hash.rs new file mode 100644 index 000000000..0bd3f14d8 --- /dev/null +++ b/libs/css/src/content_hash.rs @@ -0,0 +1,269 @@ +//! Fixed SHA-256 (FIPS 180-4), independent of host hashers and dependencies. + +const INITIAL: [u32; 8] = [ + 0x6a09_e667, + 0xbb67_ae85, + 0x3c6e_f372, + 0xa54f_f53a, + 0x510e_527f, + 0x9b05_688c, + 0x1f83_d9ab, + 0x5be0_cd19, +]; +const ROUND: [u32; 64] = [ + 0x428a_2f98, + 0x7137_4491, + 0xb5c0_fbcf, + 0xe9b5_dba5, + 0x3956_c25b, + 0x59f1_11f1, + 0x923f_82a4, + 0xab1c_5ed5, + 0xd807_aa98, + 0x1283_5b01, + 0x2431_85be, + 0x550c_7dc3, + 0x72be_5d74, + 0x80de_b1fe, + 0x9bdc_06a7, + 0xc19b_f174, + 0xe49b_69c1, + 0xefbe_4786, + 0x0fc1_9dc6, + 0x240c_a1cc, + 0x2de9_2c6f, + 0x4a74_84aa, + 0x5cb0_a9dc, + 0x76f9_88da, + 0x983e_5152, + 0xa831_c66d, + 0xb003_27c8, + 0xbf59_7fc7, + 0xc6e0_0bf3, + 0xd5a7_9147, + 0x06ca_6351, + 0x1429_2967, + 0x27b7_0a85, + 0x2e1b_2138, + 0x4d2c_6dfc, + 0x5338_0d13, + 0x650a_7354, + 0x766a_0abb, + 0x81c2_c92e, + 0x9272_2c85, + 0xa2bf_e8a1, + 0xa81a_664b, + 0xc24b_8b70, + 0xc76c_51a3, + 0xd192_e819, + 0xd699_0624, + 0xf40e_3585, + 0x106a_a070, + 0x19a4_c116, + 0x1e37_6c08, + 0x2748_774c, + 0x34b0_bcb5, + 0x391c_0cb3, + 0x4ed8_aa4a, + 0x5b9c_ca4f, + 0x682e_6ff3, + 0x748f_82ee, + 0x78a5_636f, + 0x84c8_7814, + 0x8cc7_0208, + 0x90be_fffa, + 0xa450_6ceb, + 0xbef9_a3f7, + 0xc671_78f2, +]; + +fn compress(state: &mut [u32; 8], block: &[u8]) { + let mut words = [0_u32; 64]; + for (word, bytes) in words.iter_mut().zip(block.as_chunks::<4>().0) { + *word = u32::from_be_bytes(*bytes); + } + for i in 16..64 { + let x = words[i - 15]; + let y = words[i - 2]; + let s0 = x.rotate_right(7) ^ x.rotate_right(18) ^ (x >> 3); + let s1 = y.rotate_right(17) ^ y.rotate_right(19) ^ (y >> 10); + words[i] = words[i - 16] + .wrapping_add(s0) + .wrapping_add(words[i - 7]) + .wrapping_add(s1); + } + let [ + mut reg_a, + mut reg_b, + mut reg_c, + mut reg_d, + mut reg_e, + mut reg_f, + mut reg_g, + mut reg_h, + ] = *state; + for (word, constant) in words.into_iter().zip(ROUND) { + let s1 = reg_e.rotate_right(6) ^ reg_e.rotate_right(11) ^ reg_e.rotate_right(25); + let choose = (reg_e & reg_f) ^ (!reg_e & reg_g); + let t1 = reg_h + .wrapping_add(s1) + .wrapping_add(choose) + .wrapping_add(constant) + .wrapping_add(word); + let s0 = reg_a.rotate_right(2) ^ reg_a.rotate_right(13) ^ reg_a.rotate_right(22); + let majority = (reg_a & reg_b) ^ (reg_a & reg_c) ^ (reg_b & reg_c); + let t2 = s0.wrapping_add(majority); + reg_h = reg_g; + reg_g = reg_f; + reg_f = reg_e; + reg_e = reg_d.wrapping_add(t1); + reg_d = reg_c; + reg_c = reg_b; + reg_b = reg_a; + reg_a = t1.wrapping_add(t2); + } + for (part, added) in state + .iter_mut() + .zip([reg_a, reg_b, reg_c, reg_d, reg_e, reg_f, reg_g, reg_h]) + { + *part = part.wrapping_add(added); + } +} + +/// SHA-256 bytes, with the standard big-endian bit length and padding. +#[must_use] +pub fn sha256(bytes: &[u8]) -> [u8; 32] { + let mut state = INITIAL; + let (chunks, tail) = bytes.as_chunks::<64>(); + for block in chunks { + compress(&mut state, block); + } + let mut padding = [0_u8; 128]; + padding[..tail.len()].copy_from_slice(tail); + padding[tail.len()] = 0x80; + let length = if tail.len() < 56 { 64 } else { 128 }; + let bit_length = u64::try_from(bytes.len()) + .unwrap_or(u64::MAX) + .wrapping_mul(8); + padding[length - 8..length].copy_from_slice(&bit_length.to_be_bytes()); + for block in padding[..length].as_chunks::<64>().0 { + compress(&mut state, block); + } + let mut result = [0_u8; 32]; + for (out, word) in result.as_chunks_mut::<4>().0.iter_mut().zip(state) { + out.copy_from_slice(&word.to_be_bytes()); + } + result +} + +/// First 80 SHA-256 bits, big-endian, in sixteen zero-padded base-37 digits. +#[must_use] +pub fn fingerprint(bytes: &[u8]) -> String { + fingerprint_with_bits(bytes, FingerprintBits::PRODUCTION) +} + +/// A nonzero prefix width bounded by the production fingerprint's 80 bits. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FingerprintBits(u8); + +impl FingerprintBits { + pub const PRODUCTION: Self = Self(80); + + #[must_use] + pub const fn new(bits: u8) -> Option { + if bits > 0 && bits <= 80 { + Some(Self(bits)) + } else { + None + } + } + + #[must_use] + pub const fn digits(self) -> usize { + let maximum = (1_u128 << self.0) - 1; + let mut capacity = 37_u128; + let mut digits = 1; + while capacity <= maximum { + capacity *= 37; + digits += 1; + } + digits + } +} + +/// The same fixed digest/encoding at an explicitly supplied prefix width. +#[must_use] +pub fn fingerprint_with_bits(bytes: &[u8], bits: FingerprintBits) -> String { + let digest = sha256(bytes); + let value = digest[..10] + .iter() + .fold(0_u128, |value, byte| (value << 8) | u128::from(*byte)); + encode(value >> (80 - bits.0), bits.digits()) +} + +fn encode(mut value: u128, length: usize) -> String { + const ALPHABET: &[u8; 37] = b"abcdefghijklmnopqrstuvwxyz0123456789_"; + let mut digits = vec![b'a'; length]; + for digit in digits.iter_mut().rev() { + let index = usize::try_from(value % 37).unwrap_or_default(); + *digit = ALPHABET[index]; + value /= 37; + } + digits.into_iter().map(char::from).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn standard_sha_goldens_cover_empty_single_and_multiple_blocks() { + for (input, expected) in [ + ( + "", + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ), + ( + "abc", + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ), + ( + "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", + ), + ( + "abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu", + "cf5b16a778af8380036ce59e7b0492370b249b11e8f07a51afac45037afee9d1", + ), + ] { + let actual: String = sha256(input.as_bytes()) + .iter() + .flat_map(|byte| { + const HEX: &[u8; 16] = b"0123456789abcdef"; + [ + char::from(HEX[usize::from(byte >> 4)]), + char::from(HEX[usize::from(byte & 15)]), + ] + }) + .collect(); + assert_eq!(actual, expected); + } + } + + #[test] + fn eighty_bits_keep_leading_zeroes_and_big_endian_order() { + assert_eq!(encode(0, 16), "aaaaaaaaaaaaaaaa"); + assert_eq!(encode(37, 16), "aaaaaaaaaaaaaaba"); + assert_eq!(fingerprint(b"abc"), encode(0xba78_16bf_8f01_cfea_4141, 16)); + } + + #[test] + fn nist_complete_block_golden_keeps_the_first_eighty_bits() { + let input = vec![b'a'; 1_000_000]; + let digest = sha256(&input); + assert_eq!( + &digest[..10], + &[0xcd, 0xc7, 0x6e, 0x5c, 0x99, 0x14, 0xfb, 0x92, 0x81, 0xa1] + ); + } +} diff --git a/libs/css/src/content_name.rs b/libs/css/src/content_name.rs new file mode 100644 index 000000000..451060235 --- /dev/null +++ b/libs/css/src/content_name.rs @@ -0,0 +1,207 @@ +//! Versioned semantic descriptors shared by extraction and sheet registration. + +use crate::{ + Naming, + naming::escape_into, + style_selector::{AtRuleKind, StyleSelector}, +}; + +/// The declaration identity, without runtime values or delivery/cleanup owners. +pub struct AtomContent<'a> { + pub property: &'a str, + pub value: Option<&'a str>, + pub naming: Naming, + pub level: u8, + pub order: u8, + pub selector: Option<&'a StyleSelector>, + pub layer: Option<&'a str>, + pub dynamic: bool, +} + +/// Exact versioned bytes plus their injective, escaped text representation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContentName { + pub descriptor: Vec, + pub lossless: String, + pub domain: char, +} + +fn count(bytes: &mut Vec, length: usize) { + bytes.extend_from_slice(&u64::try_from(length).unwrap_or(u64::MAX).to_be_bytes()); +} + +fn text(bytes: &mut Vec, value: &str) { + count(bytes, value.len()); + bytes.extend_from_slice(value.as_bytes()); +} + +fn option(bytes: &mut Vec, value: Option<&str>) { + bytes.push(u8::from(value.is_some())); + if let Some(value) = value { + text(bytes, value); + } +} + +const fn kind(kind: AtRuleKind) -> u8 { + match kind { + AtRuleKind::Media => 0, + AtRuleKind::Supports => 1, + AtRuleKind::Container => 2, + AtRuleKind::Layer => 3, + } +} + +fn selector(bytes: &mut Vec, lossless: &mut String, value: Option<&StyleSelector>) { + match value { + None => bytes.push(0), + Some(StyleSelector::Selector(value)) => { + bytes.push(1); + text(bytes, value); + lossless.push_str("-s"); + escape_into(lossless, value); + } + Some(StyleSelector::Global(value, _)) => { + bytes.push(2); + text(bytes, value); + lossless.push_str("-g"); + escape_into(lossless, value); + } + Some(StyleSelector::At { + kind: rule_kind, + query, + selector: inner, + outer, + .. + }) => { + bytes.push(3); + count(bytes, outer.len()); + lossless.push_str("-a"); + for rule in outer { + bytes.push(kind(rule.kind)); + text(bytes, &rule.query); + lossless.push(char::from(b'0' + kind(rule.kind))); + escape_into(lossless, &rule.query); + lossless.push('-'); + } + bytes.push(kind(*rule_kind)); + text(bytes, query); + option(bytes, inner.as_deref()); + lossless.push_str("e-"); + lossless.push(char::from(b'0' + kind(*rule_kind))); + escape_into(lossless, query); + if let Some(inner) = inner { + lossless.push_str("-s"); + escape_into(lossless, inner); + } + } + } +} + +impl AtomContent<'_> { + /// Fixed order: version, variant, provenance, property, value, level, order, + /// structured selector/at-rules, layer. Strings/counts use big-endian u64. + #[must_use] + pub fn content(&self) -> ContentName { + let property = self.property.trim(); + let value = self.value.map(|value| { + let normalized = crate::optimize_value::optimize_value(value); + crate::content_value::emitted(&normalized).into_owned() + }); + let mut bytes = vec![ + 1, + if self.dynamic { 2 } else { 1 }, + match self.naming { + Naming::Own => 0, + Naming::Risky => 1, + }, + ]; + text(&mut bytes, property); + option(&mut bytes, value.as_deref()); + bytes.extend_from_slice(&[self.level, self.order]); + let mut lossless = String::new(); + if self.dynamic { + lossless.push_str("d-"); + } + escape_into(&mut lossless, property); + if let Some(value) = value.as_deref() { + lossless.push_str("-v"); + escape_into(&mut lossless, value); + } + if self.level != 0 { + lossless.push_str("-l"); + crate::write_u8(&mut lossless, self.level); + } + if self.order != 255 { + lossless.push_str("-o"); + crate::write_u8(&mut lossless, self.order); + } + selector(&mut bytes, &mut lossless, self.selector); + option(&mut bytes, self.layer); + if let Some(layer) = self.layer { + lossless.push_str("-y"); + escape_into(&mut lossless, layer); + } + ContentName { + descriptor: bytes, + lossless, + domain: match self.naming { + Naming::Own => 'O', + Naming::Risky => 'R', + }, + } + } +} + +impl ContentName { + /// Keyframe step and declaration order are significant, exactly as emitted. + #[must_use] + pub fn keyframes(steps: &[(String, Vec<(String, String)>)]) -> Self { + let mut bytes = vec![1, 3]; + let mut lossless = String::new(); + count(&mut bytes, steps.len()); + for (step, properties) in steps { + text(&mut bytes, step); + count(&mut bytes, properties.len()); + lossless.push('s'); + escape_into(&mut lossless, step); + for (property, value) in properties { + text(&mut bytes, property); + text(&mut bytes, value); + lossless.push_str("-p"); + escape_into(&mut lossless, property); + lossless.push_str("-v"); + escape_into(&mut lossless, value); + } + lossless.push_str("-e-"); + } + Self { + descriptor: bytes, + lossless, + domain: 'K', + } + } + + /// Lossless wins ties; the prefix and domain are included in both lengths. + #[must_use] + pub fn name(&self, prefix: &str) -> String { + self.name_with_bits(prefix, crate::content_hash::FingerprintBits::PRODUCTION) + } + + /// Width is an input to naming, never global state or witness-dependent. + #[must_use] + pub fn name_with_bits( + &self, + prefix: &str, + bits: crate::content_hash::FingerprintBits, + ) -> String { + if self.lossless.len() <= bits.digits() { + format!("{prefix}{}L{}", self.domain, self.lossless) + } else { + format!( + "{prefix}{}H{}", + self.domain, + crate::content_hash::fingerprint_with_bits(&self.descriptor, bits) + ) + } + } +} diff --git a/libs/css/src/content_name_tests.rs b/libs/css/src/content_name_tests.rs new file mode 100644 index 000000000..14487dd21 --- /dev/null +++ b/libs/css/src/content_name_tests.rs @@ -0,0 +1,212 @@ +use super::content_name::{AtomContent, ContentName}; +use super::{ + Naming, + content_hash::fingerprint, + naming::private_counter, + style_selector::{AtRule, AtRuleKind, StyleSelector}, +}; +use serial_test::serial; + +fn atom(value: Option<&str>) -> AtomContent<'_> { + AtomContent { + property: "color", + value, + naming: Naming::Own, + level: 0, + order: 255, + selector: None, + layer: None, + dynamic: false, + } +} + +#[test] +fn descriptor_bytes_and_short_names_are_fixed_goldens() { + // Given + let input = atom(Some("red")); + // When + let content = input.content(); + // Then + assert_eq!( + content.descriptor, + [ + 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 5, b'c', b'o', b'l', b'o', b'r', 1, 0, 0, 0, 0, 0, 0, 0, + 3, b'r', b'e', b'd', 0, 255, 0, 0, + ] + ); + assert_eq!(content.name("du-"), "du-OLcolor-vred"); +} + +#[test] +fn names_choose_the_complete_escaped_minimum_and_lossless_on_ties() { + // Given + let descriptor = b"abc".to_vec(); + // When / Then + for length in [0, 15, 16, 17, 500] { + let content = ContentName { + descriptor: descriptor.clone(), + lossless: "x".repeat(length), + domain: 'R', + }; + let name = content.name("prefix-"); + let expected = if length <= 16 { + format!("prefix-RL{}", "x".repeat(length)) + } else { + format!("prefix-RH{}", fingerprint(b"abc")) + }; + assert_eq!(name, expected); + assert_eq!(name.len(), 9 + length.min(16)); + } +} + +#[test] +fn parameterized_width_preserves_prefix_bits_padding_and_lossless_ties() { + // Given + use crate::content_hash::{FingerprintBits, fingerprint_with_bits}; + let descriptor = b"abc".to_vec(); + // When / Then + for (width, expected) in [(1, "b"), (5, "x"), (6, "bj")] { + let bits = FingerprintBits::new(width).unwrap_or_else(|| panic!("valid width")); + assert_eq!(fingerprint_with_bits(b"abc", bits), expected); + } + for width in 1..=80 { + let bits = FingerprintBits::new(width).unwrap_or_else(|| panic!("valid width")); + for length in [bits.digits() - 1, bits.digits(), bits.digits() + 1] { + let content = ContentName { + descriptor: descriptor.clone(), + lossless: "x".repeat(length), + domain: 'R', + }; + let name = content.name_with_bits("prefix-", bits); + assert_eq!(name.len(), 9 + length.min(bits.digits())); + assert_eq!(name.starts_with("prefix-RL"), length <= bits.digits()); + } + } + assert_eq!(FingerprintBits::new(0), None); + assert_eq!(FingerprintBits::new(81), None); + assert_eq!(FingerprintBits::PRODUCTION.digits(), 16); + assert_eq!( + fingerprint_with_bits(b"abc", FingerprintBits::PRODUCTION), + fingerprint(b"abc") + ); +} + +#[test] +fn selector_structure_and_layer_are_not_flattened_or_cleanup_owned() { + // Given + let at = |file| StyleSelector::At { + kind: AtRuleKind::Media, + query: "print".into(), + selector: None, + outer: vec![AtRule { + kind: AtRuleKind::Supports, + query: "(display:grid)".into(), + }], + file, + }; + let first = at(Some("a.tsx".into())); + let second = at(Some("b.tsx".into())); + let mut input = atom(Some("red")); + input.selector = Some(&first); + // When + let a = input.content(); + input.selector = Some(&second); + let b = input.content(); + input.layer = Some("print"); + let layer = input.content(); + // Then + assert_eq!(a, b); + assert_ne!(a.descriptor, layer.descriptor); + assert_ne!(a.name(""), layer.name("")); +} + +#[test] +fn semantic_fields_and_variants_remain_separate_even_with_equal_values() { + // Given + let mut input = atom(None); + let missing = input.content(); + // When + input.value = Some(""); + let empty = input.content(); + input.naming = Naming::Risky; + let risky = input.content(); + input.dynamic = true; + let dynamic = input.content(); + // Then + let names = [ + missing.name(""), + empty.name(""), + risky.name(""), + dynamic.name(""), + ]; + assert_eq!( + names + .into_iter() + .collect::>() + .len(), + 4 + ); +} + +#[test] +fn keyframes_keep_step_and_declaration_order_in_the_descriptor() { + // Given + let mut steps = vec![( + "from".into(), + vec![ + ("opacity".into(), "0".into()), + ("color".into(), "red".into()), + ], + )]; + let first = ContentName::keyframes(&steps); + // When + steps[0].1.reverse(); + let reversed = ContentName::keyframes(&steps); + // Then + assert_ne!(first.descriptor, reversed.descriptor); + assert_ne!(first.name(""), reversed.name("")); + assert_eq!( + ContentName::keyframes(&[]).descriptor, + [1, 3, 0, 0, 0, 0, 0, 0, 0, 0] + ); + assert_eq!(ContentName::keyframes(&[]).name(""), "KL"); +} + +#[test] +#[serial] +fn only_original_d9_private_own_atoms_take_counter_slots_even_in_atom_mode() { + // Given + crate::file_map::reset_file_map(); + crate::file_map::reset_canonical_map(); + crate::class_map::reset_class_map(); + crate::atom_hoist::restore_atom_plan(None); + crate::atom_hoist::set_atom_hoist(Some(2)); + crate::file_routes::set_file_routes(std::collections::HashMap::from([( + "shared.tsx".into(), + std::collections::HashSet::from([0, 1]), + )])); + crate::file_map::seed_file_numbers(&[ + "a.tsx".into(), + "private.tsx".into(), + "shared.tsx".into(), + ]); + // When + let private = crate::sheet_to_classname_content(&atom(Some("red")), Some("private.tsx")); + let shared = crate::sheet_to_classname_content(&atom(Some("red")), Some("shared.tsx")); + let unnumbered = crate::sheet_to_classname_content(&atom(Some("red")), Some("unknown.tsx")); + // Then + assert_eq!(private, "b-a"); + assert_eq!(shared, "OLcolor-vred"); + assert_eq!(unnumbered, "OLcolor-vred"); + assert_eq!( + private_counter(Some("private.tsx"), Naming::Risky, 255), + None + ); + assert_eq!(private_counter(Some("private.tsx"), Naming::Own, 0), None); + assert_eq!(private_counter(None, Naming::Own, 255), None); + crate::atom_hoist::set_atom_hoist(None); + crate::atom_hoist::restore_atom_plan(None); + crate::file_routes::reset_file_routes(); + crate::file_map::reset_file_map(); + crate::class_map::reset_class_map(); +} diff --git a/libs/css/src/content_typography.rs b/libs/css/src/content_typography.rs new file mode 100644 index 000000000..9f3b85a8f --- /dev/null +++ b/libs/css/src/content_typography.rs @@ -0,0 +1,71 @@ +use std::{ + collections::BTreeMap, + sync::{LazyLock, RwLock}, +}; + +pub type Declarations = Vec<(u8, String, String)>; +static PRESETS: LazyLock>> = + LazyLock::new(|| RwLock::new(BTreeMap::new())); + +pub fn set(presets: BTreeMap) { + *PRESETS + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = presets; +} + +/// The effective declarations of one conditional typography atom. +pub fn declarations(value: &str, level: u8) -> Declarations { + let (preset, yielded) = value.split_once('|').unwrap_or((value, "")); + let yielded: Vec<_> = yielded + .split(',') + .filter_map(|item| { + let (property, level) = item.split_once(':')?; + Some((property, level.parse::().ok()?)) + }) + .collect(); + let presets = PRESETS + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut result: Declarations = Vec::new(); + for (from, property, value) in presets.get(preset).into_iter().flatten() { + let target = level.max(*from); + match result + .iter_mut() + .find(|(level, name, _)| *level == target && name == property) + { + Some((_, _, previous)) => previous.clone_from(value), + None => result.push((target, property.clone(), value.clone())), + } + } + drop(presets); + result.retain(|(level, property, _)| { + !yielded + .iter() + .any(|(name, from)| *name == property.as_str() && level >= from) + }); + result +} + +pub fn identity(value: &str, level: u8) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let declarations = declarations(value, level); + let mut bytes = Vec::new(); + bytes.extend_from_slice( + &u64::try_from(declarations.len()) + .unwrap_or(u64::MAX) + .to_be_bytes(), + ); + for (level, property, value) in declarations { + bytes.push(level); + for field in [&property, &value] { + bytes.extend_from_slice(&u64::try_from(field.len()).unwrap_or(u64::MAX).to_be_bytes()); + bytes.extend_from_slice(field.as_bytes()); + } + } + let mut identity = String::from("t"); + for byte in bytes { + identity.push(char::from(HEX[usize::from(byte >> 4)])); + identity.push(char::from(HEX[usize::from(byte & 15)])); + } + identity +} diff --git a/libs/css/src/content_typography_tests.rs b/libs/css/src/content_typography_tests.rs new file mode 100644 index 000000000..b293fca9d --- /dev/null +++ b/libs/css/src/content_typography_tests.rs @@ -0,0 +1,53 @@ +use super::content_typography::{declarations, identity, set}; +use serial_test::serial; +use std::collections::BTreeMap; + +#[test] +#[serial] +fn effective_typography_merges_levels_and_yields_only_actual_declarations() { + // Given + set(BTreeMap::from([( + "body".into(), + vec![ + (0, "color".into(), "red".into()), + (1, "color".into(), "blue".into()), + (2, "font-size".into(), "16px".into()), + ], + )])); + // When + let merged = declarations("body", 1); + let yielded = declarations("body|color:1,invalid,font-size:no", 1); + // Then + assert_eq!( + merged, + vec![ + (1, "color".into(), "blue".into()), + (2, "font-size".into(), "16px".into()) + ] + ); + assert_eq!(yielded, vec![(2, "font-size".into(), "16px".into())]); + assert_eq!(declarations("missing", 0), vec![]); + set(BTreeMap::new()); +} + +#[test] +#[serial] +fn content_identity_tracks_theme_values_not_preset_labels() { + // Given + set(BTreeMap::from([ + ("body".into(), vec![(0, "color".into(), "red".into())]), + ("same".into(), vec![(0, "color".into(), "red".into())]), + ])); + let before = identity("body", 0); + let equivalent = identity("same", 0); + // When + set(BTreeMap::from([( + "body".into(), + vec![(0, "color".into(), "blue".into())], + )])); + let after = identity("body", 0); + // Then + assert_eq!(before, equivalent); + assert_ne!(before, after); + set(BTreeMap::new()); +} diff --git a/libs/css/src/content_value.rs b/libs/css/src/content_value.rs new file mode 100644 index 000000000..ab946444f --- /dev/null +++ b/libs/css/src/content_value.rs @@ -0,0 +1,30 @@ +use crate::utils::compile_regex; +use regex_lite::Regex; +use std::{borrow::Cow, sync::LazyLock}; + +static TOKEN: LazyLock = LazyLock::new(|| compile_regex(r"\$\w[\w.-]*")); + +/// The declaration value after theme references are expanded for emission. +pub fn emitted(value: &str) -> Cow<'_, str> { + if !value.contains('$') { + return Cow::Borrowed(value); + } + match TOKEN.replace_all(value, |caps: ®ex_lite::Captures| { + format!("var(--{})", caps[0][1..].replace('.', "-")) + }) { + Cow::Owned(value) => Cow::Owned(value), + Cow::Borrowed(_) => Cow::Borrowed(value), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn effective_values_expand_only_authored_theme_tokens() { + assert_eq!(emitted("1px solid $line.100"), "1px solid var(--line-100)"); + assert_eq!(emitted("$text"), "var(--text)"); + assert_eq!(emitted("var(--text)"), "var(--text)"); + assert_eq!(emitted("$"), "$"); + } +} diff --git a/libs/css/src/file_map.rs b/libs/css/src/file_map.rs index f0a20a939..b109b5e9e 100644 --- a/libs/css/src/file_map.rs +++ b/libs/css/src/file_map.rs @@ -56,9 +56,12 @@ where } } +pub use crate::sparse_site::source_ids::{get_original_ids, original_id, set_original_ids}; + /// for test pub fn reset_file_map() { with_file_map_mut(BiHashMap::clear); + set_original_ids(std::collections::BTreeMap::new()); } pub fn set_file_map(new_map: BiHashMap) { @@ -99,6 +102,7 @@ pub fn get_file_num_by_filename(filename: &str) -> usize { /// 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]) { + crate::sparse_site::source_ids::seed_original_ids(files); let mut sorted: Vec = files.iter().map(|file| canonical(file)).collect(); sorted.sort_unstable(); sorted.dedup(); @@ -170,9 +174,11 @@ where /// for test pub fn reset_canonical_map() { with_canonical_map_mut(std::collections::HashMap::clear); + crate::naming::set_collapsed_buckets(&std::collections::HashMap::new()); } pub fn set_canonical_map(new_map: std::collections::HashMap) { + crate::naming::set_collapsed_buckets(&new_map); with_canonical_map_mut(|map| *map = new_map); } diff --git a/libs/css/src/legacy_variable_names.rs b/libs/css/src/legacy_variable_names.rs new file mode 100644 index 000000000..91d905d64 --- /dev/null +++ b/libs/css/src/legacy_variable_names.rs @@ -0,0 +1,59 @@ +use std::fmt::Write; + +pub(crate) fn encode_selector(selector: &str) -> String { + let mut result = String::with_capacity(selector.len() + 8); + for character in selector.chars() { + let encoded = match character { + '&' => "_a_", + ':' => "_c_", + '(' => "_lp_", + ')' => "_rp_", + '[' => "_lb_", + ']' => "_rb_", + '=' => "_eq_", + '>' => "_gt_", + '<' => "_lt_", + '~' => "_tl_", + '+' => "_pl_", + ' ' => "_s_", + '*' => "_st_", + '.' => "_d_", + '#' => "_h_", + ',' => "_cm_", + '"' => "_dq_", + '\'' => "_sq_", + '/' => "_sl_", + '\\' => "_bs_", + '%' => "_pc_", + '^' => "_cr_", + '$' => "_dl_", + '|' => "_pp_", + '@' => "_at_", + '!' => "_ex_", + '?' => "_qm_", + ';' => "_sc_", + '{' => "_lc_", + '}' => "_rc_", + character if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') => { + result.push(character); + continue; + } + character => { + let _ = write!(result, "_u{:04x}_", u32::from(character)); + continue; + } + }; + result.push_str(encoded); + } + result +} + +pub(crate) fn write_u8(output: &mut String, value: u8) { + if value >= 100 { + output.push(char::from(b'0' + value / 100)); + output.push(char::from(b'0' + (value / 10) % 10)); + } else if value >= 10 { + output.push(char::from(b'0' + value / 10)); + } + output.push(char::from(b'0' + value % 10)); +} diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index d0e82a66a..0cc8be2b8 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -3,18 +3,32 @@ pub mod atom_hoist; pub mod atom_name; pub mod class_map; mod constant; +pub mod content_hash; +pub mod content_name; +#[cfg(test)] +mod content_name_tests; +pub mod content_typography; +#[cfg(test)] +mod content_typography_tests; +pub mod content_value; pub mod debug; pub mod file_map; pub mod file_routes; pub mod is_special_property; +mod legacy_variable_names; +pub mod naming; mod num_to_nm_base; pub mod optimize_multi_css_value; pub mod optimize_value; pub mod rm_css_comment; mod selector_separator; +pub mod sparse_site; +pub mod style_origin; pub mod style_selector; pub mod theme_tokens; pub mod utils; +use legacy_variable_names::encode_selector; +use legacy_variable_names::write_u8; use std::borrow::Cow; use std::collections::BTreeMap; @@ -23,9 +37,10 @@ use std::sync::{LazyLock, RwLock}; use crate::constant::{GLOBAL_ENUM_STYLE_PROPERTY, GLOBAL_STYLE_PROPERTY}; use crate::debug::is_debug; -use crate::file_map::get_file_num_by_filename; + +pub use crate::naming::{Naming, Site}; use crate::num_to_nm_base::num_to_nm_base; -use crate::optimize_value::optimize_value; +pub use crate::sparse_site::{sheet_to_variable_name, sheet_to_variable_name_at}; use crate::style_selector::StyleSelector; use crate::utils::to_kebab_case; @@ -131,7 +146,7 @@ pub fn merge_selector(class_name: &str, selector: Option<&StyleSelector>) -> Str let sel = selector.map(StyleSelector::as_class_str); // `extra_amps` is `(&-count - 1)`, nonzero ONLY for the rare multi-`&` selector. // Locate the FIRST `&` with a `memchr`-backed `find` (bailing to 0 for the common - // zero/one-`&` selectors — `.a`, `.a:hover`, `theme-dark` — without spinning up the + // zero/one-`&` selectors ??`.a`, `.a:hover`, `theme-dark` ??without spinning up the // `filter(...).count()` iterator over the whole string), then count only the `&`s in // the tail AFTER it. That tail count already excludes the first `&`, so it equals // `count - 1` directly, dropping the `saturating_sub`. Byte-identical capacity. @@ -226,7 +241,7 @@ pub fn disassemble_property(property: &str) -> DisassembleProperty { // Build `-` directly into ONE buffer instead of allocating // a `to_kebab_case(property)` String and copying it into a second // presized buffer. This inlines `to_kebab_case`'s exact conversion - // (ASCII-uppercase char → `-` before it when not first, then its + // (ASCII-uppercase char ??`-` before it when not first, then its // lowercase; other chars copied verbatim) after the leading `-`. // The `i != 0` guard matches `to_kebab_case`, so the vendor // prefix's uppercase first char (`W`/`M`/`m`→lowercase) gets no @@ -324,7 +339,7 @@ pub fn is_enum_property(property: &str) -> bool { /// Borrow the phf expansion map for `(property, value)` without materializing a `Vec`. /// /// The caller iterates `.entries()`/reads `.len()` directly off the static map, so the -/// enum-property extract hot path (every `display`/`alignItems`/… prop per breakpoint +/// enum-property extract hot path (every `display`/`alignItems`/??prop per breakpoint /// level) no longer allocates a throwaway `Vec` per call. #[must_use] pub fn get_enum_property_value( @@ -348,7 +363,7 @@ pub fn get_enum_property_map(property: &str) -> Option } else { let len = file_entry.len(); file_entry.insert(key.clone(), len); + class_map::record_insert(filename_key, &key); num_to_nm_base(len) } } else { // First style seen for this file: build the inner map presized to // exactly the one entry we insert, so the initial insert never starts // from a zero-capacity map (which would rehash/grow on the first few - // inserts). Output/behavior is byte-identical — same single entry, - // same `0` numbering — this only fixes the allocation shape. + // inserts). Output/behavior is byte-identical ??same single entry, + // same `0` numbering ??this only fixes the allocation shape. let mut inner = std::collections::HashMap::with_capacity(1); inner.insert(key.clone(), 0); map.insert(filename_key.to_string(), inner); + class_map::record_insert(filename_key, &key); num_to_nm_base(0) } }) }) } +/// An animation's name from its escaped content: equal animations share one +/// name wherever they are met. +#[must_use] +pub fn keyframes_name_of_escaped(escaped: &str) -> String { + with_prefix(|prefix| format!("{prefix}{}{escaped}", if is_debug() { "k-" } else { "K" })) +} + #[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()); - result.push_str(prefix); - result.push_str("k-"); - result.push_str(keyframes); - result - } else { - let filename_key = filename.unwrap_or_default(); - let class_num = class_num_for_key(filename_key, |key| { - key.reserve(2 + keyframes.len()); - key.push_str("k-"); - key.push_str(keyframes); - }); - if let Some(fname) = filename { - let file_num = num_to_nm_base(get_file_num_by_filename(fname)); - let mut result = - String::with_capacity(prefix.len() + file_num.len() + 1 + class_num.len()); - result.push_str(prefix); - result.push_str(&file_num); - result.push('-'); - result.push_str(&class_num); - result - } else { - let mut result = String::with_capacity(prefix.len() + class_num.len()); - result.push_str(prefix); - result.push_str(&class_num); - result - } - } - }) -} - -/// ASCII lookup table for selector encoding. `None` means pass through (alphanumeric, `-`, `_`) -/// or fall through to the Unicode escape path. -const SELECTOR_ENCODE: [Option<&str>; 128] = { - let mut table: [Option<&str>; 128] = [None; 128]; - table[b'&' as usize] = Some("_a_"); - table[b':' as usize] = Some("_c_"); - table[b'(' as usize] = Some("_lp_"); - table[b')' as usize] = Some("_rp_"); - table[b'[' as usize] = Some("_lb_"); - table[b']' as usize] = Some("_rb_"); - table[b'=' as usize] = Some("_eq_"); - table[b'>' as usize] = Some("_gt_"); - table[b'<' as usize] = Some("_lt_"); - table[b'~' as usize] = Some("_tl_"); - table[b'+' as usize] = Some("_pl_"); - table[b' ' as usize] = Some("_s_"); - table[b'*' as usize] = Some("_st_"); - table[b'.' as usize] = Some("_d_"); - table[b'#' as usize] = Some("_h_"); - table[b',' as usize] = Some("_cm_"); - table[b'"' as usize] = Some("_dq_"); - table[b'\'' as usize] = Some("_sq_"); - table[b'/' as usize] = Some("_sl_"); - table[b'\\' as usize] = Some("_bs_"); - table[b'%' as usize] = Some("_pc_"); - table[b'^' as usize] = Some("_cr_"); - table[b'$' as usize] = Some("_dl_"); - table[b'|' as usize] = Some("_pp_"); - table[b'@' as usize] = Some("_at_"); - table[b'!' as usize] = Some("_ex_"); - table[b'?' as usize] = Some("_qm_"); - table[b';' as usize] = Some("_sc_"); - table[b'{' as usize] = Some("_lc_"); - table[b'}' as usize] = Some("_rc_"); - table -}; - -fn encode_selector(selector: &str) -> String { - use std::fmt::Write; - let mut result = String::with_capacity(selector.len() + 8); - for c in selector.chars() { - if c.is_ascii() { - let byte = c as u8; - if let Some(encoded) = SELECTOR_ENCODE[byte as usize] { - result.push_str(encoded); - } else if c.is_ascii_alphanumeric() || c == '-' || c == '_' { - result.push(c); - } else { - // ASCII but not in table and not alphanumeric/-/_ - let _ = write!(result, "_u{:04x}_", c as u32); - } - } else { - let _ = write!(result, "_u{:04x}_", c as u32); - } - } - result + let mut escaped = String::with_capacity(keyframes.len() + 8); + naming::escape_into(&mut escaped, keyframes); + keyframes_name_of_escaped(&escaped) } pub fn sheet_to_classname( @@ -504,168 +441,59 @@ pub fn sheet_to_classname( style_order: Option, filename: Option<&str>, ) -> String { - // base style - let filename = if style_order == Some(0) { - None - } else { - filename - }; - // `optimize_value` returns `Cow`: unmodifiable values (`red`, `14px`, - // `$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() { - String::new() - } else { - encode_selector(selector) - }; - // Encode the value like the selector: `.8`, `$text` or `#FFF` would break the - // CSS selector, and spaces would split the class attribute into several classes. - let value = encode_selector(&optimized); - let file_suffix = filename.map(get_file_num_by_filename); - let order = style_order.unwrap_or(255); - let prop = property.trim(); - with_prefix(|prefix| { - // Estimate capacity: prefix + prop + separators + level(1-3) + value + encoded + order(1-3) + file - let mut result = - String::with_capacity(prefix.len() + prop.len() + value.len() + encoded.len() + 16); - result.push_str(prefix); - result.push_str(prop); - result.push('-'); - write_u8(&mut result, level); - result.push('-'); - result.push_str(&value); - result.push('-'); - result.push_str(&encoded); - result.push('-'); - write_u8(&mut result, order); - if let Some(fnum) = file_suffix { - result.push('-'); - result.push_str(&num_to_nm_base(fnum)); - } - result - }) - } else { - let trimmed_selector = selector.unwrap_or_default().trim(); - let order = style_order.unwrap_or(255); - let file_num_str = filename.map(|f| num_to_nm_base(get_file_num_by_filename(f))); - let trimmed_prop = property.trim(); - - let filename_key = filename.unwrap_or_default(); - // Build key into the reusable buffer; probe borrows it, insert clones it. - let clas_num = class_num_for_key(filename_key, |key| { - key.reserve(trimmed_prop.len() + optimized.len() + trimmed_selector.len() + 16); - key.push_str(trimmed_prop); - key.push('-'); - write_u8(key, level); - key.push('-'); - key.push_str(&optimized); - key.push('-'); - key.push_str(trimmed_selector); - key.push('-'); - write_u8(key, order); - if let Some(fstr) = &file_num_str { - key.push('-'); - key.push_str(fstr); - } - }); - with_prefix(|prefix| { - if let Some(fstr) = &file_num_str { - let mut result = String::with_capacity(prefix.len() + 8 + clas_num.len()); - result.push_str(prefix); - result.push_str(fstr); - result.push('-'); - result.push_str(&clas_num); - result - } else { - let mut result = String::with_capacity(prefix.len() + clas_num.len()); - result.push_str(prefix); - result.push_str(&clas_num); - result - } - }) - } + sheet_to_classname_named( + property, + level, + value, + selector, + style_order, + filename, + Naming::Own, + ) } -/// Write a u8 value to a string without allocating via format! -#[inline] -fn write_u8(s: &mut String, v: u8) { - if v >= 100 { - s.push((b'0' + v / 100) as char); - s.push((b'0' + (v / 10) % 10) as char); - s.push((b'0' + v % 10) as char); - } else if v >= 10 { - s.push((b'0' + v / 10) as char); - s.push((b'0' + v % 10) as char); - } else { - s.push((b'0' + v) as char); - } +pub fn sheet_to_classname_named( + property: &str, + level: u8, + value: Option<&str>, + selector: Option<&str>, + style_order: Option, + filename: Option<&str>, + naming: Naming, +) -> String { + let selector = selector.map(|text| StyleSelector::Selector(text.trim().to_string())); + sheet_to_classname_content( + &content_name::AtomContent { + property: property.trim(), + value, + level, + order: style_order.unwrap_or(255), + naming, + selector: selector.as_ref(), + layer: None, + dynamic: false, + }, + filename, + ) } +/// Name the same structured content that the sheet validates before insertion. #[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() { - String::new() - } else { - encode_selector(selector) - }; - with_prefix(|prefix| { - let mut result = - String::with_capacity(2 + prefix.len() + property.len() + 4 + encoded.len()); - result.push_str("--"); - result.push_str(prefix); - result.push_str(property); - result.push('-'); - write_u8(&mut result, level); - result.push('-'); - result.push_str(&encoded); - result - }) - } else { - let trimmed_selector = selector.unwrap_or_default().trim(); - let base_name = class_num_for_key("", |key| { - key.reserve(property.len() + 4 + trimmed_selector.len()); - key.push_str(property); - key.push('-'); - write_u8(key, level); - key.push('-'); - key.push_str(trimmed_selector); - }); - with_prefix(|prefix| { - let mut result = String::with_capacity(2 + prefix.len() + base_name.len()); - result.push_str("--"); - result.push_str(prefix); - result.push_str(&base_name); - result - }) +pub fn sheet_to_classname_content( + content: &content_name::AtomContent<'_>, + filename: Option<&str>, +) -> String { + let descriptor = content.content(); + match naming::private_counter(filename, content.naming, content.order) { + Some(id) => { + let scope = format!("D9-{id}"); + let number = class_num_for_key(&scope, |key| { + key.push_str(&atom_name::hex(&descriptor.lossless)); + }); + let file = num_to_nm_base(usize::try_from(id).unwrap_or_default()); + with_prefix(|prefix| format!("{prefix}{file}-{number}")) + } + None => with_prefix(|prefix| descriptor.name(prefix)), } } @@ -793,20 +621,18 @@ mod tests { reset_class_map(); assert_eq!( sheet_to_classname("background", 0, Some("red"), None, None, None), - "a" - ); - assert_eq!( - sheet_to_classname("background", 0, Some("red"), Some("hover"), None, None), - "b" + "OLbackground-vred" ); + let hover = sheet_to_classname("background", 0, Some("red"), Some("hover"), None, None); + assert_eq!(hover.len(), 18); + assert!(hover.starts_with("OH")); assert_eq!( sheet_to_classname("background", 1, None, None, None, None), - "c" - ); - assert_eq!( - sheet_to_classname("background", 1, None, Some("hover"), None, None), - "d" + "OLbackground-l1" ); + let responsive_hover = sheet_to_classname("background", 1, None, Some("hover"), None, None); + assert_eq!(responsive_hover.len(), 18); + assert_ne!(responsive_hover, hover); reset_class_map(); assert_eq!( @@ -861,13 +687,7 @@ mod tests { sheet_to_classname("background", 0, Some("#FF000080"), None, None, None), ); - class_map::with_class_map(|map| { - assert_eq!( - map.get("") - .and_then(|entry| entry.get("background-0-#FF000080--255")), - Some(&2) - ); - }); + assert_eq!(get_class_map().len(), 0, "the shared sheet has no counter"); assert_eq!( sheet_to_classname("background", 0, Some("#fff"), None, None, None), sheet_to_classname(" background ", 0, Some("#FFF"), None, None, None), @@ -878,14 +698,6 @@ mod tests { sheet_to_classname("background", 0, Some("#FFF"), None, None, None), ); - class_map::with_class_map(|map| { - assert_eq!( - map.get("") - .and_then(|entry| entry.get("background-0-#FFF--255")), - Some(&3) - ); - }); - assert_eq!( sheet_to_classname("background", 0, Some("#ffffff"), None, None, None), sheet_to_classname("background", 0, Some("#FFFFFF"), None, None, None), @@ -896,13 +708,6 @@ mod tests { sheet_to_classname("background", 0, Some("#FFFFFFaa"), None, None, None), ); - class_map::with_class_map(|map| { - assert_eq!( - map.get("") - .and_then(|entry| entry.get("background-0-#FFFA--255")), - Some(&4) - ); - }); assert_eq!( sheet_to_classname( "background", @@ -925,118 +730,129 @@ mod tests { reset_class_map(); assert_eq!( sheet_to_classname("background", 0, None, None, None, None), - "a" + "OLbackground" ); assert_eq!( sheet_to_classname("background", 0, None, None, Some(1), None), - "b" + "OLbackground-o1" ); reset_class_map(); assert_eq!( sheet_to_classname("width", 0, Some("0px"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0em"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0rem"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0vh"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0%"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0dvh"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0dvw"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0vw"), None, None, None), - "a" + "OLwidth-v0" ); assert_eq!( sheet_to_classname("width", 0, Some("0"), None, None, None), - "a" + "OLwidth-v0" ); + let border = sheet_to_classname("border", 0, Some("solid 0 red"), None, None, None); + assert_eq!(border.len(), 18); assert_eq!( sheet_to_classname("border", 0, Some("solid 0px red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("border", 0, Some("solid 0% red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("border", 0, Some("solid 0em red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("border", 0, Some("solid 0rem red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("border", 0, Some("solid 0vh red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("border", 0, Some("solid 0vw red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("border", 0, Some("solid 0dvh red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("border", 0, Some("solid 0dvw red"), None, None, None), - "b" + border ); assert_eq!( sheet_to_classname("test", 0, Some("0px 0"), None, None, None), - "c" + "OLtest-v0_s0" ); assert_eq!( sheet_to_classname("test", 0, Some("0em 0"), None, None, None), - "c" + "OLtest-v0_s0" ); assert_eq!( sheet_to_classname("test", 0, Some("0rem 0"), None, None, None), - "c" + "OLtest-v0_s0" ); assert_eq!( sheet_to_classname("test", 0, Some("0vh 0"), None, None, None), - "c" + "OLtest-v0_s0" ); assert_eq!( sheet_to_classname("test", 0, Some("0vw 0"), None, None, None), - "c" + "OLtest-v0_s0" ); assert_eq!( sheet_to_classname("test", 0, Some("0dvh 0"), None, None, None), - "c" + "OLtest-v0_s0" ); assert_eq!( sheet_to_classname("test", 0, Some("0 0vh"), None, None, None), - "c" + "OLtest-v0_s0" ); assert_eq!( sheet_to_classname("test", 0, Some("0 0vw"), None, None, None), - "c" + "OLtest-v0_s0" ); reset_class_map(); + let transition = sheet_to_classname( + "transition", + 0, + Some("all .3s ease-in-out"), + None, + None, + None, + ); + assert_eq!(transition.len(), 18); assert_eq!( sheet_to_classname( "transition", @@ -1046,7 +862,7 @@ mod tests { None, None ), - "a" + transition ); assert_eq!( sheet_to_classname( @@ -1057,7 +873,7 @@ mod tests { None, None ), - "a" + transition ); } @@ -1067,19 +883,18 @@ mod tests { set_debug(true); assert_eq!( sheet_to_classname("background", 0, None, None, None, None), - "background-0---255" - ); - assert_eq!( - sheet_to_classname("background", 0, Some("red"), Some("hover"), None, None), - "background-0-red-hover-255" + "OLbackground" ); + let hover = sheet_to_classname("background", 0, Some("red"), Some("hover"), None, None); + assert!(hover.starts_with("OH")); + assert_eq!(hover.len(), 18); assert_eq!( sheet_to_classname("background", 1, None, None, None, None), - "background-1---255" + "OLbackground-l1" ); - assert_eq!( + assert_ne!( sheet_to_classname("background", 1, Some("red"), Some("hover"), None, None), - "background-1-red-hover-255" + hover ); } @@ -1087,20 +902,19 @@ mod tests { #[serial] fn test_debug_sheet_to_classname_encodes_value() { set_debug(true); - for (property, value, expected) in [ - ("scale", "0.8", "scale-0-_d_8--255"), - ("background", "$text", "background-0-_dl_text--255"), - ("color", "#fff", "color-0-_h_FFF--255"), - ("height", "50%", "height-0-50_pc_--255"), - ( - "transition", - "all .2s ease-in-out", - "transition-0-all_s__d_2s_s_ease-in-out--255", - ), + for (property, value) in [ + ("scale", "0.8"), + ("background", "$text"), + ("color", "#fff"), + ("height", "50%"), + ("transition", "all .2s ease-in-out"), ] { - assert_eq!( - sheet_to_classname(property, 0, Some(value), None, None, None), - expected + let name = sheet_to_classname(property, 0, Some(value), None, None, None); + assert!(name.len() <= 18, "{name}"); + assert!( + name.bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')), + "{name}" ); } set_debug(false); @@ -1110,16 +924,11 @@ mod tests { #[serial] fn test_debug_sheet_to_classname_with_filename() { reset_class_map(); + file_map::reset_file_map(); set_debug(true); - // Debug mode + filename triggers the file_suffix branch (lines 234-235) let class_name = sheet_to_classname("background", 0, Some("red"), None, None, Some("test.tsx")); - assert!(class_name.contains("background-0-red--255-")); - // Should have a file number suffix - assert!( - class_name.split('-').count() >= 6, - "Expected file suffix in debug classname: {class_name}" - ); + assert_eq!(class_name, "OLbackground-vred"); set_debug(false); } @@ -1214,13 +1023,15 @@ mod tests { fn test_keyframes_to_keyframes_name() { reset_class_map(); set_debug(false); - assert_eq!(keyframes_to_keyframes_name("spin", None), num_to_nm_base(0)); - assert_eq!(keyframes_to_keyframes_name("spin", None), num_to_nm_base(0)); + assert_eq!(keyframes_to_keyframes_name("spin", None), "Kspin"); + assert_eq!(keyframes_to_keyframes_name("spin", None), "Kspin"); + assert_eq!(keyframes_to_keyframes_name("spin2", None), "Kspin2"); assert_eq!( - keyframes_to_keyframes_name("spin2", None), - num_to_nm_base(1) + keyframes_to_keyframes_name("spin", Some("a.tsx")), + "Kspin", + "equal animations share one name in every file" ); - reset_class_map(); + assert_eq!(get_class_map().len(), 0, "no counter is used"); set_debug(true); assert_eq!(keyframes_to_keyframes_name("spin", None), "k-spin"); assert_eq!(keyframes_to_keyframes_name("spin1", None), "k-spin1"); @@ -1269,7 +1080,7 @@ mod tests { let class1 = sheet_to_classname("background", 0, Some("red"), None, None, None); assert!(class1.starts_with("app-")); - assert_eq!(class1, "app-a"); + assert_eq!(class1, "app-OLbackground-vred"); let class2 = sheet_to_classname("color", 0, Some("blue"), None, None, None); assert!(class2.starts_with("app-")); @@ -1285,7 +1096,7 @@ mod tests { set_prefix(Some("my-".to_string())); let class_name = sheet_to_classname("background", 0, Some("red"), None, None, None); - assert_eq!(class_name, "my-background-0-red--255"); + assert_eq!(class_name, "my-OLbackground-vred"); let with_selector = sheet_to_classname("background", 0, Some("red"), Some("hover"), None, None); @@ -1341,24 +1152,21 @@ mod tests { fn test_keyframes_to_keyframes_name_with_filename() { reset_class_map(); set_debug(false); - // Test with filename to cover lines 148-151 let name = keyframes_to_keyframes_name("spin", Some("test.tsx")); - // Should include file number prefix - assert!(name.contains('-')); - // Same keyframe in same file should return same name let name2 = keyframes_to_keyframes_name("spin", Some("test.tsx")); assert_eq!(name, name2); - // Different file should have different prefix let name3 = keyframes_to_keyframes_name("spin", Some("other.tsx")); - assert_ne!(name, name3); + assert_eq!(name, name3, "the name is the content, not the file"); + assert_ne!(name, keyframes_to_keyframes_name("spin2", Some("test.tsx"))); } #[test] #[serial] fn test_sheet_to_classname_with_filename() { reset_class_map(); + file_map::reset_file_map(); set_debug(false); // Test with filename to cover the filename branch let class1 = sheet_to_classname("background", 0, Some("red"), None, None, Some("test.tsx")); @@ -1369,10 +1177,12 @@ mod tests { let class2 = sheet_to_classname("background", 0, Some("red"), None, None, Some("test.tsx")); assert_eq!(class1, class2); - // Different file should have different prefix let class3 = sheet_to_classname("background", 0, Some("red"), None, None, Some("other.tsx")); - assert_ne!(class1, class3); + assert_eq!( + class1, class3, + "without D9 both own atoms use their content identity" + ); } #[test] diff --git a/libs/css/src/naming.rs b/libs/css/src/naming.rs new file mode 100644 index 000000000..6003336b6 --- /dev/null +++ b/libs/css/src/naming.rs @@ -0,0 +1,165 @@ +//! Production names that never depend on which build, environment or +//! processing order reached a style first. +//! +//! Short counter names are only handed out inside a namespace one original +//! file owns, and only to styles whose existence does not depend on how an +//! import resolves ([`Naming::Own`]). Everything else is named by what it +//! declares, so equal styles share one name wherever they are met. + +use std::collections::{HashMap, HashSet}; +use std::fmt::Write; +use std::sync::{LazyLock, Mutex}; + +/// Whether a style may take a slot of its file's counter. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Naming { + /// Value and existence derived solely from this file's source and theme. + #[default] + Own, + /// Chosen or valued by something that may differ between environments. + Risky, +} + +impl Naming { + #[must_use] + pub const fn join(self, other: Self) -> Self { + match (self, other) { + (Self::Own, Self::Own) => Self::Own, + _ => Self::Risky, + } + } +} + +pub use crate::sparse_site::Site; + +static COLLAPSED: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); + +/// Remember which buckets hold more than one original file. +pub fn set_collapsed_buckets(canonical: &HashMap) { + *COLLAPSED + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = canonical + .iter() + .filter(|(file, bucket)| file != bucket) + .map(|(_, bucket)| bucket.clone()) + .collect(); +} + +/// Whether styles named in `filename`'s namespace are met from several files. +pub fn is_shared_namespace(filename: Option<&str>) -> bool { + filename.is_none_or(|bucket| { + COLLAPSED + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(bucket) + }) +} + +/// Lowercase letters and digits stay; every other character becomes an +/// `_`-led code, so the encoding is lossless and holds no `-`. +pub fn escape_into(out: &mut String, text: &str) { + for c in text.chars() { + let code = match c { + 'a'..='z' | '0'..='9' => { + out.push(c); + continue; + } + '-' => 'd', + '_' => 'u', + '.' => 'p', + '%' => 'c', + '#' => 'h', + '(' => 'o', + ')' => 'e', + ',' => 'm', + ' ' => 's', + '/' => 'l', + ':' => 'n', + '&' => 'a', + '"' => 'q', + _ => { + let _ = write!(out, "_x{:x}_", u32::from(c)); + continue; + } + }; + out.push('_'); + out.push(code); + } +} + +/// The name of a class by what it declares. +pub fn content_class( + property: &str, + level: u8, + value: Option<&str>, + selector: &str, + style_order: u8, +) -> String { + let selector = (!selector.is_empty()) + .then(|| crate::style_selector::StyleSelector::Selector(selector.to_string())); + crate::content_name::AtomContent { + property, + level, + value, + selector: selector.as_ref(), + order: style_order, + naming: Naming::Risky, + layer: None, + dynamic: false, + } + .content() + .name("") +} + +/// Only a source numbered before extraction may consume its private counter. +#[must_use] +pub fn private_counter(filename: Option<&str>, naming: Naming, order: u8) -> Option { + if naming != Naming::Own || order == 0 || is_shared_namespace(filename) { + return None; + } + filename + .filter(|file| !crate::atom_hoist::is_hoisted_bucket(file)) + .and_then(crate::file_map::original_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + fn join_is_risky_when_either_side_is() { + assert_eq!(Naming::Own.join(Naming::Own), Naming::Own); + assert_eq!(Naming::Own.join(Naming::Risky), Naming::Risky); + assert_eq!(Naming::Risky.join(Naming::Own), Naming::Risky); + assert_eq!(Naming::Risky.join(Naming::Risky), Naming::Risky); + } + + #[test] + fn escape_is_lossless_and_free_of_separators() { + let mut out = String::new(); + escape_into(&mut out, "a-B_1%é"); + assert_eq!(out, "a_d_x42__u1_c_xe9_"); + } + + #[test] + fn content_class_tells_none_from_empty_value_and_keeps_defaults_out() { + assert_eq!(content_class("color", 0, None, "", 255), "RLcolor"); + assert_eq!(content_class("color", 0, Some(""), "", 255), "RLcolor-v"); + let named = content_class("color", 1, Some("red"), "&:hover", 0); + assert_eq!(named.len(), 18); + assert!(named.starts_with("RH")); + } + + #[test] + #[serial] + fn shared_namespace_is_the_base_sheet_or_a_collapsed_bucket() { + set_collapsed_buckets(&HashMap::from([("/b.tsx".into(), "/a.tsx".into())])); + + assert!(is_shared_namespace(None)); + assert!(is_shared_namespace(Some("/a.tsx"))); + assert!(!is_shared_namespace(Some("/b.tsx"))); + assert!(!is_shared_namespace(Some("/c.tsx"))); + set_collapsed_buckets(&HashMap::new()); + } +} diff --git a/libs/css/src/sparse_site.rs b/libs/css/src/sparse_site.rs new file mode 100644 index 000000000..26370bcc7 --- /dev/null +++ b/libs/css/src/sparse_site.rs @@ -0,0 +1,230 @@ +use std::sync::Arc; + +pub(crate) mod source_ids; + +use crate::{ + atom_hoist, atom_name, class_num_for_key, debug::is_debug, encode_selector, + naming::escape_into, num_to_nm_base::num_to_nm_base, with_prefix, write_u8, +}; +use source_ids::original_id; + +/// D9 original identity or normalized received source text, never a path fallback. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SourceFile { + D9(u32), + Unnumbered(Arc), +} + +impl SourceFile { + /// Look up D9 by filename; otherwise retain received source without BOM or CR bytes. + #[must_use] + pub fn from_source(filename: &str, source: &str) -> Self { + if let Some(number) = original_id(filename) { + Self::D9(number) + } else { + let source = source.strip_prefix('\u{feff}').unwrap_or(source); + Self::Unnumbered(Arc::from(source.replace('\r', ""))) + } + } +} + +/// Sparse source position plus a role index from the source's syntax order. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Site { + pub file: SourceFile, + pub at: usize, + pub role: usize, +} + +/// The numeric alphabet has no separator; class-name ad-blocker fixups do not apply. +fn number(number: usize) -> String { + let mut name = num_to_nm_base(number); + name.retain(|character| character != '-'); + name +} + +impl Site { + /// Grammar: `---S(|U)-[-]`. + /// Theme keys cannot start with `-`; uppercase U is outside the numeric alphabet. + #[must_use] + pub fn variable_name(&self, prefix: &str) -> String { + let mut name = format!("---{prefix}S"); + match &self.file { + SourceFile::D9(file) => { + const { assert!(usize::BITS >= u32::BITS) }; + #[expect( + clippy::expect_used, + reason = "supported native and wasm32 targets hold every D9 u32" + )] + let file = usize::try_from(*file).expect("D9 u32 fits the target's usize"); + name.push_str(&number(file)); + } + SourceFile::Unnumbered(source) => { + name.push('U'); + escape_into(&mut name, source); + } + } + name.push('-'); + name.push_str(&number(self.at)); + if self.role != 0 { + name.push('-'); + name.push_str(&number(self.role)); + } + name + } +} + +#[must_use] +pub fn sheet_to_variable_name(property: &str, level: u8, selector: Option<&str>) -> String { + sheet_to_variable_name_at(property, level, selector, None) +} + +/// Name the variable for a dynamic assignment's original source site. +/// +/// Known sites use only original source identity, source offset and source role in every +/// naming mode. One assignment can feed several selectors or properties; +/// distinct assignments need distinct sites because custom properties inherit. +#[must_use] +pub fn sheet_to_variable_name_at( + property: &str, + level: u8, + selector: Option<&str>, + site: Option, +) -> String { + if let Some(site) = site { + return with_prefix(|prefix| site.variable_name(prefix)); + } + 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() { + String::new() + } else { + encode_selector(selector) + }; + with_prefix(|prefix| { + let mut result = + String::with_capacity(2 + prefix.len() + property.len() + 4 + encoded.len()); + result.push_str("--"); + result.push_str(prefix); + result.push_str(property); + result.push('-'); + write_u8(&mut result, level); + result.push('-'); + result.push_str(&encoded); + result + }) + } else { + let trimmed_selector = selector.unwrap_or_default().trim(); + let base_name = class_num_for_key("", |key| { + key.reserve(property.len() + 4 + trimmed_selector.len()); + key.push_str(property); + key.push('-'); + write_u8(key, level); + key.push('-'); + key.push_str(trimmed_selector); + }); + with_prefix(|prefix| { + let mut result = String::with_capacity(2 + prefix.len() + base_name.len()); + result.push_str("--"); + result.push_str(prefix); + result.push_str(&base_name); + result + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grammar_keeps_numbers_and_roles_injective_when_base37_crosses_a_digit() { + // Given: both the first multi-digit number and the ad-blocker suffix. + let sites = [(0, 0, 0), (27, 30, 0), (30, 27, 0), (27, 30, 1)]; + // When: sparse identities are encoded. + let names: Vec<_> = sites + .into_iter() + .map(|(file, at, role)| { + Site { + file: SourceFile::D9(file), + at, + role, + } + .variable_name("test-") + }) + .collect(); + // Then: separators distinguish file, position and role without decimal fallback. + assert_eq!( + names, + [ + "---test-Sa-a", + "---test-Saa-ad", + "---test-Sad-aa", + "---test-Saa-ad-b" + ] + ); + } + + #[test] + #[serial_test::serial] + fn source_encoding_keeps_unnumbered_and_d9_names_disjoint() { + // Given: source text resembling numeric names and containing namespace or escape characters. + crate::file_map::set_original_ids(std::collections::BTreeMap::new()); + let sources = ["a", "U", "a-b", "a_db", "é"]; + // When: unnumbered source text is encoded losslessly. + let names: Vec<_> = sources + .into_iter() + .map(|source| { + Site { + file: SourceFile::from_source("/unnumbered.tsx", source), + at: 0, + role: 0, + } + .variable_name("") + }) + .collect(); + // Then: neither a D9 identity nor another original source can alias them. + assert_eq!( + names, + [ + "---SUa-a", + "---SU_x55_-a", + "---SUa_db-a", + "---SUa_udb-a", + "---SU_xe9_-a" + ] + ); + } + + #[test] + #[serial_test::serial] + fn unnumbered_identity_retains_source_when_bom_and_cr_are_received() { + // Given: equivalent received sources and no D9 identities. + crate::file_map::set_original_ids(std::collections::BTreeMap::new()); + let inputs = [ + ("/a.tsx", "a\nb"), + ("/b.tsx", "\u{feff}a\r\nb"), + ("/c.tsx", "a\rb"), + ]; + // When: the boundary constructs each source identity. + let files = inputs.map(|(filename, source)| SourceFile::from_source(filename, source)); + // Then: normalization retains the full source text, not the filename or a hash. + assert_eq!( + files, + [ + SourceFile::Unnumbered(Arc::from("a\nb")), + SourceFile::Unnumbered(Arc::from("a\nb")), + SourceFile::Unnumbered(Arc::from("ab")), + ] + ); + } +} diff --git a/libs/css/src/sparse_site/source_ids.rs b/libs/css/src/sparse_site/source_ids.rs new file mode 100644 index 000000000..822b18f0d --- /dev/null +++ b/libs/css/src/sparse_site/source_ids.rs @@ -0,0 +1,43 @@ +use std::{ + collections::BTreeMap, + sync::{LazyLock, Mutex}, +}; + +static ORIGINAL_IDS: LazyLock>> = + LazyLock::new(|| Mutex::new(BTreeMap::new())); + +fn with_original_ids(f: impl FnOnce(&mut BTreeMap) -> R) -> R { + f(&mut ORIGINAL_IDS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner)) +} + +/// D9 number of `file`, if it belongs to the numbered original sources. +#[must_use] +pub fn original_id(file: &str) -> Option { + with_original_ids(|ids| ids.get(file).copied()) +} + +#[must_use] +pub fn get_original_ids() -> BTreeMap { + with_original_ids(|ids| ids.clone()) +} + +pub fn set_original_ids(new_ids: BTreeMap) { + with_original_ids(|ids| *ids = new_ids); +} + +pub(crate) fn seed_original_ids(files: &[String]) { + let mut originals: Vec<&String> = files.iter().collect(); + originals.sort_unstable(); + originals.dedup(); + with_original_ids(|ids| { + for file in originals { + if !ids.contains_key(file) + && let Ok(id) = u32::try_from(ids.len()) + { + ids.insert(file.clone(), id); + } + } + }); +} diff --git a/libs/css/src/style_origin.rs b/libs/css/src/style_origin.rs new file mode 100644 index 000000000..b99650e8f --- /dev/null +++ b/libs/css/src/style_origin.rs @@ -0,0 +1,112 @@ +use serde::{Deserialize, Serialize}; +use std::{ + cmp::Ordering, + hash::{Hash, Hasher}, +}; + +/// A diagnostic witness from the actual source read, never a generated offset. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct StyleOrigin { + pub file: String, + pub line: usize, + #[serde(rename = "col")] + pub column: usize, + #[serde(rename = "originalExpression")] + pub expression: String, +} + +/// Ordered diagnostic tiers; none claims a generated position is source text. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum RealLocation { + Exact(StyleOrigin), + ProducedByCall(StyleOrigin), + ModuleExport { + file: String, + binding: Option, + }, +} + +impl std::fmt::Display for RealLocation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Exact(origin) => write!( + f, + "{}:{}:{}: `{}`", + origin.file, origin.line, origin.column, origin.expression + ), + Self::ProducedByCall(origin) => write!( + f, + "produced by the call at {}:{}:{}: `{}`", + origin.file, origin.line, origin.column, origin.expression + ), + Self::ModuleExport { file, binding } => { + write!(f, "{file}")?; + if let Some(binding) = binding { + write!(f, " export `{binding}`")?; + } + write!( + f, + ": position unavailable (produced inside eval or a callback)" + ) + } + } + } +} + +/// Diagnostic metadata does not participate in a style's semantic identity. +#[derive(Clone, Debug, Default)] +pub struct Origin(pub Option>, pub Option>); + +impl PartialEq for Origin { + fn eq(&self, _: &Self) -> bool { + true + } +} +impl Eq for Origin {} +impl Hash for Origin { + fn hash(&self, _: &mut H) {} +} +impl PartialOrd for Origin { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Origin { + fn cmp(&self, _: &Self) -> Ordering { + Ordering::Equal + } +} + +impl Origin { + pub fn fill(&mut self, origin: Option<&StyleOrigin>) { + if self.0.is_none() { + self.0 = origin.cloned().map(Box::new); + } + } + + #[must_use] + pub fn location(&self) -> Option { + self.0 + .as_deref() + .cloned() + .map(RealLocation::Exact) + .or_else(|| self.1.as_deref().cloned()) + } + + #[must_use] + pub fn from_location(location: RealLocation) -> Self { + match location { + RealLocation::Exact(origin) => Self(Some(Box::new(origin)), None), + RealLocation::ProducedByCall(_) | RealLocation::ModuleExport { .. } => { + Self(None, Some(Box::new(location))) + } + } + } + + pub fn fill_from(&mut self, origin: &Self) { + if self.0.is_none() && self.1.is_none() { + self.0.clone_from(&origin.0); + self.1.clone_from(&origin.1); + } + } +} diff --git a/libs/extractor/src/assignment_blocker_support.rs b/libs/extractor/src/assignment_blocker_support.rs new file mode 100644 index 000000000..26b757d69 --- /dev/null +++ b/libs/extractor/src/assignment_blocker_support.rs @@ -0,0 +1,44 @@ +use crate::{ + ExtractStyleValue, + assignment_test_support::{evaluate, extracted, jsx_js}, + extract_style::style_property::StyleProperty, +}; + +pub(super) fn selected(source: &str, result: &str) -> String { + let output = extracted(source); + let records = output + .styles + .iter() + .filter_map(|value| { + let style = value.extract(None)?; + match (value, style) { + (ExtractStyleValue::Static(value), StyleProperty::ClassName(class)) => Some(( + class, + format!("{}:{}:{}", value.property, value.value, value.level), + )), + ( + ExtractStyleValue::Dynamic(value), + StyleProperty::Variable { + class_name, + variable_name, + .. + }, + ) => Some(( + class_name, + format!( + "{}:var({}):{}", + value.property(), + variable_name, + value.level() + ), + )), + _ => None, + } + }) + .collect::>(); + let records = serde_json::to_string(&records).unwrap_or_else(|error| panic!("{error}")); + evaluate(&format!( + "{} const records={records}; const selected=node=>String(node.className??'').split(/\\s+/).filter(Boolean).map(name=>{{const entry=records.find(([key])=>key===name);if(!entry)throw Error('unbound class '+name);return entry[1]}}).sort(); const assigned=node=>selected(node).map(value=>value.replace(/var\\(([^)]+)\\)/g,(_,variable)=>String(node.style[variable]))); {result}", + jsx_js(&output.code) + )) +} diff --git a/libs/extractor/src/assignment_blocker_tests.rs b/libs/extractor/src/assignment_blocker_tests.rs new file mode 100644 index 000000000..b07c65e8e --- /dev/null +++ b/libs/extractor/src/assignment_blocker_tests.rs @@ -0,0 +1,150 @@ +use crate::assignment_blocker_support::selected; +use crate::assignment_test_support::{compiled_jsx, evaluate}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case( + false, + "[\"color:red:0\",\"padding:4px:0\"]", + "[\"background:red:0\",\"margin:12px:0\"]" +)] +#[case( + true, + "[\"color:blue:0\",\"color:green:0\",\"padding:4px:0\"]", + "[\"margin:8px:0\"]" +)] +#[serial] +fn composition_selects_source_rules_when_conditions_change( + #[case] flag: bool, + #[case] a: &str, + #[case] b: &str, +) { + // Given: the exact rejected compositions with distinct overridden values. + let source = format!( + "import {{css}} from '@devup-ui/react';function render(cond,flag){{return [css({{color:'red',p:1}},cond&&{{color:'blue',_hover:{{color:'green'}}}}),css([{{m:1}},flag?{{m:2}}:{{m:3,bg:'red'}}])]}}const nodes=render({flag},{flag});" + ); + // When: both class strings are evaluated and matched to emitted declarations. + let actual = selected( + &source, + "JSON.stringify(nodes.map(className=>selected({className})));", + ); + // Then: each condition chooses only its authored rules. + assert_eq!(actual, format!("[{a},{b}]")); +} + +#[rstest] +#[case(true, "[\"box-shadow:none:0\",\"box-shadow:none:2\"]")] +#[case(false, "[]")] +#[serial] +fn nested_selector_keeps_branch_bindings_when_responsive_styles_are_selected( + #[case] enabled: bool, + #[case] expected: &str, +) { + // Given: an AND style object with a nested responsive assignment. + let source = format!( + "import {{Box}} from '@devup-ui/react';function render(enabled){{return }}const node=render({enabled});" + ); + // When: the actual emitted branch executes. + let actual = selected(&source, "JSON.stringify(selected(node));"); + // Then: the enabled branch is bound and the disabled branch is empty. + assert_eq!(actual, expected); +} + +#[rstest] +#[case("sm", false, "12px")] +#[case("md", true, "28px")] +#[case("lg", false, "20px")] +#[serial] +fn nested_shorthand_members_keep_classes_when_consumers_coalesce( + #[case] size: &str, + #[case] icon: bool, + #[case] value: &str, +) { + // Given: px's two consumers and the exact nested icon/size choices. + let source = format!( + "import {{Box}} from '@devup-ui/react';function render(size,icon){{return }}const node=render('{size}',{icon});" + ); + // When: the selected classes execute through both maps. + let actual = selected(&source, "JSON.stringify(selected(node));"); + // Then: both padding sides use the source's selected value. + assert_eq!( + actual, + format!("[\"padding-left:{value}:0\",\"padding-right:{value}:0\"]"), + "{}", + compiled_jsx(&source) + ); +} + +#[rstest] +#[case("[1,0.5,...some][a]", "some:[0.25],a:2")] +#[case("[...arr,1][idx]", "arr:[0.25],idx:0")] +#[serial] +fn spread_array_selects_raw_value_when_index_is_dynamic( + #[case] expression: &str, + #[case] input: &str, +) { + // Given: each distinct rejected spread-array shape selects the spread's numeric value. + let source = format!( + "import {{Box}} from '@devup-ui/react';function render({{some,arr,a,idx}}){{return }}const node=render({{{input}}});JSON.stringify([String(node.className??'').split(/\\s+/).filter(Boolean).length,Object.values(node.style??{{}})]);" + ); + // When: the actual class and inline assignment are evaluated jointly. + let actual = evaluate(&compiled_jsx(&source)); + // Then: a dynamic class exists and receives 0.25 rather than tuple projections of a number. + assert_eq!(actual, "[1,[0.25]]"); +} + +#[rstest] +#[case( + "}>{state.child}", + "[\"f\",\"tag\",\"child\"]" +)] +#[case( + "jsx(Box,{...f(),[state.key]:state.value})", + "[\"f\",\"key\",\"value\"]" +)] +#[case( + "jsx(Box,{className:'z',onClick:state.click,[state.key]:2,...f(),[state.key]:1,title:'x'})", + "[\"click\",\"key\",\"f\",\"key\"]" +)] +#[case("", "[\"rest\"]")] +#[serial] +fn spread_reads_remain_once_and_ordered_when_adjacent_fields_execute( + #[case] expression: &str, + #[case] expected: &str, +) { + // Given: getters expose computed keys, element type and children around spreads. + let source = format!( + "import {{Box}} from '@devup-ui/react';import {{jsx}} from 'react/jsx-runtime';function render(state,f){{return {expression}}}let trace=[];const state={{}};for(const [key,value]of Object.entries({{tag:'b',child:'child',key:'x',value:3,click:'handler',rest:{{id:1}}}}))Object.defineProperty(state,key,{{get(){{trace.push(key);return value}}}});const f=()=>{{trace.push('f');return {{id:2}}}};const jsx=(tag,props)=>props;const node=render(state,f);JSON.stringify(trace);" + ); + // When: emitted full props execute in Boa. + let actual = evaluate(&compiled_jsx(&source)); + // Then: source order and count, including both authored key reads, remain exact. + assert_eq!(actual, expected); +} + +#[rstest] +#[case("({lg:'buttonLg',md:'button',sm:'buttonSm',tag:'tag'}as const)[state.size]")] +#[case("({lg:'buttonLg',md:'button',sm:'buttonSm',tag:'tag'})[state.size]")] +#[serial] +fn typography_reads_stay_before_children_when_literal_maps_are_selected(#[case] expression: &str) { + // Given: both rejected typography forms and observable key/child accessors. + let source = format!( + "import {{Text}} from '@devup-ui/react';function render(state){{return {{state.child}}}}let trace=[];const state={{get size(){{trace.push('size');return 'sm'}},get child(){{trace.push('child');return 'text'}}}};const node=render(state);JSON.stringify(trace);" + ); + // When: emitted attributes and children execute. + let actual = evaluate(&compiled_jsx(&source)); + // Then: the source's lookup precedes its child. + assert_eq!(actual, "[\"size\",\"child\"]"); +} + +#[test] +#[serial] +fn jsx_call_reads_order_before_background_when_controller_changes() { + // Given: consecutive controller reads differ, so reversing them changes both outputs. + let source = "import {Box}from '@devup-ui/react';import {jsx}from 'react/jsx-runtime';function render(state){return jsx(Box,{styleOrder:state.active?5:10,bg:state.active?'red':'blue'})}let count=0;const state={get active(){return ++count===1}};const jsx=(tag,props)=>props;const node=render(state);"; + // When: actual emitted classes select an order and a value. + let actual = selected(source, "JSON.stringify([count,selected(node)]);"); + // Then: the second read selects blue while the first selects order 5 (order asserted separately). + assert_eq!(actual, "[2,[\"background:blue:0\"]]"); +} diff --git a/libs/extractor/src/assignment_capture.rs b/libs/extractor/src/assignment_capture.rs new file mode 100644 index 000000000..70e0972ba --- /dev/null +++ b/libs/extractor/src/assignment_capture.rs @@ -0,0 +1,262 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, ObjectProperty, ObjectPropertyKind, Str}, + builder::AstBuilder, +}; +use oxc_ast_visit::VisitMut; +use oxc_span::{GetSpan, SPAN}; + +use crate::{ExtractStyleProp, utils::call_with_values}; + +#[cfg(test)] +#[path = "styled_creation_tests.rs"] +mod styled_creation_tests; + +pub(crate) fn coalesce(styles: &mut [ExtractStyleProp<'_>]) { + for index in 0..styles.len() { + let (previous, rest) = styles.split_at_mut(index); + if let Some(ExtractStyleProp::Evaluated { binding, styles: consumers, .. }) = rest.first_mut() + && let Some(ExtractStyleProp::Evaluated { styles: owner, .. }) = previous.iter_mut().find(|style| { + matches!(style, ExtractStyleProp::Evaluated { binding: existing, .. } if existing == binding) + }) + { + owner.append(consumers); + crate::assignment_consumers::merge(owner); + rest[0] = ExtractStyleProp::StaticArray(vec![]); + } + } +} + +pub(crate) fn take_evaluations<'a>( + styles: &mut [ExtractStyleProp<'a>], +) -> Vec<(String, Expression<'a>)> { + let mut values = Vec::new(); + for style in styles { + match style { + ExtractStyleProp::Evaluated { + binding, + evaluation, + .. + } => { + if let Some(value) = evaluation.take() { + values.push((binding.clone(), value)); + } + } + ExtractStyleProp::StaticArray(styles) => values.extend(take_evaluations(styles)), + _ => {} + } + } + values +} + +pub(crate) fn orders( + styles: &mut [ExtractStyleProp<'_>], + alternate: Option, + is_alternate: bool, +) { + for style in styles { + match style { + ExtractStyleProp::Evaluated { + alternate_order, + alternate_class, + .. + } => { + *alternate_order = + Some(crate::assignment_lowering::AlternateOrder { value: alternate }); + *alternate_class = is_alternate; + } + ExtractStyleProp::StaticArray(styles) => orders(styles, alternate, is_alternate), + _ => {} + } + } +} + +pub(crate) fn class_expression<'a>( + ast: &AstBuilder<'a>, + styles: &mut [ExtractStyleProp<'a>], + expression: Option>, +) -> Option> { + let mut values = take_evaluations(styles); + values.sort_by_key(|(_, value)| crate::provenance::source_offset(value.span().start)); + expression.map(|expression| { + if values.is_empty() { + expression + } else { + call_with_values(ast, values, expression) + } + }) +} + +pub(crate) fn component<'a>( + ast: &AstBuilder<'a>, + component: &mut Expression<'a>, + styles: &mut [ExtractStyleProp<'a>], +) { + let mut values = take_evaluations(styles); + values.sort_by_key(|(_, value)| crate::provenance::source_offset(value.span().start)); + if !values.is_empty() + && let Expression::ArrowFunctionExpression(component) = component + && let Some(body) = component.body.as_expression_mut() + { + let original = std::mem::replace(body, Expression::new_null_literal(SPAN, ast)); + *body = call_with_values(ast, values, original); + } +} + +/// Literal fields are evaluated when styled is created, not by its render body. +pub(crate) struct StyledCreation<'b, 'a> { + pub(crate) source: &'b Expression<'a>, + pub(crate) styles: &'b mut [ExtractStyleProp<'a>], +} + +pub(crate) fn styled_creation<'a>( + ast: &AstBuilder<'a>, + component: &mut Expression<'a>, + input: StyledCreation<'_, 'a>, +) { + let mut values = take_evaluations(input.styles); + let evaluation_count = values.len(); + let mut fields = Vec::new(); + literal_fields(input.source, &mut fields); + let records = input + .styles + .iter() + .flat_map(ExtractStyleProp::extract) + .collect::>(); + let source_code = crate::utils::expression_to_code(input.source); + let component_code = crate::utils::expression_to_code(component); + let mut replacements = Vec::new(); + for (field, typography) in fields { + if values.iter().any(|(_, value)| value.span() == field.span()) { + continue; + } + let mut name = format!( + "__devupStyled{}", + crate::provenance::source_offset(field.span().start) + ); + while source_code.contains(&name) || component_code.contains(&name) { + name.push('_'); + } + let start = field.span().start; + let site_span = oxc_span::Span::new(start, start.saturating_add(1)); + let variables = records + .iter() + .filter_map(|record| match record { + crate::ExtractStyleValue::Dynamic(style) + if style.site.as_ref().is_some_and(|site| { + crate::sparse_sites::contains_site(site_span, site) + }) => + { + Some(style.variable_name()) + } + _ => None, + }) + .collect(); + replacements.push(CreationReference { + code: crate::utils::expression_to_code(field), + name: name.clone(), + span: field.span(), + typography, + variables, + }); + values.push((name, field.clone_in(ast.allocator()))); + } + let mut replace = CreationReferences { + ast, + replacements: &replacements, + }; + replace.visit_expression(component); + // Only lowered evaluations use the references; capture arguments remain authored. + for (_, value) in values.iter_mut().take(evaluation_count) { + replace.visit_expression(value); + } + values.sort_by_key(|(_, value)| crate::provenance::source_offset(value.span().start)); + if !values.is_empty() { + let original = std::mem::replace(component, Expression::new_null_literal(SPAN, ast)); + *component = call_with_values(ast, values, original); + } +} + +fn literal_fields<'b, 'a>( + source: &'b Expression<'a>, + fields: &mut Vec<(&'b Expression<'a>, bool)>, +) { + if let Expression::ObjectExpression(object) = crate::utils::unwrap_syntax_only(source) { + for property in &object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) => { + let value = &property.value; + match crate::utils::unwrap_syntax_only(value) { + Expression::ObjectExpression(_) => literal_fields(value, fields), + Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) => {} + _ if crate::utils::get_string_by_literal_expression(value).is_some() => {} + _ => fields.push(( + value, + property.key.name().is_some_and(|name| name == "typography"), + )), + } + } + ObjectPropertyKind::SpreadProperty(spread) => { + literal_fields(&spread.argument, fields); + } + } + } + } +} + +struct CreationReference { + code: String, + name: String, + span: oxc_span::Span, + typography: bool, + variables: Vec, +} + +struct CreationReferences<'b, 'a> { + ast: &'b AstBuilder<'a>, + replacements: &'b [CreationReference], +} + +impl<'a> VisitMut<'a> for CreationReferences<'_, 'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + if matches!(expression, Expression::ArrowFunctionExpression(function) if function.span != SPAN) + || matches!(expression, Expression::FunctionExpression(_)) + { + return; + } + let code = crate::utils::expression_to_code(expression); + if let Some(reference) = self.replacements.iter().find(|reference| { + reference.code == code + && (reference.span == expression.span() + || expression.span() == SPAN && reference.typography) + }) { + *expression = Expression::new_identifier( + expression.span(), + Str::from_in(reference.name.as_str(), self.ast.allocator()), + self.ast, + ); + } else { + oxc_ast_visit::walk_mut::walk_expression(self, expression); + } + } + + fn visit_object_property(&mut self, property: &mut ObjectProperty<'a>) { + if let Some(reference) = self.replacements.iter().find(|reference| { + property.key.name().is_some_and(|key| { + reference + .variables + .iter() + .any(|variable| variable == key.as_ref()) + }) + }) { + property.value = Expression::new_identifier( + property.value.span(), + Str::from_in(reference.name.as_str(), self.ast.allocator()), + self.ast, + ); + } else { + oxc_ast_visit::walk_mut::walk_object_property(self, property); + } + } +} diff --git a/libs/extractor/src/assignment_composition.rs b/libs/extractor/src/assignment_composition.rs new file mode 100644 index 000000000..f595762ab --- /dev/null +++ b/libs/extractor/src/assignment_composition.rs @@ -0,0 +1,199 @@ +use crate::utils::{ + call_with_values, get_string_by_literal_expression, unwrap_syntax_only, unwrap_syntax_only_mut, +}; +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{ + Argument, ArrayExpressionElement, BinaryOperator, ComputedMemberExpression, Expression, + LogicalOperator, Str, + }, + builder::AstBuilder, +}; +use oxc_span::{GetSpan, GetSpanMut}; + +pub(crate) fn capture<'a>( + ast: &AstBuilder<'a>, + arguments: &mut [Argument<'a>], +) -> Vec<(String, Expression<'a>)> { + let mut captures = Captures { + ast, + reads: Vec::new(), + }; + for argument in arguments { + if let Some(value) = argument.as_expression_mut() { + captures.part(value); + } + } + captures.reads +} + +struct Captures<'b, 'a> { + ast: &'b AstBuilder<'a>, + reads: Vec<(String, Expression<'a>)>, +} + +impl<'a> Captures<'_, 'a> { + fn read(&mut self, value: &mut Expression<'a>) { + let ast = self.ast; + if get_string_by_literal_expression(value).is_some() { + return; + } + let name = format!("__devupCompose{}", self.reads.len()); + let reference = Expression::new_identifier( + value.span(), + Str::from_in(name.as_str(), ast.allocator()), + ast, + ); + self.reads.push((name, std::mem::replace(value, reference))); + } + + fn part(&mut self, value: &mut Expression<'a>) { + let ast = self.ast; + match unwrap_syntax_only_mut(value) { + Expression::ArrayExpression(array) => { + for element in &mut array.elements { + if let Some(value) = element.as_expression_mut() { + self.part(value); + } + } + } + Expression::ConditionalExpression(conditional) => { + let con = class(&conditional.consequent, ast); + let alt = class(&conditional.alternate, ast); + let name = self.choice( + &mut conditional.test, + con.clone_in(ast.allocator()), + alt.clone_in(ast.allocator()), + ); + if con.is_some() { + conditional.consequent = slot(ast, &name, 1); + } + if alt.is_some() { + conditional.alternate = slot(ast, &name, 1); + } + } + Expression::LogicalExpression(logical) => { + if logical.operator == LogicalOperator::And + || !matches!( + unwrap_syntax_only(&logical.left), + Expression::ObjectExpression(_) + ) + { + let right = class(&logical.right, ast); + let name = if logical.operator == LogicalOperator::And { + self.choice(&mut logical.left, right.clone_in(ast.allocator()), None) + } else { + let original = logical.left.clone_in(ast.allocator()); + let controller = + Expression::new_identifier(oxc_span::SPAN, "__devupChoice", ast); + let test = if logical.operator == LogicalOperator::Coalesce { + Expression::new_binary_expression( + oxc_span::SPAN, + controller.clone_in(ast.allocator()), + BinaryOperator::Inequality, + Expression::new_null_literal(oxc_span::SPAN, ast), + ast, + ) + } else { + controller.clone_in(ast.allocator()) + }; + let selected = Expression::new_conditional_expression( + oxc_span::SPAN, + test, + controller.clone_in(ast.allocator()), + right.clone_in(ast.allocator()).unwrap_or_else(|| { + Expression::new_string_literal(oxc_span::SPAN, "", None, ast) + }), + ast, + ); + self.pair(&mut logical.left, original, selected) + }; + if right.is_some() { + logical.right = slot(ast, &name, 1); + } + } + } + Expression::Identifier(identifier) if identifier.name == "undefined" => {} + Expression::Identifier(_) + | Expression::StaticMemberExpression(_) + | Expression::ComputedMemberExpression(_) + | Expression::TemplateLiteral(_) => self.read(value), + _ => {} + } + } + + fn choice( + &mut self, + value: &mut Expression<'a>, + con: Option>, + alt: Option>, + ) -> String { + let ast = self.ast; + let original = value.clone_in(ast.allocator()); + let controller = Expression::new_identifier(oxc_span::SPAN, "__devupChoice", ast); + let selected = Expression::new_conditional_expression( + oxc_span::SPAN, + controller.clone_in(ast.allocator()), + con.unwrap_or_else(|| Expression::new_string_literal(oxc_span::SPAN, "", None, ast)), + alt.unwrap_or_else(|| Expression::new_string_literal(oxc_span::SPAN, "", None, ast)), + ast, + ); + self.pair(value, original, selected) + } + + fn pair( + &mut self, + value: &mut Expression<'a>, + original: Expression<'a>, + selected: Expression<'a>, + ) -> String { + let ast = self.ast; + let controller = Expression::new_identifier(oxc_span::SPAN, "__devupChoice", ast); + let name = format!("__devupCompose{}", self.reads.len()); + let tuple = Expression::new_array_expression( + oxc_span::SPAN, + oxc_allocator::Vec::from_array_in( + [ + ArrayExpressionElement::from(controller), + ArrayExpressionElement::from(selected), + ], + ast, + ), + ast, + ); + let result = call_with_values(ast, vec![("__devupChoice".to_string(), original)], tuple); + self.reads.push((name.clone(), result)); + let span = value.span(); + *value = slot(ast, &name, 0); + *value.span_mut() = span; + name + } +} + +fn class<'a>(value: &Expression<'a>, ast: &AstBuilder<'a>) -> Option> { + match unwrap_syntax_only(value) { + Expression::Identifier(identifier) if identifier.name == "undefined" => None, + Expression::Identifier(_) + | Expression::StaticMemberExpression(_) + | Expression::ComputedMemberExpression(_) + | Expression::StringLiteral(_) + | Expression::TemplateLiteral(_) => Some(value.clone_in(ast.allocator())), + _ => None, + } +} + +fn slot<'a>(ast: &AstBuilder<'a>, name: &str, index: u8) -> Expression<'a> { + Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( + oxc_span::SPAN, + Expression::new_identifier(oxc_span::SPAN, Str::from_in(name, ast.allocator()), ast), + Expression::new_numeric_literal( + oxc_span::SPAN, + f64::from(index), + None, + oxc_syntax::number::NumberBase::Decimal, + ast, + ), + false, + ast, + )) +} diff --git a/libs/extractor/src/assignment_consumers.rs b/libs/extractor/src/assignment_consumers.rs new file mode 100644 index 000000000..62b971949 --- /dev/null +++ b/libs/extractor/src/assignment_consumers.rs @@ -0,0 +1,90 @@ +use crate::{ExtractStyleProp, utils::expression_to_code}; +use oxc_span::GetSpan; + +pub(crate) fn merge(styles: &mut Vec>) { + let mut result = Vec::new(); + for style in std::mem::take(styles) { + match style { + ExtractStyleProp::StaticArray(mut inner) => { + merge(&mut inner); + result.extend(inner); + } + style => result.push(style), + } + } + for style in result { + let existing = styles + .iter_mut() + .find(|existing| match (&style, &**existing) { + ( + ExtractStyleProp::Conditional { + condition: left, .. + }, + ExtractStyleProp::Conditional { + condition: right, .. + }, + ) + | ( + ExtractStyleProp::MemberExpression { + expression: left, .. + }, + ExtractStyleProp::MemberExpression { + expression: right, .. + }, + ) => { + left.span() == right.span() + && expression_to_code(left) == expression_to_code(right) + } + _ => false, + }); + match (existing, style) { + ( + Some(ExtractStyleProp::Conditional { + consequent, + alternate, + .. + }), + ExtractStyleProp::Conditional { + consequent: added_con, + alternate: added_alt, + .. + }, + ) => { + append_branch(consequent, added_con); + append_branch(alternate, added_alt); + } + ( + Some(ExtractStyleProp::MemberExpression { map, .. }), + ExtractStyleProp::MemberExpression { map: added, .. }, + ) => { + for (key, added) in added { + let selected = map + .entry(key) + .or_insert_with(|| Box::new(ExtractStyleProp::StaticArray(vec![]))); + let original = + std::mem::replace(selected.as_mut(), ExtractStyleProp::StaticArray(vec![])); + let mut consumers = vec![original, *added]; + merge(&mut consumers); + **selected = ExtractStyleProp::StaticArray(consumers); + } + } + (_, style) => styles.push(style), + } + } +} + +fn append_branch<'a>( + branch: &mut Option>>, + added: Option>>, +) { + if let Some(added) = added { + let mut styles = branch + .take() + .into_iter() + .map(|style| *style) + .chain([*added]) + .collect(); + merge(&mut styles); + *branch = Some(Box::new(ExtractStyleProp::StaticArray(styles))); + } +} diff --git a/libs/extractor/src/assignment_container_tests.rs b/libs/extractor/src/assignment_container_tests.rs new file mode 100644 index 000000000..73815d3c6 --- /dev/null +++ b/libs/extractor/src/assignment_container_tests.rs @@ -0,0 +1,145 @@ +use crate::assignment_blocker_support::selected; +use crate::assignment_test_support::{compiled_jsx, evaluate, extracted}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("{state.child}")] +#[case(" span':{p:state.pad}}} id={state.id}>{state.child}")] +#[case("jsx(Box,{_hover:{p:state.pad},id:state.id,children:state.child})")] +#[case("jsx(Box,{selectors:{'& > span':{p:state.pad}},id:state.id,children:state.child})")] +#[serial] +fn selector_container_assigns_leaf_when_adjacent_reads_are_captured(#[case] expression: &str) { + // Given: an observable scalar leaf inside a selector object and later operands. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{get pad(){{trace.push('pad');return '16px'}},get id(){{trace.push('id');return 'target'}},get child(){{trace.push('child');return 'text'}}}};const jsx=(tag,props)=>props;const node=render(state);" + ); + // When: actual emitted classes are joined to their actual inline variables. + let actual = selected(&source, "JSON.stringify([trace,assigned(node),node.id]);"); + // Then: padding receives the leaf, never the containing object, in source order. + assert_eq!( + actual, + "[[\"pad\",\"id\",\"child\"],[\"padding:16px:0\"],\"target\"]", + "{}", + compiled_jsx(&source) + ); +} + +#[rstest] +#[case("{state.child}")] +#[case( + " span':{['p']:state.pad,color:state.color},'& > b':{p:state.other}}} id={state.id}>{state.child}" +)] +#[case("jsx(Box,{_hover:{['p']:state.pad,color:state.color},id:state.id,children:state.child})")] +#[serial] +fn selector_container_keeps_variable_associations_when_properties_are_distinct( + #[case] expression: &str, +) { + // Given: different property values, computed literal keys and adjacent getters. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{}};for(const[key,value]of Object.entries({{pad:'16px',color:'red',other:'24px',id:'target',child:'text'}}))Object.defineProperty(state,key,{{get(){{trace.push(key);return value}}}});const jsx=(tag,props)=>props;const node=render(state);" + ); + let descendant = expression.contains("selectors"); + // When: selected classes consume the variable assigned by their authored leaf. + let actual = selected(&source, "JSON.stringify([trace,assigned(node)]);"); + // Then: different leaves neither alias nor receive an entire object. + let expected = if descendant { + "[[\"pad\",\"color\",\"other\",\"id\",\"child\"],[\"color:red:0\",\"padding:16px:0\",\"padding:24px:0\"]]" + } else { + "[[\"pad\",\"color\",\"id\",\"child\"],[\"color:red:0\",\"padding:16px:0\"]]" + }; + assert_eq!(actual, expected); +} + +#[rstest] +#[case("0")] +#[case("false")] +#[case("null")] +#[case("undefined")] +#[case("''")] +#[case("NaN")] +#[serial] +fn and_zero_is_no_style_when_controller_is_falsy(#[case] controller: &str) { + // Given: main/#751's AND style guard with observable left and right getters. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}let trace=[];const state={{get a(){{trace.push('a');return {controller}}},get b(){{trace.push('b');return '16px'}},get id(){{trace.push('id');return 'target'}}}};const node=render(state);JSON.stringify([trace,String(node.className??'').trim(),Object.values(node.style??{{}})]);" + ); + // When: the compiled guard selects its no-style branch. + let actual = evaluate(&compiled_jsx(&source)); + // Then: even zero supplies no class/value, a runs once and b never runs. + assert_eq!(actual, "[[\"a\",\"id\"],\"\",[]]"); +} + +#[test] +#[serial] +fn literal_selectors_keep_compiling_when_children_are_conditional_arrays() { + // Given: literal selectors/keys and lazy conditional array children. + let source = "import {Box,css}from '@devup-ui/react';function render(state){return span':{['p']:state.pad}}} id={state.id}>{state.flag?[state.child]:[]}}let trace=[];const state={pad:'16px',id:'target',flag:false,get child(){trace.push('child');return 'text'}};const node=render(state);"; + // When: the actual output executes the false child branch. + let actual = selected( + source, + "JSON.stringify([trace,assigned(node),node.children]);", + ); + // Then: selector classes remain selected and the unused child is not read. + assert_eq!(actual, "[[],[\"padding:16px:0\"],[[]]]"); +} + +#[test] +#[serial] +fn css_and_literal_keeps_compiling_when_background_is_guarded() { + // Given: the previously valid main/#751 literal CSS guard. + let source = "import {css}from '@devup-ui/react';const LIGHT='white';function render(dark){return css({bg:dark&&LIGHT})}const node=render(false);"; + // When: extraction compiles the guard without rejecting valid CSS. + let output = extracted(source); + // Then: its no-style branch executes without a generated class. + assert_eq!( + evaluate(&format!( + "{}JSON.stringify(node);", + crate::assignment_test_support::jsx_js(&output.code) + )), + "\"\"" + ); +} + +#[rstest] +#[case( + " span':{p:state.pad},'& > b':{p:state.pad}}} id={state.id}>{state.child}" +)] +#[case( + "jsx(Box,{selectors:{'& > span':{p:state.pad},'& > b':{p:state.pad}},id:state.id,children:state.child})" +)] +#[serial] +fn selector_container_keeps_source_sites_when_repeated_getters_return_different_values( + #[case] expression: &str, +) { + // Given: BOM/CRLF/multibyte source and identical getter syntax at different sites. + let source = format!( + "\u{feff}import {{Box}}from '@devup-ui/react';\r\nimport {{jsx}}from 'react/jsx-runtime';\r\nconst label='한';function render(state){{return {expression}}}let trace=[];let reads=0;const state={{get pad(){{trace.push('pad');return ++reads===1?'16px':'24px'}},get id(){{trace.push('id');return 'target'}},get child(){{trace.push('child');return 'text'}}}};const jsx=(tag,props)=>props;const node=render(state);" + ); + // When: each selected class consumes its own source assignment. + let actual = selected(&source, "JSON.stringify([trace,assigned(node)]);"); + // Then: repeated syntax remains two ordered reads with different variable values. + assert_eq!( + actual, + "[[\"pad\",\"pad\",\"id\",\"child\"],[\"padding:16px:0\",\"padding:24px:0\"]]" + ); +} + +#[rstest] +#[case("0")] +#[case("false")] +#[case("null")] +#[case("undefined")] +#[case("''")] +#[case("NaN")] +#[serial] +fn css_and_zero_is_no_style_when_controller_is_falsy(#[case] controller: &str) { + // Given: the same style-guard convention on the css() caller path. + let source = format!( + "import {{css}}from '@devup-ui/react';function render(state){{return css({{bg:state.a&&'white'}})}}let reads=0;const state={{get a(){{reads++;return {controller}}}}};const node=render(state);JSON.stringify([reads,node]);" + ); + // When: the falsy controller selects no CSS class. + let actual = evaluate(&compiled_jsx(&source)); + // Then: all falsy controllers, including zero, are read once and yield no style. + assert_eq!(actual, "[1,\"\"]"); +} diff --git a/libs/extractor/src/assignment_lowering.rs b/libs/extractor/src/assignment_lowering.rs new file mode 100644 index 000000000..f1bfa9c22 --- /dev/null +++ b/libs/extractor/src/assignment_lowering.rs @@ -0,0 +1,191 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{ + BinaryOperator, ChainElement, ComputedMemberExpression, Expression, LogicalOperator, Str, + UnaryOperator, + }, + builder::AstBuilder, +}; +use oxc_span::SPAN; +use oxc_syntax::number::NumberBase; + +pub(crate) use crate::assignment_capture::{ + class_expression, coalesce, component, orders, take_evaluations, +}; +use crate::{ + ExtractStyleProp, + utils::{call_with_values, unwrap_syntax_only}, +}; + +pub(crate) fn projection<'a>(ast: &AstBuilder<'a>, binding: &str, slot: u8) -> Expression<'a> { + let value = Expression::new_chain_expression( + SPAN, + ChainElement::ComputedMemberExpression(ComputedMemberExpression::boxed( + SPAN, + Expression::new_identifier(SPAN, Str::from_in(binding, ast.allocator()), ast), + Expression::new_numeric_literal(SPAN, f64::from(slot), None, NumberBase::Decimal, ast), + true, + ast, + )), + ast, + ); + if matches!(slot, 0 | 3) { + Expression::new_logical_expression( + SPAN, + value, + LogicalOperator::Coalesce, + Expression::new_string_literal(SPAN, "", None, ast), + ast, + ) + } else { + value + } +} + +pub(super) fn empty<'a>(ast: &AstBuilder<'a>) -> Expression<'a> { + Expression::new_object_expression(SPAN, oxc_allocator::Vec::new_in(ast), ast) +} + +pub(crate) struct Lowering<'b, 'a> { + pub(crate) ast: &'b AstBuilder<'a>, + pub(crate) order: Option, + pub(crate) filename: Option<&'b str>, + pub(crate) alternate_order: Option, +} + +#[derive(Debug, Clone, Copy)] +pub struct AlternateOrder { + pub(super) value: Option, +} + +pub(super) struct Classes<'a> { + pub(super) primary: Expression<'a>, + pub(super) alternate: Option>, +} + +impl<'a> Lowering<'_, 'a> { + pub(crate) fn lower( + &self, + source: &Expression<'a>, + styles: &mut [ExtractStyleProp<'a>], + ) -> Expression<'a> { + let ast = self.ast; + let source = unwrap_syntax_only(source); + if let [ + ExtractStyleProp::StaticArray(inner) + | ExtractStyleProp::Evaluated { styles: inner, .. }, + ] = styles + { + return self.lower(source, inner); + } + match (source, styles) { + ( + Expression::ConditionalExpression(source), + [ + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + }, + ], + ) => Expression::new_conditional_expression( + SPAN, + source.test.clone_in(ast.allocator()), + self.branch(&source.consequent, consequent), + self.branch(&source.alternate, alternate), + ast, + ), + ( + Expression::LogicalExpression(source), + [ + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + }, + ], + ) => { + let (left, right) = match source.operator { + LogicalOperator::And => (alternate, consequent), + LogicalOperator::Or | LogicalOperator::Coalesce => (consequent, alternate), + }; + let selected = self.branch(&source.left, left); + let value = projection(ast, "__devupLeft", 2); + let test = match source.operator { + LogicalOperator::And => Expression::new_unary_expression( + SPAN, + UnaryOperator::LogicalNot, + value, + ast, + ), + LogicalOperator::Or => value, + LogicalOperator::Coalesce => Expression::new_logical_expression( + SPAN, + Expression::new_binary_expression( + SPAN, + value.clone_in(ast.allocator()), + BinaryOperator::StrictInequality, + Expression::new_null_literal(SPAN, ast), + ast, + ), + LogicalOperator::And, + Expression::new_binary_expression( + SPAN, + value, + BinaryOperator::StrictInequality, + Expression::new_unary_expression( + SPAN, + UnaryOperator::Void, + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + NumberBase::Decimal, + ast, + ), + ast, + ), + ast, + ), + ast, + ), + }; + let left = call_with_values( + ast, + vec![("__devupLeft".to_string(), selected)], + Expression::new_conditional_expression( + SPAN, + test, + Expression::new_identifier(SPAN, "__devupLeft", ast), + Expression::new_null_literal(SPAN, ast), + ast, + ), + ); + Expression::new_logical_expression( + SPAN, + left, + LogicalOperator::Coalesce, + self.branch(&source.right, right), + ast, + ) + } + (Expression::ComputedMemberExpression(source), styles) => self.member(source, styles), + (Expression::ArrayExpression(source), styles) => self.array(source, styles), + (Expression::ObjectExpression(source), styles) => self.object(source, styles), + (_, styles) => self.leaf(source, styles), + } + } + + fn branch( + &self, + source: &Expression<'a>, + styles: &mut Option>>, + ) -> Expression<'a> { + self.lower( + source, + styles + .as_mut() + .map_or(&mut [], |style| std::slice::from_mut(style.as_mut())), + ) + } +} diff --git a/libs/extractor/src/assignment_lowering_tests.rs b/libs/extractor/src/assignment_lowering_tests.rs new file mode 100644 index 000000000..9d00d579d --- /dev/null +++ b/libs/extractor/src/assignment_lowering_tests.rs @@ -0,0 +1,184 @@ +use crate::assignment_test_support::{compiled_jsx, evaluate, extracted, lowered}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("flag ? selected : absent", "const flag=true, selected='4px';")] +#[case("flag ? absent : selected", "const flag=false, selected='4px';")] +#[case("selected ?? absent", "const selected=0;")] +#[case("selected || absent", "const selected='4px';")] +#[case("selected && absent", "const selected=0;")] +#[case( + "[flag ? selected : absent, e, f, '2px']", + "const flag=true, selected='4px', e='8px', f='12px';" +)] +#[case( + "[flag ? selected : absent, e, f, nested ? '2px' : '3px']", + "const flag=true, nested=false, selected='4px', e='8px', f='12px';" +)] +#[case( + "[flag ? absent : selected, e, f, nested ? '2px' : '3px']", + "const flag=false, nested=true, selected='4px', e='8px', f='12px';" +)] +#[case( + "flag ? [selected, '2px'] : [absent, '3px']", + "const flag=true, selected='4px';" +)] +#[test] +#[serial] +fn unselected_branch_is_unread_when_it_is_in_the_tdz( + #[case] expression: &str, + #[case] setup: &str, +) { + // Given: the unselected branch cannot be read before its lexical declaration. + let compiled = lowered(expression); + // When: the lowered assignment is evaluated in that authored scope. + let actual = evaluate(&format!( + "{setup} const node={compiled}; let absent; JSON.stringify('ok');" + )); + // Then: selection succeeds without reading that branch. + assert_eq!(actual, "\"ok\""); +} + +#[rstest] +#[case("reads.flag ? reads.a : reads.b")] +#[case("reads.a ?? reads.b")] +#[case("reads.a || reads.b")] +#[case("reads.zero && reads.b")] +#[case("({a:reads.a,b:reads.b})[index()]")] +#[case("[[reads.a,1,reads.c],[reads.d,reads.e,2]][index()]")] +#[case("[, [reads.d,reads.e,reads.f]][index()]")] +#[case("[[reads.a,reads.b,reads.c],[reads.d,reads.e,reads.f]][index()]")] +#[case("({0:[reads.a,reads.b,reads.c],1:[reads.d,reads.e,reads.f]})[index()]")] +#[case("[reads.a, reads.b, reads.c]")] +#[case("({b:reads.b,a:reads.a})[key]")] +#[case("({a:reads.a,a:reads.b})['a']")] +#[test] +#[serial] +fn reads_follow_source_when_selections_and_responsive_values_are_lowered(#[case] expression: &str) { + // Given: getters and key coercion expose every read, including eager literal entries. + let setup = "let trace=[]; const reads={}; for(const [key,value] of Object.entries({flag:true,a:'4px',b:'8px',c:'12px',d:'16px',e:'20px',f:'24px',zero:0})) Object.defineProperty(reads,key,{get(){trace.push(key);return value}}); const index=()=>{trace.push('index');return 1}; const key={toString(){trace.push('key');return 'a'}};"; + let expected = evaluate(&format!( + "{setup} const value=({expression}); JSON.stringify(trace);" + )); + let compiled = lowered(expression); + // When: the generated class and inline assignment are evaluated together. + let actual = evaluate(&format!( + "{setup} const node={compiled}; JSON.stringify(trace);" + )); + // Then: the exact authored read order and count survive. + assert_eq!(actual, expected, "{expression}"); +} + +#[rstest] +#[case("state.choose ? state.a : state.b", "['4px']")] +#[case("state.a ?? state.b", "['4px']")] +#[case( + "[state.choose ? state.a : state.b, state.c, state.d, '2px']", + "['4px','12px','16px']" +)] +#[case("({a:state.a,b:state.b})[state.key]", "['8px']")] +#[case( + "[[state.a,1,state.c],[state.d,state.e,2]][state.index]", + "['16px','20px']" +)] +#[case("[, [state.d,state.e,state.f]][state.index]", "['16px','20px','24px']")] +#[case( + "[[state.a,state.b,state.c],[state.d,state.e,state.f]][state.index]", + "['16px','20px','24px']" +)] +#[case( + "({0:[state.a,state.b,state.c],1:[state.d,state.e,state.f]})[state.index]", + "['16px','20px','24px']" +)] +#[case("({a:state.a,a:state.b})['a']", "['8px']")] +#[case("({a:[state.a,state.b]})['a']", "['4px','8px']")] +#[test] +#[serial] +fn selected_values_survive_when_full_jsx_is_compiled( + #[case] expression: &str, + #[case] expected: &str, +) { + // Given: responsive and scalar selections with distinguishable branch values. + let source = format!( + "import {{Box}} from '@devup-ui/react'; function render(state){{return }} const node=render({{choose:true,a:'4px',b:'8px',c:'12px',d:'16px',e:'20px',f:'24px',key:'b',index:1}}); JSON.stringify(Object.values(node.style));" + ); + let compiled = compiled_jsx(&source); + // When: the emitted JSX's actual attributes are evaluated. + let actual = evaluate(&compiled); + // Then: only values belonging to the selected assignment reach its variables. + assert_eq!( + actual, + evaluate(&format!("JSON.stringify({expected})")), + "{compiled}" + ); +} + +#[test] +#[serial] +fn attribute_reads_stay_ordered_when_shorthand_and_children_share_capture() { + // Given: source attributes and children with observable getters around a selected shorthand. + let source = "import {Box} from '@devup-ui/react'; function render(state){return {state.child}} let trace=[]; const state={}; for(const [key,value] of Object.entries({before:'before',choose:true,a:'4px',b:'8px',after:'after',child:'child'})) Object.defineProperty(state,key,{get(){trace.push(key);return value}}); const node=render(state); JSON.stringify(trace);"; + let compiled = compiled_jsx(source); + // When: all consumers and adjacent authored reads execute through the emitted expression. + let actual = evaluate(&compiled); + // Then: px reads its selected value once, between the adjacent attributes and before its child. + assert_eq!( + actual, "[\"before\",\"choose\",\"a\",\"after\",\"child\"]", + "{compiled}" + ); +} + +#[test] +#[serial] +fn yields_stay_in_the_generator_when_selected_values_are_captured() { + // Given: lazy yielded alternatives and a later yielded attribute. + let source = "import {Box} from '@devup-ui/react'; function* render(state){return } const g=render({flag:true,a:'chosen',after:'later'}); const first=g.next(); const second=g.next('4px'); const last=g.next('title'); let absent; JSON.stringify([first.value,second.value,Object.values(last.value.style),last.value.title]);"; + let compiled = compiled_jsx(source); + // When: the authored generator is resumed in order. + let actual = evaluate(&compiled); + // Then: yields remain in that scope and the unselected branch remains unread. + assert_eq!( + actual, "[\"chosen\",\"later\",[\"4px\"],\"title\"]", + "{compiled}" + ); +} + +#[test] +#[serial] +fn conditional_order_selects_matching_classes_when_assignment_values_are_shared() { + // Given: both cascade orders consume the same responsive assignment. + let source = "import {Box} from '@devup-ui/react';function render(state){return };const nodes=[render({order:true,a:'4px',b:'8px'}),render({order:false,a:'4px',b:'8px'})];"; + let output = extracted(source); + let expected = [1, 2].map(|order| { + let mut classes = output + .styles + .iter() + .filter_map(|value| match value { + crate::ExtractStyleValue::Dynamic(style) if style.style_order() == Some(order) => { + match value.extract(None) { + Some(crate::extract_style::style_property::StyleProperty::Variable { + class_name, + .. + }) => Some(class_name), + _ => None, + } + } + _ => None, + }) + .collect::>(); + classes.sort(); + assert_eq!(classes.len(), 2); + classes + }); + // When: each input condition selects its declared cascade order. + let actual = evaluate(&format!( + "{} JSON.stringify(nodes.map(node=>node.className.split(' ').filter(Boolean).sort()));", + compiled_jsx(source) + )); + // Then: the selected classes agree with that input's emitted CSS order. + assert_eq!( + actual, + serde_json::to_string(&expected).unwrap_or_else(|error| panic!("{error}")) + ); +} diff --git a/libs/extractor/src/assignment_member.rs b/libs/extractor/src/assignment_member.rs new file mode 100644 index 000000000..f750ac8b2 --- /dev/null +++ b/libs/extractor/src/assignment_member.rs @@ -0,0 +1,121 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::{ComputedMemberExpression, Expression, ObjectPropertyKind}; + +use crate::{ + ExtractStyleProp, + assignment_lowering::Lowering, + utils::{ + get_number_by_literal_expression, get_string_by_literal_expression, + get_string_by_property_key, unwrap_syntax_only, + }, +}; + +impl<'a> Lowering<'_, 'a> { + pub(super) fn member( + &self, + source: &ComputedMemberExpression<'a>, + styles: &mut [ExtractStyleProp<'a>], + ) -> Expression<'a> { + let ast = self.ast; + let mut member = source.clone_in(ast.allocator()); + if get_string_by_literal_expression(&source.expression).is_some() { + return self.leaf( + &Expression::ComputedMemberExpression(oxc_allocator::Box::new_in(member, ast)), + styles, + ); + } + let map = match styles { + [ExtractStyleProp::MemberExpression { map, .. }] => Some(map), + _ => None, + }; + match unwrap_syntax_only(&source.object) { + Expression::ObjectExpression(object) => { + let mut object = object.clone_in(ast.allocator()); + let mut map = map; + for property in &mut object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property { + let key = get_string_by_property_key(&property.key); + if key.as_deref() == Some("__proto__") + && !property.computed + && !property.shorthand + { + continue; + } + let selected = map + .as_mut() + .and_then(|map| key.as_ref().and_then(|key| map.get_mut(key))); + property.value = match selected { + Some(selected) => { + self.lower(&property.value, std::slice::from_mut(selected.as_mut())) + } + None => self.lower(&property.value, &mut []), + }; + } + } + member.object = Expression::ObjectExpression(object); + Expression::ComputedMemberExpression(oxc_allocator::Box::new_in(member, ast)) + } + Expression::ArrayExpression(array) => { + let mut array = array.clone_in(ast.allocator()); + let mut map = map; + for (index, element) in array.elements.iter_mut().enumerate() { + if let Some(value) = element.as_expression_mut() { + let selected = map.as_mut().and_then(|map| map.get_mut(&index.to_string())); + *value = match selected { + Some(selected) => { + self.lower(value, std::slice::from_mut(selected.as_mut())) + } + None => self.lower(value, &mut []), + }; + } + } + member.object = Expression::ArrayExpression(array); + Expression::ComputedMemberExpression(oxc_allocator::Box::new_in(member, ast)) + } + _ => self.leaf( + &Expression::ComputedMemberExpression(oxc_allocator::Box::new_in(member, ast)), + styles, + ), + } + } +} + +pub(super) fn selected_array(source: &Expression<'_>) -> bool { + let Expression::ComputedMemberExpression(source) = unwrap_syntax_only(source) else { + return false; + }; + let key = get_string_by_literal_expression(&source.expression); + match unwrap_syntax_only(&source.object) { + Expression::ObjectExpression(object) => object + .properties + .iter() + .rev() + .find_map(|property| match property { + ObjectPropertyKind::ObjectProperty(property) + if get_string_by_property_key(&property.key).as_deref() == key.as_deref() => + { + Some(matches!( + unwrap_syntax_only(&property.value), + Expression::ArrayExpression(_) + )) + } + _ => None, + }) + .unwrap_or(false), + Expression::ArrayExpression(array) => array + .elements + .iter() + .enumerate() + .find_map(|(index, value)| { + (get_number_by_literal_expression(&source.expression) + == u32::try_from(index).ok().map(f64::from)) + .then(|| { + value.as_expression().is_some_and(|value| { + matches!(unwrap_syntax_only(value), Expression::ArrayExpression(_)) + }) + }) + }) + .unwrap_or(false), + _ => false, + } +} diff --git a/libs/extractor/src/assignment_mixed_container_support.rs b/libs/extractor/src/assignment_mixed_container_support.rs new file mode 100644 index 000000000..6726aead5 --- /dev/null +++ b/libs/extractor/src/assignment_mixed_container_support.rs @@ -0,0 +1,61 @@ +use crate::assignment_test_support::{evaluate, extracted, jsx_js}; +use crate::{ExtractStyleValue, extract_style::style_property::StyleProperty}; + +pub(super) fn associated(source: &str) -> String { + let output = extracted(source); + let records = output + .styles + .iter() + .filter_map(|value| { + let (class, property, value, level, selector, layer) = + match (value, value.extract(None)?) { + (ExtractStyleValue::Static(value), StyleProperty::ClassName(class)) => ( + class, + value.property.clone(), + value.value.clone(), + value.level, + value.selector.as_ref(), + value.layer.as_deref(), + ), + ( + ExtractStyleValue::Dynamic(value), + StyleProperty::Variable { + class_name, + variable_name, + .. + }, + ) => ( + class_name, + value.property().to_string(), + format!("var({variable_name})"), + value.level(), + value.selector(), + value.layer(), + ), + _ => return None, + }; + Some(( + class, + format!( + "{}|{property}:{value}:{level}|{}", + selector.map(ToString::to_string).unwrap_or_default(), + layer.unwrap_or_default() + ), + )) + }) + .collect::>(); + let records = serde_json::to_string(&records).unwrap_or_else(|error| panic!("{error}")); + let original = source_trace(source); + evaluate(&format!( + "{}const records={records};const classes=String(node.className??'').split(/\\s+/).filter(Boolean);const assigned=classes.map(name=>{{const record=records.find(([key])=>key===name);if(!record)throw Error('unbound class '+name);return record[1].replace(/var\\(([^)]+)\\)/g,(_,variable)=>String(node.style[variable]))}}).sort();if(JSON.stringify(trace)!=={})throw Error('source trace mismatch '+JSON.stringify(trace));JSON.stringify(assigned);", + jsx_js(&output.code), + serde_json::to_string(&original).unwrap_or_else(|error| panic!("{error}")) + )) +} + +pub(super) fn source_trace(source: &str) -> String { + evaluate(&format!( + "const Box='div';{}JSON.stringify(trace);", + jsx_js(source) + )) +} diff --git a/libs/extractor/src/assignment_mixed_container_tests.rs b/libs/extractor/src/assignment_mixed_container_tests.rs new file mode 100644 index 000000000..6b9a5759d --- /dev/null +++ b/libs/extractor/src/assignment_mixed_container_tests.rs @@ -0,0 +1,223 @@ +use crate::assignment_blocker_support::selected; +use crate::assignment_test_support::{compiled_jsx, evaluate}; +use rstest::rstest; +use serial_test::serial; + +#[path = "assignment_mixed_container_support.rs"] +mod support; + +#[rstest] +#[case("{state.child}")] +#[case(" span':{p:state.pad,color:'red'}}} id={state.id}>{state.child}")] +#[case("jsx(Box,{_hover:{p:state.pad,color:'red'},id:state.id,children:state.child})")] +#[case( + "jsx(Box,{selectors:{'& > span':{p:state.pad,color:'red'}},id:state.id,children:state.child})" +)] +#[serial] +fn mixed_container_reads_leaf_once_when_static_class_is_present(#[case] expression: &str) { + // Given: literal color and observable padding before adjacent operands. + let source = fixture(expression, "true", "return '16px'"); + let original = support::source_trace(&source); + assert_eq!(original, "[\"pad\",\"id\",\"child\"]"); + // When: the native compiled JSX executes its actual selected classes/variables. + let actual = selected(&source, "JSON.stringify([trace,assigned(node)]);"); + // Then: the literal class survives and padding is captured before id/child. + assert_eq!( + actual, + "[[\"pad\",\"id\",\"child\"],[\"color:red:0\",\"padding:16px:0\"]]" + ); +} + +#[rstest] +#[case( + "{state.child}", + "true", + "red" +)] +#[case( + "{state.child}", + "false", + "blue" +)] +#[case( + "jsx(Box,{_hover:{color:state.flag?'red':'blue',p:state.pad},id:state.id,children:state.child})", + "true", + "red" +)] +#[case( + "jsx(Box,{_hover:{color:state.flag?'red':'blue',p:state.pad},id:state.id,children:state.child})", + "false", + "blue" +)] +#[case( + " span':{color:state.flag?'red':'blue',p:state.pad}}} id={state.id}>{state.child}", + "false", + "blue" +)] +#[serial] +fn mixed_container_selects_only_authored_branch_when_color_is_conditional( + #[case] expression: &str, + #[case] flag: &str, + #[case] color: &str, +) { + // Given: static color alternatives controlled by an observable getter. + let source = fixture(expression, flag, "return '16px'"); + let original = support::source_trace(&source); + assert_eq!(original, "[\"flag\",\"pad\",\"id\",\"child\"]"); + // When: the selected branch and dynamic sibling execute together. + let actual = selected(&source, "JSON.stringify([trace,assigned(node)]);"); + // Then: there is exactly one color class, with one ordered predicate read. + assert_eq!( + actual, + format!("[[\"flag\",\"pad\",\"id\",\"child\"],[\"color:{color}:0\",\"padding:16px:0\"]]") + ); +} + +#[rstest] +#[case("{state.child}")] +#[case("jsx(Box,{_hover:{p:state.pad,color:'red'},id:state.id,children:state.child})")] +#[serial] +fn mixed_container_stops_adjacent_operands_when_first_leaf_throws(#[case] expression: &str) { + // Given: the first authored leaf throws before adjacent operands. + let source = fixture(expression, "true", "throw Error('pad failed')").replace( + "const node=render(state);", + "let error;try{render(state)}catch(value){error=value.message}", + ); + assert_eq!(support::source_trace(&source), "[\"pad\"]"); + // When: compiled source encounters the failing leaf. + let actual = evaluate(&format!( + "{}JSON.stringify([trace,error]);", + compiled_jsx(&source) + )); + // Then: neither id nor child was evaluated. + assert_eq!(actual, "[[\"pad\"],\"pad failed\"]"); +} + +#[rstest] +#[case( + "true", + "[[\"flag\",\"pad\",\"id\",\"child\"],[\"color:red:0\",\"padding:16px:0\"]]" +)] +#[case("false", "[[\"flag\",\"pad\",\"id\",\"child\"],[\"padding:16px:0\"]]")] +#[serial] +fn mixed_container_keeps_no_style_branch_when_static_color_is_guarded( + #[case] flag: &str, + #[case] expected: &str, +) { + // Given: a no-style logical color branch and a dynamic sibling. + let source = fixture( + "{state.child}", + flag, + "return '16px'", + ); + // When: the guard is selected. + let actual = selected(&source, "JSON.stringify([trace,assigned(node)]);"); + // Then: the false branch contributes no class, without skipping padding. + assert_eq!(actual, expected); +} + +fn fixture(expression: &str, flag: &str, pad: &str) -> String { + format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{get flag(){{trace.push('flag');return {flag}}},get pad(){{trace.push('pad');{pad}}},get id(){{trace.push('id');return 'target'}},get child(){{trace.push('child');return 'text'}}}};const jsx=(tag,props)=>props;const node=render(state);" + ) +} + +#[rstest] +#[case( + " span':{p:state.pad,color:state.flag?'red':'blue'},'& > b':{p:state.pad,color:'green'}}} id={state.id}>{state.child}" +)] +#[case( + "jsx(Box,{selectors:{'& > span':{p:state.pad,color:state.flag?'red':'blue'},'& > b':{p:state.pad,color:'green'}},id:state.id,children:state.child})" +)] +#[serial] +fn nested_mixed_selectors_keep_associations_when_identical_getters_return_distinct_values( + #[case] expression: &str, +) { + // Given: identical property/getter syntax under different authored selectors. + let source = fixture(expression, "false", "return ++reads===1?'16px':'24px'") + .replace("let trace=[];", "let trace=[];let reads=0;"); + // When: emitted class identities are joined to selector/property/variable records. + let actual = support::associated(&source); + // Then: each selector receives its own value, while native source trace is unchanged. + assert_eq!( + actual, + "[\"& > b|color:green:0|\",\"& > b|padding:24px:0|\",\"& > span|color:blue:0|\",\"& > span|padding:16px:0|\"]" + ); +} + +#[rstest] +#[case( + "{state.child}" +)] +#[case( + "jsx(Box,{_hover:{p:['4px',state.pad],color:state.flag?'red':'blue'},id:state.id,children:state.child})" +)] +#[serial] +fn mixed_array_field_keeps_levels_when_literal_and_dynamic_values_coexist( + #[case] expression: &str, +) { + // Given: a responsive field with a literal role and a dynamic role. + let source = fixture(expression, "false", "return '16px'"); + // When: extraction preserves the field's semantic IR before flattening. + let actual = support::associated(&source); + // Then: literal and dynamic padding retain their distinct levels and selector. + assert_eq!( + actual, + "[\"&:hover|color:blue:0|\",\"&:hover|padding:16px:1|\",\"&:hover|padding:4px:0|\"]" + ); +} + +#[rstest] +#[case( + "true", + "[\"&:hover:focus|color:red:0|\",\"&:hover:focus|padding:16px:0|\",\"&:hover|margin:8px:0|\"]" +)] +#[case("false", "[\"&:hover|margin:8px:0|\"]")] +#[serial] +fn mixed_nested_container_is_lazy_when_logical_branch_has_no_styles( + #[case] flag: &str, + #[case] expected: &str, +) { + // Given: an entire guarded selector object next to an unconditional literal. + let source = fixture( + "{state.child}", + flag, + "return '16px'", + ); + // When: the compiled object is compared with authored native source execution. + let actual = support::associated(&source); + // Then: the false branch never reads padding or emits either guarded class. + assert_eq!(actual, expected); +} + +#[test] +#[serial] +fn mixed_layer_container_keeps_layer_when_properties_are_repeated() { + // Given: repeated padding under distinct source-authored layers. + let source = fixture("{state.child}", "true", "return ++reads===1?'16px':'24px'") + .replace("let trace=[];", "let trace=[];let reads=0;"); + // When: actual emitted classes and inline assignments execute. + let actual = support::associated(&source); + // Then: layer, selector, property and selected value remain paired. + assert_eq!( + actual, + "[\"&:hover|color:blue:0|second\",\"&:hover|color:red:0|first\",\"&:hover|padding:16px:0|first\",\"&:hover|padding:24px:0|second\"]" + ); +} + +#[test] +#[serial] +fn literal_only_container_has_no_capture_when_no_runtime_leaf_exists() { + // Given: a pure literal selector object and observable adjacent operands. + let source = fixture( + "{state.child}", + "true", + "return 'unused'", + ); + // When: the compiler extracts the static container. + let output = compiled_jsx(&source); + // Then: it introduces no assignment/field/value capture for that container. + assert!(!output.contains("__devupAssignment")); + assert!(!output.contains("__devupField")); + assert!(!output.contains("__devupValue")); +} diff --git a/libs/extractor/src/assignment_object.rs b/libs/extractor/src/assignment_object.rs new file mode 100644 index 000000000..622238ae0 --- /dev/null +++ b/libs/extractor/src/assignment_object.rs @@ -0,0 +1,83 @@ +use crate::{ + ExtractStyleProp, ExtractStyleValue, + assignment_lowering::{Classes, Lowering, empty, projection}, + gen_class_name::merge_expression_for_class_name, + utils::{call_with_values, get_string_by_property_key, merge_object_expressions}, +}; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::{Expression, ObjectExpression, ObjectPropertyKind}; +use oxc_span::GetSpan; + +impl<'a> Lowering<'_, 'a> { + pub(super) fn object( + &self, + source: &ObjectExpression<'a>, + styles: &[ExtractStyleProp<'a>], + ) -> Expression<'a> { + let ast = self.ast; + let mut raw = source.clone_in(ast.allocator()); + let mut values = Vec::new(); + let mut refs = Vec::new(); + for (index, property) in raw.properties.iter_mut().enumerate() { + if let ObjectPropertyKind::ObjectProperty(property) = property { + let names = get_string_by_property_key(&property.key) + .map(|key| { + css::disassemble_property(&key) + .map(|name| css::utils::to_kebab_case(&name).into_owned()) + .collect::>() + }) + .unwrap_or_default(); + let mut consumers = styles + .iter() + .filter(|style| { + if crate::assignment_owner::contains_consumer(source.span(), style) { + return crate::assignment_owner::contains_consumer( + property.value.span(), + style, + ); + } + style.extract().iter().any(|value| match value { + ExtractStyleValue::Static(value) => names.contains(&value.property), + ExtractStyleValue::Dynamic(value) => { + names.iter().any(|name| name == value.property()) + } + _ => false, + }) + }) + .map(|style| style.clone_in(ast.allocator())) + .collect::>(); + let binding = format!("__devupField{index}"); + values.push((binding.clone(), self.lower(&property.value, &mut consumers))); + property.value = projection(ast, &binding, 2); + refs.push(binding); + } + } + let classes = |slot| { + merge_expression_for_class_name( + ast, + refs.iter().map(|name| projection(ast, name, slot)), + ) + .unwrap_or_else(|| Expression::new_string_literal(oxc_span::SPAN, "", None, ast)) + }; + let inline = merge_object_expressions( + ast, + &refs + .iter() + .map(|name| projection(ast, name, 1)) + .collect::>(), + ) + .unwrap_or_else(|| empty(ast)); + call_with_values( + ast, + values, + self.pair( + Classes { + primary: classes(0), + alternate: self.alternate_order.map(|_| classes(3)), + }, + inline, + Expression::ObjectExpression(oxc_allocator::Box::new_in(raw, ast)), + ), + ) + } +} diff --git a/libs/extractor/src/assignment_owner.rs b/libs/extractor/src/assignment_owner.rs new file mode 100644 index 000000000..6828a3e0e --- /dev/null +++ b/libs/extractor/src/assignment_owner.rs @@ -0,0 +1,177 @@ +use std::{cell::RefCell, collections::BTreeMap}; + +use css::Site; +use oxc_ast::ast::Expression; +use oxc_span::GetSpan; + +use crate::utils::expression_to_code; +use crate::{ExtractStyleProp, ExtractStyleValue}; + +struct Owner { + start: u32, + assignment: String, + sites: BTreeMap>, +} + +thread_local! { + static OWNER: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) struct AssignmentOwner(Option); + +impl AssignmentOwner { + pub(crate) fn enter(expression: &Expression<'_>) -> Self { + Self(OWNER.with_borrow_mut(|owner| { + owner.replace(Owner { + start: expression.span().start, + assignment: expression_to_code(expression), + sites: BTreeMap::new(), + }) + })) + } + + pub(crate) fn active() -> bool { + OWNER.with_borrow(Option::is_some) + } +} + +impl Drop for AssignmentOwner { + fn drop(&mut self) { + OWNER.with_borrow_mut(|owner| *owner = self.0.take()); + } +} + +/// A complete selected assignment registers once; atoms only consume its site. +pub(crate) fn site(start: u32, level: u8, assignment: &str) -> Option { + OWNER.with_borrow_mut(|owner| match owner { + Some(owner) => owner + .sites + .entry(level) + .or_insert_with(|| { + crate::provenance::site_at(owner.start, usize::from(level), &owner.assignment) + }) + .clone(), + None => crate::provenance::site_at(start, 0, assignment), + }) +} + +pub(crate) fn scalar( + source: &Expression<'_>, + styles: &[ExtractStyleProp<'_>], +) -> Option { + let values: Vec<_> = styles.iter().flat_map(ExtractStyleProp::extract).collect(); + let [ExtractStyleValue::Dynamic(first), ..] = values.as_slice() else { + return None; + }; + if styles.iter().all(always_present) + && values.iter().all(|value| { + matches!(value, ExtractStyleValue::Dynamic(style) + if style.property() == first.property() && style.level() == first.level() + && style.selector() == first.selector() && style.layer() == first.layer() + && style.naming() == first.naming() && !style.important()) + }) + && scalar_shape(source) + && !expression_to_code(source).contains('`') + { + let mut selected = first.clone(); + selected.replace_identifier(expression_to_code(source).trim().trim_end_matches(';')); + Some(ExtractStyleValue::Dynamic(selected)) + } else { + None + } +} + +pub(crate) fn contains_assignment( + span: oxc_span::Span, + style: &crate::extract_style::extract_dynamic_style::ExtractDynamicStyle, +) -> bool { + style + .site + .as_ref() + .is_some_and(|site| crate::sparse_sites::contains_site(span, site)) +} + +pub(crate) fn contains_consumer(span: oxc_span::Span, style: &ExtractStyleProp<'_>) -> bool { + let contains = |source: &Expression<'_>| { + let start = crate::provenance::source_offset(source.span().start); + (crate::provenance::source_offset(span.start)..crate::provenance::source_offset(span.end)) + .contains(&start) + }; + match style { + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style)) => { + contains_assignment(span, style) + } + ExtractStyleProp::Static(_) | ExtractStyleProp::Unreadable { .. } => false, + ExtractStyleProp::StaticArray(styles) => { + styles.iter().any(|style| contains_consumer(span, style)) + } + ExtractStyleProp::Evaluated { source, .. } => contains(source), + ExtractStyleProp::Conditional { condition, .. } + | ExtractStyleProp::Enum { condition, .. } => contains(condition), + ExtractStyleProp::Expression { expression, .. } + | ExtractStyleProp::MemberExpression { expression, .. } => contains(expression), + } +} + +pub(crate) fn take_consumers<'a>( + span: oxc_span::Span, + styles: &mut [ExtractStyleProp<'a>], +) -> Vec> { + let mut consumers = Vec::new(); + for style in styles { + match style { + ExtractStyleProp::StaticArray(styles) => consumers.extend(take_consumers(span, styles)), + style if contains_consumer(span, style) => consumers.push(std::mem::replace( + style, + ExtractStyleProp::StaticArray(vec![]), + )), + _ => {} + } + } + consumers +} + +fn always_present(style: &ExtractStyleProp<'_>) -> bool { + match style { + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(_)) => true, + ExtractStyleProp::StaticArray(styles) => { + !styles.is_empty() && styles.iter().all(always_present) + } + ExtractStyleProp::Conditional { + consequent: Some(consequent), + alternate: Some(alternate), + .. + } => always_present(consequent) && always_present(alternate), + _ => false, + } +} + +fn scalar_shape(source: &Expression<'_>) -> bool { + use crate::utils::unwrap_syntax_only; + match unwrap_syntax_only(source) { + Expression::ArrayExpression(_) => false, + Expression::ConditionalExpression(source) => { + scalar_shape(&source.consequent) && scalar_shape(&source.alternate) + } + Expression::LogicalExpression(source) => { + scalar_shape(&source.left) && scalar_shape(&source.right) + } + Expression::ComputedMemberExpression(source) => match unwrap_syntax_only(&source.object) { + Expression::ArrayExpression(source) => source + .elements + .iter() + .filter_map(|value| value.as_expression()) + .all(scalar_shape), + Expression::ObjectExpression(source) => { + source.properties.iter().all(|property| match property { + oxc_ast::ast::ObjectPropertyKind::ObjectProperty(property) => { + scalar_shape(&property.value) + } + oxc_ast::ast::ObjectPropertyKind::SpreadProperty(_) => false, + }) + } + _ => true, + }, + _ => true, + } +} diff --git a/libs/extractor/src/assignment_residual_tests.rs b/libs/extractor/src/assignment_residual_tests.rs new file mode 100644 index 000000000..487835483 --- /dev/null +++ b/libs/extractor/src/assignment_residual_tests.rs @@ -0,0 +1,218 @@ +use crate::assignment_blocker_support::selected; +use crate::assignment_test_support::{compiled_jsx, evaluate, extracted}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("")] +#[case("jsx(Box,{className:state.flag?'p-4':'p-8',color:state.color})")] +#[serial] +fn literal_class_rules_are_retained_when_adjacent_assignments_are_captured( + #[case] expression: &str, +) { + // Given: a literal-bearing Tailwind class expression beside a dynamic style. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const input={{get flag(){{trace.push('flag');return true}},get color(){{trace.push('color');return 'red'}}}};const jsx=(tag,props)=>props;const node=render(input);" + ); + // When: actual selected classes are associated with emitted declarations. + let actual = selected( + &source, + "JSON.stringify([trace,selected(node).filter(value=>value.startsWith('padding:'))]);", + ); + // Then: capture does not hide the literal class from compile-time extraction. + assert_eq!(actual, "[[\"flag\",\"color\"],[\"padding:1rem:0\"]]"); +} + +#[rstest] +#[case( + "css({color:'red',p:1},state.cond&&{color:'blue',_hover:{color:'green'}})", + "[\"cond\"]" +)] +#[case( + "css({color:'red'},state.flag?state.other:{color:'blue'})", + "[\"flag\",\"other\"]" +)] +#[case( + "css({color:'red'},state.cond&&state.other,state.flag?'x':null,[undefined,false])", + "[\"cond\",\"other\",\"flag\"]" +)] +#[serial] +fn composition_reads_controls_and_classes_once_when_parts_are_conditional( + #[case] expression: &str, + #[case] expected: &str, +) { + // Given: controllers and class values expose the source argument order. + let source = format!( + "import {{css}}from '@devup-ui/react';function render(state){{return {expression}}}let trace=[];const input={{get cond(){{trace.push('cond');return true}},get flag(){{trace.push('flag');return true}},get other(){{trace.push('other');return 'external'}}}};const node=render(input);JSON.stringify(trace);" + ); + // When: the composed class expression is evaluated. + let actual = evaluate(&compiled_jsx(&source)); + // Then: a controller shared by class and rule selection is read only once. + assert_eq!(actual, expected); +} + +#[test] +#[serial] +fn selector_values_remain_distinct_when_multiple_properties_share_a_condition() { + // Given: two dynamic properties and one logical selector clause. + let source = "import {Box}from '@devup-ui/react';function render(state){return }const node=render({flag:true,color:'red',background:'blue'});"; + // When: the shared condition selects both property assignments. + let actual = selected(source, "JSON.stringify(assigned(node));"); + // Then: the values use distinct source roles, never one overwritten variable. + assert_eq!(actual, "[\"background:blue:0\",\"color:red:0\"]"); +} + +#[rstest] +#[case("({a:'red',b:'blue'})[state.key]", "missing")] +#[case("({a:'red',b:'blue'})[state.key]", "toString")] +#[case("({a:'red',b:'blue'})[state.key]", "constructor")] +#[case("({a:'red',__proto__:'blue'})[state.key]", "__proto__")] +#[case("[1,2][state.key]", "9")] +#[serial] +fn missing_and_prototype_selections_are_empty_when_no_authored_entry_applies( + #[case] expression: &str, + #[case] key: &str, +) { + // Given: a static class alongside a lookup which selects no literal-defined CSS value. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}const node=render({{key:'{key}'}});" + ); + // When: selected class fragments are rendered in a template. + let actual = selected(&source, "JSON.stringify(selected(node));"); + // Then: no undefined/prototype fragment is rendered as a class. + assert_eq!(actual, "[\"padding:4px:0\"]"); +} + +#[rstest] +#[case("state.flag && state.color", false, "[0,[]]")] +#[case("state.flag ? state.color : null", false, "[0,[]]")] +#[case("state.flag ? false : state.color", true, "[0,[]]")] +#[case("state.flag && state.color", true, "[1,[\"red\"]]")] +#[case("state.flag ? state.color : null", true, "[1,[\"red\"]]")] +#[case("state.flag ? false : state.color", false, "[1,[\"red\"]]")] +#[case("(state.flag ? false : state.color) ?? 'blue'", true, "[0,[]]")] +#[case("(state.flag ? null : state.color) ?? 'blue'", true, "[1,[]]")] +#[case("(state.flag ? false : state.color) || 'blue'", true, "[1,[]]")] +#[serial] +fn no_style_branches_stay_absent_when_dynamic_alternatives_exist( + #[case] expression: &str, + #[case] flag: bool, + #[case] expected: &str, +) { + // Given: a logical/conditional branch which deliberately supplies no styles. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}const node=render({{flag:{flag},color:'red'}});JSON.stringify([String(node.className??'').split(/\\s+/).filter(Boolean).length,Object.values(node.style??{{}})]);" + ); + // When: class and inline value selection execute together. + let actual = evaluate(&compiled_jsx(&source)); + // Then: only the selected value branch carries its inline assignment. + assert_eq!(actual, expected); +} + +#[rstest] +#[case( + "{state.child}" +)] +#[case( + "jsx(Box,{className:state.className,color:state.color,style:state.style,as:state.tag,...state.rest,title:state.title,children:state.child})" +)] +#[serial] +fn adjacent_reads_remain_authored_when_class_style_type_spread_and_children_mix( + #[case] expression: &str, +) { + // Given: getters expose every adjacent source operand, including className and style. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const input={{}};for(const[key,value]of Object.entries({{className:'external',color:'red',style:{{}},tag:'section',rest:{{id:1}},title:'title',child:'child'}}))Object.defineProperty(input,key,{{get(){{trace.push(key);return value}}}});const jsx=(tag,props)=>props;const node=render(input);JSON.stringify(trace);" + ); + // When: the real emitted expression executes. + let actual = evaluate(&compiled_jsx(&source)); + // Then: no member read is called pure or moved across another read. + assert_eq!( + actual, + "[\"className\",\"color\",\"style\",\"tag\",\"rest\",\"title\",\"child\"]" + ); +} + +#[test] +#[serial] +fn asynchronous_nested_selector_reads_stay_lazy_when_awaits_resume() { + // Given: a lazy selector branch with awaited responsive values and a later title. + let source = "import {Box}from '@devup-ui/react';async function render(state){return }let trace=[];const state={flag:true};for(const[key,value]of Object.entries({first:'none',last:'none',title:'title'}))Object.defineProperty(state,key,{get(){trace.push(key);return Promise.resolve(value)}});let result;render(state).then(node=>{result=[trace,Object.values(node.style),node.title]},error=>{result=['error',String(error)]});"; + let compiled = compiled_jsx(source); + // When: Boa's actual promise jobs resume every authored await. + let mut context = boa_engine::Context::default(); + context + .eval(boa_engine::Source::from_bytes(&compiled)) + .unwrap_or_else(|error| panic!("{error}\n{compiled}")); + context.run_jobs().unwrap_or_else(|error| panic!("{error}")); + let actual = context + .eval(boa_engine::Source::from_bytes("JSON.stringify(result)")) + .unwrap_or_else(|error| panic!("{error}")) + .as_string() + .unwrap_or_else(|| panic!("resolved JSON")) + .to_std_string_escaped(); + // Then: await remains outside synchronous captures and value order is preserved. + assert_eq!( + actual, "[[\"first\",\"last\",\"title\"],[\"none\",\"none\"],\"title\"]", + "{compiled}" + ); +} + +#[rstest] +#[case(true, Some(5), "state.flag?5:10")] +#[case(false, Some(10), "state.flag?5:10")] +#[case(true, None, "state.flag?undefined:3")] +#[case(false, Some(3), "state.flag?undefined:3")] +#[serial] +fn call_cascade_order_matches_source_when_values_are_shared( + #[case] flag: bool, + #[case] order: Option, + #[case] order_expression: &str, +) { + // Given: a JSX call with conditional orders and a dynamic CSS value. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return jsx(Box,{{styleOrder:{order_expression},color:state.color}})}}const jsx=(tag,props)=>props;const node=render({{flag:{flag},color:'red'}});" + ); + let output = extracted(&source); + let classes=output.styles.iter().filter(|value|matches!(value,crate::ExtractStyleValue::Dynamic(style)if style.style_order()==order)).filter_map(|value|match value.extract(None){Some(crate::extract_style::style_property::StyleProperty::Variable{class_name,..})=>Some(class_name),_=>None}).collect::>(); + assert_eq!(classes.len(), 1); + // When: the input flag selects one authored order. + let actual = evaluate(&format!( + "{}JSON.stringify([node.className,Object.values(node.style)]);", + compiled_jsx(&source) + )); + // Then: the class carrying that CSS order uses the same captured red value. + assert_eq!( + actual, + serde_json::to_string(&(classes[0].clone(), vec!["red"])) + .unwrap_or_else(|error| panic!("{error}")) + ); +} + +#[test] +#[serial] +fn generator_computed_keys_stay_after_spreads_when_yield_resumes() { + // Given: the rejected JSX-call spread shape with a yielding computed value. + let source = "import {Box}from '@devup-ui/react';import {jsx}from 'react/jsx-runtime';function* render(f,state){return jsx(Box,{...f(),[state.key]:yield state.value})}let trace=[];const input={get key(){trace.push('key');return 'title'},get value(){trace.push('value');return 'yielded'}};const f=()=>{trace.push('f');return {id:1}};const jsx=(tag,props)=>props;const generator=render(f,input);const first=generator.next();const last=generator.next('resumed');JSON.stringify([trace,first.value,last.value.title]);"; + // When: the actual generator is resumed. + let actual = evaluate(&compiled_jsx(source)); + // Then: the spread runs once, followed by key/value, with yield in the authored scope. + assert_eq!( + actual, + "[[\"f\",\"key\",\"value\"],\"yielded\",\"resumed\"]" + ); +} + +#[test] +#[serial] +fn spread_property_getters_run_once_when_class_and_style_are_composed() { + // Given: source object enumeration exposes className/style getters before a later title. + let source = "import {Box}from '@devup-ui/react';function render(f,state){return }let trace=[];const input={get title(){trace.push('title');return 'later'}};const f=()=>{trace.push('f');return {get className(){trace.push('class');return 'external'},get style(){trace.push('style');return {color:'red'}}}};const node=render(f,input);JSON.stringify([trace,node.className,node.style]);"; + // When: the emitted spread and generated composition execute. + let actual = evaluate(&compiled_jsx(source)); + // Then: enumeration getters run once in source position, not again for composition. + assert_eq!( + actual, + "[[\"f\",\"class\",\"style\",\"title\"],\"external\",{\"color\":\"red\"}]" + ); +} diff --git a/libs/extractor/src/assignment_test_support.rs b/libs/extractor/src/assignment_test_support.rs new file mode 100644 index 000000000..db8bcee01 --- /dev/null +++ b/libs/extractor/src/assignment_test_support.rs @@ -0,0 +1,217 @@ +use boa_engine::{Context, Source}; +use oxc_allocator::Allocator; +use oxc_ast::{ast::Statement, builder::AstBuilder}; +use oxc_parser::Parser; +use oxc_span::GetSpan; +use oxc_span::SourceType; + +use crate::{ + extractor::extract_style_from_expression::{LiteralHandling, extract_style_from_expression}, + gen_class_name::gen_class_names, + gen_style::gen_styles, + provenance::SiteScope, + utils::expression_to_code, +}; + +fn code(expression: &oxc_ast::ast::Expression<'_>) -> String { + expression_to_code(expression) + .trim() + .trim_end_matches(';') + .to_string() +} + +pub(super) fn lowered(expression: &str) -> String { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let source = format!("({expression});"); + let _sites = SiteScope::enter("assignment.tsx", &source, &[]); + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut program = Parser::new(&allocator, &source, SourceType::ts()) + .parse() + .program; + let Statement::ExpressionStatement(statement) = &mut program.body[0] else { + panic!("fixture expression"); + }; + let mut styles = extract_style_from_expression( + &builder, + Some("gap"), + &mut statement.expression, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ) + .styles; + let class = gen_class_names(&builder, &mut styles, None, None) + .map_or_else(|| "''".to_string(), |value| code(&value)); + let style = + gen_styles(&builder, &styles, None).map_or_else(|| "{}".to_string(), |value| code(&value)); + let values = crate::assignment_lowering::take_evaluations(&mut styles); + let params = values + .iter() + .map(|(name, _)| name.as_str()) + .collect::>() + .join(","); + let arguments = values + .iter() + .map(|(_, value)| code(value)) + .collect::>() + .join(","); + if values.is_empty() { + format!("({{className:{class},style:{style}}})") + } else { + format!("(({params})=>({{className:{class},style:{style}}}))({arguments})") + } +} + +pub(super) fn evaluate(script: &str) -> String { + Context::default() + .eval(Source::from_bytes(script)) + .unwrap_or_else(|error| panic!("{error}\n{script}")) + .as_string() + .unwrap_or_else(|| panic!("fixture returns JSON")) + .to_std_string_escaped() +} + +struct JsxProps<'a>(AstBuilder<'a>); + +impl<'a> oxc_ast_visit::VisitMut<'a> for JsxProps<'a> { + fn visit_expression(&mut self, expression: &mut oxc_ast::ast::Expression<'a>) { + use oxc_allocator::{CloneIn, GetAllocator}; + use oxc_ast::ast::{ + Expression, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, ObjectPropertyKind, + PropertyKey, PropertyKind, + }; + oxc_ast_visit::walk_mut::walk_expression(self, expression); + let unwrapped = crate::utils::unwrap_syntax_only(expression); + if unwrapped.span() != expression.span() { + *expression = unwrapped.clone_in(self.0.allocator()); + } + if let Expression::JSXElement(element) = expression { + let builder = &self.0; + let allocator = builder.allocator(); + let mut properties = oxc_allocator::Vec::new_in(builder); + let tag = element.opening_element.name.to_string(); + if matches!( + &element.opening_element.name, + oxc_ast::ast::JSXElementName::MemberExpression(_) + ) || tag.starts_with(|character: char| character.is_ascii_uppercase()) + { + properties.push(ObjectPropertyKind::new_object_property( + oxc_span::SPAN, + PropertyKind::Init, + PropertyKey::new_static_identifier(oxc_span::SPAN, "tag", builder), + Expression::new_identifier(oxc_span::SPAN, allocator.alloc_str(&tag), builder), + false, + false, + false, + builder, + )); + } + for attribute in &element.opening_element.attributes { + match attribute { + JSXAttributeItem::Attribute(attribute) => { + let value = match &attribute.value { + Some(JSXAttributeValue::ExpressionContainer(container)) => container + .expression + .as_expression() + .map(|value| value.clone_in(allocator)), + Some(JSXAttributeValue::StringLiteral(value)) => { + Some(Expression::StringLiteral(value.clone_in(allocator))) + } + _ => None, + }; + if let Some(value) = value { + let name = match &attribute.name { + JSXAttributeName::Identifier(name) => name.name.to_string(), + JSXAttributeName::NamespacedName(name) => { + format!("{}:{}", name.namespace.name, name.name.name) + } + }; + properties.push(ObjectPropertyKind::new_object_property( + oxc_span::SPAN, + PropertyKind::Init, + PropertyKey::new_string_literal( + oxc_span::SPAN, + allocator.alloc_str(&name), + None, + builder, + ), + value, + false, + false, + false, + builder, + )); + } + } + JSXAttributeItem::SpreadAttribute(spread) => { + properties.push(ObjectPropertyKind::new_spread_property( + oxc_span::SPAN, + spread.argument.clone_in(allocator), + builder, + )); + } + } + } + let children = element + .children + .iter() + .filter_map(|child| match child { + oxc_ast::ast::JSXChild::ExpressionContainer(container) => container + .expression + .as_expression() + .map(|value| value.clone_in(allocator)), + _ => None, + }) + .map(oxc_ast::ast::ArrayExpressionElement::from); + properties.push(ObjectPropertyKind::new_object_property( + oxc_span::SPAN, + PropertyKind::Init, + PropertyKey::new_static_identifier(oxc_span::SPAN, "children", builder), + Expression::new_array_expression( + oxc_span::SPAN, + oxc_allocator::Vec::from_iter_in(children, builder), + builder, + ), + false, + false, + false, + builder, + )); + *expression = Expression::new_object_expression(oxc_span::SPAN, properties, builder); + } + } +} + +pub(super) fn extracted(source: &str) -> crate::ExtractOutput { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + crate::extract_without_source_map( + "assignment.tsx", + source, + crate::ExtractOption { + single_css: true, + ..crate::ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +pub(super) fn compiled_jsx(source: &str) -> String { + let output = extracted(source); + jsx_js(&output.code) +} + +pub(super) fn jsx_js(source: &str) -> String { + use oxc_ast_visit::VisitMut; + let allocator = Allocator::default(); + let mut program = Parser::new(&allocator, source, SourceType::tsx()) + .parse() + .program; + program + .body + .retain(|statement| !matches!(statement, Statement::ImportDeclaration(_))); + JsxProps(AstBuilder::new(&allocator)).visit_program(&mut program); + oxc_codegen::Codegen::new().build(&program).code +} diff --git a/libs/extractor/src/assignment_value.rs b/libs/extractor/src/assignment_value.rs new file mode 100644 index 000000000..0a8c49ba7 --- /dev/null +++ b/libs/extractor/src/assignment_value.rs @@ -0,0 +1,186 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::ast::{ArrayExpression, ArrayExpressionElement, BinaryOperator, Expression, Str}; +use oxc_span::{GetSpan, SPAN}; + +use crate::{ + ExtractStyleProp, ExtractStyleValue, + assignment_lowering::{Classes, Lowering, empty, projection}, + assignment_member::selected_array, + gen_class_name::{gen_class_names, merge_expression_for_class_name}, + gen_style::gen_styles, + utils::{call_with_values, merge_object_expressions}, +}; + +impl<'a> Lowering<'_, 'a> { + pub(super) fn array( + &self, + source: &ArrayExpression<'a>, + styles: &[ExtractStyleProp<'a>], + ) -> Expression<'a> { + let ast = self.ast; + let mut values = Vec::new(); + let mut refs = Vec::new(); + let mut raw = source.clone_in(ast.allocator()); + for (index, element) in source.elements.iter().enumerate() { + if let Some(value) = element.as_expression() { + let mut selected: Vec<_> = styles + .iter() + .filter(|style| { + style.extract().iter().any(|value| match value { + ExtractStyleValue::Dynamic(style) => { + usize::from(style.level()) == index + } + ExtractStyleValue::Static(style) => usize::from(style.level()) == index, + _ => false, + }) + }) + .map(|style| style.clone_in(ast.allocator())) + .collect(); + let binding = format!("__devupLevel{index}"); + values.push((binding.clone(), self.lower(value, &mut selected))); + raw.elements[index] = ArrayExpressionElement::from(projection(ast, &binding, 2)); + refs.push(binding); + } + } + let class = + merge_expression_for_class_name(ast, refs.iter().map(|name| projection(ast, name, 0))) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast)); + let alternate = self.alternate_order.map(|_| { + merge_expression_for_class_name(ast, refs.iter().map(|name| projection(ast, name, 3))) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast)) + }); + let inline: Vec<_> = refs.iter().map(|name| projection(ast, name, 1)).collect(); + let inline = merge_object_expressions(ast, &inline).unwrap_or_else(|| empty(ast)); + call_with_values( + ast, + values, + self.pair( + Classes { + primary: class, + alternate, + }, + inline, + Expression::ArrayExpression(oxc_allocator::Box::new_in(raw, ast)), + ), + ) + } + + pub(super) fn leaf( + &self, + source: &Expression<'a>, + styles: &[ExtractStyleProp<'a>], + ) -> Expression<'a> { + let ast = self.ast; + let mut values = styles + .iter() + .flat_map(ExtractStyleProp::extract) + .collect::>(); + let input = values + .iter() + .find_map(|value| match value { + ExtractStyleValue::Dynamic(style) + if matches!(source, Expression::TemplateLiteral(_)) || style.important() => + { + Some(Expression::new_identifier( + source.span(), + Str::from_in(style.identifier(), ast.allocator()), + ast, + )) + } + _ => None, + }) + .unwrap_or_else(|| source.clone_in(ast.allocator())); + let important = values + .iter() + .any(|value| matches!(value, ExtractStyleValue::Dynamic(style) if style.important())); + let normalized = values.iter().any(|value| matches!(value, ExtractStyleValue::Dynamic(_) if matches!(source, Expression::TemplateLiteral(_)))) || important; + let suffix = match source { + _ if !normalized => "", + Expression::TemplateLiteral(template) => template.quasis.last().map_or("", |quasi| { + let original = quasi.value.raw.as_str(); + let trimmed = original.trim_end_matches(';'); + let cleaned = if important { + trimmed.strip_suffix(" !important").unwrap_or(trimmed) + } else { + trimmed + }; + original.strip_prefix(cleaned).unwrap_or("") + }), + _ if important => " !important", + _ => "", + }; + let responsive = selected_array(source); + for value in &mut values { + if let ExtractStyleValue::Dynamic(style) = value { + style.replace_identifier(&if responsive { + format!("__devupValue?.[{}]", style.level()) + } else { + "__devupValue".to_string() + }); + } + } + let mut props: Vec<_> = values.into_iter().map(ExtractStyleProp::Static).collect(); + let alternatives = self.alternate_order.map(|_| { + props + .iter() + .map(|prop| prop.clone_in(ast.allocator())) + .collect::>() + }); + let class = gen_class_names(ast, &mut props, self.order, self.filename) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast)); + let alternate = self + .alternate_order + .zip(alternatives) + .map(|(order, mut alternative)| { + gen_class_names(ast, &mut alternative, order.value, self.filename) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast)) + }); + props.reverse(); + let inline = gen_styles(ast, &props, self.filename).unwrap_or_else(|| empty(ast)); + let mut value = Expression::new_identifier(SPAN, "__devupValue", ast); + if !suffix.is_empty() { + value = Expression::new_binary_expression( + SPAN, + value, + BinaryOperator::Addition, + Expression::new_string_literal( + SPAN, + Str::from_in(suffix, ast.allocator()), + None, + ast, + ), + ast, + ); + } + call_with_values( + ast, + vec![("__devupValue".to_string(), input)], + self.pair( + Classes { + primary: class, + alternate, + }, + inline, + value, + ), + ) + } + + pub(super) fn pair( + &self, + classes: Classes<'a>, + inline: Expression<'a>, + value: Expression<'a>, + ) -> Expression<'a> { + let mut slots = oxc_allocator::Vec::from_iter_in( + [classes.primary, inline, value] + .into_iter() + .map(ArrayExpressionElement::from), + self.ast, + ); + if let Some(alternate) = classes.alternate { + slots.push(ArrayExpressionElement::from(alternate)); + } + Expression::new_array_expression(SPAN, slots, self.ast) + } +} diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 1e8bdd7e3..90f68f187 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -21,6 +21,8 @@ use oxc_syntax::reference::ReferenceId; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; +use css::Naming; + use crate::stylex::StylexFunction; use crate::utils::{binding_root, get_string_by_literal_expression, unwrap_syntax_only}; use crate::{ExtractOption, ModuleResolver}; @@ -910,35 +912,43 @@ impl<'a> Visit<'a> for Reads<'_> { /// replacements made, and the files read; `None` when running the code it /// reads computes none of them as a string, a finite number, or a plain /// object or array of those +type Evaluation = ( + String, + Vec, + BTreeSet, + Vec<(usize, usize)>, +); + pub(crate) fn evaluate( code: &str, filename: &str, option: &ExtractOption, resolver: Option<&ModuleResolver>, unknown: &crate::imported_constants::Unknown, -) -> Option<( - String, - Vec, - BTreeSet, -)> { +) -> Option { let (mut values, dependencies) = compute(code, filename, option, resolver, unknown)?; - values.sort_unstable_by_key(|(span, _)| span.start); + values.sort_unstable_by_key(|(span, ..)| span.start); let mut result = String::with_capacity(code.len()); let mut edits = Vec::with_capacity(values.len()); + let mut risky = Vec::new(); let mut copied = 0; - for (span, literal) in values { + for (span, literal, naming) in values { let (start, end) = (span.start as usize, span.end as usize); result.push_str(&code[copied..start]); + if naming == Naming::Risky { + risky.push((result.len(), result.len() + literal.len())); + } result.push_str(&literal); edits.push((start, end, literal.len())); copied = end; } result.push_str(&code[copied..]); - Some((result, edits, dependencies)) + Some((result, edits, dependencies, risky)) } -/// A value's source text by the span of the code computing it -type Replacement = (Span, String); +/// A value's source text by the span of the code computing it, and whether it +/// depends on an import that may resolve differently between environments +type Replacement = (Span, String, Naming); /// Run before the values: the code checked to read nothing that differs /// between builds or pages, nothing does even if the check missed it @@ -1082,12 +1092,18 @@ fn compute( if found.rules_only && !literal.starts_with(['{', '[', '"']) { continue; } + let naming = if found.closure.imports.is_empty() { + Naming::Own + } else { + Naming::Risky + }; computed.push(( found.span, match found.shorthand { Some(key) => format!("{key}: {literal}"), None => literal, }, + naming, )); } (!computed.is_empty()).then_some((computed, changes.dependencies())) diff --git a/libs/extractor/src/class_evaluation.rs b/libs/extractor/src/class_evaluation.rs new file mode 100644 index 000000000..66adcac32 --- /dev/null +++ b/libs/extractor/src/class_evaluation.rs @@ -0,0 +1,192 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{BinaryOperator, Expression, LogicalOperator, Str, UnaryOperator}, + builder::AstBuilder, +}; +use oxc_span::{GetSpan, GetSpanMut, SPAN}; +use oxc_syntax::number::NumberBase; + +use crate::{ + assignment_lowering::projection, + utils::{call_with_values, unwrap_syntax_only}, +}; + +#[path = "class_template.rs"] +mod class_template; + +pub(super) struct ClassEvaluation<'b, 'a>(pub(super) &'b AstBuilder<'a>); + +pub(crate) fn capture<'a>( + ast: &AstBuilder<'a>, + source: &Expression<'a>, + prepared: &mut [Option>], +) -> Option<(String, Expression<'a>)> { + if matches!(unwrap_syntax_only(source), Expression::StringLiteral(_)) { + return None; + } + let name = format!("__devupClass{}", source.span().start); + let mut value = match prepared { + [Some(primary), Some(alternate)] => { + ClassEvaluation(ast).lower(source, [primary, alternate]) + } + [Some(primary)] => primary.clone_in(ast.allocator()), + _ => return None, + }; + *value.span_mut() = source.span(); + let joint = prepared.len() == 2; + for (index, prepared) in prepared.iter_mut().enumerate() { + *prepared = Some(if joint { + projection(ast, &name, if index == 0 { 0 } else { 3 }) + } else { + Expression::new_identifier( + source.span(), + Str::from_in(name.as_str(), ast.allocator()), + ast, + ) + }); + } + Some((name, value)) +} + +impl<'a> ClassEvaluation<'_, 'a> { + pub(super) fn lower( + &self, + source: &Expression<'a>, + variants: [&Expression<'a>; 2], + ) -> Expression<'a> { + let ast = self.0; + let source = unwrap_syntax_only(source); + let [primary, alternate] = variants.map(unwrap_syntax_only); + match (source, primary, alternate) { + (Expression::StringLiteral(_), _, _) => self.tuple( + [ + primary.clone_in(ast.allocator()), + source.clone_in(ast.allocator()), + alternate.clone_in(ast.allocator()), + ], + false, + ), + ( + Expression::ConditionalExpression(source), + Expression::ConditionalExpression(primary), + Expression::ConditionalExpression(alternate), + ) => Expression::new_conditional_expression( + source.span, + source.test.clone_in(ast.allocator()), + self.lower( + &source.consequent, + [&primary.consequent, &alternate.consequent], + ), + self.lower( + &source.alternate, + [&primary.alternate, &alternate.alternate], + ), + ast, + ), + ( + Expression::LogicalExpression(source), + Expression::LogicalExpression(primary), + Expression::LogicalExpression(alternate), + ) => { + let left = self.lower(&source.left, [&primary.left, &alternate.left]); + let raw = projection(ast, "__devupClassLeft", 2); + let test = match source.operator { + LogicalOperator::And => { + Expression::new_unary_expression(SPAN, UnaryOperator::LogicalNot, raw, ast) + } + LogicalOperator::Or => raw, + LogicalOperator::Coalesce => Expression::new_logical_expression( + SPAN, + Expression::new_binary_expression( + SPAN, + raw.clone_in(ast.allocator()), + BinaryOperator::StrictInequality, + Expression::new_null_literal(SPAN, ast), + ast, + ), + LogicalOperator::And, + Expression::new_binary_expression( + SPAN, + raw, + BinaryOperator::StrictInequality, + Expression::new_unary_expression( + SPAN, + UnaryOperator::Void, + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + NumberBase::Decimal, + ast, + ), + ast, + ), + ast, + ), + ast, + ), + }; + let selected = call_with_values( + ast, + vec![("__devupClassLeft".to_string(), left)], + Expression::new_conditional_expression( + SPAN, + test, + Expression::new_identifier(SPAN, "__devupClassLeft", ast), + Expression::new_null_literal(SPAN, ast), + ast, + ), + ); + Expression::new_logical_expression( + source.span, + selected, + LogicalOperator::Coalesce, + self.lower(&source.right, [&primary.right, &alternate.right]), + ast, + ) + } + ( + Expression::TemplateLiteral(source), + Expression::TemplateLiteral(primary), + Expression::TemplateLiteral(alternate), + ) => self.template(source, [primary, alternate]), + _ => { + let value = Expression::new_identifier(SPAN, "__devupClassValue", ast); + call_with_values( + ast, + vec![( + "__devupClassValue".to_string(), + source.clone_in(ast.allocator()), + )], + self.tuple( + [ + value.clone_in(ast.allocator()), + value.clone_in(ast.allocator()), + value, + ], + true, + ), + ) + } + } + } + + pub(super) fn tuple(&self, values: [Expression<'a>; 3], shared: bool) -> Expression<'a> { + let [primary, raw, alternate] = values; + Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_iter_in( + [ + primary, + Expression::new_boolean_literal(SPAN, shared, self.0), + raw, + alternate, + ] + .into_iter() + .map(oxc_ast::ast::ArrayExpressionElement::from), + self.0, + ), + self.0, + ) + } +} diff --git a/libs/extractor/src/class_template.rs b/libs/extractor/src/class_template.rs new file mode 100644 index 000000000..1dd104456 --- /dev/null +++ b/libs/extractor/src/class_template.rs @@ -0,0 +1,117 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::ast::{Expression, Str, TemplateElement, TemplateElementValue, TemplateLiteral}; +use oxc_span::SPAN; + +use super::ClassEvaluation; +use crate::{assignment_lowering::projection, utils::call_with_values}; + +impl<'a> ClassEvaluation<'_, 'a> { + pub(super) fn template( + &self, + source: &TemplateLiteral<'a>, + variants: [&TemplateLiteral<'a>; 2], + ) -> Expression<'a> { + let ast = self.0; + let [primary, alternate] = variants; + let mut bindings = Vec::with_capacity(source.expressions.len()); + let mut values = Vec::with_capacity(source.expressions.len()); + for (index, ((source, primary), alternate)) in source + .expressions + .iter() + .zip(&primary.expressions) + .zip(&alternate.expressions) + .enumerate() + { + let name = format!("__devupClassPart{index}"); + values.push(( + name.clone(), + self.coerced(self.lower(source, [primary, alternate])), + )); + bindings.push(name); + } + let prepared = |template: &TemplateLiteral<'a>, slot| { + Expression::new_template_literal( + template.span, + template.quasis.clone_in(ast.allocator()), + oxc_allocator::Vec::from_iter_in( + bindings.iter().map(|name| projection(ast, name, slot)), + ast, + ), + ast, + ) + }; + call_with_values( + ast, + values, + self.tuple( + [ + prepared(primary, 0), + prepared(source, 2), + prepared(alternate, 3), + ], + false, + ), + ) + } + + fn coerced(&self, value: Expression<'a>) -> Expression<'a> { + let ast = self.0; + let string = Expression::new_identifier(SPAN, "__devupClassString", ast); + let coerced = call_with_values( + ast, + vec![( + "__devupClassString".to_string(), + Expression::new_template_literal( + SPAN, + oxc_allocator::Vec::from_array_in( + [ + TemplateElement::new( + SPAN, + TemplateElementValue { + raw: Str::from_in("", ast.allocator()), + cooked: None, + }, + false, + ast, + ), + TemplateElement::new( + SPAN, + TemplateElementValue { + raw: Str::from_in("", ast.allocator()), + cooked: None, + }, + true, + ast, + ), + ], + ast, + ), + oxc_allocator::Vec::from_array_in( + [projection(ast, "__devupClassPart", 2)], + ast, + ), + ast, + ), + )], + self.tuple( + [ + string.clone_in(ast.allocator()), + string.clone_in(ast.allocator()), + string, + ], + false, + ), + ); + call_with_values( + ast, + vec![("__devupClassPart".to_string(), value)], + Expression::new_conditional_expression( + SPAN, + projection(ast, "__devupClassPart", 1), + coerced, + Expression::new_identifier(SPAN, "__devupClassPart", ast), + ast, + ), + ) + } +} diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index f0d67200b..dfe3b6d07 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -428,12 +428,15 @@ pub fn css_to_style_template( // Value is just the expression - use expression code directly let identifier = dynamic_expr_code(&css.expressions[*idx], &shared_allocator); - styles.push(CssToStyleResult::Dynamic(ExtractDynamicStyle::new( - style.property(), - style.level(), - &identifier, - style.selector().cloned(), - ))); + styles.push(CssToStyleResult::Dynamic( + ExtractDynamicStyle::new( + style.property(), + style.level(), + &identifier, + style.selector().cloned(), + ) + .at(css.expressions[*idx].span().start), + )); } else { // Value has surrounding text - need to create template literal // Reconstruct the template literal by replacing placeholders with ${expr} syntax @@ -502,12 +505,22 @@ pub fn css_to_style_template( // Wrap in template literal backticks let final_identifier = format!("`{template_literal}`"); - styles.push(CssToStyleResult::Dynamic(ExtractDynamicStyle::new( - style.property(), - style.level(), - &final_identifier, - style.selector().cloned(), - ))); + styles.push(CssToStyleResult::Dynamic( + ExtractDynamicStyle::new( + style.property(), + style.level(), + &final_identifier, + style.selector().cloned(), + ) + .at_role( + css.span.start, + found_placeholders + .iter() + .map(|(_, index)| *index) + .min() + .map_or(0, |index| index + 1), + ), + )); } } } diff --git a/libs/extractor/src/element_capture.rs b/libs/extractor/src/element_capture.rs new file mode 100644 index 000000000..fd6c18bbc --- /dev/null +++ b/libs/extractor/src/element_capture.rs @@ -0,0 +1,116 @@ +//! Capture authored element operands without changing their scheduling coordinates. + +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{ + Expression, JSXAttributeItem, JSXAttributeName, JSXChild, JSXElement, + JSXExpressionContainer, Str, + }, + builder::AstBuilder, +}; +use oxc_span::{GetSpan, GetSpanMut}; +use oxc_syntax::operator::LogicalOperator; + +use super::needed; +use crate::{utils::stays_attribute, visit::attribute_value_mut}; + +#[cfg(test)] +#[path = "element_capture_tests.rs"] +mod tests; + +pub(crate) fn default_type<'a>( + ast: &AstBuilder<'a>, + value: Expression<'a>, + default: &str, +) -> Expression<'a> { + if matches!(crate::utils::unwrap_syntax_only(&value), Expression::StringLiteral(literal) if !literal.value.is_empty()) + { + return value; + } + let span = value.span(); + Expression::new_logical_expression( + span, + Expression::new_parenthesized_expression(span, value, ast), + LogicalOperator::Or, + Expression::new_string_literal(span, Str::from_in(default, ast.allocator()), None, ast), + ast, + ) +} + +pub(crate) fn capture<'a>( + ast: &AstBuilder<'a>, + element: &mut JSXElement<'a>, + next: &mut usize, +) -> Vec<(String, Expression<'a>)> { + if !needed(element) { + return vec![]; + } + let mut values = Vec::new(); + let mut read = |value: &mut Expression<'a>| { + let name = format!("__devupSpread{next}"); + *next += 1; + let reference = Expression::new_identifier( + value.span(), + Str::from_in(name.as_str(), ast.allocator()), + ast, + ); + values.push((name, std::mem::replace(value, reference))); + }; + for attribute in &mut element.opening_element.attributes { + match attribute { + JSXAttributeItem::Attribute(attribute) => { + let span = attribute.span; + let class_name = matches!(&attribute.name, JSXAttributeName::Identifier(name) if name.name == "className"); + let retained = match &attribute.name { + JSXAttributeName::Identifier(name) => { + stays_attribute(&name.name) + || matches!(name.name.as_str(), "className" | "style") + } + JSXAttributeName::NamespacedName(_) => true, + }; + if retained && let Some(value) = attribute_value_mut(attribute) { + if class_name { + continue; + } + if value.span().start == 0 { + *value.span_mut() = span; + } + read(value); + } + } + JSXAttributeItem::SpreadAttribute(_) => {} + } + } + for child in &mut element.children { + match child { + JSXChild::ExpressionContainer(container) => { + if let Some(value) = container.expression.as_expression_mut() { + read(value); + } + } + JSXChild::Spread(spread) => read(&mut spread.expression), + JSXChild::Element(element) => { + let span = element.span; + let mut value = Expression::JSXElement(element.clone_in(ast.allocator())); + read(&mut value); + *child = JSXChild::ExpressionContainer(JSXExpressionContainer::boxed( + span, + value.into(), + ast, + )); + } + JSXChild::Fragment(fragment) => { + let span = fragment.span; + let mut value = Expression::JSXFragment(fragment.clone_in(ast.allocator())); + read(&mut value); + *child = JSXChild::ExpressionContainer(JSXExpressionContainer::boxed( + span, + value.into(), + ast, + )); + } + JSXChild::Text(_) => {} + } + } + values +} diff --git a/libs/extractor/src/element_capture_tests.rs b/libs/extractor/src/element_capture_tests.rs new file mode 100644 index 000000000..4b2c668f3 --- /dev/null +++ b/libs/extractor/src/element_capture_tests.rs @@ -0,0 +1,282 @@ +use crate::assignment_blocker_support::selected; +use crate::assignment_test_support::{compiled_jsx, evaluate}; +use rstest::rstest; +use serial_test::serial; + +#[test] +#[serial] +fn nested_types_keep_authored_order_when_getters_change_results() { + // Given: the rejected exact nested shape, with changing global identifier getters. + let source = "import {Box}from '@devup-ui/react';function render(){return }let trace=[],reads=0;for(const key of ['x','y'])Object.defineProperty(globalThis,key,{get(){trace.push(key);return ++reads===1?'section':'article'}});const node=render();"; + // When: execute emitted captures and join the child's selected class to padding. + let actual = selected( + source, + "JSON.stringify([trace,reads,node.tag,node.children[0].tag,assigned(node.children[0])]);", + ); + // Then: x precedes y once each, and the selected inner type retains padding4. + assert_eq!( + actual, + "[[\"x\",\"y\"],2,\"section\",\"article\",[\"padding:4px:0\"]]" + ); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{as:state.x,children:jsx(Box,{as:state.y,p:1})})")] +#[serial] +fn nested_type_stops_before_child_when_outer_getter_throws(#[case] expression: &str) { + // Given: an outer type getter that fails before the inner type is reached. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[],error;const state={{get x(){{trace.push('x');throw Error('outer')}},get y(){{trace.push('y');return 'a'}}}};const jsx=(tag,props)=>({{...props,tag}});try{{render(state)}}catch(e){{error=e.message}}JSON.stringify([trace,error]);" + ); + // When: execute the transformed expression. + let actual = evaluate(&compiled_jsx(&source)); + // Then: a child cannot run ahead of the throwing authored outer operand. + assert_eq!(actual, "[[\"x\"],\"outer\"]"); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{as:state.x,children:jsx(Text,{as:state.y,children:jsx(Box,{as:state.z,p:1})})})")] +#[serial] +fn nested_types_keep_three_levels_when_defaults_differ(#[case] expression: &str) { + // Given: three authored type sites whose values change on every getter read. + let source = format!( + "import {{Box,Text}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[],reads=0;const state={{}};for(const key of ['x','y','z'])Object.defineProperty(state,key,{{get(){{trace.push(key);return ++reads===3?'article':null}}}});const jsx=(tag,props)=>({{...props,tag,children:props.children?[props.children]:[]}});const node=render(state);" + ); + // When: observe all three selected types and the innermost class association. + let actual = selected( + &source, + "const middle=node.children[0],inner=middle.children[0];JSON.stringify([trace,reads,node.tag,middle.tag,inner.tag,assigned(inner)]);", + ); + // Then: fallbacks remain component-specific while authored nesting stays ordered. + assert_eq!( + actual, + "[[\"x\",\"y\",\"z\"],3,\"div\",\"span\",\"article\",[\"padding:4px:0\"]]" + ); +} + +#[rstest] +#[case( + "{state.child}", + "Box", + "div" +)] +#[case( + "{state.child}", + "Text", + "span" +)] +#[case( + "jsx(Box,{id:state.id,className:state.className,style:state.style,p:state.pad,as:state.type,...{title:state.spread.title},children:state.child})", + "Box", + "div" +)] +#[case( + "jsx(Text,{id:state.id,className:state.className,style:state.style,p:state.pad,as:state.type,...{title:state.spread.title},children:state.child})", + "Text", + "span" +)] +#[serial] +fn captured_type_keeps_sibling_order_when_styles_and_spreads_are_present( + #[case] expression: &str, + #[case] component: &str, + #[case] default: &str, +) { + // Given: type is authored after ordinary/style operands and before a spread/child. + // A written title key keeps unknown CSS-spread composition in the #756 gate. + let source = format!( + "import {{{component}}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{}};for(const[key,value]of Object.entries({{id:'target',className:'',style:{{opacity:'0.5'}},pad:'16px',type:false,spread:{{title:'spread'}},child:'text'}}))Object.defineProperty(state,key,{{get(){{trace.push(key);return value}}}});const jsx=(tag,props)=>({{...props,tag}});const node=render(state);" + ); + // When: observe execution and selected padding rather than generated spellings. + let actual = selected( + &source, + "JSON.stringify([trace,node.tag,node.id,node.title,node.style.opacity,assigned(node)]);", + ); + // Then: fallback adds no extra read and cannot move type before its siblings. + assert_eq!( + actual, + format!( + "[[\"id\",\"className\",\"style\",\"pad\",\"type\",\"spread\",\"child\"],\"{default}\",\"target\",\"spread\",\"0.5\",[\"padding:16px:0\"]]" + ) + ); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{id:state.id,as:state.type,children:jsx(Box,{as:state.inner,p:1})})")] +#[serial] +fn captured_type_skips_later_reads_when_ordinary_sibling_throws(#[case] expression: &str) { + // Given: an ordinary getter throws before both the outer type and the child. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[],error;const state={{get id(){{trace.push('id');throw Error('sibling')}},get type(){{trace.push('type');return 'a'}},get inner(){{trace.push('inner');return 'b'}}}};const jsx=(tag,props)=>props;try{{render(state)}}catch(e){{error=e.message}}JSON.stringify([trace,error]);" + ); + // When: execute the transformed source. + let actual = evaluate(&compiled_jsx(&source)); + // Then: source timing, not type-priority scheduling, controls the exception. + assert_eq!(actual, "[[\"id\"],\"sibling\"]"); +} + +#[rstest] +#[case("0")] +#[case("false")] +#[case("null")] +#[case("undefined")] +#[case("''")] +#[serial] +fn selected_type_defaults_when_runtime_value_is_falsy(#[case] value: &str) { + // Given: literal object/array selections and scalar types on both caller paths. + let expressions = [ + "", + "", + "", + "", + "jsx(Box,{as:state.type,id:state.id,p:1})", + "jsx(Text,{as:{a:state.type}[state.key],id:state.id,p:1})", + "jsx(Text,{as:[state.type][state.index],id:state.id,p:1})", + ]; + for expression in expressions { + let source = format!( + "import {{Box,Text}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let reads=0;const state={{get type(){{reads++;return {value}}},id:'target',key:'a',index:0}};const jsx=(tag,props)=>({{...props,tag}});const node=render(state);" + ); + // When: run the emitted selected type with a falsy authored value. + let actual = selected(&source, "JSON.stringify([reads,node.tag,assigned(node)]);"); + // Then: all falsy values use the component's default without losing padding. + let default = if expression.contains("Text") { + "span" + } else { + "div" + }; + assert_eq!( + actual, + format!("[1,\"{default}\",[\"padding:4px:0\"]]"), + "{expression}" + ); + } +} + +#[rstest] +#[case("", "div")] +#[case("", "span")] +#[case("jsx(Box,{as:{a:'a',b:'button'}[state.key],id:state.id,p:1})", "div")] +#[case("jsx(Text,{as:['div','a'][state.index],id:state.id,p:1})", "span")] +#[serial] +fn literal_member_type_defaults_when_selected_key_is_missing( + #[case] expression: &str, + #[case] default: &str, +) { + // Given: the existing literal-member type shapes with missing object/array keys. + let source = format!( + "import {{Box,Text}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{get key(){{trace.push('key');return 'missing'}},get index(){{trace.push('key');return 7}},get id(){{trace.push('id');return 'target'}}}};const jsx=(tag,props)=>({{...props,tag}});const node=render(state);" + ); + // When: execute the actual member selection and selected class. + let actual = selected(&source, "JSON.stringify([trace,node.tag,assigned(node)]);"); + // Then: undefined selection defaults, retaining key-before-id timing and padding4. + assert_eq!( + actual, + format!("[[\"key\",\"id\"],\"{default}\",[\"padding:4px:0\"]]") + ); +} + +#[rstest] +#[case("state.flag?:null")] +#[case("state.flag&&")] +#[case("state.flag?jsx(Box,{as:state.x,children:jsx(Text,{as:state.y,p:1})}):null")] +#[serial] +fn nested_type_capture_stays_lazy_when_branch_is_not_selected(#[case] expression: &str) { + // Given: an unselected branch containing both type getters. + let source = format!( + "import {{Box,Text}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{flag:false,get x(){{trace.push('x');throw Error('unused')}},get y(){{trace.push('y');throw Error('unused')}}}};const jsx=(tag,props)=>props;render(state);JSON.stringify(trace);" + ); + // When: execute the false branch. + let actual = evaluate(&compiled_jsx(&source)); + // Then: wrapper metadata never moves captures outside their lazy source scope. + assert_eq!(actual, "[]"); +} + +#[rstest] +#[case("0")] +#[case("false")] +#[case("null")] +#[case("undefined")] +#[case("''")] +#[serial] +fn literal_member_type_defaults_when_selected_entry_is_falsy(#[case] value: &str) { + // Given: literal entries rather than runtime getters, selected by a runtime key. + let expressions = [ + format!(""), + format!(""), + format!("jsx(Box,{{as:{{a:{value}}}[state.key],id:state.id,p:1}})"), + format!("jsx(Text,{{as:[{value}][state.index],id:state.id,p:1}})"), + ]; + for expression in expressions { + let source = format!( + "import {{Box,Text}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{get key(){{trace.push('key');return 'a'}},get index(){{trace.push('key');return 0}},get id(){{trace.push('id');return 'target'}}}};const jsx=(tag,props)=>({{...props,tag}});const node=render(state);" + ); + // When: execute the selected literal entry and its associated class. + let actual = selected(&source, "JSON.stringify([trace,node.tag,assigned(node)]);"); + // Then: literal-member lowering preserves the same established default. + let default = if expression.contains("Text") { + "span" + } else { + "div" + }; + assert_eq!( + actual, + format!("[[\"key\",\"id\"],\"{default}\",[\"padding:4px:0\"]]") + ); + } +} + +#[rstest] +#[case("Button", "button")] +#[case("Image", "img")] +#[case("Input", "input")] +#[serial] +fn captured_type_defaults_to_component_tag_when_value_is_empty( + #[case] component: &str, + #[case] default: &str, +) { + // Given: the remaining distinct component default tags on both caller paths. + for expression in [ + format!("<{component} as={{state.type}} id={{state.id}}/>"), + format!("jsx({component},{{as:state.type,id:state.id}})"), + ] { + let source = format!( + "import {{{component}}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let reads=0;const state={{get type(){{reads++;return ''}},id:'target'}};const jsx=(tag,props)=>({{...props,tag}});const node=render(state);JSON.stringify([reads,node.tag]);" + ); + // When: execute the captured falsy type. + let actual = evaluate(&compiled_jsx(&source)); + // Then: capture never hardcodes Box's div fallback for another component. + assert_eq!(actual, format!("[1,\"{default}\"]")); + } +} + +#[rstest] +#[case("0")] +#[case("false")] +#[case("null")] +#[case("undefined")] +#[case("''")] +#[serial] +fn jsx_runtime_type_defaults_when_authored_literal_is_falsy(#[case] value: &str) { + // Given: falsy literal types, including fixed-key literal member selections. + for (component, default) in [("Box", "div"), ("Text", "span")] { + for tag in [ + value.to_string(), + format!("({{a:{value}}})['a']"), + format!("[{value}][0]"), + ] { + let source = format!( + "import {{{component}}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return jsx({component},{{id:state.id,as:{tag},p:1}})}}let trace=[];const state={{get id(){{trace.push('id');return 'target'}}}};const jsx=(tag,props)=>({{...props,tag}});const node=render(state);" + ); + // When: execute the authored literal and its selected padding class. + let actual = selected(&source, "JSON.stringify([trace,node.tag,assigned(node)]);"); + // Then: CSS literal stringification cannot stand in for type defaulting. + assert_eq!( + actual, + format!("[[\"id\"],\"{default}\",[\"padding:4px:0\"]]") + ); + } + } +} diff --git a/libs/extractor/src/element_evaluation.rs b/libs/extractor/src/element_evaluation.rs new file mode 100644 index 000000000..4d1039924 --- /dev/null +++ b/libs/extractor/src/element_evaluation.rs @@ -0,0 +1,212 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXElement, Str}, + builder::AstBuilder, +}; +use oxc_span::{GetSpan, GetSpanMut, SPAN}; + +use crate::{ + ExtractStyleProp, ExtractStyleValue, + utils::{get_string_by_literal_expression, stays_attribute}, +}; + +#[path = "element_capture.rs"] +mod element_capture; +pub(crate) use element_capture::{capture, default_type}; + +#[cfg(test)] +#[path = "assignment_container_tests.rs"] +mod assignment_container_tests; + +#[cfg(test)] +#[path = "assignment_mixed_container_tests.rs"] +mod assignment_mixed_container_tests; + +pub(crate) fn needed(element: &JSXElement<'_>) -> bool { + let values = element.opening_element.attributes.iter().filter(|attribute| matches!(attribute, + JSXAttributeItem::Attribute(attribute) if matches!(attribute.value, Some(JSXAttributeValue::ExpressionContainer(_))))).count(); + element.opening_element.attributes.iter().any(|attribute| matches!(attribute, JSXAttributeItem::SpreadAttribute(_))) || element.opening_element.attributes.iter().any(|attribute| matches!(attribute, + JSXAttributeItem::Attribute(attribute) + if matches!(&attribute.name, JSXAttributeName::Identifier(name) if !stays_attribute(&name.name) || name.name == "className") + && matches!(&attribute.value, Some(JSXAttributeValue::ExpressionContainer(container)) + if container.expression.as_expression().is_some_and(|value| get_string_by_literal_expression(value).is_none())))) + && (values > 1 || !element.children.is_empty()) +} + +pub(crate) fn typography<'a>( + ast: &AstBuilder<'a>, + styles: &mut Vec>, + source: &Expression<'a>, + next: &mut usize, +) -> Option<(String, Expression<'a>)> { + let mut class = crate::gen_class_name::gen_class_names(ast, styles, None, None)?; + if matches!(class, Expression::StringLiteral(_)) + && get_string_by_literal_expression(source).is_none() + { + class = crate::utils::call_with_values( + ast, + vec![( + "__devupTypography".to_string(), + source.clone_in(ast.allocator()), + )], + class, + ); + } + *class.span_mut() = source.span(); + let name = format!("__devupSpread{next}"); + *next += 1; + let values = styles.iter().flat_map(ExtractStyleProp::extract).collect(); + *styles = vec![ExtractStyleProp::Expression { + styles: values, + expression: Expression::new_identifier( + source.span(), + Str::from_in(name.as_str(), ast.allocator()), + ast, + ), + }]; + Some((name, class)) +} + +pub(crate) fn raw_typography<'a>(ast: &AstBuilder<'a>, source: &Expression<'a>) -> Expression<'a> { + use oxc_ast::ast::{TemplateElement, TemplateElementValue}; + let span = source.span(); + let value = Expression::new_identifier(span, "__devupTypography", ast); + let class = Expression::new_conditional_expression( + span, + value.clone_in(ast.allocator()), + Expression::new_template_literal( + span, + oxc_allocator::Vec::from_array_in( + [ + TemplateElement::new( + span, + TemplateElementValue { + raw: Str::from("typo-"), + cooked: None, + }, + false, + ast, + ), + TemplateElement::new( + span, + TemplateElementValue { + raw: Str::from(""), + cooked: None, + }, + true, + ast, + ), + ], + ast, + ), + oxc_allocator::Vec::from_array_in([value], ast), + ast, + ), + Expression::new_string_literal(span, "", None, ast), + ast, + ); + let mut result = crate::utils::call_with_values( + ast, + vec![( + "__devupTypography".to_string(), + source.clone_in(ast.allocator()), + )], + class, + ); + *result.span_mut() = span; + result +} + +pub(crate) fn snapshot<'a>(ast: &AstBuilder<'a>, value: &Expression<'a>) -> Expression<'a> { + let body = Expression::new_object_expression( + value.span(), + oxc_allocator::Vec::from_array_in( + [oxc_ast::ast::ObjectPropertyKind::new_spread_property( + value.span(), + Expression::new_identifier(SPAN, "__devupSpreadValue", ast), + ast, + )], + ast, + ), + ast, + ); + let mut result = crate::utils::call_with_values( + ast, + vec![( + "__devupSpreadValue".to_string(), + value.clone_in(ast.allocator()), + )], + body, + ); + *result.span_mut() = value.span(); + result +} + +pub(crate) fn scalar<'a>( + ast: &AstBuilder<'a>, + styles: &mut [ExtractStyleProp<'a>], + source: &Expression<'a>, + next: &mut usize, +) -> Option<(String, Expression<'a>)> { + if !styles.is_empty() + && matches!( + crate::utils::unwrap_syntax_only(source), + Expression::ObjectExpression(_) | Expression::ArrayExpression(_) + ) + { + let mut consumers = crate::assignment_owner::take_consumers(source.span(), styles); + crate::assignment_consumers::merge(&mut consumers); + if !consumers.is_empty() { + let remaining = + std::mem::replace(&mut styles[0], ExtractStyleProp::StaticArray(vec![])); + styles[0] = ExtractStyleProp::StaticArray(vec![ + remaining, + ExtractStyleProp::Evaluated { + binding: crate::sparse_sites::binding_name(source.span().start), + styles: consumers, + source: source.clone_in(ast.allocator()), + evaluation: None, + alternate_order: None, + alternate_class: false, + }, + ]); + } + return None; + } + if !styles.iter().all(|style| { + matches!( + style, + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(_)) + ) + }) || styles.is_empty() + { + return None; + } + let name = format!("__devupSpread{next}"); + *next += 1; + let input = styles + .iter() + .find_map(|style| match style { + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style)) + if style.important() + || matches!( + crate::utils::unwrap_syntax_only(source), + Expression::TemplateLiteral(_) + ) => + { + Some(Expression::new_identifier( + source.span(), + Str::from_in(style.identifier(), ast.allocator()), + ast, + )) + } + _ => None, + }) + .unwrap_or_else(|| source.clone_in(ast.allocator())); + for style in styles { + if let ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style)) = style { + style.replace_identifier(&name); + } + } + Some((name, input)) +} diff --git a/libs/extractor/src/evaluation_location.rs b/libs/extractor/src/evaluation_location.rs new file mode 100644 index 000000000..9a5df4fe8 --- /dev/null +++ b/libs/extractor/src/evaluation_location.rs @@ -0,0 +1,199 @@ +use std::path::Path; + +use boa_engine::{Context, vm::SourcePath}; +use css::style_origin::{RealLocation, StyleOrigin}; +use oxc_allocator::Allocator; +use oxc_ast::ast::{CallExpression, Program}; +use oxc_ast_visit::{Visit, walk}; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::{SourceType, Span}; +use oxc_transformer::{TransformOptions, Transformer}; + +use crate::import_alias_visit::{Edit, source_offset}; + +#[derive(Clone)] +pub(crate) struct StrippedSource { + pub code: String, + calls: Vec<(Span, Span)>, +} + +impl StrippedSource { + pub(crate) fn new(code: &str, filename: &str) -> Self { + let allocator = Allocator::default(); + let source_type = SourceType::from_path(filename).unwrap_or_else(|_| SourceType::ts()); + let mut program = Parser::new(&allocator, code, source_type).parse().program; + let authored = calls(&program); + let scoping = SemanticBuilder::new() + .with_enum_eval(true) + .build(&program) + .semantic + .into_scoping(); + let options = TransformOptions::default(); + let _ = Transformer::new(&allocator, Path::new("input.css.ts"), &options) + .build_with_scoping(scoping, &mut program); + let transformed = calls(&program); + let generated = Codegen::new().build(&program).code; + let parsed = Parser::new(&allocator, &generated, SourceType::mjs()).parse(); + let printed = calls(&parsed.program); + let mapped = if printed.len() == transformed.len() { + printed + .into_iter() + .zip(transformed) + .filter(|(_, original)| authored.contains(original)) + .collect() + } else { + Vec::new() + }; + Self { + code: generated, + calls: mapped, + } + } + + pub(crate) fn authored_calls( + &self, + source: (&str, &str), + edits: &[Edit], + ) -> Vec<(Span, StyleOrigin)> { + let allocator = Allocator::default(); + let source_type = SourceType::from_path(source.0).unwrap_or_else(|_| SourceType::ts()); + let program = Parser::new(&allocator, source.1, source_type) + .parse() + .program; + let authored = calls(&program); + self.calls + .iter() + .filter_map(|(generated, original)| { + let start = source_offset(edits, usize::try_from(original.start).ok()?); + let end = source_offset(edits, usize::try_from(original.end).ok()?); + let span = Span::new(u32::try_from(start).ok()?, u32::try_from(end).ok()?); + if !authored.contains(&span) { + return None; + } + crate::style_origin::evaluated_at(source.0, source.1, span) + .map(|origin| (*generated, origin)) + }) + .collect() + } +} + +fn calls(program: &Program<'_>) -> Vec { + struct Calls(Vec); + impl<'a> Visit<'a> for Calls { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + self.0.push(call.span); + walk::walk_call_expression(self, call); + } + } + let mut visitor = Calls(Vec::new()); + visitor.visit_program(program); + visitor.0 +} + +#[derive(Clone, Debug)] +pub(crate) struct CopiedSource { + pub generated: std::ops::Range, + pub original: usize, +} + +pub(crate) fn append_mapped( + body: &mut String, + copies: &mut Vec, + text: (String, Vec), +) { + let start = body.len(); + body.push_str(&text.0); + copies.extend(text.1.into_iter().map(|mut copy| { + copy.generated.start += start; + copy.generated.end += start; + copy + })); +} + +pub(crate) struct EvaluationLookup { + pub file: String, + pub run: String, + pub prefix: usize, + pub copies: Vec, + pub calls: Vec<(Span, StyleOrigin)>, +} + +impl EvaluationLookup { + pub(crate) fn produced_by(&self, context: &Context) -> Option { + let frames: Vec<_> = context + .stack_trace() + .map(boa_engine::vm::CallFrame::position) + .collect(); + if frames + .iter() + .any(|frame| matches!(frame.path, SourcePath::Eval)) + { + return None; + } + for frame in frames.into_iter().rev() { + let SourcePath::Path(path) = frame.path else { + continue; + }; + if path.as_ref() != Path::new(&self.file) { + continue; + } + let Some(position) = frame.position else { + continue; + }; + let Some(offset) = + byte_offset(&self.run, position.line_number(), position.column_number()) + .and_then(|offset| offset.checked_sub(self.prefix)) + else { + continue; + }; + let Some(copy) = self + .copies + .iter() + .find(|copy| copy.generated.contains(&offset)) + else { + continue; + }; + let original = copy.original + offset - copy.generated.start; + let Some((_, origin)) = self + .calls + .iter() + .filter(|(span, _)| { + usize::try_from(span.start).is_ok_and(|start| start <= original) + && usize::try_from(span.end).is_ok_and(|end| original < end) + }) + .max_by_key(|(span, _)| span.size()) + else { + continue; + }; + return Some(RealLocation::ProducedByCall(origin.clone())); + } + None + } +} + +fn byte_offset(source: &str, line: u32, column: u32) -> Option { + let row = usize::try_from(line.checked_sub(1)?).ok()?; + let mut start = 0; + let text = source + .split_inclusive('\n') + .enumerate() + .find_map(|(index, text)| { + if index == row { + Some(text) + } else { + start += text.len(); + None + } + })?; + let mut units = column.checked_sub(1)?; + for character in text.chars() { + if units == 0 { + return Some(start); + } + units = units.checked_sub(u32::try_from(character.len_utf16()).ok()?)?; + start += character.len_utf8(); + } + (units == 0).then_some(start) +} diff --git a/libs/extractor/src/evaluation_location_tests.rs b/libs/extractor/src/evaluation_location_tests.rs new file mode 100644 index 000000000..99a30ff53 --- /dev/null +++ b/libs/extractor/src/evaluation_location_tests.rs @@ -0,0 +1,134 @@ +use css::style_origin::RealLocation; + +use crate::{ExtractOption, vanilla_extract::execute_stylesheet}; + +#[test] +fn opaque_calls_use_authored_outermost_producers_when_boa_returns_parent_positions() { + // Given + let cases = [ + ( + "const identity=f=>f;export const cls=identity(style)({color:'red'});", + "identity(style)({color:'red'})", + ), + ( + "const bound=style.bind(null);export const cls=bound({color:'red'});", + "bound({color:'red'})", + ), + ( + "export const cls=style.call(null,{color:'red'});", + "style.call(null,{color:'red'})", + ), + ( + "export const cls=style.apply(null,[{color:'red'}]);", + "style.apply(null,[{color:'red'}])", + ), + ( + "export const cls=[{color:'red'}].map(style);", + "[{color:'red'}].map(style)", + ), + ( + "function run(cb,f){return cb(f);}export const cls=run(function callback(f){return f({color:'red'});},style);", + "run(function callback(f){return f({color:'red'});},style)", + ), + ( + "const identity=x=>x;const native=identity(style);export const cls=identity(native({color:'red'}));", + "identity(native({color:'red'}))", + ), + ( + "function pair(a,b){return [a,b];}const opaque=f=>f;export const cls=pair(opaque(style)({color:'red'}),opaque(style)({color:'blue'}));", + "pair(opaque(style)({color:'red'}),opaque(style)({color:'blue'}))", + ), + ]; + // When / Then + for (body, expected) in cases { + let source = format!("import {{style}} from '@devup-ui/react';\n{body}"); + let (collected, _) = execute_stylesheet( + &source, + "call-proof.css.ts", + &ExtractOption::default(), + None, + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_ne!(collected.styles.len(), 0); + for entry in collected.styles.values() { + assert_eq!(entry.origin, None, "{body}"); + assert!( + matches!(&entry.location, Some(RealLocation::ProducedByCall(origin)) + if origin.file == "call-proof.css.ts" && origin.expression == expected), + "{body}: {entry:?}" + ); + } + } +} + +#[test] +fn nested_opaque_native_entry_clears_enclosing_factory_witness_when_no_packet_exists() { + // Given + let source = "import {style,styleVariants} from '@devup-ui/react';const identity=f=>f;export const classes=styleVariants({one:1},()=>identity(style)({color:'red'}));"; + // When + let (collected, _) = + execute_stylesheet(source, "nested.css.ts", &ExtractOption::default(), None) + .unwrap_or_else(|error| panic!("{error}")); + // Then + let inner = collected + .styles + .values() + .find(|entry| entry.json.contains("red")) + .unwrap_or_else(|| panic!("missing inner style")); + assert_eq!(inner.origin, None); + assert!( + matches!(&inner.location, Some(RealLocation::ProducedByCall(origin)) if origin.expression.starts_with("styleVariants(")), + "{inner:?}" + ); + assert!(collected.styles.values().any(|entry| { + entry + .origin + .as_ref() + .is_some_and(|origin| origin.expression.starts_with("styleVariants(")) + })); +} + +#[test] +fn eval_uses_the_actual_nested_export_binding_when_source_path_has_no_file_linkage() { + // Given + let source = "import {style} from '@devup-ui/react';const native=style;const result={nested:[eval(\"native({color:'red'})\"),eval(\"native({color:'blue'})\")],get untouched(){throw new Error('metadata invoked a getter');}};result.self=result;export {result as actualExport};"; + // When + let (collected, _) = execute_stylesheet(source, "eval.css.ts", &ExtractOption::default(), None) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert_eq!(collected.styles.len(), 2); + for entry in collected.styles.values() { + assert_eq!(entry.origin, None); + assert_eq!( + entry.location, + Some(RealLocation::ModuleExport { + file: "eval.css.ts".into(), + binding: Some("actualExport".into()) + }) + ); + } +} + +#[test] +fn eval_default_export_uses_default_binding_when_generated_local_name_is_not_authored() { + // Given + let source = "import {style} from '@devup-ui/react';const native=style;export default eval(\"native({color:'red'})\");"; + // When + let (collected, _) = + execute_stylesheet(source, "default.css.ts", &ExtractOption::default(), None) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert_eq!(collected.styles.len(), 1); + assert_eq!( + collected + .styles + .values() + .next() + .unwrap_or_else(|| panic!("missing style")) + .location, + Some(RealLocation::ModuleExport { + file: "default.css.ts".into(), + binding: Some("default".into()) + }) + ); +} diff --git a/libs/extractor/src/evaluation_origin.rs b/libs/extractor/src/evaluation_origin.rs new file mode 100644 index 000000000..233ed51ea --- /dev/null +++ b/libs/extractor/src/evaluation_origin.rs @@ -0,0 +1,272 @@ +use css::style_origin::{RealLocation, StyleOrigin}; +use oxc_allocator::Allocator; +use oxc_ast::ast::{ + CallExpression, Expression, ImportDeclarationSpecifier, ModuleExportName, Statement, + VariableDeclarator, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_semantic::{Scoping, SemanticBuilder}; +use oxc_span::{GetSpan, SourceType}; +use oxc_syntax::symbol::SymbolId; +use std::collections::{BTreeMap, BTreeSet}; + +pub(crate) const PACKET: &str = "__devup_source_v1"; + +pub(crate) fn json(origin: &StyleOrigin) -> String { + format!( + "{{\"file\":{},\"line\":{},\"col\":{},\"originalExpression\":{}}}", + crate::vanilla_extract::json_string(&origin.file), + origin.line, + origin.column, + crate::vanilla_extract::json_string(&origin.expression) + ) +} + +pub(crate) fn mark_location(code: String, location: Option<&RealLocation>) -> String { + match location { + None => code, + Some(location) => { + let serialized = match location { + RealLocation::Exact(origin) => format!("{{\"Exact\":{}}}", json(origin)), + RealLocation::ProducedByCall(origin) => { + format!("{{\"ProducedByCall\":{}}}", json(origin)) + } + RealLocation::ModuleExport { file, binding } => format!( + "{{\"ModuleExport\":{{\"file\":{},\"binding\":{}}}}}", + crate::vanilla_extract::json_string(file), + binding + .as_deref() + .map_or_else(|| "null".into(), crate::vanilla_extract::json_string) + ), + }; + format!( + "{}({}, {code})", + crate::style_origin::MARKER, + crate::vanilla_extract::json_string(&serialized) + ) + } + } +} + +pub(crate) fn instrument(code: &str, filename: &str, option: &crate::ExtractOption) -> String { + instrument_with_edits(code, filename, option).0 +} + +pub(crate) fn instrument_with_edits( + code: &str, + filename: &str, + option: &crate::ExtractOption, +) -> (String, Vec) { + let allocator = Allocator::default(); + let source_type = SourceType::from_path(filename).unwrap_or_else(|_| SourceType::ts()); + let program = Parser::new(&allocator, code, source_type).parse().program; + let scoping = SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let mut factories = BTreeSet::new(); + let mut namespaces = BTreeSet::new(); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement + && (import.source.value.as_str() == option.package.as_str() + || option + .import_aliases + .contains_key(import.source.value.as_str())) + && let Some(specifiers) = &import.specifiers + { + for specifier in specifiers { + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(import) => { + let name = match &import.imported { + ModuleExportName::IdentifierName(name) => name.name.as_str(), + ModuleExportName::IdentifierReference(name) => name.name.as_str(), + ModuleExportName::StringLiteral(name) => name.value.as_str(), + }; + if factory_name(name) + && let Some(symbol) = import.local.symbol_id.get() + { + factories.insert(symbol); + } + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(import) => { + if let Some(symbol) = import.local.symbol_id.get() { + namespaces.insert(symbol); + } + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => {} + } + } + } + } + loop { + let before = factories.len(); + Aliases { + scoping: &scoping, + factories: &mut factories, + namespaces: &namespaces, + } + .visit_program(&program); + if factories.len() == before { + break; + } + } + let mut calls = Calls { + file: filename, + source: code, + scoping: &scoping, + factories: &factories, + namespaces: &namespaces, + insertions: BTreeMap::new(), + }; + calls.visit_program(&program); + let mut instrumented = code.to_string(); + let edits = calls + .insertions + .iter() + .map(|(position, text)| (*position, *position, text.len())) + .collect(); + for (position, text) in calls.insertions.into_iter().rev() { + instrumented.insert_str(position, &text); + } + (instrumented, edits) +} + +fn factory_name(name: &str) -> bool { + matches!(name, "style" | "styleVariants" | "keyframes") +} + +fn symbol(expression: &Expression<'_>, scoping: &Scoping) -> Option { + match crate::utils::unwrap_syntax_only(expression) { + Expression::Identifier(name) => name + .reference_id + .get() + .and_then(|reference| scoping.get_reference(reference).symbol_id()), + _ => None, + } +} + +struct ApiBindings<'a> { + scoping: &'a Scoping, + factories: &'a BTreeSet, + namespaces: &'a BTreeSet, +} +impl ApiBindings<'_> { + fn factory(&self, expression: &Expression<'_>) -> bool { + match crate::utils::unwrap_syntax_only(expression) { + Expression::Identifier(_) => symbol(expression, self.scoping) + .is_some_and(|symbol| self.factories.contains(&symbol)), + Expression::StaticMemberExpression(member) => { + factory_name(member.property.name.as_str()) + && symbol(&member.object, self.scoping) + .is_some_and(|symbol| self.namespaces.contains(&symbol)) + } + Expression::ComputedMemberExpression(member) => { + crate::utils::get_string_by_literal_expression(&member.expression) + .is_some_and(|name| factory_name(&name)) + && symbol(&member.object, self.scoping) + .is_some_and(|symbol| self.namespaces.contains(&symbol)) + } + _ => false, + } + } +} + +struct Aliases<'a> { + scoping: &'a Scoping, + factories: &'a mut BTreeSet, + namespaces: &'a BTreeSet, +} +impl<'a> Visit<'a> for Aliases<'_> { + fn visit_variable_declarator(&mut self, declaration: &VariableDeclarator<'a>) { + if let Some(symbol) = declaration + .id + .get_binding_identifier() + .and_then(|binding| binding.symbol_id.get()) + && self.scoping.symbol_flags(symbol).is_const_variable() + && let Some(init) = &declaration.init + && (ApiBindings { + scoping: self.scoping, + factories: self.factories, + namespaces: self.namespaces, + }) + .factory(init) + { + self.factories.insert(symbol); + } + walk::walk_variable_declarator(self, declaration); + } +} + +struct Calls<'a> { + file: &'a str, + source: &'a str, + scoping: &'a Scoping, + factories: &'a BTreeSet, + namespaces: &'a BTreeSet, + insertions: BTreeMap, +} +impl<'a> Visit<'a> for Calls<'_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if (ApiBindings { + scoping: self.scoping, + factories: self.factories, + namespaces: self.namespaces, + }) + .factory(&call.callee) + && let Some(origin) = + crate::style_origin::evaluated_at(self.file, self.source, call.span) + && let Ok(end) = usize::try_from(call.span.end) + { + let mut packet = if call.arguments.is_empty() { + String::from("undefined,undefined,") + } else { + String::from(",undefined,undefined,") + }; + packet.push_str(&crate::vanilla_extract::json_string(PACKET)); + packet.push(','); + packet.push_str(&crate::vanilla_extract::json_string(&json(&origin))); + let position = call.arguments.last().map_or(end - 1, |argument| { + usize::try_from(argument.span().end).unwrap_or(end - 1) + }); + self.insertions + .entry(position) + .or_default() + .push_str(&packet); + } + walk::walk_call_expression(self, call); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_packets_preserve_call_evaluation_parentheses_and_trailing_comments() { + // Given + let source = "import {style} from '@devup-ui/react';const alias=style;alias(({color:eval('value')}),/* , */);"; + // When + let instrumented = instrument(source, "actual.css.ts", &crate::ExtractOption::default()); + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, &instrumented, SourceType::ts()).parse(); + // Then + assert_eq!(parsed.diagnostics.len(), 0, "{instrumented}"); + assert!(!instrumented.contains("=>"), "{instrumented}"); + assert!( + instrumented.contains("alias(({color:eval('value')}),undefined,undefined,"), + "{instrumented}" + ); + assert!(instrumented.contains(PACKET), "{instrumented}"); + } + + #[test] + fn a_shadowed_style_function_does_not_receive_compiler_arguments() { + // Given + let source = "import {style} from '@devup-ui/react';function run(style){return style({color:'red'});}const custom=(...args)=>args.length;run(custom);"; + // When + let instrumented = instrument(source, "shadow.css.ts", &crate::ExtractOption::default()); + // Then + assert_eq!(instrumented, source); + } +} diff --git a/libs/extractor/src/extract_style/extract_dynamic_style.rs b/libs/extractor/src/extract_style/extract_dynamic_style.rs index 8d75636f5..e9278e3dd 100644 --- a/libs/extractor/src/extract_style/extract_dynamic_style.rs +++ b/libs/extractor/src/extract_style/extract_dynamic_style.rs @@ -1,7 +1,10 @@ use std::fmt::{Debug, Formatter}; use css::{ - sheet_to_classname, sheet_to_variable_name, + Naming, Site, + content_name::{AtomContent, ContentName}, + sheet_to_classname_content, sheet_to_variable_name_at, + style_origin::Origin, style_selector::{StyleSelector, optimize_selector}, }; @@ -24,6 +27,12 @@ pub struct ExtractDynamicStyle { important: bool, pub(crate) layer: Option, + + pub(crate) naming: Naming, + + /// Where in the original sources it was written; names its variable + pub(crate) site: Option, + pub origin: Origin, } impl Debug for ExtractDynamicStyle { @@ -40,6 +49,9 @@ impl Debug for ExtractDynamicStyle { if let Some(layer) = &self.layer { s.field("layer", layer); } + if self.naming != Naming::Own { + s.field("naming", &self.naming); + } s.finish() } } @@ -118,9 +130,42 @@ impl ExtractDynamicStyle { style_order: None, important, layer: None, + naming: Naming::Own, + site: None, + origin: crate::style_origin::current(), } } + /// Place it where the code being read has it at `start`. + #[must_use] + pub fn at(self, start: u32) -> Self { + self.at_role(start, 0) + } + + pub(crate) fn with_assignment_site(mut self, start: u32) -> Self { + self.site = crate::assignment_owner::site(start, self.level, &self.identifier); + self + } + + /// Select a sub-role by source syntax order, not dynamic extraction order. + #[must_use] + pub fn at_role(mut self, start: u32, role: usize) -> Self { + self.site = crate::provenance::site_at(start, role, &self.identifier); + self + } + + /// The CSS variable that carries its value. + #[must_use] + pub fn variable_name(&self) -> String { + let selector = super::class_selector(self.selector.as_ref(), self.layer()); + sheet_to_variable_name_at( + self.property.as_str(), + self.level, + selector.as_deref(), + self.site.clone(), + ) + } + pub const fn property(&self) -> &str { self.property.as_str() } @@ -137,6 +182,10 @@ impl ExtractDynamicStyle { self.identifier.as_str() } + pub(crate) fn replace_identifier(&mut self, identifier: &str) { + self.identifier = identifier.to_string(); + } + pub const fn style_order(&self) -> Option { self.style_order } @@ -145,41 +194,51 @@ impl ExtractDynamicStyle { self.important } + pub const fn naming(&self) -> Naming { + self.naming + } + pub fn layer(&self) -> Option<&str> { self.layer.as_deref() } + + #[must_use] + pub fn effective_value(&self) -> String { + format!( + "var({}){}", + self.variable_name(), + if self.important { " !important" } else { "" } + ) + } + + fn atom_content<'a>(&'a self, value: &'a str) -> AtomContent<'a> { + AtomContent { + property: &self.property, + value: Some(value), + naming: self.naming, + level: self.level, + order: self.style_order.unwrap_or(255), + selector: self.selector.as_ref(), + layer: self.layer.as_deref(), + dynamic: true, + } + } + + #[must_use] + pub fn content_name(&self) -> ContentName { + self.atom_content(&self.effective_value()).content() + } } impl ExtractStyleProperty for ExtractDynamicStyle { fn extract(&self, filename: Option<&str>) -> StyleProperty { - let selector = super::class_selector(self.selector.as_ref(), self.layer()); - let ordinary_class = (!css::atom_hoist::is_atom_hoist()).then(|| { - sheet_to_classname( - self.property.as_str(), - self.level, - self.important.then_some("!important"), - selector.as_deref(), - self.style_order, - filename, - ) - }); - let variable_name = - sheet_to_variable_name(self.property.as_str(), self.level, selector.as_deref()); + let variable_name = self.variable_name(); 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, - ) - }), + class_name: sheet_to_classname_content(&self.atom_content(&declaration), 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 1d28e6be5..3ad84d4cc 100644 --- a/libs/extractor/src/extract_style/extract_keyframes.rs +++ b/libs/extractor/src/extract_style/extract_keyframes.rs @@ -1,60 +1,51 @@ -use std::{ - collections::BTreeMap, - hash::{DefaultHasher, Hash, Hasher}, -}; +use std::collections::BTreeMap; -use css::keyframes_to_keyframes_name; +use css::{content_name::ContentName, style_origin::Origin}; use crate::extract_style::{ ExtractStyleProperty, extract_static_style::ExtractStaticStyle, style_property::StyleProperty, }; -#[derive(Debug, Default, PartialEq, Clone, Eq, Hash, Ord, PartialOrd)] +#[derive(Default, PartialEq, Clone, Eq, Hash, Ord, PartialOrd)] pub struct ExtractKeyframes { pub keyframes: BTreeMap>, + pub origin: Origin, } -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 - // `String`; `keyframes_to_keyframes_name` only reads it as `&str` and - // copies it into its own key, so the owned allocation was pure waste. - let mut buf = [0u8; 20]; - let hash_key = write_u64(&mut buf, hasher.finish()); - StyleProperty::ClassName(keyframes_to_keyframes_name(hash_key, filename)) +impl std::fmt::Debug for ExtractKeyframes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExtractKeyframes") + .field("keyframes", &self.keyframes) + .finish() + } +} + +impl ExtractKeyframes { + #[must_use] + pub fn effective_steps(&self) -> Vec<(String, Vec<(String, String)>)> { + self.keyframes + .iter() + .map(|(step, styles)| { + ( + step.clone(), + styles + .iter() + .map(|style| (style.property().to_string(), style.effective_value())) + .collect(), + ) + }) + .collect() + } + + #[must_use] + pub fn content_name(&self) -> ContentName { + ContentName::keyframes(&self.effective_steps()) } } -/// Writes `value`'s decimal digits into the tail of `buf` and returns the -/// written slice as `&str`. A `u64` is at most 20 decimal digits, so `buf` -/// never overflows. -fn write_u64(buf: &mut [u8; 20], mut value: u64) -> &str { - let mut pos = buf.len(); - loop { - pos -= 1; - buf[pos] = b'0' + (value % 10) as u8; - value /= 10; - if value == 0 { - break; - } +impl ExtractStyleProperty for ExtractKeyframes { + fn extract(&self, _filename: Option<&str>) -> StyleProperty { + let content = self.content_name(); + StyleProperty::ClassName(content.name(css::get_prefix().as_deref().unwrap_or_default())) } - // Only ASCII digits were written, so this slice is valid UTF-8. - str::from_utf8(&buf[pos..]).unwrap_or("0") } diff --git a/libs/extractor/src/extract_style/extract_static_style.rs b/libs/extractor/src/extract_style/extract_static_style.rs index 2a5b62775..60238e588 100644 --- a/libs/extractor/src/extract_style/extract_static_style.rs +++ b/libs/extractor/src/extract_style/extract_static_style.rs @@ -2,9 +2,12 @@ use std::borrow::Cow; use std::fmt::{Debug, Formatter}; use css::{ + Naming, + content_name::{AtomContent, ContentName}, optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value}, optimize_value::optimize_value, - sheet_to_classname, + sheet_to_classname_content, + style_origin::Origin, style_selector::{StyleSelector, optimize_selector}, theme_tokens::get_first_theme_token_value, }; @@ -41,18 +44,25 @@ pub struct ExtractStaticStyle { pub layer: Option, /// How theme tokens should be resolved when converting to CSS. pub theme_token_resolution: ThemeTokenResolution, + /// Which counter, if any, names the class; kept so the sheet names it + /// again the same way. + pub naming: Naming, + pub origin: Origin, } impl Debug for ExtractStaticStyle { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ExtractStaticStyle") - .field("property", &self.property) + let mut s = f.debug_struct("ExtractStaticStyle"); + s.field("property", &self.property) .field("value", &self.value) .field("level", &self.level) .field("selector", &self.selector) .field("style_order", &self.style_order) - .field("layer", &self.layer) - .finish() + .field("layer", &self.layer); + if self.naming != Naming::Own { + s.field("naming", &self.naming); + } + s.finish() } } @@ -106,6 +116,8 @@ impl ExtractStaticStyle { style_order: None, layer: None, theme_token_resolution: ThemeTokenResolution::CssVariable, + naming: Naming::Own, + origin: crate::style_origin::current(), } } @@ -138,6 +150,8 @@ impl ExtractStaticStyle { style_order: Some(0), layer: None, theme_token_resolution: ThemeTokenResolution::CssVariable, + naming: Naming::Own, + origin: crate::style_origin::current(), } } @@ -147,6 +161,12 @@ impl ExtractStaticStyle { self } + #[must_use] + pub const fn with_naming(mut self, naming: Naming) -> Self { + self.naming = naming; + self + } + /// Get the layer name #[must_use] pub fn layer(&self) -> Option<&str> { @@ -200,26 +220,46 @@ impl ExtractStaticStyle { } } } -} -impl ExtractStyleProperty for ExtractStaticStyle { - fn extract(&self, filename: Option<&str>) -> StyleProperty { - let s = self.class_selector(); + #[must_use] + pub fn content_name(&self) -> ContentName { + let value = self.effective_value(); + self.atom_content(&value).content() + } + + #[must_use] + pub fn effective_value(&self) -> String { + if self.property == "typography" { + return css::content_typography::identity(&self.value, self.level); + } 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(&value) + let value = if self.property != "content" && check_multi_css_optimize(&self.property) { + optimize_multi_css_value(&value).into_owned() } else { - Cow::Borrowed(value.as_ref()) + value.into_owned() }; - StyleProperty::ClassName(sheet_to_classname( - &self.property, - self.level, - Some(v.as_ref()), - s.as_deref(), - self.style_order, + css::content_value::emitted(&value).into_owned() + } + + fn atom_content<'a>(&'a self, value: &'a str) -> AtomContent<'a> { + AtomContent { + property: &self.property, + value: Some(value), + naming: self.naming, + level: self.level, + order: self.style_order.unwrap_or(255), + selector: self.selector.as_ref(), + layer: self.layer.as_deref(), + dynamic: false, + } + } +} + +impl ExtractStyleProperty for ExtractStaticStyle { + fn extract(&self, filename: Option<&str>) -> StyleProperty { + let value = self.effective_value(); + StyleProperty::ClassName(sheet_to_classname_content( + &self.atom_content(&value), filename, )) } @@ -229,6 +269,19 @@ impl ExtractStyleProperty for ExtractStaticStyle { mod tests { use super::*; + #[test] + fn content_strings_keep_quotes_when_effective_values_name_and_emit_them() { + for value in ["\"\"", "\"red\"", "'hello world'"] { + let style = ExtractStaticStyle::new("content", value, 0, None); + assert_eq!(style.effective_value(), value); + assert_ne!( + style.content_name(), + ExtractStaticStyle::new("content", value.trim_matches(['\'', '"']), 0, None) + .content_name() + ); + } + } + #[test] fn test_extract_static_style() { let style = ExtractStaticStyle::new("color", "red", 0, None); diff --git a/libs/extractor/src/extract_style/extract_style_value.rs b/libs/extractor/src/extract_style/extract_style_value.rs index 588057752..18cbe52de 100644 --- a/libs/extractor/src/extract_style/extract_style_value.rs +++ b/libs/extractor/src/extract_style/extract_style_value.rs @@ -34,6 +34,18 @@ impl ExtractStyleValue { | ExtractStyleValue::FontFace(_) => None, } } + pub const fn join_naming(&mut self, naming: css::Naming) { + match self { + ExtractStyleValue::Static(style) => style.naming = style.naming.join(naming), + ExtractStyleValue::Dynamic(style) => style.naming = style.naming.join(naming), + ExtractStyleValue::Keyframes(_) + | ExtractStyleValue::Typography(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) => {} + } + } + pub const fn set_style_order(&mut self, order: u8) { match self { ExtractStyleValue::Static(style) if style.style_order.is_none() => { diff --git a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs index ae468ce3d..8ab35fddd 100644 --- a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs +++ b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs @@ -16,12 +16,16 @@ use oxc_ast::{ ast::{Expression, ObjectPropertyKind}, builder::AstBuilder, }; +use oxc_span::GetSpan; pub fn extract_keyframes_from_expression<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, ) -> KeyframesExtractResult { - let mut keyframes = ExtractKeyframes::default(); + let mut keyframes = ExtractKeyframes { + origin: crate::style_origin::for_span(expression.span()), + ..ExtractKeyframes::default() + }; let Expression::ObjectExpression(obj) = unwrap_syntax_only_mut(expression) else { return KeyframesExtractResult { keyframes, diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 4b8f7f27b..d171842a8 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -9,6 +9,7 @@ use crate::{ extractor::{ ExtractResult, extract_style_from_member_expression::extract_style_from_member_expression, }, + provenance::{join_naming, mark, provenance_of}, utils::{ expression_to_code, get_number_by_literal_expression, get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, is_same_expression, @@ -16,7 +17,7 @@ use crate::{ }, }; use css::{ - add_selector_params, + Naming, add_selector_params, at_rule::{media_shorthand_query, split_at_rule_key}, disassemble_property, get_enum_property_map, get_enum_property_value, is_enum_property, is_special_property::is_special_property, @@ -35,6 +36,10 @@ use oxc_ast::{ use oxc_span::{GetSpan, SPAN}; use std::collections::BTreeMap; +mod boolean_expression; +#[cfg(test)] +mod boolean_or_tests; + const IGNORED_IDENTIFIERS: [&str; 3] = ["undefined", "NaN", "Infinity"]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -107,7 +112,11 @@ pub(crate) fn flatten_spreads<'a>(ast_builder: &AstBuilder<'a>, object: &mut Obj &mut inner.properties, oxc_allocator::Vec::new_in(ast_builder), ); - for property in spread_properties { + let naming = provenance_of(unwrap_syntax_only(&spread.argument)); + for mut property in spread_properties { + if let ObjectPropertyKind::ObjectProperty(property) = &mut property { + mark(&mut property.value, naming); + } push_replacing(&mut object.properties, property); } } else { @@ -203,6 +212,9 @@ fn create_static_styles<'a>( styles } +/// The styles `expression` gives, each carrying what its value depends on so +/// production class names stay the same wherever an import resolves +/// differently. pub fn extract_style_from_expression<'a>( ast_builder: &AstBuilder<'a>, name: Option<&str>, @@ -210,6 +222,67 @@ pub fn extract_style_from_expression<'a>( level: u8, selector: &Option, literal_handling: LiteralHandling, +) -> ExtractResult<'a> { + let naming = provenance_of(expression); + let origin = crate::style_origin::for_span(expression.span()); + let _origin = crate::style_origin::CurrentOrigin::enter(expression.span()); + let owns_assignment = !crate::assignment_owner::AssignmentOwner::active() + && name.is_some_and(|name| { + !matches!(name, "as" | "typography" | "selectors") + && get_enum_property_map(name).is_none() + }) + && matches!( + unwrap_syntax_only(expression), + Expression::ConditionalExpression(_) + | Expression::LogicalExpression(_) + | Expression::ArrayExpression(_) + | Expression::ComputedMemberExpression(_) + ); + let source = owns_assignment.then(|| expression.clone_in(ast_builder.allocator())); + let _assignment = (owns_assignment + && name.is_some_and(|name| !name.starts_with(['_', '@']) && !is_nested_selector_key(name))) + .then(|| crate::assignment_owner::AssignmentOwner::enter(expression)); + let mut result = extract_style_values( + ast_builder, + name, + expression, + level, + selector, + literal_handling, + ); + if naming != Naming::Own { + join_naming(&mut result.styles, naming); + } + crate::style_origin::fill(&mut result.styles, &origin); + if let Some(source) = source { + if result.styles.is_empty() { + return result; + } + if !matches!(unwrap_syntax_only(&source), Expression::ArrayExpression(_)) + && let Some(scalar) = crate::assignment_owner::scalar(&source, &result.styles) + { + result.styles = vec![ExtractStyleProp::Static(scalar)]; + return result; + } + result.styles = vec![ExtractStyleProp::Evaluated { + binding: crate::sparse_sites::binding_name(source.span().start), + styles: result.styles, + source, + evaluation: None, + alternate_order: None, + alternate_class: false, + }]; + } + result +} + +fn extract_style_values<'a>( + ast_builder: &AstBuilder<'a>, + name: Option<&str>, + expression: &mut Expression<'a>, + level: u8, + selector: &Option, + literal_handling: LiteralHandling, ) -> ExtractResult<'a> { let mut typo = false; let expression = unwrap_syntax_only_mut(expression); @@ -494,6 +567,9 @@ pub fn extract_style_from_expression<'a>( } typo = name == "typography"; } + if matches!(expression, Expression::BooleanLiteral(_)) { + return ExtractResult::default(); + } if let Some(value) = get_string_by_literal_expression(expression) { if let Some(name) = name { ExtractResult { @@ -660,7 +736,11 @@ pub fn extract_style_from_expression<'a>( } else if typo && let Some(style) = conditional_typography( ast_builder, - &Expression::new_identifier(SPAN, identifier.name.as_str(), ast_builder), + &Expression::new_identifier( + identifier.span, + identifier.name.as_str(), + ast_builder, + ), level, selector, ) @@ -672,46 +752,9 @@ pub fn extract_style_from_expression<'a>( } else if typo { ExtractResult { styles: vec![ExtractStyleProp::Expression { - expression: Expression::new_conditional_expression( - SPAN, - Expression::new_identifier( - SPAN, - identifier.name.as_str(), - ast_builder, - ), - Expression::new_template_literal( - SPAN, - oxc_allocator::Vec::from_array_in( - [ - TemplateElement::new( - SPAN, - TemplateElementValue { - raw: Str::from("typo-"), - cooked: None, - }, - false, - ast_builder, - ), - TemplateElement::new( - SPAN, - TemplateElementValue { - raw: Str::from(""), - cooked: None, - }, - true, - ast_builder, - ), - ], - ast_builder, - ), - oxc_allocator::Vec::from_array_in( - [expression.clone_in(ast_builder.allocator())], - ast_builder, - ), - ast_builder, - ), - Expression::new_string_literal(SPAN, "", None, ast_builder), + expression: crate::element_evaluation::raw_typography( ast_builder, + expression, ), styles: vec![], }], @@ -788,7 +831,21 @@ pub fn extract_style_from_expression<'a>( LogicalOperator::Or => ExtractResult { styles: vec![ExtractStyleProp::Conditional { condition: logical.left.clone_in(ast_builder.allocator()), - consequent: None, + consequent: name + .filter(|_| !boolean_expression::is_boolean(&logical.left)) + .map(|_| { + Box::new(ExtractStyleProp::StaticArray( + extract_style_from_expression( + ast_builder, + name, + &mut logical.left, + level, + selector, + literal_handling, + ) + .styles, + )) + }), alternate: res, }], ..ExtractResult::default() @@ -1021,7 +1078,8 @@ pub fn extract_style_from_expression<'a>( level, &format!("({})", code.trim_end().trim_end_matches(';')), selector.clone(), - ), + ) + .with_assignment_site(expression.span().start), ))], ..ExtractResult::default() } @@ -1056,7 +1114,8 @@ pub(crate) fn place_in_layer(props: &mut [ExtractStyleProp<'_>], layer: &str) { match prop { ExtractStyleProp::Static(ExtractStyleValue::Static(style)) => nest(&mut style.layer), ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style)) => nest(&mut style.layer), - ExtractStyleProp::StaticArray(props) => place_in_layer(props, layer), + ExtractStyleProp::StaticArray(props) + | ExtractStyleProp::Evaluated { styles: props, .. } => place_in_layer(props, layer), ExtractStyleProp::Conditional { consequent, alternate, @@ -1116,7 +1175,8 @@ fn declared_from(props: &[ExtractStyleProp<'_>]) -> Declared { }; Declared::from([((selector.cloned(), property), level)]) } - ExtractStyleProp::StaticArray(props) => declared_from(props), + ExtractStyleProp::StaticArray(props) + | ExtractStyleProp::Evaluated { styles: props, .. } => declared_from(props), ExtractStyleProp::Conditional { consequent, alternate, @@ -1185,7 +1245,8 @@ fn skip_declared(props: &mut [ExtractStyleProp<'_>], declared: &Declared) { style.value = format!("{}|{}", style.value, skipped.join(",")); } } - ExtractStyleProp::StaticArray(props) => skip_declared(props, declared), + ExtractStyleProp::StaticArray(props) + | ExtractStyleProp::Evaluated { styles: props, .. } => skip_declared(props, declared), ExtractStyleProp::Conditional { consequent, alternate, @@ -1285,6 +1346,8 @@ fn typography_atom(name: &str, level: u8, selector: &Option) -> E style_order: None, layer: None, theme_token_resolution: ThemeTokenResolution::default(), + naming: css::Naming::Own, + origin: crate::style_origin::current(), } } @@ -1353,12 +1416,15 @@ pub fn dynamic_style<'a>( .collect(), } } else { - ExtractStyleProp::Static(ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( - name, - level, - &expression_to_code(expression), - selector.clone(), - ))) + ExtractStyleProp::Static(ExtractStyleValue::Dynamic( + ExtractDynamicStyle::new( + name, + level, + &expression_to_code(expression), + selector.clone(), + ) + .with_assignment_site(expression.span().start), + )) } } diff --git a/libs/extractor/src/extractor/extract_style_from_expression/boolean_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression/boolean_expression.rs new file mode 100644 index 000000000..fc241a230 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/boolean_expression.rs @@ -0,0 +1,82 @@ +use oxc_ast::ast::{Expression, UnaryOperator}; + +use crate::utils::unwrap_syntax_only; + +/// Prove a boolean result from syntax, without inferring identifier or call types. +pub(super) fn is_boolean(expression: &Expression<'_>) -> bool { + match unwrap_syntax_only(expression) { + Expression::BooleanLiteral(_) => true, + Expression::UnaryExpression(unary) => unary.operator == UnaryOperator::LogicalNot, + Expression::BinaryExpression(binary) => { + binary.operator.is_equality() + || binary.operator.is_compare() + || binary.operator.is_relational() + } + Expression::ConditionalExpression(conditional) => { + is_boolean(&conditional.consequent) && is_boolean(&conditional.alternate) + } + Expression::LogicalExpression(logical) => { + is_boolean(&logical.left) && is_boolean(&logical.right) + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::is_boolean; + use oxc_allocator::Allocator; + use oxc_ast::ast::Statement; + use oxc_parser::Parser; + use oxc_span::SourceType; + use rstest::rstest; + + #[rstest] + #[case("true", true)] + #[case("false", true)] + #[case("a == b", true)] + #[case("a != b", true)] + #[case("a === b", true)] + #[case("a !== b", true)] + #[case("a < b", true)] + #[case("a <= b", true)] + #[case("a > b", true)] + #[case("a >= b", true)] + #[case("a in b", true)] + #[case("a instanceof b", true)] + #[case("!a", true)] + #[case("!!a", true)] + #[case("((a === b) as boolean) satisfies boolean", true)] + #[case("(a === b)!", true)] + #[case("flag ? a === b : !a", true)] + #[case("a === b || !a", true)] + #[case("a === b && !a", true)] + #[case("a === b ?? !a", true)] + #[case("a", false)] + #[case("a as boolean", false)] + #[case("Boolean(a)", false)] + #[case("'true'", false)] + #[case("a + b", false)] + #[case("a & b", false)] + #[case("+a", false)] + #[case("a === b || '4px'", false)] + #[case("a === b && value", false)] + #[case("flag ? true : value", false)] + fn boolean_result_is_proven_when_syntax_guarantees_it( + #[case] expression: &str, + #[case] expected: bool, + ) { + // Given: parsed syntax, with no identifier-resolution assumptions. + let allocator = Allocator::default(); + let source = format!("({expression});"); + let parsed = Parser::new(&allocator, &source, SourceType::ts()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("expected expression statement"); + }; + // When: syntax-only wrappers are stripped for result classification. + let actual = is_boolean(&statement.expression); + // Then: only guaranteed booleans are excluded from CSS consumers. + assert_eq!(actual, expected); + } +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression/boolean_or_tests.rs b/libs/extractor/src/extractor/extract_style_from_expression/boolean_or_tests.rs new file mode 100644 index 000000000..b89fb4115 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/boolean_or_tests.rs @@ -0,0 +1,157 @@ +use crate::assignment_blocker_support::selected; +use crate::assignment_test_support::{compiled_jsx, evaluate, extracted}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("state.a === state.b || state.a", true, "[[\"a\",\"b\"],[],[]]")] +#[case( + "state.a === state.b || state.a", + false, + "[[\"a\",\"b\",\"a\"],[\"margin:9px:0\"],[\"9px\"]]" +)] +#[case("state.a === state.b || '1px'", true, "[[\"a\",\"b\"],[],[]]")] +#[case( + "state.a === state.b || '1px'", + false, + "[[\"a\",\"b\"],[\"margin:1px:0\"],[]]" +)] +#[case("[null, state.a === state.b || '4px']", true, "[[\"a\",\"b\"],[],[]]")] +#[case( + "[null, state.a === state.b || '4px']", + false, + "[[\"a\",\"b\"],[\"margin:4px:1\"],[]]" +)] +#[case( + "[null, state.a === state.b || state.a]", + true, + "[[\"a\",\"b\"],[],[]]" +)] +#[case( + "[null, state.a === state.b || state.a]", + false, + "[[\"a\",\"b\",\"a\"],[\"margin:9px:1\"],[\"9px\"]]" +)] +#[serial] +fn boolean_or_selects_no_css_when_left_is_true( + #[case] expression: &str, + #[case] flag: bool, + #[case] expected: &str, +) { + // Given: the three browser failures and their dynamic responsive counterpart. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}let trace=[];const node=render({{get a(){{trace.push('a');return '9px'}},get b(){{trace.push('b');return {flag}?'9px':'8px'}}}});" + ); + // When: emitted class and inline projections execute jointly in Boa. + let actual = selected( + &source, + "JSON.stringify([trace,assigned(node),Object.values(node.style??{})]);", + ); + // Then: true supplies neither consumer, false supplies only the RHS at its level. + assert_eq!(actual, expected); +} + +#[rstest] +#[case("!state.flag || state.rhs", false)] +#[case("!state.flag || state.rhs", true)] +#[case("((!state.flag) as boolean) || state.rhs", false)] +#[case("((!state.flag) as boolean) || state.rhs", true)] +#[serial] +fn boolean_or_keeps_rhs_lazy_when_controller_is_negated( + #[case] expression: &str, + #[case] flag: bool, +) { + // Given: an unselected RHS throws, so eager evaluation cannot pass. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}let trace=[];const node=render({{get flag(){{trace.push('flag');return {flag}}},get rhs(){{trace.push('rhs');if(!{flag})throw Error('unselected');return '7px'}}}});" + ); + // When: the real logical controller is evaluated exactly once. + let actual = selected(&source, "JSON.stringify([trace,assigned(node)]);"); + // Then: only a false left operand reads/selects the dynamic RHS. + let expected = if flag { + "[[\"flag\",\"rhs\"],[\"margin:7px:0\"]]" + } else { + "[[\"flag\"],[]]" + }; + assert_eq!(actual, expected); +} + +#[rstest] +#[case("'6px'", "[[\"left\"],[\"margin:6px:0\"]]")] +#[case("''", "[[\"left\",\"right\"],[\"margin:7px:0\"]]")] +#[serial] +fn value_or_retains_css_when_left_is_a_runtime_string(#[case] left: &str, #[case] expected: &str) { + // Given: a value-producing OR, not a boolean controller. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}let trace=[];const node=render({{get left(){{trace.push('left');return {left}}},get right(){{trace.push('right');return '7px'}}}});" + ); + // When: the source logical expression selects its actual CSS value. + let actual = selected(&source, "JSON.stringify([trace,assigned(node)]);"); + // Then: a truthy string remains CSS and each selected getter runs once. + assert_eq!(actual, expected); +} + +#[rstest] +#[case(true, "[2,0]")] +#[case(false, "[2,1]")] +#[serial] +fn static_css_boolean_or_compiles_when_rhs_is_a_literal( + #[case] flag: bool, + #[case] expected: &str, +) { + // Given: css() can know every possible declaration without a boolean variable rule. + let source = format!( + "import {{css}}from '@devup-ui/react';function render(state){{return css({{margin:state.a===state.b||'1px'}})}}let reads=0;const className=render({{get a(){{reads++;return 'x'}},get b(){{reads++;return {flag}?'x':'y'}}}});JSON.stringify([reads,String(className??'').split(/\\s+/).filter(Boolean).length]);" + ); + // When: extraction succeeds and its emitted class expression executes. + let actual = evaluate(&compiled_jsx(&source)); + // Then: no dynamic-css build error or true-branch class is introduced. + assert_eq!(actual, expected); +} + +#[rstest] +#[case("state.a === state.b || state.a", 0)] +#[case("[null, state.a === state.b || state.a]", 1)] +#[serial] +fn boolean_or_rhs_keeps_whole_source_site_when_responsive_slots_are_skipped( + #[case] expression: &str, + #[case] level: u8, +) { + // Given: one authored assignment, with a skipped level before the responsive RHS. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}" + ); + let at = source + .find(expression) + .unwrap_or_else(|| panic!("fixture source")); + // When: the remaining CSS consumer is extracted from the false branch. + let output = extracted(&source); + let sites = output + .styles + .iter() + .filter_map(|value| match value { + crate::ExtractStyleValue::Dynamic(style) => style + .site + .as_ref() + .map(|site| (site.at, site.role, style.level())), + _ => None, + }) + .collect::>(); + // Then: the RHS consumes the whole expression site and its original level role. + assert_eq!(sites, vec![(at, usize::from(level), level)]); +} + +#[rstest] +#[case("state.a === state.b || '1px'")] +#[case("[null, state.a === state.b || '4px']")] +#[serial] +fn boolean_or_preserves_existing_class_when_true_has_no_css(#[case] expression: &str) { + // Given: the authored existing class must keep supplying its prior margin rule. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}const node=render({{a:'x',b:'x'}});JSON.stringify([String(node.className).split(/\\s+/).filter(Boolean),Object.values(node.style??{{}})]);" + ); + // When: true selects the classless boolean branch. + let actual = evaluate(&compiled_jsx(&source)); + // Then: neither a generated margin rule nor an inline value overrides the prior class. + assert_eq!(actual, "[[\"prior\",\"exists\"],[]]"); +} diff --git a/libs/extractor/src/extractor/extract_style_from_member_expression.rs b/libs/extractor/src/extractor/extract_style_from_member_expression.rs index b52e3169e..240949e5e 100644 --- a/libs/extractor/src/extractor/extract_style_from_member_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_member_expression.rs @@ -17,7 +17,7 @@ use oxc_ast::{ ast::{ArrayExpressionElement, ComputedMemberExpression, Expression, ObjectPropertyKind}, builder::AstBuilder, }; -use oxc_span::SPAN; +use oxc_span::Span; use std::collections::BTreeMap; pub(super) fn extract_style_from_member_expression<'a>( @@ -57,18 +57,29 @@ pub(super) fn extract_style_from_member_expression<'a>( _ => false, } .then(|| mem.object.clone_in(ast_builder.allocator())); - let runtime = |whole: &Expression<'a>, offset: u32| { + let span = mem.span; + let runtime = |whole: &Expression<'a>| { runtime_member( ast_builder, name, whole.clone_in(ast_builder.allocator()), mem_expression, - offset, + span, level, selector, ) }; + if matches!(&mem.object, Expression::ArrayExpression(_)) + && spread.is_some() + && get_number_by_literal_expression(mem_expression).is_none() + { + return ExtractResult { + styles: vec![runtime(&mem.object)], + ..ExtractResult::default() + }; + } + if let Expression::ArrayExpression(array) = &mut mem.object && !array.elements.is_empty() { @@ -96,20 +107,17 @@ pub(super) fn extract_style_from_member_expression<'a>( } } return ExtractResult { - styles: spread - .iter() - .map(|whole| runtime(whole, mem.span.start)) - .collect(), + styles: spread.iter().map(runtime).collect(), ..ExtractResult::default() }; } let mut map = BTreeMap::new(); for (idx, p) in array.elements.iter_mut().enumerate() { - if let ArrayExpressionElement::SpreadElement(sp) = p + if let ArrayExpressionElement::SpreadElement(_) = p && let Some(whole) = &spread { - map.insert(idx.to_string(), Box::new(runtime(whole, sp.span.start))); + map.insert(idx.to_string(), Box::new(runtime(whole))); } else if let Some(p) = p.as_expression_mut() { map.insert( idx.to_string(), @@ -148,7 +156,7 @@ pub(super) fn extract_style_from_member_expression<'a>( .any(|p| matches!(p, ObjectPropertyKind::SpreadProperty(_))); if replaced && let Some(whole) = &spread { return ExtractResult { - styles: vec![runtime(whole, mem.span.start)], + styles: vec![runtime(whole)], ..ExtractResult::default() }; } @@ -206,7 +214,7 @@ pub(super) fn extract_style_from_member_expression<'a>( name, mem.object.clone_in(ast_builder.allocator()), mem_expression, - mem.span.start, + span, level, selector, )); @@ -225,12 +233,12 @@ fn runtime_member<'a>( name: Option<&str>, object: Expression<'a>, key: &Expression<'a>, - offset: u32, + span: Span, level: u8, selector: &Option, ) -> ExtractStyleProp<'a> { let member = Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( - SPAN, + span, object, key.clone_in(ast_builder.allocator()), false, @@ -239,7 +247,7 @@ fn runtime_member<'a>( match name { Some(name) => dynamic_style(ast_builder, name, &member, level, selector), None => ExtractStyleProp::Unreadable { - offset, + offset: span.start, code: readable_code(&member), prop: false, }, diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 425ed0245..5f5cf2c70 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -185,10 +185,15 @@ pub fn extract_style_from_styled<'a>( attrs: &[Expression<'a>], ) -> (ExtractResult<'a>, Expression<'a>, Vec<(u32, String)>) { let mut composed_classes = Vec::new(); + let mut composition_reads = Vec::new(); let mut errors = Vec::new(); if let Expression::CallExpression(call) = expression && extract_base_tag_and_class_name(ast_builder, &call.callee, imports).is_some() { + if style_arguments(ast_builder, &call.arguments).is_some() { + composition_reads = + crate::assignment_composition::capture(ast_builder, &mut call.arguments); + } match style_arguments(ast_builder, &call.arguments) { Some(StyleArguments { classes, rules }) => { call.arguments = @@ -274,12 +279,13 @@ pub fn extract_style_from_styled<'a>( None, ast_builder, )); - let component = create_styled_component( + let mut component = create_styled_component( ast_builder, &base.name, &class_name, &gen_styles(ast_builder, &props_styles, None), ); + crate::assignment_lowering::component(ast_builder, &mut component, &mut props_styles); let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); let result = ExtractResult { @@ -298,6 +304,11 @@ pub fn extract_style_from_styled<'a>( // Case 2: styled.div({ bg: "red" }), styled("div")({ bg: "red" }), // or styled("div", { bg: "red" }) + // Retain authored fields before extraction rewrites conditional/member values. + let creation_source = match &call.arguments[style_index] { + Argument::SpreadElement(spread) => spread.argument.clone_in(ast_builder.allocator()), + argument => argument.to_expression().clone_in(ast_builder.allocator()), + }; // Extract styles from object expression let ExtractResult { mut styles, @@ -337,12 +348,27 @@ pub fn extract_style_from_styled<'a>( split_filename, )), ); - let component = create_styled_component( + let mut component = create_styled_component( ast_builder, &base.name, &class_name, &gen_styles(ast_builder, &styles, None), ); + if matches!( + unwrap_syntax_only(&creation_source), + Expression::ObjectExpression(_) + ) { + crate::assignment_capture::styled_creation( + ast_builder, + &mut component, + crate::assignment_capture::StyledCreation { + source: &creation_source, + styles: &mut styles, + }, + ); + } else { + crate::assignment_lowering::component(ast_builder, &mut component, &mut styles); + } let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); let result = ExtractResult { @@ -368,7 +394,16 @@ pub fn extract_style_from_styled<'a>( }; ( result.unwrap_or_else(ExtractResult::default), - new_expr.unwrap_or_else(|| expression.clone_in(ast_builder.allocator())), + new_expr.map_or_else( + || expression.clone_in(ast_builder.allocator()), + |value| { + if composition_reads.is_empty() { + value + } else { + call_with_values(ast_builder, composition_reads, value) + } + }, + ), errors, ) } diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs index 69ff713a8..2e22e92fd 100644 --- a/libs/extractor/src/extractor/extract_style_from_stylex.rs +++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs @@ -7,7 +7,6 @@ use crate::stylex::{ dynamic_number_suffix, is_include_call_static, normalize_stylex_property, stylex_value, }; use css::optimize_value::optimize_value; -use css::sheet_to_variable_name; use css::style_selector::StyleSelector; use oxc_ast::ast::{ Argument, ArrowFunctionExpression, BindingPattern, Expression, ObjectExpression, @@ -36,6 +35,8 @@ fn raw_static_style<'a>( style_order: None, layer: None, theme_token_resolution: Default::default(), + naming: css::Naming::Own, + origin: crate::style_origin::current(), })) } @@ -367,12 +368,15 @@ fn extract_stylex_dynamic_namespace<'a>( if let Some(param_idx) = is_dynamic { // Dynamic property: generate CSS variable - let var_name = sheet_to_variable_name(&css_property, 0, None); - css_vars.push((param_idx, var_name, dynamic_number_suffix(&css_property))); let param_name = ¶m_names[param_idx]; - styles.push(ExtractStyleProp::Static(ExtractStyleValue::Dynamic( - ExtractDynamicStyle::new(&css_property, 0, param_name, None), - ))); + let style = ExtractDynamicStyle::new(&css_property, 0, param_name, None) + .at(prop.value.span().start); + css_vars.push(( + param_idx, + style.variable_name(), + dynamic_number_suffix(&css_property), + )); + styles.push(ExtractStyleProp::Static(ExtractStyleValue::Dynamic(style))); continue; } match leaf(&css_property, &prop.value) { diff --git a/libs/extractor/src/gen_class_name.rs b/libs/extractor/src/gen_class_name.rs index 3b274b7fd..1b9110e14 100644 --- a/libs/extractor/src/gen_class_name.rs +++ b/libs/extractor/src/gen_class_name.rs @@ -9,7 +9,7 @@ use oxc_ast::ast::{ StringLiteral, TemplateElement, TemplateElementValue, }; use oxc_ast::builder::AstBuilder; -use oxc_span::SPAN; +use oxc_span::{GetSpan, GetSpanMut, SPAN}; pub fn gen_class_names<'a>( ast_builder: &AstBuilder<'a>, @@ -18,6 +18,7 @@ pub fn gen_class_names<'a>( filename: Option<&str>, ) -> Option> { yield_typography(style_props); + crate::assignment_lowering::coalesce(style_props); merge_expression_for_class_name( ast_builder, style_props @@ -34,6 +35,31 @@ fn gen_class_name<'a>( filename: Option<&str>, ) -> Option> { match style_prop { + ExtractStyleProp::Evaluated { + source, + styles, + binding, + evaluation, + alternate_order, + alternate_class, + } => { + crate::assignment_consumers::merge(styles); + let mut value = crate::assignment_lowering::Lowering { + ast: ast_builder, + order: style_order, + filename, + alternate_order: *alternate_order, + } + .lower(source, styles); + let _ = gen_class_names(ast_builder, styles, style_order, filename); + *value.span_mut() = source.span(); + *evaluation = Some(value); + Some(crate::assignment_lowering::projection( + ast_builder, + binding, + if *alternate_class { 3 } else { 0 }, + )) + } ExtractStyleProp::Enum { map, condition } => { let properties = map.iter_mut().filter_map(|(key, value)| { merge_expression_for_class_name( diff --git a/libs/extractor/src/gen_style.rs b/libs/extractor/src/gen_style.rs index 85b2f086d..a22c85401 100644 --- a/libs/extractor/src/gen_style.rs +++ b/libs/extractor/src/gen_style.rs @@ -38,6 +38,14 @@ pub fn gen_styles<'a>( } let mut properties: Vec<_> = Vec::with_capacity(style_props.len()); extend_reversed_styles(ast_builder, &mut properties, style_props, filename); + let mut assigned = FxHashSet::default(); + properties.retain(|property| match property { + ObjectPropertyKind::ObjectProperty(property) => property + .key + .name() + .is_none_or(|key| assigned.insert(key.into_owned())), + ObjectPropertyKind::SpreadProperty(_) => true, + }); if properties.is_empty() { return None; } @@ -92,6 +100,13 @@ fn gen_style<'a>( filename: Option<&str>, ) -> Vec> { let mut properties = vec![]; + if let ExtractStyleProp::Evaluated { binding, .. } = style { + properties.push(ObjectPropertyKind::new_spread_property( + SPAN, + crate::assignment_lowering::projection(ast_builder, binding, 1), + ast_builder, + )); + } if let ExtractStyleProp::Static(st) = style { if let Some(StyleProperty::Variable { variable_name, @@ -306,24 +321,9 @@ fn gen_style<'a>( )); } } - // Cache each property's key once (`PropertyKey::name()` may allocate for computed - // keys) instead of recomputing it twice per comparison. The cached key stays a - // borrowed `Cow` for the common `StaticIdentifier` props (`color`, `padding`, - // ...), so sorting no longer heap-allocates an owned `String` per property. - // `Cow: Ord` compares by contents, and `Reverse` keeps the existing descending - // order, so the generated property order is byte-identical. - properties.sort_by_cached_key(|p| std::cmp::Reverse(object_property_key(p))); properties } -fn object_property_key<'k>(p: &ObjectPropertyKind<'k>) -> Option> { - if let ObjectPropertyKind::ObjectProperty(p) = p { - p.key.name() - } else { - None - } -} - #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index a600d1deb..b15e6fd63 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -15,14 +15,16 @@ use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; use oxc_parser::Parser; use oxc_semantic::{Scoping, SemanticBuilder}; -use oxc_span::{SPAN, SourceType}; +use oxc_span::{GetSpan, SPAN, SourceType}; use oxc_syntax::number::NumberBase; use oxc_syntax::operator::BinaryOperator; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; +use crate::provenance::mark; use crate::stylex::StylexFunction; use crate::{ExtractOption, ModuleResolver}; +use css::Naming; #[derive(Clone, Debug)] enum Constant { @@ -350,13 +352,9 @@ pub(crate) fn inline_constants<'a>( if read.names.is_empty() { return Inlined::default(); } - let mut modules = Modules { - resolver, - option, - exports: FxHashMap::default(), - loading: Vec::new(), - }; + let mut modules = Modules::new(resolver, option); let mut symbols: FxHashMap = FxHashMap::default(); + let namings; let mut inlined = Inlined::default(); let (scoping, reads_math) = { let mut scope = ModuleScope::new(filename, program, None); @@ -424,13 +422,11 @@ pub(crate) fn inline_constants<'a>( // Scoping is only worth building when a style reads a name that may // hold a constant let reads_math = read.names.contains("Math") && !scope.binds("Math"); - if !reads_math && !read.names.iter().any(|name| scope.binds(name)) { - return Inlined::default(); - } let scoping = SemanticBuilder::new() .build(program) .semantic .into_scoping(); + namings = crate::source_naming::symbols(program, &scoping, option); for name in &read.names { let bound = scope.binds(name); let constant = scope.lookup(&mut modules, name); @@ -480,11 +476,13 @@ pub(crate) fn inline_constants<'a>( (scoping, reads_math) }; inlined.dependencies = modules.exports.into_keys().collect(); - if !symbols.is_empty() || reads_math { + if !symbols.is_empty() || !namings.is_empty() || reads_math { Inline { ast_builder, scoping: &scoping, symbols: &symbols, + namings: &namings, + ambient: Naming::Own, style_roots: &style_roots, apis: &apis, objects: false, @@ -533,12 +531,7 @@ impl<'p, 'a, 'r> ChangeCheck<'p, 'a, 'r> { } Self { scope: std::cell::RefCell::new(scope), - modules: std::cell::RefCell::new(Modules { - resolver, - option, - exports: FxHashMap::default(), - loading: Vec::new(), - }), + modules: std::cell::RefCell::new(Modules::new(resolver, option)), } } @@ -721,7 +714,16 @@ struct Modules<'r> { loading: Vec, } -impl Modules<'_> { +impl<'r> Modules<'r> { + fn new(resolver: Option<&'r ModuleResolver>, option: &'r ExtractOption) -> Self { + Self { + resolver, + option, + exports: FxHashMap::default(), + loading: Vec::new(), + } + } + fn exports( &mut self, specifier: &str, @@ -1165,10 +1167,9 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => Imported::Namespace, }; - self.imports.insert( - specifier.local().name.to_string(), - (import.source.value.to_string(), imported), - ); + let local = specifier.local().name.to_string(); + self.imports + .insert(local, (import.source.value.to_string(), imported)); } } /// `const x = require('m')` and `const { a, b: c } = require('m')` @@ -1693,6 +1694,10 @@ struct Inline<'s, 'a> { ast_builder: &'s AstBuilder<'a>, scoping: &'s Scoping, symbols: &'s FxHashMap, + /// What each import, or constant reading one, depends on + namings: &'s FxHashMap, + /// What the condition choosing the code being visited depends on + ambient: Naming, style_roots: &'s FxHashSet<&'s str>, apis: &'s StyleApis<'s>, /// Inside what the build reads as style objects @@ -1701,7 +1706,50 @@ struct Inline<'s, 'a> { styles: bool, } +/// What the bindings an expression reads depend on +struct Dependencies<'s> { + scoping: &'s Scoping, + namings: &'s FxHashMap, + naming: Naming, +} + +impl<'a> Visit<'a> for Dependencies<'_> { + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { + if matches!(&call.callee, Expression::Identifier(id) if id.name == "require") { + self.naming = Naming::Risky; + } + walk::walk_call_expression(self, call); + } + + fn visit_import_expression(&mut self, import: &oxc_ast::ast::ImportExpression<'a>) { + self.naming = Naming::Risky; + walk::walk_import_expression(self, import); + } + fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) { + if let Some(naming) = identifier + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + .and_then(|symbol| self.namings.get(&symbol)) + { + self.naming = self.naming.join(*naming); + } + } +} + impl<'a> Inline<'_, 'a> { + fn naming_of(&self, expression: &Expression<'a>) -> Naming { + let mut dependencies = Dependencies { + scoping: self.scoping, + namings: self.namings, + naming: self + .ambient + .join(crate::provenance::provenance_of(expression)), + }; + dependencies.visit_expression(expression); + dependencies.naming + } + fn constant(&self, expression: &Expression<'a>) -> Option { match expression { Expression::Identifier(identifier) => { @@ -1795,17 +1843,21 @@ impl<'a> Inline<'_, 'a> { /// The side of a condition or of `&&`, `||` or `??` a constant or literal /// chooses, taken out of `expression` - fn chosen(&self, expression: &mut Expression<'a>) -> Option> { + fn chosen(&self, expression: &mut Expression<'a>) -> Option<(Expression<'a>, Naming)> { use oxc_allocator::TakeIn; let allocator = self.ast_builder; match expression { Expression::ConditionalExpression(conditional) => { - Some(if self.holds(&conditional.test)? { - conditional.consequent.take_in(allocator) - } else { - conditional.alternate.take_in(allocator) - }) + let naming = self.naming_of(&conditional.test); + Some(( + if self.holds(&conditional.test)? { + conditional.consequent.take_in(allocator) + } else { + conditional.alternate.take_in(allocator) + }, + naming, + )) } Expression::LogicalExpression(logical) => { let left = self.operand(&logical.left)?; @@ -1816,11 +1868,15 @@ impl<'a> Inline<'_, 'a> { !matches!(left, Constant::Null | Constant::Undefined) } }; - Some(if keep_left { - logical.left.take_in(allocator) - } else { - logical.right.take_in(allocator) - }) + let naming = self.naming_of(&logical.left); + Some(( + if keep_left { + logical.left.take_in(allocator) + } else { + logical.right.take_in(allocator) + }, + naming, + )) } _ => None, } @@ -1836,24 +1892,24 @@ impl<'a> Inline<'_, 'a> { .is_none()) } - fn literal(&self, constant: &Constant) -> Option> { + fn literal(&self, constant: &Constant, span: oxc_span::Span) -> Option> { let builder = self.ast_builder; match constant { Constant::String(value) => Some(Expression::new_string_literal( - SPAN, + span, Str::from_in(value.as_str(), builder.allocator()), None, builder, )), Constant::Number(value) => Some(Expression::new_numeric_literal( - SPAN, + span, *value, None, NumberBase::Decimal, builder, )), - Constant::Null => Some(Expression::new_null_literal(SPAN, builder)), - Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)), + Constant::Null => Some(Expression::new_null_literal(span, builder)), + Constant::Bool(value) => Some(Expression::new_boolean_literal(span, *value, builder)), Constant::Record(entries) if self.objects => { let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder); for (key, value) in entries.iter() { @@ -1868,21 +1924,21 @@ impl<'a> Inline<'_, 'a> { builder, ), ), - self.literal(value)?, + self.literal(value, span)?, false, false, false, builder, )); } - Some(Expression::new_object_expression(SPAN, properties, builder)) + Some(Expression::new_object_expression(span, properties, builder)) } Constant::Array(values) if self.objects => { let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder); for value in values.iter() { - elements.push(self.literal(value)?.into()); + elements.push(self.literal(value, span)?.into()); } - Some(Expression::new_array_expression(SPAN, elements, builder)) + Some(Expression::new_array_expression(span, elements, builder)) } _ => None, } @@ -1907,18 +1963,74 @@ impl<'a> Inline<'_, 'a> { impl<'a> VisitMut<'a> for Inline<'_, 'a> { fn visit_expression(&mut self, expression: &mut Expression<'a>) { if self.styles { - if let Some(literal) = self + let structural = match &*expression { + Expression::ObjectExpression(object) => { + object + .properties + .iter() + .fold(Naming::Own, |naming, property| { + naming.join(match property { + ObjectPropertyKind::SpreadProperty(spread) => { + self.naming_of(&spread.argument) + } + ObjectPropertyKind::ObjectProperty(property) + if property.computed => + { + property + .key + .as_expression() + .map_or(Naming::Own, |key| self.naming_of(key)) + } + ObjectPropertyKind::ObjectProperty(_) => Naming::Own, + }) + }) + } + Expression::ArrayExpression(array) => { + array.elements.iter().fold(Naming::Own, |naming, element| { + naming.join(match element { + ArrayExpressionElement::SpreadElement(spread) => { + self.naming_of(&spread.argument) + } + _ => Naming::Own, + }) + }) + } + _ => Naming::Own, + }; + mark(expression, structural); + if let Some(mut literal) = self .constant(expression) - .and_then(|constant| self.literal(&constant)) + .and_then(|constant| self.literal(&constant, expression.span())) { + mark(&mut literal, self.naming_of(expression)); *expression = literal; return; } - if let Some(chosen) = self.chosen(expression) { + let owner_start = expression.span().start; + if let Some((mut chosen, naming)) = self.chosen(expression) { + crate::sparse_sites::retain_folded_owner(chosen.span().start, owner_start); + crate::sparse_sites::retain_folded_owner( + crate::utils::unwrap_syntax_only(&chosen).span().start, + owner_start, + ); + let outer = self.ambient; + self.ambient = outer.join(naming); + mark(&mut chosen, self.ambient); *expression = chosen; self.visit_expression(expression); + self.ambient = outer; return; } + if let Expression::Identifier(identifier) = &*expression + && let Some(naming) = identifier + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + .and_then(|symbol| self.namings.get(&symbol)) + .copied() + { + mark(expression, naming); + } } walk_mut::walk_expression(self, expression); } diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index ec87d82b4..1a5abb257 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,7 +1,31 @@ mod as_visit; +#[cfg(test)] +mod assignment_blocker_support; +#[cfg(test)] +mod assignment_blocker_tests; +mod assignment_capture; +mod assignment_composition; +mod assignment_consumers; +mod assignment_lowering; +#[cfg(test)] +mod assignment_lowering_tests; +mod assignment_member; +mod assignment_object; +mod assignment_owner; +#[cfg(test)] +mod assignment_residual_tests; +#[cfg(test)] +mod assignment_test_support; +mod assignment_value; mod build_time_values; +mod class_evaluation; mod component; mod css_utils; +mod element_evaluation; +mod evaluation_location; +#[cfg(test)] +mod evaluation_location_tests; +mod evaluation_origin; pub mod extract_style; mod extractor; mod gen_class_name; @@ -10,8 +34,21 @@ mod import_alias_visit; mod imported_constants; mod module_loader; mod mutations; +#[cfg(test)] +mod named_capture_order_tests; +#[cfg(test)] +mod named_capture_support; +#[cfg(test)] +mod named_capture_tests; mod prop_modify_utils; +mod provenance; mod source_map; +mod source_naming; +mod sparse_sites; +mod style_export_locations; +mod style_origin; +#[cfg(test)] +mod style_origin_tests; mod style_values; mod stylex; mod tailwind; @@ -50,6 +87,14 @@ pub enum ImportAlias { pub enum ExtractStyleProp<'a> { Static(ExtractStyleValue), StaticArray(Vec>), + Evaluated { + styles: Vec>, + source: Expression<'a>, + binding: String, + evaluation: Option>, + alternate_order: Option, + alternate_class: bool, + }, Conditional { condition: Expression<'a>, consequent: Option>>, @@ -84,6 +129,21 @@ impl<'a> ExtractStyleProp<'a> { ExtractStyleProp::StaticArray(arr) => { ExtractStyleProp::StaticArray(arr.iter().map(|s| s.clone_in(alloc)).collect()) } + ExtractStyleProp::Evaluated { + styles, + source, + binding, + evaluation, + alternate_order, + alternate_class, + } => Self::Evaluated { + styles: styles.iter().map(|style| style.clone_in(alloc)).collect(), + source: source.clone_in(alloc), + binding: binding.clone(), + evaluation: evaluation.as_ref().map(|value| value.clone_in(alloc)), + alternate_order: *alternate_order, + alternate_class: *alternate_class, + }, ExtractStyleProp::Conditional { condition, consequent, @@ -143,7 +203,8 @@ impl<'a> ExtractStyleProp<'a> { } (None, None) => vec![], }, - ExtractStyleProp::StaticArray(array) => { + ExtractStyleProp::StaticArray(array) + | ExtractStyleProp::Evaluated { styles: array, .. } => { array.iter().flat_map(ExtractStyleProp::extract).collect() } ExtractStyleProp::Expression { styles, .. } => styles.clone(), @@ -183,7 +244,8 @@ impl<'a> ExtractStyleProp<'a> { } (None, None) => vec![], }, - ExtractStyleProp::StaticArray(array) => array + ExtractStyleProp::StaticArray(array) + | ExtractStyleProp::Evaluated { styles: array, .. } => array .into_iter() .flat_map(ExtractStyleProp::into_extract) .collect(), @@ -281,15 +343,25 @@ fn extract_with_source_map( source_map: bool, resolver: Option<&ModuleResolver>, ) -> Result> { - extract_source(filename, code, None, option, source_map, resolver) + let source = provenance::normalize_source(code); + extract_source(filename, &source, None, option, source_map, resolver) } -/// `evaluated` is the source `code` was computed from, with the layers of -/// edits, last made first, that map `code` back to it +/// The source `code` was computed from, with the layers of edits, last made +/// first, that map `code` back to it, and where values that depend on an +/// import that may differ between environments were written +#[derive(Clone, Copy)] +struct Evaluated<'a> { + source: &'a str, + edits: &'a [&'a [import_alias_visit::Edit]], + risky: &'a [(usize, usize)], +} + +/// `evaluated` is what `code` was computed from, when it was fn extract_source( filename: &str, code: &str, - evaluated: Option<(&str, &[&[import_alias_visit::Edit]])>, + evaluated: Option>, option: ExtractOption, source_map: bool, resolver: Option<&ModuleResolver>, @@ -320,8 +392,15 @@ fn extract_source( }); } + let stylesheet_naming = if utils::is_vanilla_extract_file(filename) { + source_naming::stylesheet(code, filename, &option) + } else { + css::Naming::Own + }; let mut dependencies = std::collections::BTreeSet::new(); let mut evaluation_error = None; + let _evaluation_origins = utils::is_vanilla_extract_file(filename) + .then(|| style_origin::EvaluationScope::enter(filename, code, &alias_edits)); // Step 3: Handle vanilla-extract style files (.css.ts, .css.js) // `processed_code` is Some only when vanilla-extract generation succeeded; // otherwise the untouched `transformed_code` is parsed directly (no copy). @@ -345,6 +424,7 @@ fn extract_source( ), &option, &referenced, + stylesheet_naming, )? }; let code = vanilla_extract::collected_styles_to_code_with_keyframes( @@ -418,6 +498,24 @@ fn extract_source( if fatal_error { return Err("Parser panicked".into()); } + let (source, earlier_edits) = evaluated.map_or((code, &[][..]), |evaluated| { + (evaluated.source, evaluated.edits) + }); + let edits: Vec<&[import_alias_visit::Edit]> = std::iter::once(alias_edits.as_slice()) + .chain(earlier_edits.iter().copied()) + .collect(); + let _origins = if processed_code.is_some() { + style_origin::OriginScope::generated(&mut program) + } else { + style_origin::OriginScope::enter((filename, source), &edits, &program) + }; + if let Some(evaluated) = evaluated { + provenance::MarkRanges(evaluated.risky).visit_program(&mut program); + } + if processed_code.is_some() && stylesheet_naming == css::Naming::Risky { + provenance::MarkRanges(&[(0, code_to_parse.len())]).visit_program(&mut program); + } + let _sites = provenance::SiteScope::enter(filename, source, &edits); let inlined = if processed_code.is_none() { imported_constants::inline_constants( &oxc_ast::builder::AstBuilder::new(&allocator), @@ -440,6 +538,7 @@ fn extract_source( visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes); visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed.clone()); + let attempt = css::class_map::Attempt::begin(); visitor.visit_program(&mut program); if let Some(error) = evaluation_error && imports_uncompiled(&program, &option.package) @@ -451,7 +550,7 @@ fn extract_source( if (!visitor.errors.is_empty() || visitor.composes_unknown) && evaluated.is_none() && !utils::is_vanilla_extract_file(filename) - && let Some((computed, value_edits, read)) = build_time_values::evaluate( + && let Some((computed, value_edits, read, risky)) = build_time_values::evaluate( &transformed_code, filename, &option, @@ -459,10 +558,15 @@ fn extract_source( &inlined.unknown, ) { + drop(attempt); let mut output = extract_source( filename, &computed, - Some((code, &[value_edits.as_slice(), alias_edits.as_slice()])), + Some(Evaluated { + source: code, + edits: &[value_edits.as_slice(), alias_edits.as_slice()], + risky: &risky, + }), option, source_map, resolver, @@ -473,10 +577,7 @@ fn extract_source( output.dependencies = files.into_iter().collect(); return Ok(output); } - let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); - let edits: Vec<&[import_alias_visit::Edit]> = std::iter::once(alias_edits.as_slice()) - .chain(earlier_edits.iter().copied()) - .collect(); + visitor.errors.extend(provenance::site_errors()); visitor.errors.append(&mut visitor.unknown_parts); if !visitor.errors.is_empty() { let mut message = located_errors(filename, source, &edits, visitor.errors); @@ -492,9 +593,15 @@ fn extract_source( CodegenOptions::default() }; let result = Codegen::new().with_options(codegen_options).build(&program); - // A stylesheet's output is generated, so its map stays on that code let map = result.map.map(|map| { - if processed_code.is_some() || edits.iter().all(|edits| edits.is_empty()) { + if processed_code.is_some() { + source_map::remap( + map, + code_to_parse, + source, + &[&[(0, source.len(), code_to_parse.len())]], + ) + } else if edits.iter().all(|edits| edits.is_empty()) { map } else { source_map::remap(map, code_to_parse, source, &edits) @@ -502,6 +609,7 @@ fn extract_source( .to_json_string() }); + attempt.commit(); Ok(ExtractOutput { styles: visitor.styles, code: result.code, @@ -553,6 +661,9 @@ fn located_errors( edits: &[&[import_alias_visit::Edit]], mut errors: Vec<(u32, String)>, ) -> String { + for (offset, _) in &mut errors { + *offset = provenance::source_offset(*offset); + } errors.sort_unstable(); errors.dedup(); errors @@ -641,6 +752,7 @@ fn extract_class_map_from_code( partial_code: &str, option: &ExtractOption, style_names: &FxHashSet, + naming: css::Naming, ) -> Result, Box> { let source_type = SourceType::from_path(filename)?; let (bucket, global, css_file) = resolve_css_target(filename, option); @@ -655,6 +767,10 @@ fn extract_class_map_from_code( if fatal_error { Ok(FxHashMap::default()) } else { + let _origins = style_origin::OriginScope::generated(&mut program); + if naming == css::Naming::Risky { + provenance::MarkRanges(&[(0, partial_code.len())]).visit_program(&mut program); + } let mut visitor = DevupVisitor::new( &allocator, filename, @@ -14380,6 +14496,7 @@ export const card = style({ import_aliases: HashMap::new(), }, &style_names, + css::Naming::Own, ) .unwrap(); @@ -15384,9 +15501,7 @@ globalStyle('body', { margin: 2 }) fn test_stylex_numbers_follow_stylex_units() { reset_class_map(); reset_file_map(); - let output = extract( - "test.tsx", - r"import stylex from '@stylexjs/stylex'; + let source = r"import stylex from '@stylexjs/stylex'; const styles = stylex.create({ base: { fontSize: 16, @@ -15402,10 +15517,8 @@ const styles = stylex.create({ }, dynamic: (opacity, delay) => ({ opacity, transitionDelay: delay, bottom: 3 }), }); -const result = stylex.props(styles.dynamic(o, d));", - ExtractOption::default(), - ) - .unwrap(); +const result = stylex.props(styles.dynamic(o, d));"; + let output = extract("test.tsx", source, ExtractOption::default()).unwrap(); let values = static_values(&output); for (property, value) in [ ("font-size", "16px"), @@ -15426,14 +15539,37 @@ const result = stylex.props(styles.dynamic(o, d));", "{property}: {value} not in {values:?}" ); } - assert!(output.code.contains(r#""--a": o"#), "{}", output.code); - assert!( + let opacity_site = css::Site { + file: css::sparse_site::SourceFile::Unnumbered(source.into()), + at: source.find("opacity, transitionDelay").unwrap(), + role: 0, + }; + let delay_site = css::Site { + file: css::sparse_site::SourceFile::Unnumbered(source.into()), + at: source.find("transitionDelay: delay").unwrap() + "transitionDelay: ".len(), + role: 0, + }; + assert_eq!( output - .code - .contains(r#"((v) => typeof v === "number" ? v + "ms" : v)(d)"#), - "{}", - output.code + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style.site.clone()), + _ => None, + }) + .collect::>(), + std::collections::HashSet::from([Some(opacity_site.clone()), Some(delay_site.clone())]) ); + for (site, value) in [ + (opacity_site, "o"), + ( + delay_site, + r#"((v) => typeof v === "number" ? v + "ms" : v)(d)"#, + ), + ] { + let assignment = format!(r#""{}": {value}"#, site.variable_name("")); + assert!(output.code.contains(&assignment), "{}", output.code); + } } #[test] diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index f64390a8f..d186855ec 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -14,6 +14,7 @@ use oxc_span::{GetSpan, SourceType}; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; +use crate::evaluation_location::{CopiedSource, append_mapped}; use crate::{ExtractOption, ModuleResolver, utils::is_vanilla_extract_file}; /// The object the package's API is bound to while a stylesheet runs @@ -200,7 +201,12 @@ impl<'r> ModuleLoader<'r> { } else { module.code }; - let script = crate::vanilla_extract::strip_typescript(&code, &module.path); + let instrumented = (!stylesheet && loading_for_stylesheet()) + .then(|| crate::evaluation_origin::instrument(&code, &module.path, self.option)); + let script = crate::vanilla_extract::strip_typescript( + instrumented.as_deref().unwrap_or(&code), + &module.path, + ); let module_script = module_script(&script, &module.path, self, false)?; // Live bindings: a read before the binding is initialized fails as it // does in an ES module @@ -242,7 +248,8 @@ impl<'r> ModuleLoader<'r> { pub(crate) struct ModuleScript { pub body: String, /// `(exported name, expression)` - exports: Vec<(String, String)>, + pub(crate) exports: Vec<(String, String)>, + pub(crate) copies: Vec, /// Modules re-exported whole spreads: Vec, /// Written with `module.exports` rather than `export` @@ -356,19 +363,31 @@ pub(crate) fn module_script( replacements.sort_by_key(|(start, ..)| *start); let text = |span: oxc_span::Span| { let mut code = String::new(); + let mut copies = Vec::new(); let mut copied = span.start; for (start, end, replacement) in &replacements { if *start >= span.start && *end <= span.end { + let length = usize::try_from(*start - copied).unwrap_or_default(); + copies.push(CopiedSource { + generated: code.len()..code.len() + length, + original: usize::try_from(copied).unwrap_or_default(), + }); code.push_str(&script[copied as usize..*start as usize]); code.push_str(replacement); copied = *end; } } + let length = usize::try_from(span.end - copied).unwrap_or_default(); + copies.push(CopiedSource { + generated: code.len()..code.len() + length, + original: usize::try_from(copied).unwrap_or_default(), + }); code.push_str(&script[copied as usize..span.end as usize]); - code + (code, copies) }; let mut body = String::with_capacity(script.len()); + let mut copies = Vec::new(); let mut exports = Vec::new(); let mut spreads = Vec::new(); for statement in &program.body { @@ -399,7 +418,7 @@ pub(crate) fn module_script( } } Statement::ExportDeclaration(export) => { - body.push_str(&text(export.declaration.span())); + append_mapped(&mut body, &mut copies, text(export.declaration.span())); body.push('\n'); for name in declared_names(&export.declaration) { exports.push((name.clone(), name)); @@ -433,10 +452,12 @@ pub(crate) fn module_script( }; let declaration = text(export.declaration.span()); let local = if let Some(id) = id { - body.push_str(&declaration); + append_mapped(&mut body, &mut copies, declaration); id.name.to_string() } else { - let _ = write!(body, "const __default__ = ({declaration});"); + body.push_str("const __default__ = ("); + append_mapped(&mut body, &mut copies, declaration); + body.push_str(");"); "__default__".to_string() }; body.push('\n'); @@ -450,7 +471,7 @@ pub(crate) fn module_script( } } statement => { - body.push_str(&text(statement.span())); + append_mapped(&mut body, &mut copies, text(statement.span())); body.push('\n'); } } @@ -458,6 +479,7 @@ pub(crate) fn module_script( Ok(ModuleScript { body, exports, + copies, spreads, commonjs, }) diff --git a/libs/extractor/src/named_capture_order_tests.rs b/libs/extractor/src/named_capture_order_tests.rs new file mode 100644 index 000000000..4083bd256 --- /dev/null +++ b/libs/extractor/src/named_capture_order_tests.rs @@ -0,0 +1,148 @@ +use crate::{ + ExtractStyleValue, + assignment_test_support::{extracted, jsx_js}, + extract_style::style_property::StyleProperty, + named_capture_support::evaluate, +}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case(("`p-4 ${state.opaque('default')}`", "1rem", true))] +#[case(("state.flag?`p-4 ${state.opaque('default')}`:`p-8 ${state.opaque('default')}`", "1rem", true))] +#[case(("state.flag?`p-4 ${state.other()}`:`p-8 ${state.opaque('default')}`", "2rem", false))] +#[case(("state.left||`p-4 ${state.opaque('default')}`", "1rem", true))] +#[case(("state.left??`p-4 ${state.opaque('default')}`", "1rem", true))] +#[case(("state.flag&&`p-4 ${state.opaque('default')}`", "1rem", true))] +#[serial] +fn prepared_tailwind_variants_share_source_evaluation_when_order_is_conditional( + #[case] fixture: (&str, &str, bool), + #[values(false, true)] caller: bool, + #[values((false, false), (false, true), (true, false), (true, true))] order: (bool, bool), +) { + // Given: actual supported Tailwind surrounds an opaque call; order may be later. + let (class, value, flag) = fixture; + let (order_first, order_flag) = order; + let order = "state.order?5:10"; + let attrs = match (caller, order_first) { + (false, false) => format!( + "className={{{class}}} typography={{state.typo}} styleOrder={{{order}}} color={{state.color}}" + ), + (false, true) => format!( + "styleOrder={{{order}}} className={{{class}}} typography={{state.typo}} color={{state.color}}" + ), + (true, false) => { + format!("className:{class},typography:state.typo,styleOrder:{order},color:state.color") + } + (true, true) => { + format!("styleOrder:{order},className:{class},typography:state.typo,color:state.color") + } + }; + let expression = if caller { + format!("jsx(Box,{{{attrs}}})") + } else { + format!("") + }; + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[];const state={{get flag(){{trace.push('flag');return {flag}}},get left(){{trace.push('left');return null}},get order(){{trace.push('order');return {order_flag}}},opaque(arg){{if(this!==state)throw Error('receiver');trace.push('opaque:'+arg);return {{toString(){{trace.push('coerce');return 'external'}}}}}},other(){{throw Error('unselected')}},get typo(){{trace.push('typo');return 'heading'}},get color(){{trace.push('color');return 'red'}}}};const jsx=(tag,props)=>props;const node=render(state);" + ); + let expected_trace = evaluate(&format!( + "const Box='div';{}", + jsx_js(&format!("{source}JSON.stringify(trace);")) + )); + let output = extracted(&source); + let records = output + .styles + .iter() + .filter_map(|style| match (style, style.extract(None)) { + (ExtractStyleValue::Static(style), Some(StyleProperty::ClassName(class))) + if style.property == "padding" && style.value == value => + { + Some((class, style.style_order)) + } + _ => None, + }) + .collect::>(); + let records = serde_json::to_string(&records).unwrap_or_else(|error| panic!("{error}")); + // When: the generated class selects one of both compile-time prepared orders. + let actual = evaluate(&format!( + "{} const records={records};JSON.stringify([trace,String(node.className).split(/\\s+/).flatMap(name=>records.filter(([key])=>key===name).map(([,order])=>order)),node.className.includes('external')]);", + jsx_js(&output.code) + )); + // Then: actual declaration order matches the captured later operand, no early read. + let selected_order = if order_flag { 5 } else { 10 }; + assert_eq!( + actual, + format!("[{expected_trace},[{selected_order}],true]"), + "{}", + output.code + ); +} + +#[rstest] +#[case("state.flag?state.opaque('default'):'p-8'", true, "null")] +#[case("state.flag?state.opaque('default'):'p-8'", false, "null")] +#[case("state.left&&`p-4 ${state.opaque('default')}`", true, "0")] +#[case("state.left||`p-4 ${state.opaque('default')}`", true, "'external'")] +#[case("state.left??`p-4 ${state.opaque('default')}`", true, "false")] +#[case( + "`p-4 ${state.left||state.opaque('default')} ${state.last()}`", + true, + "({toString(){trace.push('left-coerce');return 'external'}})" +)] +#[case( + "`p-4 ${state.left&&state.opaque('default')} ${state.last()}`", + true, + "({toString(){throw Error('unselected coercion')}})" +)] +#[case( + "`p-4 ${state.left} ${state.last()}`", + true, + "({toString(){trace.push('coerce-throw');throw Error('stop')}})" +)] +#[serial] +fn class_controls_remain_lazy_when_raw_truthiness_and_coercion_differ( + #[case] class: &str, + #[case] flag: bool, + #[case] left: &str, +) { + // Given: raw left values distinguish truthiness, nullishness and string conversion. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return }}let trace=[];const state={{get flag(){{trace.push('flag');return {flag}}},get left(){{trace.push('left');return {left}}},opaque(arg){{trace.push('opaque:'+arg);return {{toString(){{trace.push('coerce');return 'external'}}}}}},last(){{trace.push('last');return 'last'}},get order(){{trace.push('order');return false}},get color(){{trace.push('color');return 'red'}}}};let node,error;try{{node=render(state);String(node.className)}}catch(e){{error=e.message}}JSON.stringify([trace,error??null]);" + ); + // When: the source and compiled supported grammar execute with observable coercion. + let expected = evaluate(&format!("const Box='div';{}", jsx_js(&source))); + let actual = evaluate(&crate::assignment_test_support::compiled_jsx(&source)); + // Then: only selected branches coerce, once, before subsequent interpolation/order. + assert_eq!(actual, expected); +} + +#[rstest] +#[serial] +fn class_yield_stays_in_lazy_branch_when_generator_resumes(#[values(false, true)] flag: bool) { + // Given: a later order cannot be used inside the class argument, and yield is lazy. + let source = format!( + "import {{Box}}from '@devup-ui/react';function* render(state){{return }}let trace=[];const state={{get flag(){{trace.push('flag');return {flag}}},get value(){{trace.push('value');return 'pause'}},get order(){{trace.push('order');return false}},get color(){{trace.push('color');return 'red'}}}};const generator=render(state);const first=generator.next();const last=generator.next('external');JSON.stringify([trace,first.done,first.done?null:first.value,last.done]);" + ); + // When: native generator execution resumes a captured interpolation. + let expected = evaluate(&format!("const Box='div';{}", jsx_js(&source))); + let actual = evaluate(&crate::assignment_test_support::compiled_jsx(&source)); + // Then: yield remains in its original scope, before the later order read. + assert_eq!(actual, expected); +} + +#[rstest] +#[serial] +fn class_await_stays_lazy_when_a_later_order_operand_is_captured( + #[values(false, true)] flag: bool, +) { + // Given: a selected await and coercion precede a later interpolation and order. + let source = format!( + "import {{Box}}from '@devup-ui/react';async function render(state){{return }}let trace=[];const state={{get flag(){{trace.push('flag');return {flag}}},get value(){{trace.push('value');return Promise.resolve({{toString(){{trace.push('coerce');return 'external'}}}})}},last(){{trace.push('last');return 'last'}},get order(){{trace.push('order');return false}},get color(){{trace.push('color');return 'red'}}}};render(state).then(node=>JSON.stringify(trace),error=>JSON.stringify(['error',String(error)]));" + ); + // When: native promises resume the original lazy branch. + let expected = evaluate(&format!("const Box='div';{}", jsx_js(&source))); + let actual = evaluate(&crate::assignment_test_support::compiled_jsx(&source)); + // Then: no await moves into a new synchronous arrow body or crosses the later order. + assert_eq!(actual, expected); +} diff --git a/libs/extractor/src/named_capture_support.rs b/libs/extractor/src/named_capture_support.rs new file mode 100644 index 000000000..8fb0b0f76 --- /dev/null +++ b/libs/extractor/src/named_capture_support.rs @@ -0,0 +1,16 @@ +pub(super) fn evaluate(source: &str) -> String { + let source = serde_json::to_string(source).unwrap_or_else(|error| panic!("{error}")); + let script = format!( + "Promise.resolve(eval({source})).then(value=>process.stdout.write(String(value)),error=>{{console.error(error);process.exitCode=1}})" + ); + let output = std::process::Command::new("node") + .args(["-e", &script]) + .output() + .unwrap_or_else(|error| panic!("native Node observer: {error}")); + assert!( + output.status.success(), + "{}\n{script}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap_or_else(|error| panic!("{error}")) +} diff --git a/libs/extractor/src/named_capture_tests.rs b/libs/extractor/src/named_capture_tests.rs new file mode 100644 index 000000000..fc957f7df --- /dev/null +++ b/libs/extractor/src/named_capture_tests.rs @@ -0,0 +1,97 @@ +use crate::assignment_test_support::{compiled_jsx, jsx_js}; +use crate::named_capture_support::evaluate; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case( + "" +)] +#[case( + "jsx(Box,{boxSizing:'border-box',className:clsx(variants[variant],isError&&variant==='default'&&errorClassNames,className),typography:state.typo,...state.rest})" +)] +#[case( + "" +)] +#[case( + "jsx(Box,{className:state.combine('default',state.argument),typography:state.typo,...state.rest})" +)] +#[serial] +fn opaque_call_runs_at_authored_position_when_typography_and_spread_follow( + #[case] expression: &str, + #[values(false, true)] throws: bool, +) { + // Given: the rejected clsx shape and a receiver-sensitive opaque call. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state,clsx,variants,variant,isError,errorClassNames,className){{return {expression}}}let trace=[];const jsx=(tag,props)=>props;const variants={{get default(){{trace.push('variant');return 'p-4'}}}};let state={{get argument(){{trace.push('argument');return 'arg'}},combine(a,b){{if(this!==state)throw Error('receiver');trace.push('call:'+a+':'+b);if({throws})throw Error('stop');return 'external'}},get typo(){{trace.push('typo');return 'heading'}},get rest(){{trace.push('rest');return {{id:1}}}}}};const clsx=(...args)=>{{trace.push('clsx:'+args.join('|'));if({throws})throw Error('stop');return 'external'}};let node,error;try{{node=render(state,clsx,variants,'default',true,'error','custom')}}catch(e){{error=e.message}}JSON.stringify([trace,error??null,node?.className?.includes('external')??false]);" + ); + // When: source and compiled element expressions execute natively. + let expected = evaluate(&format!("const Box='div';{}", jsx_js(&source))); + let actual = evaluate(&compiled_jsx(&source)); + // Then: one intact call runs before later reads, or cuts them off on throw. + assert_eq!(actual, expected); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{typography})")] +#[case("{state.child}")] +#[case("jsx(Box,{title:state.before,typography,color:state.color,children:state.child})")] +#[serial] +fn raw_typography_is_read_once_when_global_getter_throws_on_second_read( + #[case] expression: &str, + #[values("'heading'", "''", "false", "0", "null")] value: &str, +) { + // Given: even a solitary identifier is an observable authored operand. + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(state){{return {expression}}}let trace=[],reads=0;Object.defineProperty(globalThis,'typography',{{get(){{trace.push('typo');if(++reads>1)throw Error('second');return {value}}}}});const state={{get before(){{trace.push('before');return 'title'}},get color(){{trace.push('color');return 'red'}},get child(){{trace.push('child');return 'child'}}}};const jsx=(tag,props)=>props;let node,error;try{{node=render(state)}}catch(e){{error=e.message}}" + ); + let expected = evaluate(&format!( + "const Box='div';{}", + jsx_js(&format!("{source}JSON.stringify([trace,error??null]);")) + )); + // When: the generated truthiness/interpolation executes. + let compiled = compiled_jsx(&source); + let actual = evaluate(&format!("{compiled}JSON.stringify([trace,error??null]);")); + // Then: the raw read is singular and siblings keep source order. + assert_eq!(actual, expected, "{compiled}"); + let classes = evaluate(&format!( + "{compiled}JSON.stringify(String(node?.className??'').split(/\\s+/).filter(v=>v.startsWith('typo-')));" + )); + assert_eq!( + classes, + if value == "'heading'" { + "[\"typo-heading\"]" + } else { + "[]" + } + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn namespace_value_keeps_its_position_when_later_values_are_captured(#[case] throws: bool) { + // Given: xlink:href precedes handler/title/styles and children. + let source = format!( + "import {{Box}}from '@devup-ui/react';function render(state){{return {{state.child}}}}let trace=[];const state={{get href(){{trace.push('href');if({throws})throw Error('stop');return '#icon'}},get handler(){{trace.push('handler');return 1}},get title(){{trace.push('title');return 'title'}},get color(){{trace.push('color');return 'red'}},get child(){{trace.push('child');return 'child'}}}};let node,error;try{{node=render(state)}}catch(e){{error=e.message}}JSON.stringify([trace,error??null,node?.['xlink:href']??null]);" + ); + // When: quoted namespace keys are observed without changing their values. + let expected = evaluate(&format!("const Box='div';{}", jsx_js(&source))); + let actual = evaluate(&compiled_jsx(&source)); + // Then: an early namespace throw prevents every subsequent operand. + assert_eq!(actual, expected); +} + +#[test] +#[serial] +fn namespace_read_precedes_await_when_async_element_resumes() { + // Given: the namespace read precedes a later awaited style and child. + let source = "import {Box}from '@devup-ui/react';async function render(state){return {await state.child}}let trace=[];const state={get href(){trace.push('href');return '#icon'},get handler(){trace.push('handler');return 1},get title(){trace.push('title');return 'title'},get color(){trace.push('color');return Promise.resolve('red')},get child(){trace.push('child');return Promise.resolve('child')}};render(state).then(node=>JSON.stringify([trace,node['xlink:href'],node.children]),error=>JSON.stringify(['error',String(error)]));"; + // When: native promise jobs resume both source and emitted expressions. + let expected = evaluate(&format!("const Box='div';{}", jsx_js(source))); + let actual = evaluate(&compiled_jsx(source)); + // Then: namespace timing survives suspension at the actual invocation argument. + assert_eq!(actual, expected); +} diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index 7076a2e68..655ebd7e7 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -65,23 +65,42 @@ fn resolve_class_name_expression<'a>( spread_props: &[Expression<'a>], filename: Option<&str>, conditional_branch: Option<(Expression<'a>, &mut [ExtractStyleProp<'a>], Option)>, -) -> (Option>, Vec) { +) -> ( + Option>, + Vec, + Option<(String, Expression<'a>)>, +) { + let (mut tailwind, primary) = + extract_tailwind_from_class_name(ast_builder, class_name_prop, style_order, filename); + let primary = primary.or_else(|| class_name_prop.clone_in(ast_builder.allocator())); if let Some((condition, alt_styles, alt_style_order)) = conditional_branch { - // Conditional styleOrder: generate className for both branches - let (con_expr, con_tailwind) = get_class_name_expression( + crate::assignment_lowering::orders(styles, alt_style_order, false); + crate::assignment_lowering::orders(alt_styles, alt_style_order, true); + let (alternate_tailwind, alternate) = extract_tailwind_from_class_name( ast_builder, class_name_prop, + alt_style_order, + filename, + ); + tailwind.extend(alternate_tailwind); + let mut prepared = [ + primary, + alternate.or_else(|| class_name_prop.clone_in(ast_builder.allocator())), + ]; + let capture = class_name_prop.as_ref().and_then(|source| { + crate::class_evaluation::capture(ast_builder, source, &mut prepared) + }); + let (con_expr, _) = get_class_name_expression( + ast_builder, + &prepared[0], styles, style_order, spread_props, filename, ); - let alt_class_name_prop = class_name_prop - .as_ref() - .map(|c| c.clone_in(ast_builder.allocator())); - let (alt_expr, alt_tailwind) = get_class_name_expression( + let (alt_expr, _) = get_class_name_expression( ast_builder, - &alt_class_name_prop, + &prepared[1], alt_styles, alt_style_order, spread_props, @@ -91,18 +110,21 @@ fn resolve_class_name_expression<'a>( let combined_expr = combine_conditional_class_name(ast_builder, condition, con_expr, alt_expr); - let mut all_tailwind = con_tailwind; - all_tailwind.extend(alt_tailwind); - (combined_expr, all_tailwind) + (combined_expr, tailwind, capture) } else { - get_class_name_expression( + let mut prepared = [primary]; + let capture = class_name_prop.as_ref().and_then(|source| { + crate::class_evaluation::capture(ast_builder, source, &mut prepared) + }); + let (expression, _) = get_class_name_expression( ast_builder, - class_name_prop, + &prepared[0], styles, style_order, spread_props, filename, - ) + ); + (expression, tailwind, capture) } } @@ -120,7 +142,7 @@ pub fn modify_prop_object<'a>( props_prop: Option>, filename: Option<&str>, conditional_branch: Option<(Expression<'a>, &mut [ExtractStyleProp<'a>], Option)>, -) -> Vec { +) -> (Vec, Option<(String, Expression<'a>)>) { let mut class_name_prop = None; let mut style_prop = None; let mut spread_props = vec![]; @@ -147,7 +169,7 @@ pub fn modify_prop_object<'a>( } } - let (class_name_expr, tailwind_styles) = resolve_class_name_expression( + let (class_name_expr, tailwind_styles, capture) = resolve_class_name_expression( ast_builder, &class_name_prop, styles, @@ -195,7 +217,7 @@ pub fn modify_prop_object<'a>( ast_builder, )); } - tailwind_styles + (tailwind_styles, capture) } /// modify JSX props /// Returns extracted Tailwind styles from static className strings @@ -211,7 +233,7 @@ pub fn modify_props<'a>( props_prop: Option>, filename: Option<&str>, conditional_branch: Option<(Expression<'a>, &mut [ExtractStyleProp<'a>], Option)>, -) -> Vec { +) -> (Vec, Option<(String, Expression<'a>)>) { let mut class_name_prop = None; let mut style_prop = None; let mut spread_props = vec![]; @@ -254,7 +276,7 @@ pub fn modify_props<'a>( } } } - let (class_name_expr, tailwind_styles) = resolve_class_name_expression( + let (class_name_expr, tailwind_styles, capture) = resolve_class_name_expression( ast_builder, &class_name_prop, styles, @@ -301,7 +323,7 @@ pub fn modify_props<'a>( ast_builder, )); } - tailwind_styles + (tailwind_styles, capture) } /// Returns (className expression, extracted Tailwind styles) diff --git a/libs/extractor/src/provenance.rs b/libs/extractor/src/provenance.rs new file mode 100644 index 000000000..8c8a002bc --- /dev/null +++ b/libs/extractor/src/provenance.rs @@ -0,0 +1,140 @@ +//! Where the values of styles come from, as far as production names care: +//! whether a style exists only because of what an import may resolve to, and +//! where in its original file a dynamic style was written. +//! +//! A risky expression is marked in its span, which keeps the position it was +//! written at: the mark is a high bit of the start and the span is empty, so +//! code generation never maps it. + +use css::Naming; +use oxc_ast::ast::Expression; +use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; +use oxc_span::{GetSpan, GetSpanMut}; + +use crate::{ExtractStyleProp, utils::unwrap_syntax_only}; + +pub(crate) use crate::sparse_sites::{SiteScope, normalize_source, site_at, site_errors}; + +const RISKY: u32 = 1 << 31; + +/// A position in the source, whatever mark the span carries. +pub(crate) const fn source_offset(start: u32) -> u32 { + start & !RISKY +} + +pub(crate) const fn naming_of_start(start: u32) -> Naming { + if start & RISKY == 0 { + Naming::Own + } else { + Naming::Risky + } +} + +const fn naming_of_span(span: oxc_span::Span) -> Naming { + naming_of_start(span.start) +} + +/// Mark `expression` as depending on `naming`. +pub(crate) fn mark(expression: &mut Expression<'_>, naming: Naming) { + if naming == Naming::Risky { + let span = expression.span_mut(); + span.start |= RISKY; + span.end = span.start; + } +} + +struct Marks(Naming); + +impl<'a> Visit<'a> for Marks { + fn visit_expression(&mut self, expression: &Expression<'a>) { + self.0 = self.0.join(naming_of_span(expression.span())); + walk::walk_expression(self, expression); + } +} + +/// The identity of every mark within `expression`. +fn scan(expression: &Expression<'_>) -> Naming { + let mut marks = Marks(Naming::Own); + marks.visit_expression(expression); + marks.0 +} + +/// What the styles read from `expression` depend on. An object or array +/// leaves that to its members, a condition to the value it tests too, as +/// that may pick the branches; anything else is one value. +pub(crate) fn provenance_of(expression: &Expression<'_>) -> Naming { + let inner = unwrap_syntax_only(expression); + let wrapper = naming_of_span(expression.span()).join(naming_of_span(inner.span())); + match inner { + Expression::ObjectExpression(_) | Expression::ArrayExpression(_) => wrapper, + Expression::ConditionalExpression(conditional) => wrapper.join(scan(&conditional.test)), + Expression::LogicalExpression(logical) => wrapper.join(scan(&logical.left)), + _ => wrapper.join(scan(inner)), + } +} + +/// Join `naming` into every style of `props`. +pub(crate) fn join_naming(props: &mut [ExtractStyleProp<'_>], naming: Naming) { + for prop in props { + match prop { + ExtractStyleProp::Static(style) => style.join_naming(naming), + ExtractStyleProp::StaticArray(props) + | ExtractStyleProp::Evaluated { styles: props, .. } => join_naming(props, naming), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for branch in [consequent, alternate].into_iter().flatten() { + join_naming(std::slice::from_mut(branch.as_mut()), naming); + } + } + ExtractStyleProp::Enum { map, .. } => { + for props in map.values_mut() { + join_naming(props, naming); + } + } + ExtractStyleProp::MemberExpression { map, .. } => { + for prop in map.values_mut() { + join_naming(std::slice::from_mut(prop.as_mut()), naming); + } + } + ExtractStyleProp::Expression { styles, .. } => { + for style in styles { + style.join_naming(naming); + } + } + ExtractStyleProp::Unreadable { .. } => {} + } + } +} + +/// Marks the expressions that lie within `ranges`, which a build-time +/// evaluation wrote in place of values that depend on an import. +pub(crate) struct MarkRanges<'r>(pub &'r [(usize, usize)]); + +impl<'a> VisitMut<'a> for MarkRanges<'_> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + let start = source_offset(expression.span().start) as usize; + if self + .0 + .iter() + .any(|(from, to)| (*from..*to).contains(&start)) + { + mark(expression, Naming::Risky); + } + walk_mut::walk_expression(self, expression); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn marks_keep_the_position_they_were_written_at() { + assert_eq!(naming_of_start(10), Naming::Own); + assert_eq!(naming_of_start(0xa | RISKY), Naming::Risky); + assert_eq!(source_offset(0xa | RISKY), 10); + } +} diff --git a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-2.snap b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-2.snap index 312595a05..2c66abe69 100644 --- a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-2.snap @@ -1,8 +1,8 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Text} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Text} from '@devup-ui/core'\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: {}, - code: ";\n", + code: " __devupTypography ? `typo-${__devupTypography}` : \"\")(a) : ((__devupTypography) => __devupTypography ? `typo-${__devupTypography}` : \"\")(b)} />;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-4.snap b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-4.snap index 0ec710378..38f228fcf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-4.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box as DevupButton} from '@devup-ui/core'\n \n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box as DevupButton} from '@devup-ui/core'\n \n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -15,5 +15,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupClass117, __devupSpread0) =>
\n
)(className, ((__devupTypography) => __devupTypography ? `typo-${__devupTypography}` : \"\")(typography));\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-5.snap b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-5.snap index 35b02b3a0..02edb6ac6 100644 --- a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-5.snap +++ b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-5.snap @@ -17,5 +17,5 @@ ToBTreeSet { "tag", ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n\n {children}\n ;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupSpread1, __devupSpread0) => \n {__devupSpread0}\n )({\n\t\"lg\": \"typo-buttonLg\",\n\t\"md\": \"typo-button\",\n\t\"sm\": \"typo-buttonSm\",\n\t\"tag\": \"typo-tag\"\n}[size] || \"\", children);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-6.snap b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-6.snap index ba70c53a2..941fded4d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-6.snap +++ b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-6.snap @@ -17,5 +17,5 @@ ToBTreeSet { "tag", ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n\n {children}\n ;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupSpread1, __devupSpread0) => \n {__devupSpread0}\n )({\n\t\"lg\": \"typo-buttonLg\",\n\t\"md\": \"typo-button\",\n\t\"sm\": \"typo-buttonSm\",\n\t\"tag\": \"typo-tag\"\n}[size] || \"\", children);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography.snap b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography.snap index 1f65a8422..bd27f6546 100644 --- a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography.snap +++ b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography.snap @@ -1,8 +1,8 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Text} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Text} from '@devup-ui/core'\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: {}, - code: ";\n", + code: " __devupTypography ? `typo-${__devupTypography}` : \"\")(variable)} />;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap index d997635d3..a42c6bbc6 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap index 3c0446efc..8f3e47cd2 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap index 50e7177f0..f6cbe4169 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap index 0519ae097..91b835b4c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap index 831bd6971..f9af7e544 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap index 5784017e3..4a38f7c2b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap index ebb729213..f445c68f4 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__avoid_same_name_component.snap b/libs/extractor/src/snapshots/extractor__tests__avoid_same_name_component.snap index d3d2d6ac6..781b912d6 100644 --- a/libs/extractor/src/snapshots/extractor__tests__avoid_same_name_component.snap +++ b/libs/extractor/src/snapshots/extractor__tests__avoid_same_name_component.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Box} from '@devup-ui/core'\nimport {Button} from '@devup/ui'\n ;\n ; @@ -229,15 +230,15 @@ exports[`Button should render loading spinner when loading is true 1`] = ` exports[`Button should render loading spinner when loading is true and loadingSpinner is partial 1`] = ` "
- @@ -246,10 +247,10 @@ exports[`Button should render loading spinner when loading is true and loadingSp exports[`Button should render loading spinner when icon is provided and loading is true 1`] = ` "
- diff --git a/packages/components/src/components/Button/__tests__/index.browser.test.tsx b/packages/components/src/components/Button/__tests__/index.browser.test.tsx index 4f49187b9..a76866562 100644 --- a/packages/components/src/components/Button/__tests__/index.browser.test.tsx +++ b/packages/components/src/components/Button/__tests__/index.browser.test.tsx @@ -14,9 +14,7 @@ describe('Button', () => { it('should render default style when variant is default', () => { const { container } = render() expect(container).toMatchSnapshot() - expect(container.querySelector('button')).toHaveClass( - 'color-0-var_lp_--text_cm__h_272727_rp_--1', - ) + expect(container.querySelector('button')).toHaveClass('OHdsoizdtbkuep7e97') }) it('should disable', () => { @@ -28,9 +26,7 @@ describe('Button', () => { it('should render error style when danger is true and variant is default', () => { const { container } = render() expect(container).toMatchSnapshot() - expect(container.querySelector('button')).toHaveClass( - 'color-0-var_lp_--error_cm__h_D52B2E_rp_--1', - ) + expect(container.querySelector('button')).toHaveClass('OHak4bikju8xji8lqu') }) it('should render primary background color when danger is true and variant is primary', () => { @@ -40,15 +36,13 @@ describe('Button', () => { , ) expect(container).toMatchSnapshot() - expect(container.querySelector('button')).toHaveClass( - 'background-0-var_lp_--primary_cm__h_8163E1_rp_--1', - ) + expect(container.querySelector('button')).toHaveClass('OHbdbo4lswv91x4s_w') }) it('should not render error color when danger is false and variant is default', async () => { const { container } = render() const button = container.querySelector('button') - expect(button).toHaveClass('color-0-var_lp_--text_cm__h_272727_rp_--1') + expect(button).toHaveClass('OHdsoizdtbkuep7e97') expect(button).toMatchSnapshot() }) @@ -65,7 +59,12 @@ describe('Button', () => { } as unknown as React.ComponentProps const { container } = render(, ) expect(container).toMatchSnapshot() - expect(container.querySelector('button')).toHaveClass('font-size-0-15px--1') + expect(container.querySelector('button')).toHaveClass('OHbl76x2rm_e09_q_o') }) it('should have font size 15px when size is sm and variant is primary', () => { @@ -111,7 +110,7 @@ describe('Button', () => { , ) expect(container).toMatchSnapshot() - expect(container.querySelector('button')).toHaveClass('font-size-0-15px--1') + expect(container.querySelector('button')).toHaveClass('OHbl76x2rm_e09_q_o') }) it('should have font size 14px when size is sm and variant is default', () => { @@ -121,7 +120,7 @@ describe('Button', () => { , ) expect(container).toMatchSnapshot() - expect(container.querySelector('button')).toHaveClass('font-size-0-14px--1') + expect(container.querySelector('button')).toHaveClass('OHayncw4cji61v_2tv') }) it('should render icon when icon is provided', () => { @@ -168,9 +167,7 @@ describe('Button', () => { , ) expect(container).toMatchSnapshot() - expect(container.querySelector('button')).toHaveClass( - 'color-0-var_lp_--text_cm__h_272727_rp_--1', - ) + expect(container.querySelector('button')).toHaveClass('OHdsoizdtbkuep7e97') }) it('should have typography when typography is provided', () => { diff --git a/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap b/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap index 4845f4a2d..e5c6a3d02 100644 --- a/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap +++ b/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap @@ -1,13 +1,14 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots +// W31: 9 complete bodies reviewed in W31e-bun-alignment.json. exports[`Checkbox should render basic checkbox 1`] = ` "
-
-