diff --git a/.changepacks/changepack_log_argument_spread_contract.json b/.changepacks/changepack_log_argument_spread_contract.json new file mode 100644 index 000000000..6201d0dbc --- /dev/null +++ b/.changepacks/changepack_log_argument_spread_contract.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Keep JavaScript argument-spread semantics in css() and styled(): compose supported literal arrays statically, and reject strings, other iterables and unknown spreads with a located composition error instructing users to pass the value itself instead. No iterators execute at build time or at styling runtime. Preserve optional producer coordinates and checked prelude offsets for located collision diagnostics.", + "date": "2026-10-07T00:00:00.000Z" +} 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" +} 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/.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/.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/.changepacks/changepack_log_deterministic_numbering.json b/.changepacks/changepack_log_deterministic_numbering.json new file mode 100644 index 000000000..053739d3d --- /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 (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/.changepacks/changepack_log_environment_stable_names.json b/.changepacks/changepack_log_environment_stable_names.json new file mode 100644 index 000000000..dcd8ba16e --- /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, bound unnumbered source identities with compact deterministic names, reset generated variables on their owning elements, and preserve authored evaluation order and lazy selection. Exact content-name and source-name collisions report both contents and their best real source locations; conflicting source-variable roles and unprotected naming caches now fail closed without rejecting literal CSS content strings.", + "date": "2026-10-05T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_static_scoped_names.json b/.changepacks/changepack_log_static_scoped_names.json new file mode 100644 index 000000000..7267d0485 --- /dev/null +++ b/.changepacks/changepack_log_static_scoped_names.json @@ -0,0 +1,13 @@ +{ + "changes": { + "bindings/devup-ui-wasm/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", + "packages/components/package.json": "Patch" + }, + "note": "Compile proven static responsive arrays directly to classes and isolate content-named atoms by their existing delivered sheet identity. Safe original sources keep independent D9 counter namespaces even when their CSS shares a collapsed bucket. Build adapters supply an independent names-only project root; resolver IDs, D9 numbering and stylesheet placement stay unchanged. Versioned self-contained naming snapshots ignore and rebuild obsolete or invalid cache data that reaches WASM; cache build errors are reserved for real exact name collisions with located diagnostics. Static Textarea font-size arrays retain their declarations and DOM while compiling directly to classes.", + "date": "2026-10-06T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_styled_creation.json b/.changepacks/changepack_log_styled_creation.json new file mode 100644 index 000000000..748ebc975 --- /dev/null +++ b/.changepacks/changepack_log_styled_creation.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Evaluate supported conditional styled rule arguments once when the component is created, preserving branch laziness, authored field order, static selectors and layers, typography and runtime value associations. Keep rule capture outside attrs wrappers while authored attrs callbacks remain render-time. Responsive typography retains its shared base class and order-aware class selection without introducing empty styles for proven inert base-preset lookups. This scheduling repair adds no build errors or styling runtime.", + "date": "2026-10-07T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_typography_classes.json b/.changepacks/changepack_log_typography_classes.json new file mode 100644 index 000000000..c47791b47 --- /dev/null +++ b/.changepacks/changepack_log_typography_classes.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Select dynamic typography and enum classes from the authored value exactly once in source order, without typography CSS variables or declarations. Preserve responsive and selector-scoped presets, conditional style order, template coercion and the existing spread contract. No new build errors or styling runtime are introduced.", + "date": "2026-10-07T00:00:00.000Z" +} 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..554a224d6 --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_tests.rs @@ -0,0 +1,275 @@ +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) { + // 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-)?(?:(?:(?:[a-z_][a-z0-9_]*|[a-z0-9_]*a-d|F(?:L[a-z0-9_]{0,16}|H[a-z0-9_]{16}))-)?[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 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#"
"#)] +#[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}}); +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..f2395b0f3 --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs @@ -0,0 +1,285 @@ +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).unwrap_or_else(|error| panic!("{error}")); + 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 properties = with_style_sheet(|sheet| { + sheet.properties["private.tsx"] + .values() + .flat_map(BTreeMap::values) + .flatten() + .cloned() + .collect::>() + }); + 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(); +} + +#[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_admission_tests.rs b/bindings/devup-ui-wasm/src/cache_admission_tests.rs new file mode 100644 index 000000000..dc7d91dc5 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_admission_tests.rs @@ -0,0 +1,48 @@ +use super::*; + +#[path = "cache_allocator_tests.rs"] +mod allocators; +#[path = "cache_naming_coverage_tests.rs"] +mod naming_coverage; +#[path = "cache_reset_tests.rs"] +mod resets; +#[path = "cache_seed_tests.rs"] +mod seeds; +#[path = "cache_typography_coverage_tests.rs"] +mod typography_coverage; + +fn fresh() { + reset_build_state_internal(); + set_debug(false); + register_theme_internal(sheet::theme::Theme::default()); +} + +fn output(file: &str, source: &str, single: bool) -> (String, String, String) { + let compiled = code_extract_internal( + file, + source, + "@devup-ui/react", + "df".into(), + single, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + with_style_sheet(|sheet| { + ( + compiled.code(), + sheet.create_css(None, false), + sheet.create_css(Some(file), false), + ) + }) +} + +fn companions(value: &serde_json::Value) { + cache_restore::classes(Some( + serde_json::from_value(value["classMap"].clone()).unwrap_or_else(|error| panic!("{error}")), + )); + cache_restore::files(Some( + serde_json::from_value(value["fileMap"].clone()).unwrap_or_else(|error| panic!("{error}")), + )); +} diff --git a/bindings/devup-ui-wasm/src/cache_allocator_proof.rs b/bindings/devup-ui-wasm/src/cache_allocator_proof.rs new file mode 100644 index 000000000..5b58547f8 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_allocator_proof.rs @@ -0,0 +1,123 @@ +use css::{ + Naming, + content_name::AtomContent, + sparse_site::{Site, SourceFile}, +}; +use sheet::{StyleSheet, StyleSheetProperty, cache_snapshot::ClassMap}; + +fn namespace_id(namespace: &str) -> Option { + namespace.strip_prefix("D9-")?.parse().ok() +} + +fn counter_label(number: usize) -> String { + let encoded = Site { + file: SourceFile::D9(0), + at: number, + role: 0, + } + .variable_name(""); + let label = encoded.trim_start_matches("---Sa-"); + match label.strip_suffix("ad") { + Some(head) => format!("{head}a-d"), + None => label.to_string(), + } +} + +fn variable_owner(value: &str, prefix: &str, owner_label: &str) -> bool { + let variable = value.strip_prefix("var(").and_then(|value| { + value + .strip_suffix(')') + .or_else(|| value.strip_suffix(") !important")) + }); + let mut number = owner_label.to_string(); + number.retain(|character| character != '-'); + variable.is_some_and(|variable| variable.starts_with(&format!("---{prefix}S{number}-"))) +} + +fn declaration_matches( + property: &StyleSheetProperty, + position: (u8, u8), + slot: (&str, &str), +) -> bool { + let (key, owner_label) = slot; + let prefix = css::get_prefix().unwrap_or_default(); + [false, true].into_iter().any(|dynamic| { + let content = AtomContent { + property: &property.property, + value: Some(&property.value), + naming: Naming::Own, + level: position.0, + order: position.1, + selector: property.selector.as_ref(), + layer: property.layer.as_deref(), + dynamic, + } + .content(); + css::atom_name::hex(&content.lossless) == key + && (!dynamic || variable_owner(&property.value, &prefix, owner_label)) + }) +} + +pub(super) fn validate(sheet: &StyleSheet, classes: &ClassMap) -> bool { + if classes.keys().any(|namespace| { + namespace_id(namespace) + .is_some_and(|id| !sheet.source_ids.values().any(|source| *source == id)) + || (namespace.starts_with("D9-") && namespace_id(namespace).is_none()) + }) { + return false; + } + let prefix = css::get_prefix().unwrap_or_default(); + sheet.properties.values().all(|orders| { + orders.iter().all(|(order, levels)| { + levels.iter().all(|(level, properties)| { + properties.iter().all(|property| { + if property.owner_reset || sheet.names.contains_key(&property.class_name) { + return true; + } + let Some((owner, owner_label)) = sheet + .source_ids + .values() + .filter_map(|owner| { + usize::try_from(*owner) + .ok() + .map(|number| (*owner, counter_label(number))) + }) + .filter(|(_, label)| { + property + .class_name + .starts_with(&format!("{prefix}{label}-")) + }) + .max_by_key(|(_, label)| label.len()) + else { + return true; + }; + let Some(slots) = classes.get(&format!("D9-{owner}")) else { + return false; + }; + slots.iter().any(|(key, slot)| { + property.class_name + == format!("{prefix}{owner_label}-{}", counter_label(*slot)) + && if property.typography { + crate::cache_special_proof::counter_typography( + property, + (levels, *order), + key, + ) + .is_some() + } else { + let owns_reset = levels.values().flatten().any(|other| { + other.owner_reset && other.class_name == property.class_name + }); + (!owns_reset || key.starts_with("642d")) + && declaration_matches( + property, + (*level, *order), + (key, &owner_label), + ) + } + }) + }) + }) + }) + }) +} diff --git a/bindings/devup-ui-wasm/src/cache_allocator_tests.rs b/bindings/devup-ui-wasm/src/cache_allocator_tests.rs new file mode 100644 index 000000000..e17021313 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_allocator_tests.rs @@ -0,0 +1,130 @@ +use super::*; +use std::fmt::Write; + +const SOURCE: &str = + "import {Box} from '@devup-ui/react';export const x=(p)=>;"; + +enum MapDamage { + ClassGap, + FileGap, + SourceGap, + SwappedSlots, + MissingClasses, + MissingSources, + WrongKey, + WrongVariableSource, +} + +#[rstest] +#[case(MapDamage::ClassGap)] +#[case(MapDamage::FileGap)] +#[case(MapDamage::SourceGap)] +#[case(MapDamage::SwappedSlots)] +#[case(MapDamage::MissingClasses)] +#[case(MapDamage::MissingSources)] +#[case(MapDamage::WrongKey)] +#[case(MapDamage::WrongVariableSource)] +#[serial] +fn invalid_allocator_snapshot_is_cold_with_matching_or_absent_companions( + #[case] damage: MapDamage, + #[values(false, true)] matching: bool, +) { + // Given: real counter declarations and their complete self-contained allocator snapshot. + fresh(); + seed_file_map(vec!["fresh.tsx".into(), "other.tsx".into()]); + let cold = output("fresh.tsx", SOURCE, false); + let mut value = snapshot(); + match damage { + MapDamage::ClassGap => { + let classes = value["classMap"]["D9-0"] + .as_object_mut() + .unwrap_or_else(|| panic!("counter namespace")); + *classes + .values_mut() + .find(|id| **id == 0) + .unwrap_or_else(|| panic!("slot zero")) = 99.into(); + } + MapDamage::FileGap => value["fileMap"]["fresh.tsx"] = 99.into(), + MapDamage::SourceGap => value["sourceIds"]["fresh.tsx"] = 99.into(), + MapDamage::SwappedSlots => { + for id in value["classMap"]["D9-0"] + .as_object_mut() + .unwrap_or_else(|| panic!("counter namespace")) + .values_mut() + { + if *id == 0 { + *id = 1.into(); + } else if *id == 1 { + *id = 0.into(); + } + } + } + MapDamage::MissingClasses => value["classMap"] = serde_json::json!({}), + MapDamage::MissingSources => value["sourceIds"] = serde_json::json!({}), + MapDamage::WrongKey => { + let classes = value["classMap"]["D9-0"] + .as_object_mut() + .unwrap_or_else(|| panic!("counter namespace")); + let key = classes + .keys() + .next() + .cloned() + .unwrap_or_else(|| panic!("key")); + let slot = classes.remove(&key).unwrap_or_else(|| panic!("slot")); + classes.insert("wrong-key".into(), slot); + } + MapDamage::WrongVariableSource => { + for property in value["properties"]["fresh.tsx"]["255"]["0"] + .as_array_mut() + .unwrap_or_else(|| panic!("properties")) + { + for field in ["p", "v"] { + if let Some(text) = property[field].as_str() { + property[field] = text.replace("---Sa-", "---Sb-").into(); + } + } + } + } + } + fresh(); + seed_file_map(vec!["fresh.tsx".into(), "other.tsx".into()]); + let before = snapshot(); + if matching { + companions(&value); + } + // When: invalid allocator state reaches authoritative admission without a companion veto. + assert_eq!(import(value), Ok(())); + // Then: fresh numbering survives, no cached declarations are adopted, and full output is cold. + assert_eq!(snapshot(), before); + assert_eq!(output("fresh.tsx", SOURCE, false), cold); + fresh(); +} + +#[rstest] +#[case(1)] +#[case(31)] +#[serial] +fn current_counter_cache_restores_file_and_slot_ad_blocker_splices(#[case] slots: usize) { + // Given: a complete real cache crossing both original-file and counter-slot label boundaries. + let configure = || { + fresh(); + set_prefix(Some("du-FLa-".into())); + seed_file_map((0..31).map(|file| format!("{file:02}.tsx")).collect()); + }; + configure(); + let mut elements = String::new(); + for value in 0..slots { + write!(elements, "").unwrap_or_else(|error| panic!("{error}")); + } + let source = format!("import {{Box}} from '@devup-ui/react';export const x=<>{elements}; "); + let cold = output("30.tsx", &source, false); + let value = snapshot(); + assert!(cold.0.contains("du-FLa-a-d-"), "{}", cold.0); + configure(); + // When: the valid snapshot is restored without any companion map authority. + assert_eq!(import(value.clone()), Ok(())); + // Then: admission keeps the cache and the complete generated output exactly. + assert_eq!(snapshot(), value); + assert_eq!(output("30.tsx", &source, false), cold); + fresh(); +} diff --git a/bindings/devup-ui-wasm/src/cache_atom_proof.rs b/bindings/devup-ui-wasm/src/cache_atom_proof.rs new file mode 100644 index 000000000..751de4644 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_atom_proof.rs @@ -0,0 +1,68 @@ +use css::{Naming, content_name::AtomContent}; +use sheet::StyleSheet; + +#[path = "cache_reset_proof.rs"] +mod resets; + +pub(crate) fn validate(sheet: &StyleSheet) -> Result<(), String> { + if !resets::validate(sheet) { + return Err("cached owner reset has no exact dynamic consumer proof".into()); + } + crate::cache_special_proof::validate_frames(sheet)?; + for orders in sheet.properties.values() { + for (order, levels) in orders { + for (level, properties) in levels { + for property in properties { + let Some(claim) = sheet.names.get(&property.class_name) else { + continue; + }; + if property.owner_reset { + continue; + } + if property.typography { + if crate::cache_special_proof::typography( + property, + (levels, *order), + &claim.descriptor, + ) + .is_none() + { + return Err(format!( + "cached typography `{}` has a corrupt exact declaration proof", + property.class_name + )); + } + continue; + } + let valid = [Naming::Own, Naming::Risky].into_iter().any(|naming| { + [false, true].into_iter().any(|dynamic| { + let content = AtomContent { + property: &property.property, + value: Some(&property.value), + naming, + level: *level, + order: *order, + selector: property.selector.as_ref(), + layer: property.layer.as_deref(), + dynamic, + } + .content(); + content.descriptor == claim.descriptor + && crate::cache_descriptor::name_matches( + &content, + &property.class_name, + ) + }) + }); + if !valid { + return Err(format!( + "cached atom `{}` has a corrupt exact declaration proof", + property.class_name + )); + } + } + } + } + } + Ok(()) +} diff --git a/bindings/devup-ui-wasm/src/cache_descriptor.rs b/bindings/devup-ui-wasm/src/cache_descriptor.rs new file mode 100644 index 000000000..7695eaf4d --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_descriptor.rs @@ -0,0 +1,44 @@ +pub(crate) struct Cursor<'a>(pub(crate) &'a [u8]); + +impl<'a> Cursor<'a> { + pub(crate) fn take(&mut self, length: usize) -> Option<&'a [u8]> { + let (head, tail) = self.0.split_at_checked(length)?; + self.0 = tail; + Some(head) + } + + pub(crate) fn byte(&mut self) -> Option { + self.take(1)?.first().copied() + } + + pub(crate) fn count(&mut self) -> Option { + usize::try_from(u64::from_be_bytes(self.take(8)?.try_into().ok()?)).ok() + } + + pub(crate) fn text(&mut self) -> Option<&'a str> { + let length = self.count()?; + std::str::from_utf8(self.take(length)?).ok() + } +} + +pub(crate) fn name_matches(content: &css::content_name::ContentName, name: &str) -> bool { + name.rfind(|character: char| character.is_ascii_uppercase()) + .and_then(|marker| marker.checked_sub(1)) + .is_some_and(|domain| content.name(&name[..domain]) == name) +} + +pub(crate) fn hex_bytes(value: &str) -> Option> { + let digit = |byte| match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + }; + let (chunks, remainder) = value.as_bytes().as_chunks::<2>(); + if !remainder.is_empty() { + return None; + } + chunks + .iter() + .map(|pair| Some(digit(pair[0])? * 16 + digit(pair[1])?)) + .collect() +} 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..a3af1e6c4 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_names.rs @@ -0,0 +1,173 @@ +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>) { + if let Err(incoming) = result { + ERROR.with_borrow_mut(|error| { + if error.is_none() { + *error = Some(incoming.clone()); + } + }); + } +} + +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> { + crate::cache_atom_proof::validate(sheet)?; + for (file, orders) in &sheet.properties { + for property in orders.values().flat_map(|levels| levels.values()).flatten() { + if property.hoisted + && !sheet + .atom_plan + .as_ref() + .is_some_and(|plan| plan.contains(file)) + { + return Err(format!( + "cached hoisted atom `{}` has no frozen bucket proof", + property.class_name + )); + } + } + } + for property in sheet + .properties + .values() + .flat_map(|orders| orders.values()) + .flat_map(|levels| levels.values()) + .flatten() + { + if property.owner_reset { + crate::cache_source_names::validate(&property.property, &sheet.names)?; + } + if let Some(reference) = property.value.strip_prefix("var(") { + crate::cache_source_names::validate(reference, &sheet.names)?; + } + } + let atoms = sheet.properties.iter().flat_map(|(file, orders)| { + orders.iter().flat_map(move |(order, levels)| { + levels.values().flatten().map(move |property| { + ( + property.class_name.as_str(), + !file.is_empty() && *order != 0 && !property.hoisted, + ) + }) + }) + }); + let frames = sheet + .keyframes + .values() + .flat_map(|frames| frames.keys().map(|name| (name.as_str(), false))); + let prefix = css::get_prefix().unwrap_or_default(); + for (name, per_file) in atoms.chain(frames) { + let generated = name.strip_prefix(&prefix).is_some_and(content_name); + if per_file + && generated + && name + .rfind(|character: char| character.is_ascii_uppercase()) + .and_then(|index| index.checked_sub(1)) + == Some(prefix.len()) + && name.starts_with(&prefix) + { + return Err(format!( + "cached per-file generated name `{name}` has no sheet namespace; rebuild all caches with the current naming contract" + )); + } + if generated && !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" + )); + } + if generated + && let Some(domain) = name + .rfind(|character: char| character.is_ascii_uppercase()) + .and_then(|index| index.checked_sub(1)) + && let Some(scope) = name[..domain].strip_suffix('-') + && scope.len() >= prefix.len() + && let Some(marker) = scope.rfind('F') + && marker >= prefix.len() + && matches!(scope.as_bytes().get(marker + 1), Some(b'L' | b'H')) + { + let claim = sheet.names.get(scope).ok_or_else(|| format!( + "cached generated scope `{scope}` has no exact sheet-key claim; rebuild all caches with the current naming contract" + ))?; + let content = css::content_name::ContentName::scope(&claim.content); + if claim.descriptor != content.descriptor || content.name(&scope[..marker]) != scope { + return Err(format!( + "cached generated scope `{scope}` has a corrupt exact sheet-key claim; rebuild all caches" + )); + } + } + } + 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", + "OXred", + ] { + assert!(!content_name(name), "{name}"); + } + } +} diff --git a/bindings/devup-ui-wasm/src/cache_naming_coverage_tests.rs b/bindings/devup-ui-wasm/src/cache_naming_coverage_tests.rs new file mode 100644 index 000000000..772b8a112 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_naming_coverage_tests.rs @@ -0,0 +1,109 @@ +use super::*; + +#[rstest] +#[case(0)] +#[case(30)] +#[serial] +fn dynamic_counter_owner_round_trip_keeps_the_validated_label(#[case] owner: usize) { + // Given: real dynamic declarations at the plain and ad-blocker owner-label boundaries. + let configure = || { + fresh(); + set_prefix(Some("du-FLa-".into())); + seed_file_map((0..=owner).map(|id| format!("{id:02}.tsx")).collect()); + }; + configure(); + let file = format!("{owner:02}.tsx"); + let source = "import {Box} from '@devup-ui/react';export const x=(p)=>;"; + let cold = output(&file, source, false); + let value = snapshot(); + assert!(cold.2.contains("color:var(---du-FLa-S"), "{}", cold.2); + configure(); + // When: current admission reuses the already validated owner label for variable proof. + assert_eq!(import(value.clone()), Ok(())); + // Then: the complete allocator/reset/declaration snapshot and full output round trip. + assert_eq!(snapshot(), value); + assert_eq!(output(&file, source, false), cold); + fresh(); +} + +#[test] +#[serial] +fn hoisted_declaration_without_bucket_proof_is_cold() { + // Given: a genuine current atom whose stored placement falsely claims hoisting. + fresh(); + let source = "import {Box} from '@devup-ui/react';export const x=;"; + let cold = output("fresh.tsx", source, false); + let mut value = snapshot(); + value["properties"]["fresh.tsx"]["255"]["0"][0]["h"] = true.into(); + fresh(); + let before = snapshot(); + companions(&value); + // When: admission checks actual placement against the frozen plan. + assert_eq!(import(value), Ok(())); + // Then: the entire snapshot remains cold and extraction remains usable. + assert_eq!(snapshot(), before); + assert_eq!(output("fresh.tsx", source, false), cold); + fresh(); +} + +#[test] +#[serial] +fn invalid_manual_generated_claim_does_not_replace_live_sheet() { + // Given: authored manual controls plus a generated-looking name lacking its claim. + fresh(); + let mut live = StyleSheet::default(); + live.add_property( + "manual-card", + "color", + 0, + "red", + None, + None, + Some("fresh.tsx"), + ); + assert_eq!(import_sheet_internal(live), Ok(())); + let before = snapshot(); + let css = with_style_sheet(|sheet| sheet.create_css(Some("fresh.tsx"), false)); + assert!( + css.contains(&format!(".manual-card{{color:{}}}", "red")), + "{css}" + ); + let mut incoming = StyleSheet::default(); + incoming.add_property("OLcolor-vblue", "color", 0, "blue", None, Some(0), None); + // When: in-memory manual import still enforces the generated-name contract. + assert_eq!(import_sheet_internal(incoming), Ok(())); + // Then: authored controls stay intact and the rejected claim is not sticky. + assert_eq!(snapshot(), before); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("fresh.tsx"), false)), + css + ); + assert_eq!(cache_names::check(), Ok(())); + fresh(); +} + +#[test] +#[serial] +fn wrong_keyframe_descriptor_variant_is_cold() { + // Given: actual keyframe output, with an atom variant substituted in the frame proof. + fresh(); + let source = "import {keyframes} from '@devup-ui/react';export const x=keyframes({from:{opacity:0},to:{opacity:1}});"; + let cold = output("fresh.tsx", source, false); + let mut value = snapshot(); + let claim = value["names"] + .as_object_mut() + .unwrap_or_else(|| panic!("names object")) + .iter_mut() + .find(|(name, _)| name.starts_with('K')) + .unwrap_or_else(|| panic!("keyframe claim")) + .1; + claim["descriptor"][1] = 1.into(); + fresh(); + let before = snapshot(); + // When: exact frame decoding rejects the wrong semantic variant. + assert_eq!(import(value), Ok(())); + // Then: no cached state is installed and complete binding output is cold. + assert_eq!(snapshot(), before); + assert_eq!(output("fresh.tsx", source, false), cold); + fresh(); +} 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..29d5ea412 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_protocol_tests.rs @@ -0,0 +1,148 @@ +use super::*; +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)] +#[case(true, true)] +#[serial] +fn legacy_serialized_import_is_cold_and_rewritten_current( + #[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 = StyleSheet::default().create_css(Some("private.tsx"), false); + let global = StyleSheet::default().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).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"], 4); + 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).unwrap_or_else(|error| panic!("{error}")); + 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).unwrap_or_else(|error| panic!("{error}")); + // 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/cache_reset_proof.rs b/bindings/devup-ui-wasm/src/cache_reset_proof.rs new file mode 100644 index 000000000..40337c327 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_reset_proof.rs @@ -0,0 +1,36 @@ +use sheet::StyleSheet; + +pub(super) fn validate(sheet: &StyleSheet) -> bool { + sheet.properties.values().all(|orders| { + orders.values().all(|levels| { + levels.iter().all(|(level, properties)| { + properties + .iter() + .filter(|property| property.owner_reset) + .all(|reset| { + *level == 0 + && reset.value == "initial" + && reset.property.starts_with("--") + && reset.selector.is_none() + && reset.layer.is_none() + && !reset.typography + && levels.values().flatten().any(|consumer| { + !consumer.owner_reset + && !consumer.typography + && consumer.class_name == reset.class_name + && consumer.hoisted == reset.hoisted + && consumer.value.strip_prefix("var(").and_then(|value| { + value + .strip_suffix(')') + .or_else(|| value.strip_suffix(") !important")) + }) == Some(reset.property.as_str()) + && sheet + .names + .get(&consumer.class_name) + .is_none_or(|claim| claim.descriptor.starts_with(&[1, 2])) + }) + }) + }) + }) + }) +} diff --git a/bindings/devup-ui-wasm/src/cache_reset_tests.rs b/bindings/devup-ui-wasm/src/cache_reset_tests.rs new file mode 100644 index 000000000..16634e716 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_reset_tests.rs @@ -0,0 +1,124 @@ +use super::*; + +const STATIC: &str = "import {css} from '@devup-ui/react';export const x=css({color:'red'});"; +const DYNAMIC: &str = + "import {Box} from '@devup-ui/react';export const x=(p)=>;"; + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn forged_static_reset_is_cold_when_companions_match_or_are_absent(#[case] matching: bool) { + // Given: a real complete snapshot with a static declaration disguised as a reset. + fresh(); + let cold = output("reset.tsx", STATIC, true); + let mut value = snapshot(); + let property = &mut value["properties"][""]["255"]["0"][0]; + property["r"] = true.into(); + property["v"] = "blue".into(); + fresh(); + if matching { + companions(&value); + } + // When: the corrupt current snapshot reaches admission. + assert_eq!(import(value), Ok(())); + // Then: no poisoned declaration survives and whole output is cold. + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + assert_eq!(output("reset.tsx", STATIC, true), cold); + fresh(); +} + +enum ResetDamage { + Value, + Selector, + Level, + Layer, + Typography, + Variable, + Orphan, +} + +#[rstest] +#[case(ResetDamage::Value)] +#[case(ResetDamage::Selector)] +#[case(ResetDamage::Level)] +#[case(ResetDamage::Layer)] +#[case(ResetDamage::Typography)] +#[case(ResetDamage::Variable)] +#[case(ResetDamage::Orphan)] +#[serial] +fn corrupt_owner_reset_restores_nothing( + #[case] damage: ResetDamage, + #[values(false, true)] seeded: bool, +) { + // Given: independently cold output and a damaged genuine owner reset. + fresh(); + if seeded { + seed_file_map(vec!["reset.tsx".into()]); + } + let cold = output("reset.tsx", DYNAMIC, false); + let mut value = snapshot(); + let properties = value["properties"]["reset.tsx"]["255"]["0"] + .as_array_mut() + .unwrap_or_else(|| panic!("properties")); + let reset = properties + .iter_mut() + .find(|property| property["r"] == true) + .unwrap_or_else(|| panic!("owner reset")); + match damage { + ResetDamage::Value => reset["v"] = "inherit".into(), + ResetDamage::Selector => reset["s"] = serde_json::json!({"Selector": "&:hover"}), + ResetDamage::Level => { + let reset = reset.clone(); + properties.retain(|property| property["r"] != true); + value["properties"]["reset.tsx"]["255"]["1"] = serde_json::json!([reset]); + } + ResetDamage::Layer => reset["l"] = "t".into(), + ResetDamage::Typography => reset["t"] = true.into(), + ResetDamage::Variable => reset["p"] = "--theme".into(), + ResetDamage::Orphan => properties.retain(|property| property["r"] == true), + } + fresh(); + if seeded { + seed_file_map(vec!["reset.tsx".into()]); + } + // When: malformed reset state is admitted through the serialized API. + assert_eq!(import(value), Ok(())); + // Then: all cache state is discarded, including the reset exemption. + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + assert_eq!(output("reset.tsx", DYNAMIC, false), cold); + fresh(); +} + +#[test] +#[serial] +fn invalid_reset_claim_does_not_latch_a_false_collision() { + // Given: a live exact red claim and corrupt descriptor bytes hidden behind r=true. + fresh(); + output("live.tsx", STATIC, true); + let before = snapshot(); + let mut value = before.clone(); + value["properties"][""]["255"]["0"][0]["r"] = true.into(); + value["names"]["OLcolor-vred"]["descriptor"] + .as_array_mut() + .unwrap_or_else(|| panic!("descriptor")) + .push(99.into()); + // When: the invalid snapshot overlaps a genuine live claim. + assert_eq!(import(value), Ok(())); + // Then: the live sheet remains unchanged and extraction is not poisoned by a latch. + assert_eq!(snapshot(), before); + assert!( + code_extract_internal( + "later.tsx", + STATIC, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new() + ) + .is_ok() + ); + fresh(); +} diff --git a/bindings/devup-ui-wasm/src/cache_restore.rs b/bindings/devup-ui-wasm/src/cache_restore.rs new file mode 100644 index 000000000..794902ec3 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_restore.rs @@ -0,0 +1,233 @@ +use std::{cell::RefCell, collections::BTreeMap}; + +use sheet::{ + StyleSheet, + cache_snapshot::{CacheRestore, ClassMap, FileMap}, +}; + +use crate::{cache_names, with_style_sheet_mut}; + +#[path = "cache_allocator_proof.rs"] +mod allocator; + +struct Authority { + classes: ClassMap, + files: FileMap, + before: StyleSheet, + before_classes: ClassMap, + before_files: FileMap, + before_sources: BTreeMap, + before_plan: Option>, + fresh_seeds: Vec>, +} + +#[derive(Default)] +enum Companion { + #[default] + Unseen, + Invalid, + Supplied(T), +} + +impl Companion { + fn matches(&self, expected: &T) -> bool { + match self { + Self::Unseen => true, + Self::Invalid => false, + Self::Supplied(value) => value == expected, + } + } +} + +impl From> for Companion { + fn from(value: Option) -> Self { + match value { + Some(value) => Self::Supplied(value), + None => Self::Invalid, + } + } +} + +#[derive(Default)] +struct Restore { + classes: Companion, + files: Companion, + authority: Option, +} + +thread_local! { + static RESTORE: RefCell = RefCell::new(Restore::default()); +} + +pub(crate) fn absent() -> StyleSheet { + StyleSheet { + cache_restore: CacheRestore::Rejected, + ..StyleSheet::default() + } +} + +fn set_classes(classes: ClassMap) { + css::class_map::set_class_map( + classes + .into_iter() + .map(|(file, classes)| (file, classes.into_iter().collect())) + .collect(), + ); +} + +fn set_files(files: FileMap) { + css::file_map::set_file_map(files.into_iter().collect()); +} + +fn rollback(state: &mut Restore) { + if let Some(mut authority) = state.authority.take() { + with_style_sheet_mut(|current| { + authority.before.theme = std::mem::take(&mut current.theme); + *current = authority.before; + }); + set_classes(authority.before_classes); + set_files(authority.before_files); + css::file_map::set_original_ids(authority.before_sources); + css::atom_hoist::restore_atom_plan(authority.before_plan); + for files in authority.fresh_seeds { + css::file_map::seed_file_numbers(&files); + } + } +} + +pub(crate) fn clear() { + RESTORE.with_borrow_mut(|state| *state = Restore::default()); +} + +pub(crate) fn seeded(files: &[String]) { + RESTORE.with_borrow_mut(|state| { + if let Some(authority) = &mut state.authority { + authority.fresh_seeds.push(files.to_vec()); + } + }); +} + +pub(crate) fn classes(incoming: Option) { + let incoming = Companion::from(incoming); + RESTORE.with_borrow_mut(|state| { + if state + .authority + .as_ref() + .is_some_and(|authority| incoming.matches(&authority.classes)) + { + state.classes = Companion::Unseen; + return; + } + if state + .authority + .as_ref() + .is_some_and(|authority| !incoming.matches(&authority.classes)) + { + rollback(state); + } + state.classes = incoming; + }); +} + +pub(crate) fn files(incoming: Option) { + let incoming = Companion::from(incoming); + RESTORE.with_borrow_mut(|state| { + if state + .authority + .as_ref() + .is_some_and(|authority| incoming.matches(&authority.files)) + { + state.files = Companion::Unseen; + return; + } + if state + .authority + .as_ref() + .is_some_and(|authority| !incoming.matches(&authority.files)) + { + rollback(state); + } + state.files = incoming; + }); +} + +pub(crate) fn import(mut incoming: StyleSheet) -> Result<(), String> { + let result = + RESTORE.with_borrow_mut(|state| match std::mem::take(&mut incoming.cache_restore) { + CacheRestore::Rejected => { + rollback(state); + Ok(()) + } + CacheRestore::Manual => { + if cache_names::validate(&incoming).is_err() { + return Ok(()); + } + with_style_sheet_mut(|current| { + let claims = + sheet::name_registry::preflight(¤t.names, incoming.names.clone()) + .map_err(|error| error.to_string())?; + incoming.names = current.names.clone(); + incoming.names.extend(claims); + incoming.theme = std::mem::take(&mut current.theme); + css::atom_hoist::restore_atom_plan(incoming.atom_plan.clone()); + *current = incoming; + Ok(()) + }) + } + CacheRestore::Serialized { classes, files } => { + if !allocator::validate(&incoming, &classes) + || cache_names::validate(&incoming).is_err() + || !state.classes.matches(&classes) + || !state.files.matches(&files) + { + rollback(state); + return Ok(()); + } + let claims = crate::with_style_sheet(|current| { + sheet::name_registry::preflight(¤t.names, incoming.names.clone()) + .map_err(|error| error.to_string()) + })?; + rollback(state); + with_style_sheet_mut(|current| { + incoming.names = current.names.clone(); + incoming.names.extend(claims); + let before_classes = css::class_map::with_class_map(|map| { + map.iter() + .map(|(file, classes)| { + ( + file.clone(), + classes.iter().map(|(key, id)| (key.clone(), *id)).collect(), + ) + }) + .collect() + }); + let before_files = css::file_map::with_file_map(|map| { + map.iter().map(|(file, id)| (file.clone(), *id)).collect() + }); + let before_sources = css::file_map::get_original_ids(); + let before_plan = css::atom_hoist::atom_plan(); + set_classes(classes.clone()); + set_files(files.clone()); + css::file_map::set_original_ids(incoming.source_ids.clone()); + css::atom_hoist::restore_atom_plan(incoming.atom_plan.clone()); + incoming.theme = std::mem::take(&mut current.theme); + let before = std::mem::replace(current, incoming); + state.authority = Some(Authority { + classes, + files, + before, + before_classes, + before_files, + before_sources, + before_plan, + fresh_seeds: Vec::new(), + }); + state.classes = Companion::Unseen; + state.files = Companion::Unseen; + Ok(()) + }) + } + }); + cache_names::record(&result); + result +} diff --git a/bindings/devup-ui-wasm/src/cache_seed_tests.rs b/bindings/devup-ui-wasm/src/cache_seed_tests.rs new file mode 100644 index 000000000..7692f6d72 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_seed_tests.rs @@ -0,0 +1,37 @@ +use super::*; + +fn seed_batches() { + seed_file_map(vec!["b.tsx".into(), "b.tsx".into()]); + seed_file_map(vec!["a.tsx".into(), "b.tsx".into()]); + seed_file_map(vec!["c.tsx".into(), "a.tsx".into(), "c.tsx".into()]); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn rollback_keeps_seed_call_order_and_repeated_files(#[case] class_companion: bool) { + // Given: independently cold incremental numbering and an admitted empty cache. + const SOURCE: &str = + "import {Box} from '@devup-ui/react';export const x=(p)=>;"; + fresh(); + seed_batches(); + let cold = output("a.tsx", SOURCE, false); + let cold_ids = css::file_map::get_original_ids(); + let cold_files = css::file_map::get_file_map(); + fresh(); + let empty = snapshot(); + assert_eq!(import(empty), Ok(())); + seed_batches(); + // When: an invalid companion rolls back cached state after three fresh seed calls. + if class_companion { + cache_restore::classes(None); + } else { + cache_restore::files(None); + } + // Then: original/delivery numbering and the full emitted output match those same cold calls. + assert_eq!(css::file_map::get_original_ids(), cold_ids); + assert_eq!(css::file_map::get_file_map(), cold_files); + assert_eq!(output("a.tsx", SOURCE, false), cold); + fresh(); +} diff --git a/bindings/devup-ui-wasm/src/cache_source_names.rs b/bindings/devup-ui-wasm/src/cache_source_names.rs new file mode 100644 index 000000000..b5a562ffc --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_source_names.rs @@ -0,0 +1,97 @@ +use css::content_name::ContentName; +use sheet::name_registry::NameRegistry; + +pub(crate) fn validate(text: &str, names: &NameRegistry) -> Result<(), String> { + let mut scanned = 0; + for (start, _) in text.match_indices("---") { + if start < scanned { + continue; + } + let tail = &text[start..]; + let end = tail + .find(|character: char| { + !(character.is_ascii_alphanumeric() + || character >= '\u{80}' + || matches!(character, '-' | '_')) + }) + .unwrap_or(tail.len()); + scanned = start + end; + let variable = &tail[..end]; + let Some(marker) = variable.rfind(|character: char| character.is_ascii_uppercase()) else { + continue; + }; + let (prefix, file_part) = match variable.as_bytes()[marker] { + b'L' | b'H' if variable[..marker].ends_with("SU") => { + (&variable[..marker - 1], &variable[marker - 1..]) + } + b'U' if variable[..marker].ends_with('S') => { + return Err(format!( + "cached source variable `{variable}` uses an unprotected source naming contract; rebuild all caches" + )); + } + _ => continue, + }; + let Some((file_part, _position)) = file_part.split_once('-') else { + return Err(format!( + "cached source variable `{variable}` has no source position; rebuild all caches" + )); + }; + let name = &variable[..prefix.len() + file_part.len()]; + let Some(claim) = names.get(name) else { + return Err(format!( + "cached source variable `{variable}` has no exact source claim; rebuild all caches with the current naming contract" + )); + }; + if claim.descriptor != claim.content.as_bytes() + || ContentName::source(&claim.content).name(prefix) != name + { + return Err(format!( + "cached source variable `{variable}` has an invalid exact source claim; rebuild all caches with the current naming contract" + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use css::style_origin::RealLocation; + use rstest::rstest; + use sheet::name_registry::NameClaim; + + #[rstest] + #[case("---Sa-a", true)] + #[case("---Mixed-SUH-prefix-Sa-b-c", true)] + #[case("--theme-color", true)] + #[case("--v1-color-0-selector", true)] + #[case("plain text", true)] + #[case("---lowercase", true)] + #[case("var(---SULa-b)", true)] + #[case("---SULa-b-c", true)] + #[case("var(---SULmissing-b)", false)] + #[case("---SULa", false)] + #[case("var(---SUa-b)", false)] + fn cached_source_variables_require_exact_claims_without_confusing_other_namespaces( + #[case] text: &str, + #[case] protected: bool, + ) { + // Given: one exact short source claim and arbitrary cached CSS tokens. + let names = NameRegistry::from([( + "---SULa".into(), + NameClaim { + descriptor: b"a".to_vec(), + content: "a".into(), + origin: None, + location: RealLocation::ModuleExport { + file: "/real/a.tsx".into(), + binding: None, + }, + }, + )]); + // When: the production cache boundary scans both reset/value token shapes. + let result = validate(text, &names); + // Then: only source-variable tokens require source proof and a position. + assert_eq!(result.is_ok(), protected, "{result:?}"); + } +} diff --git a/bindings/devup-ui-wasm/src/cache_special_proof.rs b/bindings/devup-ui-wasm/src/cache_special_proof.rs new file mode 100644 index 000000000..0d7a4b63d --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_special_proof.rs @@ -0,0 +1,152 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use css::{ + Naming, + content_name::{AtomContent, ContentName}, +}; +use sheet::{StyleSheet, StyleSheetProperty}; + +use crate::cache_descriptor::{Cursor, name_matches}; + +type FrameSteps = Vec<(String, Vec<(String, String)>)>; + +fn frames(descriptor: &[u8]) -> Option { + let mut cursor = Cursor(descriptor); + if cursor.take(2)? != [1, 3] { + return None; + } + let count = cursor.count()?; + let mut steps = Vec::new(); + for _ in 0..count { + let step = cursor.text()?.to_string(); + let count = cursor.count()?; + let mut properties = Vec::new(); + for _ in 0..count { + properties.push((cursor.text()?.to_string(), cursor.text()?.to_string())); + } + steps.push((step, properties)); + } + cursor.0.is_empty().then_some(steps) +} + +pub(crate) fn validate_frames(sheet: &StyleSheet) -> Result<(), String> { + for frames_map in sheet.keyframes.values() { + for (name, steps) in frames_map { + if let Some(claim) = sheet.names.get(name) { + let valid = frames(&claim.descriptor).is_some_and(|decoded| { + let content = ContentName::keyframes(&decoded); + name_matches(&content, name) + && decoded.into_iter().collect::>() == *steps + }); + if !valid { + return Err(format!( + "cached keyframes `{name}` have a corrupt exact frame proof" + )); + } + } + } + } + Ok(()) +} + +fn declarations(value: &str) -> Option> { + let bytes = crate::cache_descriptor::hex_bytes(value.strip_prefix('t')?)?; + let mut cursor = Cursor(&bytes); + let count = cursor.count()?; + let mut result = BTreeSet::new(); + for _ in 0..count { + result.insert(( + cursor.byte()?, + cursor.text()?.to_string(), + cursor.text()?.to_string(), + )); + } + (cursor.0.is_empty() && result.len() == count).then_some(result) +} + +pub(crate) fn typography( + property: &StyleSheetProperty, + group: (&BTreeMap>, u8), + descriptor: &[u8], +) -> Option<()> { + let mut cursor = Cursor(descriptor); + if cursor.take(2)? != [1, 1] { + return None; + } + let naming = match cursor.byte()? { + 0 => Naming::Own, + 1 => Naming::Risky, + _ => return None, + }; + if cursor.text()? != "typography" || cursor.byte()? != 1 { + return None; + } + let value = cursor.text()?; + let level = cursor.byte()?; + let order = cursor.byte()?; + if order != group.1 { + return None; + } + let content = AtomContent { + property: "typography", + value: Some(value), + naming, + level, + order, + selector: property.selector.as_ref(), + layer: property.layer.as_deref()?.strip_suffix(".t"), + dynamic: false, + } + .content(); + if content.descriptor != descriptor || !name_matches(&content, &property.class_name) { + return None; + } + let actual = group + .0 + .iter() + .flat_map(|(level, properties)| { + properties + .iter() + .filter(|other| other.class_name == property.class_name && other.typography) + .map(|other| (*level, other.property.clone(), other.value.clone())) + }) + .collect(); + (declarations(value)? == actual).then_some(()) +} + +pub(crate) fn counter_typography( + property: &StyleSheetProperty, + group: (&BTreeMap>, u8), + key: &str, +) -> Option<()> { + let lossless = String::from_utf8(crate::cache_descriptor::hex_bytes(key)?).ok()?; + let tail = lossless.strip_prefix("typography-v")?; + let value = tail.split('-').next()?; + let suffix = tail.strip_prefix(value)?; + let level = match suffix.strip_prefix("-l") { + Some(level) => level.split('-').next()?.parse().ok()?, + None => 0, + }; + let content = AtomContent { + property: "typography", + value: Some(value), + naming: Naming::Own, + level, + order: group.1, + selector: property.selector.as_ref(), + layer: property.layer.as_deref()?.strip_suffix(".t"), + dynamic: false, + } + .content(); + let actual = group + .0 + .iter() + .flat_map(|(level, properties)| { + properties + .iter() + .filter(|other| other.class_name == property.class_name && other.typography) + .map(|other| (*level, other.property.clone(), other.value.clone())) + }) + .collect(); + (content.lossless == lossless && declarations(value)? == actual).then_some(()) +} diff --git a/bindings/devup-ui-wasm/src/cache_typography_coverage_tests.rs b/bindings/devup-ui-wasm/src/cache_typography_coverage_tests.rs new file mode 100644 index 000000000..3341ec815 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_typography_coverage_tests.rs @@ -0,0 +1,158 @@ +use super::*; + +#[path = "cache_typography_descriptor_tests.rs"] +mod descriptors; + +fn configure(numbered: bool) { + fresh(); + register_theme_internal( + serde_json::from_value(serde_json::json!({ + "typography":{"body":[{"fontSize":"14px","fontFamily":"Sans"},null,{"fontSize":"18px"}]} + })) + .unwrap_or_else(|error| panic!("{error}")), + ); + if numbered { + seed_file_map(vec!["fresh.tsx".into()]); + } +} + +fn source(responsive: bool) -> &'static str { + if responsive { + "import {Box} from '@devup-ui/react';export const x=;" + } else { + "import {Box} from '@devup-ui/react';export const x=;" + } +} + +#[rstest] +#[serial] +fn current_typography_restores_exact_state_without_fresh_theme_authority( + #[values(false, true)] numbered: bool, + #[values(false, true)] responsive: bool, + #[values(false, true)] matching: bool, +) { + // Given: actual preset expansion, including the original-owner counter path. + configure(numbered); + let cold = output("fresh.tsx", source(responsive), false); + let value = snapshot(); + assert!(cold.2.contains("font-size:18px"), "{}", cold.2); + if numbered { + assert!(cold.0.contains("a-a"), "{}", cold.0); + assert_eq!( + value["classMap"]["D9-0"] + .as_object() + .unwrap_or_else(|| panic!("counter class map")) + .len(), + 1 + ); + } + fresh(); + let configured_theme = with_style_sheet(|sheet| sheet.theme.typography.len()); + if matching { + companions(&value); + } + // When: the cache alone proves its declarations with no current preset available. + assert_eq!(import(value.clone()), Ok(())); + // Then: this is admission, not a silent cold miss, and fresh theme stays fresh. + assert_eq!(snapshot(), value); + assert_eq!( + with_style_sheet(|sheet| sheet.theme.typography.len()), + configured_theme + ); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("fresh.tsx"), false)), + cold.2 + ); + register_theme_internal( + serde_json::from_value(serde_json::json!({ + "typography":{"body":[{"fontSize":"14px","fontFamily":"Sans"},null,{"fontSize":"18px"}]} + })) + .unwrap_or_else(|error| panic!("{error}")), + ); + assert_eq!(output("fresh.tsx", source(responsive), false), cold); + fresh(); +} + +#[rstest] +#[serial] +fn corrupt_typography_declarations_are_cold_not_sticky( + #[values(false, true)] numbered: bool, + #[values(false, true)] responsive: bool, +) { + // Given: a real cache with one expanded declaration changed but its identity retained. + configure(numbered); + let cold = output("fresh.tsx", source(responsive), false); + let mut value = snapshot(); + let mut changed = false; + for orders in value["properties"] + .as_object_mut() + .unwrap_or_else(|| panic!("properties object")) + .values_mut() + { + for levels in orders + .as_object_mut() + .unwrap_or_else(|| panic!("orders object")) + .values_mut() + { + for properties in levels + .as_object_mut() + .unwrap_or_else(|| panic!("levels object")) + .values_mut() + { + for property in properties + .as_array_mut() + .unwrap_or_else(|| panic!("declaration array")) + { + if property["t"] == true && property["p"] == "font-size" { + property["v"] = "99px".into(); + changed = true; + } + } + } + } + } + assert!(changed); + configure(numbered); + let before = snapshot(); + companions(&value); + // When: matching companions cannot excuse the corrupt declaration proof. + assert_eq!(import(value), Ok(())); + // Then: state and complete JS/global/local CSS are independently cold; no error latched. + assert_eq!(snapshot(), before); + assert_eq!(output("fresh.tsx", source(responsive), false), cold); + assert_eq!(cache_names::check(), Ok(())); + fresh(); +} + +#[rstest] +#[case("g0")] +#[case("0g")] +#[case("0")] +#[serial] +fn malformed_counter_identity_is_cold_with_matching_companions(#[case] key: &str) { + // Given: a real current counter typography cache with a non-hex or truncated slot key. + configure(true); + let cold = output("fresh.tsx", source(false), false); + let mut value = snapshot(); + let classes = value["classMap"]["D9-0"] + .as_object_mut() + .unwrap_or_else(|| panic!("counter class map")); + let previous = classes + .keys() + .next() + .cloned() + .unwrap_or_else(|| panic!("counter slot key")); + let slot = classes + .remove(&previous) + .unwrap_or_else(|| panic!("counter slot value")); + classes.insert(key.into(), slot); + configure(true); + let before = snapshot(); + companions(&value); + // When: the malformed identity reaches the counter proof itself. + assert_eq!(import(value), Ok(())); + // Then: no partial adoption or false collision, with full cold output preserved. + assert_eq!(snapshot(), before); + assert_eq!(output("fresh.tsx", source(false), false), cold); + fresh(); +} diff --git a/bindings/devup-ui-wasm/src/cache_typography_descriptor_tests.rs b/bindings/devup-ui-wasm/src/cache_typography_descriptor_tests.rs new file mode 100644 index 000000000..928aaf523 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_typography_descriptor_tests.rs @@ -0,0 +1,281 @@ +use super::*; +use crate::cache_descriptor::Cursor; +use css::{Naming, content_name::AtomContent}; + +#[rstest] +#[case(None)] +#[case(Some("first.tsx"))] +#[case(Some("second.tsx"))] +#[serial] +fn get_css_selects_global_or_numbered_file_with_main_import_control( + #[case] file: Option<&str>, + #[values(false, true)] import_main_css: bool, +) { + // Given: distinct file declarations and real global typography CSS. + configure(false); + let first = output( + "first.tsx", + "import {Box}from '@devup-ui/react';export const x=;", + false, + ); + let second = output( + "second.tsx", + "import {Box}from '@devup-ui/react';export const x=;", + false, + ); + assert!(first.2.contains("color:red"), "{}", first.2); + assert!(second.2.contains("color:blue"), "{}", second.2); + assert_ne!(first.2, second.2); + assert!(second.1.contains("font-size:14px"), "{}", second.1); + let file_num = file.map(|file| { + with_file_map(|map| { + map.get_by_left(file) + .copied() + .unwrap_or_else(|| panic!("registered file: {file}")) + }) + }); + let expected = match file { + None => second.1, + Some("first.tsx") => first.2, + Some("second.tsx") => second.2, + Some(other) => panic!("unexpected fixture: {other}"), + }; + // When + let actual = get_css_internal(file_num, import_main_css) + .unwrap_or_else(|error| panic!("getCss: {error}")); + // Then: file selection is exact; only numbered files receive the requested main import. + let main_import = "@import \"./devup-ui.css\";"; + assert_eq!( + actual.matches(main_import).count(), + usize::from(file.is_some() && import_main_css) + ); + assert_eq!(actual.replace(main_import, ""), expected); + fresh(); +} + +#[rstest] +#[serial] +fn get_css_uses_global_output_when_file_number_is_unmapped( + #[values(false, true)] import_main_css: bool, +) { + // Given + configure(false); + let expected = output("first.tsx", source(false), false).1; + // When + let actual = get_css_internal(Some(usize::MAX), import_main_css); + // Then: the existing failed lookup passes None to CSS generation. + assert_eq!(actual, Ok(expected)); + fresh(); +} + +enum Damage { + Header, + Naming, + Property, + ValueTag, + Order, + Trailing, +} + +#[rstest] +#[case(Damage::Header)] +#[case(Damage::Naming)] +#[case(Damage::Property)] +#[case(Damage::ValueTag)] +#[case(Damage::Order)] +#[case(Damage::Trailing)] +#[serial] +fn malformed_typography_descriptor_is_cold_before_claim_preflight(#[case] damage: Damage) { + // Given: an actual content-named typography atom and its versioned proof. + configure(false); + let cold = output("fresh.tsx", source(false), false); + let mut value = snapshot(); + let claim = value["names"] + .as_object_mut() + .unwrap_or_else(|| panic!("names object")) + .values_mut() + .find(|claim| { + claim["descriptor"] + .as_array() + .is_some_and(|bytes| bytes.get(1) == Some(&1.into())) + }) + .unwrap_or_else(|| panic!("typography claim")); + let mut descriptor: Vec = serde_json::from_value(claim["descriptor"].clone()) + .unwrap_or_else(|error| panic!("descriptor: {error}")); + match damage { + Damage::Header => descriptor[1] = 3, + Damage::Naming => descriptor[2] = 2, + Damage::Property => descriptor[11] = b'x', + Damage::ValueTag => descriptor[21] = 0, + Damage::Order => { + let mut cursor = Cursor(&descriptor[3..]); + assert_eq!(cursor.text(), Some("typography")); + assert_eq!(cursor.byte(), Some(1)); + assert!(cursor.text().is_some()); + assert_eq!(cursor.byte(), Some(0)); + let index = descriptor.len() - cursor.0.len(); + descriptor[index] = 1; + } + Damage::Trailing => descriptor.push(99), + } + claim["descriptor"] = + serde_json::to_value(descriptor).unwrap_or_else(|error| panic!("descriptor JSON: {error}")); + configure(false); + let before = snapshot(); + // When: cache admission encounters the corrupt exact typography proof. + assert_eq!(import(value), Ok(())); + // Then: the failure is a cold miss, not a real-name collision or partial adoption. + assert_eq!(snapshot(), before); + assert_eq!(output("fresh.tsx", source(false), false), cold); + assert_eq!(cache_names::check(), Ok(())); + fresh(); +} + +#[test] +#[serial] +fn current_typography_claim_collision_keeps_both_locations_and_live_state() { + // Given: two genuinely different preset expansions with real source locations. + let source = + "import {Box} from '@devup-ui/react';\nexport const x=;"; + let configure = |size: &str| { + fresh(); + register_theme_internal( + serde_json::from_value(serde_json::json!({"typography":{"body":{"fontSize":size}}})) + .unwrap_or_else(|error| panic!("theme fixture: {error}")), + ); + }; + configure("18px"); + output("cached.tsx", source, true); + let mut incoming = snapshot(); + let cached_claim = incoming["names"] + .as_object() + .unwrap_or_else(|| panic!("cached names object")) + .values() + .find(|claim| { + claim["descriptor"] + .as_array() + .is_some_and(|bytes| bytes.get(1) == Some(&1.into())) + }) + .unwrap_or_else(|| panic!("cached typography claim")) + .clone(); + configure("14px"); + output("live.tsx", source, true); + let before = snapshot(); + let live_name = before["names"] + .as_object() + .unwrap_or_else(|| panic!("live names object")) + .iter() + .find(|(_, claim)| { + claim["descriptor"] + .as_array() + .is_some_and(|bytes| bytes.get(1) == Some(&1.into())) + }) + .unwrap_or_else(|| panic!("live typography claim")) + .0 + .clone(); + incoming["properties"] = serde_json::json!({}); + incoming["names"] + .as_object_mut() + .unwrap_or_else(|| panic!("incoming names object")) + .insert(live_name.clone(), cached_claim); + let column = source + .split('\n') + .nth(1) + .unwrap_or_else(|| panic!("authored second line")) + .find("'body'") + .unwrap_or_else(|| panic!("authored typography operand")) + + 1; + // When: a complete current snapshot presents conflicting exact bytes under a live name. + let error = import(incoming) + .err() + .unwrap_or_else(|| panic!("conflicting claims must fail")); + // Then: both source sites survive, no live state changes, and the genuine error stays sticky. + assert!( + error.contains(&format!("cached.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains(&format!("live.tsx:2:{column}:")), "{error}"); + assert!(error.contains(&format!("`{live_name}`")), "{error}"); + assert_eq!(snapshot(), before); + let extraction_error = code_extract_internal( + "live.tsx", + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .err() + .unwrap_or_else(|| panic!("sticky collision rejects extraction")); + assert_eq!(extraction_error, error); + assert_eq!(get_css_internal(None, false), Err(error.clone())); + assert_eq!(snapshot(), before); + assert_eq!(cache_names::check(), Err(error)); + fresh(); +} + +#[rstest] +#[case(None)] +#[case(Some("t0"))] +#[case(Some("tgg"))] +#[case(Some("t0000000000000001"))] +#[serial] +fn risky_typography_proof_accepts_only_exact_complete_declarations( + #[case] malformed: Option<&str>, +) { + // Given: real expanded declarations, named with Risky provenance at the pure proof boundary. + configure(false); + output("fresh.tsx", source(false), false); + let (mut property, mut levels, descriptor) = with_style_sheet(|sheet| { + let levels = &sheet.properties["fresh.tsx"][&255]; + let property = levels + .values() + .flatten() + .find(|property| property.typography) + .unwrap_or_else(|| panic!("expanded typography property")); + ( + property.clone(), + levels.clone(), + sheet.names[&property.class_name].descriptor.clone(), + ) + }); + let mut cursor = Cursor(&descriptor[3..]); + assert_eq!(cursor.text(), Some("typography")); + assert_eq!(cursor.byte(), Some(1)); + let original = cursor + .text() + .unwrap_or_else(|| panic!("typography declaration encoding")); + let content = AtomContent { + property: "typography", + value: Some(malformed.unwrap_or(original)), + naming: Naming::Risky, + level: 0, + order: 255, + selector: property.selector.as_ref(), + layer: None, + dynamic: false, + } + .content(); + let old_name = property.class_name.clone(); + property.class_name = content.name("FLfresh_ptsx-"); + for properties in levels.values_mut() { + *properties = properties + .iter() + .cloned() + .map(|mut other| { + if other.class_name == old_name { + other.class_name.clone_from(&property.class_name); + } + other + }) + .collect(); + } + // When: provenance and declaration decoding are checked against the actual expanded group. + let result = + crate::cache_special_proof::typography(&property, (&levels, 255), &content.descriptor); + // Then: Risky is valid, but odd/non-hex/truncated declaration encodings cannot prove it. + assert_eq!(result, malformed.is_none().then_some(())); + fresh(); +} diff --git a/bindings/devup-ui-wasm/src/cache_v4_tests.rs b/bindings/devup-ui-wasm/src/cache_v4_tests.rs new file mode 100644 index 000000000..29270b3bc --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_v4_tests.rs @@ -0,0 +1,229 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; +#[path = "cache_admission_tests.rs"] +mod admission; +mod collisions; +mod configuration; +mod special; + +fn compile() -> (String, String) { + let output = code_extract_internal( + "fresh.tsx", + "import {Box} from '@devup-ui/react';export const View=(p)=>;", + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + ( + output.code(), + with_style_sheet(|sheet| sheet.create_css(Some("fresh.tsx"), false)), + ) +} + +fn snapshot() -> serde_json::Value { + serde_json::from_str(&export_sheet_internal().unwrap_or_else(|error| panic!("{error}"))) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn import(value: serde_json::Value) -> Result<(), String> { + import_sheet_internal(serde_json::from_value(value).unwrap_or_else(|_| cache_restore::absent())) +} + +#[rstest] +#[case(None)] +#[case(Some(0))] +#[case(Some(3))] +#[case(Some(5))] +#[serial] +fn serialized_old_or_future_generation_restores_nothing(#[case] version: Option) { + // Given: an independently compiled cold output and a poisoned old snapshot. + reset_build_state_internal(); + let cold = compile(); + reset_build_state_internal(); + let mut poison = StyleSheet::default(); + poison.add_property( + "old-alias", + "color", + 0, + "magenta", + None, + None, + Some("fresh.tsx"), + ); + import_sheet_internal(poison).unwrap_or_else(|error| panic!("{error}")); + let mut value: serde_json::Value = + serde_json::from_str(&export_sheet_internal().unwrap_or_else(|error| panic!("{error}"))) + .unwrap_or_else(|error| panic!("{error}")); + let object = value + .as_object_mut() + .unwrap_or_else(|| panic!("snapshot object")); + match version { + Some(version) => { + object.insert("atomNamingVersion".into(), version.into()); + } + None => { + object.remove("atomNamingVersion"); + } + } + reset_build_state_internal(); + // When: serialized admission runs through the existing typed internal API. + let incoming = serde_json::from_value(value).unwrap_or_else(|error| panic!("{error}")); + import_sheet_internal(incoming).unwrap_or_else(|error| panic!("{error}")); + // Then: no old rule/claim survives; complete JS and CSS are the cold bytes. + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + assert_eq!(compile(), cold); + reset_build_state_internal(); +} + +#[rstest] +#[case("names")] +#[case("atom_plan")] +#[case("properties")] +#[case("css")] +#[case("keyframes")] +#[case("global_css_files")] +#[case("imports")] +#[case("font_faces")] +#[case("sourceIds")] +#[case("classMap")] +#[case("fileMap")] +#[serial] +fn current_snapshot_requires_even_empty_restore_fields(#[case] field: &str) { + // Given: independent cold output and a current snapshot with one missing field. + reset_build_state_internal(); + let cold = compile(); + let mut value = snapshot(); + value + .as_object_mut() + .unwrap_or_else(|| panic!("snapshot object")) + .remove(field); + reset_build_state_internal(); + // When: the partially written object reaches serialized admission. + assert_eq!(import(value), Ok(())); + // Then: no partial state survives and complete output is cold. + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + assert_eq!(compile(), cold); + reset_build_state_internal(); +} + +#[rstest] +#[case(serde_json::json!(null))] +#[case(serde_json::json!(42))] +#[case(serde_json::json!([]))] +#[case(serde_json::json!({"atomNamingVersion":"4"}))] +#[case(serde_json::json!({"atomNamingVersion":4,"properties":false}))] +#[serial] +fn malformed_typed_cache_objects_are_absent(#[case] value: serde_json::Value) { + // Given: a separately compiled cold fixture and malformed serialized data. + reset_build_state_internal(); + let cold = compile(); + reset_build_state_internal(); + // When: the typed deserializer receives the malformed value. + assert_eq!(import(value), Ok(())); + // Then: extraction and current rewrite remain usable and byte-exact. + assert_eq!(compile(), cold); + assert_eq!(snapshot()["atomNamingVersion"], 4); + reset_build_state_internal(); +} + +#[rstest] +#[case(false, false)] +#[case(false, true)] +#[case(true, false)] +#[case(true, true)] +#[serial] +fn companion_mismatch_undoes_only_cache_restore(#[case] before: bool, #[case] class_map: bool) { + // Given: a poisoned authoritative snapshot and genuine fresh D9 configuration. + reset_build_state_internal(); + seed_file_map(vec!["fresh.tsx".into()]); + compile(); + let mut value = snapshot(); + value["sourceIds"]["fresh.tsx"] = 777.into(); + value["fileMap"]["fresh.tsx"] = 888.into(); + value["classMap"] = serde_json::json!({"fresh.tsx":{"poison":999}}); + reset_build_state_internal(); + seed_file_map(vec!["configured.tsx".into()]); + let cold = compile(); + reset_build_state_internal(); + seed_file_map(vec!["configured.tsx".into()]); + let mismatch = || { + if class_map { + cache_restore::classes(Some(BTreeMap::new())); + } else { + cache_restore::files(Some(BTreeMap::new())); + } + }; + // When: an old/missing companion disagrees before or after sheet admission. + if before { + mismatch(); + } + assert_eq!(import(value), Ok(())); + if !before { + mismatch(); + } + // Then: cached IDs/counters/rules disappear, but prepass configuration remains. + assert_eq!( + css::file_map::get_original_ids(), + BTreeMap::from([("configured.tsx".into(), 0)]) + ); + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + assert_eq!(with_class_map(HashMap::len), 0); + assert_eq!(compile(), cold); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn complete_current_restore_works_in_both_companion_orders(#[case] before: bool) { + // Given: a real current cache including original-owner counters and sparse variables. + reset_build_state_internal(); + seed_file_map(vec!["a.tsx".into(), "fresh.tsx".into()]); + let output = compile(); + let value = snapshot(); + let classes: sheet::cache_snapshot::ClassMap = + serde_json::from_value(value["classMap"].clone()).unwrap_or_else(|error| panic!("{error}")); + let files: sheet::cache_snapshot::FileMap = + serde_json::from_value(value["fileMap"].clone()).unwrap_or_else(|error| panic!("{error}")); + reset_build_state_internal(); + // When: only the complete sheet can authorize companion state in either order. + if before { + cache_restore::classes(Some(classes.clone())); + cache_restore::files(Some(files.clone())); + } + assert_eq!(import(value.clone()), Ok(())); + if !before { + cache_restore::classes(Some(classes)); + cache_restore::files(Some(files)); + } + // Then: the entire naming snapshot and full JS/CSS round trip exactly. + assert_eq!(snapshot(), value); + assert_eq!(compile(), output); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn companions_without_sheet_authority_never_allocate_poisoned_state() { + // Given: independent cold output and arbitrary legacy map data. + reset_build_state_internal(); + let cold = compile(); + reset_build_state_internal(); + // When: raw companions are imported without a sheet. + cache_restore::classes(Some(BTreeMap::from([( + "fresh.tsx".into(), + BTreeMap::from([("poison".into(), 999)]), + )]))); + cache_restore::files(Some(BTreeMap::from([("fresh.tsx".into(), 888)]))); + // Then: neither map becomes live state and complete output remains cold. + assert_eq!(css::file_map::get_file_map().len(), 0); + assert_eq!(with_class_map(HashMap::len), 0); + assert_eq!(compile(), cold); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/cache_v4_tests/collisions.rs b/bindings/devup-ui-wasm/src/cache_v4_tests/collisions.rs new file mode 100644 index 000000000..c3132cd3e --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_v4_tests/collisions.rs @@ -0,0 +1,107 @@ +use super::*; + +fn colored(file: &str, color: &str) -> Result { + code_extract_internal( + file, + &format!( + "import {{css}} from '@devup-ui/react';\nexport const x=css({{color:'{color}'}});" + ), + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) +} + +#[test] +#[serial] +fn current_exact_claim_conflict_is_atomic_located_and_sticky_until_reset() { + // Given: complete current blue proof and a live red atom from a real second site. + reset_build_state_internal(); + colored("cached.tsx", "blue").unwrap_or_else(|error| panic!("{error}")); + let mut value = snapshot(); + value["properties"] = serde_json::json!({}); + value["names"]["OLcolor-vred"] = value["names"]["OLcolor-vblue"].clone(); + reset_build_state_internal(); + colored("live.tsx", "red").unwrap_or_else(|error| panic!("{error}")); + let before = snapshot(); + // When: a structurally complete current claim conflicts with live exact bytes. + let error = import(value) + .err() + .unwrap_or_else(|| panic!("exact collision accepted")); + // Then: both best sites/contents are retained, and no state is partially imported. + for evidence in ["cached.tsx:2:27:", "live.tsx:2:27:", "`'blue'`", "`'red'`"] { + assert!(error.contains(evidence), "{error}"); + } + assert_eq!(snapshot(), before); + assert_eq!(import(serde_json::json!({})), Ok(())); + cache_restore::classes(None); + cache_restore::files(None); + assert_eq!(colored("later.tsx", "green").err(), Some(error.clone())); + assert_eq!(import(before), Ok(())); + assert_eq!(colored("later.tsx", "green").err(), Some(error)); + reset_build_state_internal(); + assert!(colored("later.tsx", "green").is_ok()); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn corrupt_referenced_atom_descriptor_is_a_cold_miss_not_a_collision() { + // Given: real generated output with a referenced descriptor corrupted in storage. + reset_build_state_internal(); + let cold = compile(); + let mut value = snapshot(); + let names = value["names"] + .as_object_mut() + .unwrap_or_else(|| panic!("claims")); + let claim = names + .values_mut() + .find(|claim| { + claim["descriptor"] + .as_array() + .is_some_and(|bytes| bytes.first() == Some(&1.into())) + }) + .unwrap_or_else(|| panic!("atom claim")); + claim["descriptor"] + .as_array_mut() + .unwrap_or_else(|| panic!("descriptor bytes")) + .push(99.into()); + reset_build_state_internal(); + // When: structural proof validation precedes any state mutation. + assert_eq!(import(value), Ok(())); + // Then: nothing is adopted and full output is independently cold. + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert_eq!(compile(), cold); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn manual_in_memory_names_remain_trusted_without_serialized_generation() { + // Given: a freshly constructed manual sheet, not generation-less JSON. + reset_build_state_internal(); + let mut sheet = StyleSheet::default(); + sheet.add_property( + "manual-card", + "color", + 0, + "red", + None, + None, + Some("card.tsx"), + ); + // When: the existing in-memory API imports explicit construction. + assert_eq!(import_sheet_internal(sheet), Ok(())); + // Then: the authored manual class is still usable in emitted CSS. + assert!( + with_style_sheet(|sheet| sheet.create_css(Some("card.tsx"), false)).contains(concat!( + ".manual-card{", + "color:red", + "}" + )) + ); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/cache_v4_tests/configuration.rs b/bindings/devup-ui-wasm/src/cache_v4_tests/configuration.rs new file mode 100644 index 000000000..03f4bc0f5 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_v4_tests/configuration.rs @@ -0,0 +1,54 @@ +use super::*; + +#[test] +#[serial] +fn late_companion_mismatch_preserves_fresh_configuration_and_prepass() { + // Given: restored cache followed by legitimate fresh names-only and routing inputs. + reset_build_state_internal(); + compile(); + let value = snapshot(); + reset_build_state_internal(); + assert_eq!(import(value), Ok(())); + set_prefix(Some("du-FLa-".into())); + set_naming_root(Some("/checkout".into()), Some("/checkout".into())); + set_debug(true); + register_theme_internal( + serde_json::from_value(serde_json::json!({"colors":{"default":{"primary":"#123456"}}})) + .unwrap_or_else(|error| panic!("{error}")), + ); + register_shorthands_internal(BTreeMap::from([( + "insetX".into(), + vec!["left".into(), "right".into()], + )])); + let theme_css = with_style_sheet(|sheet| sheet.theme.to_css()); + import_canonical_map_internal(HashMap::from([("child.tsx".into(), "bucket.tsx".into())])); + import_file_routes_internal(HashMap::from([( + "child.tsx".into(), + std::collections::HashSet::from([1, 2]), + )])); + set_atom_hoist(Some(2)); + seed_file_map(vec!["child.tsx".into()]); + // When: malformed companion data invalidates only authoritative cached state. + cache_restore::classes(None); + // Then: fresh prefix/root/debug/canonical/routes/hoist/D9 inputs remain in effect. + assert_eq!(get_prefix(), Some("du-FLa-".into())); + assert_eq!(css::naming_root::key("/checkout/src/a.tsx"), "src/a.tsx"); + assert!(is_debug()); + assert_eq!(css::file_map::canonical("child.tsx"), "bucket.tsx"); + assert_eq!(css::file_map::original_id("child.tsx"), Some(0)); + assert_eq!( + css::file_map::get_file_map().get_by_left("bucket.tsx"), + Some(&0) + ); + assert!(css::atom_hoist::is_hoisted_bucket("child.tsx")); + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + assert_eq!(with_style_sheet(|sheet| sheet.theme.to_css()), theme_css); + assert_eq!( + css::get_custom_shorthand_names(), + vec!["insetX".to_string()] + ); + register_shorthands_internal(BTreeMap::new()); + register_theme_internal(sheet::theme::Theme::default()); + set_debug(false); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/cache_v4_tests/special.rs b/bindings/devup-ui-wasm/src/cache_v4_tests/special.rs new file mode 100644 index 000000000..eba06a229 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_v4_tests/special.rs @@ -0,0 +1,140 @@ +use super::*; + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn current_typography_and_keyframe_proofs_round_trip_or_go_cold(#[case] corrupt: bool) { + // Given: real responsive typography and keyframes with deliberately nonlexical step order. + reset_build_state_internal(); + register_theme_internal( + serde_json::from_value(serde_json::json!({ + "typography":{"body":[{"fontSize":"14px","fontFamily":"Sans"},null,{"fontSize":"18px"}]} + })) + .unwrap_or_else(|error| panic!("{error}")), + ); + let source = "import {Box,keyframes} from '@devup-ui/react';export const k=keyframes({to:{opacity:1},from:{opacity:0}});export const x=;"; + let output = code_extract_internal( + "special.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + let cold_css = with_style_sheet(|sheet| sheet.create_css(Some("special.tsx"), false)); + let mut value = snapshot(); + assert!(with_style_sheet(|sheet| sheet + .properties + .values() + .flat_map(|orders| orders.values()) + .flat_map(|levels| levels.values()) + .flatten() + .any(|property| property.typography))); + if corrupt { + let claim = value["names"] + .as_object_mut() + .unwrap_or_else(|| panic!("claims")) + .iter_mut() + .find(|(name, _)| name.starts_with('K')) + .unwrap_or_else(|| panic!("keyframes proof")) + .1; + claim["descriptor"] + .as_array_mut() + .unwrap_or_else(|| panic!("descriptor bytes")) + .push(99.into()); + } + reset_build_state_internal(); + // When: complete current data is admitted without changing fresh theme configuration. + assert_eq!(import(value), Ok(())); + // Then: valid cached output is preserved, but corrupted proof adopts nothing. + if corrupt { + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + } else { + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("special.tsx"), false)), + cold_css + ); + } + let fresh = code_extract_internal( + "special.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!(fresh.code(), output.code()); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("special.tsx"), false)), + cold_css + ); + register_theme_internal(sheet::theme::Theme::default()); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn validated_companions_do_not_block_a_later_complete_snapshot(#[case] before: bool) { + // Given: a previously admitted cache whose companions have been validated. + reset_build_state_internal(); + seed_file_map(vec!["fresh.tsx".into()]); + compile(); + let initial = snapshot(); + reset_build_state_internal(); + assert_eq!(import(initial.clone()), Ok(())); + cache_restore::classes(Some( + serde_json::from_value(initial["classMap"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + )); + cache_restore::files(Some( + serde_json::from_value(initial["fileMap"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + )); + seed_file_map(vec!["later.tsx".into()]); + code_extract_internal( + "later.tsx", + "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 later = snapshot(); + let classes = || { + Some( + serde_json::from_value(later["classMap"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + ) + }; + let files = || { + Some( + serde_json::from_value(later["fileMap"].clone()) + .unwrap_or_else(|error| panic!("{error}")), + ) + }; + // When: existing APIs restore the next complete snapshot in either order. + if before { + cache_restore::classes(classes()); + cache_restore::files(files()); + } + assert_eq!(import(later.clone()), Ok(())); + if !before { + cache_restore::classes(classes()); + cache_restore::files(files()); + } + // Then: no old companion observation vetoes or partially restores the new snapshot. + assert_eq!(snapshot(), later); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/compact_source_tests.rs b/bindings/devup-ui-wasm/src/compact_source_tests.rs new file mode 100644 index 000000000..38179e308 --- /dev/null +++ b/bindings/devup-ui-wasm/src/compact_source_tests.rs @@ -0,0 +1,143 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +mod cache; + +fn compile(file: &str, source: &str) -> Result { + code_extract_internal( + file, + source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) +} + +fn fresh() { + reset_build_state_internal(); + css::debug::set_debug(false); + register_theme_internal(sheet::theme::Theme::default()); +} + +#[test] +#[serial] +fn full_output_repeats_when_100_kb_unnumbered_source_runs_in_fresh_builds() { + // Given: 100 KB of comments before multiple dynamic assignments. + let source = format!( + "{}import {{Box}} from '@devup-ui/react';\nexport const View=(p)=>;", + "// filler\n".repeat(10_240) + ); + let run = || { + fresh(); + let output = compile("/real/large.tsx", &source).unwrap_or_else(|error| panic!("{error}")); + let css = with_style_sheet(|sheet| sheet.create_css(None, false)); + let names = with_style_sheet(|sheet| sheet.names.clone()); + ( + output.code, + output.css, + output.css_file, + output.map, + output.updated_base_style, + output.dependencies, + css, + names, + ) + }; + // When: the same received source runs twice without retained compiler state. + let first = run(); + let second = run(); + // Then: complete generated output and claims repeat, with bounded emitted names. + assert_eq!(first, second); + let variables: Vec<_> = first + .0 + .split('"') + .filter(|text| text.starts_with("---SUH")) + .collect(); + assert_eq!(variables.len(), 2); + assert!(variables.iter().all(|name| name.len() <= 50)); + assert!(variables.iter().all(|name| first.6.contains(name))); + let source_claims: Vec<_> = first + .7 + .values() + .filter(|claim| claim.content == source) + .collect(); + assert_eq!(source_claims.len(), 1); + assert_eq!(source_claims[0].descriptor, source.as_bytes()); + reset_build_state_internal(); +} + +#[rstest] +#[case("/real/a.tsx", "/real/b.tsx")] +#[case("/real/b.tsx", "/real/a.tsx")] +#[serial] +fn generated_output_ignores_filenames_when_received_source_is_identical( + #[case] first: &str, + #[case] second: &str, +) { + // Given: an unnumbered source with distinct source-ordered responsive roles. + let source = "import {Box} from '@devup-ui/react';\nexport const View=(p)=>;"; + fresh(); + let before = compile(first, source) + .unwrap_or_else(|error| panic!("{error}")) + .code(); + let css_before = with_style_sheet(|sheet| sheet.create_css(None, false)); + // When: another real filename supplies exactly the same received source. + let after = compile(second, source) + .unwrap_or_else(|error| panic!("{error}")) + .code(); + // Then: names and whole compiled code agree, and no duplicate CSS is emitted. + assert_eq!(after, before); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + css_before + ); + let variables: std::collections::BTreeSet<_> = after + .split('"') + .filter(|text| text.starts_with("---SUH")) + .collect(); + assert_eq!(variables.len(), 3); + reset_build_state_internal(); +} + +#[rstest] +#[case("")] +#[case("Mixed-SUH-prefix-")] +#[serial] +fn full_generated_output_agrees_when_received_source_uses_lf_crlf_or_bom(#[case] prefix: &str) { + // Given: equivalent received text with runtime branches and responsive roles. + let lf = "import {Box} from '@devup-ui/react';\nexport const View=(p)=>;\n"; + let sources = [ + lf.to_string(), + lf.replace('\n', "\r\n"), + format!("\u{feff}{lf}"), + format!("\u{feff}{}", lf.replace('\n', "\r\n")), + ]; + // When: each normalization variant runs through the actual native binding path. + let outputs: Vec<_> = sources + .iter() + .map(|source| { + fresh(); + css::set_prefix(Some(prefix.into())); + let output = + compile("/real/normalized.tsx", source).unwrap_or_else(|error| panic!("{error}")); + ( + output.code, + output.css, + output.css_file, + output.map, + output.updated_base_style, + output.dependencies, + with_style_sheet(|sheet| sheet.create_css(None, false)), + ) + }) + .collect(); + // Then: full generated JS/CSS/output metadata are byte-identical, not just names. + for output in &outputs[1..] { + assert_eq!(output, &outputs[0]); + } + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/compact_source_tests/cache.rs b/bindings/devup-ui-wasm/src/compact_source_tests/cache.rs new file mode 100644 index 000000000..f9beb2f23 --- /dev/null +++ b/bindings/devup-ui-wasm/src/compact_source_tests/cache.rs @@ -0,0 +1,197 @@ +use super::*; + +const SOURCE: &str = + "import {Box} from '@devup-ui/react';\nexport const View=(p)=>;"; + +#[rstest] +#[case("'---SULmissing-b'")] +#[case("'var(---SUHaaaaaaaaaaaaaaaa-a)'")] +#[serial] +fn literal_css_content_roundtrips_when_it_resembles_a_generated_source_variable( + #[case] content: &str, +) { + // Given: a literal CSS string, not a generated variable consumer or reset. + fresh(); + let literal = serde_json::to_string(content).unwrap_or_else(|error| panic!("{error}")); + let source = format!( + "import {{css}} from '@devup-ui/react';export const cls=css({{content:{literal}}});" + ); + compile("/real/content.ts", &source).unwrap_or_else(|error| panic!("{error}")); + let snapshot = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let cached = serde_json::from_str(&snapshot).unwrap_or_else(|error| panic!("{error}")); + fresh(); + // When: actual sheet cache import validates generated source claims. + let restored = import_sheet_internal(cached); + // Then: source-like text in literal content remains a valid static declaration. + assert_eq!(restored, Ok(())); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + snapshot + ); + reset_build_state_internal(); +} + +#[rstest] +#[case("")] +#[case("Mixed---SUH-한😀-")] +#[serial] +fn source_claims_roundtrip_when_cached_sheet_is_imported_into_a_fresh_build(#[case] prefix: &str) { + // Given: a complete cache with exact full-source claims. + fresh(); + css::set_prefix(Some(prefix.into())); + let before = compile("/real/a.tsx", SOURCE) + .unwrap_or_else(|error| panic!("{error}")) + .code(); + let css_before = with_style_sheet(|sheet| sheet.create_css(None, false)); + let snapshot = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let imported: StyleSheet = + serde_json::from_str(&snapshot).unwrap_or_else(|error| panic!("{error}")); + let source_claims: BTreeMap<_, _> = imported + .names + .iter() + .filter(|(name, _)| name.starts_with("---")) + .map(|(name, claim)| (name.clone(), claim.clone())) + .collect(); + assert_eq!(source_claims.len(), 1); + fresh(); + css::set_prefix(Some(prefix.into())); + // When: the cache is restored and identical text comes from another real file. + import_sheet_internal(imported).unwrap_or_else(|error| panic!("{error}")); + let after = compile("/real/z.tsx", SOURCE) + .unwrap_or_else(|error| panic!("{error}")) + .code(); + // Then: names, complete JS/CSS and exact source claims remain unchanged. + assert_eq!(after, before); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + css_before + ); + with_style_sheet(|sheet| { + for (name, claim) in source_claims { + assert_eq!(sheet.names[&name], claim); + } + }); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn missing_source_claim_metadata_restores_nothing_and_matches_cold_output( + #[case] retain_reset: bool, +) { + // Given: all atom claims exist, but the exact source claim is missing. + fresh(); + let cold = compile("/real/a.tsx", SOURCE).unwrap_or_else(|error| panic!("{error}")); + let cold_css = with_style_sheet(|sheet| sheet.create_css(None, false)); + 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 + .retain(|name, _| !name.starts_with("---SUH")); + if !retain_reset { + for orders in incomplete.properties.values_mut() { + for levels in orders.values_mut() { + for properties in levels.values_mut() { + properties.retain(|property| !property.owner_reset); + } + } + } + } + fresh(); + // When: a current snapshot lacks source proof but retains valid atom proof. + assert_eq!(import_sheet_internal(incomplete), Ok(())); + // Then: no cached source/atom alias survives and complete output is cold. + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert_eq!(css::file_map::get_original_ids(), BTreeMap::new()); + let next = compile("/real/a.tsx", SOURCE).unwrap_or_else(|error| panic!("{error}")); + assert_eq!(next.code(), cold.code()); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + cold_css + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn unequal_cached_source_claims_report_both_real_files_before_replacing_the_sheet() { + // Given: an existing exact source claim and a different full source under its name. + fresh(); + compile("/real/first.tsx", SOURCE).unwrap_or_else(|error| panic!("{error}")); + let snapshot = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let cached: StyleSheet = + serde_json::from_str(&snapshot).unwrap_or_else(|error| panic!("{error}")); + let (name, mut claim) = cached + .names + .into_iter() + .find(|(name, _)| name.starts_with("---SUH")) + .unwrap_or_else(|| panic!("source claim")); + let other = format!("{SOURCE}\n// another received source"); + claim.content.clone_from(&other); + claim.descriptor = other.as_bytes().to_vec(); + let css::style_origin::RealLocation::Exact(origin) = &mut claim.location else { + panic!("real witness") + }; + origin.file = "/real/second.tsx".into(); + claim.origin = Some(origin.clone()); + let mut incoming = StyleSheet::default(); + incoming.names.insert(name, claim); + // When: importing overlapping claims without any atom descriptor difference. + let error = import_sheet_internal(incoming) + .err() + .unwrap_or_else(|| panic!("unequal exact source")); + // Then: both real witnesses/full sources are printed and the old sheet survives. + assert!(error.contains("/real/first.tsx:2:"), "{error}"); + assert!(error.contains("/real/second.tsx:2:"), "{error}"); + assert!(error.contains(SOURCE), "{error}"); + assert!(error.contains(&other), "{error}"); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + snapshot + ); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn corrupt_source_proof_restores_nothing_even_when_the_named_entry_exists( + #[case] corrupt_content: bool, +) { + // Given: a source variable whose claim exists but has inconsistent proof bytes. + fresh(); + let cold = compile("/real/a.tsx", SOURCE).unwrap_or_else(|error| panic!("{error}")); + let cold_css = with_style_sheet(|sheet| sheet.create_css(None, false)); + let snapshot = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let mut corrupt: StyleSheet = + serde_json::from_str(&snapshot).unwrap_or_else(|error| panic!("{error}")); + let claim = corrupt + .names + .iter_mut() + .find(|(name, _)| name.starts_with("---SUH")) + .unwrap_or_else(|| panic!("source claim")) + .1; + if corrupt_content { + claim.content.push_str("\n// forged source"); + claim.descriptor = claim.content.as_bytes().to_vec(); + } else { + claim.descriptor.push(0); + } + fresh(); + // When: source proof and content-only naming disagree at admission. + assert_eq!(import_sheet_internal(corrupt), Ok(())); + // Then: existence alone cannot adopt the original alias or any cached atom. + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert_eq!(css::file_map::get_original_ids(), BTreeMap::new()); + let next = compile("/real/a.tsx", SOURCE).unwrap_or_else(|error| panic!("{error}")); + assert_eq!(next.code(), cold.code()); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + cold_css + ); + reset_build_state_internal(); +} 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..532721788 --- /dev/null +++ b/bindings/devup-ui-wasm/src/content_location_contract_tests.rs @@ -0,0 +1,265 @@ +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.export_snapshot()) + .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..933a11eba --- /dev/null +++ b/bindings/devup-ui-wasm/src/content_name_tests.rs @@ -0,0 +1,259 @@ +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 missing_cached_atom_claim_restores_no_state_and_produces_cold_bytes() { + // Given + reset_build_state_internal(); + css::debug::set_debug(false); + let cold = compile( + "source.tsx", + "import {css} from '@devup-ui/react';export const x=css({color:'red'});", + ) + .unwrap_or_else(|error| panic!("{error}")); + let cold_css = with_style_sheet(|sheet| sheet.create_css(None, false)); + 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(); + reset_build_state_internal(); + // When + assert_eq!(import_sheet_internal(incomplete), Ok(())); + // Then + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + let next = compile( + "source.tsx", + "import {css} from '@devup-ui/react';export const x=css({color:'red'});", + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!(next.code(), cold.code()); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + cold_css + ); + 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 0a2347ac2..42ab7a077 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1,7 +1,7 @@ -use css::class_map::{set_class_map, with_class_map}; -use css::file_map::{ - canonical, is_global, set_canonical_map, set_file_map, with_canonical_map, with_file_map, -}; +use css::class_map::with_class_map; +use css::file_map::{canonical, is_global, set_canonical_map, with_canonical_map, with_file_map}; +#[cfg(test)] +use css::{class_map::set_class_map, file_map::set_file_map}; use extractor::extract_style::extract_style_value::ExtractStyleValue; use extractor::{ ExtractOption, ImportAlias, ModuleResolver, ResolvedModule, extract, extract_with_modules, @@ -14,6 +14,32 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt::Display; use std::sync::{LazyLock, Mutex}; use wasm_bindgen::prelude::*; +mod cache_atom_proof; +mod cache_descriptor; +mod cache_names; +mod cache_restore; +mod cache_source_names; +mod cache_special_proof; +#[cfg(test)] +mod cache_v4_tests; +#[cfg(test)] +mod compact_source_tests; +#[cfg(test)] +mod content_location_contract_tests; +#[cfg(test)] +mod content_name_tests; +#[cfg(test)] +mod naming_root_tests; +#[cfg(test)] +mod original_counter_tests; +#[cfg(test)] +mod prefix_cache_tests; +#[cfg(test)] +mod scoped_cache_tests; +#[cfg(test)] +mod scoped_cascade_tests; +#[cfg(test)] +mod static_theme_tests; static GLOBAL_STYLE_SHEET: LazyLock> = LazyLock::new(|| Mutex::new(StyleSheet::default())); @@ -74,7 +100,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 +109,22 @@ 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| { + let message = error.to_string(); + cache_names::record(&Err(message.clone())); + message + })?; // 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 +144,7 @@ impl Output { )) } }, - } + }) }) } @@ -211,6 +245,15 @@ pub fn set_prefix(prefix: Option) { css::set_prefix(prefix); } +/// Set the build's names-only project root before extraction in every compiler. +/// This never changes resolver IDs, extraction keys, buckets or D9 numbering. +/// +/// Supply the existing ID basis when relative extraction IDs are not root-relative. +#[wasm_bindgen(js_name = "setNamingRoot")] +pub fn set_naming_root(root: Option, relative_base: Option) { + css::naming_root::set_context(root, relative_base); +} + #[wasm_bindgen(js_name = "getPrefix")] #[must_use] pub fn get_prefix() -> Option { @@ -218,21 +261,22 @@ pub fn get_prefix() -> Option { } /// Internal function to import a `StyleSheet` (testable without `JsValue`) -pub fn import_sheet_internal(sheet: StyleSheet) { - with_style_sheet_mut(|global_sheet| *global_sheet = sheet); +pub fn import_sheet_internal(sheet: StyleSheet) -> Result<(), String> { + cache_restore::import(sheet) } #[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(()) + let sheet: StyleSheet = + serde_wasm_bindgen::from_value(sheet_object).unwrap_or_else(|_| cache_restore::absent()); + import_sheet_internal(sheet).map_err(js_error) } /// 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")] @@ -243,13 +287,20 @@ 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")] #[cfg(not(tarpaulin_include))] pub fn import_class_map(sheet_object: JsValue) -> Result<(), JsValue> { - set_class_map(serde_wasm_bindgen::from_value(sheet_object).map_err(js_error)?); + cache_restore::classes(serde_wasm_bindgen::from_value(sheet_object).ok()); Ok(()) } @@ -261,13 +312,17 @@ 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")] #[cfg(not(tarpaulin_include))] pub fn import_file_map(sheet_object: JsValue) -> Result<(), JsValue> { - set_file_map(serde_wasm_bindgen::from_value(sheet_object).map_err(js_error)?); + cache_restore::files(serde_wasm_bindgen::from_value(sheet_object).ok()); Ok(()) } @@ -284,7 +339,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,15 +359,47 @@ 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) { + cache_restore::seeded(&files); + 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() { + cache_names::clear(); + cache_restore::clear(); + 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::atom_hoist::restore_atom_plan(None); + css::set_prefix(None); + css::naming_root::set_root(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 -/// 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) { @@ -426,6 +517,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, @@ -446,7 +538,7 @@ fn code_extract_internal_impl( }; match extracted { - Ok(output) => Ok(Output::new( + Ok(output) => Output::new( output.code, output.styles, output.map, @@ -455,7 +547,7 @@ fn code_extract_internal_impl( output.css_file, import_main_css_in_css, output.dependencies, - )), + ), Err(error) => Err(error.to_string()), } } @@ -631,6 +723,12 @@ 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 { + get_css_internal(file_num, import_main_css).map_err(js_error) +} + +/// Internal CSS retrieval using the same sticky-error and file-selection contract as getCss. +pub fn get_css_internal(file_num: Option, import_main_css: bool) -> Result { + cache_names::check()?; Ok(with_style_sheet(|sheet| { if let Some(file_num) = file_num { with_file_map(|map| { @@ -674,6 +772,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 { @@ -707,16 +814,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, @@ -771,337 +892,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; - } - - 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 { "" } - )); + 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}"); } - 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] @@ -1548,7 +1360,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(), ""); @@ -1575,7 +1388,8 @@ mod tests { None, false, Vec::new(), - ); + ) + .unwrap_or_else(|error| panic!("{error}")); // Test updated_base_style getter let _ = output.updated_base_style(); @@ -1638,7 +1452,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()); } @@ -1677,7 +1492,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()); @@ -1873,7 +1689,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(); @@ -1929,6 +1745,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(); + 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() { diff --git a/bindings/devup-ui-wasm/src/naming_root_tests.rs b/bindings/devup-ui-wasm/src/naming_root_tests.rs new file mode 100644 index 000000000..b6b0b73ab --- /dev/null +++ b/bindings/devup-ui-wasm/src/naming_root_tests.rs @@ -0,0 +1,256 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +const SOURCE: &str = "import {Box} from '@devup-ui/react'; export const x=;"; + +fn compile(file: &str) -> Output { + code_extract_internal( + file, + SOURCE, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn snapshot() -> StyleSheet { + serde_json::from_str(&export_sheet_internal().unwrap_or_else(|error| panic!("{error}"))) + .unwrap_or_else(|error| panic!("{error}")) +} + +#[test] +#[serial] +fn configured_prefix_is_not_misread_as_a_generated_scope_in_shared_css() { + // Given + reset_build_state_internal(); + set_prefix(Some("du-FLa-".into())); + code_extract_internal( + "x.tsx", + SOURCE, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + let incoming = snapshot(); + // When + let imported = import_sheet_internal(incoming); + // Then + assert_eq!(imported, Ok(())); + reset_build_state_internal(); +} + +#[rstest] +#[case("du-FLa-")] +#[case("FHprefix-")] +#[serial] +fn configured_prefix_does_not_require_a_fallback_claim_for_a_d9_scope(#[case] prefix: &str) { + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + seed_file_map(vec!["x.tsx".into()]); + let styles = std::iter::once(ExtractStyleValue::Static( + extractor::extract_style::extract_static_style::ExtractStaticStyle::new( + "color", "red", 0, None, + ) + .with_naming(css::Naming::Risky), + )) + .collect(); + with_style_sheet_mut(|sheet| sheet.update_styles(&styles, "x.tsx", false)) + .unwrap_or_else(|error| panic!("{error}")); + let incoming = snapshot(); + + let restored = import_sheet_internal(incoming); + + assert_eq!(restored, Ok(())); + reset_build_state_internal(); +} + +#[rstest] +#[case("C:/repo/a", "C:\\repo\\a\\src\\x.tsx", "src/x.tsx")] +#[case("/repo/a", "/repo/a/src/../x.tsx", "x.tsx")] +#[case("/repo/a", "/repo/shared/x.tsx", "../shared/x.tsx")] +#[case("C:/repo/a", "D:\\shared\\x.tsx", "d:/shared/x.tsx")] +#[case("/repo/a", "../shared/x.tsx", "../shared/x.tsx")] +#[case("//server/share/a", "//server/other/x.tsx", "//server/other/x.tsx")] +#[serial] +fn naming_keys_are_relative_when_a_compatible_root_exists( + #[case] root: &str, + #[case] file: &str, + #[case] expected: &str, +) { + // Given + reset_build_state_internal(); + set_naming_root(Some(root.into()), None); + // When + let key = css::naming_root::key(file); + // Then + assert_eq!(key, expected); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn no_root_and_reset_preserve_the_existing_raw_key_contract() { + // Given + set_naming_root(Some("C:/repo".into()), None); + reset_build_state_internal(); + // When + let key = css::naming_root::key("C:\\repo\\x.tsx"); + // Then + assert_eq!(key, "C:\\repo\\x.tsx"); +} + +#[test] +#[serial] +fn checkout_roots_do_not_change_full_output_when_only_naming_context_differs() { + // Given: no D9 IDs, identical source and root-relative file/sheet identities. + let mut outputs = Vec::new(); + // When: two independent builds use the new channel, not rewritten extraction IDs. + for root in ["/checkout/one", "/different/place/two"] { + reset_build_state_internal(); + css::debug::set_debug(false); + set_naming_root(Some(root.into()), None); + let file = format!("{root}/src/x.tsx"); + let output = compile(&file); + let sheet = snapshot(); + assert!(sheet.properties.contains_key(&file)); + let claims = sheet + .names + .iter() + .filter(|(name, _)| name.starts_with('F') && !name.contains('-')) + .collect::>(); + assert_eq!(claims.len(), 1); + assert_eq!(claims[0].1.content, "src/x.tsx"); + assert_eq!(claims[0].1.descriptor, b"src/x.tsx"); + outputs.push((output.code(), sheet.create_css(Some(&file), false))); + } + // Then: every emitted JS and CSS byte matches; delivery keys remain absolute. + assert_eq!(outputs[0], outputs[1]); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn cwd_relative_ids_keep_checkout_stable_names_when_compiler_context_differs() { + // Given: Webpack keeps cwd-relative IDs even when compiler.context is elsewhere. + let mut outputs = Vec::new(); + // When + for checkout in ["one", "two"] { + reset_build_state_internal(); + set_naming_root( + Some(format!("/checkouts/{checkout}")), + Some("/runner".into()), + ); + let file = format!("../checkouts/{checkout}/src/x.tsx"); + let output = compile(&file); + outputs.push(( + output.code(), + with_style_sheet(|sheet| { + assert!(sheet.properties.contains_key(&file)); + sheet.create_css(Some(&file), false) + }), + )); + } + // Then: resolver/delivery IDs stay raw, but every CSS/JS byte is checkout-stable. + assert_eq!(outputs[0], outputs[1]); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn scope_collision_reports_full_relative_keys_and_both_real_locations() { + // Given: a narrow width forces two different sheet keys to the same prefix. + reset_build_state_internal(); + set_naming_root(Some("/checkout".into()), None); + let bits = css::content_hash::FingerprintBits::new(1).unwrap_or_else(|| panic!("valid width")); + let first_name = + css::content_name::ContentName::scope("src/first.tsx").name_with_bits("", bits); + let second = (0..64) + .map(|index| format!("src/second{index}.tsx")) + .find(|key| { + css::content_name::ContentName::scope(key).name_with_bits("", bits) == first_name + }) + .unwrap_or_else(|| panic!("narrow scope collision fixture")); + let second_file = format!("/checkout/{second}"); + let source = "import {Box} from '@devup-ui/react';export const x=;"; + let styles = + extractor::extract_without_source_map(&second_file, source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")) + .styles; + let mut sheet = StyleSheet::default(); + let first_styles = extractor::extract_without_source_map( + "/checkout/src/first.tsx", + source, + ExtractOption::default(), + ) + .unwrap_or_else(|error| panic!("{error}")) + .styles; + sheet.names = sheet + .preflight_styles_with_bits(&first_styles, ("/checkout/src/first.tsx", false), bits) + .unwrap_or_else(|error| panic!("{error}")); + // When + let error = sheet + .preflight_styles_with_bits(&styles, (&second_file, false), bits) + .err() + .unwrap_or_else(|| panic!("scope collision accepted")) + .to_string(); + // Then + for value in [ + "src/first.tsx", + &second, + "/checkout/src/first.tsx:1:", + &format!("{second_file}:1:"), + ] { + assert!(error.contains(value), "{error}"); + } + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn cache_rejects_missing_or_corrupt_scope_claims_before_any_extraction(#[case] corrupt: bool) { + // Given + reset_build_state_internal(); + set_naming_root(Some("/checkout".into()), None); + let cold = compile("/checkout/src/x.tsx"); + let cold_css = with_style_sheet(|sheet| sheet.create_css(Some("/checkout/src/x.tsx"), false)); + let mut incoming = snapshot(); + let scope = incoming + .names + .keys() + .find(|name| name.starts_with('F')) + .unwrap_or_else(|| panic!("missing scope")) + .clone(); + if corrupt { + incoming + .names + .get_mut(&scope) + .unwrap_or_else(|| panic!("missing scope")) + .descriptor + .push(1); + } else { + incoming.names.remove(&scope); + } + reset_build_state_internal(); + set_naming_root(Some("/checkout".into()), None); + // When + assert_eq!(import_sheet_internal(incoming), Ok(())); + // Then: corrupt scope proof adopts no aliases; the names-only root stays configured. + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert_eq!(compile("/checkout/src/x.tsx").code(), cold.code()); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("/checkout/src/x.tsx"), false)), + cold_css + ); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/original_counter_tests.rs b/bindings/devup-ui-wasm/src/original_counter_tests.rs new file mode 100644 index 000000000..689c27acf --- /dev/null +++ b/bindings/devup-ui-wasm/src/original_counter_tests.rs @@ -0,0 +1,229 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; +mod cache; +mod controls; +mod environments; + +fn setup() { + reset_build_state_internal(); + css::debug::set_debug(false); + seed_file_map(vec!["a.tsx".into(), "child.tsx".into()]); + set_canonical_map(HashMap::from([("child.tsx".into(), "a.tsx".into())])); +} + +fn compile(file: &str, source: &str) -> Output { + code_extract_internal( + file, + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn delivered() -> String { + with_style_sheet(|sheet| sheet.create_css(Some("a.tsx"), false)) +} + +#[rstest] +#[case("import {Box} from '@devup-ui/react';export const x=;")] +#[case("import {css} from '@devup-ui/react';export const x=css({color:'red'});")] +#[case( + "import * as stylex from '@stylexjs/stylex';export const x=stylex.create({red:{color:'red'}});" +)] +#[serial] +fn single_atom_uses_each_original_first_slot(#[case] source: &str) { + // Given + setup(); + // When + let outputs: Vec<_> = ["child.tsx", "a.tsx"] + .into_iter() + .map(|file| compile(file, source)) + .collect(); + // Then + for (output, name) in outputs.iter().zip(["b-a", "a-a"]) { + assert!(output.code().contains(name), "{}", output.code()); + assert!(delivered().contains(&format!(".{name}{{color:red}}"))); + } + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 2); + assert!(map.values().all(|slots| slots.len() == 1)); + }); + with_style_sheet(|sheet| assert_eq!(sheet.names.len(), 0)); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn collapsed_literals_keep_original_first_slots_when_source_order_reverses(#[case] reverse: bool) { + // Given: two numbered originals deliver into the same canonical sheet. + setup(); + let mut inputs = [("a.tsx", "red", "a"), ("child.tsx", "blue", "b")]; + if reverse { + inputs.reverse(); + } + // When: each literal is compiled through the real binding pipeline. + for (file, color, owner) in inputs { + let source = format!( + "import {{Box}} from '@devup-ui/react';export const x=;" + ); + let output = compile(file, &source); + // Then: names, canonical delivery, allocator and registry agree. + assert!( + output + .code() + .contains(&format!("className=\"{owner}-a {owner}-b\"")), + "{}", + output.code() + ); + assert!(output.code().contains("df/devup-ui-0.css")); + assert_eq!(output.css_file(), Some("df/devup-ui-0.css".into())); + let css = delivered(); + assert!( + css.contains(&format!(".{owner}-a{{color:{color}}}")), + "{css}" + ); + assert!( + css.contains(&format!(".{owner}-b{{padding:16px}}")), + "{css}" + ); + } + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 2); + for id in ["D9-0", "D9-1"] { + let slots = map + .get(id) + .unwrap_or_else(|| panic!("missing {id}: {map:?}")); + assert_eq!(slots.len(), 2); + assert_eq!( + slots + .values() + .copied() + .collect::>(), + [0, 1].into_iter().collect() + ); + } + }); + with_style_sheet(|sheet| { + assert_eq!(sheet.names.len(), 0); + assert_eq!(sheet.properties.len(), 1); + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn equal_content_survives_deferred_union_when_original_scopes_have_ended() { + // Given: equal declarations captured in independent raw-source scopes. + setup(); + let source = "import {Box} from '@devup-ui/react';export const x=;"; + let outputs: Vec<_> = ["a.tsx", "child.tsx"] + .into_iter() + .map(|file| { + extract_without_source_map( + file, + source, + ExtractOption { + css_dir: "df".into(), + ..Default::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")) + }) + .collect(); + let union: FxHashSet<_> = outputs + .iter() + .flat_map(|output| output.styles.iter().cloned()) + .collect(); + // When: the union is emitted only after both extraction scopes have ended. + let mut sheet = StyleSheet::default(); + sheet + .update_styles(&union, "a.tsx", false) + .unwrap_or_else(|error| panic!("{error}")); + // Then: neither owner's reference was discarded and emission uses existing slots. + assert_eq!(union.len(), 2); + let css = sheet.create_css(Some("a.tsx"), false); + for (output, name) in outputs.iter().zip(["a-a", "b-a"]) { + assert!(output.code.contains(name), "{}", output.code); + assert!(css.contains(&format!(".{name}{{color:red}}")), "{css}"); + } + assert_eq!(sheet.names.len(), 0); + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 2); + assert!(map.values().all(|slots| slots.len() == 1)); + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn dynamic_site_owner_names_the_class_and_delivered_reset_without_registry_claims() { + // Given + setup(); + let source = "import {Box} from '@devup-ui/react';export const x=(tone)=>;"; + // When + let output = compile("child.tsx", source); + // Then + assert!(output.code().contains("b-a"), "{}", output.code()); + let css = delivered(); + let variable = css::Site { + file: css::sparse_site::SourceFile::D9(1), + at: source + .find("tone}/>") + .unwrap_or_else(|| panic!("missing assignment")), + role: 0, + } + .variable_name(""); + assert!(output.code().contains(&variable), "{}", output.code()); + assert!( + css.contains(&format!(".b-a{{{variable}:initial;color:var({variable})}}")), + "{css}" + ); + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 1); + assert_eq!(map["D9-1"].len(), 1); + }); + with_style_sheet(|sheet| assert_eq!(sheet.names.len(), 0)); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn conditional_typography_constructor_keeps_each_original_counter() { + // Given + setup(); + register_theme_internal( + serde_json::from_value(serde_json::json!({"typography":{"body":{"fontSize":"14px"}}})) + .unwrap_or_else(|error| panic!("{error}")), + ); + let source = + "import {Box} from '@devup-ui/react';export const x=;"; + // When + let outputs: Vec<_> = ["a.tsx", "child.tsx"] + .into_iter() + .map(|file| compile(file, source)) + .collect(); + // Then + for (output, name) in outputs.iter().zip(["a-a", "b-a"]) { + assert!(output.code().contains(name), "{}", output.code()); + assert!( + delivered().contains(&format!(".{name}:hover{{font-size:14px}}")), + "{}", + delivered() + ); + } + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 2); + assert!(map.values().all(|slots| slots.len() == 1)); + }); + with_style_sheet(|sheet| assert_eq!(sheet.names.len(), 0)); + reset_build_state_internal(); + register_theme_internal(sheet::theme::Theme::default()); +} diff --git a/bindings/devup-ui-wasm/src/original_counter_tests/cache.rs b/bindings/devup-ui-wasm/src/original_counter_tests/cache.rs new file mode 100644 index 000000000..33dfd613f --- /dev/null +++ b/bindings/devup-ui-wasm/src/original_counter_tests/cache.rs @@ -0,0 +1,57 @@ +use super::*; + +#[rstest] +#[case("")] +#[case("du-FLa-")] +#[serial] +fn cache_restores_original_counters_without_extra_slots_or_dangling_references( + #[case] prefix: &str, +) { + // Given + setup(); + set_prefix(Some(prefix.into())); + let sources = [ + ( + "a.tsx", + "import {Box} from '@devup-ui/react';export const x=;", + ), + ( + "child.tsx", + "import {Box} from '@devup-ui/react';export const x=;", + ), + ]; + let before = sources.map(|(file, source)| compile(file, source).code()); + let sheet_json = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let classes = export_class_map_internal().unwrap_or_else(|error| panic!("{error}")); + let files = css::file_map::get_file_map(); + let canonical_map = css::file_map::get_canonical_map(); + let ids = css::file_map::get_original_ids(); + let css_before = delivered(); + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + set_file_map(files.clone()); + set_canonical_map(canonical_map.clone()); + set_class_map(serde_json::from_str(&classes).unwrap_or_else(|error| panic!("{error}"))); + import_sheet_internal( + serde_json::from_str(&sheet_json).unwrap_or_else(|error| panic!("{error}")), + ) + .unwrap_or_else(|error| panic!("{error}")); + // When + let after: Vec<_> = sources + .iter() + .rev() + .map(|(file, source)| compile(file, source).code()) + .collect(); + // Then + assert_eq!(after, before.into_iter().rev().collect::>()); + assert_eq!(delivered(), css_before); + assert_eq!( + export_class_map_internal().unwrap_or_else(|error| panic!("{error}")), + classes + ); + assert_eq!(css::file_map::get_file_map(), files); + assert_eq!(css::file_map::get_canonical_map(), canonical_map); + assert_eq!(css::file_map::get_original_ids(), ids); + with_style_sheet(|sheet| assert_eq!(sheet.names.len(), 0)); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/original_counter_tests/controls.rs b/bindings/devup-ui-wasm/src/original_counter_tests/controls.rs new file mode 100644 index 000000000..62d20f59a --- /dev/null +++ b/bindings/devup-ui-wasm/src/original_counter_tests/controls.rs @@ -0,0 +1,159 @@ +use super::*; +use extractor::extract_style::{ExtractStyleProperty, extract_static_style::ExtractStaticStyle}; + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn a_numbered_root_never_lends_its_counter_when_the_member_is_unnumbered(#[case] reverse: bool) { + // Given + reset_build_state_internal(); + seed_file_map(vec!["a.tsx".into()]); + set_canonical_map(HashMap::from([("child.tsx".into(), "a.tsx".into())])); + let mut files = ["a.tsx", "child.tsx"]; + if reverse { + files.reverse(); + } + // When + for file in files { + let output = compile( + file, + "import {Box} from '@devup-ui/react';export const x=;", + ); + let name = if file == "a.tsx" { + "a-a" + } else { + "a-OLcolor-vred" + }; + // Then + assert!(output.code().contains(name), "{}", output.code()); + assert!(delivered().contains(&format!(".{name}{{color:red}}"))); + } + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 1); + assert_eq!(map["D9-0"].len(), 1); + }); + with_style_sheet(|sheet| assert_eq!(sheet.names.len(), 1)); + reset_build_state_internal(); +} + +#[rstest] +#[case(false, false)] +#[case(true, false)] +#[case(false, true)] +#[serial] +fn shared_delivery_stays_content_named_when_single_or_hoisted( + #[case] single: bool, + #[case] hoisted: bool, +) { + // Given + setup(); + if hoisted { + css::atom_hoist::set_atom_hoist(Some(1)); + css::atom_hoist::restore_atom_plan(Some(std::iter::once("a.tsx".into()).collect())); + } + let order = if single || hoisted { 255 } else { 0 }; + let source = format!( + "import {{Box,globalCss}} from '@devup-ui/react';globalCss({{body:{{color:'blue'}}}});export const x=;" + ); + // When + let output = code_extract_internal( + "child.tsx", + &source, + "@devup-ui/react", + "df".into(), + single, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert!(output.code().contains("OLcolor-vred"), "{}", output.code()); + let css = with_style_sheet(|sheet| sheet.create_css(None, false)); + assert!(css.contains("color:red"), "{css}"); + assert!(css.contains(concat!("body{", "color:blue", "}")), "{css}"); + css::class_map::with_class_map(|map| assert_eq!(map.len(), 0)); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn diagnostics_and_naming_transitions_preserve_semantic_identity() { + // Given + setup(); + let source = "import {Box} from '@devup-ui/react';export const x=;"; + let mut styles: Vec<_> = ["a.tsx", "child.tsx"] + .into_iter() + .map(|file| { + extract_without_source_map(file, source, ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")) + .styles + .into_iter() + .next() + .unwrap_or_else(|| panic!("missing style")) + }) + .collect(); + // When + for style in &mut styles { + match style { + ExtractStyleValue::Static(style) => { + style.origin = css::style_origin::Origin::from_location( + css::style_origin::RealLocation::ModuleExport { + file: "unrelated-diagnostic.css.ts".into(), + binding: None, + }, + ); + } + other => panic!("expected literal: {other:?}"), + } + } + // Then + for (style, name) in styles.iter().zip(["a-a", "b-a"]) { + assert_eq!( + style + .extract(Some("a.tsx")) + .unwrap_or_else(|| panic!("missing class")) + .to_string(), + name + ); + } + assert_ne!(styles[0], styles[1]); + for style in &mut styles { + style.join_naming(css::Naming::Risky); + } + assert_eq!(styles[0], styles[1]); + assert_eq!(styles.iter().cloned().collect::>().len(), 1); + let literal = ExtractStaticStyle::new("color", "red", 0, None); + assert_eq!(literal.extract(Some("child.tsx")).to_string(), "b-a"); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn counter_only_atoms_do_not_claim_an_unused_unnumbered_canonical_scope() { + // Given + reset_build_state_internal(); + seed_file_map(vec!["child.tsx".into()]); + set_canonical_map(HashMap::from([( + "child.tsx".into(), + "unseeded-root.tsx".into(), + )])); + // When + let output = compile( + "child.tsx", + "import {Box} from '@devup-ui/react';export const x=;", + ); + // Then + assert!(output.code().contains("a-a")); + with_style_sheet(|sheet| { + assert_eq!(sheet.names.len(), 0); + assert!( + sheet + .create_css(Some("unseeded-root.tsx"), false) + .contains(concat!(".a-a{", "color:red", "}")) + ); + }); + css::class_map::with_class_map(|map| assert_eq!(map["D9-0"].len(), 1)); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/original_counter_tests/environments.rs b/bindings/devup-ui-wasm/src/original_counter_tests/environments.rs new file mode 100644 index 000000000..87c5a7f59 --- /dev/null +++ b/bindings/devup-ui-wasm/src/original_counter_tests/environments.rs @@ -0,0 +1,255 @@ +use super::*; + +#[test] +#[serial] +fn imported_outer_render_condition_keeps_css_and_original_counter_classes() { + // Given: D9-1 owns both literal atoms despite canonical delivery through D9-0. + let source = "import {Box,css} from '@devup-ui/react';import {enabled} from './tokens';export const witness=css({color:enabled?'red':'red'});export const x=;export const y=enabled&&;"; + let mut witnesses = Vec::new(); + for enabled in [Some(true), Some(false), None] { + setup(); + let calls = std::rc::Rc::new(std::cell::Cell::new(0)); + let provider_calls = std::rc::Rc::clone(&calls); + let resolver = move |specifier: &str, importer: &str| { + assert_eq!(specifier, "./tokens"); + assert_eq!(importer, "child.tsx"); + provider_calls.set(provider_calls.get() + 1); + enabled.map(|value| ResolvedModule { + path: "tokens.ts".into(), + code: format!("export const enabled={value};"), + }) + }; + // When: the same source is extracted with each distinct provider outcome. + let output = code_extract_with_modules_internal( + "child.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("enabled={enabled:?}: {error}")); + // Then: style resolution changes, but the runtime render guard and atoms do not. + assert!(calls.get() > 0, "provider was not called: {enabled:?}"); + let code = output.code(); + let controller = code + .split("export const y = ") + .nth(1) + .and_then(|expression| expression.split_once("&&")) + .map_or_else( + || panic!("missing render condition: {code}"), + |(condition, _)| condition.trim(), + ); + assert_eq!(controller, "enabled", "{code}"); + assert!( + code.lines().any(|line| line.starts_with("import ") + && line.contains("enabled") + && line.contains("./tokens")), + "runtime import was removed: {code}" + ); + let witness = code + .split("export const witness = ") + .nth(1) + .and_then(|expression| expression.split("export const x = ").next()) + .unwrap_or_else(|| panic!("missing style-resolution witness: {code}")); + assert_eq!(witness.contains("enabled"), enabled.is_none(), "{code}"); + let classes: Vec<_> = code + .split("className=") + .skip(1) + .map(|field| { + field + .strip_prefix('"') + .and_then(|literal| literal.split_once('"')) + .map_or_else( + || panic!("nonliteral className: {code}"), + |(name, _)| name.to_owned(), + ) + }) + .collect(); + assert_eq!(classes, ["b-a", "b-b"], "{code}"); + let css = delivered(); + assert!(css.contains(".b-a{padding:16px}"), "{css}"); + assert!(css.contains(".b-b{height:12px}"), "{css}"); + assert!( + css.contains(concat!(".a-RLcolor-vred{", "color:red", "}")), + "{css}" + ); + assert_eq!(output.css(), Some(css.clone())); + assert_eq!(output.css_file(), Some("df/devup-ui-0.css".into())); + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 1); + let slots = map + .get("D9-1") + .unwrap_or_else(|| panic!("missing original owner: {map:?}")); + assert_eq!(slots.len(), 2); + assert_eq!( + slots + .values() + .copied() + .collect::>(), + [0, 1].into_iter().collect() + ); + }); + with_style_sheet(|sheet| { + assert_eq!(sheet.names.len(), 1); + assert!(sheet.names.contains_key("a-RLcolor-vred")); + }); + println!( + "enabled={enabled:?}; classes={classes:?}; full_css={css:?}; controller={controller}" + ); + witnesses.push((css, classes)); + reset_build_state_internal(); + } + for pair in witnesses.windows(2) { + assert_eq!(pair[0], pair[1]); + } +} + +#[rstest] +#[case( + "export const tone='red';export const enabled=true;export const styles={color:'green',m:2};" +)] +#[case( + "export const tone='blue';export const enabled=false;export const styles={bg:'yellow',w:'8px'};" +)] +#[serial] +fn risky_import_values_presence_and_shape_leave_local_padding_slot_unchanged( + #[case] module: &'static str, +) { + // Given + setup(); + let resolver = |specifier: &str, _: &str| { + (specifier == "./tokens").then(|| ResolvedModule { + path: "tokens.ts".into(), + code: module.into(), + }) + }; + let source = "import {Box} from '@devup-ui/react';import {tone,enabled,styles} from './tokens';export const x=;export const y=enabled&&;"; + // When + let output = code_extract_with_modules_internal( + "child.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert!(output.code().contains("b-a"), "{}", output.code()); + assert!( + delivered().contains(".b-a{padding:16px}"), + "{}", + delivered() + ); + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 1); + assert_eq!(map["D9-1"].len(), 2); + }); + with_style_sheet(|sheet| { + assert!( + sheet + .names + .keys() + .any(|name| name.contains("-RL") || name.contains("-RH")) + ); + assert!(!sheet.names.contains_key("b-a")); + }); + reset_build_state_internal(); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn nested_stylesheet_scope_restores_consumer_owner_without_copying_producer_rules( + #[case] producer_first: bool, +) { + // Given + reset_build_state_internal(); + seed_file_map(vec!["a.css.ts".into(), "child.css.ts".into()]); + set_canonical_map(HashMap::from([("child.css.ts".into(), "a.css.ts".into())])); + let producer = "import {style} from '@devup-ui/react';export const red=style({color:'red'});"; + let consumer = "import {style} from '@devup-ui/react';import {red} from './a.css';export const selected=style({selectors:{[`.${red} &`]:{padding:'4px'}}});export const local=style({margin:'8px'});"; + let resolver = |specifier: &str, _: &str| { + (specifier == "./a.css").then(|| ResolvedModule { + path: "a.css.ts".into(), + code: producer.into(), + }) + }; + let run_producer = || { + code_extract_with_modules_internal( + "a.css.ts", + producer, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")) + }; + if producer_first { + run_producer(); + } + // When + let output = code_extract_with_modules_internal( + "child.css.ts", + consumer, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + let before_producer = with_style_sheet(|sheet| sheet.create_css(Some("a.css.ts"), false)); + if !producer_first { + run_producer(); + } + // Then + let code = output.code(); + let selected = code + .lines() + .find_map(|line| { + line.strip_prefix("export const selected = \"") + .and_then(|line| line.strip_suffix("\";")) + }) + .unwrap_or_else(|| panic!("missing selected export: {code}")); + assert!(selected.starts_with("a-RH"), "{code}"); + assert!(code.contains("a-RLmargin-v8px"), "{code}"); + if !producer_first { + assert!(!before_producer.contains("color:red"), "{before_producer}"); + } + let css = with_style_sheet(|sheet| sheet.create_css(Some("a.css.ts"), false)); + assert!(css.contains(concat!(".a-a{", "color:red", "}")), "{css}"); + assert!( + css.contains(&format!(".a-a .{selected}{{padding:4px}}")), + "{css}" + ); + assert!( + css.contains(concat!(".a-RLmargin-v8px{", "margin:8px", "}")), + "{css}" + ); + assert_eq!(css.matches("color:red").count(), 1); + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 1); + assert_eq!(map["D9-0"].len(), 1); + }); + with_style_sheet(|sheet| { + assert_eq!(sheet.names.len(), 2); + assert!(sheet.names.contains_key(selected)); + assert!(sheet.names.contains_key("a-RLmargin-v8px")); + }); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/prefix_cache_tests.rs b/bindings/devup-ui-wasm/src/prefix_cache_tests.rs new file mode 100644 index 000000000..ffa6dbaa6 --- /dev/null +++ b/bindings/devup-ui-wasm/src/prefix_cache_tests.rs @@ -0,0 +1,215 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("OL")] +#[case("RL")] +#[case("KL")] +#[case("OH")] +#[serial] +fn seeded_counter_cache_round_trips_when_prefix_resembles_content(#[case] prefix: &str) { + // Given: actual safe-own counter output under an unrestricted configured prefix. + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + seed_file_map(vec!["x.tsx".into()]); + let output = code_extract_internal( + "x.tsx", + "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 incoming: StyleSheet = + serde_json::from_str(&export_sheet_internal().unwrap_or_else(|error| panic!("{error}"))) + .unwrap_or_else(|error| panic!("{error}")); + let class = format!("{prefix}a-a"); + assert!(output.code().contains(&class)); + assert!(!incoming.names.contains_key(&class)); + let before = incoming.create_css(Some("x.tsx"), false); + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + // When + let imported = import_sheet_internal(incoming); + // Then: cached CSS remains usable instead of being mistaken for an unclaimed atom. + assert_eq!(imported, Ok(())); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("x.tsx"), false)), + before + ); + reset_build_state_internal(); +} + +fn compiled_content(single: bool) -> Output { + code_extract_internal( + "x.tsx", + "import {Box,keyframes} from '@devup-ui/react';export const k=keyframes({from:{opacity:0},to:{opacity:1}});export const x=;", + "@devup-ui/react", + "df".into(), + single, + false, + false, + HashMap::new(), + ).unwrap_or_else(|error| panic!("{error}")) +} + +fn exported_content(prefix: &str, single: bool) -> StyleSheet { + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + compiled_content(single); + serde_json::from_str(&export_sheet_internal().unwrap_or_else(|error| panic!("{error}"))) + .unwrap_or_else(|error| panic!("{error}")) +} + +#[rstest] +#[case("OL")] +#[case("RL")] +#[case("KL")] +#[case("OH")] +#[serial] +fn generated_cache_round_trips_when_prefix_resembles_content( + #[case] prefix: &str, + #[values(false, true)] single: bool, +) { + // Given: genuine short/hashed content atoms and global keyframes, not legacy counters. + let incoming = exported_content(prefix, single); + let namespace = if single { + prefix.into() + } else { + format!("{prefix}FLx_ptsx-") + }; + assert!( + incoming + .names + .contains_key(&format!("{namespace}OLcolor-vred")) + ); + assert!( + incoming + .names + .keys() + .any(|name| name.starts_with(&format!("{namespace}OH"))) + ); + assert_eq!( + incoming + .keyframes + .values() + .map(BTreeMap::len) + .sum::(), + 1 + ); + let before = incoming.create_css(None, false); + let claims = incoming.names.clone(); + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + // When + let imported = import_sheet_internal(incoming); + // Then: the real imported CSS and exact claims survive the prefix boundary. + assert_eq!(imported, Ok(())); + with_style_sheet(|sheet| { + assert_eq!(sheet.create_css(None, false), before); + assert_eq!(sheet.names, claims); + }); + reset_build_state_internal(); +} + +enum ClaimDamage { + MissingAtom, + MissingScope, + CorruptScope, +} + +#[rstest] +#[case("OL")] +#[case("RL")] +#[case("KL")] +#[case("OH")] +#[serial] +fn genuine_generated_cache_rejects_damaged_claims_when_prefix_resembles_content( + #[case] prefix: &str, + #[values( + ClaimDamage::MissingAtom, + ClaimDamage::MissingScope, + ClaimDamage::CorruptScope + )] + damage: ClaimDamage, +) { + // Given: only one required exact claim is removed or corrupted in real scoped output. + let mut incoming = exported_content(prefix, false); + let cold_css = incoming.create_css(Some("x.tsx"), false); + let cold_code = compiled_content(false).code(); + let scope = format!("{prefix}FLx_ptsx"); + match damage { + ClaimDamage::MissingAtom => { + incoming + .names + .remove(&format!("{scope}-OLcolor-vred")) + .unwrap_or_else(|| panic!("missing atom claim fixture")); + } + ClaimDamage::MissingScope => { + incoming + .names + .remove(&scope) + .unwrap_or_else(|| panic!("missing scope claim fixture")); + } + ClaimDamage::CorruptScope => { + incoming + .names + .get_mut(&scope) + .unwrap_or_else(|| panic!("missing scope claim fixture")) + .descriptor + .push(1); + } + } + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + // When + let imported = import_sheet_internal(incoming); + // Then: recognizing a user prefix must not adopt any damaged generated namespace. + assert_eq!(imported, Ok(())); + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert_eq!(compiled_content(false).code(), cold_code); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("x.tsx"), false)), + cold_css + ); + reset_build_state_internal(); +} + +#[rstest] +#[case("OL")] +#[case("RL")] +#[case("KL")] +#[case("OH")] +#[serial] +fn previous_global_namespace_is_rejected_in_per_file_cache_with_content_shaped_prefix( + #[case] prefix: &str, +) { + // Given: valid global atoms and claims placed in an old nonzero-order per-file bucket. + let mut incoming = exported_content(prefix, true); + let cold = exported_content(prefix, false); + let cold_css = cold.create_css(Some("x.tsx"), false); + let cold_code = compiled_content(false).code(); + let orders = incoming + .properties + .remove("") + .unwrap_or_else(|| panic!("missing single CSS bucket fixture")); + incoming.properties.insert("x.tsx".into(), orders); + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + // When + let imported = import_sheet_internal(incoming); + // Then: stripping the configured prefix cannot allow previous cross-sheet aliases back in. + assert_eq!(imported, Ok(())); + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + assert_eq!(compiled_content(false).code(), cold_code); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("x.tsx"), false)), + cold_css + ); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/scoped_cache_tests.rs b/bindings/devup-ui-wasm/src/scoped_cache_tests.rs new file mode 100644 index 000000000..db402fa9b --- /dev/null +++ b/bindings/devup-ui-wasm/src/scoped_cache_tests.rs @@ -0,0 +1,278 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[test] +#[serial] +fn previous_per_file_content_names_cannot_restore_cross_sheet_aliases() { + reset_build_state_internal(); + let source = "import {Box} from '@devup-ui/react';export const x=;"; + let cold = code_extract_internal( + "x.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + let cold_css = with_style_sheet(|sheet| sheet.create_css(Some("x.tsx"), false)); + let exported = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let mut previous: StyleSheet = + serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + let prefix = "FLx_ptsx-"; + for levels in previous + .properties + .get_mut("x.tsx") + .unwrap_or_else(|| panic!("per-file fixture")) + .values_mut() + { + for properties in levels.values_mut() { + *properties = properties + .iter() + .cloned() + .map(|mut property| { + property.class_name = property + .class_name + .strip_prefix(prefix) + .unwrap_or_else(|| panic!("scoped fixture")) + .to_string(); + property + }) + .collect(); + } + } + previous.names = previous + .names + .into_iter() + .filter(|(name, _)| name != "FLx_ptsx") + .map(|(name, claim)| { + ( + name.strip_prefix(prefix).unwrap_or(&name).to_string(), + claim, + ) + }) + .collect(); + reset_build_state_internal(); + + let imported = import_sheet_internal(previous); + + assert_eq!(imported, Ok(())); + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + let fresh = code_extract_internal( + "x.tsx", + source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!(fresh.code(), cold.code()); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("x.tsx"), false)), + cold_css + ); + reset_build_state_internal(); +} + +#[rstest] +#[case("", 255)] +#[case("du-FLa-", 1)] +#[case("FHprefix-", 255)] +#[serial] +fn previous_hashed_per_file_names_are_rejected_even_with_exact_atom_claims( + #[case] prefix: &str, + #[case] order: u8, +) { + // Given: real exported long-content atoms, stripped of only their sheet namespace. + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + let source = format!( + "import {{Box}} from '@devup-ui/react';export const x=;" + ); + let cold = code_extract_internal( + "x.tsx", + &source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + let cold_css = with_style_sheet(|sheet| sheet.create_css(Some("x.tsx"), false)); + let mut incoming: StyleSheet = + serde_json::from_str(&export_sheet_internal().unwrap_or_else(|error| panic!("{error}"))) + .unwrap_or_else(|error| panic!("{error}")); + let scope = format!("{prefix}FLx_ptsx"); + let scoped_prefix = format!("{scope}-"); + for properties in incoming + .properties + .get_mut("x.tsx") + .unwrap_or_else(|| panic!("missing per-file bucket fixture")) + .get_mut(&order) + .unwrap_or_else(|| panic!("missing style order fixture")) + .values_mut() + { + *properties = properties + .iter() + .cloned() + .map(|mut property| { + property.class_name = format!( + "{prefix}{}", + property + .class_name + .strip_prefix(&scoped_prefix) + .unwrap_or_else(|| panic!("missing scoped name fixture")) + ); + property + }) + .collect(); + } + incoming.names = incoming + .names + .into_iter() + .filter(|(name, _)| name != &scope) + .map(|(name, claim)| match name.strip_prefix(&scoped_prefix) { + Some(atom) => (format!("{prefix}{atom}"), claim), + None => (name, claim), + }) + .collect(); + reset_build_state_internal(); + set_prefix(Some(prefix.into())); + // When + assert_eq!(import_sheet_internal(incoming), Ok(())); + // Then: an exact atom claim cannot admit an old unscoped namespace. + assert_eq!(with_style_sheet(|sheet| sheet.names.len()), 0); + assert_eq!( + code_extract_internal( + "x.tsx", + &source, + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")) + .code(), + cold.code() + ); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("x.tsx"), false)), + cold_css + ); + reset_build_state_internal(); +} + +#[rstest] +#[case(false, false, 0)] +#[case(true, false, 255)] +#[case(false, true, 255)] +#[case(false, false, 255)] +#[serial] +fn actual_exported_shared_and_scoped_names_round_trip( + #[case] single: bool, + #[case] hoist: bool, + #[case] order: u8, +) { + // Given: actual global base, single CSS, hoisted, or current per-file output. + reset_build_state_internal(); + set_prefix(Some("du-FLa-".into())); + if hoist { + set_atom_hoist(Some(2)); + import_file_routes_internal(HashMap::from([( + "x.tsx".into(), + std::collections::HashSet::from([0, 1]), + )])); + } + let source = format!( + "import {{Box,keyframes}} from '@devup-ui/react';export const k=keyframes({{from:{{opacity:0}},to:{{opacity:1}}}});export const x=;" + ); + code_extract_internal( + "x.tsx", + &source, + "@devup-ui/react", + "df".into(), + single, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + let exported = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let incoming: StyleSheet = + serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + let property = incoming + .properties + .values() + .flat_map(|orders| orders.values()) + .flat_map(|levels| levels.values()) + .flatten() + .next() + .unwrap_or_else(|| panic!("missing exported property fixture")); + let expected = if order == 0 { + "du-FLa-OLcolor-vred-o0" + } else if single || hoist { + "du-FLa-OLcolor-vred" + } else { + "du-FLa-FLx_ptsx-OLcolor-vred" + }; + assert_eq!(property.class_name, expected); + assert_eq!(property.hoisted, hoist); + assert_eq!( + incoming + .keyframes + .values() + .map(BTreeMap::len) + .sum::(), + 1 + ); + assert!( + incoming + .keyframes + .values() + .flat_map(|frames| frames.keys()) + .all(|name| name.starts_with("du-FLa-K")) + ); + let before = incoming.create_css(None, false); + reset_build_state_internal(); + set_prefix(Some("du-FLa-".into())); + // When: import runs without relying on the producer's active hoist configuration. + let result = import_sheet_internal(incoming); + // Then + assert_eq!(result, Ok(())); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + before + ); + reset_build_state_internal(); +} + +#[rstest] +#[case("a-a")] +#[case("Rcolor-vred")] +#[case("manual-card")] +#[serial] +fn generationless_serialized_counter_and_manual_names_are_cold(#[case] name: &str) { + // Given: legacy JSON with a real per-file declaration, no generation marker or names registry. + reset_build_state_internal(); + let json = serde_json::json!({ + "properties": {"x.tsx": {"255": {"0": [{"c": name, "p": "color", "v": "red", "s": null}]}}} + }); + let incoming: StyleSheet = + serde_json::from_value(json).unwrap_or_else(|error| panic!("{error}")); + // When + let result = import_sheet_internal(incoming); + // Then: serialized legacy names are never silently promoted to current state. + assert_eq!(result, Ok(())); + assert_eq!(with_style_sheet(|sheet| sheet.properties.len()), 0); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/scoped_cascade_tests.rs b/bindings/devup-ui-wasm/src/scoped_cascade_tests.rs new file mode 100644 index 000000000..911abb60f --- /dev/null +++ b/bindings/devup-ui-wasm/src/scoped_cascade_tests.rs @@ -0,0 +1,211 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +fn compile(file: &str, source: &str, single: bool) -> Output { + code_extract_internal( + file, + source, + "@devup-ui/react", + "df".into(), + single, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +#[rstest] +#[case(false, false)] +#[case(false, true)] +#[case(true, false)] +#[case(true, true)] +#[serial] +fn independent_sheets_cannot_override_responsive_atoms_when_base_content_matches( + #[case] seeded: bool, + #[case] reverse: bool, +) { + // Given: two delivered sheets share base content, but only A has a desktop override. + reset_build_state_internal(); + css::debug::set_debug(false); + if seeded { + seed_file_map(vec!["a.tsx".into(), "b.tsx".into()]); + } + let inputs = [ + ( + "a.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + ), + ( + "b.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + ), + ]; + let order = if reverse { [1, 0] } else { [0, 1] }; + // When: extraction arrives in either order without changing placement. + let mut outputs = BTreeMap::new(); + for index in order { + outputs.insert( + inputs[index].0, + compile(inputs[index].0, inputs[index].1, false), + ); + } + // Then: B's base selectors cannot match A's classes; each rule remains in its exact bucket. + with_style_sheet(|sheet| { + let base = |file: &str| { + sheet.properties[file][&255][&0] + .iter() + .find(|property| property.property == "flex-direction") + .unwrap_or_else(|| panic!("missing base rule")) + .class_name + .clone() + }; + let a = base("a.tsx"); + let b = base("b.tsx"); + assert_ne!(a, b); + assert!(outputs["a.tsx"].code().contains(&a)); + assert!(outputs["b.tsx"].code().contains(&b)); + assert!( + !sheet + .create_css(Some("b.tsx"), false) + .contains(&format!(".{a}{{")) + ); + assert_eq!( + sheet.properties["a.tsx"][&255] + .keys() + .copied() + .collect::>(), + vec![0, 1, 3] + ); + assert_eq!( + sheet.properties["b.tsx"][&255] + .keys() + .copied() + .collect::>(), + vec![0] + ); + assert_eq!(sheet.properties.get(""), None); + }); + reset_build_state_internal(); +} + +#[rstest] +#[case(false, false)] +#[case(false, true)] +#[case(true, false)] +#[case(true, true)] +#[serial] +fn shared_delivery_keeps_existing_rule_placement_when_atoms_are_unprefixed( + #[case] hoist: bool, + #[case] reverse: bool, +) { + // Given: single CSS or a frozen atom-hoist plan, not moved per-file rules. + reset_build_state_internal(); + css::debug::set_debug(false); + if hoist { + set_atom_hoist(Some(2)); + import_file_routes_internal(HashMap::from([ + ("a.tsx".into(), std::collections::HashSet::from([0, 1])), + ("b.tsx".into(), std::collections::HashSet::from([0, 1])), + ])); + } + let inputs = [ + ( + "a.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + ), + ( + "b.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + ), + ]; + // When + for index in if reverse { [1, 0] } else { [0, 1] } { + compile(inputs[index].0, inputs[index].1, !hoist); + } + // Then: the shared sheet retains base-before-responsive order; raw buckets do not change. + with_style_sheet(|sheet| { + let css = sheet.create_css(None, false); + assert!( + css.find("flex-direction:column") + .unwrap_or_else(|| panic!("missing base")) + < css + .find("@media") + .unwrap_or_else(|| panic!("missing responsive rule")) + ); + assert_eq!(css.matches("flex-direction:column").count(), 1); + assert!(css.contains("flex-direction:row")); + let expected = if hoist { + vec!["a.tsx", "b.tsx"] + } else { + vec![""] + }; + assert_eq!( + sheet + .properties + .keys() + .map(String::as_str) + .collect::>(), + expected + ); + for orders in sheet.properties.values() { + for property in orders[&255].values().flatten() { + assert!(property.class_name.starts_with('O')); + assert_eq!(property.hoisted, hoist); + } + } + }); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn collapsed_bucket_retains_its_identity_prefix_and_cache_claims() { + // Given: A and its child share a canonical bucket, B remains independently delivered. + reset_build_state_internal(); + import_canonical_map_internal(HashMap::from([("child.tsx".into(), "a.tsx".into())])); + compile( + "child.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + false, + ); + compile( + "b.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + false, + ); + let before = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let css_before = with_style_sheet(|sheet| sheet.create_css(Some("a.tsx"), false)); + // When: exact name claims round-trip through the existing cache protocol. + import_sheet_internal(serde_json::from_str(&before).unwrap_or_else(|error| panic!("{error}"))) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("a.tsx"), false)), + css_before + ); + with_style_sheet(|sheet| { + assert_eq!( + sheet + .properties + .keys() + .map(String::as_str) + .collect::>(), + vec!["a.tsx", "b.tsx"] + ); + let a = sheet.properties["a.tsx"][&255][&0] + .iter() + .next() + .unwrap_or_else(|| panic!("missing A")); + let b = sheet.properties["b.tsx"][&255][&0] + .iter() + .next() + .unwrap_or_else(|| panic!("missing B")); + assert_ne!(a.class_name, b.class_name); + assert!(a.class_name.starts_with("FLa_ptsx-")); + assert!(sheet.names.contains_key(&a.class_name)); + assert_eq!(sheet.names["FLa_ptsx"].content, "a.tsx"); + }); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/static_theme_tests.rs b/bindings/devup-ui-wasm/src/static_theme_tests.rs new file mode 100644 index 000000000..68314f15c --- /dev/null +++ b/bindings/devup-ui-wasm/src/static_theme_tests.rs @@ -0,0 +1,60 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("px='$gutter'", vec![0, 2])] +#[case("px={'$gutter'}", vec![0, 2])] +#[case("px={['$gutter']}", vec![0])] +#[case("_hover={{px:['$gutter']}}", vec![0])] +#[case("selectors={{'&:focus':{px:['$gutter']}}}", vec![0])] +#[serial] +fn theme_arrays_remain_static_when_array_indices_define_the_responsive_levels( + #[case] props: &str, + #[case] expected: Vec, +) { + // Given: a responsive token expanded only by the established root-token path. + reset_build_state_internal(); + register_theme_internal( + serde_json::from_value( + serde_json::json!({"length":{"default":{"gutter":["8px",null,"16px"]}}}), + ) + .unwrap_or_else(|error| panic!("{error}")), + ); + let source = format!("import {{Box}} from '@devup-ui/react'; export const x=;"); + // When + let output = code_extract_internal( + "theme.tsx", + &source, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then: class-only tokens have no tuple/inline spreads, and array tokens stay at index zero. + for marker in [ + "__devupAssignment", + "__devupLevel", + "__devupValue", + "style=", + ] { + assert!(!output.code().contains(marker), "{}", output.code()); + } + with_style_sheet(|sheet| { + let levels = sheet.properties[""][&255] + .iter() + .filter(|(_, properties)| { + properties + .iter() + .any(|property| property.property == "padding-left") + }) + .map(|(level, _)| *level) + .collect::>(); + assert_eq!(levels, expected); + }); + reset_build_state_internal(); + register_theme_internal(sheet::theme::Theme::default()); +} 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..58b65a3a0 --- /dev/null +++ b/bindings/devup-ui-wasm/tests/content-names.mjs @@ -0,0 +1,162 @@ +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] === 'compact-sources') { + const source = + '// filler\n'.repeat(10_240) + + "import {Box} from '@devup-ui/react';\nexport const View=(p)=>;\n" + const root = '---SUH' + fingerprint(Buffer.from(source)) + const run = (received) => { + wasm.resetBuildState() + wasm.setDebug(false) + wasm.registerTheme({}) + const output = compile('/real/large.tsx', received) + const variables = output.code.match(/---SUH[a-z0-9_]{16}-[a-z0-9_]+/gu) + assert.equal(variables.length, 2) + assert.ok(variables.every((name) => name.startsWith(root + '-'))) + assert.ok(variables.every((name) => name.length <= 38)) + const css = wasm.getCss(null, false) + assert.ok(variables.every((name) => css.includes(name))) + const sheet = JSON.parse(wasm.exportSheet()) + assert.equal(sheet.names[root].content, source) + assert.deepEqual(sheet.names[root].descriptor, [...Buffer.from(source)]) + const result = { + code: output.code, + css: output.css, + map: output.map, + cssFile: output.cssFile, + updatedBaseStyle: output.updatedBaseStyle, + dependencies: output.dependencies, + completeCss: css, + } + output.free() + wasm.resetBuildState() + wasm.importSheet(sheet) + const restored = compile('/real/other.tsx', source) + assert.equal(restored.code, result.code) + assert.equal(wasm.getCss(null, false), css) + restored.free() + return result + } + const baseline = run(source) + for (const received of [ + source, + source.replaceAll('\n', '\r\n'), + '\uFEFF' + source, + '\uFEFF' + source.replaceAll('\n', '\r\n'), + ]) { + assert.deepEqual(run(received), baseline) + } +} 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.ok(css.includes('padding:11px')) + let collision + 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')) + collision = error.message + return true + }, + ) + assert.equal(wasm.exportSheet(), before) + assert.throws( + () => wasm.getCss(null, false), + (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.equal(error.message, collision) + return true + }, + ) +} 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/bun.lock b/bun.lock index a6f7b10d1..906ce66d6 100644 --- a/bun.lock +++ b/bun.lock @@ -489,11 +489,11 @@ }, "bindings/devup-ui-wasm": { "name": "@devup-ui/wasm", - "version": "1.0.82", + "version": "1.0.83", }, "packages/bun-plugin": { "name": "@devup-ui/bun-plugin", - "version": "1.0.21", + "version": "1.0.22", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -508,7 +508,7 @@ }, "packages/components": { "name": "@devup-ui/components", - "version": "0.1.59", + "version": "0.1.60", "dependencies": { "@devup-ui/react": "workspace:^", "clsx": "^2.1", @@ -535,7 +535,7 @@ }, "packages/eslint-plugin": { "name": "@devup-ui/eslint-plugin", - "version": "1.0.21", + "version": "1.0.22", "dependencies": { "@typescript-eslint/utils": "^8.70", "typescript-eslint": "^8.70", @@ -552,7 +552,7 @@ }, "packages/next-plugin": { "name": "@devup-ui/next-plugin", - "version": "1.0.89", + "version": "1.0.90", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -570,7 +570,7 @@ }, "packages/plugin-utils": { "name": "@devup-ui/plugin-utils", - "version": "1.0.16", + "version": "1.0.17", "devDependencies": { "typescript": "^7.0.2", }, @@ -611,7 +611,7 @@ }, "packages/rsbuild-plugin": { "name": "@devup-ui/rsbuild-plugin", - "version": "1.0.66", + "version": "1.0.67", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -627,7 +627,7 @@ }, "packages/vite-plugin": { "name": "@devup-ui/vite-plugin", - "version": "1.0.72", + "version": "1.0.73", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -642,7 +642,7 @@ }, "packages/webpack-plugin": { "name": "@devup-ui/webpack-plugin", - "version": "1.0.70", + "version": "1.0.71", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", diff --git a/e2e/environment-cascade-driver.ts b/e2e/environment-cascade-driver.ts new file mode 100644 index 000000000..825d9ecce --- /dev/null +++ b/e2e/environment-cascade-driver.ts @@ -0,0 +1,142 @@ +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 cascadeModes = [ + { name: 'ordinary', atomHoist: undefined, singleCss: false }, + { name: 'singleCss', atomHoist: undefined, singleCss: true }, + { name: 'atomHoist', atomHoist: 2, singleCss: false }, +] as const + +export const cascadeOrders = [ + ['A', 'B'], + ['B', 'A'], +] as const + +type CompiledFile = { + readonly source: string + readonly code: string + readonly cssFile: string + readonly css: string + readonly markup: string +} + +export type CascadePair = { + readonly A: CompiledFile + readonly B: CompiledFile + readonly shared: string +} + +// Authored once, unchanged for both extraction orders and delivery orders. +const sources = { + A: `export function View() { return ; }`, + B: `export function View() { return ; }`, +} as const + +function parseFile(value: unknown): CompiledFile { + if ( + typeof value !== 'object' || + value === null || + !('source' in value) || + typeof value.source !== 'string' || + !('code' in value) || + typeof value.code !== 'string' || + !('cssFile' in value) || + typeof value.cssFile !== 'string' || + !('css' in value) || + typeof value.css !== 'string' || + !('markup' in value) || + typeof value.markup !== 'string' + ) + throw new TypeError('Invalid public-WASM cascade file receipt') + return { + source: value.source, + code: value.code, + cssFile: value.cssFile, + css: value.css, + markup: value.markup, + } +} + +export function compileCascadePair( + mode: (typeof cascadeModes)[number], + order: (typeof cascadeOrders)[number], + seeded: boolean, +): CascadePair { + const root = mkdtempSync(join(tmpdir(), 'devup-cascade-pair-')) + try { + const result: unknown = JSON.parse( + 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 filenames = { A: 'a.tsx', B: 'b.tsx' }; +const authored = ${JSON.stringify(sources)}; +wasm.resetBuildState(); +wasm.setDebug(false); +wasm.setPrefix(undefined); +if (${seeded}) wasm.seedFileMap(Object.values(filenames)); +wasm.setAtomHoist(${JSON.stringify(mode.atomHoist) ?? 'undefined'}); +wasm.importFileRoutes({ 'a.tsx': [0, 1], 'b.tsx': [0, 1] }); +const outputs = {}; +try { + for (const id of ${JSON.stringify(order)}) { + const source = 'import React from ' + JSON.stringify(pathToFileURL(require.resolve('react')).href) + '; import { Box } from "@devup-ui/react"; ' + authored[id]; + const output = wasm.codeExtract(filenames[id], source, '@devup-ui/react', 'df/devup-ui', ${mode.singleCss}, false, false, {}); + outputs[id] = { output, source }; + } + const transpiler = new Bun.Transpiler({ loader: 'tsx', tsconfig: { compilerOptions: { jsx: 'react', jsxFactory: 'React.createElement' } } }); + const files = {}; + for (const id of ['A', 'B']) { + const { output, source } = outputs[id]; + // Preserve raw compiled JS in the receipt; remove only bundler CSS imports for SSR. + const executable = output.code.replace(/import\s*["'][^"']+\.css(?:\?[^"']*)?["'];?/g, ''); + const modulePath = join(${JSON.stringify(root)}, id + '.mjs'); + writeFileSync(modulePath, transpiler.transformSync(executable)); + const { View } = await import(pathToFileURL(modulePath).href); + const number = output.cssFile?.match(/\/devup-ui-(\d+)\.css$/); + if (!${mode.singleCss} && !number) throw new TypeError('Missing public per-file CSS number'); + files[id] = { source, code: output.code, cssFile: output.cssFile ?? '', + css: ${mode.singleCss} ? '' : wasm.getCss(Number(number[1]), false), + markup: renderToStaticMarkup(React.createElement(View)) }; + } + process.stdout.write(JSON.stringify({ ...files, shared: wasm.getCss(null, false) })); +} finally { + for (const { output } of Object.values(outputs)) output.free(); +} +`, + ], + { cwd: root, encoding: 'utf8', timeout: 30_000 }, + ), + ) + if ( + typeof result !== 'object' || + result === null || + !('A' in result) || + !('B' in result) || + !('shared' in result) || + typeof result.shared !== 'string' + ) + throw new TypeError('Invalid public-WASM cascade pair receipt') + return { + A: parseFile(result.A), + B: parseFile(result.B), + shared: result.shared, + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +} diff --git a/e2e/environment-cascade-pair.spec.ts b/e2e/environment-cascade-pair.spec.ts new file mode 100644 index 000000000..db1d00498 --- /dev/null +++ b/e2e/environment-cascade-pair.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from '@playwright/test' + +import { + cascadeModes, + cascadeOrders, + compileCascadePair, +} from './environment-cascade-driver' + +for (const mode of cascadeModes) { + test.describe(`cascade pair: ${mode.name}`, () => { + test('full output is identical when seeded extraction order reverses', async ({ + browserName, + }, testInfo) => { + // Given: D9 assigns both original IDs and CSS file numbers in path order. + const forward = compileCascadePair(mode, cascadeOrders[0], true) + // When: sources arrive BA rather than AB. + const reverse = compileCascadePair(mode, cascadeOrders[1], true) + // Then: compare complete source/code/markup/CSS/path bytes, without normalization. + await testInfo.attach(`${browserName}-seeded-full-output`, { + body: JSON.stringify({ forward, reverse }, null, 2), + contentType: 'application/json', + }) + expect(reverse).toEqual(forward) + }) + + for (const seeded of [false, true]) { + for (const extraction of cascadeOrders) { + for (const delivery of cascadeOrders) { + for (const width of [320, 1440]) { + test(`seeded=${seeded}, extract=${extraction.join('')}, sheets=${delivery.join('')}, width=${width}`, async ({ + page, + }, testInfo) => { + // Given: real public WASM compiles two unchanged authored files. + // The unseeded case is essential: 1968's private D9 counters mask the alias. + const pair = compileCascadePair(mode, extraction, seeded) + await testInfo.attach('raw-public-output', { + body: JSON.stringify(pair, null, 2), + contentType: 'application/json', + }) + const declarations = /(?:flex-direction|display):/ + if (mode.singleCss || mode.atomHoist !== undefined) { + expect(pair.shared).toMatch(declarations) + expect(pair.A.css).not.toMatch(declarations) + expect(pair.B.css).not.toMatch(declarations) + } else { + expect(pair.shared).not.toMatch(declarations) + expect(pair.A.css).toMatch(declarations) + expect(pair.B.css).toMatch(declarations) + } + const sheets = [ + { id: 'shared', css: pair.shared }, + ...delivery.map((id) => ({ id, css: pair[id].css })), + ] + await page.route('https://cascade.test/*.css', async (route) => { + const sheet = sheets.find( + ({ id }) => + route.request().url() === `https://cascade.test/${id}.css`, + ) + if (!sheet) + throw new TypeError('Unexpected cascade stylesheet request') + await route.fulfill({ + status: 200, + contentType: 'text/css', + body: sheet.css, + }) + }) + await page.setViewportSize({ width, height: 900 }) + // When: Chromium loads independent stylesheet links in explicit AB or BA order. + await page.setContent( + `${sheets.map(({ id }) => ``).join('')}${pair.A.markup}${pair.B.markup}`, + { waitUntil: 'load' }, + ) + // Then: #751 placement stays intact and no later base rule crosses file scopes. + expect( + await page.evaluate(() => + Array.from(document.styleSheets, (sheet) => sheet.href), + ), + ).toEqual( + sheets.map(({ id }) => `https://cascade.test/${id}.css`), + ) + expect( + await page.getByTestId('A').evaluate((element) => { + const style = getComputedStyle(element) + return [style.flexDirection, style.display] + }), + ).toEqual(width === 1440 ? ['row', 'flex'] : ['column', 'none']) + expect( + await page.getByTestId('B').evaluate((element) => { + const style = getComputedStyle(element) + return [style.flexDirection, style.display] + }), + ).toEqual(['column', 'none']) + }) + } + } + } + } + }) +} 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..eb3ab531f 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,31 @@ 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)$/ + // Landing has no project prefix. D9 retains its existing anti-ad splice. + const d9Scope = + '(?:(?![a-z0-9_]{0,5}ad-)[a-z_][a-z0-9_]{0,6}|[a-z_][a-z0-9_]{0,4}a-d|a-d)' + const fallbackScope = + 'F(?:H[a-z0-9_]{16}|L(?=[a-z0-9_]{1,16}-)(?:[a-z0-9]|_(?:[dupchoemslnaq]|x[0-9a-f]+_)){1,16})' + const scopedContentAtom = new RegExp( + `^(?:${d9Scope}|${fallbackScope})-${contentAtom.source.slice(1, -1)}$`, ) + const isCompiledAtom = (className: string) => + contentAtom.test(className) || + (className.length <= (className.startsWith('F') ? 37 : 27) && + scopedContentAtom.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/e2e/playwright.cascade.config.ts b/e2e/playwright.cascade.config.ts new file mode 100644 index 000000000..38e8b2432 --- /dev/null +++ b/e2e/playwright.cascade.config.ts @@ -0,0 +1,16 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: '.', + testMatch: 'environment-cascade-pair.spec.ts', + outputDir: join(tmpdir(), `devup-cascade-results-${process.pid}`), + workers: 1, + retries: 0, + timeout: 60_000, + globalTimeout: 600_000, + reporter: 'line', + projects: [{ name: 'chromium', use: { browserName: 'chromium' } }], +}) 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/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..a465534bc --- /dev/null +++ b/libs/css/src/content_name.rs @@ -0,0 +1,231 @@ +//! 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 { + /// Exact canonical sheet key, distinct from source sites and declaration content. + #[must_use] + pub fn scope(key: &str) -> Self { + let mut lossless = String::new(); + escape_into(&mut lossless, key); + Self { + descriptor: key.as_bytes().to_vec(), + lossless, + domain: 'F', + } + } + + /// Exact normalized source bytes, in the unnumbered site's separate domain. + #[must_use] + pub fn source(source: &str) -> Self { + let mut lossless = String::new(); + escape_into(&mut lossless, source); + Self { + descriptor: source.as_bytes().to_vec(), + lossless, + domain: 'U', + } + } + + /// 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..dfa996fc5 --- /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, "FLunknown_ptsx-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 2a8c86a94..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) { @@ -93,6 +96,20 @@ 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, 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]) { + 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(); + 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| { @@ -157,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); } @@ -196,6 +215,47 @@ 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() { + 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/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/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 576ba53b9..7864dd7e0 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -1,19 +1,40 @@ pub mod at_rule; 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; +#[cfg(test)] +mod naming_coverage_tests; +pub mod naming_root; +pub mod naming_scope; mod num_to_nm_base; pub mod optimize_multi_css_value; pub mod optimize_value; pub mod rm_css_comment; +#[cfg(test)] +mod scoped_name_tests; 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; @@ -22,9 +43,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; @@ -130,7 +152,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. @@ -225,7 +247,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 @@ -323,7 +345,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( @@ -347,7 +369,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_to_keyframes_name(keyframes: &str, filename: Option<&str>) -> String { - 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 - } - } - }) +pub fn keyframes_name_of_escaped(escaped: &str) -> String { + with_prefix(|prefix| format!("{prefix}{}{escaped}", if is_debug() { "k-" } else { "K" })) } -/// 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); - } +#[must_use] +pub fn keyframes_to_keyframes_name(keyframes: &str, filename: Option<&str>) -> String { + if atom_hoist::is_atom_hoist() { + 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))); } - 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( @@ -495,144 +447,73 @@ 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 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 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 { + sheet_to_classname_owned(content, filename, naming::CounterOwner::Inactive) +} + +/// Keep original counter ownership separate from canonical content and delivery scope. +#[must_use] +pub fn sheet_to_classname_owned( + content: &content_name::AtomContent<'_>, + filename: Option<&str>, + owner: naming::CounterOwner, +) -> String { + let descriptor = content.content(); + match naming::owned_private_counter(owner, (filename, content.order), content.naming) { + 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 => naming_scope::name( + &descriptor, + (filename, content.order), + content_hash::FingerprintBits::PRODUCTION, + ), } } @@ -760,20 +641,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!( @@ -828,13 +707,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), @@ -845,14 +718,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), @@ -863,13 +728,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", @@ -892,118 +750,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", @@ -1013,7 +882,7 @@ mod tests { None, None ), - "a" + transition ); assert_eq!( sheet_to_classname( @@ -1024,7 +893,7 @@ mod tests { None, None ), - "a" + transition ); } @@ -1034,19 +903,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 ); } @@ -1054,20 +922,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); @@ -1077,16 +944,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, "FLtest_ptsx-OLbackground-vred"); set_debug(false); } @@ -1181,13 +1043,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"); @@ -1236,7 +1100,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-")); @@ -1252,7 +1116,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); @@ -1308,24 +1172,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")); @@ -1336,10 +1197,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_ne!( + class1, class3, + "independently delivered sheets must retain distinct identities without D9" + ); } #[test] diff --git a/libs/css/src/naming.rs b/libs/css/src/naming.rs new file mode 100644 index 000000000..394ffc0ae --- /dev/null +++ b/libs/css/src/naming.rs @@ -0,0 +1,206 @@ +//! 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; + +/// Original counter identity, independent of canonical delivery and diagnostics. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum CounterOwner { + /// No extraction context; the low-level filename contract applies. + #[default] + Inactive, + /// An unnumbered original must not borrow a canonical root's counter. + Unnumbered, + /// The original source's predeclared D9 number. + D9(u32), +} + +impl CounterOwner { + #[must_use] + pub const fn from_source(source: &crate::sparse_site::SourceFile) -> Self { + match source { + crate::sparse_site::SourceFile::D9(id) => Self::D9(*id), + crate::sparse_site::SourceFile::Unnumbered(_) => Self::Unnumbered, + } + } +} + +/// Shared eligibility for class allocation and content/scope registry claims. +#[must_use] +pub fn owned_private_counter( + owner: CounterOwner, + delivery: (Option<&str>, u8), + naming: Naming, +) -> Option { + let (filename, order) = delivery; + if naming != Naming::Own || order == 0 { + return None; + } + let file = filename.filter(|file| !crate::atom_hoist::is_hoisted_bucket(file))?; + match owner { + CounterOwner::D9(id) => Some(id), + CounterOwner::Unnumbered => None, + CounterOwner::Inactive => private_counter(Some(file), naming, order), + } +} + +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/naming_coverage_tests.rs b/libs/css/src/naming_coverage_tests.rs new file mode 100644 index 000000000..69832927e --- /dev/null +++ b/libs/css/src/naming_coverage_tests.rs @@ -0,0 +1,109 @@ +use crate::style_selector::{AtRule, AtRuleKind, StyleSelector}; +use crate::{Naming, atom_name, content_name::AtomContent}; +use serial_test::serial; +use std::collections::{BTreeSet, HashMap}; + +struct Configuration; + +impl Drop for Configuration { + fn drop(&mut self) { + crate::atom_hoist::set_atom_hoist(None); + crate::atom_hoist::restore_atom_plan(None); + crate::file_map::reset_canonical_map(); + crate::set_prefix(None); + crate::debug::set_debug(false); + } +} + +#[test] +#[serial] +fn placement_scope_uses_canonical_buckets_not_original_cleanup_owners() { + // Given: distinct originals delivering into local and hoisted canonical buckets. + let _configuration = Configuration; + crate::atom_hoist::set_atom_hoist(Some(2)); + crate::atom_hoist::restore_atom_plan(Some(BTreeSet::from(["shared.tsx".into()]))); + crate::file_map::set_canonical_map(HashMap::from([ + ("child.tsx".into(), "a.tsx".into()), + ("shared-child.tsx".into(), "shared.tsx".into()), + ])); + // When: the public pure placement helper resolves each scope. + let scopes = [ + None, + Some("child.tsx"), + Some("a.tsx"), + Some("shared-child.tsx"), + ] + .map(atom_name::scope); + // Then: local aliases coincide, but global and hoisted delivery stay separate. + assert_eq!(scopes, ["g", "l-612e747378", "l-612e747378", "h"]); +} + +#[test] +fn nested_layer_selector_identity_keeps_inner_selector_but_not_cleanup_file() { + // Given: equal layer structure with a pseudo selector, and one different selector. + let selector = |inner: &str, file: &str| StyleSelector::At { + kind: AtRuleKind::Layer, + query: "cards".into(), + selector: Some(inner.into()), + outer: vec![AtRule { + kind: AtRuleKind::Layer, + query: "base".into(), + }], + file: Some(file.into()), + }; + let a = selector("&:hover", "a.tsx"); + let b = selector("&:hover", "b.tsx"); + let focus = selector("&:focus", "a.tsx"); + let content = |selector| { + AtomContent { + property: "color", + value: Some("red"), + naming: Naming::Own, + level: 0, + order: 255, + selector: Some(selector), + layer: None, + dynamic: false, + } + .content() + }; + // When: lossless selector keys and versioned descriptors are built from the same structure. + let keys = [&a, &b, &focus].map(|value| atom_name::selector_key(Some(value), None)); + let names = [&a, &b, &focus].map(content); + // Then: cleanup does not rename a rule, while a changed pseudo-selector must. + assert_eq!(keys[0], keys[1]); + assert_ne!(keys[0], keys[2]); + assert_eq!( + keys[0], + "a-layer-62617365-end-layer-6361726473-s-263a686f766572-n" + ); + assert_eq!(names[0], names[1]); + assert_ne!(names[0].descriptor, names[2].descriptor); + assert_ne!(names[0].name(""), names[2].name("")); + assert!(names[0].lossless.contains("-a3base-e-3cards-s_a_nhover")); +} + +#[test] +#[serial] +fn pure_keyframe_api_respects_atom_scope_and_legacy_content_controls() { + // Given: a custom prefix and canonical delivery alias in atom mode. + let _configuration = Configuration; + crate::set_prefix(Some("du-".into())); + crate::debug::set_debug(false); + crate::atom_hoist::set_atom_hoist(Some(2)); + crate::file_map::set_canonical_map(HashMap::from([("child.tsx".into(), "a.tsx".into())])); + // When: the existing low-level API names global/local animations, then its legacy control. + let global = crate::keyframes_to_keyframes_name("spin", None); + let child = crate::keyframes_to_keyframes_name("spin", Some("child.tsx")); + let canonical = crate::keyframes_to_keyframes_name("spin", Some("a.tsx")); + let other = crate::keyframes_to_keyframes_name("spin", Some("b.tsx")); + crate::atom_hoist::set_atom_hoist(None); + let legacy = crate::keyframes_to_keyframes_name("spin", Some("child.tsx")); + // Then: atom placement is canonical and injective, legacy stays content-only. + assert_eq!(global, "du-k1-g-7370696e"); + assert_eq!(child, "du-k1-l-612e747378-7370696e"); + assert_eq!(child, canonical); + assert_ne!(child, other); + assert_ne!(global, child); + assert_eq!(legacy, "du-Kspin"); +} diff --git a/libs/css/src/naming_root.rs b/libs/css/src/naming_root.rs new file mode 100644 index 000000000..7f4a5c6a3 --- /dev/null +++ b/libs/css/src/naming_root.rs @@ -0,0 +1,90 @@ +use std::sync::{LazyLock, Mutex}; + +#[derive(Clone)] +struct NamingContext { + root: String, + relative_base: Option, +} + +static ROOT: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +/// Set the independent names-only root; resolver and delivery identities stay unchanged. +pub fn set_root(root: Option) { + set_context(root, None); +} + +/// A relative-ID basis is names-only too; raw resolver and sheet keys are never rewritten. +pub fn set_context(root: Option, relative_base: Option) { + *ROOT + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = root.map(|root| NamingContext { + root, + relative_base, + }); +} + +fn parts(path: &str) -> (String, Vec<&str>) { + let segments: Vec<_> = path.split('/').filter(|part| !part.is_empty()).collect(); + let (root, start) = if path.starts_with("//") && segments.len() >= 2 { + (format!("//{}/{}", segments[0], segments[1]), 2) + } else if segments.first().is_some_and(|part| part.ends_with(':')) { + (segments[0].to_ascii_lowercase(), 1) + } else if path.starts_with('/') { + ("/".into(), 0) + } else { + (String::new(), 0) + }; + let mut normalized = Vec::new(); + for part in &segments[start..] { + match *part { + "." => {} + ".." if normalized.last().is_some_and(|last| *last != "..") => { + normalized.pop(); + } + ".." if !root.is_empty() => {} + _ => normalized.push(*part), + } + } + (root, normalized) +} + +/// Relativize only naming keys; outsiders keep `..`, incompatible roots keep absolute keys. +#[must_use] +pub fn key(filename: &str) -> String { + let root = ROOT + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let Some(context) = root.as_ref() else { + return filename.to_string(); + }; + let filename = filename.replace('\\', "/"); + let filename = match &context.relative_base { + Some(base) if parts(&filename).0.is_empty() => { + format!("{}/{filename}", base.replace('\\', "/")) + } + _ => filename, + }; + let root = context.root.replace('\\', "/"); + let (file_root, file) = parts(&filename); + if file_root.is_empty() { + return file.join("/"); + } + let (root_root, root) = parts(&root); + if file_root != root_root { + return if file_root == "/" { + format!("/{}", file.join("/")) + } else { + format!("{file_root}/{}", file.join("/")) + }; + } + let common = file + .iter() + .zip(&root) + .take_while(|(file, root)| file == root) + .count(); + std::iter::repeat_n("..", root.len().saturating_sub(common)) + .chain(file[common..].iter().copied()) + .collect::>() + .join("/") +} diff --git a/libs/css/src/naming_scope.rs b/libs/css/src/naming_scope.rs new file mode 100644 index 000000000..b2e98f26d --- /dev/null +++ b/libs/css/src/naming_scope.rs @@ -0,0 +1,33 @@ +use crate::{content_hash::FingerprintBits, content_name::ContentName}; + +/// A content atom is global only when #751 already delivers its rules in one shared sheet. +#[must_use] +pub fn local_file(filename: Option<&str>, order: u8) -> Option<&str> { + filename.filter(|file| order != 0 && !crate::atom_hoist::is_hoisted_bucket(file)) +} + +#[must_use] +pub fn fallback(filename: Option<&str>, order: u8) -> Option { + local_file(filename, order) + .filter(|file| crate::file_map::original_id(file).is_none()) + .map(|file| ContentName::scope(&crate::naming_root::key(&crate::file_map::canonical(file)))) +} + +/// Content names and exact claims use this same scope prefix and fingerprint width. +#[must_use] +pub fn name(content: &ContentName, source: (Option<&str>, u8), bits: FingerprintBits) -> String { + let prefix = crate::get_prefix().unwrap_or_default(); + let scope = match local_file(source.0, source.1) { + None => String::new(), + Some(file) => { + (match crate::file_map::original_id(file) { + Some(id) => { + crate::num_to_nm_base::num_to_nm_base(usize::try_from(id).unwrap_or_default()) + } + None => fallback(Some(file), source.1) + .map_or_else(String::new, |scope| scope.name_with_bits("", bits)), + }) + "-" + } + }; + content.name_with_bits(&format!("{prefix}{scope}"), bits) +} diff --git a/libs/css/src/scoped_name_tests.rs b/libs/css/src/scoped_name_tests.rs new file mode 100644 index 000000000..4710d5cc3 --- /dev/null +++ b/libs/css/src/scoped_name_tests.rs @@ -0,0 +1,85 @@ +use crate::{Naming, class_map, file_map, naming_root, sheet_to_classname_named}; +use serial_test::serial; + +#[rstest::rstest] +#[case(30)] +#[case(u32::MAX)] +#[serial] +fn d9_scope_bound_includes_the_existing_anti_ad_splice(#[case] id: u32) { + // Given + file_map::reset_file_map(); + crate::set_prefix(None); + file_map::set_original_ids(std::collections::BTreeMap::from([("x.tsx".into(), id)])); + // When + let name = sheet_to_classname_named( + "font-family", + 0, + Some(&"long".repeat(1000)), + None, + None, + Some("x.tsx"), + Naming::Risky, + ); + // Then + assert!(name.len() <= 27, "{name}"); + assert!(name.contains("-RH")); + if id == 30 { + assert!(name.starts_with("a-d-"), "{name}"); + } + file_map::reset_file_map(); +} + +#[test] +#[serial] +fn risky_atoms_use_d9_identity_without_taking_any_counter_slots() { + // Given + class_map::reset_class_map(); + file_map::reset_file_map(); + crate::set_prefix(None); + file_map::seed_file_numbers(&["a.tsx".into(), "b.tsx".into()]); + // When + let names = ["a.tsx", "b.tsx"].map(|file| { + sheet_to_classname_named( + "color", + 0, + Some("red"), + None, + None, + Some(file), + Naming::Risky, + ) + }); + // Then + assert_ne!(names[0], names[1]); + assert_eq!(names, ["a-RLcolor-vred", "b-RLcolor-vred"]); + assert_eq!(class_map::get_class_map().len(), 0); + file_map::reset_file_map(); +} + +#[test] +#[serial] +fn long_scope_and_content_are_bounded_without_arrival_numbering() { + // Given + file_map::reset_file_map(); + naming_root::set_root(Some("/checkout".into())); + crate::set_prefix(Some("du-".into())); + let file = format!("/checkout/src/{}.tsx", "long".repeat(1000)); + let value = "content".repeat(1000); + // When + let name = sheet_to_classname_named( + "font-family", + 0, + Some(&value), + None, + None, + Some(&file), + Naming::Risky, + ); + // Then + assert_eq!(name.len(), 40); + assert!(name.starts_with("du-FH")); + assert!(name.contains("-RH")); + assert_eq!(file_map::get_file_map().len(), 0); + naming_root::set_root(None); + crate::set_prefix(None); +} diff --git a/libs/css/src/sparse_site.rs b/libs/css/src/sparse_site.rs new file mode 100644 index 000000000..fc4ab0b0f --- /dev/null +++ b/libs/css/src/sparse_site.rs @@ -0,0 +1,236 @@ +use std::sync::Arc; + +pub(crate) mod source_ids; + +use crate::{ + atom_hoist, atom_name, class_num_for_key, content_hash::FingerprintBits, + content_name::ContentName, debug::is_debug, encode_selector, 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)-[-]`. + /// The source file part is at most 18 bytes; lossless wins ties with 80 bits. + #[must_use] + pub fn variable_name(&self, prefix: &str) -> String { + self.variable_name_with_bits(prefix, FingerprintBits::PRODUCTION) + } + + /// Width is explicit for the same production naming/collision path. + #[must_use] + pub fn variable_name_with_bits(&self, prefix: &str, bits: FingerprintBits) -> 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_str(&ContentName::source(source).name_with_bits("", bits)); + } + } + 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, + [ + "---SULa-a", + "---SUL_x55_-a", + "---SULa_db-a", + "---SULa_udb-a", + "---SUL_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..3995bb513 --- /dev/null +++ b/libs/css/src/style_origin.rs @@ -0,0 +1,155 @@ +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); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filling_missing_witness_keeps_the_first_real_expression() { + let first = StyleOrigin { + file: "src/first.tsx".into(), + line: 2, + column: 7, + expression: "first.value".into(), + }; + let later = StyleOrigin { + file: "src/later.tsx".into(), + expression: "later.value".into(), + ..first + }; + let mut origin = Origin::default(); + + origin.fill(Some(&first)); + origin.fill(Some(&later)); + + assert_eq!(origin.location(), Some(RealLocation::Exact(first))); + } + + #[test] + fn diagnostic_witnesses_do_not_change_style_ordering() { + let left = Origin::from_location(RealLocation::ModuleExport { + file: "src/left.css.ts".into(), + binding: Some("left".into()), + }); + let right = Origin::from_location(RealLocation::ModuleExport { + file: "src/right.css.ts".into(), + binding: Some("right".into()), + }); + + let ordering = left.partial_cmp(&right); + + assert_eq!(ordering, Some(Ordering::Equal)); + assert_ne!(left.location(), right.location()); + } +} 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/css/tests/compact_sources.rs b/libs/css/tests/compact_sources.rs new file mode 100644 index 000000000..0b45b2c76 --- /dev/null +++ b/libs/css/tests/compact_sources.rs @@ -0,0 +1,63 @@ +use css::{Site, sparse_site::SourceFile}; +use css::{content_hash::FingerprintBits, content_name::ContentName}; +use std::sync::Arc; + +#[test] +fn variable_length_is_bounded_when_unnumbered_source_is_100_kb() { + // Given: a large received source and the largest supported position/role. + let source = "// filler\n".repeat(10_240); + let site = Site { + file: SourceFile::Unnumbered(Arc::from(source)), + at: usize::MAX, + role: usize::MAX, + }; + // When: the production naming API emits its variable. + let name = site.variable_name("test-"); + // Then: source size cannot inflate the file part beyond 18 bytes. + let digits = if usize::BITS == 64 { 13 } else { 7 }; + assert!( + name.len() <= 4 + 5 + 18 + 2 + 2 * digits, + "length={}", + name.len() + ); + assert_eq!(name.len(), 4 + 5 + 18 + 2 + 2 * digits); +} + +#[test] +fn lossless_wins_when_source_payload_ties_the_fingerprint_length() { + // Given: normalized source payloads at either side of the 16-byte boundary. + let sources = ["a".repeat(15), "a".repeat(16), "a".repeat(17)]; + // When: the shared min-length algorithm names each source. + let names = sources.map(|source| ContentName::source(&source).name("")); + // Then: ties stay lossless and only the longer source uses a fingerprint. + assert_eq!(names[0], format!("UL{}", "a".repeat(15))); + assert_eq!(names[1], format!("UL{}", "a".repeat(16))); + assert!(names[2].starts_with("UH")); + assert_eq!(names[2].len(), 18); +} + +#[test] +fn source_fingerprint_is_fixed_when_source_has_multiple_sha_blocks() { + // Given: the NIST two-block input, independent of Rust/compiler versions. + let source = "abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu"; + // When: the source is named without a filename or diagnostic witness. + let name = ContentName::source(source).name(""); + // Then: the first 80 SHA-256 bits match the independent fixed byte golden. + assert_eq!(name, "UHc8yjo0690l8os8yl"); +} + +#[test] +fn narrow_width_names_collide_when_distinct_sources_have_the_same_digest_prefix() { + // Given: three distinct sources competing for two one-bit fingerprints. + let bits = FingerprintBits::new(1).unwrap_or_else(|| panic!("valid width")); + let sites = ["// source zero", "// source one", "// source two"].map(|source| Site { + file: SourceFile::Unnumbered(Arc::from(source)), + at: 10, + role: 2, + }); + // When: the production function receives the explicit narrow width. + let names = sites.map(|site| site.variable_name_with_bits("", bits)); + // Then: the collision path is reachable without globals or a test-only hasher. + assert!(names[0] == names[1] || names[0] == names[2] || names[1] == names[2]); + assert!(names.iter().all(|name| name.starts_with("---SUH"))); +} diff --git a/libs/extractor/src/as_member_boundary_tests.rs b/libs/extractor/src/as_member_boundary_tests.rs new file mode 100644 index 000000000..eeedeadd3 --- /dev/null +++ b/libs/extractor/src/as_member_boundary_tests.rs @@ -0,0 +1,34 @@ +use super::{As, rename, resolve}; +use crate::coverage_tests::expression; +use oxc_allocator::Allocator; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use rstest::rstest; + +#[rstest] +#[case("motion.button", "motion.button")] +#[case("motion.controls.button", "motion.controls.button")] +#[case("(motion.controls).button", "motion.controls.button")] +fn residual_member_name_retains_unresolved_receiver_chain( + #[case] source: &str, + #[case] expected: &str, +) { + // Given: no declared object exists for the constant evaluator to fold. + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let Expression::JSXElement(mut element) = expression(&allocator, "
") else { + panic!("JSX fixture") + }; + let value = expression(&allocator, source); + // When + let resolved = resolve(&ast, &element, value, "div"); + // Then: observe both JSX names, not just successful resolution. + let As::Name(name) = resolved else { + panic!("static member must remain a JSX binding") + }; + rename(&ast, &mut element, name); + assert_eq!(element.opening_element.name.to_string(), expected); + let Some(closing) = &element.closing_element else { + panic!("paired JSX fixture") + }; + assert_eq!(closing.name.to_string(), expected); +} diff --git a/libs/extractor/src/as_visit.rs b/libs/extractor/src/as_visit.rs index 3676c7db3..b8dceb8f5 100644 --- a/libs/extractor/src/as_visit.rs +++ b/libs/extractor/src/as_visit.rs @@ -11,6 +11,10 @@ use oxc_syntax::operator::LogicalOperator; use crate::utils::unwrap_syntax_only; +#[cfg(test)] +#[path = "as_member_boundary_tests.rs"] +mod member_boundary_tests; + /// What an element with an `as` becomes pub enum As<'a> { /// The element renamed 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..cba10a8ca --- /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::static_assignment::literal_source(value) => {} + _ => 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_class_reference.rs b/libs/extractor/src/assignment_class_reference.rs new file mode 100644 index 000000000..0c237be31 --- /dev/null +++ b/libs/extractor/src/assignment_class_reference.rs @@ -0,0 +1,34 @@ +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_ast_visit::VisitMut; +use oxc_span::{GetSpan, Span}; + +use crate::utils::expression_to_code; + +pub(super) fn replace<'a>( + ast: &AstBuilder<'a>, + source: &Expression<'a>, + class: &mut Expression<'a>, +) { + ClassReference { + ast, + span: source.span(), + code: expression_to_code(source), + } + .visit_expression(class); +} + +struct ClassReference<'b, 'a> { + ast: &'b AstBuilder<'a>, + span: Span, + code: String, +} + +impl<'a> VisitMut<'a> for ClassReference<'_, 'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + if expression.span() == self.span && expression_to_code(expression) == self.code { + *expression = Expression::new_identifier(expression.span(), "__devupValue", self.ast); + } else { + oxc_ast_visit::walk_mut::walk_expression(self, expression); + } + } +} 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..6602058a7 --- /dev/null +++ b/libs/extractor/src/assignment_lowering.rs @@ -0,0 +1,214 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{ + BinaryOperator, ChainElement, ComputedMemberExpression, Expression, LogicalOperator, Str, + UnaryOperator, + }, + builder::AstBuilder, +}; +use oxc_span::{GetSpan, 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 styled_argument<'a>( + ast: &AstBuilder<'a>, + source: &Expression<'a>, + styles: &mut Vec>, +) { + if matches!( + unwrap_syntax_only(source), + Expression::ConditionalExpression(_) + | Expression::LogicalExpression(_) + | Expression::ComputedMemberExpression(_) + ) && !crate::static_assignment::literal_source(source) + { + *styles = vec![ExtractStyleProp::Evaluated { + binding: crate::sparse_sites::binding_name(source.span().start), + styles: std::mem::take(styles), + source: source.clone_in(ast.allocator()), + evaluation: None, + alternate_order: None, + alternate_class: false, + }]; + } +} + +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..fafd6bbf6 --- /dev/null +++ b/libs/extractor/src/assignment_mixed_container_support.rs @@ -0,0 +1,78 @@ +use crate::assignment_test_support::{evaluate, extracted, jsx_js}; +use crate::{ExtractStyleValue, extract_style::style_property::StyleProperty}; + +#[test] +#[serial_test::serial] +fn unrelated_typography_record_does_not_change_selected_selector_association() { + // Given + let source = "import {Box,css}from '@devup-ui/react';const unused=()=>css({typography:'heading'});let trace=[];const node=;"; + assert!( + extracted(source) + .styles + .iter() + .any(|value| matches!(value, ExtractStyleValue::Typography(name) if name == "heading")) + ); + // When + let actual = associated(source); + // Then + assert_eq!(actual, "[\"&:hover|color:red:0|\"]"); +} + +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..a6e8b89d2 --- /dev/null +++ b/libs/extractor/src/assignment_object.rs @@ -0,0 +1,116 @@ +use crate::{ + ExtractStyleProp, ExtractStyleValue, + assignment_lowering::{Classes, Lowering, empty, projection}, + gen_class_name::{gen_class_names, 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(); + let mut assigned = vec![false; styles.len()]; + 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() + .enumerate() + .filter_map(|(index, style)| { + let consumes = + if crate::assignment_owner::contains_consumer(source.span(), style) { + crate::assignment_owner::contains_consumer( + property.value.span(), + style, + ) + } else { + style.extract().iter().any(|value| match value { + ExtractStyleValue::Static(value) => { + names.contains(&value.property) + } + ExtractStyleValue::Dynamic(value) => { + names.iter().any(|name| name == value.property()) + } + ExtractStyleValue::Typography(_) => { + names.iter().any(|name| name == "typography") + } + _ => false, + }) + }; + assigned[index] |= consumes; + if consumes { + Some(style.clone_in(ast.allocator())) + } else { + None + } + }) + .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 mut residual = styles + .iter() + .zip(assigned) + .filter(|(_, assigned)| !assigned) + .map(|(style, _)| style.clone_in(ast.allocator())) + .collect::>(); + let alternate = self.alternate_order.and_then(|order| { + let mut residual = residual + .iter() + .map(|style| style.clone_in(ast.allocator())) + .collect::>(); + gen_class_names(ast, &mut residual, order.value, self.filename) + }); + let residual = gen_class_names(ast, &mut residual, self.order, self.filename); + let classes = |slot| { + let residual = if slot == 3 { &alternate } else { &residual }; + merge_expression_for_class_name( + ast, + refs.iter().map(|name| projection(ast, name, slot)).chain( + residual + .as_ref() + .map(|class| class.clone_in(ast.allocator())), + ), + ) + .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..44046d27f --- /dev/null +++ b/libs/extractor/src/assignment_value.rs @@ -0,0 +1,202 @@ +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}, +}; + +#[path = "assignment_class_reference.rs"] +mod assignment_class_reference; + +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| { + if crate::assignment_owner::contains_consumer(value.span(), style) { + return true; + } + style.extract().iter().any(|value| match value { + ExtractStyleValue::Dynamic(style) => { + usize::from(style.level()) == index + } + ExtractStyleValue::Static(style) => usize::from(style.level()) == index, + ExtractStyleValue::Typography(_) => index == 0, + _ => 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 { + Expression::TemplateLiteral(template) if normalized => { + 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 mut class_props = styles + .iter() + .map(|style| style.clone_in(ast.allocator())) + .collect::>(); + let class_alternatives = self.alternate_order.map(|_| { + class_props + .iter() + .map(|style| style.clone_in(ast.allocator())) + .collect::>() + }); + let mut class = gen_class_names(ast, &mut class_props, self.order, self.filename) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast)); + assignment_class_reference::replace(ast, source, &mut class); + let alternate = + self.alternate_order + .zip(class_alternatives) + .map(|(order, mut alternative)| { + let mut class = + gen_class_names(ast, &mut alternative, order.value, self.filename) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast)); + assignment_class_reference::replace(ast, source, &mut class); + class + }); + 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/coverage_tests.rs b/libs/extractor/src/coverage_tests.rs new file mode 100644 index 000000000..763066785 --- /dev/null +++ b/libs/extractor/src/coverage_tests.rs @@ -0,0 +1,48 @@ +mod capture; +mod elements; +mod generation; +mod naming; +mod normalization; +mod operands; +mod origins; +mod residual_operands; + +use oxc_allocator::{Allocator, CloneIn}; +use oxc_ast::ast::{Expression, Statement}; +use oxc_parser::Parser; +use oxc_span::SourceType; + +pub(crate) fn expression<'a>(allocator: &'a Allocator, source: &'a str) -> Expression<'a> { + let parsed = Parser::new(allocator, source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{source}"); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("expression fixture"); + }; + crate::utils::unwrap_syntax_only(&statement.expression).clone_in(allocator) +} + +fn dynamic(property: &str, identifier: &str) -> crate::ExtractStyleValue { + crate::ExtractStyleValue::Dynamic( + crate::extract_style::extract_dynamic_style::ExtractDynamicStyle::new( + property, 0, identifier, None, + ), + ) +} + +pub(crate) fn code(expression: &Expression<'_>) -> String { + crate::utils::expression_to_code(expression) + .trim() + .trim_end_matches(';') + .to_string() +} + +#[test] +#[should_panic(expected = "expression fixture")] +fn residual_expression_fixture_rejects_valid_declaration_statement() { + // Given: valid TS, but not the expression-statement fixture contract. + let allocator = Allocator::default(); + // When / Then: the helper intentionally rejects the statement shape. + expression(&allocator, "let value = 1;"); +} + +mod spread_alignment; diff --git a/libs/extractor/src/coverage_tests/capture.rs b/libs/extractor/src/coverage_tests/capture.rs new file mode 100644 index 000000000..754491f87 --- /dev/null +++ b/libs/extractor/src/coverage_tests/capture.rs @@ -0,0 +1,141 @@ +use super::{code, expression}; +use crate::{ExtractStyleProp, ExtractStyleValue, assignment_test_support::evaluate}; +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use oxc_span::{GetSpan, SPAN}; + +#[test] +fn component_capture_orders_evaluations_inside_each_render() { + // Given: consumers are stored in reverse order, while spans retain authored order. + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let mut component = expression(&allocator, "()=>({value:first+second})"); + let mut styles = + [("second", 20), ("first", 10)].map(|(name, start)| ExtractStyleProp::Evaluated { + styles: vec![], + source: expression(&allocator, "input"), + binding: name.into(), + evaluation: Some(oxc_ast::ast::Expression::new_call_expression( + oxc_span::Span::new(start, start + 1), + expression(&allocator, name), + None, + oxc_allocator::Vec::new_in(&ast), + false, + &ast, + )), + alternate_order: None, + alternate_class: false, + }); + // When + crate::assignment_capture::component(&ast, &mut component, &mut styles); + let actual = evaluate(&format!( + "let trace=[];function first(){{trace.push('first');return 2}}function second(){{trace.push('second');return 3}}const render={};const before=trace.length;const a=render(),b=render();JSON.stringify([before,trace,a.value,b.value]);", + code(&component) + )); + // Then + assert_eq!( + actual, + "[0,[\"first\",\"second\",\"first\",\"second\"],5,5]" + ); + assert!(styles.iter().all(|style| matches!( + style, + ExtractStyleProp::Evaluated { + evaluation: None, + .. + } + ))); +} + +#[test] +fn class_capture_leaves_missing_variants_untouched() { + // Given + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = expression(&allocator, "state.value"); + let mut variants = [None, Some(expression(&allocator, "('alternate')"))]; + // When + let captured = crate::class_evaluation::capture(&ast, &source, &mut variants); + // Then + assert!(captured.is_none()); + assert!(variants[0].is_none()); + assert_eq!( + crate::utils::get_string_by_literal_expression( + variants[1] + .as_ref() + .unwrap_or_else(|| panic!("alternate retained")) + ) + .as_deref(), + Some("alternate") + ); +} + +#[test] +fn constant_typography_class_still_evaluates_its_authored_operand_once() { + // Given: precomputed class data must not erase observable source evaluation. + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = expression(&allocator, "state.value"); + let mut styles = vec![ExtractStyleProp::Static(ExtractStyleValue::Typography( + "heading".into(), + ))]; + let mut next = 0; + // When + let (_, capture) = crate::element_evaluation::typography(&ast, &mut styles, &source, &mut next) + .unwrap_or_else(|| panic!("typography capture")); + let actual = evaluate(&format!( + "let reads=0;const state={{get value(){{reads++;return 'heading'}}}};const value={};JSON.stringify([reads,value]);", + code(&capture) + )); + // Then + assert_eq!(actual, "[1,\"typo-heading\"]"); + assert_eq!(capture.span(), source.span()); +} + +#[test] +fn synthesized_attribute_capture_uses_the_authored_attribute_coordinate() { + // Given + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let mut source = expression(&allocator, ""); + let oxc_ast::ast::Expression::JSXElement(element) = &mut source else { + panic!("element fixture") + }; + let oxc_ast::ast::JSXAttributeItem::Attribute(attribute) = + &mut element.opening_element.attributes[0] + else { + panic!("attribute fixture") + }; + let span = attribute.span; + *crate::visit::attribute_value_mut(attribute).unwrap_or_else(|| panic!("id value")) = + oxc_ast::ast::Expression::new_identifier(SPAN, "synthesized", &ast); + // When + let values = crate::element_evaluation::capture(&ast, element, &mut 0); + // Then: scheduling gets the original id position rather than the synthetic zero. + assert_eq!(values.len(), 1); + assert_eq!(values[0].1.span(), span); + assert_eq!(code(&values[0].1), "synthesized"); +} + +#[test] +fn creation_capture_does_not_rewrite_an_authored_callback_body() { + // Given + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = expression(&allocator, "({color:state.value})"); + let mut component = expression(&allocator, "()=>state.value"); + // When + crate::assignment_capture::styled_creation( + &ast, + &mut component, + crate::assignment_capture::StyledCreation { + source: &source, + styles: &mut [], + }, + ); + let actual = evaluate(&format!( + "let reads=0;const state={{get value(){{return ++reads}}}};const render={};const created=reads;const first=render(),second=render();JSON.stringify([created,first,second,reads]);", + code(&component) + )); + // Then: creating the literal and invoking the independent callback are separate reads. + assert_eq!(actual, "[1,2,3,3]"); +} diff --git a/libs/extractor/src/coverage_tests/elements.rs b/libs/extractor/src/coverage_tests/elements.rs new file mode 100644 index 000000000..cbaeab5d5 --- /dev/null +++ b/libs/extractor/src/coverage_tests/elements.rs @@ -0,0 +1,161 @@ +use crate::assignment_blocker_support::selected; +use crate::assignment_test_support::{compiled_jsx, evaluate}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("styled.div")] +#[case("css")] +#[serial] +fn hard_string_spread_reports_the_authored_call(#[case] factory: &str) { + // Given + let source = format!( + "import {{styled,css}} from '@devup-ui/react';\nexport const A = {factory}(...'color:red;');" + ); + let offset = source + .find(&format!("{factory}(...")) + .unwrap_or_else(|| panic!("authored call")); + let before = &source[..offset]; + let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = before + .rsplit('\n') + .next() + .unwrap_or_else(|| panic!("authored line")) + .len() + + 1; + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + // When + let Err(actual) = crate::extract("spread.tsx", &source, crate::ExtractOption::default()) else { + panic!("string iteration is not static style composition"); + }; + // Then + assert_eq!( + actual.to_string(), + format!( + "spread.tsx:{line}:{column}: Cannot compose `...\"color:red;\"` at build time: each style must be a rule object, a class, or a condition choosing between them; pass the value itself instead of spreading it" + ) + ); +} + +#[rstest] +#[case("styled.div", "{[Symbol.iterator]: function*(){yield {color:'red'}}}")] +#[case("css", "{[Symbol.iterator]: function*(){yield {color:'red'}}}")] +#[case("styled.div", "globalThis.styles")] +#[case("css", "globalThis.styles")] +#[serial] +fn hard_unsupported_spread_reports_the_authored_call(#[case] factory: &str, #[case] operand: &str) { + // Given: a valid iterator or a value whose iterable contents are unknown. + let source = format!( + "import {{styled,css}} from '@devup-ui/react';\nexport const A = {factory}(...{operand});" + ); + let offset = source + .find(&format!("{factory}(...")) + .unwrap_or_else(|| panic!("authored call")); + let before = &source[..offset]; + let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = before + .rsplit('\n') + .next() + .unwrap_or_else(|| panic!("authored line")) + .chars() + .count() + + 1; + let allocator = oxc_allocator::Allocator::default(); + let expression = oxc_parser::Parser::new(&allocator, operand, oxc_span::SourceType::ts()) + .parse_expression() + .unwrap_or_else(|error| panic!("valid spread operand: {error:?}")); + let code = crate::utils::readable_code(&expression); + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + // When + let Err(actual) = crate::extract("spread.tsx", &source, crate::ExtractOption::default()) else { + panic!("unsupported argument spread must not execute its iterator"); + }; + // Then: the call, rather than its computed iterator key, owns the diagnostic. + assert_eq!( + actual.to_string(), + format!( + "spread.tsx:{line}:{column}: Cannot compose `...{code}` at build time: each style must be a rule object, a class, or a condition choosing between them; pass the value itself instead of spreading it" + ) + ); +} + +#[test] +#[serial] +fn hard_array_css_spread_selects_the_later_literal_rule() { + // Given + let source = "import {css}from '@devup-ui/react';const node={className:css(...[{color:'blue'},{color:'red'}])};"; + // When + let actual = selected(source, "JSON.stringify(assigned(node));"); + // Then + assert_eq!(actual, "[\"color:red:0\"]"); +} + +#[rstest] +#[case("styled.div")] +#[case("styled('div')")] +#[serial] +fn hard_array_styled_spread_reads_its_getter_at_creation_only(#[case] factory: &str) { + // Given + let source = format!( + "import {{styled}}from '@devup-ui/react';let reads=0;Object.defineProperty(globalThis,'color',{{get(){{reads++;return 'red'}}}});const A={factory}(...[{{color:globalThis.color}}]);const created=reads;const first=A({{}}),second=A({{}});" + ); + // When + let actual = selected( + &source, + "JSON.stringify([created,reads,first.className===second.className,Object.values(first.style),Object.values(second.style),assigned(first),assigned(second)]);", + ); + // Then + assert_eq!( + actual, + "[1,1,true,[\"red\"],[\"red\"],[\"color:red:0\"],[\"color:red:0\"]]" + ); +} + +#[rstest] +#[case( + "", + true, + "[[\"enabled\"],[\"padding:1rem:0\"]]" +)] +#[case("", false, "[[\"enabled\"],[]]")] +#[case( + "jsx(Box,{className:state.enabled&&'p-4'})", + true, + "[[\"enabled\"],[\"padding:1rem:0\"]]" +)] +#[case( + "jsx(Box,{className:state.enabled&&'p-4'})", + false, + "[[\"enabled\"],[]]" +)] +#[serial] +fn rebuilt_tailwind_logical_class_omits_falsy_values( + #[case] expression: &str, + #[case] enabled: bool, + #[case] expected: &str, +) { + // Given + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';let trace=[];const state={{get enabled(){{trace.push('enabled');return {enabled}}}}};const node={expression};" + ); + // When + let actual = selected( + &source, + "function jsx(tag,props){return props}JSON.stringify([trace,assigned(node)]);", + ); + // Then: the true branch selects its emitted padding; false never becomes a class word. + assert_eq!(actual, expected); +} + +#[test] +#[serial] +fn conditional_styled_argument_keeps_its_creation_evaluation_when_rendered_twice() { + // Given: the whole authored rule argument selects a static rule at creation. + let source = "import {styled}from '@devup-ui/react';let reads=0;Object.defineProperty(globalThis,'flag',{get(){reads++;return true}});const A=styled.div(flag?{color:'red'}:{color:'blue'});const created=reads;const first=A({}),second=A({});JSON.stringify([created,reads,first.className===second.className]);"; + // When + let actual = evaluate(&compiled_jsx(source)); + // Then: rendering never reevaluates the already selected argument. + assert_eq!(actual, "[1,1,true]"); +} diff --git a/libs/extractor/src/coverage_tests/generation.rs b/libs/extractor/src/coverage_tests/generation.rs new file mode 100644 index 000000000..a4865a690 --- /dev/null +++ b/libs/extractor/src/coverage_tests/generation.rs @@ -0,0 +1,79 @@ +use super::{code, dynamic, expression}; +use crate::{ExtractStyleProp, assignment_test_support::evaluate, gen_style::gen_styles}; +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use serial_test::serial; +use std::collections::BTreeMap; + +#[test] +#[serial] +fn inline_value_is_absent_when_one_sided_condition_is_false() { + // Given + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let styles = [ExtractStyleProp::Conditional { + condition: expression(&allocator, "enabled"), + consequent: Some(Box::new(ExtractStyleProp::Static(dynamic( + "color", "chosen", + )))), + alternate: None, + }]; + // When + let generated = + code(&gen_styles(&ast, &styles, None).unwrap_or_else(|| panic!("inline value"))); + let actual = evaluate(&format!( + "let enabled=false,chosen='red';const absent={generated};enabled=true;const present={generated};JSON.stringify([Object.values(absent),Object.values(present)]);" + )); + // Then + assert_eq!(actual, "[[null],[\"red\"]]"); +} + +#[test] +#[serial] +fn shared_variable_selects_only_the_requested_branch() { + // Given: identical property identity and distinct observable inputs. + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let styles = [ExtractStyleProp::Conditional { + condition: expression(&allocator, "enabled"), + consequent: Some(Box::new(ExtractStyleProp::Static(dynamic( + "color", + "state.first", + )))), + alternate: Some(Box::new(ExtractStyleProp::Static(dynamic( + "color", + "state.second", + )))), + }]; + // When + let generated = + code(&gen_styles(&ast, &styles, None).unwrap_or_else(|| panic!("inline value"))); + let actual = evaluate(&format!( + "let enabled=true,trace=[];const state={{get first(){{trace.push('first');return 'red'}},get second(){{trace.push('second');return 'blue'}}}};const first={generated};enabled=false;const second={generated};JSON.stringify([trace,Object.keys(first).length,Object.keys(second).length,Object.values(first),Object.values(second)]);" + )); + // Then + assert_eq!(actual, "[[\"first\",\"second\"],1,1,[\"red\"],[\"blue\"]]"); +} + +#[test] +#[serial] +fn single_member_variable_reads_its_value_without_reading_the_selector() { + // Given + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let styles = [ExtractStyleProp::MemberExpression { + map: BTreeMap::from([( + "one".into(), + Box::new(ExtractStyleProp::Static(dynamic("color", "state.value"))), + )]), + expression: expression(&allocator, "state.key"), + }]; + // When + let generated = + code(&gen_styles(&ast, &styles, None).unwrap_or_else(|| panic!("inline value"))); + let actual = evaluate(&format!( + "let reads=0;const state={{get value(){{reads++;return 'red'}},get key(){{throw Error('unused selector')}}}};const result={generated};JSON.stringify([reads,Object.values(result)]);" + )); + // Then: this IR seam has already selected the sole variable consumer. + assert_eq!(actual, "[1,[\"red\"]]"); +} diff --git a/libs/extractor/src/coverage_tests/naming.rs b/libs/extractor/src/coverage_tests/naming.rs new file mode 100644 index 000000000..32890980e --- /dev/null +++ b/libs/extractor/src/coverage_tests/naming.rs @@ -0,0 +1,100 @@ +use super::{dynamic, expression}; +use crate::{ExtractStyleProp, ExtractStyleValue, provenance::join_naming}; +use css::Naming; +use oxc_allocator::Allocator; +use rstest::rstest; +use std::collections::BTreeMap; + +#[rstest] +#[case("conditional")] +#[case("enum")] +#[case("member")] +#[case("expression")] +fn imported_provenance_reaches_every_nested_consumer(#[case] shape: &str) { + // Given: mixed preexisting ownership; joining Own must not erase Risky. + let allocator = Allocator::default(); + let source = expression(&allocator, "input"); + let mut already_risky = dynamic("color", "first"); + already_risky.join_naming(Naming::Risky); + let values = vec![already_risky, dynamic("background-color", "second")]; + let leaf = || { + ExtractStyleProp::StaticArray( + values + .iter() + .cloned() + .map(ExtractStyleProp::Static) + .collect(), + ) + }; + let prop = match shape { + "conditional" => ExtractStyleProp::Conditional { + condition: source, + consequent: Some(Box::new(leaf())), + alternate: Some(Box::new(leaf())), + }, + "enum" => ExtractStyleProp::Enum { + condition: source, + map: BTreeMap::from([("a".into(), vec![leaf()]), ("b".into(), vec![leaf()])]), + }, + "member" => ExtractStyleProp::MemberExpression { + expression: source, + map: BTreeMap::from([ + ("a".into(), Box::new(leaf())), + ("b".into(), Box::new(leaf())), + ]), + }, + "expression" => ExtractStyleProp::Expression { + expression: source, + styles: values, + }, + _ => panic!("fixture shape"), + }; + let mut props = [prop]; + // When + join_naming(&mut props, Naming::Risky); + join_naming(&mut props, Naming::Own); + // Then: both branches/entries retain every property and all become Risky. + let actual: Vec<_> = props[0] + .extract() + .into_iter() + .map(|value| match value { + ExtractStyleValue::Dynamic(style) => (style.property().to_string(), style.naming()), + other => panic!("unexpected record {other:?}"), + }) + .collect(); + let pair = vec![ + ("color".to_string(), Naming::Risky), + ("background-color".to_string(), Naming::Risky), + ]; + let expected = if shape == "expression" { + pair + } else { + pair.iter().chain(&pair).cloned().collect() + }; + assert_eq!(actual, expected); +} + +#[test] +fn typography_identity_is_unchanged_when_dependency_naming_is_joined() { + // Given + let mut value = ExtractStyleValue::Typography("heading".into()); + let expected = value.clone(); + // When + value.join_naming(Naming::Risky); + // Then + assert_eq!(value, expected); +} + +#[test] +fn assignment_binding_avoids_every_authored_prefix_collision() { + // Given + let _scope = crate::sparse_sites::SiteScope::enter( + "collision.tsx", + "let __devupAssignment17;let __devupAssignment17_;", + &[], + ); + // When + let binding = crate::sparse_sites::binding_name(17); + // Then: name must be safe as a JS binding, including the already suffixed name. + assert_eq!(binding, "__devupAssignment17__"); +} diff --git a/libs/extractor/src/coverage_tests/normalization.rs b/libs/extractor/src/coverage_tests/normalization.rs new file mode 100644 index 000000000..fdc6c6550 --- /dev/null +++ b/libs/extractor/src/coverage_tests/normalization.rs @@ -0,0 +1,65 @@ +use super::{code, dynamic, expression}; +use crate::{ExtractStyleProp, assignment_test_support::evaluate}; +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use serial_test::serial; + +#[test] +#[serial] +fn important_leaf_keeps_clean_inline_value_and_original_raw_suffix() { + // Given + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = expression(&allocator, "normalized"); + let mut styles = [ExtractStyleProp::Static(dynamic( + "color", + "normalized !important", + ))]; + let lowering = crate::assignment_lowering::Lowering { + ast: &ast, + order: None, + filename: None, + alternate_order: None, + }; + // When + let generated = lowering.lower(&source, &mut styles); + let actual = evaluate(&format!( + "const normalized='red';const result={};JSON.stringify([Object.values(result[1]),result[2]]);", + code(&generated) + )); + // Then + assert_eq!(actual, "[[\"red\"],\"red !important\"]"); +} + +#[test] +#[serial] +fn risky_partial_class_map_uses_import_dependent_atom_identity() { + use crate::extract_style::ExtractStyleProperty; + + // Given + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let source = "import {css}from '@devup-ui/react';export const sample=css({color:'red'});"; + let names = rustc_hash::FxHashSet::from_iter(["sample".to_string()]); + let option = crate::ExtractOption::default(); + // When + let actual = crate::extract_class_map_from_code( + "partial.tsx", + source, + &option, + &names, + css::Naming::Risky, + ) + .unwrap_or_else(|error| panic!("class map: {error}")); + // Then: compare to independently constructed risky content, not the output itself. + let style = crate::extract_style::extract_static_style::ExtractStaticStyle::new( + "color", "red", 0, None, + ) + .with_naming(css::Naming::Risky); + let crate::extract_style::style_property::StyleProperty::ClassName(expected) = + style.extract(Some("partial.tsx")) + else { + panic!("static class") + }; + assert_eq!(actual.get("sample"), Some(&expected)); +} diff --git a/libs/extractor/src/coverage_tests/operands.rs b/libs/extractor/src/coverage_tests/operands.rs new file mode 100644 index 000000000..6606290b5 --- /dev/null +++ b/libs/extractor/src/coverage_tests/operands.rs @@ -0,0 +1,162 @@ +use super::{code, dynamic, expression}; +use crate::{ + ExtractStyleProp, ExtractStyleValue, + assignment_test_support::{compiled_jsx, evaluate}, +}; +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("motion.button", "button")] +#[case("motion.controls.button", "button")] +#[serial] +fn static_member_types_keep_the_receiver_chain(#[case] tag: &str, #[case] expected: &str) { + // Given + let source = format!( + "import {{Box}}from '@devup-ui/react';const motion={{button:'button',controls:{{button:'button'}}}};const node=;JSON.stringify(node.tag);" + ); + // When + let actual = evaluate(&compiled_jsx(&source)); + // Then + assert_eq!(actual, format!("\"{expected}\"")); +} + +#[test] +#[serial] +fn array_shape_cannot_collapse_to_one_scalar_assignment() { + // Given + let allocator = Allocator::default(); + let source = expression(&allocator, "[state.value]"); + let styles = [ExtractStyleProp::Static(dynamic("color", "state.value"))]; + // When + let scalar = crate::assignment_owner::scalar(&source, &styles); + // Then: responsive arrays require the structured consumer, not a scalar variable. + assert_eq!(scalar, None); +} + +#[test] +fn object_spread_is_not_a_literal_evaluation_proof() { + // Given + let allocator = Allocator::default(); + let source = expression(&allocator, "({...{color:'red'}})"); + // When + let literal = crate::static_assignment::literal_source(&source); + // Then + assert!(!literal); +} + +#[test] +fn dynamic_records_require_inline_assignment_even_inside_class_containers() { + // Given + let style = + ExtractStyleProp::StaticArray(vec![ExtractStyleProp::Static(dynamic("color", "input"))]); + // When + let class_only = crate::static_assignment::class_only(&style); + // Then + assert!(!class_only); +} + +#[rstest] +#[case("state", true)] +#[case("read()", false)] +fn private_member_purity_follows_the_receiver(#[case] receiver: &str, #[case] expected: bool) { + use oxc_ast_visit::Visit; + + struct PrivateReads(Vec); + impl<'a> oxc_ast_visit::Visit<'a> for PrivateReads { + fn visit_expression(&mut self, expression: &oxc_ast::ast::Expression<'a>) { + if matches!( + expression, + oxc_ast::ast::Expression::PrivateFieldExpression(_) + ) { + self.0.push(crate::utils::is_pure(expression)); + } + oxc_ast_visit::walk::walk_expression(self, expression); + } + } + // Given + let allocator = Allocator::default(); + let source = format!("class Receiver{{#value;method(state,read){{return {receiver}.#value}}}}"); + let parsed = oxc_parser::Parser::new(&allocator, &source, oxc_span::SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let mut reads = PrivateReads(vec![]); + // When + reads.visit_program(&parsed.program); + // Then + assert_eq!(reads.0, vec![expected]); +} + +#[test] +#[serial] +fn static_selector_identity_distinguishes_layered_declarations() { + // Given + let plain = crate::extract_style::extract_static_style::ExtractStaticStyle::new( + "color", "red", 0, None, + ); + let mut layered = plain.clone(); + layered.layer = Some("base".into()); + // When + let identity = ( + plain.class_selector().map(std::borrow::Cow::into_owned), + layered.class_selector().map(std::borrow::Cow::into_owned), + ); + // Then + assert_eq!(identity, (None, Some("@layer base".into()))); +} + +#[rstest] +#[case("({color:state.color})", "[\"red\",[\"color\"]]")] +#[case("[state.color]", "[\"red\",[\"color\"]]")] +#[serial] +fn lowered_container_keeps_raw_values_when_special_records_are_not_consumers( + #[case] source: &str, + #[case] expected: &str, +) { + // Given: typed IR with a dynamic consumer and an unrelated special record. + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = expression(&allocator, source); + let mut styles = [ + ExtractStyleProp::Static(dynamic("color", "state.color")), + ExtractStyleProp::Static(ExtractStyleValue::Typography("heading".into())), + ]; + let lowering = crate::assignment_lowering::Lowering { + ast: &ast, + order: None, + filename: None, + alternate_order: None, + }; + // When + let generated = lowering.lower(&source, &mut styles); + let actual = evaluate(&format!( + "let trace=[];const state={{get color(){{trace.push('color');return 'red'}}}};const result={};JSON.stringify([Array.isArray(result[2])?result[2][0]:result[2].color,trace]);", + code(&generated) + )); + // Then + assert_eq!(actual, expected); +} + +#[test] +#[serial] +fn missing_member_consumers_preserve_all_literal_field_reads_before_selection() { + // Given + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = expression(&allocator, "({a:state.first,b:state.second})[state.key]"); + let lowering = crate::assignment_lowering::Lowering { + ast: &ast, + order: None, + filename: None, + alternate_order: None, + }; + // When + let generated = lowering.lower(&source, &mut []); + let actual = evaluate(&format!( + "let trace=[];const state={{get first(){{trace.push('first');return 'red'}},get second(){{trace.push('second');return 'blue'}},get key(){{trace.push('key');return 'b'}}}};const result={};JSON.stringify([trace,result[2]]);", + code(&generated) + )); + // Then + assert_eq!(actual, "[[\"first\",\"second\",\"key\"],\"blue\"]"); +} diff --git a/libs/extractor/src/coverage_tests/origins.rs b/libs/extractor/src/coverage_tests/origins.rs new file mode 100644 index 000000000..8c3d4bb1b --- /dev/null +++ b/libs/extractor/src/coverage_tests/origins.rs @@ -0,0 +1,158 @@ +use super::{dynamic, expression}; +use crate::{ExtractStyleProp, ExtractStyleValue}; +use css::style_origin::{Origin, RealLocation, StyleOrigin}; +use oxc_allocator::Allocator; +use rstest::rstest; + +#[test] +fn expression_records_receive_missing_origins_without_overwriting_exact_ones() { + // Given + let allocator = Allocator::default(); + let expected = StyleOrigin { + file: "authored.tsx".into(), + line: 4, + column: 9, + expression: "state.color".into(), + }; + let original = StyleOrigin { + file: "inner.tsx".into(), + line: 2, + column: 3, + expression: "makeFrames()".into(), + }; + let keyframes = crate::extract_style::extract_keyframes::ExtractKeyframes { + origin: Origin(Some(Box::new(original.clone())), None), + ..Default::default() + }; + let mut props = [ExtractStyleProp::Expression { + expression: expression(&allocator, "state.color"), + styles: vec![ + dynamic("color", "state.color"), + ExtractStyleValue::Keyframes(keyframes), + ], + }]; + // When + crate::style_origin::fill(&mut props, &Origin(Some(Box::new(expected.clone())), None)); + // Then + let records = props[0].extract(); + let ExtractStyleValue::Dynamic(value) = &records[0] else { + panic!("dynamic fixture") + }; + assert_eq!(value.origin.0.as_deref(), Some(&expected)); + let ExtractStyleValue::Keyframes(value) = &records[1] else { + panic!("keyframes fixture") + }; + assert_eq!(value.origin.0.as_deref(), Some(&original)); +} + +#[test] +fn cyclic_composition_gets_export_locations_without_revisiting_bases() { + // Given + let mut collected = crate::vanilla_extract::CollectedStyles::default(); + collected.styles.insert( + "a".into(), + crate::vanilla_extract::StyleEntry { + bases: std::iter::once("b".to_string()).collect(), + ..Default::default() + }, + ); + collected.styles.insert( + "b".into(), + crate::vanilla_extract::StyleEntry { + bases: std::iter::once("a".to_string()).collect(), + ..Default::default() + }, + ); + collected + .keyframes + .insert("unexported".into(), Default::default()); + // When + crate::style_export_locations::associate( + &mut collected, + &[("public".into(), "a".into())], + "actual.css.ts", + ); + // Then + let exported = Some(RealLocation::ModuleExport { + file: "actual.css.ts".into(), + binding: Some("public".into()), + }); + assert_eq!(collected.styles["a"].location, exported); + assert_eq!(collected.styles["b"].location, exported); + assert_eq!( + collected.keyframes["unexported"].location, + Some(RealLocation::ModuleExport { + file: "actual.css.ts".into(), + binding: None + }) + ); +} + +#[test] +fn absent_location_preserves_source_bytes() { + // Given + let source = "({get value(){return '\\u0061'}}) /* authored */"; + // When + let actual = crate::evaluation_origin::mark_location(source.into(), None); + // Then + assert_eq!(actual, source); +} + +#[rstest] +#[case( + "import * as api from '@devup-ui/react';const x=api['style']({color:'red'});", + "api['style']({color:'red'})" +)] +#[case( + "import {'style' as factory} from '@devup-ui/react';const x=factory({color:'red'});", + "factory({color:'red'})" +)] +fn source_packets_keep_exact_authored_factory_calls(#[case] source: &str, #[case] expected: &str) { + use oxc_ast_visit::Visit; + + struct Packets(Vec); + impl<'a> oxc_ast_visit::Visit<'a> for Packets { + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { + if let Some(oxc_ast::ast::Argument::StringLiteral(packet)) = call.arguments.last() + && let Ok(origin) = serde_json::from_str::(&packet.value) + { + self.0.push(origin); + } + oxc_ast_visit::walk::walk_call_expression(self, call); + } + } + // Given + let allocator = Allocator::default(); + // When + let instrumented = crate::evaluation_origin::instrument( + source, + "packet.css.ts", + &crate::ExtractOption::default(), + ); + let parsed = + oxc_parser::Parser::new(&allocator, &instrumented, oxc_span::SourceType::ts()).parse(); + // Then: inspect the packet consumed by the evaluator, not prose/code formatting. + assert_eq!(parsed.diagnostics.len(), 0); + let mut packets = Packets(vec![]); + packets.visit_program(&parsed.program); + assert_eq!(packets.0.len(), 1); + assert_eq!(packets.0[0].file, "packet.css.ts"); + assert_eq!(packets.0[0].expression, expected); +} + +#[rstest] +#[case("import factory from '@devup-ui/react';factory({color:'red'});")] +#[case("import * as api from '@devup-ui/react';({style:x=>x}).style({color:'red'});")] +#[case( + "import * as api from '@devup-ui/react';function run(api){return api['style']({color:'red'})}" +)] +fn non_factory_bindings_receive_no_origin_arguments(#[case] source: &str) { + // Given / When + let actual = crate::evaluation_origin::instrument( + source, + "nonfactory.css.ts", + &crate::ExtractOption::default(), + ); + // Then + assert_eq!(actual, source); +} diff --git a/libs/extractor/src/coverage_tests/residual_operands.rs b/libs/extractor/src/coverage_tests/residual_operands.rs new file mode 100644 index 000000000..b6683247f --- /dev/null +++ b/libs/extractor/src/coverage_tests/residual_operands.rs @@ -0,0 +1,64 @@ +use super::{code, dynamic, expression}; +use crate::extract_style::{ + extract_css::ExtractCss, extract_keyframes::ExtractKeyframes, + extract_static_style::ExtractStaticStyle, style_property::StyleProperty, +}; +use crate::{ExtractStyleProp, ExtractStyleValue, assignment_test_support::evaluate}; +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("({color:state.color,other:state.other})", true)] +#[case("[state.color,state.other]", false)] +#[serial] +fn residual_special_records_do_not_consume_color_fields_or_responsive_slots( + #[case] source: &str, + #[case] object: bool, +) { + // Given: real special records, which are not property/level consumers. + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let source = expression(&allocator, source); + let frames = ExtractKeyframes { + keyframes: [( + "from".into(), + vec![ExtractStaticStyle::new("opacity", "0", 0, None)], + )] + .into(), + ..Default::default() + }; + let Some(StyleProperty::ClassName(frame_class)) = + ExtractStyleValue::Keyframes(frames.clone()).extract(None) + else { + panic!("keyframes class") + }; + let mut styles = [ + ExtractStyleProp::Static(dynamic("color", "state.color")), + ExtractStyleProp::Static(ExtractStyleValue::Keyframes(frames)), + ExtractStyleProp::Static(ExtractStyleValue::Css(ExtractCss { + css: "body { color: red; }".into(), + file: "residual.css".into(), + })), + ]; + let lowering = crate::assignment_lowering::Lowering { + ast: &ast, + order: None, + filename: None, + alternate_order: None, + }; + // When + let generated = lowering.lower(&source, &mut styles); + let actual = evaluate(&format!( + "let trace=[];const state={{get color(){{trace.push('color');return 'red'}},get other(){{trace.push('other');return 'blue'}}}};const result={};JSON.stringify([Array.isArray(result[2])?result[2]:[result[2].color,result[2].other],trace,result[0].split(' ').filter(x=>x==='{}').length,Object.values(result[1])]);", + code(&generated), + frame_class, + )); + // Then: object residual class appears once; arrays omit non-level records. + let expected_count = usize::from(object); + assert_eq!( + actual, + format!("[[\"red\",\"blue\"],[\"color\",\"other\"],{expected_count},[\"red\"]]") + ); +} diff --git a/libs/extractor/src/coverage_tests/spread_alignment.rs b/libs/extractor/src/coverage_tests/spread_alignment.rs new file mode 100644 index 000000000..973e3629d --- /dev/null +++ b/libs/extractor/src/coverage_tests/spread_alignment.rs @@ -0,0 +1,46 @@ +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case(";", "{bg: \"red\"}")] +#[case(";", "{}")] +#[case(";", "{...{bg: \"red\"}}")] +#[case("export const j = css(...{ bg: 'red' });", "{ bg: 'red' }")] +#[serial] +fn legacy_object_spread_rejects_with_the_authored_call_and_fix( + #[case] statement: &str, + #[case] operand: &str, +) { + // Given: preserve the original noniterable sources as rejection controls. + let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); + let offset = source + .find("css(...") + .unwrap_or_else(|| panic!("authored call")); + let before = &source[..offset]; + let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = before + .rsplit('\n') + .next() + .unwrap_or_else(|| panic!("authored line")) + .chars() + .count() + + 1; + let allocator = oxc_allocator::Allocator::default(); + let expression = oxc_parser::Parser::new(&allocator, operand, oxc_span::SourceType::ts()) + .parse_expression() + .unwrap_or_else(|error| panic!("valid spread operand: {error:?}")); + let code = crate::utils::readable_code(&expression); + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + // When + let Err(actual) = crate::extract("spread.tsx", &source, crate::ExtractOption::default()) else { + panic!("noniterable object argument spread must be rejected"); + }; + // Then + assert_eq!( + actual.to_string(), + format!( + "spread.tsx:{line}:{column}: Cannot compose `...{code}` at build time: each style must be a rule object, a class, or a condition choosing between them; pass the value itself instead of spreading it" + ) + ); +} 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/dependency_coverage_tests.rs b/libs/extractor/src/dependency_coverage_tests.rs new file mode 100644 index 000000000..60cba7964 --- /dev/null +++ b/libs/extractor/src/dependency_coverage_tests.rs @@ -0,0 +1,36 @@ +use super::Dependencies; +use css::Naming; +use oxc_allocator::Allocator; +use oxc_ast_visit::Visit; +use oxc_semantic::SemanticBuilder; +use rstest::rstest; + +#[rstest] +#[case("require('module')", Naming::Risky)] +#[case("import('module')", Naming::Risky)] +#[case("require('module').value", Naming::Risky)] +#[case("({require:x=>x}).require('module')", Naming::Own)] +#[case("ordinary('module')", Naming::Own)] +fn raw_dependency_syntax_keeps_its_naming_distinction( + #[case] source: &str, + #[case] expected: Naming, +) { + // Given: Dependencies classifies raw syntax, not resolver execution or shadow policy. + let allocator = Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::ts()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let namings = rustc_hash::FxHashMap::default(); + let mut dependencies = Dependencies { + scoping: &scoping, + namings: &namings, + naming: Naming::Own, + }; + // When + dependencies.visit_program(&parsed.program); + // Then + assert_eq!(dependencies.naming, expected); +} 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..4d5098f66 --- /dev/null +++ b/libs/extractor/src/element_evaluation.rs @@ -0,0 +1,182 @@ +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}; + +#[path = "element_typography.rs"] +mod element_typography; +pub(crate) use element_typography::capture as typography; + +#[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 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/element_typography.rs b/libs/extractor/src/element_typography.rs new file mode 100644 index 000000000..8c50d8d4e --- /dev/null +++ b/libs/extractor/src/element_typography.rs @@ -0,0 +1,88 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, Str}, + builder::AstBuilder, +}; +use oxc_span::{GetSpan, GetSpanMut}; + +use crate::{ExtractStyleProp, utils::get_string_by_literal_expression}; + +#[path = "typography_scalar_projection.rs"] +mod scalar_projection; + +pub(crate) fn capture<'a>( + ast: &AstBuilder<'a>, + styles: &mut Vec>, + source: &Expression<'a>, + next: &mut usize, +) -> Option<(String, Expression<'a>)> { + let structured = !styles.is_empty() + && matches!( + crate::utils::unwrap_syntax_only(source), + Expression::ArrayExpression(_) + ) + && !crate::static_assignment::literal_source(source) + || styles.iter().any(|style| { + matches!( + style, + ExtractStyleProp::Conditional { .. } | ExtractStyleProp::MemberExpression { .. } + ) + }); + let scalar = if structured && styles.iter().all(scalar_projection::base_only) { + crate::gen_class_name::gen_class_names(ast, styles, None, None) + .filter(|class| scalar_projection::preserves_reads(source, class)) + } else { + None + }; + if structured && scalar.is_none() { + *styles = vec![ExtractStyleProp::Evaluated { + binding: crate::sparse_sites::binding_name(source.span().start), + styles: std::mem::take(styles), + source: source.clone_in(ast.allocator()), + evaluation: None, + alternate_order: None, + alternate_class: false, + }]; + return None; + } + if let [ExtractStyleProp::Enum { condition, .. }] = styles.as_mut_slice() { + let name = format!("__devupSpread{next}"); + *next += 1; + let input = std::mem::replace( + condition, + Expression::new_identifier( + source.span(), + Str::from_in(name.as_str(), ast.allocator()), + ast, + ), + ); + return Some((name, input)); + } + let mut class = + scalar.or_else(|| 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)) +} diff --git a/libs/extractor/src/evaluation_location.rs b/libs/extractor/src/evaluation_location.rs new file mode 100644 index 000000000..b8e1084a2 --- /dev/null +++ b/libs/extractor/src/evaluation_location.rs @@ -0,0 +1,208 @@ +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}; + +#[cfg(test)] +#[path = "evaluation_lookup_coverage_tests.rs"] +mod coverage_tests; + +#[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(offset) = copied_body_offset( + &self.run, + frame + .position + .map(|position| (position.line_number(), position.column_number())), + 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 copied_body_offset( + source: &str, + coordinate: Option<(u32, u32)>, + prefix: usize, +) -> Option { + let (line, column) = coordinate?; + 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 start.checked_sub(prefix); + } + units = units.checked_sub(u32::try_from(character.len_utf16()).ok()?)?; + start += character.len_utf8(); + } + (units == 0).then_some(start)?.checked_sub(prefix) +} 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_lookup_coverage_tests.rs b/libs/extractor/src/evaluation_lookup_coverage_tests.rs new file mode 100644 index 000000000..5fb6961af --- /dev/null +++ b/libs/extractor/src/evaluation_lookup_coverage_tests.rs @@ -0,0 +1,164 @@ +use super::{CopiedSource, EvaluationLookup, StrippedSource, copied_body_offset}; +use boa_engine::{Context, JsValue, NativeFunction, Source, js_string}; +use css::style_origin::{RealLocation, StyleOrigin}; +use rstest::rstest; +use std::{cell::RefCell, path::Path}; + +thread_local! { + static LOOKUP: RefCell> = const { RefCell::new(None) }; +} + +fn observed_value(context: &Context) -> JsValue { + let location = LOOKUP.with_borrow(|lookup| { + lookup + .as_ref() + .unwrap_or_else(|| panic!("lookup fixture")) + .produced_by(context) + }); + JsValue::from(js_string!( + serde_json::to_string(&location).unwrap_or_else(|error| panic!("location JSON: {error}")) + )) +} + +struct LookupScope; +impl Drop for LookupScope { + fn drop(&mut self) { + LOOKUP.with_borrow_mut(|lookup| *lookup = None); + } +} + +#[rstest] +#[case("no-path")] +#[case("other-file")] +#[case("prefix")] +#[case("no-copy")] +#[case("no-call")] +#[case("matched")] +fn producer_lookup_requires_a_complete_authored_file_chain(#[case] mode: &str) { + // Given + let source = "function run(){return observed()}run();"; + let origin = StyleOrigin { + file: "authored.css.ts".into(), + line: 3, + column: 7, + expression: "opaque(style)({color:'red'})".into(), + }; + let copies = if mode == "no-copy" { + vec![] + } else { + vec![CopiedSource { + generated: 0..source.len(), + original: 0, + }] + }; + let calls = if mode == "no-call" { + vec![] + } else { + vec![( + oxc_span::Span::new( + 0, + u32::try_from(source.len()).unwrap_or_else(|error| panic!("fixture span: {error}")), + ), + origin.clone(), + )] + }; + LOOKUP.with_borrow_mut(|lookup| { + *lookup = Some(EvaluationLookup { + file: "run.js".into(), + run: source.into(), + prefix: if mode == "prefix" { + source.len() + 1 + } else { + 0 + }, + copies, + calls, + }); + }); + let _scope = LookupScope; + let mut context = Context::default(); + context + .register_global_builtin_callable( + js_string!("observed"), + 0, + NativeFunction::from_fn_ptr(|_, _, context| Ok(observed_value(context))), + ) + .unwrap_or_else(|error| panic!("native observer: {error}")); + let input = Source::from_bytes(source); + let input = match mode { + "no-path" => input, + "other-file" => input.with_path(Path::new("other.js")), + _ => input.with_path(Path::new("run.js")), + }; + // When + let actual = context + .eval(input) + .unwrap_or_else(|error| panic!("lookup execution: {error}")) + .as_string() + .unwrap_or_else(|| panic!("serialized location")) + .to_std_string_escaped(); + // Then: incomplete chains must never invent an Exact or ProducedByCall witness. + let expected = if mode == "matched" { + Some(RealLocation::ProducedByCall(origin)) + } else { + None + }; + assert_eq!( + serde_json::from_str::>(&actual) + .unwrap_or_else(|error| panic!("location result: {error}")), + expected + ); +} + +#[test] +fn stripped_call_mapping_rejects_non_call_authored_coordinates() { + // Given + let stripped = StrippedSource::new("factory();", "actual.css.ts"); + // When + let actual = stripped.authored_calls(("actual.css.ts", "notACall;"), &[]); + // Then + assert_eq!(actual.len(), 0); +} + +#[rstest] +#[case(("a", 1, 2), Some(1))] +#[case(("a", 1, 3), None)] +#[case(("😀", 1, 3), Some(4))] +#[case(("😀", 1, 2), None)] +#[case(("a\nb", 2, 2), Some(3))] +fn end_columns_map_utf16_units_to_exact_byte_offsets( + #[case] coordinate: (&str, u32, u32), + #[case] expected: Option, +) { + // Given / When + let actual = copied_body_offset(coordinate.0, Some((coordinate.1, coordinate.2)), 0); + // Then + assert_eq!(actual, expected); +} + +#[rstest] +#[case(("a", None, 0), None)] +#[case(("a", Some((1, 1)), 0), Some(0))] +#[case(("a", Some((1, 1)), 1), None)] +#[case(("a", Some((1, 2)), 1), Some(0))] +#[case(("a", Some((1, 2)), 2), None)] +#[case(("a", Some((1, 3)), 0), None)] +#[case(("a", Some((0, 1)), 0), None)] +#[case(("a", Some((1, 0)), 0), None)] +#[case(("a", Some((2, 1)), 0), None)] +#[case(("😀", Some((1, 2)), 0), None)] +#[case(("😀", Some((1, 3)), 0), Some(4))] +#[case(("a\n😀b", Some((2, 1)), 2), Some(0))] +#[case(("a\n😀b", Some((2, 3)), 2), Some(4))] +#[case(("a\n😀b", Some((2, 4)), 2), Some(5))] +#[case(("", Some((1, 1)), 0), None)] +#[case(("a\n", Some((2, 1)), 0), None)] +fn hard_copied_body_coordinates_preserve_absence_and_prefix_boundaries( + #[case] input: (&str, Option<(u32, u32)>, usize), + #[case] expected: Option, +) { + // Given / When + let actual = copied_body_offset(input.0, input.1, input.2); + // Then + assert_eq!(actual, expected); +} diff --git a/libs/extractor/src/evaluation_origin.rs b/libs/extractor/src/evaluation_origin.rs new file mode 100644 index 000000000..90b5a5c4d --- /dev/null +++ b/libs/extractor/src/evaluation_origin.rs @@ -0,0 +1,267 @@ +use css::style_origin::{RealLocation, StyleOrigin}; +use oxc_allocator::Allocator; +use oxc_ast::ast::{ + CallExpression, Expression, ImportDeclarationSpecifier, 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 = import.imported.name(); + if factory_name(name.as_str()) + && 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 f09b03b07..2867c2581 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_owned, 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,28 +194,70 @@ impl ExtractDynamicStyle { self.important } + pub const fn naming(&self) -> Naming { + self.naming + } + + /// Read source identity without changing its position or syntax role. + pub const fn site(&self) -> Option<&Site> { + self.site.as_ref() + } + + /// Original allocation identity already carried by the dynamic source site. + pub fn counter_owner(&self) -> css::naming::CounterOwner { + self.site + .as_ref() + .map_or(css::naming::CounterOwner::Inactive, |site| { + css::naming::CounterOwner::from_source(&site.file) + }) + } + 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 variable_name = self.variable_name(); + let declaration = format!( + "var({variable_name}){}", + if self.important { " !important" } else { "" } + ); StyleProperty::Variable { - class_name: sheet_to_classname( - self.property.as_str(), - self.level, - None, - selector.as_deref(), - self.style_order, + class_name: sheet_to_classname_owned( + &self.atom_content(&declaration), filename, + self.counter_owner(), ), - variable_name: sheet_to_variable_name( - self.property.as_str(), - self.level, - selector.as_deref(), - ), + 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..3ad84d4cc 100644 --- a/libs/extractor/src/extract_style/extract_keyframes.rs +++ b/libs/extractor/src/extract_style/extract_keyframes.rs @@ -1,45 +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 { - 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 1adc78be6..5651fc7be 100644 --- a/libs/extractor/src/extract_style/extract_static_style.rs +++ b/libs/extractor/src/extract_style/extract_static_style.rs @@ -2,10 +2,14 @@ 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_owned, + style_origin::Origin, style_selector::{StyleSelector, optimize_selector}, + theme_tokens::get_first_theme_token_value, }; use crate::{ @@ -24,7 +28,7 @@ pub enum ThemeTokenResolution { FirstValue, } -#[derive(PartialEq, Clone, Eq, Hash, Ord, PartialOrd)] +#[derive(Clone)] pub struct ExtractStaticStyle { /// property pub property: String, @@ -40,18 +44,27 @@ 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, + /// Captured original allocation identity, retained for deferred sheet emission. + pub counter_owner: css::naming::CounterOwner, + 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() } } @@ -105,6 +118,9 @@ impl ExtractStaticStyle { style_order: None, layer: None, theme_token_resolution: ThemeTokenResolution::CssVariable, + naming: Naming::Own, + counter_owner: crate::sparse_sites::counter_owner(), + origin: crate::style_origin::current(), } } @@ -137,6 +153,9 @@ impl ExtractStaticStyle { style_order: Some(0), layer: None, theme_token_resolution: ThemeTokenResolution::CssVariable, + naming: Naming::Own, + counter_owner: crate::sparse_sites::counter_owner(), + origin: crate::style_origin::current(), } } @@ -146,6 +165,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> { @@ -188,26 +213,59 @@ 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) + } + } + } + + #[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(); + let value = if self.property != "content" && check_multi_css_optimize(&self.property) { + optimize_multi_css_value(&value).into_owned() + } else { + value.into_owned() + }; + 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 s = self.class_selector(); - // `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) - } else { - std::borrow::Cow::Borrowed(self.value.as_str()) - }; - StyleProperty::ClassName(sheet_to_classname( - &self.property, - self.level, - Some(v.as_ref()), - s.as_deref(), - self.style_order, + let value = self.effective_value(); + StyleProperty::ClassName(sheet_to_classname_owned( + &self.atom_content(&value), filename, + self.counter_owner, )) } } @@ -216,6 +274,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/extract_style/mod.rs b/libs/extractor/src/extract_style/mod.rs index e101f8bbb..680ee5640 100644 --- a/libs/extractor/src/extract_style/mod.rs +++ b/libs/extractor/src/extract_style/mod.rs @@ -6,6 +6,9 @@ pub(super) mod extract_import; pub(super) mod extract_keyframes; pub mod extract_static_style; pub mod extract_style_value; +#[cfg(test)] +mod original_owner_tests; +mod static_identity; pub mod style_property; use crate::extract_style::style_property::StyleProperty; @@ -16,6 +19,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/extract_style/original_owner_tests.rs b/libs/extractor/src/extract_style/original_owner_tests.rs new file mode 100644 index 000000000..63a5ab315 --- /dev/null +++ b/libs/extractor/src/extract_style/original_owner_tests.rs @@ -0,0 +1,131 @@ +use css::{Naming, naming::CounterOwner}; +use rustc_hash::FxHashSet; +use serial_test::serial; + +use super::{ + ExtractStyleProperty, extract_dynamic_style::ExtractDynamicStyle, + extract_static_style::ExtractStaticStyle, extract_style_value::ExtractStyleValue, +}; +use crate::sparse_sites::SiteScope; + +#[test] +#[serial] +fn nested_raw_scopes_restore_owners_when_construction_is_deferred() { + // Given + css::file_map::reset_file_map(); + css::file_map::seed_file_numbers(&["a.tsx".into(), "child.tsx".into()]); + let outer = SiteScope::enter("a.tsx", "outer", &[]); + let first = ExtractStaticStyle::new("color", "red", 0, None); + // When + let nested = { + let _inner = SiteScope::enter("child.tsx", "inner", &[]); + ExtractStaticStyle::new_basic("color", "red", 0, None) + }; + let restored = ExtractStaticStyle::new("color", "red", 0, None); + drop(outer); + // Then + assert_eq!(first.counter_owner, CounterOwner::D9(0)); + assert_eq!(nested.counter_owner, CounterOwner::D9(1)); + assert_eq!(first, restored); + assert_eq!(crate::sparse_sites::counter_owner(), CounterOwner::Inactive); + css::file_map::reset_file_map(); +} + +#[test] +#[serial] +fn direct_order_and_naming_transitions_keep_only_counter_bearing_owners_distinct() { + // Given + css::file_map::reset_file_map(); + css::file_map::seed_file_numbers(&["a.tsx".into(), "child.tsx".into()]); + let mut styles: Vec<_> = ["a.tsx", "child.tsx"] + .into_iter() + .map(|file| { + let _scope = SiteScope::enter(file, "source", &[]); + ExtractStaticStyle::new_basic("color", "red", 0, None) + }) + .collect(); + assert_eq!(styles[0], styles[1]); + // When + for style in &mut styles { + style.style_order = Some(255); + } + // Then + assert_ne!(styles[0], styles[1]); + assert_eq!(styles.iter().cloned().collect::>().len(), 2); + assert_eq!( + styles + .iter() + .cloned() + .collect::>() + .len(), + 2 + ); + let risky: Vec<_> = styles + .into_iter() + .map(|style| style.with_naming(Naming::Risky)) + .collect(); + assert_eq!(risky[0], risky[1]); + assert_eq!(risky.iter().cloned().collect::>().len(), 1); + css::file_map::reset_file_map(); +} + +#[test] +#[serial] +fn dynamic_class_uses_site_file_when_delivery_is_collapsed() { + // Given + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::seed_file_numbers(&["a.tsx".into(), "child.tsx".into()]); + css::file_map::set_canonical_map(std::collections::HashMap::from([( + "child.tsx".into(), + "a.tsx".into(), + )])); + let style = { + let _scope = SiteScope::enter("child.tsx", "tone", &[]); + ExtractDynamicStyle::new("color", 0, "tone", None).at(0) + }; + // When + let result = style.extract(Some("a.tsx")); + // Then + match result { + super::style_property::StyleProperty::Variable { + class_name, + variable_name, + .. + } => { + assert_eq!(class_name, "b-a"); + assert_eq!(variable_name, "---Sb-a"); + } + super::style_property::StyleProperty::ClassName(_) => panic!("dynamic class lost variable"), + } + css::class_map::with_class_map(|map| { + assert_eq!(map.len(), 1); + assert_eq!(map["D9-1"].len(), 1); + }); + css::file_map::reset_canonical_map(); + css::file_map::reset_file_map(); + css::class_map::reset_class_map(); +} + +#[test] +#[serial] +fn provenance_join_leaves_original_owner_available_without_affecting_content() { + // Given + let mut value = ExtractStyleValue::Static(ExtractStaticStyle::new("color", "red", 0, None)); + let before = match &value { + ExtractStyleValue::Static(style) => style.content_name(), + other => panic!("{other:?}"), + }; + // When + value.join_naming(Naming::Risky); + value.set_style_order(0); + // Then + match value { + ExtractStyleValue::Static(style) => { + assert_eq!(style.counter_owner, CounterOwner::Inactive); + assert_ne!(style.content_name(), before); + assert_eq!(style.style_order, Some(0)); + } + other => panic!("{other:?}"), + } +} diff --git a/libs/extractor/src/extract_style/static_identity.rs b/libs/extractor/src/extract_style/static_identity.rs new file mode 100644 index 000000000..ff27ff6ed --- /dev/null +++ b/libs/extractor/src/extract_style/static_identity.rs @@ -0,0 +1,63 @@ +use std::{ + cmp::Ordering, + hash::{Hash, Hasher}, +}; + +use css::{Naming, naming::CounterOwner, style_selector::StyleSelector}; + +use super::extract_static_style::{ExtractStaticStyle, ThemeTokenResolution}; + +#[derive(PartialEq, Eq, Hash, PartialOrd, Ord)] +struct Identity<'a> { + property: &'a str, + value: &'a str, + level: u8, + selector: &'a Option, + order: Option, + layer: &'a Option, + resolution: ThemeTokenResolution, + naming: Naming, + owner: CounterOwner, +} + +impl ExtractStaticStyle { + fn identity(&self) -> Identity<'_> { + Identity { + property: &self.property, + value: &self.value, + level: self.level, + selector: &self.selector, + order: self.style_order, + layer: &self.layer, + resolution: self.theme_token_resolution, + naming: self.naming, + owner: if self.naming == Naming::Own && self.style_order != Some(0) { + self.counter_owner + } else { + CounterOwner::Inactive + }, + } + } +} + +impl PartialEq for ExtractStaticStyle { + fn eq(&self, other: &Self) -> bool { + self.identity() == other.identity() + } +} +impl Eq for ExtractStaticStyle {} +impl Hash for ExtractStaticStyle { + fn hash(&self, state: &mut H) { + self.identity().hash(state); + } +} +impl PartialOrd for ExtractStaticStyle { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for ExtractStaticStyle { + fn cmp(&self, other: &Self) -> Ordering { + self.identity().cmp(&other.identity()) + } +} 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..444d71fbb 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,13 @@ use oxc_ast::{ use oxc_span::{GetSpan, SPAN}; use std::collections::BTreeMap; +mod boolean_expression; +#[cfg(test)] +mod boolean_or_tests; +mod class_only_expression; +#[cfg(test)] +mod typography_class_tests; + const IGNORED_IDENTIFIERS: [&str; 3] = ["undefined", "NaN", "Infinity"]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -107,7 +115,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 +215,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,10 +225,101 @@ 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 crate::static_assignment::literal_source(&source) + && result + .styles + .iter() + .all(crate::static_assignment::class_only) + { + 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); + if let Some(name) = name + && (name == "typography" || get_enum_property_map(name).is_some()) + && class_only_expression::runtime_scalar(expression) + && get_string_by_literal_expression(expression).is_none() + && !matches!( + expression, + Expression::BooleanLiteral(_) | Expression::NullLiteral(_) + ) + && !matches!(expression, Expression::Identifier(value) if IGNORED_IDENTIFIERS.contains(&value.name.as_str())) + { + return ExtractResult { + styles: vec![dynamic_style( + ast_builder, + name, + expression, + level, + selector, + )], + ..ExtractResult::default() + }; + } + if name.is_none() && selector.is_none() { let mut style_order = None; let mut style_vars = None; @@ -494,6 +600,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 { @@ -607,7 +716,7 @@ pub fn extract_style_from_expression<'a>( } else if typo { vec![ExtractStyleProp::Expression { expression: Expression::new_template_literal( - SPAN, + expression.span(), oxc_allocator::Vec::from_array_in( [ TemplateElement::new( @@ -657,66 +766,6 @@ pub fn extract_style_from_expression<'a>( }; if IGNORED_IDENTIFIERS.contains(&identifier.name.as_str()) { ExtractResult::default() - } else if typo - && let Some(style) = conditional_typography( - ast_builder, - &Expression::new_identifier(SPAN, identifier.name.as_str(), ast_builder), - level, - selector, - ) - { - ExtractResult { - styles: vec![style], - ..ExtractResult::default() - } - } 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), - ast_builder, - ), - styles: vec![], - }], - ..ExtractResult::default() - } } else { ExtractResult { styles: vec![dynamic_style( @@ -788,7 +837,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 +1084,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 +1120,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 +1181,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 +1251,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 +1352,9 @@ fn typography_atom(name: &str, level: u8, selector: &Option) -> E style_order: None, layer: None, theme_token_resolution: ThemeTokenResolution::default(), + naming: css::Naming::Own, + counter_owner: crate::sparse_sites::counter_owner(), + origin: crate::style_origin::current(), } } @@ -1333,7 +1403,14 @@ pub fn dynamic_style<'a>( level: u8, selector: &Option, ) -> ExtractStyleProp<'a> { - if let Some(map) = get_enum_property_map(name) { + if name == "typography" { + conditional_typography(ast_builder, expression, level, selector).unwrap_or_else(|| { + ExtractStyleProp::Expression { + expression: crate::element_evaluation::raw_typography(ast_builder, expression), + styles: vec![], + } + }) + } else if let Some(map) = get_enum_property_map(name) { ExtractStyleProp::Enum { condition: expression.clone_in(ast_builder.allocator()), map: map @@ -1353,12 +1430,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_expression/class_only_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression/class_only_expression.rs new file mode 100644 index 000000000..9b3ec46a9 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/class_only_expression.rs @@ -0,0 +1,25 @@ +use oxc_ast::ast::Expression; + +use crate::utils::unwrap_syntax_only; + +/// Scalar class selections retain their entire authored operand, rather than +/// separately evaluating a logical predicate and the value it selected. +pub(super) fn runtime_scalar(expression: &Expression<'_>) -> bool { + match unwrap_syntax_only(expression) { + Expression::LogicalExpression(value) => { + runtime_scalar(&value.left) && runtime_scalar(&value.right) + } + Expression::ComputedMemberExpression(value) => !matches!( + unwrap_syntax_only(&value.object), + Expression::ObjectExpression(_) | Expression::ArrayExpression(_) + ), + Expression::ConditionalExpression(_) + | Expression::TemplateLiteral(_) + | Expression::ObjectExpression(_) + | Expression::ArrayExpression(_) + | Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) + | Expression::ClassExpression(_) => false, + _ => true, + } +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests.rs b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests.rs new file mode 100644 index 000000000..fe73c0233 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests.rs @@ -0,0 +1,234 @@ +use rstest::rstest; +use serial_test::serial; + +use crate::assignment_test_support::{extracted, jsx_js}; + +mod class_only_tests; +mod context_tests; +mod support; + +#[rstest] +#[case(("t", &[][..]))] +#[case(("typography", &["t"][..]))] +#[case(("state.t", &["t"][..]))] +#[case(("state[state.key]", &["key", "t"][..]))] +#[case(("state.pick(state.arg)", &["arg", "call"][..]))] +#[case(("state.flag?state.t:state.bad", &["flag", "t"][..]))] +#[case(("state.flag?state.bad:state.t", &["flag", "t"][..]))] +#[case(("state.t||state.bad", &["t"][..]))] +#[case(("state.t??state.bad", &["t"][..]))] +#[case(("state.flag&&state.t", &["flag", "t"][..]))] +#[case(("state?.t", &["t"][..]))] +#[case(("(state.t, state.t)", &["t", "t"][..]))] +#[case(("`${state.t}`", &["t"][..]))] +#[serial] +fn selects_typography_once_when_scalar_is_dynamic( + #[case] selection: (&str, &[&str]), + #[values(false, true)] runtime: bool, + #[values("alone", "before", "after")] order: &str, +) { + // Given: observable operands and a throwing unselected branch. + let (expression, reads) = selection; + let mut source = support::fixture(expression, runtime, order); + if expression == "state.flag?state.bad:state.t" { + source = source.replace("return true", "return false"); + } + let mut trace = vec!["seed"]; + if order == "before" { + trace.push("c"); + } + trace.extend_from_slice(reads); + if order == "after" { + trace.push("c"); + } + let classes = if order == "alone" { + vec!["typo-body"] + } else { + vec!["external", "typo-body"] + }; + // When: both native engines execute the real extraction output. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify([trace,String(node.className??'').split(/\\s+/).filter(Boolean).sort(),Object.entries(node.style??{{}})]);", + jsx_js(&output.code) + )); + // Then: selected class, scheduling and absence of CSS consumers are joint contracts. + assert_eq!(actual, serde_json::json!([trace, classes, []])); + support::class_only(&output); +} + +#[rstest] +#[case("t")] +#[case("typography")] +#[case("state.t")] +#[case("state[state.key]")] +#[case("state.pick(state.arg)")] +#[case("state.flag?state.t:state.bad")] +#[case("state.t??state.empty")] +#[serial] +fn omits_typography_when_selected_value_is_falsy( + #[case] expression: &str, + #[values(false, true)] runtime: bool, + #[values("alone", "before", "after")] order: &str, +) { + // Given: every falsy value admitted by the existing identifier convention. + for value in ["false", "null", "undefined", "0", "''", "NaN"] { + let source = support::fixture(expression, runtime, order) + .replace("let value='body'", &format!("let value={value}")); + // When: the authored selection executes, including a lazy nullish fallback. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify([String(node.className??'').split(/\\s+/).filter(Boolean),Object.entries(node.style??{{}})]);", + jsx_js(&output.code) + )); + // Then: no typography class or CSS variable survives. + let classes: Vec<&str> = if order == "alone" { + vec![] + } else { + vec!["external"] + }; + assert_eq!(actual, serde_json::json!([classes, []])); + support::class_only(&output); + } +} + +#[rstest] +#[case("state.t")] +#[case("state[state.key]")] +#[case("state.pick(state.arg)")] +#[case("state.flag?state.t:state.bad")] +#[serial] +fn reads_selected_value_at_its_authored_position_when_classname_mutates_it( + #[case] expression: &str, + #[values(false, true)] runtime: bool, + #[values("before", "after")] order: &str, +) { + // Given: className changes a mutable fixture value, not a resolver-foldable const. + let source = support::fixture(expression, runtime, order) + .replace("trace.push('c');", "trace.push('c');value='heading';"); + // When: the generated class selection observes that source order. + let actual = support::observe(&format!( + "{}JSON.stringify(String(node.className).split(/\\s+/).filter(Boolean).sort());", + jsx_js(&extracted(&source).code) + )); + // Then: moving typography across className changes which preset is selected. + let class = if order == "before" { + "typo-heading" + } else { + "typo-body" + }; + assert_eq!(actual, serde_json::json!(["external", class])); +} + +#[rstest] +#[case("state.t")] +#[case("state[state.key]")] +#[case("state.pick(state.arg)")] +#[case("state.flag?state.t:state.bad")] +#[serial] +fn stops_later_operands_when_classname_throws( + #[case] expression: &str, + #[values(false, true)] runtime: bool, + #[values("before", "after")] order: &str, +) { + // Given: className throws, so only earlier typography operands may execute. + let source = support::fixture(expression, runtime, order) + .replace("return 'external'", "throw Error('class failed')") + .replace( + "const node=render(state.seed,state);", + "let error;try{render(state.seed,state)}catch(value){error=value.message}", + ); + let result = "JSON.stringify([trace,error]);"; + let original = support::observe(&format!("{}{result}", jsx_js(&source))); + // When: compiled operands execute under the same exception boundary. + let actual = support::observe(&format!("{}{result}", jsx_js(&extracted(&source).code))); + // Then: throwing and counting source witnesses match, not merely generated text. + assert_eq!(actual, original); + assert_eq!(actual[1], "class failed"); + if order == "before" { + assert_eq!(actual[0], serde_json::json!(["seed", "c"])); + } +} + +#[rstest] +#[case("state.t||state.empty")] +#[case("state.t??state.empty")] +#[serial] +fn reads_fallback_once_when_left_typography_is_nullish( + #[case] expression: &str, + #[values(false, true)] runtime: bool, + #[values("alone", "before", "after")] order: &str, +) { + // Given: distinct left/fallback values and an observable logical left operand. + let source = support::fixture(expression, runtime, order) + .replace("let value='body'", "let value=null") + .replace( + "trace.push('empty');return value", + "trace.push('empty');return 'heading'", + ); + let mut trace = vec!["seed"]; + if order == "before" { + trace.push("c"); + } + trace.extend(["t", "empty"]); + if order == "after" { + trace.push("c"); + } + // When: a fallback is selected at the typography operand's authored position. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify([trace,String(node.className??'').split(/\\s+/).filter(name=>name.startsWith('typo-')),Object.entries(node.style??{{}})]);", + jsx_js(&output.code) + )); + // Then: left and fallback each read once, selecting only the fallback class. + assert_eq!(actual, serde_json::json!([trace, ["typo-heading"], []])); + support::class_only(&output); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn guard_is_lazy_when_typography_branch_is_unselected(#[case] runtime: bool) { + // Given: a false style guard protecting a throwing branch. + let source = support::fixture("state.flag&&state.bad", runtime, "after") + .replace("return true", "return false"); + // When: the authored logical selection executes. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify([trace,String(node.className).trim(),Object.entries(node.style??{{}})]);", + jsx_js(&output.code) + )); + // Then: the branch stays unexecuted and no typography class/variable is emitted. + assert_eq!( + actual, + serde_json::json!([["seed", "flag", "c"], "external", []]) + ); + support::class_only(&output); +} + +#[rstest] +#[serial] +fn template_typography_keeps_empty_string_class_when_authored_value_is_coerced( + #[values(false, true)] runtime: bool, + #[values("alone", "before", "after")] order: &str, +) { + // Given: the existing template path produces a string, not a falsy raw preset. + let source = support::fixture("`${state.t}`", runtime, order) + .replace("let value='body'", "let value=''"); + // When: extraction captures the template's authored read before later operands. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify([trace,String(node.className).split(/\\s+/).filter(name=>name.startsWith('typo-')),Object.entries(node.style??{{}})]);", + jsx_js(&output.code) + )); + // Then: source scheduling and the pre-existing typo- class convention both survive. + let trace = match order { + "alone" => vec!["seed", "t"], + "before" => vec!["seed", "c", "t"], + "after" => vec!["seed", "t", "c"], + _ => panic!("fixture order"), + }; + assert_eq!(actual, serde_json::json!([trace, ["typo-"], []])); + support::class_only(&output); +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/class_only_tests.rs b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/class_only_tests.rs new file mode 100644 index 000000000..673e868f3 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/class_only_tests.rs @@ -0,0 +1,186 @@ +use rstest::rstest; +use serial_test::serial; + +use super::support; +use crate::assignment_test_support::{extracted, jsx_js}; + +#[rstest] +#[case("state.t")] +#[case("state[state.key]")] +#[case("state.pick(state.arg)")] +#[case("state.flag?state.t:state.bad")] +#[case("state.t||state.bad")] +#[case("state.t??state.bad")] +#[serial] +fn positioning_keeps_expanded_classes_when_classname_changes_the_selected_value( + #[case] expression: &str, + #[values(false, true)] runtime: bool, + #[values("before", "after")] order: &str, +) { + // Given: an enum class-only property with an order-sensitive selection. + let source = support::fixture(expression, runtime, order) + .replace("typography", "positioning") + .replace("let value='body'", "let value='top-left'") + .replace("trace.push('c');", "trace.push('c');value='bottom-right';"); + // When: selected generated classes are joined to their actual CSS declarations. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}const records={};JSON.stringify([String(node.className).split(/\\s+/).filter(name=>name!=='external').map(name=>records.find(([key])=>key===name)[1]).sort(),Object.entries(node.style??{{}})]);", + jsx_js(&output.code), + support::declarations(&output) + )); + // Then: no enum declaration/variable appears and the correct two edge classes win. + let declarations = if order == "before" { + vec!["bottom:0:0", "right:0:0"] + } else { + vec!["left:0:0", "top:0:0"] + }; + assert_eq!(actual, serde_json::json!([declarations, []])); + assert_eq!( + output + .styles + .iter() + .filter(|value| matches!(value, crate::ExtractStyleValue::Dynamic(_))) + .count(), + 0 + ); +} + +#[rstest] +#[case("before", &["seed", "c", "t"][..])] +#[case("after", &["seed", "t", "c"][..])] +#[serial] +fn as_keeps_type_and_order_when_adjacent_to_classname( + #[case] order: &str, + #[case] trace: &[&str], + #[values(false, true)] runtime: bool, +) { + // Given: a class-free tag operand beside an observable className. + let source = support::fixture("state.t", runtime, order) + .replace("typography", "as") + .replace("let value='body'", "let value='section'"); + // When: both caller forms execute the compiled type capture. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify([trace,node.tag,node.className,Object.entries(node.style??{{}})]);", + jsx_js(&output.code) + )); + // Then: the selected tag and operand schedule are unchanged. + assert_eq!( + actual, + serde_json::json!([trace, "section", "external", []]) + ); + assert_eq!(output.styles.len(), 0); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn typography_keeps_supported_unknown_spread_when_it_follows_selection(#[case] runtime: bool) { + // Given: a mutable spread supplies only an ordinary id, not #756 fallback semantics. + let source = support::fixture("state.t", runtime, "alone") + .replace("/>", " {...state.rest}/>") + .replace("typography:state.t}", "typography:state.t,...state.rest}") + .replace( + "get c()", + "get rest(){trace.push('rest');return {id:'target'}},get c()", + ); + // When: the supported unknown-spread program runs through extraction. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify([trace,String(node.className).trim(),node.id,Object.entries(node.style??{{}})]);", + jsx_js(&output.code) + )); + // Then: selection precedes the once-read spread, retaining its ordinary property. + assert_eq!( + actual, + serde_json::json!([["seed", "t", "rest"], "typo-body", "target", []]) + ); + support::class_only(&output); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn call_typography_coerces_selected_value_before_next_operand(#[case] runtime: bool) { + // Given: receiver/argument-sensitive call returns an observably coerced preset. + let source = support::fixture("state.pick(state.arg)", runtime, "after").replace( + "let value='body'", + "let value={toString(){trace.push('coerce');return 'body'}}", + ); + // When: the entire call is captured, then its result is used as a class. + let actual = support::observe(&format!( + "{}JSON.stringify([trace,String(node.className).split(/\\s+/).filter(Boolean).sort()]);", + jsx_js(&extracted(&source).code) + )); + // Then: coercion, receiver and arguments are each observed exactly once in order. + assert_eq!( + actual, + serde_json::json!([ + ["seed", "arg", "call", "coerce", "c"], + ["external", "typo-body"] + ]) + ); +} + +#[rstest] +#[serial] +fn positioning_selects_ordered_atoms_when_style_order_is_conditional( + #[values(false, true)] runtime: bool, + #[values(false, true)] active: bool, +) { + // Given: conditional order precedes an observable enum selection. + let source = support::fixture("state.t", runtime, "after") + .replace("typography", "positioning") + .replace("let value='body'", "let value='top-left'") + .replace( + "positioning:state.t", + "styleOrder:state.flag?5:10,positioning:state.t", + ) + .replace( + "positioning={state.t}", + "styleOrder={state.flag?5:10} positioning={state.t}", + ) + .replace( + "return true", + if active { + "return true" + } else { + "return false" + }, + ); + // When: both order branches generate their classes after capturing the raw enum value. + let output = extracted(&source); + let records = output + .styles + .iter() + .filter_map(|value| { + let crate::ExtractStyleValue::Static(style) = value else { + return None; + }; + let crate::extract_style::style_property::StyleProperty::ClassName(class) = + value.extract(None)? + else { + return None; + }; + Some((class, (&style.property, &style.value, style.style_order))) + }) + .collect::>(); + let actual = support::observe(&format!( + "{}const records={};JSON.stringify([trace,String(node.className).split(/\\s+/).filter(name=>name&&name!=='external').map(name=>records.find(([key])=>key===name)[1]).sort(),Object.entries(node.style??{{}})]);", + jsx_js(&output.code), + serde_json::to_string(&records).unwrap_or_else(|error| panic!("{error}")) + )); + // Then: exactly the selected order's left/top atoms match emitted CSS, with one enum read. + let order = if active { 5 } else { 10 }; + assert_eq!( + actual, + serde_json::json!([ + ["seed", "flag", "t", "c"], + [["left", "0", order], ["top", "0", order]], + [] + ]) + ); +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/context_tests.rs b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/context_tests.rs new file mode 100644 index 000000000..37e109ed1 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/context_tests.rs @@ -0,0 +1,125 @@ +use rstest::rstest; +use serial_test::serial; + +use super::support; +use crate::{ + ExtractStyleValue, + assignment_test_support::{extracted, jsx_js}, +}; + +#[rstest] +#[case("state.t", &["t"][..])] +#[case("state[state.key]", &["key", "t"][..])] +#[case("state.pick(state.arg)", &["arg", "call"][..])] +#[case("state.flag?state.t:state.bad", &["flag", "t"][..])] +#[case("state.t||state.bad", &["t"][..])] +#[serial] +fn selector_typography_selects_only_scoped_preset_when_value_is_dynamic( + #[case] expression: &str, + #[case] reads: &[&str], + #[values(false, true)] runtime: bool, +) { + // Given: a dynamic preset inside a mixed selector object with an adjacent literal. + let _theme = support::TypographyTheme::register(); + let source = support::fixture(expression, runtime, "after"); + let source = if runtime { + source.replace( + &format!("typography:{expression}"), + &format!("_hover:{{typography:{expression},color:'red'}}"), + ) + } else { + source.replace( + &format!("typography={{{expression}}}"), + &format!("_hover={{{{typography:{expression},color:'red'}}}}"), + ) + }; + // When: class-only and declaration consumers lower together at the selector field. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}const records={};JSON.stringify([String(node.className).split(/\\s+/).filter(name=>name&&name!=='external').map(name=>records.find(([key])=>key===name)[1]).sort(),Object.entries(node.style??{{}})]);", + jsx_js(&output.code), + support::declarations(&output) + )); + // Then: only body is selected, and its CSS atom is still scoped to hover. + assert_eq!( + actual, + serde_json::json!([["color:red:0", "typography:body:0"], []]) + ); + let mut trace = vec!["seed"]; + trace.extend_from_slice(reads); + trace.push("c"); + assert_eq!( + support::observe(&format!("{}JSON.stringify(trace);", jsx_js(&output.code))), + serde_json::json!(trace) + ); + for value in &output.styles { + let ExtractStyleValue::Static(style) = value else { + panic!("scoped static atom expected") + }; + assert_eq!( + style.selector.as_ref().map(ToString::to_string).as_deref(), + Some("&:hover") + ); + } +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn responsive_typography_keeps_breakpoint_when_dynamic_preset_follows_static_preset( + #[case] runtime: bool, + #[values("alone", "before", "after")] order: &str, +) { + // Given: a shared static root preset and a dynamic preset at breakpoint one. + let _theme = support::TypographyTheme::register(); + let source = support::fixture("['heading',state.t]", runtime, order); + // When: the two class roles are selected through the same authored array operand. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}const records={};JSON.stringify([String(node.className).split(/\\s+/).filter(name=>name&&name!=='external').map(name=>name==='typo-heading'?name:records.find(([key])=>key===name)[1]).sort(),Object.entries(node.style??{{}})]);", + jsx_js(&output.code), + support::declarations(&output) + )); + // Then: the later breakpoint selects body, without applying all presets globally. + assert_eq!( + actual, + serde_json::json!([["typo-heading", "typography:body:1"], []]) + ); + let trace = match order { + "alone" => vec!["seed", "t"], + "before" => vec!["seed", "c", "t"], + "after" => vec!["seed", "t", "c"], + _ => panic!("fixture order"), + }; + assert_eq!( + support::observe(&format!("{}JSON.stringify(trace);", jsx_js(&output.code))), + serde_json::json!(trace) + ); + assert_eq!( + output + .styles + .iter() + .filter(|value| matches!(value, ExtractStyleValue::Dynamic(_))) + .count(), + 0 + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn static_typography_remains_shared_when_a_literal_preset_is_authored(#[case] runtime: bool) { + // Given: the pre-existing static preset path, with an adjacent dynamic className. + let source = support::fixture("'heading'", runtime, "after"); + // When: the literal is extracted normally rather than rerouted into dynamic CSS. + let output = extracted(&source); + let actual = support::observe(&format!( + "{}JSON.stringify(String(node.className).split(/\\s+/).filter(Boolean).sort());", + jsx_js(&output.code) + )); + // Then: its shared preset class survives without declarations or variables. + assert_eq!(actual, serde_json::json!(["external", "typo-heading"])); + support::class_only(&output); +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/support.rs b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/support.rs new file mode 100644 index 000000000..1726fddd8 --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/typography_class_tests/support.rs @@ -0,0 +1,108 @@ +use crate::{ExtractOutput, ExtractStyleValue}; + +pub(super) struct TypographyTheme; + +impl TypographyTheme { + pub(super) fn register() -> Self { + css::theme_tokens::set_typography_keys(vec!["body".to_string(), "heading".to_string()]); + css::content_typography::set( + [("body", "14px"), ("heading", "24px")] + .into_iter() + .map(|(name, size)| { + ( + name.to_string(), + vec![(0, "font-size".to_string(), size.to_string())], + ) + }) + .collect(), + ); + Self + } +} + +impl Drop for TypographyTheme { + fn drop(&mut self) { + css::theme_tokens::set_typography_keys(vec![]); + css::content_typography::set(Default::default()); + } +} + +pub(super) fn fixture(expression: &str, runtime: bool, order: &str) -> String { + let mut fields = vec![("typography", expression)]; + match order { + "before" => fields.insert(0, ("className", "state.c")), + "after" => fields.push(("className", "state.c")), + "alone" => {} + _ => panic!("fixture order"), + } + let element = if runtime { + format!( + "jsx(Box,{{{}}})", + fields + .iter() + .map(|(key, value)| format!("{key}:{value}")) + .collect::>() + .join(",") + ) + } else { + format!( + "", + fields + .iter() + .map(|(key, value)| format!("{key}={{{value}}}")) + .collect::>() + .join(" ") + ) + }; + let source = format!( + "import {{Box}}from '@devup-ui/react';import {{jsx}}from 'react/jsx-runtime';function render(t,state){{return {element}}}let trace=[];let value='body';let state={{get seed(){{trace.push('seed');return value}},get t(){{trace.push('t');return value}},get key(){{trace.push('key');return 't'}},get flag(){{trace.push('flag');return true}},get bad(){{throw Error('not lazy')}},get empty(){{trace.push('empty');return value}},get arg(){{trace.push('arg');return 7}},pick(arg){{trace.push(this===state&&arg===7?'call':'broken');return value}},get c(){{trace.push('c');return 'external'}}}};const jsx=(tag,props)=>({{tag,...props}});const node=render(state.seed,state);" + ); + if expression == "typography" { + source.replace("const node=", "let reads=0;Object.defineProperty(globalThis,'typography',{get(){if(++reads>1)throw Error('second read');return state.t}});const node=") + } else { + source + } +} + +pub(super) fn observe(script: &str) -> serde_json::Value { + let script = format!("const Box='div';{script}"); + let boa = crate::assignment_test_support::evaluate(&script); + let node = crate::named_capture_support::evaluate(&script); + assert_eq!(boa, node, "native engines disagree\n{script}"); + serde_json::from_str(&node).unwrap_or_else(|error| panic!("{error}\n{node}")) +} + +pub(super) fn class_only(output: &ExtractOutput) { + assert_eq!( + output + .styles + .iter() + .filter(|value| matches!(value, ExtractStyleValue::Dynamic(_))) + .count(), + 0, + "typography must not create CSS variables" + ); + assert_eq!( + output.styles.iter().filter(|value| matches!(value, ExtractStyleValue::Static(style) if style.property == "typography")).count(), + 0, + "root typography must not create CSS declarations" + ); +} + +pub(super) fn declarations(output: &ExtractOutput) -> String { + let records = output + .styles + .iter() + .filter_map(|value| match (value, value.extract(None)?) { + ( + ExtractStyleValue::Static(style), + crate::extract_style::style_property::StyleProperty::ClassName(class), + ) => Some(( + class, + format!("{}:{}:{}", style.property, style.value, style.level), + )), + _ => None, + }) + .collect::>(); + serde_json::to_string(&records).unwrap_or_else(|error| panic!("{error}")) +} 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..df73161cf 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,21 +107,14 @@ 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 - && let Some(whole) = &spread - { - map.insert(idx.to_string(), Box::new(runtime(whole, sp.span.start))); - } else if let Some(p) = p.as_expression_mut() { + if let Some(p) = p.as_expression_mut() { map.insert( idx.to_string(), Box::new(ExtractStyleProp::StaticArray( @@ -148,7 +152,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 +210,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 +229,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 +243,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..97d24256b 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,10 @@ 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 = call.arguments[style_index] + .to_expression() + .clone_in(ast_builder.allocator()); // Extract styles from object expression let ExtractResult { mut styles, @@ -308,11 +318,7 @@ pub fn extract_style_from_styled<'a>( } = extract_style_from_expression( ast_builder, None, - if let Argument::SpreadElement(spread) = &mut call.arguments[style_index] { - &mut spread.argument - } else { - call.arguments[style_index].to_expression_mut() - }, + call.arguments[style_index].to_expression_mut(), 0, &None, LiteralHandling::ExpandResponsiveThemeToken, @@ -324,6 +330,7 @@ pub fn extract_style_from_styled<'a>( .into_iter() .map(|(offset, code)| (offset, build_time_error("styled", &code, STYLE_OBJECT))), ); + crate::assignment_lowering::styled_argument(ast_builder, &creation_source, &mut styles); if let Some(default_class_name) = base.styles.take() { styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); } @@ -337,13 +344,22 @@ 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), ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); + component = apply_attrs(ast_builder, component, attrs); + crate::assignment_capture::styled_creation( + ast_builder, + &mut component, + crate::assignment_capture::StyledCreation { + source: &creation_source, + styles: &mut styles, + }, + ); + let styled_component = base.render(ast_builder, component); let result = ExtractResult { styles, @@ -368,7 +384,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..5940e44e9 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,9 @@ fn raw_static_style<'a>( style_order: None, layer: None, theme_token_resolution: Default::default(), + naming: css::Naming::Own, + counter_owner: crate::sparse_sites::counter_owner(), + origin: crate::style_origin::current(), })) } @@ -367,12 +369,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..200507436 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,15 +352,10 @@ 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 mut inlined = Inlined::default(); - let (scoping, reads_math) = { + let (scoping, reads_math, namings) = { let mut scope = ModuleScope::new(filename, program, None); scope .style_names @@ -424,13 +421,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(); + let 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); @@ -477,14 +472,16 @@ pub(crate) fn inline_constants<'a>( symbols.extend(symbol.get().map(|symbol| (symbol, constant.clone()))); } } - (scoping, reads_math) + (scoping, reads_math, namings) }; 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 +530,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 +713,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 +1166,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 +1693,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 +1705,54 @@ 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, +} + +#[cfg(test)] +#[path = "dependency_coverage_tests.rs"] +mod dependency_coverage_tests; + +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 +1846,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 +1871,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 +1895,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 +1927,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 +1966,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 96c6ca430..0cf18382a 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,7 +1,33 @@ 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; +#[cfg(test)] +mod coverage_tests; 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 +36,24 @@ 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 static_assignment; +#[cfg(test)] +mod static_assignment_tests; +mod style_export_locations; +mod style_origin; +#[cfg(test)] +mod style_origin_tests; mod style_values; mod stylex; mod tailwind; @@ -50,6 +92,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 +134,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 +208,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 +249,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,20 +348,31 @@ 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) +} + +/// 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 the source `code` was computed from, with the layers of -/// edits, last made first, that map `code` back to it +/// `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>, ) -> 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( @@ -319,8 +397,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). @@ -344,6 +429,7 @@ fn extract_source( ), &option, &referenced, + stylesheet_naming, )? }; let code = vanilla_extract::collected_styles_to_code_with_keyframes( @@ -417,6 +503,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), @@ -439,6 +543,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) @@ -450,7 +555,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, @@ -458,10 +563,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, @@ -472,10 +582,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); @@ -491,9 +598,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) @@ -501,6 +614,7 @@ fn extract_source( .to_json_string() }); + attempt.commit(); Ok(ExtractOutput { styles: visitor.styles, code: result.code, @@ -552,6 +666,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 @@ -640,6 +757,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); @@ -654,6 +772,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, @@ -4365,7 +4487,7 @@ import clsx from 'clsx' extract( "test.tsx", r#"import { css } from "@devup-ui/core"; -; +; "#, ExtractOption { package: "@devup-ui/core".to_string(), @@ -4384,7 +4506,7 @@ import clsx from 'clsx' extract( "test.tsx", r#"import { css } from "@devup-ui/core"; -; +; "#, ExtractOption { package: "@devup-ui/core".to_string(), @@ -4403,7 +4525,7 @@ import clsx from 'clsx' extract( "test.tsx", r#"import { css } from "@devup-ui/core"; -; +; "#, ExtractOption { package: "@devup-ui/core".to_string(), @@ -13631,6 +13753,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() { @@ -14355,6 +14501,7 @@ export const card = style({ import_aliases: HashMap::new(), }, &style_names, + css::Naming::Own, ) .unwrap(); @@ -15359,9 +15506,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, @@ -15377,10 +15522,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"), @@ -15401,14 +15544,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] @@ -18990,7 +19156,7 @@ export const f = (rest, handler, k) => ; export const h = (Base) => styled.div(Base); export const i = keyframes({ from: { opacity: 0 } } as const); -export const j = css(...{ bg: 'red' });", +export const j = css(...[{ bg: 'red' }]);", ExtractOption::default(), ) .unwrap() @@ -20403,7 +20569,7 @@ export const K = styled.div(base, cond && { color: 'blue' }, { margin: 1 });", ), ( "import { css } from '@devup-ui/react';\ncss({ color: 'red' }, ...rest);", - "Cannot compose `{ color: \"red\" }, ...rest` at build time: each style must be a rule object, a class, or a condition choosing between them", + "Cannot compose `{ color: \"red\" }, ...rest` at build time: each style must be a rule object, a class, or a condition choosing between them; pass the value itself instead of spreading it", ), ( "import { styled } from '@devup-ui/react';\nstyled.div({ color: 'red' }, getStyles());", diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index 4082ff395..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 @@ -21,6 +22,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 @@ -185,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 @@ -227,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` @@ -341,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 { @@ -384,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)); @@ -418,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'); @@ -435,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'); } } @@ -443,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_coverage_tests.rs b/libs/extractor/src/prop_coverage_tests.rs new file mode 100644 index 000000000..95a0e0a45 --- /dev/null +++ b/libs/extractor/src/prop_coverage_tests.rs @@ -0,0 +1,47 @@ +use super::may_hold; +use crate::coverage_tests::expression; +use oxc_allocator::Allocator; +use rstest::rstest; + +#[rstest] +#[case("({title:'text'})", false)] +#[case("({style:{color:'red'}})", true)] +#[case("({['title']:'text'})", true)] +#[case("({...rest})", true)] +fn literal_spread_keys_control_possible_inline_overrides( + #[case] source: &str, + #[case] expected: bool, +) { + // Given + let allocator = Allocator::default(); + let spread = expression(&allocator, source); + // When + let actual = may_hold(&spread, "style"); + // Then + assert_eq!(actual, expected); +} + +#[test] +fn unknown_property_keys_survive_object_prop_modification() { + // Given + let allocator = Allocator::default(); + let ast = oxc_ast::builder::AstBuilder::new(&allocator); + let mut source = expression(&allocator, "({[state.key]:state.value})"); + let expected = crate::coverage_tests::code(&source); + // When + let oxc_ast::ast::Expression::ObjectExpression(object) = &mut source else { + panic!("object fixture") + }; + let _result = super::modify_prop_object( + &ast, + &mut object.properties, + &mut [], + None, + None, + None, + None, + None, + ); + // Then + assert_eq!(crate::coverage_tests::code(&source), expected); +} diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index 7076a2e68..e00760134 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -19,6 +19,14 @@ use oxc_span::SPAN; use rustc_hash::FxHashMap; use std::borrow::Cow; +#[cfg(test)] +#[path = "prop_coverage_tests.rs"] +mod coverage_tests; + +#[cfg(test)] +#[path = "tailwind_logical_ir_tests.rs"] +mod tailwind_logical_ir_tests; + /// Combine two optional className expressions into a conditional expression. /// `condition ? con_expr : alt_expr`, falling back to `""` for the missing branch. /// Returns `None` only when both branches are `None`. @@ -65,23 +73,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 +118,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 +150,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 +177,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 +225,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 +241,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 +284,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 +331,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..8436b9daf 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..0e6512d87 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..c11f8c8a5 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..3266fee1c 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..c5ab22402 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..075fc206b 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..b0d0c500e 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`] = ` "
-
-