From 08a04858ba1538a62b47ad79f28a3a494d75b17a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 9 Oct 2026 18:17:24 +0900 Subject: [PATCH 1/3] test(extractor): lock imported css composition precedence in both modes Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- e2e/css-composition.spec.ts | 223 ++++++++++++++++++++++++++++++++++++ libs/extractor/src/lib.rs | 124 ++++++++++++++++++++ 2 files changed, 347 insertions(+) create mode 100644 e2e/css-composition.spec.ts diff --git a/e2e/css-composition.spec.ts b/e2e/css-composition.spec.ts new file mode 100644 index 000000000..54463931b --- /dev/null +++ b/e2e/css-composition.spec.ts @@ -0,0 +1,223 @@ +import { expect, test } from '@playwright/test' + +import { + codeExtract, + exportCanonicalMap, + exportClassMap, + exportFileMap, + exportSheet, + getCss, + getPrefix, + importCanonicalMap, + importClassMap, + importFileMap, + importSheet, + isDebug, + setDebug, + setModuleResolver, + setPrefix, +} from '../bindings/devup-ui-wasm/pkg/index.js' + +const api = "import { css } from '@devup-ui/react';" +const leafRules = `{ + color: ['orange', 'red', 'purple'], p: 2, + _hover: { color: ['tomato', 'red', 'purple'] } +}` +const laterRules = `{ + color: ['blue', null, 'green', null, 'pink'], margin: 3, + _hover: { color: ['navy', null, 'lime'] } +}` + +function exportClasses(code: string, name: string): string { + const literal = code.match( + new RegExp(`export const ${name}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")\\s*;`), + )?.[1] + if (literal === undefined) { + throw new TypeError(`Expected a compiled literal for ${name}: ${code}`) + } + const classes: unknown = JSON.parse(literal) + if (typeof classes !== 'string') { + throw new TypeError(`Expected class string for ${name}`) + } + expect(classes).toMatch(/^[a-zA-Z0-9_-]+(?: [a-zA-Z0-9_-]+)*$/) + return classes +} + +function compile(filename: string, source: string, singleCss: boolean) { + const output = codeExtract( + filename, + source, + '@devup-ui/react', + 'df', + singleCss, + false, + false, + {}, + ) + try { + const css = singleCss ? '' : output.css + if (css === undefined) { + throw new TypeError(`Expected per-file CSS for ${filename}`) + } + return { code: output.code, css, dependencies: output.dependencies } + } finally { + output.free() + } +} + +function artifacts( + singleCss: boolean, + parentSource: string, + childSource?: string, +) { + const saved = { + sheet: exportSheet(), + classes: exportClassMap(), + files: exportFileMap(), + canonical: exportCanonicalMap(), + debug: isDebug(), + prefix: getPrefix(), + } + try { + importClassMap({}) + importFileMap({}) + importCanonicalMap({}) + setDebug(false) + setPrefix(null) + setModuleResolver((specifier: string, importer: string) => + childSource !== undefined && + specifier === './child' && + importer === '/src/parent.ts' + ? { path: '/src/child.ts', code: childSource } + : undefined, + ) + const child = + childSource === undefined + ? undefined + : compile('/src/child.ts', childSource, singleCss) + const parent = compile('/src/parent.ts', parentSource, singleCss) + if (child !== undefined) { + expect(parent.dependencies).toContain('/src/child.ts') + } + return { + css: singleCss + ? getCss(undefined, false) + : `${child?.css ?? ''}\n${parent.css}`, + parent: exportClasses(parent.code, 'base'), + child: exportClasses( + child?.code ?? parent.code, + child === undefined ? 'leaf' : 'base', + ), + } + } finally { + setModuleResolver(null) + importSheet(JSON.parse(saved.sheet)) + importClassMap(JSON.parse(saved.classes)) + importFileMap(JSON.parse(saved.files)) + importCanonicalMap(JSON.parse(saved.canonical)) + setDebug(saved.debug) + setPrefix(saved.prefix) + } +} + +for (const singleCss of [true, false]) { + test.describe(`css composition (${singleCss ? 'single' : 'per-file'} CSS)`, () => { + test('later argument wins across the imported W35i leaf', async ({ + page, + }) => { + const emitted = artifacts( + singleCss, + `${api} import { base as child } from './child'; export const base = css(child, { color: 'blue', margin: 3 });`, + `${api} export const base = css({ color: 'orange', p: 2 });`, + ) + await page.setContent(` +
Parent
+
Child
`) + await expect(page.locator('#parent')).toHaveCSS('color', 'rgb(0, 0, 255)') + await expect(page.locator('#parent')).toHaveCSS('padding', '8px') + await expect(page.locator('#parent')).toHaveCSS('margin', '12px') + await expect(page.locator('#child')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + await expect(page.locator('#child')).toHaveCSS('padding', '8px') + }) + + for (const chain of [false, true]) { + test(`later hover/responsive arguments win across ${chain ? 'a same-module three-level chain' : 'an imported leaf'}`, async ({ + page, + }) => { + const emitted = chain + ? artifacts( + singleCss, + `${api} + export const leaf = css(${leafRules}); + const middle = css(leaf, ${laterRules}); + export const base = css(middle, { + color: ['cyan', null, null, null, 'gold'], + _hover: { color: ['aqua', null, null, null, 'yellow'] } + });`, + ) + : artifacts( + singleCss, + `${api} import { base as child } from './child'; export const base = css(child, ${laterRules});`, + `${api} export const base = css(${leafRules});`, + ) + await page.setContent(` +
Parent
+
Child
`) + const widths = [ + [ + 320, + chain ? 'rgb(0, 255, 255)' : 'rgb(0, 0, 255)', + chain ? 'rgb(0, 255, 255)' : 'rgb(0, 0, 128)', + 'rgb(255, 165, 0)', + 'rgb(255, 99, 71)', + ], + [ + 600, + 'rgb(255, 0, 0)', + 'rgb(255, 0, 0)', + 'rgb(255, 0, 0)', + 'rgb(255, 0, 0)', + ], + [ + 900, + 'rgb(0, 128, 0)', + 'rgb(0, 255, 0)', + 'rgb(128, 0, 128)', + 'rgb(128, 0, 128)', + ], + [ + 1400, + chain ? 'rgb(255, 215, 0)' : 'rgb(255, 192, 203)', + chain ? 'rgb(255, 255, 0)' : 'rgb(0, 255, 0)', + 'rgb(128, 0, 128)', + 'rgb(128, 0, 128)', + ], + ] as const + for (const [ + width, + parentColor, + hoverColor, + childColor, + childHoverColor, + ] of widths) { + await page.setViewportSize({ width, height: 600 }) + await page.mouse.move(width - 1, 599) + await expect(page.locator('#parent')).toHaveCSS('color', parentColor) + await expect(page.locator('#parent')).toHaveCSS('padding', '8px') + await expect(page.locator('#parent')).toHaveCSS('margin', '12px') + await expect(page.locator('#child')).toHaveCSS('color', childColor) + await page.locator('#parent').hover() + await expect(page.locator('#parent')).toHaveCSS('color', hoverColor) + await page.locator('#child').hover() + await expect(page.locator('#child')).toHaveCSS( + 'color', + childHoverColor, + ) + } + }) + } + }) +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 7a0def3fc..46fec6bde 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -14813,6 +14813,130 @@ export const d = css(ordered, danger, called);", } } + fn assert_composed_export_atoms(code: &str, export: &str, expected: &[&str]) { + let declaration = format!("export const {export} = \""); + let classes = code + .split_once(&declaration) + .expect("compiled export must be a static class string") + .1 + .split_once('"') + .unwrap() + .0; + let mut actual: Vec<_> = classes + .split_whitespace() + .map(|class| class.trim_end_matches("-a")) + .collect(); + actual.sort_unstable(); + let mut expected = expected.to_vec(); + expected.sort_unstable(); + assert_eq!(actual, expected, "{code}"); + } + + #[rstest] + #[case( + "{ color: 'orange', p: 2 }", + "{ color: 'blue', margin: 3 }", + &["color-0-blue--255", "padding-0-8px--255", "margin-0-12px--255"] + )] + #[case( + "{ color: ['orange', 'red', 'purple'], p: 2, _hover: { color: ['orange', 'red', 'purple'] } }", + "{ color: ['blue', null, 'green', null, 'pink'], margin: 3, _hover: { color: ['blue', null, 'green'] } }", + &[ + "color-0-blue--255", "color-1-red--255", "color-2-green--255", + "color-4-pink--255", "padding-0-8px--255", "margin-0-12px--255", + "color-0-blue-_a__c_hover-255", "color-1-red-_a__c_hover-255", + "color-2-green-_a__c_hover-255" + ] + )] + #[serial] + fn test_css_later_argument_wins_across_leaf( + #[case] earlier: &'static str, + #[case] later: &str, + #[case] expected: &[&str], + #[values(false, true)] imported: bool, + #[values(false, true)] single_css: bool, + ) { + reset_class_map(); + reset_file_map(); + let modules: &[(&str, &str)] = &[ + ( + "/src/child.ts", + "import { css } from '@devup-ui/react'; export const base = css({ color: 'orange', p: 2 });", + ), + ( + "/src/responsive.ts", + "import { css } from '@devup-ui/react'; export const base = css({ color: ['orange', 'red', 'purple'], p: 2, _hover: { color: ['orange', 'red', 'purple'] } });", + ), + ]; + let leaf = if earlier.contains("_hover") { + "responsive" + } else { + "child" + }; + let binding = if imported { + format!("import {{ base as child }} from './{leaf}';") + } else { + format!("const child = css({earlier});") + }; + let source = format!( + "import {{ css }} from '@devup-ui/react'; {binding} export const base = css(child, {later});" + ); + let resolver = memory_resolver(modules); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/parent.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + ); + css::debug::set_debug(false); + let output = output.unwrap(); + assert_composed_export_atoms(&output.code, "base", expected); + if imported { + assert!(output.dependencies.contains(&format!("/src/{leaf}.ts"))); + } + } + + #[rstest] + #[serial] + fn test_css_later_argument_wins_across_three_levels(#[values(false, true)] single_css: bool) { + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(&[]); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/chain.ts", + "import { css } from '@devup-ui/react'; +const leaf = css({ color: ['orange', 'red', 'purple'], p: 2, _hover: { color: ['orange', 'red', 'purple'] }, styleOrder: 3 }); +const middle = css(leaf, { color: ['blue', null, 'green'], margin: 3, _hover: { color: ['blue', null, 'green'] } }); +export const base = css(middle, { color: ['cyan', null, null, null, 'pink'], _hover: { color: 'cyan' } });", + ExtractOption { single_css, ..ExtractOption::default() }, + false, + &resolver, + ); + css::debug::set_debug(false); + let output = output.unwrap(); + assert_composed_export_atoms( + &output.code, + "base", + &[ + "color-0-cyan--255", + "color-1-red--3", + "color-2-green--255", + "color-4-pink--255", + "padding-0-8px--3", + "margin-0-12px--255", + "color-0-cyan-_a__c_hover-255", + "color-1-red-_a__c_hover-3", + "color-2-green-_a__c_hover-255", + ], + ); + } + // Each Tailwind class becomes the classes of its styles; every other class, // and every class that runs into an interpolation, stays as written #[rstest] From 2e26c2811ea24916a203b4c967eeaa6ca0a5da30 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 9 Oct 2026 22:58:48 +0900 Subject: [PATCH 2/3] fix(extractor): resolve imported css composition chains statically Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...hangepack_log_compose_imported_chains.json | 7 + e2e/css-composition.spec.ts | 358 +++++++++++- libs/extractor/src/imported_constants.rs | 249 ++++++-- libs/extractor/src/lib.rs | 539 +++++++++++++++++- libs/extractor/src/style_values.rs | 13 +- 5 files changed, 1066 insertions(+), 100 deletions(-) create mode 100644 .changepacks/changepack_log_compose_imported_chains.json diff --git a/.changepacks/changepack_log_compose_imported_chains.json b/.changepacks/changepack_log_compose_imported_chains.json new file mode 100644 index 000000000..71a3e5e8d --- /dev/null +++ b/.changepacks/changepack_log_compose_imported_chains.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Resolve native css() definitions composed from known rule objects and css() results through imported module chains. Later declarations replace earlier declarations at the same property, selector, breakpoint and layer, while earlier-only declarations remain. Imported composition chains with cycles, dynamic parts or unresolved imports now produce located build errors instead of silently deferring precedence to stylesheet order.", + "date": "2026-10-09T00:00:00.000Z" +} diff --git a/e2e/css-composition.spec.ts b/e2e/css-composition.spec.ts index 54463931b..0be7146c6 100644 --- a/e2e/css-composition.spec.ts +++ b/e2e/css-composition.spec.ts @@ -65,11 +65,12 @@ function compile(filename: string, source: string, singleCss: boolean) { } } -function artifacts( - singleCss: boolean, - parentSource: string, - childSource?: string, -) { +type FixtureModule = { + readonly path: string + readonly code: string +} + +function withWasmState(action: () => T): T { const saved = { sheet: exportSheet(), classes: exportClassMap(), @@ -79,36 +80,13 @@ function artifacts( prefix: getPrefix(), } try { + importSheet({}) importClassMap({}) importFileMap({}) importCanonicalMap({}) setDebug(false) setPrefix(null) - setModuleResolver((specifier: string, importer: string) => - childSource !== undefined && - specifier === './child' && - importer === '/src/parent.ts' - ? { path: '/src/child.ts', code: childSource } - : undefined, - ) - const child = - childSource === undefined - ? undefined - : compile('/src/child.ts', childSource, singleCss) - const parent = compile('/src/parent.ts', parentSource, singleCss) - if (child !== undefined) { - expect(parent.dependencies).toContain('/src/child.ts') - } - return { - css: singleCss - ? getCss(undefined, false) - : `${child?.css ?? ''}\n${parent.css}`, - parent: exportClasses(parent.code, 'base'), - child: exportClasses( - child?.code ?? parent.code, - child === undefined ? 'leaf' : 'base', - ), - } + return action() } finally { setModuleResolver(null) importSheet(JSON.parse(saved.sheet)) @@ -120,8 +98,328 @@ function artifacts( } } +function graphResolver(modules: readonly FixtureModule[]) { + return (specifier: string, importer: string) => { + const path = `${importer.slice(0, importer.lastIndexOf('/') + 1)}${specifier.slice(2)}.ts` + return modules.find((module) => module.path === path) + } +} + +function artifacts( + singleCss: boolean, + parentSource: string | readonly FixtureModule[], + childSource?: string, +) { + return withWasmState(() => { + const graph = + typeof parentSource === 'string' + ? [ + ...(childSource === undefined + ? [] + : [{ path: '/src/child.ts', code: childSource }]), + { path: '/src/parent.ts', code: parentSource }, + ] + : parentSource + setModuleResolver(graphResolver(graph)) + const compiled = graph.map((module) => + compile(module.path, module.code, singleCss), + ) + const parent = compiled.at(-1) + const child = compiled[0] + if (parent === undefined || child === undefined) { + throw new TypeError('Expected a nonempty composition graph') + } + expect([...parent.dependencies].sort()).toEqual( + graph + .slice(0, -1) + .map((module) => module.path) + .sort(), + ) + return { + css: singleCss + ? getCss(undefined, false) + : compiled.map((output) => output.css).join('\n'), + parent: exportClasses(parent.code, 'base'), + child: exportClasses(child.code, graph.length === 1 ? 'leaf' : 'base'), + earlier: compiled + .slice(0, -1) + .map((output) => exportClasses(output.code, 'base')), + } + }) +} + for (const singleCss of [true, false]) { test.describe(`css composition (${singleCss ? 'single' : 'per-file'} CSS)`, () => { + for (const count of [3, 4]) { + test(`terminal wins through ${count} imported modules without changing earlier exports`, async ({ + page, + }) => { + const graph = [ + { + path: '/src/a.ts', + code: `${api} export const base = css({ color: 'red', p: 2 });`, + }, + { + path: '/src/b.ts', + code: `${api} import { base as child } from './a'; export const base = css(child, { color: 'orange', margin: 3 });`, + }, + { + path: '/src/c.ts', + code: `${api} import { base as child } from './b'; export const base = css(child, { color: 'blue' });`, + }, + { + path: '/src/d.ts', + code: `${api} import { base as child } from './c'; export const base = css(child, { color: 'purple', bg: 'yellow' });`, + }, + ].slice(0, count) + const emitted = artifacts(singleCss, graph) + await page.setContent(` +
Terminal
+ ${emitted.earlier.map((classes, index) => `
Earlier
`).join('')}`) + await expect(page.locator('#parent')).toHaveCSS( + 'color', + count === 3 ? 'rgb(0, 0, 255)' : 'rgb(128, 0, 128)', + ) + await expect(page.locator('#parent')).toHaveCSS('padding', '8px') + await expect(page.locator('#parent')).toHaveCSS('margin', '12px') + if (count === 4) { + await expect(page.locator('#parent')).toHaveCSS( + 'background-color', + 'rgb(255, 255, 0)', + ) + } + const colors = ['rgb(255, 0, 0)', 'rgb(255, 165, 0)', 'rgb(0, 0, 255)'] + for (const [index, color] of colors.slice(0, count - 1).entries()) { + await expect(page.locator(`#earlier-${index}`)).toHaveCSS( + 'color', + color, + ) + await expect(page.locator(`#earlier-${index}`)).toHaveCSS( + 'padding', + '8px', + ) + if (index > 0) { + await expect(page.locator(`#earlier-${index}`)).toHaveCSS( + 'margin', + '12px', + ) + } + } + }) + } + + test('mixed local aliases preserve responsive holes and hover through imported chains', async ({ + page, + }) => { + const emitted = artifacts(singleCss, [ + { + path: '/src/a.ts', + code: `${api} export const base = css(${leafRules});`, + }, + { + path: '/src/b.ts', + code: `${api} import { base as first } from './a'; const alias = first; const local = css(alias, ${laterRules}); export const base = css(first, local);`, + }, + { + path: '/src/c.ts', + code: `${api} import { base as child } from './b'; const alias = child; const local = css(alias, { color: ['cyan', null, null, null, 'gold'], _hover: { color: ['aqua', null, null, null, 'yellow'] } }); export const base = css(local, { bg: 'white' });`, + }, + ]) + await page.setContent(` +
Terminal
+ ${emitted.earlier.map((classes, index) => `
Earlier
`).join('')}`) + const widths = [ + [ + 320, + 'rgb(0, 255, 255)', + 'rgb(0, 255, 255)', + 'rgb(255, 165, 0)', + 'rgb(255, 99, 71)', + 'rgb(0, 0, 255)', + 'rgb(0, 0, 128)', + ], + [ + 600, + 'rgb(255, 0, 0)', + 'rgb(255, 0, 0)', + 'rgb(255, 0, 0)', + 'rgb(255, 0, 0)', + 'rgb(255, 0, 0)', + 'rgb(255, 0, 0)', + ], + [ + 900, + 'rgb(0, 128, 0)', + 'rgb(0, 255, 0)', + 'rgb(128, 0, 128)', + 'rgb(128, 0, 128)', + 'rgb(0, 128, 0)', + 'rgb(0, 255, 0)', + ], + [ + 1400, + 'rgb(255, 215, 0)', + 'rgb(255, 255, 0)', + 'rgb(128, 0, 128)', + 'rgb(128, 0, 128)', + 'rgb(255, 192, 203)', + 'rgb(0, 255, 0)', + ], + ] as const + for (const [ + width, + terminal, + terminalHover, + leaf, + leafHover, + middle, + middleHover, + ] of widths) { + await page.setViewportSize({ width, height: 600 }) + await page.mouse.move(width - 1, 599) + for (const [selector, color, hover] of [ + ['#parent', terminal, terminalHover], + ['#earlier-0', leaf, leafHover], + ['#earlier-1', middle, middleHover], + ] as const) { + await page.mouse.move(width - 1, 599) + await expect(page.locator(selector)).toHaveCSS('color', color) + await expect(page.locator(selector)).toHaveCSS('padding', '8px') + await page.locator(selector).hover() + await expect(page.locator(selector)).toHaveCSS('color', hover) + } + await expect(page.locator('#parent')).toHaveCSS('margin', '12px') + await expect(page.locator('#earlier-1')).toHaveCSS('margin', '12px') + } + }) + + const refusals = [ + { + name: 'cycle', + modules: [ + { + path: '/src/a.ts', + code: `${api}\nimport { base as child } from './b';\nexport const base = css(\nchild, { color: 'red' });`, + }, + { + path: '/src/b.ts', + code: `${api}\nimport { base as child } from './a';\nexport const base = css(\nchild, { color: 'orange' });`, + }, + ], + error: + /^\/src\/[ab]\.ts:4:1: `css\(\)` cannot use `child` at build time: .*cycl/, + }, + { + name: 'dynamic rule', + modules: [ + { + path: '/src/b.ts', + code: `${api}\nexport const base = css(\n{ color: globalThis.tone });`, + }, + ], + error: + /^\/src\/b\.ts:3:1: `css\(\)` cannot use `\{ color: globalThis\.tone \}` at build time: .*constant/, + }, + { + name: 'missing import', + modules: [ + { + path: '/src/a.ts', + code: `${api}\nimport { base as child } from './missing';\nexport const base = css(\nchild, { color: 'red' });`, + }, + { + path: '/src/b.ts', + code: `${api} import { base as child } from './a'; export const base = css(child, { color: 'orange' });`, + }, + ], + error: + /^\/src\/a\.ts:4:1: `css\(\)` cannot use `child` at build time: .*module.*\.\/missing/, + }, + ] as const + for (const fixture of refusals) { + test(`real WASM refuses an imported native ${fixture.name}`, () => { + withWasmState(() => { + setModuleResolver(graphResolver(fixture.modules)) + const source = `${api} import { base as child } from './b'; export const base = css(child, { color: 'blue' });` + let failure: string | undefined + try { + compile('/src/terminal.ts', source, singleCss) + } catch (error) { + if (typeof error === 'string') { + failure = error + } else if (error instanceof Error) { + failure = error.message + } else { + throw error + } + } + expect(failure).toMatch(fixture.error) + }) + }) + } + + for (const projection of [ + { + name: 'failed member', + source: `${api} import { seed, broken } from './a'; const holder = { broken }; export const base = css(seed, holder.broken, { color: 'blue' });`, + refuses: true, + }, + { + name: 'direct failed import', + source: `${api} import { seed, broken } from './a'; export const base = css(seed, broken, { color: 'blue' });`, + refuses: true, + }, + { + name: 'shadowed failed import', + source: `${api} import { seed, broken as alias } from './a'; export function compose() { const alias = 'external-card'; return css(seed, alias, { color: 'green' }); }`, + refuses: false, + }, + { + name: 'unread failed sibling', + source: `${api} import { seed, broken } from './a'; const holder = { broken, safe: 'external-card' }; export const base = css(seed, holder.safe, { color: 'blue' });`, + refuses: false, + }, + ] as const) { + test(`real WASM projects consumption of a ${projection.name}`, () => { + withWasmState(() => { + setModuleResolver( + graphResolver([ + { + path: '/src/a.ts', + code: `${api} export const seed = css({ color: 'red', p: 2 }); export const broken = css(seed, { color: globalThis.tone });`, + }, + ]), + ) + let failure: string | undefined + let emitted: string | undefined + try { + emitted = compile( + '/src/terminal.ts', + projection.source, + singleCss, + ).code + } catch (error) { + if (typeof error === 'string') { + failure = error + } else if (error instanceof Error) { + failure = error.message + } else { + throw error + } + } + if (projection.refuses) { + expect(failure).toMatch( + /^\/src\/a\.ts:\d+:\d+: `css\(\)` cannot use/, + ) + expect(failure).toContain('globalThis.tone') + } else { + expect(failure).toBeUndefined() + expect(emitted).toContain('external-card') + } + }) + }) + } + test('later argument wins across the imported W35i leaf', async ({ page, }) => { diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 82ad48295..3f2416a90 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -15,7 +15,7 @@ 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; @@ -52,11 +52,59 @@ enum Constant { /// name, never rules; for a `css()` class, the styles behind it when /// they are known Style(Option>>), + /// A native chain's defining argument and located refusal. + Failed(String), + /// A lookup failure retained only while resolving native rule metadata. + Unavailable(ChainReason), /// An object or array code changes, or a value read from one Changed(Rc), } +#[derive(Clone, Debug)] +enum ChainReason { + Cycle, + Module(String), + Export(String), + Constant, + Static, +} + +impl ChainReason { + fn requirement(&self) -> String { + match self { + Self::Cycle => "native css chain must not be cyclic".to_string(), + Self::Module(source) => format!("native css chain needs module `{source}`"), + Self::Export(name) => format!("native css chain needs export `{name}`"), + Self::Constant => "native css rules must be constant at build time".to_string(), + Self::Static => "native css chain needs complete static rule metadata".to_string(), + } + } +} + impl Constant { + /// Retain a failed upstream edge, including one inside a rule object. + fn failure(&self) -> Option { + match self { + Self::Failed(_) | Self::Unavailable(_) => Some(self.clone()), + Self::Object(object) => object + .iter() + .filter_map(|(key, value)| value.failure().map(|failure| (key, failure))) + .min_by(|left, right| left.0.cmp(right.0)) + .map(|(_, failure)| failure), + Self::Record(entries) => entries.iter().find_map(|(_, value)| value.failure()), + Self::Array(values) => values.iter().find_map(Self::failure), + Self::String(_) + | Self::Number(_) + | Self::Null + | Self::Bool(_) + | Self::Undefined + | Self::Function + | Self::Vars(_) + | Self::Theme(_) + | Self::Style(_) + | Self::Changed(_) => None, + } + } /// Whether code can change what it holds const fn is_mutable(&self) -> bool { matches!(self, Self::Object(_) | Self::Record(_) | Self::Array(_)) @@ -162,6 +210,7 @@ pub(crate) struct Inlined { pub stylex_themes: FxHashMap, /// The styles behind imported `css()` classes pub css_styles: FxHashMap>, + pub css_failures: Vec, pub unknown: Unknown, pub changed: Changed, } @@ -456,7 +505,7 @@ pub(crate) fn inline_constants<'a>( _ => {} } match &constant { - None if bound => { + None | Some(Constant::Unavailable(_)) if bound => { inlined.unknown.names.insert(name.clone()); } Some(object) @@ -500,6 +549,7 @@ pub(crate) fn inline_constants<'a>( symbols: &symbols, style_roots: &style_roots, apis: &apis, + css_failures: &mut inlined.css_failures, objects: false, styles: false, } @@ -740,18 +790,29 @@ impl Modules<'_> { specifier: &str, importer: &str, ) -> Option>> { - let module = (self.resolver?)(specifier, importer)?; + self.resolve_exports(specifier, importer).ok() + } + + fn resolve_exports( + &mut self, + specifier: &str, + importer: &str, + ) -> Result>, ChainReason> { + let module = self + .resolver + .and_then(|resolver| resolver(specifier, importer)) + .ok_or_else(|| ChainReason::Module(specifier.to_string()))?; if let Some(exports) = self.exports.get(&module.path) { - return Some(exports.clone()); + return Ok(exports.clone()); } if self.loading.contains(&module.path) { - return None; + return Err(ChainReason::Cycle); } self.loading.push(module.path.clone()); let exports = Rc::new(self.read(&module.path, &module.code)); self.loading.pop(); self.exports.insert(module.path, exports.clone()); - Some(exports) + Ok(exports) } fn read(&mut self, path: &str, code: &str) -> FxHashMap { @@ -796,15 +857,16 @@ impl Modules<'_> { } } Statement::ExportFromDeclaration(export) => { - if let Some(from) = self.exports(&export.source.value, path) { - for specifier in &export.specifiers { - if let Some(constant) = from.get(specifier.local.name().as_str()) { - exports.insert( - specifier.exported.name().to_string(), - constant.clone(), - ); - } - } + let from = self.resolve_exports(&export.source.value, path); + for specifier in &export.specifiers { + let name = specifier.local.name(); + let constant = match &from { + Ok(from) => from.get(name.as_str()).cloned().unwrap_or_else(|| { + Constant::Unavailable(ChainReason::Export(name.to_string())) + }), + Err(reason) => Constant::Unavailable(reason.clone()), + }; + exports.insert(specifier.exported.name().to_string(), constant); } } Statement::ExportAllDeclaration(export) => { @@ -1003,6 +1065,8 @@ struct ModuleScope<'p, 'a> { source: Option<&'p str>, locals: FxHashMap, declarations: FxHashMap>, + active: FxHashSet, + resolving_css: bool, imports: FxHashMap, style_imports: FxHashSet, /// Style APIs besides the imports, which never run what they are given @@ -1019,6 +1083,8 @@ impl<'p, 'a> ModuleScope<'p, 'a> { source, locals: FxHashMap::default(), declarations: FxHashMap::default(), + active: FxHashSet::default(), + resolving_css: false, imports: FxHashMap::default(), style_imports: FxHashSet::default(), style_names: FxHashSet::default(), @@ -1045,14 +1111,12 @@ impl<'p, 'a> ModuleScope<'p, 'a> { }) } - /// The styles behind `css(rules)`, the package's own `css` given one - /// rule object every value of which is known, as the module's class names - /// do not tell them + /// Native named css definitions merge known parts in argument order. fn css_styles( &mut self, modules: &mut Modules<'_>, call: &oxc_ast::ast::CallExpression<'_>, - ) -> Option>> { + ) -> Option { let Expression::Identifier(callee) = &call.callee else { return None; }; @@ -1062,36 +1126,79 @@ impl<'p, 'a> ModuleScope<'p, 'a> { if export != "css" || !source.starts_with(modules.option.package.as_str()) { return None; } - let [argument] = call.arguments.as_slice() else { + let code = self.source?; + if matches!(call.arguments.as_slice(), [Argument::ArrayExpression(_)]) { return None; - }; - let rules = self.evaluate(modules, argument.as_expression()?)?; + } let allocator = Allocator::default(); let builder = AstBuilder::new(&allocator); - let mut rules = match constant_literal(&builder, &rules, true)? { - rules @ Expression::ObjectExpression(_) => rules, - _ => return None, - }; - let ExtractResult { - mut styles, - style_order, - .. - } = extract_style_from_expression( - &builder, - None, - &mut rules, - 0, - &None, - LiteralHandling::ExpandResponsiveThemeToken, - ); - if let Some(order) = style_order { - for prop in &mut styles { - set_prop_order(prop, order); - } - } let mut composition = Composition::default(); - composition.apply(&builder, styles); - composition.unconditional().map(Rc::new) + for argument in &call.arguments { + let previous = self.resolving_css; + self.resolving_css = true; + let value = argument + .as_expression() + .and_then(|expression| self.evaluate(modules, expression)); + self.resolving_css = previous; + let failure = value.as_ref().and_then(Constant::failure); + let reason = match failure { + Some(Constant::Failed(message)) => return Some(Constant::Failed(message)), + Some(Constant::Unavailable(reason)) => reason, + _ => match value { + Some(Constant::Null | Constant::Undefined | Constant::Bool(false)) => continue, + Some(Constant::Style(Some(styles))) => { + composition.apply( + &builder, + styles + .iter() + .cloned() + .map(crate::ExtractStyleProp::Static) + .collect(), + ); + continue; + } + Some(ref value @ Constant::Record(_)) => { + if let Some(mut rules @ Expression::ObjectExpression(_)) = + constant_literal(&builder, value, true) + { + let ExtractResult { + mut styles, + style_order, + .. + } = extract_style_from_expression( + &builder, + None, + &mut rules, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + if let Some(order) = style_order { + for prop in &mut styles { + set_prop_order(prop, order); + } + } + composition.apply(&builder, styles); + continue; + } + ChainReason::Constant + } + Some(Constant::Object(_)) | None => ChainReason::Constant, + Some(Constant::Array(_)) if call.arguments.len() == 1 => return None, + Some(_) => ChainReason::Static, + }, + }; + let span = argument.span(); + let expression = &code[span.start as usize..span.end as usize]; + return Some(Constant::Failed(format!( + "{}: {}", + crate::locate(self.path, code, span.start as usize), + crate::utils::build_time_error("css", expression, &reason.requirement()) + ))); + } + composition + .unconditional() + .map(|styles| Constant::Style(Some(Rc::new(styles)))) } fn is_style_import(&self, option: &ExtractOption, name: &str) -> bool { @@ -1303,17 +1410,30 @@ impl<'p, 'a> ModuleScope<'p, 'a> { if let Some(constant) = self.locals.get(name) { return Some(constant.clone()); } - // Taken out while it is evaluated, so a constant reading itself stops - if let Some(init) = self.declarations.remove(name) - && let Some(constant) = self.evaluate(modules, init) - { - self.locals.insert(name.to_string(), constant.clone()); - return Some(constant); + if self.active.contains(name) { + return self + .resolving_css + .then_some(Constant::Unavailable(ChainReason::Cycle)); + } + if let Some(init) = self.declarations.remove(name) { + self.active.insert(name.to_string()); + let constant = self.evaluate(modules, init); + self.active.remove(name); + if let Some(constant) = constant { + self.locals.insert(name.to_string(), constant.clone()); + return Some(constant); + } } let (source, imported) = self.imports.get(name)?; - let exports = modules.exports(source, self.path)?; + let exports = match modules.resolve_exports(source, self.path) { + Ok(exports) => exports, + Err(reason) => return self.resolving_css.then_some(Constant::Unavailable(reason)), + }; match imported { - Imported::Named(export) => exports.get(export).cloned(), + Imported::Named(export) => exports.get(export).cloned().or_else(|| { + self.resolving_css + .then(|| Constant::Unavailable(ChainReason::Export(export.clone()))) + }), Imported::Namespace => Some(Constant::Object(exports)), } } @@ -1527,9 +1647,10 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } fold_math(callee.property.name.as_str(), &arguments) } - callee if self.is_style_api(modules, callee) => { - Some(Constant::Style(self.css_styles(modules, call))) - } + callee if self.is_style_api(modules, callee) => Some( + self.css_styles(modules, call) + .unwrap_or(Constant::Style(None)), + ), _ => self.evaluate_stylex(modules, call), }, Expression::TaggedTemplateExpression(tagged) @@ -1759,6 +1880,7 @@ struct Inline<'s, 'a> { symbols: &'s FxHashMap, style_roots: &'s FxHashSet<&'s str>, apis: &'s StyleApis<'s>, + css_failures: &'s mut Vec, /// Inside what the build reads as style objects objects: bool, /// Inside the arguments of a style API or a style prop @@ -1977,12 +2099,15 @@ fn constant_literal<'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 - .constant(expression) - .and_then(|constant| self.literal(&constant)) - { - *expression = literal; - return; + if let Some(constant) = self.constant(expression) { + if let Constant::Failed(message) = constant { + self.css_failures.push(message); + return; + } + if let Some(literal) = self.literal(&constant) { + *expression = literal; + return; + } } if let Some(chosen) = self.chosen(expression) { *expression = chosen; diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 46fec6bde..53edbeccf 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -430,6 +430,12 @@ fn extract_source( imported_constants::Inlined::default() }; dependencies.extend(inlined.dependencies); + if !inlined.css_failures.is_empty() { + let mut failures = inlined.css_failures; + failures.sort_unstable(); + failures.dedup(); + return Err(failures.join("\n").into()); + } let mut visitor = DevupVisitor::new( &allocator, filename, @@ -14782,10 +14788,10 @@ export const ordered = css({ color: 'f', m: 2, styleOrder: 2 });", let output = extract_with_modules( "/src/App.tsx", "import { css } from '@devup-ui/react'; -import { base, danger, twice, text, runtime, listed, spaced, fade, called, ordered } from './styles'; +import { base, danger, twice, text, listed, spaced, fade, called, ordered } from './styles'; export const a = css(base, danger); export const b = css(danger, base, { m: 1 }); -export const c = css(twice, text, runtime, listed, spaced, fade); +export const c = css(twice, text, listed, spaced, fade); export const d = css(ordered, danger, called);", ExtractOption { import_aliases: HashMap::from([( @@ -14802,7 +14808,7 @@ export const d = css(ordered, danger, called);", for expected in [ r#"export const a = "color-0-red-_a__c_hover-255-a color-0-crimson--255-a";"#, r#"export const b = "color-0-red-_a__c_hover-255-a color-0-teal--255-a margin-0-4px--255-a";"#, - "export const c = `${twice} ${text} ${runtime} ${listed} ${spaced} ${fade}`;", + "export const c = `color-0-a--255-a margin-0-4px--255-a ${text} ${listed} ${spaced} ${fade}`;", "export const d = `margin-0-8px--2-a color-0-crimson--255-a ${called}`;", ] { assert!( @@ -14813,6 +14819,34 @@ export const d = css(ordered, danger, called);", } } + #[rstest] + #[serial] + fn test_css_composes_imported_runtime_refusal(#[values(false, true)] single_css: bool) { + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(&[( + "/src/styles.ts", + "import { css } from '@devup-ui/react';\nexport const runtime = css({ color: globalThis.tone });", + )]); + let output = extract_with_modules( + "/src/App.tsx", + "import { css } from '@devup-ui/react'; import { runtime } from './styles'; export const base = css(runtime, { color: 'blue' });", + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + ); + let error = output.unwrap_err().to_string(); + assert!(error.starts_with("/src/styles.ts:2:28: "), "{error}"); + assert!( + error.contains("`css()` cannot use `{ color: globalThis.tone }` at build time: "), + "{error}" + ); + assert!(error.contains("constant"), "{error}"); + } + fn assert_composed_export_atoms(code: &str, export: &str, expected: &[&str]) { let declaration = format!("export const {export} = \""); let classes = code @@ -14937,6 +14971,505 @@ export const base = css(middle, { color: ['cyan', null, null, null, 'pink'], _ho ); } + #[rstest] + #[case::three_modules( + "import { css } from '@devup-ui/react'; import { base as child } from './b'; export const base = css(child, { color: 'blue' });", + (&["color-0-blue--255", "padding-0-8px--255", "margin-0-12px--255"][..], + &["/src/a.ts", "/src/b.ts"][..]) + )] + #[case::four_modules( + "import { css } from '@devup-ui/react'; import { base as child } from './c'; export const base = css(child, { color: 'purple', bg: 'yellow' });", + (&["color-0-purple--255", "background-0-yellow--255", "padding-0-8px--255", "margin-0-12px--255"][..], + &["/src/a.ts", "/src/b.ts", "/src/c.ts"][..]) + )] + #[serial] + fn test_css_imported_native_chain_winners( + #[case] source: &str, + #[case] expected: (&[&str], &[&str]), + #[values(false, true)] single_css: bool, + ) { + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(&[ + ( + "/src/a.ts", + "import { css } from '@devup-ui/react'; export const base = css({ color: 'red', p: 2 });", + ), + ( + "/src/b.ts", + "import { css } from '@devup-ui/react'; import { base as child } from './a'; export const base = css(child, { color: 'orange', margin: 3 });", + ), + ( + "/src/c.ts", + "import { css } from '@devup-ui/react'; import { base as child } from './b'; export const base = css(child, { color: 'blue' });", + ), + ]); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/terminal.ts", + source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + ); + css::debug::set_debug(false); + let output = output.unwrap(); + let (atoms, dependencies) = expected; + assert_composed_export_atoms(&output.code, "base", atoms); + let mut actual: Vec<_> = output.dependencies.iter().map(String::as_str).collect(); + actual.sort_unstable(); + assert_eq!(actual, dependencies); + } + + #[rstest] + #[serial] + fn test_css_imported_native_chain_mixed_cached_levels(#[values(false, true)] single_css: bool) { + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(&[ + ( + "/src/a.ts", + "import { css } from '@devup-ui/react'; const leaf = css({ color: ['orange', 'red', 'purple'], p: 2, _hover: { color: ['orange', 'red', 'purple'] }, styleOrder: 3 }); export { leaf as seed };", + ), + ( + "/src/b.ts", + "import { css } from '@devup-ui/react'; import { seed as first } from './a'; const alias = first; const local = css(alias, { color: ['blue', null, 'green'], _hover: { color: ['blue', null, 'green'] } }); const middle = css(first, local, { margin: 3 }); export { middle as renamed };", + ), + ]); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/c.ts", + "import { css } from '@devup-ui/react'; +import { seed } from './a'; +import { renamed as child } from './b'; +const alias = child; +const local = css(seed, alias, seed, child, { color: ['cyan', null, null, null, 'pink'], _hover: { color: 'cyan' } }); +export const base = css(local, { bg: 'white' });", + ExtractOption { single_css, ..ExtractOption::default() }, + false, + &resolver, + ); + css::debug::set_debug(false); + let output = output.unwrap(); + assert_composed_export_atoms( + &output.code, + "base", + &[ + "color-0-cyan--255", + "color-1-red--3", + "color-2-green--255", + "color-4-pink--255", + "padding-0-8px--3", + "margin-0-12px--255", + "color-0-cyan-_a__c_hover-255", + "color-1-red-_a__c_hover-3", + "color-2-green-_a__c_hover-255", + "background-0-white--255", + ], + ); + let mut dependencies: Vec<_> = output.dependencies.iter().map(String::as_str).collect(); + dependencies.sort_unstable(); + assert_eq!(dependencies, ["/src/a.ts", "/src/b.ts"]); + } + + #[rstest] + #[serial] + fn test_css_imported_native_chain_root_alias(#[values(false, true)] single_css: bool) { + // Given a root const alias of a known imported class. + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(&[( + "/src/a.ts", + "import { css } from '@devup-ui/react'; export const seed = css({ color: 'red', p: 2 });", + )]); + css::debug::set_debug(true); + // When a later local composition reads the alias. + let output = extract_with_modules( + "/src/terminal.ts", + "import { css } from '@devup-ui/react'; import { seed as child } from './a'; const alias = child; export const base = css(alias, { color: 'blue' });", + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + ); + css::debug::set_debug(false); + // Then declarations merge into a literal, without the losing color. + assert_composed_export_atoms( + &output.unwrap().code, + "base", + &["color-0-blue--255", "padding-0-8px--255"], + ); + } + + #[rstest] + #[serial] + fn test_css_imported_native_chain_alias_shadow(#[values(false, true)] single_css: bool) { + // Given an unrelated nested const with the same name as a root alias. + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(&[( + "/src/a.ts", + "import { css } from '@devup-ui/react'; export const seed = css({ color: 'red', p: 2 });", + )]); + css::debug::set_debug(true); + // When both bindings are read by compositions. + let output = extract_with_modules( + "/src/terminal.ts", + "import { css } from '@devup-ui/react'; import { seed as child } from './a'; const alias = child; export const base = css(alias, { color: 'blue' }); export function compose() { const alias = 'external-card'; return css(child, alias, { color: 'green' }); }", + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + ); + css::debug::set_debug(false); + let output = output.unwrap(); + // Then the nested external class stays opaque, not root style metadata. + assert!(output.code.contains("${alias}"), "{}", output.code); + assert_composed_export_atoms( + &output.code, + "base", + &["color-0-blue--255", "padding-0-8px--255"], + ); + } + + #[rstest] + #[case::absent_parts( + &[ + ("/src/a.ts", "import { css } from '@devup-ui/react'; export const seed = css({ color: 'red', p: 2 });"), + ("/src/b.ts", "import { css } from '@devup-ui/react'; import { seed as importedbase } from './a'; export const base = css(importedbase, null, undefined, false, { color: 'blue' });"), + ], + &["color-0-blue--255", "padding-0-8px--255", "background-0-white--255"] + )] + #[case::zero_arguments( + &[("/src/b.ts", "import { css } from '@devup-ui/react'; export const base = css();")], + &["background-0-white--255"] + )] + #[serial] + fn test_css_imported_native_chain_known_absent_parts( + #[case] modules: &'static [(&'static str, &'static str)], + #[case] expected: &[&str], + #[values(false, true)] single_css: bool, + ) { + // Given a native imported definition containing only known parts. + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(modules); + css::debug::set_debug(true); + // When the terminal composes the imported definition. + let output = extract_with_modules( + "/src/terminal.ts", + "import { css } from '@devup-ui/react'; import { base as child } from './b'; export const base = css(child, { bg: 'white' });", + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + ); + css::debug::set_debug(false); + // Then absent parts contribute no declarations or refusal. + assert_composed_export_atoms(&output.unwrap().code, "base", expected); + } + + #[rstest] + #[case::cross_module_cycle( + &[ + ("/src/a.ts", "import { css } from '@devup-ui/react';\nimport { base as child } from './b';\nexport const base = css(\nchild, { color: 'red', p: 2 });"), + ("/src/b.ts", "import { css } from '@devup-ui/react';\nimport { base as child } from './a';\nexport const base = css(\nchild, { color: 'orange', margin: 3 });"), + ], + (&["/src/a.ts:4:1: ", "/src/b.ts:4:1: "][..], "child", &["cycl"][..]) + )] + #[case::self_local_cycle( + &[("/src/b.ts", "import { css } from '@devup-ui/react';\nexport const base = css(\nbase, { color: 'orange' });")], + (&["/src/b.ts:3:1: "][..], "base", &["cycl"][..]) + )] + #[case::mutual_local_cycle( + &[("/src/b.ts", "import { css } from '@devup-ui/react';\nexport const base = css(\nother, { color: 'orange' });\nconst other = css(\nbase, { p: 2 });")], + (&["/src/b.ts:3:1: ", "/src/b.ts:5:1: "][..], "base", &["cycl"][..]) + )] + #[case::dynamic_rule( + &[("/src/b.ts", "import { css } from '@devup-ui/react';\nexport const base = css(\n{ color:\nglobalThis.tone\n});")], + (&["/src/b.ts:3:1: ", "/src/b.ts:4:1: "][..], "globalThis.tone", &["constant"][..]) + )] + #[case::dynamic_argument( + &[("/src/b.ts", "import { css } from '@devup-ui/react';\nexport const base = css(\nglobalThis.rules, { color: 'orange' });")], + (&["/src/b.ts:3:1: "][..], "globalThis.rules", &["constant"][..]) + )] + #[case::nested_missing_module( + &[ + ("/src/a.ts", "import { css } from '@devup-ui/react';\nimport { base as child } from './missing';\nexport const base = css(\nchild, { color: 'red' });"), + ("/src/b.ts", "import { css } from '@devup-ui/react'; import { base as child } from './a'; export const base = css(child, { color: 'orange' });"), + ], + (&["/src/a.ts:4:1: "][..], "child", &["module", "./missing"][..]) + )] + #[case::nested_missing_export( + &[ + ("/src/present.ts", "export const other = 'external-card';"), + ("/src/a.ts", "import { css } from '@devup-ui/react';\nimport { absent as child } from './present';\nexport const base = css(\nchild, { color: 'red' });"), + ("/src/b.ts", "import { css } from '@devup-ui/react'; import { base as child } from './a'; export const base = css(child, { color: 'orange' });"), + ], + (&["/src/a.ts:4:1: "][..], "child", &["export", "absent"][..]) + )] + #[case::opaque_inside_native_chain( + &[ + ("/src/external.ts", "export const external = 'external-card';"), + ("/src/b.ts", "import { css } from '@devup-ui/react';\nimport { external } from './external';\nexport const base = css(\nexternal, { color: 'orange' });"), + ], + (&["/src/b.ts:4:1: "][..], "external", &["static"][..]) + )] + #[serial] + fn test_css_imported_native_chain_refuses_incomplete_metadata( + #[case] modules: &'static [(&'static str, &'static str)], + #[case] expected: (&[&str], &str, &[&str]), + #[values(false, true)] single_css: bool, + ) { + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(modules); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/terminal.ts", + "import { css } from '@devup-ui/react'; import { base as child } from './b'; export const base = css(child, { color: 'blue' });", + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + ); + css::debug::set_debug(false); + let error = match output { + Err(error) => error.to_string(), + Ok(output) => panic!( + "incomplete native metadata must be refused, got {}", + output.code + ), + }; + let (sites, expression, reasons) = expected; + assert!(sites.iter().any(|site| error.starts_with(site)), "{error}"); + let failed = error + .split_once("`css()` cannot use `") + .expect("located native css error") + .1; + let (failed_expression, reason) = failed + .split_once("` at build time: ") + .expect("build-time refusal shape"); + assert!(failed_expression.contains(expression), "{error}"); + for expected_reason in reasons { + assert!(reason.contains(expected_reason), "{error}"); + } + } + + #[rstest] + #[case::unused_dynamic(&[("/src/b.ts", "import { css } from '@devup-ui/react'; export const base = css({ color: 'orange', p: 2 }); export const broken = css({ color: globalThis.tone });")])] + #[case::unused_self_cycle(&[("/src/b.ts", "import { css } from '@devup-ui/react'; export const base = css({ color: 'orange', p: 2 }); export const broken = css(broken, { color: 'red' });")])] + #[case::unused_mutual_cycle(&[("/src/b.ts", "import { css } from '@devup-ui/react'; export const base = css({ color: 'orange', p: 2 }); const first = css(second, { color: 'red' }); const second = css(first, { p: 2 }); export { first as broken };")])] + #[case::unused_missing_module(&[("/src/b.ts", "import { css } from '@devup-ui/react'; import { missing } from './missing'; export const base = css({ color: 'orange', p: 2 }); export const broken = css(missing, { color: 'red' });")])] + #[case::unused_missing_export(&[ + ("/src/present.ts", "export const other = 'external-card';"), + ("/src/b.ts", "import { css } from '@devup-ui/react'; import { absent } from './present'; export const base = css({ color: 'orange', p: 2 }); export const broken = css(absent, { color: 'red' });"), + ])] + #[serial] + fn test_css_imported_native_chain_ignores_unused_failed_export( + #[case] modules: &'static [(&'static str, &'static str)], + #[values(false, true)] single_css: bool, + ) { + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(modules); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/terminal.ts", + "import { css } from '@devup-ui/react'; import { base as child } from './b'; export const base = css(child, { color: 'blue' });", + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + ); + css::debug::set_debug(false); + let output = output.unwrap(); + assert_composed_export_atoms( + &output.code, + "base", + &["color-0-blue--255", "padding-0-8px--255"], + ); + } + + #[rstest] + #[serial] + fn test_css_imported_native_chain_preserves_unrelated_opaque_inputs( + #[values(false, true)] single_css: bool, + ) { + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(&[ + ( + "/src/external.ts", + "export const external = 'external-card';", + ), + ( + "/src/animation.ts", + "import { keyframes } from '@devup-ui/react'; export const fade = keyframes({ from: { opacity: 0 }, to: { opacity: 1 } });", + ), + ]); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/terminal.ts", + "import { css } from '@devup-ui/react'; import { external } from './external'; import { fade } from './animation'; export const base = css(external, fade, { color: 'blue' });", + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + ); + css::debug::set_debug(false); + let output = output.unwrap(); + assert!(output.code.contains("external-card"), "{}", output.code); + assert!(output.code.contains("${fade}"), "{}", output.code); + let class = if single_css { + "color-0-blue--255" + } else { + "color-0-blue--255-a" + }; + assert!(output.code.contains(class), "{}", output.code); + } + + #[rstest] + #[serial] + fn test_css_imported_native_chain_failed_import_shadow( + #[values(false, true)] single_css: bool, + ) { + // Given an unused failed import shadowed by a nested external class. + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(&[( + "/src/a.ts", + "import { css } from '@devup-ui/react'; export const seed = css({ color: 'red', p: 2 }); export const broken = css({ color: globalThis.tone });", + )]); + css::debug::set_debug(true); + // When styles consume only the nested binding and the independent seed. + let output = extract_with_modules( + "/src/terminal.ts", + "import { css } from '@devup-ui/react'; import { seed, broken as alias } from './a'; export function compose() { const alias = 'external-card'; return css(seed, alias, { color: 'green' }); }", + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + ); + css::debug::set_debug(false); + // Then the shadow stays opaque and the failed root import is nonfatal. + let output = output.unwrap(); + assert!(output.code.contains("${alias}"), "{}", output.code); + let suffix = if single_css { "" } else { "-a" }; + assert!( + output.code.contains(&format!( + "return `padding-0-8px--255{suffix} color-0-green--255{suffix} ${{alias}}`;" + )), + "{}", + output.code + ); + } + + #[rstest] + #[case::static_member("const holder = { broken };", "holder.broken")] + #[case::computed_member("const holder = { broken };", "holder['broken']")] + #[case::nested_static_member("const holder = { nested: { broken } };", "holder.nested.broken")] + #[case::nested_computed_member( + "const holder = { nested: { broken } };", + "holder['nested']['broken']" + )] + #[serial] + fn test_css_imported_native_chain_refuses_selected_failed_member( + #[case] declaration: &str, + #[case] selected: &str, + #[values(false, true)] single_css: bool, + ) { + // Given a holder retaining the native definition's located failure. + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(&[( + "/src/a.ts", + "import { css } from '@devup-ui/react';\nexport const seed = css({ color: 'red', p: 2 });\nexport const broken = css(\n{ color: globalThis.tone });", + )]); + // When the selected static or computed member is consumed by styles. + let output = extract_with_modules( + "/src/terminal.ts", + &format!( + "import {{ css }} from '@devup-ui/react'; import {{ seed, broken }} from './a'; {declaration} export const base = css(seed, {selected}, {{ color: 'blue' }});" + ), + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + ); + // Then refusal retains the defining file, argument site and expression. + assert_eq!( + output.unwrap_err().to_string(), + "/src/a.ts:4:1: `css()` cannot use `{ color: globalThis.tone }` at build time: native css rules must be constant at build time" + ); + } + + #[rstest] + #[case::static_sibling("const holder = { broken, safe: 'external-card' };", "holder.safe")] + #[case::computed_sibling("const holder = { broken, safe: 'external-card' };", "holder['safe']")] + #[case::nested_sibling( + "const holder = { nested: { broken, safe: 'external-card' } };", + "holder.nested.safe" + )] + #[case::nested_computed_sibling( + "const holder = { nested: { broken, safe: 'external-card' } };", + "holder['nested']['safe']" + )] + #[serial] + fn test_css_imported_native_chain_ignores_unread_failed_member( + #[case] declaration: &str, + #[case] selected: &str, + #[values(false, true)] single_css: bool, + ) { + // Given a safe external class alongside a retained failed member. + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(&[( + "/src/a.ts", + "import { css } from '@devup-ui/react'; export const seed = css({ color: 'red', p: 2 }); export const broken = css({ color: globalThis.tone });", + )]); + css::debug::set_debug(true); + // When styles select only the safe sibling. + let output = extract_with_modules( + "/src/terminal.ts", + &format!( + "import {{ css }} from '@devup-ui/react'; import {{ seed, broken }} from './a'; {declaration} export const base = css(seed, {selected}, {{ color: 'blue' }});" + ), + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + ); + css::debug::set_debug(false); + // Then the unread failure is nonfatal and composition stays literal. + assert_composed_export_atoms( + &output.unwrap().code, + "base", + &["external-card", "color-0-blue--255", "padding-0-8px--255"], + ); + } + // Each Tailwind class becomes the classes of its styles; every other class, // and every class that runs into an interpolation, stays as written #[rstest] diff --git a/libs/extractor/src/style_values.rs b/libs/extractor/src/style_values.rs index 749817848..d2f5e81d6 100644 --- a/libs/extractor/src/style_values.rs +++ b/libs/extractor/src/style_values.rs @@ -28,7 +28,7 @@ pub enum StyleValue { pub struct StyleValues { scoping: Option, values: FxHashMap, - /// The styles behind `css()` classes the file imports, by binding + /// Known imported styles, including top-level constant aliases, by binding imported: FxHashMap>, } @@ -73,12 +73,15 @@ impl StyleValues { match self.values.get(&symbol) { Some(StyleValue::Class(_, Some(styles))) => Some(styles), Some(_) => None, - None => scoping - .symbol_flags(symbol) - .is_import() + None => { + let flags = scoping.symbol_flags(symbol); + (flags.is_import() + || (flags.is_const_variable() + && scoping.symbol_scope_id(symbol) == scoping.root_scope_id())) .then(|| self.imported.get(scoping.symbol_name(symbol))) .flatten() - .map(Vec::as_slice), + .map(Vec::as_slice) + } } } From f1d61e4d9e087330f8ba20bda0a5aa8bfbe610f7 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 9 Oct 2026 23:32:48 +0900 Subject: [PATCH 3/3] test(extractor): cover imported record and array metadata boundaries Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/lib.rs | 55 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 53edbeccf..3fb7f1e41 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -15075,6 +15075,61 @@ export const base = css(local, { bg: 'white' });", assert_eq!(dependencies, ["/src/a.ts", "/src/b.ts"]); } + #[rstest] + #[serial] + fn test_css_imported_native_chain_refuses_function_record( + #[values(false, true)] single_css: bool, + ) { + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(&[( + "/src/styles.ts", + "import { css } from '@devup-ui/react';\nexport const base = css(\n{ color: () => 'orange' });", + )]); + let error = extract_with_modules( + "/src/terminal.ts", + "import { css } from '@devup-ui/react'; import { base } from './styles'; export const result = css(base, { color: 'blue' });", + ExtractOption { single_css, ..ExtractOption::default() }, + false, + &resolver, + ).unwrap_err().to_string(); + assert!( + error.starts_with( + "/src/styles.ts:3:1: `css()` cannot use `{ color: () => 'orange' }` at build time: " + ), + "{error}" + ); + assert!(error.contains("constant"), "{error}"); + } + + #[rstest] + #[serial] + fn test_css_imported_native_chain_keeps_constant_array_opaque( + #[values(false, true)] single_css: bool, + ) { + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(&[( + "/src/styles.ts", + "import { css } from '@devup-ui/react'; const parts = [{ color: 'orange' }]; export const base = css(parts);", + )]); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/terminal.ts", + "import { css } from '@devup-ui/react'; import { base } from './styles'; export const result = css(base, { color: 'blue' });", + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + ); + css::debug::set_debug(false); + let output = output.unwrap(); + assert!(output.code.contains("${base}"), "{}", output.code); + assert!(output.code.contains("color-0-blue--255"), "{}", output.code); + } + #[rstest] #[serial] fn test_css_imported_native_chain_root_alias(#[values(false, true)] single_css: bool) {