diff --git a/.changepacks/changepack_log_atom_hoist_names.json b/.changepacks/changepack_log_atom_hoist_names.json new file mode 100644 index 000000000..135520653 --- /dev/null +++ b/.changepacks/changepack_log_atom_hoist_names.json @@ -0,0 +1,11 @@ +{ + "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", + "packages/webpack-plugin/package.json": "Patch" + }, + "note": "Enable atomHoist by default at two routes in Webpack, Rsbuild and Next Webpack, with numeric zero opting out and no effect in singleCss mode. Keep Vite and Next Turbopack in per-file mode until their shared-slot reservation lands, warning once for explicit positive atomHoist requests. Allocate shared and file-local short counter names through deterministic reserved slots instead of content names; preserve frozen route plans, dynamic !important, local keyframes, first-value responsive tokens, shared CSS updates and persisted placement. Bump the naming cache generation. No new build errors.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_order_independent_shared_slots.json b/.changepacks/changepack_log_order_independent_shared_slots.json new file mode 100644 index 000000000..d105f2850 --- /dev/null +++ b/.changepacks/changepack_log_order_independent_shared_slots.json @@ -0,0 +1,12 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch" + }, + "note": "Reserve existing short counter class, variable, keyframe and raw-file slots in physical input order before Bun, webpack, Rsbuild and Next webpack extraction. Watch revisions append sorted slots without renumbering. No new source build errors or styling runtime; unknown virtual, generated and opaque outside-inventory inputs retain observable residual allocation.", + "date": "2026-10-15T00: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..3fd5f4c1b --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_hoist_measurement.rs @@ -0,0 +1,254 @@ +//! 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 mut inventory: Vec<_> = modules + .iter() + .map(|module| super::ReservationInput { + filename: module.path.clone(), + code: module.source(), + css_dir: "df".into(), + }) + .collect(); + inventory.sort_by(|a, b| a.filename.cmp(&b.filename)); + super::reserve_class_slots_internal(&inventory, extractor::ExtractOption::default(), None); + 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..5b596a7b6 --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_hoist_measurement/error_tests.rs @@ -0,0 +1,33 @@ +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, "src/shared/s00.tsx:1:61: Unexpected token"); + assert_eq!( + error.to_string(), + "src/shared/s00.tsx: src/shared/s00.tsx:1:61: Unexpected token" + ); + 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..93255cc8b --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_tests.rs @@ -0,0 +1,253 @@ +use super::*; +use serial_test::serial; +use std::collections::HashSet; + +mod lifecycle; +mod references; +mod resumption; +mod single_css; +use references::assert_references_resolve; + +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 reserve(sources: &[(&str, &str)]) { + let inputs: Vec<_> = sources + .iter() + .map(|(file, source)| ReservationInput { + filename: (*file).into(), + code: (*source).into(), + css_dir: "df".into(), + }) + .collect(); + reserve_class_slots_internal( + &inputs, + ExtractOption { + import_aliases: HashMap::from([( + "@vanilla-extract/css".into(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + None, + ); +} + +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), + ("private.tsx", SOURCE_B), + ]; + reserve(&sources); + if reverse { + sources.reverse(); + } + let mut output = BTreeMap::new(); + // When + for (file, source) in sources { + let result = compile(file, source); + if file != "private.tsx" { + 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_eq!(get_unreserved_class_slot_count(), 0); + 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"), + emitted("private.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); + reserve(&[("a.tsx", SOURCE_A)]); + let reserved = export_class_map_internal().unwrap_or_else(|error| panic!("{error}")); + reserve(&[("a.tsx", SOURCE_A)]); + assert_eq!( + export_class_map_internal().unwrap_or_else(|error| panic!("{error}")), + reserved + ); + assert!( + css::class_map::get_class_map() + .values() + .map(HashMap::len) + .sum::() + > 0 + ); + // When + let output = compile("a.tsx", SOURCE_A); + let css = emitted("a.tsx"); + // Then + assert_references_resolve(&output.code, &css); + assert_eq!(get_unreserved_class_slot_count(), 0); + (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 shared_peer = compile("b.tsx", source); + let private = compile("private.tsx", source); + // Then + assert_references_resolve(&shared.code, &emitted("a.tsx")); + assert_references_resolve(&shared_peer.code, &emitted("b.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!(shared.code.contains("className=\"a\""), "{}", shared.code); + assert!( + private.code.contains("className=\"c-a\""), + "{}", + private.code + ); + 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..7a00544f3 --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs @@ -0,0 +1,245 @@ +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(); + reserve(&[("a.tsx", RED), ("private.tsx", RED)]); + 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}")); + let classes = export_class_map_internal().unwrap_or_else(|error| panic!("{error}")); + let files = export_file_map_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); + // 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")); + set_class_map(serde_json::from_str(&classes).unwrap_or_else(|error| panic!("{error}"))); + set_file_map(serde_json::from_str(&files).unwrap_or_else(|error| panic!("{error}"))); + 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 changed = compile( + "private.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + ); + assert_ne!(private.code, changed.code); + assert_references_resolve(&changed.code, &emitted("private.tsx")); + let shared_again = compile("b.tsx", RED); + assert_references_resolve(&shared_again.code, &emitted("b.tsx")); + assert!( + !with_style_sheet(|sheet| sheet.create_css(Some("b.tsx"), false)).contains("color:red") + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn canonical_bucket_reach_is_frozen_before_imported_styles_are_processed() { + // Given + setup(); + import_canonical_map_internal(HashMap::from([ + ("member.tsx".into(), "bucket.tsx".into()), + ("peer.tsx".into(), "bucket.tsx".into()), + ])); + css::file_routes::set_file_routes(HashMap::from([ + ("member.tsx".into(), HashSet::from([0])), + ("peer.tsx".into(), HashSet::from([1])), + ])); + let resolver = |specifier: &str, _importer: &str| { + (specifier == "./tokens").then(|| ResolvedModule { + path: "tokens.ts".to_string(), + code: "export const styles={color:'red',_hover:{color:'blue'}};".to_string(), + }) + }; + // When + let output = code_extract_with_modules_internal( + "member.tsx", + "import {Box} from '@devup-ui/react'; import {styles} from './tokens'; export const x=;", + "@devup-ui/react", + "df".to_string(), + false, + false, + false, + HashMap::new(), + &resolver, + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert_references_resolve(&output.code, &emitted("bucket.tsx")); + assert!(with_style_sheet(|sheet| sheet.create_css(None, false)).contains("color:red")); + assert!( + !with_style_sheet(|sheet| sheet.create_css(Some("bucket.tsx"), false)) + .contains("color:red") + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn important_and_plain_dynamic_values_emit_distinct_rules_in_every_mode() { + // Given + let source = r"import {Box} from '@devup-ui/react'; + export const x=<>;"; + for atom in [false, true] { + setup(); + if !atom { + css::atom_hoist::set_atom_hoist(None); + } + // When + let output = compile("private.tsx", source); + // Then + let css = emitted("private.tsx"); + let rules = with_style_sheet(|sheet| { + sheet.properties["private.tsx"] + .values() + .flat_map(BTreeMap::values) + .flatten() + .map(|prop| (prop.class_name.clone(), prop.value.clone())) + .collect::>() + }); + assert_eq!(rules.len(), 2); + assert_ne!(rules[0].0, rules[1].0); + for (name, value) in rules { + assert!(output.code.contains(&name)); + assert!(css.contains(&format!(".{name}{{color:{value}}}")), "{css}"); + } + if atom { + assert_references_resolve(&output.code, &css); + } + } + reset_build_state_internal(); +} + +#[test] +#[serial] +fn keyframe_content_and_scope_resolve_when_using_single_css() { + // Given + 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=<>;"; + let build = |atom: bool, debug| { + setup(); + css::atom_hoist::set_atom_hoist(atom.then_some(2)); + css::debug::set_debug(debug); + reserve_class_slots_internal( + &[ReservationInput { + filename: "a.tsx".into(), + code: source.into(), + css_dir: "df".into(), + }], + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + None, + ); + // 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}{{"))); + } + }); + assert_eq!(get_unreserved_class_slot_count(), 0); + (output.code, css) + }; + for debug in [false, true] { + assert_eq!(build(false, debug), build(true, debug)); + } + reset_build_state_internal(); + css::debug::set_debug(false); +} + +#[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/atom_tests/references.rs b/bindings/devup-ui-wasm/src/atom_tests/references.rs new file mode 100644 index 000000000..7460f1518 --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_tests/references.rs @@ -0,0 +1,169 @@ +use std::collections::BTreeSet; + +fn identifier(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '-' || c == '_' +} + +// These fixtures contain unescaped literals and simple template interpolations only. +fn literal_parts(source: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut rest = source; + while let Some(start) = rest.find(['"', '\'', '`']) { + let quote = rest.as_bytes()[start]; + rest = &rest[start + 1..]; + let end = rest + .bytes() + .position(|byte| byte == quote) + .unwrap_or_else(|| panic!("closed fixture literal")); + let literal = &rest[..end]; + assert!( + !literal.contains('\\'), + "unsupported escaped fixture literal: {literal}" + ); + if quote == b'`' { + let mut raw = literal; + while let Some(start) = raw.find("${") { + parts.push(&raw[..start]); + let end = raw[start..] + .find('}') + .unwrap_or_else(|| panic!("closed fixture interpolation")) + + start; + parts.extend(literal_parts(&raw[start + 2..end])); + raw = &raw[end + 1..]; + } + parts.push(raw); + } else { + parts.push(literal); + } + rest = &rest[end + 1..]; + } + parts +} + +pub(super) fn assert_references_resolve(code: &str, css: &str) { + let mut classes = BTreeSet::new(); + for field in code.split("className=").skip(1) { + let end = field + .find(" style=") + .or_else(|| field.find(" />")) + .unwrap_or_else(|| panic!("bounded fixture class attribute")); + for literal in literal_parts(&field[..end]) { + classes.extend(literal.split_whitespace()); + } + } + for field in code.split("className: ").skip(1) { + let end = field + .find('}') + .unwrap_or_else(|| panic!("bounded fixture class object")); + let literals = literal_parts(&field[..end]); + assert_eq!(literals.len(), 1); + classes.extend(literals[0].split_whitespace()); + } + for value in code.split("const s = { \"root\": ").skip(1) { + let end = value + .find('}') + .unwrap_or_else(|| panic!("bounded fixture StyleX binding")); + let literals = literal_parts(&value[..end]); + assert_eq!(literals.len(), 1); + classes.extend(literals[0].split_whitespace()); + } + for binding in ["const cls = ", "const card = "] { + for value in code.split(binding).skip(1) { + let end = value + .find(';') + .unwrap_or_else(|| panic!("bounded fixture class binding")); + let literals = literal_parts(&value[..end]); + assert_eq!(literals.len(), 1, "unsupported fixture binding: {value}"); + classes.extend(literals[0].split_whitespace()); + } + } + assert_ne!( + classes.len(), + 0, + "fixture must generate actual class references: {code}" + ); + if code.contains("const cls = ") { + assert_eq!( + classes.len(), + if code.contains("const Card = ") { + 10 + } else { + 8 + } + ); + } + if code.contains("const card = ") { + assert_eq!(classes.len(), 3); + } + if code.contains("const s = ") { + assert_eq!(classes.len(), 2); + } + let defined: BTreeSet<_> = css + .split('.') + .skip(1) + .map(|tail| tail.split(|c| !identifier(c)).next().unwrap_or_default()) + .collect(); + for name in classes { + assert!( + defined.contains(name), + "unemitted exact class {name}\n{code}\n{css}" + ); + } + let mut frames = 0; + for binding in ["const fade = ", "const a = ", "const b = ", "const c = "] { + for value in code.split(binding).skip(1) { + let end = value + .find(';') + .unwrap_or_else(|| panic!("bounded fixture animation binding")); + let literals = literal_parts(&value[..end]); + assert_eq!(literals.len(), 1); + let name = literals[0]; + assert!( + css.contains(&format!("@keyframes {name}{{")), + "unemitted keyframes {name}" + ); + assert!( + css.contains(&format!("animation:{name} ")), + "unused animation {name}" + ); + frames += 1; + } + } + let mut variables = 0; + for tail in code.split("\"--").skip(1) { + let (name, suffix) = tail + .split_once('"') + .unwrap_or_else(|| panic!("closed fixture custom property")); + assert!( + suffix.starts_with(':'), + "fixture variable must be an object key" + ); + assert!( + css.contains(&format!("var(--{name})")), + "unemitted variable --{name}" + ); + variables += 1; + } + if code.contains("const cls = ") { + assert_eq!(frames, 1); + assert_eq!( + variables, + if code.contains("const Card = ") { 2 } else { 1 } + ); + } + if code.contains("const card = ") { + assert_eq!(frames, 1); + } +} + +#[test] +fn exact_class_validation_rejects_a_local_prefix_match() { + // Given + let code = "export const x=
;"; + // When + let result = std::panic::catch_unwind(|| { + assert_references_resolve(code, concat!(".a-a{", "color:red}")); + }); + // Then + assert!(result.is_err()); +} diff --git a/bindings/devup-ui-wasm/src/atom_tests/resumption.rs b/bindings/devup-ui-wasm/src/atom_tests/resumption.rs new file mode 100644 index 000000000..9b32f2704 --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_tests/resumption.rs @@ -0,0 +1,52 @@ +use super::*; + +#[test] +#[serial] +fn full_state_restore_preserves_all_symbol_slots_and_appends_new_identities() { + // Given + setup(); + reserve(&[("a.tsx", SOURCE_A)]); + let old = compile("a.tsx", SOURCE_A); + let serialized = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let classes = export_class_map_internal().unwrap_or_else(|error| panic!("{error}")); + let files = export_file_map_internal().unwrap_or_else(|error| panic!("{error}")); + let old_slots = css::class_map::get_class_map(); + let old_global = with_style_sheet(|sheet| sheet.create_css(None, false)); + let old_local = with_style_sheet(|sheet| sheet.create_css(Some("a.tsx"), false)); + reset_build_state_internal(); + import_sheet_internal( + serde_json::from_str(&serialized).unwrap_or_else(|error| panic!("{error}")), + ); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + old_global + ); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("a.tsx"), false)), + old_local + ); + set_class_map(serde_json::from_str(&classes).unwrap_or_else(|error| panic!("{error}"))); + set_file_map(serde_json::from_str(&files).unwrap_or_else(|error| panic!("{error}"))); + css::atom_hoist::set_atom_hoist(Some(2)); + // When + let repeat = compile("a.tsx", SOURCE_A); + let changed_source = SOURCE_A + .replace("opacity:1", "opacity:0.5") + .replace("color={tone}", "outlineColor={tone}"); + reserve(&[("a.tsx", &changed_source)]); + let changed = compile("a.tsx", &changed_source); + // Then + assert_eq!(old.code, repeat.code); + assert_ne!(old.code, changed.code); + let new_slots = css::class_map::get_class_map(); + for (scope, entries) in old_slots { + for (key, slot) in entries { + assert_eq!(new_slots[&scope][&key], slot); + } + let unique: HashSet<_> = new_slots[&scope].values().collect(); + assert_eq!(unique.len(), new_slots[&scope].len()); + } + assert_eq!(with_style_sheet(|sheet| sheet.keyframes["a.tsx"].len()), 2); + assert_references_resolve(&changed.code, &emitted("a.tsx")); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/atom_tests/single_css.rs b/bindings/devup-ui-wasm/src/atom_tests/single_css.rs new file mode 100644 index 000000000..7c758acc9 --- /dev/null +++ b/bindings/devup-ui-wasm/src/atom_tests/single_css.rs @@ -0,0 +1,139 @@ +use super::*; + +const RED: &str = "import {Box} from '@devup-ui/react'; export const x=;"; + +fn build(atom: bool, debug: bool) -> (String, String, String) { + setup(); + css::atom_hoist::set_atom_hoist(atom.then_some(2)); + css::debug::set_debug(debug); + set_prefix(Some("du-".into())); + let option = ExtractOption { + single_css: true, + ..ExtractOption::default() + }; + reserve_class_slots_internal( + &[ReservationInput { + filename: "a.tsx".into(), + code: SOURCE_A.into(), + css_dir: "df".into(), + }], + option, + None, + ); + let output = code_extract_internal( + "a.tsx", + SOURCE_A, + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!(get_unreserved_class_slot_count(), 0); + ( + output.code, + with_style_sheet(|sheet| sheet.create_css(None, false)), + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + ) +} + +#[test] +#[serial] +fn w66_single_css_output_is_exactly_invariant_to_requested_atom_mode() { + // Given / When / Then + for debug in [false, true] { + assert_eq!(build(false, debug), build(true, debug)); + assert_eq!(css::atom_hoist::atom_plan(), None); + } + reset_build_state_internal(); + css::debug::set_debug(false); +} + +#[test] +#[serial] +fn w66_single_css_does_not_freeze_empty_reach_before_per_file_reservation() { + // Given + setup(); + css::file_routes::reset_file_routes(); + let inputs = [ReservationInput { + filename: "a.tsx".into(), + code: RED.into(), + css_dir: "df".into(), + }]; + // When + reserve_class_slots_internal( + &inputs, + ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + None, + ); + // Then + assert_eq!(css::atom_hoist::atom_plan(), None); + css::file_routes::set_file_routes(HashMap::from([("a.tsx".into(), HashSet::from([0, 1]))])); + reserve_class_slots_internal(&inputs, ExtractOption::default(), None); + let output = compile("a.tsx", RED); + assert!(output.code.contains("className=\"")); + assert_eq!(get_unreserved_class_slot_count(), 0); + assert!(with_style_sheet(|sheet| sheet.create_css(None, false)).contains("color:red")); + assert!( + !with_style_sheet(|sheet| sheet.create_css(Some("a.tsx"), false)).contains("color:red") + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn single_css_error_and_resolver_unwind_restore_effective_atom_mode() { + // Given + setup(); + let option = ExtractOption { + single_css: true, + ..ExtractOption::default() + }; + let bad = concat!( + "import {css} from '@devup-ui/react'; const value=props.value; css({", + "color:value});" + ); + // When + let error = match extractor::extract_without_source_map("bad.tsx", bad, option.clone()) { + Err(error) => error, + Ok(_) => panic!("located extraction error"), + }; + // Then + assert!(error.to_string().contains("bad.tsx:")); + assert!(css::atom_hoist::is_atom_hoist()); + assert_eq!(css::atom_hoist::atom_plan(), None); + let resolver = |_: &str, _: &str| -> Option { panic!("resolver unwind") }; + let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + reserve_class_slots_internal(&[ReservationInput { + filename: "root.css.ts".into(), + code: "import {style} from '@devup-ui/react'; import {base} from './supplier.css'; export const own=style(base);".into(), + css_dir: "df".into(), + }], option, Some(&resolver)); + })); + assert!(unwind.is_err()); + assert!(css::atom_hoist::is_atom_hoist()); + assert_eq!(css::atom_hoist::atom_plan(), None); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn canonical_global_placement_keeps_atom_mode_in_a_per_file_build() { + // Given + setup(); + import_canonical_map_internal(HashMap::from([("global.tsx".into(), "@global".into())])); + // When + let output = compile("global.tsx", RED); + // Then + assert!(output.code.contains("className=\"a\""), "{}", output.code); + let css = with_style_sheet(|sheet| sheet.create_css(None, false)); + assert_references_resolve(&output.code, &css); + assert!(with_style_sheet(|sheet| sheet.atom_plan.is_some())); + assert!(css::atom_hoist::is_atom_hoist()); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/cache_protocol_tests.rs b/bindings/devup-ui-wasm/src/cache_protocol_tests.rs new file mode 100644 index 000000000..990dfe2a1 --- /dev/null +++ b/bindings/devup-ui-wasm/src/cache_protocol_tests.rs @@ -0,0 +1,97 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; +use std::collections::HashSet; + +#[rstest] +#[case(false, false)] +#[case(false, true)] +#[case(true, true)] +#[serial] +fn legacy_import_changes_cache_generation_when_exported( + #[case] with_styles: bool, + #[case] atom_mode: bool, +) { + // Given + reset_build_state_internal(); + let mut legacy = StyleSheet::default(); + if with_styles { + legacy.add_property("old", "color", 0, "red", None, None, Some("private.tsx")); + } + let legacy_json = serde_json::to_string(&legacy).unwrap_or_else(|error| panic!("{error}")); + let local = legacy.create_css(Some("private.tsx"), false); + let global = legacy.create_css(None, false); + css::atom_hoist::set_atom_hoist(atom_mode.then_some(2)); + let imported = serde_json::from_str(&legacy_json).unwrap_or_else(|error| panic!("{error}")); + import_sheet_internal(imported); + // When + let exported = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + // Then + assert_ne!(exported, legacy_json); + let json: serde_json::Value = + serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + assert_eq!(json["atomNamingVersion"], 2); + let restored: StyleSheet = + serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + assert_eq!(restored.atom_plan, None); + assert_eq!(restored.create_css(Some("private.tsx"), false), local); + assert_eq!(restored.create_css(None, false), global); + import_sheet_internal(restored); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + exported + ); + reset_build_state_internal(); +} + +#[test] +#[serial] +fn exported_generation_roundtrips_frozen_atom_placement() { + // Given + reset_build_state_internal(); + css::atom_hoist::set_atom_hoist(Some(2)); + css::file_routes::set_file_routes(HashMap::from([ + ("shared.tsx".into(), HashSet::from([0, 1])), + ("private.tsx".into(), HashSet::from([0])), + ])); + for file in ["shared.tsx", "private.tsx"] { + code_extract_internal( + file, + "import {Box} from '@devup-ui/react'; export const x=;", + "@devup-ui/react", + "df".into(), + false, + false, + false, + HashMap::new(), + ) + .unwrap_or_else(|error| panic!("{error}")); + } + let exported = export_sheet_internal().unwrap_or_else(|error| panic!("{error}")); + let global = with_style_sheet(|sheet| sheet.create_css(None, false)); + let local = with_style_sheet(|sheet| sheet.create_css(Some("private.tsx"), false)); + let imported = serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); + reset_build_state_internal(); + // When + import_sheet_internal(imported); + // Then + assert_eq!(global.matches("color:red").count(), 1); + assert_eq!(local.matches("color:red").count(), 1); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(None, false)), + global + ); + assert_eq!( + with_style_sheet(|sheet| sheet.create_css(Some("private.tsx"), false)), + local + ); + assert!( + !with_style_sheet(|sheet| sheet.create_css(Some("shared.tsx"), false)) + .contains("color:red") + ); + assert_eq!( + export_sheet_internal().unwrap_or_else(|error| panic!("{error}")), + exported + ); + reset_build_state_internal(); +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index da96d5bd1..bb4e59488 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1,3 +1,8 @@ +mod reservation; +pub use reservation::{ + ReservationInput, get_unreserved_class_slot_count, reserve_class_slots_internal, +}; + 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, @@ -83,6 +88,7 @@ impl Output { // the extractor already baked into `code`. Identity when no map is loaded. // Global (shared-chunk) files are treated like single-css: emitted into the // global bucket (devup-ui.css) with prefix-less naming. + let _atom_mode = css::atom_hoist::AtomModeGuard::new(single_css); let canonical_filename = canonical(&filename); let global = single_css || is_global(&filename); with_style_sheet_mut(|sheet| { @@ -92,7 +98,7 @@ impl Output { // 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); + sheet.update_styles_in_bucket(&styles, &canonical_filename, global); Self { code, map, @@ -222,6 +228,7 @@ pub fn get_prefix() -> Option { /// Internal function to import a `StyleSheet` (testable without `JsValue`) pub fn import_sheet_internal(sheet: StyleSheet) { + css::atom_hoist::restore_atom_plan(sheet.atom_plan.clone()); with_style_sheet_mut(|global_sheet| *global_sheet = sheet); } @@ -235,7 +242,8 @@ pub fn import_sheet(sheet_object: JsValue) -> Result<(), JsValue> { /// Internal function to export `StyleSheet` as JSON string (testable without `JsValue`) pub fn export_sheet_internal() -> Result { - with_style_sheet(serde_json::to_string).map_err(|e| e.to_string()) + with_style_sheet(|sheet| serde_json::to_string(&sheet.export_snapshot())) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "exportSheet")] @@ -332,11 +340,13 @@ pub fn seed_file_map(files: Vec) { /// and the module resolver, so the next build starts from its own options /// alone. Theme, shorthands and debug mode are set by every build, and stay. pub fn reset_build_state_internal() { + css::slot_reservation::reset(); 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); with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); MODULE_RESOLVER.with_borrow_mut(|current| *current = None); @@ -348,13 +358,12 @@ pub fn reset_build_state() { } /// Set the atom-level hoist threshold. /// -/// When set to `Some(n)`, a style atom whose content is used by `>= n` distinct -/// routes is emitted into the shared global `devup-ui.css` (shipped once) instead -/// of duplicated into each per-route chunk. `None` (the default) disables atom -/// hoisting entirely (identity behavior). +/// When set to `Some(n)`, buckets with predeclared reach of `>= n` routes emit +/// their atoms into shared CSS. Eligibility freezes before the first source; +/// unknown or late reach stays local until `resetBuildState`. +/// `None` (the default) preserves ordinary per-file naming and placement. /// -/// MUST be called BEFORE `codeExtract` so atoms receive global (shared) class -/// names; enabling it afterwards leaves per-file names and nothing hoists. +/// Set this before `codeExtract` so extraction and emission share one plan. /// Pair with `importFileRoutes` to provide the file -> routes mapping. #[wasm_bindgen(js_name = "setAtomHoist")] pub fn set_atom_hoist(threshold: Option) { @@ -720,6 +729,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 { @@ -753,16 +771,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, @@ -817,337 +849,28 @@ mod tests { set_atom_hoist(None); reset_file_routes(); + css::atom_hoist::restore_atom_plan(None); } - /// Env-gated artifact emitter for split-native measurement. Writes REAL - /// devup CSS output (header, `@layer`, naming, dedup all authentic) for three - /// delivery models across several workloads, so an external script can - /// measure gzip/brotli + multi-route session + incremental-invalidation - /// bytes. Set `DEVUP_EMIT_MEASURE=1` to run; no-op (and zero cost) otherwise - /// so the normal test suite stays clean. + /// Export real before/after sheets and code for external gzip measurement. + /// `DEVUP_EMIT_MEASURE=1` enables structured stdout without shared temp files. #[test] #[serial] - #[allow(clippy::items_after_statements, clippy::format_push_string)] fn emit_split_measurement_artifacts() { - use css::atom_hoist::set_atom_hoist; - use css::class_map::reset_class_map; - use css::file_map::reset_file_map; - use css::file_routes::{reset_file_routes, set_file_routes}; - use std::collections::{HashMap, HashSet}; - use std::fs; - - if std::env::var("DEVUP_EMIT_MEASURE").is_err() { - return; + if std::env::var_os("DEVUP_EMIT_MEASURE").is_some() { + let artifacts = + super::atom_hoist_measurement::build_predeclared_measurement_artifacts() + .unwrap_or_else(|error| panic!("{error}")); + println!("{artifacts}"); } - - let props = [ - "w", - "h", - "p", - "m", - "minW", - "minH", - "maxW", - "maxH", - "fontSize", - "lineHeight", - "borderRadius", - "gap", - ]; - let atom = |key: &str, px: usize| format!(""); - let build = |els: &[String]| { - format!( - "import {{ Box }} from \"@devup-ui/react\"; const x = <>{};", - els.join("") - ) - }; - let reset = || { - { - let mut s = GLOBAL_STYLE_SHEET.lock().unwrap(); - *s = StyleSheet::default(); - } - reset_class_map(); - reset_file_map(); - reset_file_routes(); - register_theme_internal(sheet::theme::Theme::default()); - }; - - let out = std::env::temp_dir().join("devup-split-measure"); - let _ = fs::remove_dir_all(&out); - fs::create_dir_all(&out).unwrap(); - - // (name, routes, universal atoms, private atoms/route) - let workloads = [ - ("shared_heavy", 8usize, 80usize, 25usize), - ("balanced", 8usize, 50usize, 50usize), - ("disjoint", 8usize, 20usize, 60usize), - ]; - let mut manifest = String::from("["); - for (wi, (name, n, u, p)) in workloads.iter().enumerate() { - let (n, u, p) = (*n, *u, *p); - let universal: Vec = (0..u) - .map(|i| atom(props[i % props.len()], 100_000 + i)) - .collect(); - let make_priv = |r: usize| -> Vec { - (0..p) - .map(|i| { - atom( - props[i % props.len()], - 1_000_000 + wi * 1_000_000 + r * p + i, - ) - }) - .collect() - }; - let sources: Vec = (0..n) - .map(|r| { - let mut e = universal.clone(); - e.extend(make_priv(r)); - build(&e) - }) - .collect(); - let run = |single: bool| { - reset(); - for (r, src) in sources.iter().enumerate() { - code_extract_internal( - &format!("r{r}.tsx"), - src, - "@devup-ui/react", - "df".to_string(), - single, - false, - false, - HashMap::new(), - ) - .unwrap(); - } - }; - - // single-css: one shared file with every atom. - run(true); - fs::write( - out.join(format!("{name}_single.css")), - with_style_sheet(|s| s.create_css(None, false)), - ) - .unwrap(); - - // per-file: shared base (theme/base only) + one full chunk per route. - run(false); - fs::write( - out.join(format!("{name}_perfile_base.css")), - with_style_sheet(|s| s.create_css(None, false)), - ) - .unwrap(); - for r in 0..n { - fs::write( - out.join(format!("{name}_perfile_r{r}.css")), - with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)), - ) - .unwrap(); - } - - // atom-B: hoisted shared base (universal atoms) + per-route delta. - // CRITICAL: atom_hoist must be enabled BEFORE extraction so atoms get - // GLOBAL names (shared identity across files). Enabling it only at - // create_css time leaves per-file names, so the same universal atom - // looks like N distinct atoms (one per file) and never hoists. - reset(); - let mut fr = HashMap::new(); - for r in 0..n { - fr.insert(format!("r{r}.tsx"), HashSet::from([r as u32])); - } - set_file_routes(fr); - set_atom_hoist(Some(n)); - for (r, src) in sources.iter().enumerate() { - code_extract_internal( - &format!("r{r}.tsx"), - src, - "@devup-ui/react", - "df".to_string(), - false, - false, - false, - HashMap::new(), - ) - .unwrap(); - } - fs::write( - out.join(format!("{name}_atomb_base.css")), - with_style_sheet(|s| s.create_css(None, false)), - ) - .unwrap(); - for r in 0..n { - fs::write( - out.join(format!("{name}_atomb_r{r}.css")), - with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)), - ) - .unwrap(); - } - set_atom_hoist(None); - reset_file_routes(); - - manifest.push_str(&format!( - "{}{{\"name\":\"{name}\",\"n\":{n},\"u\":{u},\"p\":{p}}}", - if wi > 0 { "," } else { "" } - )); - } - manifest.push(']'); - fs::write(out.join("manifest.json"), manifest).unwrap(); - reset(); - set_atom_hoist(None); - println!("[EMIT] artifacts -> {}", out.display()); } - /// SPLIT-NATIVE LOCK: atom-level route-aware hoisting (global-named - /// shared-base + per-route delta) is a STRICT upgrade over the per-file mode - /// on the metrics that split actually competes on -- multi-route SESSION - /// bytes and incremental-deploy INVALIDATION bytes -- NOT on fresh-single- - /// route bytes (where per-file already hits the theoretical floor). - /// - /// This test supersedes an earlier "no win" lock that was built on a - /// measurement bug: enabling atom_hoist AFTER extraction left per-file class - /// names, so the same universal atom looked like N distinct atoms and never - /// hoisted -- making atom-B byte-identical to per-file (a no-op, not a - /// truth). The fix, asserted here, is that atom_hoist MUST be enabled BEFORE - /// extraction so atoms get GLOBAL (shared) names. + /// Compare usable sheets with predeclared shared-module reach and count + /// deployment invalidations after changing a common upstream declaration. #[test] #[serial] - // byte sizes are tiny so ratios are exact; doc prose names models literally - #[allow(clippy::cast_precision_loss, clippy::doc_markdown)] fn atom_b_beats_per_file_on_session_and_invalidation() { - use css::atom_hoist::set_atom_hoist; - use css::class_map::reset_class_map; - use css::file_map::reset_file_map; - use css::file_routes::{reset_file_routes, set_file_routes}; - use std::collections::{HashMap, HashSet}; - - // Realistic design-system workload: many shared primitives, fewer - // route-private atoms. Routes are disjoint on private atoms. - const ROUTES: usize = 8; - const UNIVERSAL: usize = 80; - const PRIVATE: usize = 25; - - let props = ["w", "h", "p", "m", "minW", "minH", "maxW", "maxH"]; - let atom = |key: &str, px: usize| format!(""); - let build_source = |elements: &[String]| -> String { - let body = elements.join(""); - format!("import {{ Box }} from \"@devup-ui/react\"; const x = <>{body};") - }; - let reset_engine = || { - { - let mut s = GLOBAL_STYLE_SHEET.lock().unwrap(); - *s = StyleSheet::default(); - } - reset_class_map(); - reset_file_map(); - reset_file_routes(); - register_theme_internal(sheet::theme::Theme::default()); - }; - - let universal_atoms: Vec = (0..UNIVERSAL) - .map(|i| atom(props[i % props.len()], 100_000 + i)) - .collect(); - let make_private = |route: usize| -> Vec { - (0..PRIVATE) - .map(|i| atom(props[i % props.len()], 1_000_000 + route * PRIVATE + i)) - .collect() - }; - let sources: Vec = (0..ROUTES) - .map(|r| { - let mut e = universal_atoms.clone(); - e.extend(make_private(r)); - build_source(&e) - }) - .collect(); - let extract_all = |single_css: bool| { - for (r, src) in sources.iter().enumerate() { - code_extract_internal( - &format!("r{r}.tsx"), - src, - "@devup-ui/react", - "df".to_string(), - single_css, - false, - false, - HashMap::new(), - ) - .unwrap(); - } - }; - - // ---- per-file: atom_hoist OFF, multi-css. Each chunk carries all of - // its route's atoms (universals duplicated into every chunk). ---- - reset_engine(); - extract_all(false); - let pf_base = with_style_sheet(|s| s.create_css(None, false)).len(); - let pf_chunks: Vec = (0..ROUTES) - .map(|r| with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)).len()) - .collect(); - - // ---- atom-B: enable hoist + routes BEFORE extraction so atoms get - // GLOBAL names; universals (used by all ROUTES) hoist into the base, - // privates stay in their per-route delta. ---- - reset_engine(); - let mut fr = HashMap::new(); - for r in 0..ROUTES { - fr.insert(format!("r{r}.tsx"), HashSet::from([r as u32])); - } - set_file_routes(fr); - set_atom_hoist(Some(ROUTES)); - extract_all(false); - let ab_base = with_style_sheet(|s| s.create_css(None, false)).len(); - let ab_deltas: Vec = (0..ROUTES) - .map(|r| with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)).len()) - .collect(); - set_atom_hoist(None); - reset_file_routes(); - reset_engine(); - - // Session = visit every route once (base cached after the first route). - let pf_session = pf_base + pf_chunks.iter().sum::(); - let ab_session = ab_base + ab_deltas.iter().sum::(); - // Invalidation = one route's styles change; returning user re-downloads - // only the file(s) whose hash changed. - let pf_invalidation = pf_chunks[0]; - let ab_invalidation = ab_deltas[0]; - - let session_margin = (pf_session as f64 - ab_session as f64) / pf_session as f64 * 100.0; - let invalidation_margin = - (pf_invalidation as f64 - ab_invalidation as f64) / pf_invalidation as f64 * 100.0; - println!( - "[SPLIT] base: per-file={pf_base}B atom-B={ab_base}B | chunk: per-file={}B atom-B-delta={}B", - pf_chunks[0], ab_deltas[0] - ); - println!( - "[SPLIT] session: per-file={pf_session}B atom-B={ab_session}B ({session_margin:.1}% smaller) | invalidation: per-file={pf_invalidation}B atom-B={ab_invalidation}B ({invalidation_margin:.1}% smaller)" - ); - - // Regression guard against the no-op-hoist bug: hoisting MUST have moved - // the universal atoms into the base, so the base is large and the delta - // is much smaller than a full per-file chunk. - assert!( - ab_base > pf_base + 500, - "hoist no-op: atom-B base ({ab_base}B) should hold the universal atoms, \ - but is barely larger than the empty per-file base ({pf_base}B). \ - atom_hoist was likely enabled AFTER extraction." - ); - assert!( - (ab_deltas[0] as f64) < (pf_chunks[0] as f64) * 0.6, - "hoist no-op: atom-B delta ({}B) should be far smaller than the full \ - per-file chunk ({}B) once universals are hoisted out", - ab_deltas[0], - pf_chunks[0] - ); - // The split-native wins this whole investigation hinges on. - assert!( - session_margin >= 15.0, - "atom-B should beat per-file on multi-route session bytes by >=15% \ - (got {session_margin:.1}%)" - ); - assert!( - invalidation_margin >= 30.0, - "atom-B should beat per-file on incremental-deploy invalidation by \ - >=30% (got {invalidation_margin:.1}%)" - ); + super::atom_hoist_measurement::assert_predeclared_shared_savings().unwrap(); } #[test] diff --git a/bindings/devup-ui-wasm/src/reservation.rs b/bindings/devup-ui-wasm/src/reservation.rs new file mode 100644 index 000000000..b485b671e --- /dev/null +++ b/bindings/devup-ui-wasm/src/reservation.rs @@ -0,0 +1,130 @@ +//! Rule-free reservation through the real extractor and sheet allocation path. + +use bimap::BiHashMap; +use css::{file_map, slot_reservation::ScratchSlots}; +use extractor::reservation_state::ScratchExtractionState; +use extractor::{ExtractOption, ModuleResolver, extract_with_modules, extract_without_source_map}; +use sheet::StyleSheet; +use wasm_bindgen::prelude::*; + +use crate::with_style_sheet_mut; +mod rows; +#[cfg(not(tarpaulin_include))] +use crate::{MODULE_RESOLVER, call_module_resolver, import_aliases_from_js, js_error}; + +/// Prepared physical input, keyed exactly as its later real transform. +pub struct ReservationInput { + pub filename: String, + pub code: String, + pub css_dir: String, +} + +struct ScratchSheet { + live: StyleSheet, + files: BiHashMap, +} + +impl Default for ScratchSheet { + fn default() -> Self { + let files = file_map::get_file_map(); + let live = with_style_sheet_mut(|sheet| { + let mut live = std::mem::take(sheet); + sheet.theme = std::mem::take(&mut live.theme); + live + }); + Self { live, files } + } +} + +impl Drop for ScratchSheet { + fn drop(&mut self) { + with_style_sheet_mut(|sheet| { + std::mem::swap(&mut self.live.theme, &mut sheet.theme); + *sheet = std::mem::take(&mut self.live); + }); + file_map::set_file_map(std::mem::take(&mut self.files)); + } +} + +/// Reserve in supplied physical-path order; suppress per-input errors, emitting nothing. +pub fn reserve_class_slots_internal( + inputs: &[ReservationInput], + option: ExtractOption, + resolver: Option<&ModuleResolver>, +) { + let _atom_mode = css::atom_hoist::AtomModeGuard::new(option.single_css); + let slots = ScratchSlots::default(); + let files: Vec = inputs.iter().map(|input| input.filename.clone()).collect(); + file_map::seed_file_numbers(&files); + for input in inputs { + let _ = file_map::get_file_num_by_filename(&input.filename); + } + let _sheet = ScratchSheet::default(); + let _extraction = ScratchExtractionState::default(); + for input in inputs { + let file_option = ExtractOption { + css_dir: input.css_dir.clone(), + ..option.clone() + }; + let extracted = match resolver { + Some(resolver) => { + extract_with_modules(&input.filename, &input.code, file_option, false, resolver) + } + None => extract_without_source_map(&input.filename, &input.code, file_option), + }; + if let Ok(output) = extracted { + let bucket = file_map::canonical(&input.filename); + let global = option.single_css || file_map::is_global(&input.filename); + with_style_sheet_mut(|sheet| { + sheet.rm_global_css(&input.filename, global); + sheet.update_styles_in_bucket(&output.styles, &bucket, global); + }); + } + } + slots.commit(); +} + +/// Reserve `[filename, code, cssDir]` rows using the current resolver and configuration. +#[wasm_bindgen(js_name = "reserveClassSlots")] +#[cfg(not(tarpaulin_include))] +pub fn reserve_class_slots( + rows: JsValue, + package: &str, + single_css: bool, + import_main_css_in_code: bool, + _import_main_css_in_css: bool, + import_aliases: JsValue, +) -> Result<(), JsValue> { + let rows: Vec> = serde_wasm_bindgen::from_value(rows).map_err(js_error)?; + let inputs = rows::parse_rows(rows).map_err(js_error)?; + let option = ExtractOption { + package: package.to_string(), + single_css, + import_main_css: import_main_css_in_code, + import_aliases: import_aliases_from_js(import_aliases)?, + ..ExtractOption::default() + }; + let resolver = MODULE_RESOLVER.with_borrow(Clone::clone).map(|resolver| { + move |specifier: &str, importer: &str| call_module_resolver(&resolver, specifier, importer) + }); + reserve_class_slots_internal( + &inputs, + option, + resolver + .as_ref() + .map(|resolver| resolver as &ModuleResolver), + ); + Ok(()) +} + +/// New live class and file slots since this build's first reservation; repeats do not count. +#[must_use] +#[wasm_bindgen(js_name = "getUnreservedClassSlotCount")] +pub fn get_unreserved_class_slot_count() -> usize { + css::slot_reservation::unreserved_slot_count() +} + +#[cfg(test)] +mod file_slots_tests; +#[cfg(test)] +mod tests; diff --git a/bindings/devup-ui-wasm/src/reservation/file_slots_tests.rs b/bindings/devup-ui-wasm/src/reservation/file_slots_tests.rs new file mode 100644 index 000000000..aebe21140 --- /dev/null +++ b/bindings/devup-ui-wasm/src/reservation/file_slots_tests.rs @@ -0,0 +1,301 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::tests::{TestResult, input, setup, transform}; +use super::*; +use crate::{export_class_map_internal, export_file_map_internal, seed_file_map, with_style_sheet}; +use serial_test::serial; + +static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); +const VARIABLE_CONTAINER: &str = "import {createVar,createContainer} from '@devup-ui/react'; export const variable=createVar(); export const container=createContainer();"; +const VARIABLE_LAYER: &str = "import {createVar,layer} from '@devup-ui/react'; export const variable=createVar(); export const namedLayer=layer();"; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> io::Result { + loop { + let path = std::env::temp_dir().join(format!( + "devup-raw-slots-{}-{}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) + )); + match fs::create_dir(&path) { + Ok(()) => return Ok(Self(path)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + } + } + + fn sources(&self) -> io::Result> { + [ + ("A.css.ts", VARIABLE_CONTAINER), + ("B.css.ts", VARIABLE_LAYER), + ] + .into_iter() + .map(|(name, code)| { + let path = self.0.join(name); + fs::write(&path, code)?; + Ok(input(&path.to_string_lossy(), &fs::read_to_string(&path)?)) + }) + .collect() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let removed = fs::remove_dir_all(&self.0); + assert!(removed.is_ok(), "fixture cleanup failed: {removed:?}"); + } +} + +#[derive(Debug, PartialEq, Eq)] +struct Built { + sources: BTreeMap)>, + css: Vec, + files: String, +} + +fn build( + inputs: &[ReservationInput], + order: [usize; 2], +) -> Result> { + let (_reset, option) = setup(false); + file_map::set_canonical_map( + inputs + .iter() + .map(|input| (input.filename.clone(), "bucket.tsx".into())) + .collect(), + ); + seed_file_map(inputs.iter().map(|input| input.filename.clone()).collect()); + reserve_class_slots_internal(inputs, option.clone(), None); + let mut sources = BTreeMap::new(); + for index in order { + let output = transform(&inputs[index], &option)?; + assert!(output.code.contains("var(--")); + assert!(!output.code.contains("createVar(")); + sources.insert(inputs[index].filename.clone(), (output.code, output.map)); + } + assert_eq!(export_class_map_internal()?, "{}"); + assert_eq!(get_unreserved_class_slot_count(), 0); + Ok(Built { + sources, + css: with_style_sheet(|sheet| { + vec![ + sheet.create_css(None, false), + sheet.create_css(Some("bucket.tsx"), false), + ] + }), + files: export_file_map_internal()?, + }) +} + +#[test] +#[serial] +fn physical_raw_only_outputs_match_when_callbacks_reverse() -> TestResult { + // Given: identical owned physical files, both collapsed into one canonical bucket. + let fixture = Fixture::new()?; + let inputs = fixture.sources()?; + // When: complete reservation, followed by genuine extraction in opposite orders. + let ab = build(&inputs, [0, 1])?; + let ba = build(&inputs, [1, 0])?; + // Then: actual source/maps/CSS/file assignments match, without class allocations. + assert_eq!(ab, ba); + Ok(()) +} + +#[test] +#[serial] +fn raw_virtual_slots_are_counted_once_with_legacy_spelling() -> TestResult { + // Given + let (_reset, option) = setup(false); + seed_file_map(vec!["canonical.tsx".into()]); + assert_eq!(file_map::get_file_num_by_filename("legacy.css.ts"), 1); + assert_eq!(get_unreserved_class_slot_count(), 0); + reserve_class_slots_internal(&[], option.clone(), None); + seed_file_map(vec!["watch-bucket.tsx".into()]); + assert_eq!(get_unreserved_class_slot_count(), 0); + let original = file_map::get_file_map(); + let virtual_input = input( + "virtual.css.ts", + "import {createVar} from '@devup-ui/react'; export const variable=createVar();", + ); + // When + let first = transform(&virtual_input, &option)?; + let repeated = transform(&virtual_input, &option)?; + // Then + assert_eq!(first.code, repeated.code); + assert_eq!(get_unreserved_class_slot_count(), 1); + assert_eq!(export_class_map_internal()?, "{}"); + reserve_class_slots_internal( + &[input("watch.css.ts", VARIABLE_CONTAINER)], + option.clone(), + None, + ); + assert_eq!(get_unreserved_class_slot_count(), 1); + assert_eq!(css::sheet_to_variable_name("gap", 0, None), "--a"); + assert_eq!(get_unreserved_class_slot_count(), 2); + crate::reset_build_state_internal(); + file_map::set_file_map(original); + assert_eq!(transform(&virtual_input, &option)?.code, first.code); + assert_eq!(get_unreserved_class_slot_count(), 0); + Ok(()) +} + +fn watch_build( + fixture: &Fixture, + initial: &[ReservationInput], + order: [usize; 3], +) -> Result> { + let (_reset, option) = setup(false); + file_map::set_canonical_map( + initial + .iter() + .map(|input| (input.filename.clone(), "bucket.tsx".into())) + .collect(), + ); + reserve_class_slots_internal(initial, option.clone(), None); + for input in initial { + transform(input, &option)?; + } + let previous = file_map::get_file_map(); + let mut revised = Vec::new(); + for (name, code) in [ + ("0-C.css.ts", VARIABLE_CONTAINER), + ("0-D.css.ts", VARIABLE_LAYER), + ] { + let path = fixture.0.join(name); + fs::write(&path, code)?; + revised.push(input(&path.to_string_lossy(), &fs::read_to_string(&path)?)); + } + fs::write( + &initial[0].filename, + format!("{VARIABLE_CONTAINER} export const added=createVar();"), + )?; + revised.push(input( + &initial[0].filename, + &fs::read_to_string(&initial[0].filename)?, + )); + let mut canonical = file_map::get_canonical_map(); + for input in revised.iter().take(2) { + canonical.insert(input.filename.clone(), "watch-bucket.tsx".into()); + } + file_map::set_canonical_map(canonical); + seed_file_map(revised.iter().map(|input| input.filename.clone()).collect()); + assert_eq!(get_unreserved_class_slot_count(), 0); + reserve_class_slots_internal(&revised, option.clone(), None); + for (filename, ordinal) in &previous { + assert_eq!( + file_map::get_file_map().get_by_left(filename), + Some(ordinal) + ); + } + let mut sources = BTreeMap::new(); + for index in order { + let output = transform(&revised[index], &option)?; + sources.insert(revised[index].filename.clone(), (output.code, output.map)); + } + assert_eq!(get_unreserved_class_slot_count(), 0); + Ok(Built { + sources, + files: export_file_map_internal()?, + css: with_style_sheet(|sheet| { + vec![ + sheet.create_css(None, false), + sheet.create_css(Some("bucket.tsx"), false), + ] + }), + }) +} + +#[test] +#[serial] +fn path_sorted_watch_append_preserves_old_raw_and_canonical_ordinals() -> TestResult { + // Given + let fixture = Fixture::new()?; + let initial = fixture.sources()?; + // When + let forward = watch_build(&fixture, &initial, [0, 1, 2])?; + let reverse = watch_build(&fixture, &initial, [2, 1, 0])?; + // Then + assert_eq!(forward, reverse); + Ok(()) +} + +#[test] +#[serial] +fn unknown_scratch_supplier_file_slots_do_not_persist() -> TestResult { + // Given + let (_reset, option) = setup(false); + let resolver = |_: &str, _: &str| { + Some(extractor::ResolvedModule { + path: "supplier.css.ts".into(), + code: "import {createVar} from '@devup-ui/react'; export const token=createVar();" + .into(), + }) + }; + let root = input( + "root.css.ts", + "import {style} from '@devup-ui/react'; import {token} from './supplier.css'; export const own=style({ color: token });", + ); + // When + reserve_class_slots_internal(&[root], option, Some(&resolver)); + // Then + assert_eq!( + file_map::get_file_map().get_by_left("root.css.ts"), + Some(&0) + ); + assert_eq!( + file_map::get_file_map().get_by_left("supplier.css.ts"), + None + ); + assert_eq!(get_unreserved_class_slot_count(), 0); + Ok(()) +} + +#[test] +#[serial] +fn unwind_rolls_back_inventory_raw_slots_and_previous_residual_count() -> TestResult { + // Given + let (_reset, option) = setup(false); + reserve_class_slots_internal(&[], option.clone(), None); + transform(&input("virtual.css.ts", VARIABLE_CONTAINER), &option)?; + let before = ( + export_file_map_internal()?, + export_class_map_internal()?, + crate::export_sheet_internal()?, + get_unreserved_class_slot_count(), + ); + let resolver = + |_: &str, _: &str| -> Option { panic!("supplier failure") }; + // When + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + reserve_class_slots_internal( + &[ + input("new.css.ts", VARIABLE_LAYER), + input( + "root.css.ts", + "import {style} from '@devup-ui/react'; import {base} from './supplier.css'; export const own=style(base);", + ), + ], + option, + Some(&resolver), + ); + })); + // Then + assert!(result.is_err()); + assert_eq!( + ( + export_file_map_internal()?, + export_class_map_internal()?, + crate::export_sheet_internal()?, + get_unreserved_class_slot_count() + ), + before + ); + Ok(()) +} diff --git a/bindings/devup-ui-wasm/src/reservation/rows.rs b/bindings/devup-ui-wasm/src/reservation/rows.rs new file mode 100644 index 000000000..86823f071 --- /dev/null +++ b/bindings/devup-ui-wasm/src/reservation/rows.rs @@ -0,0 +1,124 @@ +use std::fmt; + +use super::ReservationInput; + +#[derive(Debug, PartialEq, Eq)] +pub(super) struct RowArityError { + row: usize, + fields: usize, +} + +impl fmt::Display for RowArityError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "reservation row {} must contain exactly 3 strings, got {} fields", + self.row, self.fields + ) + } +} + +impl std::error::Error for RowArityError {} + +pub(super) fn parse_rows(rows: Vec>) -> Result, RowArityError> { + rows.into_iter() + .enumerate() + .map(|(index, row)| { + let [filename, code, css_dir]: [String; 3] = + row.try_into().map_err(|row: Vec| RowArityError { + row: index, + fields: row.len(), + })?; + Ok(ReservationInput { + filename, + code, + css_dir, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::reservation::tests::{TestResult, input, setup, transform}; + use serial_test::serial; + + #[test] + fn exact_three_fields_convert_without_changing_values() -> TestResult { + // Given + let row = vec!["a.tsx".into(), "source".into(), "./css".into()]; + // When + let inputs = parse_rows(vec![row])?; + // Then + assert_eq!(inputs.len(), 1); + assert_eq!( + (&*inputs[0].filename, &*inputs[0].code, &*inputs[0].css_dir), + ("a.tsx", "source", "./css") + ); + assert_eq!(parse_rows(vec![])?.len(), 0); + Ok(()) + } + + #[test] + fn two_and_four_fields_are_rejected() { + // Given / When / Then + for fields in [2, 4] { + let error = parse_rows(vec![vec![String::new(); fields]]).err(); + assert_eq!(error, Some(RowArityError { row: 0, fields })); + } + assert_eq!( + RowArityError { row: 1, fields: 4 }.to_string(), + "reservation row 1 must contain exactly 3 strings, got 4 fields" + ); + } + + #[test] + #[serial] + fn later_invalid_row_prevents_entire_packet_mutation() -> TestResult { + // Given + let (_reset, option) = setup(true); + crate::register_theme_internal(serde_json::from_value( + serde_json::json!({"colors":{"default":{"brand":"red"}}}), + )?); + let existing = input( + "existing.tsx", + "import {Box} from '@devup-ui/react'; ;", + ); + super::super::reserve_class_slots_internal(&[existing], option.clone(), None); + transform( + &input( + "existing.tsx", + "import {Box} from '@devup-ui/react'; ;", + ), + &option, + )?; + let _ = css::file_map::get_file_num_by_filename("existing-virtual"); + let state = || -> Result<_, Box> { + Ok(( + crate::export_class_map_internal()?, + crate::export_file_map_internal()?, + crate::export_sheet_internal()?, + crate::with_style_sheet(|sheet| sheet.create_css(None, false)), + crate::with_style_sheet(|sheet| serde_json::to_string(&sheet.theme))?, + super::super::get_unreserved_class_slot_count(), + )) + }; + let before = state()?; + let rows = vec![ + vec![ + "valid.tsx".into(), + "import {Box} from '@devup-ui/react'; ;".into(), + "./css".into(), + ], + vec!["invalid.tsx".into(), "source".into()], + ]; + // When + let result = parse_rows(rows) + .map(|inputs| super::super::reserve_class_slots_internal(&inputs, option, None)); + // Then + assert_eq!(result, Err(RowArityError { row: 1, fields: 2 })); + assert_eq!(state()?, before); + Ok(()) + } +} diff --git a/bindings/devup-ui-wasm/src/reservation/tests.rs b/bindings/devup-ui-wasm/src/reservation/tests.rs new file mode 100644 index 000000000..d22cf015b --- /dev/null +++ b/bindings/devup-ui-wasm/src/reservation/tests.rs @@ -0,0 +1,459 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::error::Error; + +use super::*; +use crate::{ + code_extract_internal, code_extract_with_modules_internal, export_class_map_internal, + export_file_map_internal, export_sheet_internal, register_theme_internal, + reset_build_state_internal, seed_file_map, with_style_sheet, +}; +use serial_test::serial; + +pub(super) type TestResult = Result<(), Box>; + +pub(super) struct Reset; +impl Drop for Reset { + fn drop(&mut self) { + reset_build_state_internal(); + register_theme_internal(sheet::theme::Theme::default()); + css::set_custom_shorthands(BTreeMap::new()); + css::debug::set_debug(false); + } +} + +pub(super) fn setup(single_css: bool) -> (Reset, ExtractOption) { + reset_build_state_internal(); + css::debug::set_debug(false); + register_theme_internal(sheet::theme::Theme::default()); + ( + Reset, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) +} + +pub(super) fn input(filename: &str, code: &str) -> ReservationInput { + ReservationInput { + filename: filename.into(), + code: code.into(), + css_dir: "./css".into(), + } +} + +pub(super) fn transform( + input: &ReservationInput, + option: &ExtractOption, +) -> Result { + code_extract_internal( + &input.filename, + &input.code, + &option.package, + input.css_dir.clone(), + option.single_css, + option.import_main_css, + true, + option.import_aliases.clone(), + ) +} + +const A: &str = "import {Box,keyframes,globalCss} from '@devup-ui/react'; const spin=keyframes({from:{opacity:0},to:{opacity:1}}); globalCss({body:{margin:0}}); export const A=(value:string)=>;"; +const B: &str = "import {Box,keyframes} from '@devup-ui/react'; const fade=keyframes({from:{opacity:1},to:{opacity:0}}); export const B=(value:string)=>;"; + +#[derive(Debug, PartialEq, Eq)] +struct Built { + sources: BTreeMap)>, + css: Vec, + slots: String, + files: String, +} + +fn build(order: [usize; 2], single: bool, hoist: bool) -> Result> { + let (_reset, option) = setup(single); + file_map::set_canonical_map(HashMap::from([ + ("a.tsx".into(), "bucket.tsx".into()), + ("b.tsx".into(), "bucket.tsx".into()), + ])); + if hoist { + css::atom_hoist::set_atom_hoist(Some(2)); + css::file_routes::set_file_routes(HashMap::from([( + "bucket.tsx".into(), + HashSet::from([0, 1]), + )])); + } + seed_file_map(vec!["a.tsx".into(), "b.tsx".into()]); + let inputs = [input("a.tsx", A), input("b.tsx", B)]; + reserve_class_slots_internal(&inputs, option.clone(), None); + assert_eq!(get_unreserved_class_slot_count(), 0); + let mut sources = BTreeMap::new(); + for index in order { + let output = transform(&inputs[index], &option)?; + assert!(!output.code.contains(" TestResult { + // Given / When / Then: same physical reservation, opposite real extraction order. + for (single, hoist) in [(true, false), (true, true), (false, false), (false, true)] { + assert_eq!(build([0, 1], single, hoist)?, build([1, 0], single, hoist)?); + } + assert_eq!(build([0, 1], true, false)?, build([0, 1], true, true)?); + Ok(()) +} + +#[test] +#[serial] +fn reservation_preserves_nonempty_sheet_theme_and_unchanged_registries() -> TestResult { + // Given + let (_reset, option) = setup(true); + let mut theme: sheet::theme::Theme = serde_json::from_value( + serde_json::json!({"colors":{"default":{"brand":"red"}},"length":{"default":{"size":["8px",null,"12px"]}},"shadow":{"default":{"card":["0 0 1px red",null,"0 0 2px red"]}},"typography":{"heading":{"fontSize":"20px"}}}), + )?; + theme.tailwind_css = "@theme { --color-custom: #123; }".into(); + register_theme_internal(theme); + css::set_custom_shorthands(BTreeMap::from([("customGap".into(), vec!["gap".into()])])); + css::set_prefix(Some("du-".into())); + transform(&input("live.tsx", A), &option)?; + with_style_sheet_mut(|sheet| { + sheet.add_import("live.tsx", "url(font.css)"); + sheet.add_font_face( + "live.tsx", + &BTreeMap::from([("font-family".into(), "Existing".into())]), + ); + sheet.add_css("live.tsx", "body { color: red; }"); + }); + let before = export_sheet_internal()?; + let theme = with_style_sheet(|sheet| serde_json::to_string(&sheet.theme))?; + let mut files = file_map::get_file_map(); + files.insert("unused.tsx".into(), files.len()); + let canonical = file_map::get_canonical_map(); + let routes = css::file_routes::get_file_routes(); + let registry = css::theme_tokens::get_responsive_theme_token("$size"); + // When + reserve_class_slots_internal( + &[input( + "unused.tsx", + "import {Box} from '@devup-ui/react'; ;", + )], + option, + None, + ); + // Then + assert_eq!(export_sheet_internal()?, before); + assert_eq!( + with_style_sheet(|sheet| serde_json::to_string(&sheet.theme))?, + theme + ); + assert_eq!(file_map::get_file_map(), files); + assert_eq!(file_map::get_canonical_map(), canonical); + assert_eq!(css::file_routes::get_file_routes(), routes); + assert_eq!( + css::theme_tokens::get_responsive_theme_token("$size"), + registry + ); + assert_eq!(css::theme_tokens::get_typography_keys(), vec!["heading"]); + assert!(css::theme_tokens::is_color_token("brand")); + assert_eq!( + css::tailwind_definitions::theme_value("color", "custom"), + Some("#123".into()) + ); + assert_eq!(css::get_custom_shorthand_names(), vec!["customGap"]); + assert_eq!(css::get_prefix(), Some("du-".into())); + assert!(!css::debug::is_debug()); + assert_eq!(css::atom_hoist::atom_hoist_threshold(), None); + assert_eq!(get_unreserved_class_slot_count(), 0); + let token_input = input( + "unused.tsx", + "import {Box} from '@devup-ui/react'; ;", + ); + transform( + &token_input, + &ExtractOption { + single_css: true, + ..ExtractOption::default() + }, + )?; + assert_eq!(get_unreserved_class_slot_count(), 0); + Ok(()) +} + +#[test] +#[serial] +fn invalid_inputs_do_not_emit_or_stop_valid_suffix_and_real_error_stays() -> TestResult { + // Given + let (_reset, option) = setup(true); + let bad = input( + "bad.tsx", + "import {css} from '@devup-ui/react'; const value=props.value; css({ color: value });", + ); + let error = transform(&bad, &option) + .err() + .ok_or("expected located build error")?; + let before = export_sheet_internal()?; + let valid = input( + "valid.tsx", + "import {Box} from '@devup-ui/react'; ;", + ); + // When + reserve_class_slots_internal(&[input("invalid.ext", A), bad, valid], option.clone(), None); + // Then + assert_eq!(export_sheet_internal()?, before); + let output = transform( + &input( + "valid.tsx", + "import {Box} from '@devup-ui/react'; ;", + ), + &option, + )?; + assert!(output.css.is_some()); + assert_eq!(get_unreserved_class_slot_count(), 0); + assert_eq!( + transform( + &input( + "bad.tsx", + "import {css} from '@devup-ui/react'; const value=props.value; css({ color: value });" + ), + &option + ) + .err(), + Some(error) + ); + Ok(()) +} + +#[test] +#[serial] +fn residual_names_repeat_and_watch_reservation_keeps_cumulative_count() -> TestResult { + // Given + let (_reset, option) = setup(true); + reserve_class_slots_internal(&[], option.clone(), None); + let generated = input( + "virtual.tsx", + "import {Box} from '@devup-ui/react'; ;", + ); + // When + let first = transform(&generated, &option)?; + let count = get_unreserved_class_slot_count(); + let repeat = transform(&generated, &option)?; + reserve_class_slots_internal( + &[input( + "watch.tsx", + "import {Box} from '@devup-ui/react'; ;", + )], + option.clone(), + None, + ); + // Then + assert!(count > 0); + assert_eq!(get_unreserved_class_slot_count(), count); + assert_eq!(first.code, repeat.code); + assert!(first.code.contains("className=\"a\"") && first.code.contains("--b")); + reset_build_state_internal(); + let legacy = transform(&generated, &option)?; + assert_eq!(legacy.code, first.code); + assert_eq!(get_unreserved_class_slot_count(), 0); + Ok(()) +} + +#[test] +#[serial] +fn unwind_restores_sheet_files_and_allocator_after_nested_resolver() -> TestResult { + // Given + let (_reset, option) = setup(false); + transform(&input("live.tsx", A), &option)?; + let before = ( + export_sheet_internal()?, + export_class_map_internal()?, + export_file_map_internal()?, + ); + let resolver = + |_: &str, _: &str| -> Option { panic!("resolver unwind") }; + // When + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + reserve_class_slots_internal( + &[ + input("first.tsx", B), + input( + "root.css.ts", + "import {style} from '@devup-ui/react'; import {base} from './supplier.css'; export const own=style(base);", + ), + ], + option, + Some(&resolver), + ); + })); + // Then + assert!(result.is_err()); + assert_eq!( + ( + export_sheet_internal()?, + export_class_map_internal()?, + export_file_map_internal()? + ), + before + ); + assert_eq!(get_unreserved_class_slot_count(), 0); + Ok(()) +} + +#[test] +#[serial] +fn nested_supplier_reservation_is_reused_by_two_roots() -> TestResult { + // Given + let (_reset, option) = setup(false); + seed_file_map(vec![ + "supplier.css.ts".into(), + "a.css.ts".into(), + "b.css.ts".into(), + ]); + let resolver = |specifier: &str, _: &str| { + (specifier == "./supplier.css").then(|| extractor::ResolvedModule { + path: "supplier.css.ts".into(), + code: "import {style} from '@devup-ui/react'; export const base=style({color:'red'});" + .into(), + }) + }; + let source = "import {style} from '@devup-ui/react'; import {base} from './supplier.css'; export const own=style([base,{width:'12px'}]);"; + let inputs = [input("a.css.ts", source), input("b.css.ts", source)]; + // When + reserve_class_slots_internal(&inputs, option.clone(), Some(&resolver)); + for input in inputs.iter().rev() { + let output = code_extract_with_modules_internal( + &input.filename, + &input.code, + &option.package, + input.css_dir.clone(), + false, + false, + true, + HashMap::new(), + &resolver, + )?; + assert!(output.code.contains("export const own")); + } + // Then + assert_eq!(get_unreserved_class_slot_count(), 0); + Ok(()) +} + +#[test] +#[serial] +fn resolved_token_keys_are_reserved_before_real_output() -> TestResult { + // Given + let (_reset, option) = setup(true); + register_theme_internal(serde_json::from_value( + serde_json::json!({"length":{"default":{"size":["8px",null,"12px"]}},"shadow":{"default":{"card":["0 0 1px red",null,"0 0 2px red"]}}}), + )?); + let token = input( + "tokens.tsx", + "import {Box} from '@devup-ui/react'; ;", + ); + // When + reserve_class_slots_internal(&[token], option.clone(), None); + // Then + let reserved_slots: usize = css::class_map::get_class_map() + .values() + .map(HashMap::len) + .sum(); + assert_eq!(reserved_slots, 2); + let output = transform( + &input( + "tokens.tsx", + "import {Box} from '@devup-ui/react'; ;", + ), + &option, + )?; + let css = output.css.ok_or("expected token CSS")?; + assert!(css.contains("width:8px")); + assert!(css.contains("box-shadow:0 0 1px red")); + assert!(!css.contains("$size")); + assert!(!css.contains("$card")); + assert_eq!(get_unreserved_class_slot_count(), 0); + Ok(()) +} + +#[test] +#[serial] +fn debug_configuration_stays_unchanged_when_reserving() -> TestResult { + // Given + let (_reset, option) = setup(true); + css::debug::set_debug(true); + let before = css::class_map::get_class_map(); + // When + reserve_class_slots_internal( + &[input("z.tsx", B), input("a.tsx", A)], + option.clone(), + None, + ); + // Then + assert!(css::debug::is_debug()); + assert_eq!(css::class_map::get_class_map(), before); + assert_eq!(get_unreserved_class_slot_count(), 0); + assert!( + transform(&input("a.tsx", A), &option)? + .code + .contains("color-0-red") + ); + Ok(()) +} + +#[test] +#[serial] +fn supplied_physical_order_is_not_resorted_by_engine_id() -> TestResult { + // Given + let (_reset, option) = setup(true); + let red = input( + "z.tsx", + "import {Box} from '@devup-ui/react'; ;", + ); + let blue = input( + "a.tsx", + "import {Box} from '@devup-ui/react'; ;", + ); + // When + reserve_class_slots_internal(&[red, blue], option.clone(), None); + // Then + assert!( + transform( + &input( + "z.tsx", + "import {Box} from '@devup-ui/react'; ;" + ), + &option + )? + .code + .contains("className=\"a\"") + ); + assert!( + transform( + &input( + "a.tsx", + "import {Box} from '@devup-ui/react'; ;" + ), + &option + )? + .code + .contains("className=\"b\"") + ); + assert_eq!(get_unreserved_class_slot_count(), 0); + Ok(()) +} diff --git a/libs/css/src/atom_counter_tests.rs b/libs/css/src/atom_counter_tests.rs new file mode 100644 index 000000000..a48143732 --- /dev/null +++ b/libs/css/src/atom_counter_tests.rs @@ -0,0 +1,107 @@ +use super::*; +use serial_test::serial; +use std::collections::{HashMap, HashSet}; + +fn setup() { + class_map::reset_class_map(); + file_map::reset_file_map(); + file_map::reset_canonical_map(); + atom_hoist::restore_atom_plan(None); + atom_hoist::set_atom_hoist(Some(2)); + file_routes::set_file_routes(HashMap::from([("shared".into(), HashSet::from([0, 1]))])); + set_prefix(None); + debug::set_debug(false); +} + +#[test] +#[serial] +fn w66_atom_symbols_share_short_global_slots_and_canonical_local_slots() { + // Given + setup(); + file_map::set_canonical_map(HashMap::from([("alias".into(), "private".into())])); + // When / Then + assert_eq!( + sheet_to_classname("color", 0, Some("red"), None, None, Some("shared")), + "a" + ); + assert_eq!(sheet_to_variable_name("color", 0, None), "--b"); + assert_eq!(keyframes_to_keyframes_name("frames", None), "c"); + assert_eq!( + sheet_to_classname("color", 0, Some("red"), None, None, Some("alias")), + "a-a" + ); + assert_eq!( + sheet_to_classname("color", 0, Some("red"), None, None, Some("private")), + "a-a" + ); + assert_eq!( + keyframes_to_keyframes_name("frames", Some("private")), + "a-b" + ); + assert_eq!(keyframes_to_keyframes_name("frames", Some("shared")), "b-a"); + assert_eq!(class_map::get_class_map()[""].len(), 3); + assert_eq!(class_map::get_class_map()["private"].len(), 2); + atom_hoist::set_atom_hoist(None); + atom_hoist::restore_atom_plan(None); + class_map::reset_class_map(); + file_map::reset_file_map(); + file_map::reset_canonical_map(); + file_routes::reset_file_routes(); +} + +#[test] +#[serial] +fn w66_atom_debug_uses_the_same_prefixed_counters() { + // Given + let build = |debug| { + setup(); + set_prefix(Some("du-".into())); + debug::set_debug(debug); + // When + ( + sheet_to_classname("color", 0, Some("red"), None, None, Some("shared")), + sheet_to_variable_name("color", 0, None), + keyframes_to_keyframes_name("frames", Some("private")), + ) + }; + // Then + let expected = ("du-a".into(), "--du-b".into(), "du-a-a".into()); + assert_eq!(build(false), expected); + assert_eq!(build(true), expected); + setup(); + atom_hoist::set_atom_hoist(None); + file_routes::reset_file_routes(); +} + +#[test] +#[serial] +fn atom_counter_keys_preserve_value_boundaries_and_placement_identity() { + // Given + setup(); + // When + let names = [ + sheet_to_classname("a-b", 0, Some("c"), None, None, None), + sheet_to_classname("a", 0, Some("b-c"), None, None, None), + sheet_to_classname("color", 0, None, None, None, None), + sheet_to_classname("color", 0, Some(""), None, None, None), + sheet_to_classname("color", 0, Some("red"), None, None, None), + sheet_to_classname("color", 0, Some("red"), None, None, Some("shared")), + keyframes_to_keyframes_name("color", None), + sheet_to_variable_name("color", 0, None) + .trim_start_matches("--") + .to_string(), + ]; + // Then + assert_eq!(names, ["a", "b", "c", "d", "e", "f", "g", "h"]); + assert_eq!( + sheet_to_classname("color", 0, Some("red"), None, Some(0), Some("private")), + "i" + ); + assert_eq!( + sheet_to_classname("color", 0, Some("red"), None, Some(0), None), + "i" + ); + setup(); + atom_hoist::set_atom_hoist(None); + file_routes::reset_file_routes(); +} diff --git a/libs/css/src/atom_hoist.rs b/libs/css/src/atom_hoist.rs index 259e4a6a7..ddf039962 100644 --- a/libs/css/src/atom_hoist.rs +++ b/libs/css/src/atom_hoist.rs @@ -1,8 +1,97 @@ +use std::cell::Cell; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::marker::PhantomData; +use std::rc::Rc; 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)); + +thread_local! { + static SUPPRESSED: Cell = const { Cell::new(false) }; +} + +/// Suppress effective atom mode for actual single-CSS work without changing build configuration. +#[must_use] +pub struct AtomModeGuard { + previous: bool, + thread_bound: PhantomData>, +} + +impl AtomModeGuard { + pub fn new(single_css: bool) -> Self { + let previous = SUPPRESSED.with(|state| state.replace(state.get() || single_css)); + Self { + previous, + thread_bound: PhantomData, + } + } +} + +impl Drop for AtomModeGuard { + fn drop(&mut self) { + SUPPRESSED.with(|state| state.set(self.previous)); + } +} + +/// Freeze every eligible canonical bucket before any source is processed. +pub fn freeze_atom_plan() { + if !is_atom_hoist() { + return; + } + 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 { + if !is_atom_hoist() { + return false; + } + 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)] @@ -22,7 +111,7 @@ pub fn atom_hoist_threshold() -> Option { #[inline(always)] #[must_use] pub fn is_atom_hoist() -> bool { - ATOM_HOIST_THRESHOLD.load(Ordering::Relaxed) != 0 + ATOM_HOIST_THRESHOLD.load(Ordering::Relaxed) != 0 && !SUPPRESSED.with(Cell::get) } #[cfg(test)] @@ -42,4 +131,55 @@ mod tests { set_atom_hoist(None); assert!(!is_atom_hoist()); } + + #[test] + #[serial] + fn suppression_restores_nested_mode_and_preserves_requested_plan() { + // Given + set_atom_hoist(Some(2)); + restore_atom_plan(Some(BTreeSet::from(["shared".into()]))); + let plan = atom_plan(); + // When + { + let _outer = AtomModeGuard::new(true); + assert!(!is_atom_hoist()); + let _inner = AtomModeGuard::new(false); + assert!(!is_atom_hoist()); + assert!(!is_hoisted_bucket("shared")); + freeze_atom_plan(); + assert_eq!(atom_hoist_threshold(), Some(2)); + } + // Then + assert!(is_atom_hoist()); + assert_eq!(atom_plan(), plan); + assert!(is_hoisted_bucket("shared")); + set_atom_hoist(None); + restore_atom_plan(None); + } + + #[test] + #[serial] + fn suppression_restores_after_error_and_unwind_without_freezing() { + // Given + set_atom_hoist(Some(2)); + restore_atom_plan(None); + let error = || -> Result<(), &'static str> { + let _guard = AtomModeGuard::new(true); + freeze_atom_plan(); + Err("test boundary") + }; + // When + assert_eq!(error(), Err("test boundary")); + assert!(is_atom_hoist()); + let unwind = std::panic::catch_unwind(|| { + let _guard = AtomModeGuard::new(true); + freeze_atom_plan(); + panic!("test unwind"); + }); + // Then + assert!(unwind.is_err()); + assert!(is_atom_hoist()); + assert_eq!(atom_plan(), None); + set_atom_hoist(None); + } } diff --git a/libs/css/src/atom_name.rs b/libs/css/src/atom_name.rs new file mode 100644 index 000000000..cb5382806 --- /dev/null +++ b/libs/css/src/atom_name.rs @@ -0,0 +1,72 @@ +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 +} + +/// Lossless placement identity, separate from the emitted counter namespace. +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))), + } +} + +pub(crate) fn counter_name(identity: &str, filename: Option<&str>) -> String { + let filename = filename.map(canonical); + let slot = crate::class_num_for_key(filename.as_deref().unwrap_or_default(), |key| { + key.push_str(identity); + }); + crate::with_prefix(|prefix| match filename { + Some(file) => format!( + "{prefix}{}-{slot}", + crate::num_to_nm_base::num_to_nm_base(crate::file_map::get_file_num_by_filename(&file)) + ), + None => format!("{prefix}{slot}"), + }) +} + +/// 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/file_map.rs b/libs/css/src/file_map.rs index f0a20a939..b16a66335 100644 --- a/libs/css/src/file_map.rs +++ b/libs/css/src/file_map.rs @@ -88,6 +88,7 @@ pub fn get_file_num_by_filename(filename: &str) -> usize { } else { let file_num = map.len(); map.insert(filename.to_string(), file_num); + crate::slot_reservation::record_insert(); file_num } }) @@ -99,6 +100,7 @@ pub fn get_file_num_by_filename(filename: &str) -> usize { /// not on which file a worker reaches first; files that appear later (in /// development) are numbered after the existing ones. pub fn seed_file_numbers(files: &[String]) { + let _reservation = crate::slot_reservation::FileSeedGuard::default(); let mut sorted: Vec = files.iter().map(|file| canonical(file)).collect(); sorted.sort_unstable(); sorted.dedup(); 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/lib.rs b/libs/css/src/lib.rs index d830648b6..328a6646a 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -1,5 +1,8 @@ pub mod at_rule; +#[cfg(test)] +mod atom_counter_tests; pub mod atom_hoist; +pub mod atom_name; pub mod class_map; mod constant; pub mod debug; @@ -11,6 +14,7 @@ pub mod optimize_multi_css_value; pub mod optimize_value; pub mod rm_css_comment; mod selector_separator; +pub mod slot_reservation; pub mod style_selector; pub mod tailwind_definitions; pub mod theme_tokens; @@ -417,6 +421,7 @@ fn class_num_for_key(filename_key: &str, build_key: impl FnOnce(&mut String)) -> } else { let len = file_entry.len(); file_entry.insert(key.clone(), len); + slot_reservation::record_insert(); num_to_nm_base(len) } } else { @@ -428,6 +433,7 @@ fn class_num_for_key(filename_key: &str, build_key: impl FnOnce(&mut String)) -> let mut inner = std::collections::HashMap::with_capacity(1); inner.insert(key.clone(), 0); map.insert(filename_key.to_string(), inner); + slot_reservation::record_insert(); num_to_nm_base(0) } }) @@ -436,6 +442,17 @@ fn class_num_for_key(filename_key: &str, build_key: impl FnOnce(&mut String)) -> #[must_use] pub fn keyframes_to_keyframes_name(keyframes: &str, filename: Option<&str>) -> String { + if atom_hoist::is_atom_hoist() { + // Keyframes remain in their file chunk, even when declarations are hoisted. + let scope = filename.map_or_else( + || "g".to_string(), + |file| format!("l-{}", atom_name::hex(&file_map::canonical(file))), + ); + return atom_name::counter_name( + &format!("k1-{scope}-{}", atom_name::hex(keyframes)), + filename, + ); + } with_prefix(|prefix| { if is_debug() { let mut result = String::with_capacity(prefix.len() + 2 + keyframes.len()); @@ -545,6 +562,23 @@ pub fn sheet_to_classname( // `$primary`) borrow straight from `value` with zero allocation, and the // key/result builders below only ever read `&optimized` as `&str`. let optimized = value.map_or(Cow::Borrowed(""), optimize_value); + if atom_hoist::is_atom_hoist() { + let scope = atom_name::scope(filename); + let value = match value { + None => "n".to_string(), + Some(_) => format!("s-{}", atom_name::hex(&optimized)), + }; + let identity = format!( + "a1-{scope}-{}-{level}-{value}-{}-{}", + atom_name::hex(property.trim()), + atom_name::hex(selector.unwrap_or_default()), + style_order.unwrap_or(255), + ); + return atom_name::counter_name( + &identity, + filename.filter(|file| !atom_hoist::is_hoisted_bucket(file)), + ); + } if is_debug() { let selector = selector.unwrap_or_default().trim(); let encoded = if selector.is_empty() { @@ -637,6 +671,14 @@ fn write_u8(s: &mut String, v: u8) { #[must_use] pub fn sheet_to_variable_name(property: &str, level: u8, selector: Option<&str>) -> String { + if atom_hoist::is_atom_hoist() { + let identity = format!( + "v1-{}-{level}-{}", + atom_name::hex(property.trim()), + atom_name::hex(selector.unwrap_or_default()) + ); + return format!("--{}", atom_name::counter_name(&identity, None)); + } if is_debug() { let selector = selector.unwrap_or_default().trim(); let encoded = if selector.is_empty() { diff --git a/libs/css/src/slot_reservation.rs b/libs/css/src/slot_reservation.rs new file mode 100644 index 000000000..f6a1d0677 --- /dev/null +++ b/libs/css/src/slot_reservation.rs @@ -0,0 +1,181 @@ +//! Tracks live allocator misses without changing counter keys or spelling. + +use std::collections::HashMap; + +use crate::class_map::{get_class_map, set_class_map}; +use crate::file_map::{get_file_map, set_file_map}; + +#[derive(Clone, Copy, Default)] +enum Phase { + #[default] + Legacy, + Scratch, + Live, +} + +#[derive(Clone, Copy, Default)] +struct State { + phase: Phase, + misses: usize, +} + +#[cfg_attr(target_arch = "wasm32", path = "slot_reservation/state_wasm.rs")] +#[cfg_attr(not(target_arch = "wasm32"), path = "slot_reservation/state_native.rs")] +mod state; + +#[cfg(all(test, not(target_arch = "wasm32")))] +#[path = "slot_reservation/state_wasm.rs"] +mod wasm_state_for_tests; + +use state::with_state; + +pub(crate) fn record_insert() { + with_state(|state| match state.phase { + Phase::Legacy | Phase::Scratch => {} + Phase::Live => state.misses = state.misses.saturating_add(1), + }); +} + +/// Number of new live keys inserted since reservation activated this build. +#[must_use] +pub fn unreserved_slot_count() -> usize { + with_state(|state| state.misses) +} + +/// Clear reservation bookkeeping at the existing build-reset boundary. +pub fn reset() { + with_state(|state| *state = State::default()); +} + +pub(crate) struct FileSeedGuard(Phase); + +impl Default for FileSeedGuard { + fn default() -> Self { + Self(with_state(|state| { + let phase = state.phase; + state.phase = Phase::Scratch; + phase + })) + } +} + +impl Drop for FileSeedGuard { + fn drop(&mut self) { + with_state(|state| state.phase = self.0); + } +} + +/// Suppresses scratch misses and rolls back allocator additions unless committed. +pub struct ScratchSlots { + original: HashMap>, + files: bimap::BiHashMap, + state: State, + committed: bool, +} + +impl Default for ScratchSlots { + fn default() -> Self { + let original = get_class_map(); + let files = get_file_map(); + let state = with_state(|state| { + let saved = *state; + state.phase = Phase::Scratch; + saved + }); + Self { + original, + files, + state, + committed: false, + } + } +} + +impl ScratchSlots { + /// Keep additions and activate live-miss accounting, including for an empty batch. + pub fn commit(mut self) { + self.committed = true; + } +} + +impl Drop for ScratchSlots { + fn drop(&mut self) { + if self.committed { + with_state(|state| { + *state = State { + phase: Phase::Live, + ..self.state + } + }); + } else { + set_class_map(std::mem::take(&mut self.original)); + set_file_map(std::mem::take(&mut self.files)); + with_state(|state| *state = self.state); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn wasm_state_access_preserves_mutations_and_reset() { + wasm_state_for_tests::with_state(|state| { + state.phase = Phase::Scratch; + state.misses = 7; + }); + assert_eq!(wasm_state_for_tests::with_state(|state| state.misses), 7); + wasm_state_for_tests::with_state(|state| *state = State::default()); + assert_eq!(wasm_state_for_tests::with_state(|state| state.misses), 0); + } + + #[test] + #[serial] + fn insertions_count_only_when_live_and_keep_legacy_names() { + // Given + reset(); + crate::debug::set_debug(false); + crate::class_map::reset_class_map(); + assert_eq!(crate::sheet_to_variable_name("gap", 0, None), "--a"); + let slots = ScratchSlots::default(); + assert_eq!(crate::sheet_to_variable_name("width", 0, None), "--b"); + slots.commit(); + // When + let first = crate::sheet_to_variable_name("color", 0, None); + let repeated = crate::sheet_to_variable_name("color", 0, None); + // Then + assert_eq!((first, repeated), ("--c".into(), "--c".into())); + assert_eq!(unreserved_slot_count(), 1); + ScratchSlots::default().commit(); + assert_eq!(unreserved_slot_count(), 1); + reset(); + assert_eq!(unreserved_slot_count(), 0); + } + + #[test] + #[serial] + fn unwind_restores_allocator_and_previous_live_count() { + // Given + reset(); + crate::debug::set_debug(false); + crate::class_map::reset_class_map(); + ScratchSlots::default().commit(); + record_insert(); + let before = get_class_map(); + // When + let result = std::panic::catch_unwind(|| { + let _slots = ScratchSlots::default(); + record_insert(); + drop(crate::sheet_to_variable_name("gap", 0, None)); + panic!("reservation unwind"); + }); + // Then + assert!(result.is_err()); + assert_eq!(get_class_map(), before); + assert_eq!(unreserved_slot_count(), 1); + reset(); + } +} diff --git a/libs/css/src/slot_reservation/state_native.rs b/libs/css/src/slot_reservation/state_native.rs new file mode 100644 index 000000000..1b68402ea --- /dev/null +++ b/libs/css/src/slot_reservation/state_native.rs @@ -0,0 +1,12 @@ +use super::{Phase, State}; + +static STATE: std::sync::Mutex = std::sync::Mutex::new(State { + phase: Phase::Legacy, + misses: 0, +}); + +pub(super) fn with_state(f: impl FnOnce(&mut State) -> R) -> R { + f(&mut STATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner)) +} diff --git a/libs/css/src/slot_reservation/state_wasm.rs b/libs/css/src/slot_reservation/state_wasm.rs new file mode 100644 index 000000000..a4ac2f571 --- /dev/null +++ b/libs/css/src/slot_reservation/state_wasm.rs @@ -0,0 +1,11 @@ +use super::{Phase, State}; + +thread_local! { + static STATE: std::cell::RefCell = const { + std::cell::RefCell::new(State { phase: Phase::Legacy, misses: 0 }) + }; +} + +pub(super) fn with_state(f: impl FnOnce(&mut State) -> R) -> R { + STATE.with_borrow_mut(f) +} diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index 9902a1c96..84484eee0 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, colors: BTreeSet, } @@ -19,9 +21,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; @@ -103,6 +138,67 @@ mod tests { use super::*; use serial_test::serial; + #[test] + #[serial] + fn theme_token_values_resolve_in_the_property_namespace() { + // Given + set_theme_token_values( + BTreeMap::from([("shared".into(), "8px".into())]), + BTreeMap::from([("shared".into(), "0 1px 2px black".into())]), + ); + // When / Then + assert_eq!( + get_first_theme_token_value("width", "$shared"), + Some("8px".into()) + ); + assert_eq!( + get_first_theme_token_value("box-shadow", "$shared"), + Some("0 1px 2px black".into()) + ); + assert_eq!(get_first_theme_token_value("width", "$missing"), None); + assert_eq!(get_first_theme_token_value("width", "shared"), None); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + } + + #[test] + #[serial] + fn theme_token_values_normalize_zero_literals() { + // Given + let zero = BTreeMap::from([("zero".into(), "0px".into())]); + // When + set_theme_token_values(zero.clone(), zero); + // Then + assert_eq!( + get_first_theme_token_value("width", "$zero"), + Some("0".into()) + ); + assert_eq!( + get_first_theme_token_value("box-shadow", "$zero"), + Some("0".into()) + ); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + } + + #[test] + #[serial] + fn theme_token_values_reset_when_levels_are_replaced() { + // Given + set_theme_token_values( + BTreeMap::from([("shared".into(), "8px".into())]), + BTreeMap::from([("shared".into(), "0 1px 2px black".into())]), + ); + // When + set_theme_token_levels( + BTreeMap::from([("shared".into(), vec![0, 2])]), + BTreeMap::from([("shared".into(), vec![0, 3])]), + ); + // Then + assert_eq!(get_first_theme_token_value("width", "$shared"), None); + assert_eq!(get_first_theme_token_value("box-shadow", "$shared"), None); + assert_eq!(get_responsive_theme_token("$shared"), Some(vec![0, 2])); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + } + #[test] #[serial] fn test_get_responsive_theme_token() { diff --git a/libs/extractor/src/extract_style/extract_dynamic_style.rs b/libs/extractor/src/extract_style/extract_dynamic_style.rs index 74cf36ea2..c0aefa18c 100644 --- a/libs/extractor/src/extract_style/extract_dynamic_style.rs +++ b/libs/extractor/src/extract_style/extract_dynamic_style.rs @@ -157,20 +157,34 @@ impl ExtractDynamicStyle { impl ExtractStyleProperty for ExtractDynamicStyle { fn extract(&self, filename: Option<&str>) -> StyleProperty { let selector = super::class_selector(self.selector.as_ref(), self.layer()); - StyleProperty::Variable { - class_name: sheet_to_classname( + let ordinary_class = (!css::atom_hoist::is_atom_hoist()).then(|| { + sheet_to_classname( self.property.as_str(), self.level, - None, + self.important.then_some("!important"), selector.as_deref(), self.style_order, filename, - ), - variable_name: sheet_to_variable_name( - self.property.as_str(), - self.level, - selector.as_deref(), - ), + ) + }); + let variable_name = + sheet_to_variable_name(self.property.as_str(), self.level, selector.as_deref()); + let declaration = format!( + "var({variable_name}){}", + if self.important { " !important" } else { "" } + ); + StyleProperty::Variable { + class_name: ordinary_class.unwrap_or_else(|| { + sheet_to_classname( + self.property.as_str(), + self.level, + Some(&declaration), + selector.as_deref(), + self.style_order, + filename, + ) + }), + variable_name, identifier: self.identifier.clone(), } } diff --git a/libs/extractor/src/extract_style/extract_keyframes.rs b/libs/extractor/src/extract_style/extract_keyframes.rs index 13bb9987c..ad385940e 100644 --- a/libs/extractor/src/extract_style/extract_keyframes.rs +++ b/libs/extractor/src/extract_style/extract_keyframes.rs @@ -38,6 +38,21 @@ impl Hash for KeyframeIdentity<'_> { impl ExtractStyleProperty for ExtractKeyframes { fn extract(&self, filename: Option<&str>) -> StyleProperty { + if css::atom_hoist::is_atom_hoist() { + let mut content = String::new(); + for (step, styles) in &self.keyframes { + content.push_str(&css::atom_name::hex(step)); + content.push('{'); + for style in styles { + content.push_str(&css::atom_name::hex(style.property())); + content.push(':'); + content.push_str(&css::atom_name::hex(style.value())); + content.push(';'); + } + content.push('}'); + } + return StyleProperty::ClassName(keyframes_to_keyframes_name(&content, filename)); + } let mut hasher = DefaultHasher::new(); KeyframeIdentity(&self.keyframes).hash(&mut hasher); // Format the u64 hash into a stack buffer instead of a throwaway heap diff --git a/libs/extractor/src/extract_style/extract_static_style.rs b/libs/extractor/src/extract_style/extract_static_style.rs index d250fd994..6cb8fecb4 100644 --- a/libs/extractor/src/extract_style/extract_static_style.rs +++ b/libs/extractor/src/extract_style/extract_static_style.rs @@ -6,6 +6,7 @@ use css::{ optimize_value::optimize_value, sheet_to_classname, style_selector::{StyleSelector, optimize_selector}, + theme_tokens::get_first_theme_token_value, }; use crate::{ @@ -197,18 +198,30 @@ impl ExtractStaticStyle { pub const fn theme_token_resolution(&self) -> ThemeTokenResolution { self.theme_token_resolution } + + /// Effective value shared by generated class identity and emitted declaration. + pub fn resolved_value(&self) -> Cow<'_, str> { + match self.theme_token_resolution() { + ThemeTokenResolution::CssVariable => Cow::Borrowed(&self.value), + ThemeTokenResolution::FirstValue => { + get_first_theme_token_value(&self.property, &self.value) + .map_or(Cow::Borrowed(&self.value), Cow::Owned) + } + } + } } impl ExtractStyleProperty for ExtractStaticStyle { fn extract(&self, filename: Option<&str>) -> StyleProperty { let s = self.class_selector(); + let value = self.resolved_value(); // `self.value` is already the result of `optimize_value(convert_value(..))` // (computed in the constructors), so re-running convert_value + optimize_value // here is redundant. Only the multi-css optimization is not applied at construction. let v = if check_multi_css_optimize(&self.property) { - optimize_multi_css_value(&self.value) + optimize_multi_css_value(&value) } else { - std::borrow::Cow::Borrowed(self.value.as_str()) + Cow::Borrowed(value.as_ref()) }; StyleProperty::ClassName(sheet_to_classname( &self.property, diff --git a/libs/extractor/src/extract_style/mod.rs b/libs/extractor/src/extract_style/mod.rs index aacf5ec40..730c66417 100644 --- a/libs/extractor/src/extract_style/mod.rs +++ b/libs/extractor/src/extract_style/mod.rs @@ -17,6 +17,9 @@ fn class_selector<'a>( selector: Option<&'a StyleSelector>, layer: Option<&str>, ) -> Option> { + if css::atom_hoist::is_atom_hoist() { + return Some(Cow::Owned(css::atom_name::selector_key(selector, layer))); + } let selector = selector.map(StyleSelector::as_class_str); match layer { Some(layer) => Some(Cow::Owned(format!( diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 4628bc63c..7ea579f6a 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -26,6 +26,7 @@ mod pragma_path_tests; mod pragma_tests; mod prop_modify_utils; mod prop_valid; +pub mod reservation_state; #[cfg(test)] mod responsive_selector_tests; mod source_map; @@ -359,6 +360,7 @@ fn extract_source( source_map: bool, resolver: Option<&ModuleResolver>, ) -> Result> { + let _atom_mode = css::atom_hoist::AtomModeGuard::new(option.single_css); if evaluated.is_none() && option.import_aliases.contains_key("@vanilla-extract/css") { let errors = import_alias_visit::companion_errors(code, filename); if !errors.is_empty() { @@ -371,6 +373,7 @@ fn extract_source( located_errors(filename, source, edits, errors) })?; // Step 1: Transform import aliases + css::atom_hoist::freeze_atom_plan(); // e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'` // e.g., `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` let (transformed_code, alias_edits) = import_alias_visit::transform_import_aliases_with_edits( diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index 1e49f233c..012a98488 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -53,6 +53,22 @@ thread_local! { static EVALUATING: RefCell> = const { RefCell::new(Vec::new()) }; } +/// Moves the outer evaluation stack aside until scratch extraction ends. +#[derive(Debug)] +pub(crate) struct EvaluationGuard(Vec); + +impl Default for EvaluationGuard { + fn default() -> Self { + Self(EVALUATING.with_borrow_mut(std::mem::take)) + } +} + +impl Drop for EvaluationGuard { + fn drop(&mut self) { + EVALUATING.with_borrow_mut(|stack| *stack = std::mem::take(&mut self.0)); + } +} + /// Marks `filename` as being evaluated until dropped pub(crate) struct Evaluating; diff --git a/libs/extractor/src/reservation_state.rs b/libs/extractor/src/reservation_state.rs new file mode 100644 index 000000000..f67302529 --- /dev/null +++ b/libs/extractor/src/reservation_state.rs @@ -0,0 +1,75 @@ +//! Move-based isolation for the globals scratch extraction can mutate. + +use crate::{module_loader::EvaluationGuard, vanilla_extract::CacheGuard}; + +/// Restores warmed stylesheet caches and any outer evaluation stack on drop. +#[derive(Default, Debug)] +pub struct ScratchExtractionState { + _caches: CacheGuard, + _evaluation: EvaluationGuard, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ExtractOption, ResolvedModule, extract_with_modules}; + use serial_test::serial; + + fn supplier(specifier: &str, _: &str) -> Option { + (specifier == "./supplier.css").then(|| ResolvedModule { + path: "supplier.css.ts".into(), + code: + "import { style } from '@devup-ui/react'; export const base = style({color:'red'});" + .into(), + }) + } + + #[test] + #[serial] + fn warmed_caches_restore_when_scratch_succeeds_or_unwinds() + -> Result<(), Box> { + // Given + let _isolated = ScratchExtractionState::default(); + let source = "import {style} from '@devup-ui/react'; import {base} from './supplier.css'; export const own = style([base,{width:'12px'}]);"; + extract_with_modules( + "root.css.ts", + source, + ExtractOption::default(), + false, + &supplier, + )?; + let outer = crate::module_loader::Evaluating::enter("outer.css.ts"); + let saved = ScratchExtractionState::default(); + let before = format!("{saved:?}"); + assert!(before.contains("supplier.css.ts")); + drop(saved); + // When + for unwind in [false, true] { + let result = std::panic::catch_unwind(|| { + let _scratch = ScratchExtractionState::default(); + assert!(!crate::module_loader::loading_for_stylesheet()); + let _inner = crate::module_loader::Evaluating::enter("scratch.css.ts"); + assert!(crate::module_loader::loading_for_stylesheet()); + assert!( + extract_with_modules( + "scratchroot.css.ts", + source, + ExtractOption::default(), + false, + &supplier + ) + .is_ok() + ); + assert!(!unwind, "scratch unwind"); + }); + assert_eq!(result.is_err(), unwind); + // Then + let after = ScratchExtractionState::default(); + assert_eq!(format!("{after:?}"), before); + drop(after); + assert!(crate::module_loader::loading_for_stylesheet()); + } + drop(outer); + Ok(()) + } +} diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 2ed48560e..aec99f6cf 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -321,11 +321,35 @@ pub fn execute_stylesheet( Ok((collected, imports)) } +type ImportedRuns = FxHashMap; + +#[derive(Debug)] +pub(crate) struct CacheGuard { + imported: ImportedRuns, + stripped: FxHashMap, +} + +impl Default for CacheGuard { + fn default() -> Self { + Self { + imported: IMPORTED_RUNS.with_borrow_mut(std::mem::take), + stripped: STRIPPED.with_borrow_mut(std::mem::take), + } + } +} + +impl Drop for CacheGuard { + fn drop(&mut self) { + IMPORTED_RUNS.with_borrow_mut(|runs| *runs = std::mem::take(&mut self.imported)); + STRIPPED.with_borrow_mut(|stripped| *stripped = std::mem::take(&mut self.stripped)); + } +} + thread_local! { /// What running each stylesheet other evaluations import last collected, /// with its file number, source and the script run: every module importing /// it reuses one run of the same script - static IMPORTED_RUNS: RefCell> = + static IMPORTED_RUNS: RefCell = RefCell::default(); /// The TypeScript each file last had stripped, with its source static STRIPPED: RefCell> = RefCell::default(); diff --git a/libs/sheet/src/atom_identity_tests.rs b/libs/sheet/src/atom_identity_tests.rs new file mode 100644 index 000000000..1d0e60d91 --- /dev/null +++ b/libs/sheet/src/atom_identity_tests.rs @@ -0,0 +1,237 @@ +use super::*; +use css::sheet_to_classname; +use css::style_selector::{AtRule, AtRuleKind}; +use serial_test::serial; + +mod first_value; +mod mixed_placement; +mod single_css; + +#[test] +#[serial] +fn nested_at_rule_pseudo_classes_reference_the_emitted_selector() { + // Given + css::atom_hoist::restore_atom_plan(None); + css::atom_hoist::set_atom_hoist(Some(2)); + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_routes::reset_file_routes(); + css::file_map::reset_canonical_map(); + css::set_prefix(None); + let mut sheet = StyleSheet::default(); + let source = r"import {Box} from '@devup-ui/react'; + export const x=;"; + // When + let output = extractor::extract("private.tsx", source, extractor::ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")); + sheet.update_styles(&output.styles, "private.tsx", false); + // Then + let css = sheet.create_css(Some("private.tsx"), false); + let classes: Vec<&str> = output + .code + .split("className=\"") + .nth(1) + .and_then(|code| code.split('"').next()) + .unwrap_or_else(|| panic!("{}", output.code)) + .split_whitespace() + .collect(); + assert_eq!(classes.len(), 2); + assert_ne!(classes[0], classes[1]); + assert!( + css.contains("@supports(display:grid){@media(hover:hover){"), + "{css}" + ); + for (selector, color) in [("hover", "red"), ("focus", "blue")] { + assert!( + classes + .iter() + .any(|name| css.contains(&format!(".{name}:{selector}{{color:{color}}}"))), + "{css}" + ); + } + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); +} + +#[test] +#[serial] +fn content_identities_emit_distinct_rules_for_structural_boundaries() { + // Given + css::atom_hoist::restore_atom_plan(None); + css::atom_hoist::set_atom_hoist(Some(2)); + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_routes::reset_file_routes(); + css::file_map::reset_canonical_map(); + css::set_prefix(None); + let at = |kind, outer| { + Some(StyleSelector::At { + kind, + query: "(width:10px)".to_string(), + selector: None, + outer, + file: None, + }) + }; + let cases = [ + (None, None, 0, None, None), + (None, None, 0, Some(""), None), + (None, None, 0, Some("red"), None), + (None, None, 1, Some("red"), None), + (None, None, 0, Some("red"), Some(1)), + (None, Some(""), 0, Some("red"), None), + (None, Some("cards"), 0, Some("red"), None), + ( + Some(StyleSelector::Selector("&:hover".into())), + None, + 0, + Some("red"), + None, + ), + (at(AtRuleKind::Media, vec![]), None, 0, Some("red"), None), + (at(AtRuleKind::Supports, vec![]), None, 0, Some("red"), None), + ( + at(AtRuleKind::Container, vec![]), + None, + 0, + Some("red"), + None, + ), + ( + at( + AtRuleKind::Media, + vec![AtRule { + kind: AtRuleKind::Supports, + query: "(display:grid)".into(), + }], + ), + None, + 0, + Some("red"), + None, + ), + ( + at( + AtRuleKind::Media, + vec![AtRule { + kind: AtRuleKind::Container, + query: "(display:grid)".into(), + }], + ), + None, + 0, + Some("red"), + None, + ), + ]; + let mut sheet = StyleSheet::default(); + let mut names = BTreeSet::new(); + // When + for (selector, layer, level, value, order) in cases { + let key = css::atom_name::selector_key(selector.as_ref(), layer); + let name = sheet_to_classname( + "color", + level, + value, + Some(&key), + order, + Some("private.tsx"), + ); + assert!(names.insert(name.clone()), "colliding identity {name}"); + sheet.add_property_with_layer( + &name, + "color", + level, + value.unwrap_or("var(--x)"), + selector.as_ref(), + order, + Some("private.tsx"), + layer, + ); + } + // Then + let css = sheet.create_css(Some("private.tsx"), false); + for name in names { + assert!( + css.contains(&format!(".{name}")), + "missing rule {name}\n{css}" + ); + } + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); +} + +#[test] +#[serial] +fn global_selector_owner_is_cleanup_only_not_content_identity() { + // Given + css::atom_hoist::restore_atom_plan(None); + css::atom_hoist::set_atom_hoist(Some(2)); + css::file_routes::set_file_routes(std::collections::HashMap::from([ + ("a.tsx".to_string(), std::collections::HashSet::from([0, 1])), + ("b.tsx".to_string(), std::collections::HashSet::from([0, 1])), + ])); + let mut sheet = StyleSheet::default(); + let source = "import {globalCss} from '@devup-ui/react'; globalCss({body:{color:'red'}});"; + let mut identities = BTreeSet::new(); + // When + for file in ["a.tsx", "b.tsx"] { + let output = extractor::extract(file, source, extractor::ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")); + sheet.update_styles(&output.styles, file, false); + for levels in sheet.properties[file].values() { + for prop in levels.values().flatten() { + identities.insert(prop.class_name.clone()); + } + } + } + // Then + assert_eq!(identities.len(), 1); + let emitted = sheet.create_css(None, false); + assert!( + emitted.contains(concat!("body{", "color:red}")), + "{emitted}" + ); + sheet.rm_global_css("a.tsx", false); + assert!( + sheet + .create_css(None, false) + .contains(concat!("body{", "color:red}")) + ); + let global_key = css::atom_name::selector_key( + Some(&StyleSelector::Global("body".into(), "b.tsx".into())), + None, + ); + let local_key = + css::atom_name::selector_key(Some(&StyleSelector::Selector("body".into())), None); + assert_ne!(global_key, local_key); + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + css::file_routes::reset_file_routes(); +} + +#[test] +#[serial] +fn shared_imports_and_fonts_signal_changes_only_when_added() { + // Given + css::atom_hoist::restore_atom_plan(None); + css::atom_hoist::set_atom_hoist(Some(2)); + let source = "import {globalCss} from '@devup-ui/react'; globalCss({imports:['base.css'],fontFaces:[{fontFamily:'Fixture',src:'url(font.woff2)'}]});"; + let output = extractor::extract("test.tsx", source, extractor::ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")); + let mut sheet = StyleSheet::default(); + // When + let first = sheet.update_styles(&output.styles, "test.tsx", false); + let repeated = sheet.update_styles(&output.styles, "test.tsx", false); + // Then + assert!(first.1); + assert!(!repeated.1); + let emitted = sheet.create_css(None, false); + assert!(emitted.contains("@import \"base.css\";")); + assert!(emitted.contains("font-family:Fixture")); + assert!(emitted.contains("src:url(font.woff2)")); + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); +} diff --git a/libs/sheet/src/atom_identity_tests/first_value.rs b/libs/sheet/src/atom_identity_tests/first_value.rs new file mode 100644 index 000000000..fb0fb592e --- /dev/null +++ b/libs/sheet/src/atom_identity_tests/first_value.rs @@ -0,0 +1,173 @@ +use super::*; + +fn token_theme(first: &str) -> Theme { + serde_json::from_value(serde_json::json!({ + "length": {"default": {"space": [first, "2px"], "zero": ["0px", "4px"], "nullable": [null, "3px", "4px"]}}, + "shadows": {"default": {"card": ["0 1px 2px black", "0 2px 4px black"], "space": ["0 3px 4px black", "0 4px 8px black"]}} + })) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn token_sheet(atom: bool) -> StyleSheet { + css::atom_hoist::restore_atom_plan(None); + css::atom_hoist::set_atom_hoist(atom.then_some(2)); + css::file_routes::reset_file_routes(); + css::file_map::reset_canonical_map(); + css::file_map::reset_file_map(); + css::class_map::reset_class_map(); + css::set_prefix(None); + css::debug::set_debug(false); + let mut sheet = StyleSheet::default(); + sheet.set_theme(token_theme("1px")); + sheet +} + +#[test] +#[serial] +fn first_value_refs_resolve_without_aliasing_responsive_variables() { + // Given + let source = r#"import {Box} from '@devup-ui/react'; export const A=<> + + + ;"#; + for atom in [false, true] { + let mut sheet = token_sheet(atom); + // When + let output = extractor::extract("test.tsx", source, extractor::ExtractOption::default()) + .unwrap_or_else(|error| panic!("{error}")); + sheet.update_styles(&output.styles, "test.tsx", false); + // Then + let css = sheet.create_css(Some("test.tsx"), false); + let classes: Vec> = output + .code + .split("className=\"") + .skip(1) + .map(|code| { + code.split('"') + .next() + .unwrap_or_default() + .split_whitespace() + .collect() + }) + .collect(); + assert_eq!(classes.len(), 8, "{}", output.code); + assert_eq!(classes[0].len(), 2); + for refs in &classes[1..] { + assert_eq!(refs.len(), 1); + } + for (refs, declaration) in classes.iter().zip([ + "width:var(--space)", + "width:1px", + "box-shadow:0 1px 2px black", + "box-shadow:0 3px 4px black", + "width:0", + "width:0", + "width:var(--missing)", + "width:var(--nullable)", + ]) { + for name in refs { + assert!( + css.contains(&format!(".{name}{{{declaration}}}")), + "unresolved {name} -> {declaration}\n{}\n{css}", + output.code + ); + } + } + assert_ne!(classes[0][0], classes[1][0]); + assert_eq!(classes[4], classes[5]); + } + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + StyleSheet::default().set_theme(Theme::default()); +} + +fn extract_first_value(sheet: &mut StyleSheet) -> String { + let output = extractor::extract( + "test.tsx", + r#"import {Box} from '@devup-ui/react'; export const x=;"#, + extractor::ExtractOption::default(), + ) + .unwrap_or_else(|error| panic!("{error}")); + sheet.update_styles(&output.styles, "test.tsx", false); + output + .code + .split("className=\"") + .nth(1) + .and_then(|code| code.split('"').next()) + .unwrap_or_else(|| panic!("{}", output.code)) + .to_string() +} + +#[test] +#[serial] +fn first_value_names_track_theme_updates_without_aliasing_stale_literals() { + // Given + for atom in [false, true] { + let mut sheet = token_sheet(atom); + let old_name = extract_first_value(&mut sheet); + // When + sheet.set_theme(token_theme("8px")); + let updated_name = extract_first_value(&mut sheet); + // Then + assert_ne!(old_name, updated_name); + let css = sheet.create_css(Some("test.tsx"), false); + assert!(css.contains(&format!(".{old_name}{{width:1px}}")), "{css}"); + assert!( + css.contains(&format!(".{updated_name}{{width:8px}}")), + "{css}" + ); + } + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + StyleSheet::default().set_theme(Theme::default()); +} + +#[test] +#[serial] +fn first_value_rebuild_references_the_new_default_instead_of_cached_literal() { + // Given + for atom in [false, true] { + let mut old = token_sheet(atom); + extract_first_value(&mut old); + let mut rebuilt = token_sheet(atom); + rebuilt.set_theme(token_theme("8px")); + // When + let new_name = extract_first_value(&mut rebuilt); + // Then + let css = rebuilt.create_css(Some("test.tsx"), false); + assert!(css.contains(&format!(".{new_name}{{width:8px}}")), "{css}"); + assert!(!css.contains("width:1px"), "{css}"); + } + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + StyleSheet::default().set_theme(Theme::default()); +} + +#[test] +#[serial] +fn cached_first_value_maps_allocate_a_new_slot_for_a_changed_default() { + // Given + let mut old = token_sheet(true); + let old_name = extract_first_value(&mut old); + let classes = css::class_map::get_class_map(); + let files = css::file_map::get_file_map(); + let serialized = + serde_json::to_string(&old.export_snapshot()).unwrap_or_else(|error| panic!("{error}")); + token_sheet(true); + css::class_map::set_class_map(classes); + css::file_map::set_file_map(files); + let mut resumed: StyleSheet = + serde_json::from_str(&serialized).unwrap_or_else(|error| panic!("{error}")); + resumed.set_theme(token_theme("8px")); + // When + let new_name = extract_first_value(&mut resumed); + // Then + assert_ne!(old_name, new_name); + let css = resumed.create_css(Some("test.tsx"), false); + assert!(css.contains(&format!(".{old_name}{{width:1px}}")), "{css}"); + assert!(css.contains(&format!(".{new_name}{{width:8px}}")), "{css}"); + assert!(!css.contains(&format!(".{new_name}{{width:1px}}")), "{css}"); + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + StyleSheet::default().set_theme(Theme::default()); +} diff --git a/libs/sheet/src/atom_identity_tests/mixed_placement.rs b/libs/sheet/src/atom_identity_tests/mixed_placement.rs new file mode 100644 index 000000000..af3615b71 --- /dev/null +++ b/libs/sheet/src/atom_identity_tests/mixed_placement.rs @@ -0,0 +1,76 @@ +use super::*; +use std::collections::{HashMap, HashSet}; + +#[test] +#[serial] +fn restored_legacy_rule_stays_local_when_new_rule_is_hoisted_in_same_bucket() { + // Given + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + css::file_routes::reset_file_routes(); + css::file_map::reset_canonical_map(); + css::file_map::reset_file_map(); + css::class_map::reset_class_map(); + css::set_prefix(None); + css::debug::set_debug(false); + let mut legacy = StyleSheet::default(); + legacy.set_theme(Theme::default()); + legacy.add_property("legacy", "color", 0, "red", None, None, Some("mixed.tsx")); + let persisted = serde_json::to_string(&legacy).unwrap_or_else(|error| panic!("{error}")); + assert!(!persisted.contains("\"h\":"), "{persisted}"); + let mut sheet: StyleSheet = + serde_json::from_str(&persisted).unwrap_or_else(|error| panic!("{error}")); + css::file_routes::set_file_routes(HashMap::from([( + "mixed.tsx".to_string(), + HashSet::from([0, 1]), + )])); + css::atom_hoist::set_atom_hoist(Some(2)); + + // When + let output = extractor::extract( + "mixed.tsx", + "import {Box} from '@devup-ui/react'; export const x=;", + extractor::ExtractOption::default(), + ) + .unwrap_or_else(|error| panic!("{error}")); + sheet.update_styles(&output.styles, "mixed.tsx", false); + let shared = sheet.create_css(None, false); + let local = sheet.create_css(Some("mixed.tsx"), false); + + // Then + let class_name = output + .code + .split("className=\"") + .nth(1) + .and_then(|code| code.split('"').next()) + .unwrap_or_else(|| panic!("{}", output.code)); + assert_eq!(class_name.split_whitespace().count(), 1, "{}", output.code); + assert_ne!(class_name, "legacy"); + let new_rule = format!(".{class_name}{{background:blue}}"); + assert_eq!(shared.matches(&new_rule).count(), 1, "{shared}"); + assert!(!shared.contains(".legacy"), "{shared}"); + assert_eq!( + local, + format!("{}.legacy{{color:red}}", StyleSheet::create_header()) + ); + assert!(!local.contains(&format!(".{class_name}")), "{local}"); + let bucket = &sheet.properties["mixed.tsx"][&255][&0]; + let dispositions: BTreeSet<_> = bucket + .iter() + .map(|prop| (prop.class_name.as_str(), prop.hoisted)) + .collect(); + assert_eq!( + dispositions, + BTreeSet::from([("legacy", false), (class_name, true)]) + ); + + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + css::file_routes::reset_file_routes(); + css::file_map::reset_canonical_map(); + css::file_map::reset_file_map(); + css::class_map::reset_class_map(); + css::set_prefix(None); + css::debug::set_debug(false); + StyleSheet::default().set_theme(Theme::default()); +} diff --git a/libs/sheet/src/atom_identity_tests/single_css.rs b/libs/sheet/src/atom_identity_tests/single_css.rs new file mode 100644 index 000000000..de4d0d305 --- /dev/null +++ b/libs/sheet/src/atom_identity_tests/single_css.rs @@ -0,0 +1,67 @@ +use super::*; + +#[test] +#[serial] +fn direct_single_css_extraction_and_sheet_update_ignore_requested_atom_mode() { + // Given + let build = |atom: bool, debug| { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::atom_hoist::restore_atom_plan(None); + css::atom_hoist::set_atom_hoist(atom.then_some(2)); + css::debug::set_debug(debug); + css::set_prefix(None); + let mut sheet = StyleSheet::default(); + let source = "import {Box,keyframes} from '@devup-ui/react'; const fade=keyframes({from:{opacity:0},to:{opacity:1}}); export const x=;"; + // When + let output = extractor::extract( + "single.tsx", + source, + extractor::ExtractOption { + single_css: true, + ..extractor::ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + sheet.update_styles(&output.styles, "single.tsx", true); + // Then + assert_eq!(sheet.atom_plan, None); + assert_eq!(css::atom_hoist::atom_plan(), None); + (output.code, sheet.create_css(None, false)) + }; + for debug in [false, true] { + assert_eq!(build(false, debug), build(true, debug)); + } + css::atom_hoist::set_atom_hoist(None); + css::debug::set_debug(false); +} + +#[test] +#[serial] +fn suppressed_sheet_does_not_capture_an_existing_atom_plan() { + // Given + css::atom_hoist::set_atom_hoist(Some(2)); + css::atom_hoist::restore_atom_plan(Some(BTreeSet::from(["shared".into()]))); + let mut sheet = StyleSheet::default(); + let source = "import {Box} from '@devup-ui/react'; export const x=;"; + // When + let output = extractor::extract( + "single.tsx", + source, + extractor::ExtractOption { + single_css: true, + ..extractor::ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + sheet.update_styles(&output.styles, "single.tsx", true); + // Then + assert_eq!(sheet.atom_plan, None); + assert_eq!( + css::atom_hoist::atom_plan(), + Some(BTreeSet::from(["shared".into()])) + ); + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); +} diff --git a/libs/sheet/src/global_order_tests.rs b/libs/sheet/src/global_order_tests.rs index 662438685..60fbf5aff 100644 --- a/libs/sheet/src/global_order_tests.rs +++ b/libs/sheet/src/global_order_tests.rs @@ -104,6 +104,96 @@ fn shared_tailwind_registrations_survive_source_updates( Ok(()) } +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn global_file_order_survives_atom_emission_when_source_offsets_differ( + #[case] reverse: bool, +) -> Result<(), Box> { + // Given authored globals whose file order differs from their declaration offsets. + css::debug::set_debug(false); + css::set_prefix(None); + css::atom_hoist::set_atom_hoist(Some(2)); + css::atom_hoist::restore_atom_plan(None); + css::file_routes::reset_file_routes(); + css::file_map::reset_canonical_map(); + reset_class_map(); + reset_file_map(); + let mut sheet = StyleSheet::default(); + sheet.set_theme(Theme::default()); + let mut sources = [ + ("a.tsx", "globalCss({body:{margin:0,color:'red'}});"), + ("b.tsx", "globalCss({body:{color:'blue'}});"), + ]; + if reverse { + sources.reverse(); + } + + // When extraction and atom-mode emission combine both owners. + for (file, declarations) in sources { + let output = extract( + file, + &format!("import {{globalCss}} from '@devup-ui/react';{declarations}"), + ExtractOption::default(), + )?; + sheet.update_styles(&output.styles, file, false); + } + let emitted = sheet.create_css(None, false); + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + + // Then the later file still wins instead of an earlier file's later offset. + assert_eq!( + emitted.strip_prefix(StyleSheet::create_header()), + Some("@layer b;@layer b{body{margin:0;color:red;color:blue}}") + ); + Ok(()) +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn global_atom_dedup_keeps_repeated_value_when_another_file_overrides_it( + #[case] reverse: bool, +) -> Result<(), Box> { + // Given three ordered owners whose final red overrides the intervening blue. + css::debug::set_debug(false); + css::set_prefix(None); + css::atom_hoist::set_atom_hoist(Some(2)); + css::atom_hoist::restore_atom_plan(None); + css::file_routes::reset_file_routes(); + css::file_map::reset_canonical_map(); + reset_class_map(); + reset_file_map(); + let mut sheet = StyleSheet::default(); + sheet.set_theme(Theme::default()); + let mut sources = [("a.tsx", "red"), ("b.tsx", "blue"), ("c.tsx", "red")]; + if reverse { + sources.reverse(); + } + + // When all three files pass through extraction and atom-plan emission. + for (file, color) in sources { + let source = format!( + "import {{globalCss}} from '@devup-ui/react';globalCss({{body:{{color:'{color}'}}}});" + ); + let output = extract(file, &source, ExtractOption::default())?; + sheet.update_styles(&output.styles, file, false); + } + let emitted = sheet.create_css(None, false); + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + + // Then dedup preserves the final winner in either extraction order. + assert_eq!( + emitted.strip_prefix(StyleSheet::create_header()), + Some("@layer b;@layer b{body{color:red;color:blue;color:red}}") + ); + Ok(()) +} + #[rstest] #[case( concat!("globalCss`body{color:", "red}`;globalCss`body{color:", "blue}`;"), diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 191bdfb09..ded36bc7f 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -1,29 +1,31 @@ pub mod theme; +#[cfg(test)] +mod atom_identity_tests; #[cfg(test)] mod global_order_tests; use crate::theme::Theme; use css::{ at_rule::{MediaCombination, combine_media_queries, query_order}, - atom_hoist::{atom_hoist_threshold, is_atom_hoist}, + atom_hoist::{atom_plan, freeze_atom_plan, is_atom_hoist, is_hoisted_bucket}, file_map::canonical, - file_routes::route_count_for_files, - get_custom_shorthand_names, sheet_to_classname, + get_custom_shorthand_names, style_selector::{ AtRule, AtRuleKind, StyleSelector, get_selector_order, global_selector_order, write_at_rule, }, tailwind_definitions::set_tailwind_css, - theme_tokens::{set_color_tokens, set_theme_token_levels, set_typography_keys}, + theme_tokens::{ + set_color_tokens, set_theme_token_levels, set_theme_token_values, set_typography_keys, + }, utils::compile_regex, write_merge_selector, }; use extractor::extract_style::ExtractStyleProperty; -use extractor::extract_style::extract_static_style::ThemeTokenResolution; use extractor::extract_style::extract_style_value::ExtractStyleValue; use extractor::extract_style::style_property::StyleProperty; use regex_lite::Regex; -use rustc_hash::{FxHashMap, FxHashSet}; +use rustc_hash::FxHashSet; use serde::de::Error; use serde::{Deserialize, Deserializer, Serialize}; use std::borrow::Cow; @@ -104,6 +106,9 @@ pub struct StyleSheetProperty { /// Declaration expanded from a conditional `typography` preset #[serde(rename = "t", default, skip_serializing_if = "std::ops::Not::not")] pub typography: bool, + /// Placement decided before extraction, independent of later configuration. + #[serde(rename = "h", default, skip_serializing_if = "std::ops::Not::not")] + pub hoisted: bool, /// Where a global declaration is written in its file #[serde(rename = "o", default, skip_serializing_if = "is_zero")] pub order: u32, @@ -146,6 +151,17 @@ impl Ord for StyleSheetProperty { } impl StyleSheetProperty { + fn emission_identity(&self) -> Self { + let mut emitted = self.clone(); + match emitted.selector.as_mut() { + // Authored globals need their owner to sort by file before source order. + Some(StyleSelector::Global(_, owner)) if emitted.order == 0 => owner.clear(), + Some(StyleSelector::At { file, .. }) => *file = None, + Some(StyleSelector::Global(..) | StyleSelector::Selector(_)) | None => {} + } + emitted + } + fn same_rule(&self, other: &Self) -> bool { self.class_name == other.class_name && self.selector == other.selector } @@ -270,6 +286,8 @@ where } #[derive(Default, Deserialize, Serialize, Debug)] pub struct StyleSheet { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub atom_plan: Option>, #[serde(deserialize_with = "deserialize_btree_map_u8", default)] pub properties: BTreeMap, #[serde(default)] @@ -290,6 +308,22 @@ pub struct StyleSheet { } impl StyleSheet { + /// Borrow the sheet with its naming generation for build-cache fingerprints. + pub fn export_snapshot(&self) -> impl Serialize + '_ { + #[derive(Serialize)] + struct Export<'a> { + #[serde(flatten)] + sheet: &'a StyleSheet, + #[serde(rename = "atomNamingVersion")] + atom_naming_version: u8, + } + + Export { + sheet: self, + atom_naming_version: 2, + } + } + #[allow(clippy::too_many_arguments)] pub fn add_property( &mut self, @@ -336,6 +370,7 @@ impl StyleSheet { selector: selector.cloned(), layer: layer.map(ToString::to_string), typography: false, + hoisted: false, order: 0, }, ) @@ -346,8 +381,14 @@ impl StyleSheet { level: u8, style_order: Option, filename: Option<&str>, - prop: StyleSheetProperty, + mut prop: StyleSheetProperty, ) -> bool { + freeze_atom_plan(); + if is_atom_hoist() && self.atom_plan.is_none() { + self.atom_plan = atom_plan(); + } + prop.hoisted = + is_atom_hoist() && style_order != Some(0) && filename.is_some_and(is_hoisted_bucket); // register global css file for cache if let Some( StyleSelector::Global(_, file) @@ -506,10 +547,26 @@ impl StyleSheet { } pub fn set_theme(&mut self, theme: Theme) { - set_theme_token_levels( - theme.get_length_token_levels(), - theme.get_shadow_token_levels(), - ); + let length = theme.get_length_token_levels(); + let shadow = theme.get_shadow_token_levels(); + let first_length = length + .keys() + .filter_map(|token| { + theme + .get_default_length_value(token) + .map(|value| (token.clone(), value.to_string())) + }) + .collect(); + let first_shadow = shadow + .keys() + .filter_map(|token| { + theme + .get_default_shadow_value(token) + .map(|value| (token.clone(), value.to_string())) + }) + .collect(); + set_theme_token_levels(length, shadow); + set_theme_token_values(first_length, first_shadow); set_typography_keys(theme.typography.keys().cloned().collect()); set_color_tokens(theme.get_color_token_names()); set_tailwind_css(&theme.tailwind_css); @@ -521,19 +578,32 @@ impl StyleSheet { styles: &FxHashSet, filename: &str, single_css: bool, + ) -> (bool, bool) { + let _atom_mode = css::atom_hoist::AtomModeGuard::new(single_css); + self.update_styles_in_bucket(styles, filename, single_css) + } + + /// Update placement under the caller's effective mode; global buckets are not single-CSS mode. + pub fn update_styles_in_bucket( + &mut self, + styles: &FxHashSet, + filename: &str, + global_bucket: bool, ) -> (bool, bool) { let mut collected = false; let mut updated_base_style = false; - // Decouple class NAMING from property BUCKETING. atom_hoist uses GLOBAL - // (prefix-less, shared-registry) names like single_css, but still keeps - // per-file property buckets so create_css can route each atom to the - // global chunk or a per-route chunk based on its route usage. - let name_scope = if single_css || is_atom_hoist() { - None - } else { - Some(filename) + freeze_atom_plan(); + if is_atom_hoist() && self.atom_plan.is_none() { + self.atom_plan = atom_plan(); + } + let name_scope = if global_bucket { None } else { Some(filename) }; + let bucket_scope = if global_bucket { None } else { Some(filename) }; + let atom_mode = is_atom_hoist(); + let shared_bucket = global_bucket || (atom_mode && is_hoisted_bucket(filename)); + let updates_shared = |order: Option| { + order == Some(0) + || (atom_mode && (shared_bucket || order.is_some_and(|order| order != 255))) }; - let bucket_scope = if single_css { None } else { Some(filename) }; let mut rules: Vec<(&str, u32, &str)> = Vec::new(); let mut imports: Vec<(&str, u32, &str)> = Vec::new(); let mut font_faces: Vec<(&str, u32, &BTreeMap)> = Vec::new(); @@ -587,53 +657,24 @@ impl StyleSheet { |layer| format!("{layer}.{TYPOGRAPHY_LAYER}"), )), typography: true, + hoisted: false, order: 0, }, ) { collected = true; - if st.style_order() == Some(0) { + if updates_shared(st.style_order()) { updated_base_style = true; } } } } ExtractStyleValue::Static(st) => { - let is_first_value = - st.theme_token_resolution() == ThemeTokenResolution::FirstValue; - let resolved_value: Cow<'_, str> = if is_first_value { - if let Some(token) = st.value().strip_prefix('$') { - match st.property() { - "box-shadow" => self.theme.get_default_shadow_value(token), - _ => self.theme.get_default_length_value(token), - } - .map_or_else( - || Cow::Borrowed(st.value()), - |v| Cow::Owned(v.to_string()), - ) - } else { - Cow::Borrowed(st.value()) - } - } else { - Cow::Borrowed(st.value()) - }; - - let class_name = if is_first_value { - let selector = st.class_selector(); - sheet_to_classname( - st.property(), - st.level(), - Some(&resolved_value), - selector.as_deref(), - st.style_order(), - name_scope, - ) - } else { - match st.extract(name_scope) { - StyleProperty::ClassName(cls) - | StyleProperty::Variable { - class_name: cls, .. - } => cls, - } + let resolved_value = st.resolved_value(); + let class_name = match st.extract(name_scope) { + StyleProperty::ClassName(cls) + | StyleProperty::Variable { + class_name: cls, .. + } => cls, }; if self.insert_property( @@ -647,11 +688,12 @@ impl StyleSheet { selector: st.selector().cloned(), layer: st.layer().map(ToString::to_string), typography: false, + hoisted: false, order: st.order(), }, ) { collected = true; - if st.style_order() == Some(0) { + if updates_shared(st.style_order()) { updated_base_style = true; } } @@ -692,7 +734,7 @@ impl StyleSheet { } { collected = true; - if dy.style_order() == Some(0) { + if updates_shared(dy.style_order()) { updated_base_style = true; } } @@ -726,6 +768,7 @@ impl StyleSheet { bucket_scope, ) { collected = true; + updated_base_style |= atom_mode && global_bucket; } } ExtractStyleValue::Css(cs) => { @@ -1151,6 +1194,12 @@ impl StyleSheet { .then_with(|| a.3.property.cmp(&b.3.property)) .then_with(|| a.3.value.cmp(&b.3.value)) }); + if self.atom_plan.is_some() { + // Dedup only adjacent declarations: red/blue/red must keep its last red. + global_props.dedup_by(|a, b| { + a.0 == b.0 && a.1 == b.1 && a.3.property == b.3.property && a.3.value == b.3.value + }); + } let mut open_level: Option = None; let mut open_selector: Option<&str> = None; @@ -1192,38 +1241,6 @@ impl StyleSheet { &HEADER } - /// Compute the set of atom class names that should be hoisted into the - /// global stylesheet under atom-level hoisting. - /// - /// An atom (uniquely identified by its `class_name` under global naming) is - /// hoisted when the number of routes that transitively use it reaches the - /// configured threshold. Base styles (`style_order == 0`) are excluded - /// because they are already emitted globally and shared by every chunk. - fn compute_hoisted_atoms(&self, threshold: usize) -> FxHashSet { - // atom class_name -> set of files that reference it (order != 0) - let mut atom_files: FxHashMap<&str, FxHashSet<&str>> = FxHashMap::default(); - for (filename, property_map) in &self.properties { - for (style_order, level_map) in property_map { - if *style_order == 0 { - continue; - } - for props in level_map.values() { - for prop in props { - atom_files - .entry(prop.class_name.as_str()) - .or_default() - .insert(filename.as_str()); - } - } - } - } - atom_files - .into_iter() - .filter(|(_, files)| route_count_for_files(files.iter().copied()) >= threshold) - .map(|(class_name, _)| class_name.to_string()) - .collect() - } - #[must_use] pub fn create_css(&self, filename: Option<&str>, import_main_css: bool) -> String { let mut css = String::with_capacity(4096); @@ -1243,11 +1260,6 @@ impl StyleSheet { let write_global = filename.is_none(); - // Under atom-level hoisting, decide which atoms (order != 0) live in the - // shared global stylesheet vs. their per-route chunk. - let hoisted_atoms: Option> = - atom_hoist_threshold().map(|threshold| self.compute_hoisted_atoms(threshold)); - if write_global { let mut style_orders: BTreeSet = BTreeSet::new(); // Aggregate the `order == 0` base props by BORROWED reference rather than @@ -1341,7 +1353,20 @@ impl StyleSheet { // Collect layered styles while creating base CSS let mut layered_styles: LayeredStyles = BTreeMap::new(); - let base_css = self.create_style_with_layers(&base_styles, Some(&mut layered_styles)); + let base_css = if self.atom_plan.is_some() { + let base_styles: BTreeMap> = base_styles + .iter() + .map(|(level, props)| { + ( + *level, + props.iter().map(|prop| prop.emission_identity()).collect(), + ) + }) + .collect(); + self.create_style_with_layers(&base_styles, Some(&mut layered_styles)) + } else { + self.create_style_with_layers(&base_styles, Some(&mut layered_styles)) + }; if !base_css.is_empty() { push_fmt!(&mut css, "@layer b{{{base_css}}}"); } @@ -1369,7 +1394,7 @@ impl StyleSheet { // Atom hoisting: emit shared (hoisted) order!=0 atoms into the global // stylesheet, aggregated across every file and deduplicated by atom // identity (class_name). - if let Some(hoisted) = &hoisted_atoms { + { let mut aggregated: BTreeMap>> = BTreeMap::new(); for property_map in self.properties.values() { @@ -1379,13 +1404,13 @@ impl StyleSheet { } for (level, props) in level_map { for prop in props { - if hoisted.contains(&prop.class_name) { + if prop.hoisted { aggregated .entry(*style_order) .or_default() .entry(*level) .or_default() - .insert(prop.clone()); + .insert(prop.emission_identity()); } } } @@ -1441,23 +1466,17 @@ impl StyleSheet { } // Under atom hoisting, hoisted atoms were emitted globally; the // per-route chunk keeps only its route-private atoms. - let current_css = if let Some(hoisted) = &hoisted_atoms { + let current_css = { // Common case: none of this map's atoms were hoisted, so the // filtered map would equal the original — skip the clone-collect // entirely and borrow `map` directly. - let any_hoisted = map - .values() - .flatten() - .any(|prop| hoisted.contains(&prop.class_name)); + let any_hoisted = map.values().flatten().any(|prop| prop.hoisted); if any_hoisted { let filtered: BTreeMap> = map .iter() .filter_map(|(level, props)| { - let kept: FxHashSet = props - .iter() - .filter(|prop| !hoisted.contains(&prop.class_name)) - .cloned() - .collect(); + let kept: FxHashSet = + props.iter().filter(|prop| !prop.hoisted).cloned().collect(); (!kept.is_empty()).then_some((*level, kept)) }) .collect(); @@ -1468,8 +1487,6 @@ impl StyleSheet { } else { self.create_style(map) } - } else { - self.create_style(map) }; if !current_css.is_empty() { @@ -1559,12 +1576,6 @@ mod tests { assert_debug_snapshot!(sheet.create_css(None, false).split("*/").nth(1).unwrap()); } - // Atom-level hoisting emission. Without an atom-hoist test these branches in - // compute_hoisted_atoms / create_css were uncovered: - // * compute_hoisted_atoms skips style_order 0 - // * the global hoist emission skips style_order 0 - // * the global hoist emission wraps a hoisted order != 255 in `@layer o{N}` - // * the per-route emission skips a chunk whose atoms were all hoisted away #[test] #[serial] fn create_css_atom_hoisting_emission() { @@ -1575,12 +1586,11 @@ mod tests { reset_class_map(); reset_file_map(); reset_file_routes(); - - // a.tsx and b.tsx each own one distinct route, so an atom referenced by - // BOTH is reached by 2 routes (>= threshold) and gets hoisted. + css::atom_hoist::restore_atom_plan(None); let mut routes = HashMap::new(); - routes.insert("a.tsx".to_string(), HashSet::from([0u32])); - routes.insert("b.tsx".to_string(), HashSet::from([1u32])); + routes.insert("a.tsx".to_string(), HashSet::from([0u32, 1])); + routes.insert("b.tsx".to_string(), HashSet::from([0u32, 1])); + routes.insert("private.tsx".to_string(), HashSet::from([0u32])); set_file_routes(routes); set_atom_hoist(Some(2)); @@ -1617,8 +1627,6 @@ mod tests { Some("b.tsx"), Some("lyr"), ); - // Non-hoisted responsive at-rule atom (only a.tsx): emitted in a.tsx's - // chunk via the break-point at-rule path (level != 0 -> break_point set). let at = StyleSelector::At { kind: AtRuleKind::Media, query: "(hover:hover)".to_string(), @@ -1633,10 +1641,9 @@ mod tests { "blue", Some(&at), Some(255), - Some("a.tsx"), + Some("private.tsx"), ); - // Global stylesheet: runs compute_hoisted_atoms + the hoist emission. let global_css = sheet.create_css(None, false); assert!( global_css.contains("@layer o1"), @@ -1654,6 +1661,7 @@ mod tests { !chunk_css.contains("padding:1px"), "hoisted padding atom must not be in the chunk: {chunk_css}" ); + let chunk_css = sheet.create_css(Some("private.tsx"), false); // The responsive at-rule wrapper AND its property must both be emitted // (exercises the break-point at-rule path). assert!( @@ -1667,6 +1675,7 @@ mod tests { set_atom_hoist(None); reset_file_routes(); + css::atom_hoist::restore_atom_plan(None); } #[test] @@ -1676,7 +1685,9 @@ mod tests { use css::file_routes::{get_file_routes, set_file_routes}; use std::collections::{HashMap, HashSet}; - let previous_threshold = atom_hoist_threshold(); + let previous_threshold = css::atom_hoist::atom_hoist_threshold(); + let previous_plan = atom_plan(); + css::atom_hoist::restore_atom_plan(None); let previous_routes = get_file_routes(); set_atom_hoist(Some(1)); set_file_routes(HashMap::from([( @@ -1702,6 +1713,7 @@ mod tests { let css = sheet.create_css(None, false); set_atom_hoist(previous_threshold); set_file_routes(previous_routes); + css::atom_hoist::restore_atom_plan(previous_plan); assert!( css.contains("@layer o2{@layer components{div{border-radius:9px}}}"), @@ -3044,7 +3056,12 @@ mod tests { ); let mut sheet = StyleSheet::default(); - let output = extract("index.tsx", "import {Box,globalCss,keyframes,Flex} from '@devup-ui/core';;keyframes({from:{opacity:0},to:{opacity:1}});;globalCss`div{color:red}`;globalCss({div:{display:'flex'},imports:['https://test.com/a.css'],fontFaces:[{fontFamily:'Roboto',src:'url(/fonts/Roboto-Regular.ttf)'}]})", ExtractOption { package: "@devup-ui/core".to_string(), css_dir: "@devup-ui/core".to_string(), single_css: true, import_main_css: false, import_aliases: std::collections::HashMap::new() }).unwrap(); + let output = extract( + "index.tsx", + "import {Box,globalCss,keyframes,Flex} from '@devup-ui/core';;keyframes({from:{opacity:0},to:{opacity:1}});;globalCss`div{color:red}`;globalCss({div:{display:'flex'},imports:['https://test.com/a.css'],fontFaces:[{fontFamily:'Roboto',src:'url(/fonts/Roboto-Regular.ttf)'}]})", + ExtractOption { package: "@devup-ui/core".to_string(), css_dir: "@devup-ui/core".to_string(), single_css: true, import_main_css: false, import_aliases: std::collections::HashMap::new() }, + ) + .unwrap(); sheet.update_styles(&output.styles, "index.tsx", true); assert_debug_snapshot!(sheet.create_css(None, true).split("*/").nth(1).unwrap()); } @@ -3300,6 +3317,7 @@ mod tests { selector: None, layer: None, typography: false, + hoisted: false, order: 0, }; assert_eq!(make("color", "red").cmp(&make("color", "red")), Equal); @@ -3319,6 +3337,7 @@ mod tests { selector, layer: None, typography: false, + hoisted: false, order: 0, }; let hover = || Some(StyleSelector::Selector("&:hover".to_string())); @@ -3374,11 +3393,20 @@ mod tests { #[test] #[serial] - fn test_compute_hoisted_atoms_skips_base_style_order() { + fn base_styles_emit_globally_without_atom_promotion() { let mut sheet = StyleSheet::default(); sheet.add_property("base", "color", 0, "red", None, Some(0), Some("test.tsx")); - assert!(sheet.compute_hoisted_atoms(0).is_empty()); + assert!( + sheet + .create_css(None, false) + .contains(concat!(".base{", "color:red}")) + ); + assert!( + !sheet + .create_css(Some("test.tsx"), false) + .contains("color:red") + ); } #[test] @@ -4147,13 +4175,7 @@ mod tests { let output = extract( "global.tsx", "import {globalCss} from '@devup-ui/core';globalCss({ body: { color: '$text', border: '1px solid $line.100' } })", - ExtractOption { - package: "@devup-ui/core".to_string(), - css_dir: "@devup-ui/core".to_string(), - single_css: true, - import_main_css: false, - import_aliases: std::collections::HashMap::new(), - }, + ExtractOption { package: "@devup-ui/core".to_string(), css_dir: "@devup-ui/core".to_string(), single_css: true, import_main_css: false, import_aliases: std::collections::HashMap::new() }, ) .unwrap(); sheet.update_styles(&output.styles, "global.tsx", true); @@ -4231,13 +4253,7 @@ mod tests { let output = extract( "global.tsx", "import {globalCss} from '@devup-ui/core';globalCss({ body: { color: 'red', _motionReduce: { transition: 'none' } } })", - ExtractOption { - package: "@devup-ui/core".to_string(), - css_dir: "@devup-ui/core".to_string(), - single_css: true, - import_main_css: false, - import_aliases: std::collections::HashMap::new(), - }, + ExtractOption { package: "@devup-ui/core".to_string(), css_dir: "@devup-ui/core".to_string(), single_css: true, import_main_css: false, import_aliases: std::collections::HashMap::new() }, ) .unwrap(); sheet.update_styles(&output.styles, "global.tsx", true); diff --git a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap index 12f3bb9d5..bc130cfab 100644 --- a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap +++ b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap @@ -3,6 +3,7 @@ source: libs/sheet/src/lib.rs expression: sheet --- StyleSheet { + atom_plan: None, properties: { "": { 255: { @@ -14,6 +15,7 @@ StyleSheet { selector: None, layer: None, typography: false, + hoisted: false, order: 0, }, }, diff --git a/packages/bun-plugin/src/__tests__/order-independent-build.helper.ts b/packages/bun-plugin/src/__tests__/order-independent-build.helper.ts new file mode 100644 index 000000000..11b5bc2e9 --- /dev/null +++ b/packages/bun-plugin/src/__tests__/order-independent-build.helper.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict' +import { basename, join, relative, resolve } from 'node:path' + +import type { BunPlugin, OnLoadCallback, PluginBuilder } from 'bun' + +const [fixture, schedule] = process.argv.slice(2) +assert(fixture && (schedule === 'AB' || schedule === 'BA')) +process.chdir(fixture) + +// Import only after entering the isolated fixture: the plugin resolves df at import time. +const { DevupUI } = await import('../plugin') +const { getUnreservedClassSlotCount } = await import('@devup-ui/wasm') +const a = join(fixture, 'src/A.tsx') +const b = join(fixture, 'src/B.tsx') +const entrypoints = [a, b] +const schedules = { + AB: [a, b], + BA: [b, a], +} as const +const [first, second] = schedules[schedule] + +// These accumulators record custody; they never supply source or replacement output. +const waiting = new Map void>() +const dispatched: string[] = [] +const transformed = new Map() + +function control(callback: OnLoadCallback): OnLoadCallback { + return async (args) => { + assert.equal(args.namespace, 'file') + assert( + !waiting.has(args.path), + 'each genuine source callback is dispatched once', + ) + await new Promise((release) => { + waiting.set(args.path, release) + // Both native callbacks must arrive before either production callback executes. + if (waiting.size === 2) { + const start = waiting.get(first) + assert(start) + start() + } + }) + dispatched.push(args.path) + try { + const result = await callback(args) + assert( + result && 'contents' in result && typeof result.contents === 'string', + ) + transformed.set(basename(args.path), result.contents) + return result + } finally { + // A real callback failure must also let the other callback finish the native build. + if (args.path === first) { + const next = waiting.get(second) + assert(next) + next() + } + } + } +} + +function decorate(build: PluginBuilder): PluginBuilder { + const onLoad: PluginBuilder['onLoad'] = (options, callback) => + build.onLoad(options, (args) => { + if (options.namespace !== undefined || !entrypoints.includes(args.path)) + return callback(args) + return control(callback)(args) + }) + return new Proxy(build, { + get(target, key) { + if (key === 'onLoad') return onLoad + const value: unknown = Reflect.get(target, key, target) + return typeof value === 'function' ? value.bind(target) : value + }, + }) +} + +const genuine = DevupUI() +const observed: BunPlugin = { + ...genuine, + async setup(build) { + await genuine.setup(decorate(build)) + }, +} +const result = await Bun.build({ + root: fixture, + entrypoints, + target: 'browser', + format: 'esm', + external: ['react', 'react/jsx-runtime'], + plugins: [observed], +}) +assert(result.success, result.logs.map(String).join('\n')) +assert.deepEqual(dispatched, [first, second]) +assert.equal(transformed.size, 2) + +const artifacts = new Map() +for (const artifact of result.outputs) { + const path = relative(fixture, resolve(artifact.path)).replaceAll('\\', '/') + artifacts.set( + path, + Buffer.from(await artifact.arrayBuffer()).toString('base64'), + ) +} +process.stdout.write( + JSON.stringify({ + transformed: Object.fromEntries(transformed), + artifacts: Object.fromEntries(artifacts), + residual: getUnreservedClassSlotCount(), + }), +) diff --git a/packages/bun-plugin/src/__tests__/order-independent-build.test.ts b/packages/bun-plugin/src/__tests__/order-independent-build.test.ts new file mode 100644 index 000000000..d2a88702d --- /dev/null +++ b/packages/bun-plugin/src/__tests__/order-independent-build.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +function stringRecord(value: unknown): Readonly> { + assert(value !== null && typeof value === 'object' && !Array.isArray(value)) + const entries: readonly (readonly [string, unknown])[] = Object.entries(value) + return Object.fromEntries( + entries.map(([key, text]) => { + assert(typeof text === 'string') + return [key, text] as const + }), + ) +} + +function run(fixture: string, schedule: 'AB' | 'BA') { + const child = Bun.spawnSync( + [ + process.execPath, + join(import.meta.dir, 'order-independent-build.helper.ts'), + fixture, + schedule, + ], + { + cwd: fixture, + stdout: 'pipe', + stderr: 'pipe', + timeout: 30000, + env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, + }, + ) + assert.equal( + child.exitCode, + 0, + child.stdout.toString() + child.stderr.toString(), + ) + assert( + child.stdout.length > 0, + `child exited without a build receipt: ${child.stderr.toString()}`, + ) + const receipt: unknown = JSON.parse(child.stdout.toString()) + assert(receipt !== null && typeof receipt === 'object') + assert('transformed' in receipt && 'artifacts' in receipt) + assert('residual' in receipt && typeof receipt.residual === 'number') + expect(receipt.residual).toBe(0) + return { + transformed: stringRecord(receipt.transformed), + artifacts: stringRecord(receipt.artifacts), + } +} + +it('emits identical classes, variables and bundled bytes when source callbacks run AB or BA', () => { + // Given: the same physical files and IDs, with no runtime preload or prior df state. + const fixture = mkdtempSync(join(tmpdir(), 'devup-bun-slot-order-')) + try { + mkdirSync(join(fixture, 'src')) + writeFileSync(join(fixture, 'bunfig.toml'), '') + writeFileSync(join(fixture, 'devup.json'), '{}\n') + writeFileSync( + join(fixture, 'src/A.tsx'), + 'import { Box } from "@devup-ui/react";\nexport const A = (value: string) => ;\n', + ) + writeFileSync( + join(fixture, 'src/B.tsx'), + 'import { Box } from "@devup-ui/react";\nexport const B = (value: string) => ;\n', + ) + + // When: genuine setup and Bun.build execute in fresh processes with opposite release orders. + const ab = run(fixture, 'AB') + rmSync(join(fixture, 'df'), { recursive: true, force: true }) + const ba = run(fixture, 'BA') + + // Then: both builds really extracted the styles, and every observed byte is order-independent. + for (const receipt of [ab, ba]) { + expect(Object.keys(receipt.transformed).sort()).toEqual([ + 'A.tsx', + 'B.tsx', + ]) + const outputs = Object.entries(receipt.artifacts) + const css = outputs.filter(([path]) => path.endsWith('.css')) + const js = outputs.filter(([path]) => path.endsWith('.js')) + expect(css.length).toBeGreaterThan(0) + expect(js).toHaveLength(2) + const styles = css + .map(([, bytes]) => Buffer.from(bytes, 'base64').toString()) + .join('\n') + expect(styles).toMatch(/color:\s*red\s*[;}]/) + expect(styles).toMatch(/background:\s*(?:blue|#00f|#0000ff)\s*[;}]/) + expect(styles).toMatch(/gap:\s*var\(--[a-z0-9_-]+\)/) + expect(styles).toMatch(/width:\s*var\(--[a-z0-9_-]+\)/) + for (const code of Object.values(receipt.transformed)) { + expect(code).not.toMatch(/\bBox\b/) + expect(code).toContain('
{ + // Given: a fresh real engine in an isolated child, never the suite's mocked WASM. + const fixture = mkdtempSync(join(tmpdir(), 'devup-reservation-boundary-')) + const engineUrl = pathToFileURL( + resolve(import.meta.dir, '../../../../bindings/devup-ui-wasm/pkg/index.js'), + ).href + const script = ` +import assert from 'node:assert/strict'; +const engine = await import(${JSON.stringify(engineUrl)}); +const pkg = '@devup-ui/react'; +const a = 'import {Box} from "@devup-ui/react"; export const A=(value:string)=>;'; +const b = 'import {Box} from "@devup-ui/react"; export const B=(value:string)=>;'; +const rows = [['boundary/A.tsx', a, './css'], ['boundary/B.tsx', b, './css']]; +function state() { + return [engine.exportClassMap(), engine.exportFileMap(), engine.exportSheet(), + engine.getCss(null, false), engine.getDefaultTheme(), engine.getUnreservedClassSlotCount()]; +} +engine.resetBuildState(); +engine.setDebug(false); +engine.registerTheme({colors:{default:{brand:'red'}}}); +engine.reserveClassSlots([rows[0]], pkg, true, true, false, {}); +const existing = engine.codeExtract(rows[0][0], rows[0][1], pkg, rows[0][2], true, true, false, {}); +existing.free(); +engine.codeExtract('virtual.tsx', b, pkg, './css', true, true, false, {}).free(); +const before = state(); +assert(before[5] > 0); +for (const malformed of [null, {}, [null], ['abc'], [[42,a,'./css']], + [[rows[0][0],a]], [[rows[0][0],a,'./css','extra']], + [rows[0], ['later.tsx',b,'./css','extra']]]) { + assert.throws(() => engine.reserveClassSlots(malformed, pkg, true, true, false, {})); + assert.deepEqual(state(), before); +} +assert.throws(() => engine.reserveClassSlots(rows, pkg, true, true, false, 42)); +assert.deepEqual(state(), before); +const runs = []; +for (const order of [[0,1],[1,0]]) { + engine.resetBuildState(); + engine.registerTheme({colors:{default:{brand:'red'}}}); + const sheet = engine.exportSheet(); + const css = engine.getCss(null,false); + engine.reserveClassSlots([...rows, ['boundary/Unused.tsx', + 'import {Box} from "@devup-ui/react"; ;', './css']], + pkg,true,true,false,{}); + assert.equal(engine.exportSheet(),sheet); + assert.equal(engine.getCss(null,false),css); + assert.equal(engine.getDefaultTheme(),'default'); + assert.equal(engine.getUnreservedClassSlotCount(),0); + const sources = {}; + for (const index of order) { + const [filename,code,cssDir] = rows[index]; + const output = engine.codeExtract(filename,code,pkg,cssDir,true,true,false,{}); + try { + assert(!output.code.includes(' { + // Given / When / Then: all assertions exercise the genuine child build and engine. + run(String.raw` +import assert from 'node:assert/strict'; +import {writeFileSync,readFileSync} from 'node:fs'; +import {resolve} from 'node:path'; +const {DevupUI}=await import(${JSON.stringify(pluginUrl)}); +const engine=await import(${JSON.stringify(wasmUrl)}); +const generated='import {Box} from "@devup-ui/react"; export const Generated=()=> ;'; +writeFileSync('src/Entry.tsx','export {Generated} from "./0-Generated";'); +writeFileSync('src/Unused.tsx','import {Box} from "@devup-ui/react"; ;'); +writeFileSync('src/Invalid.tsx','import {css} from "@devup-ui/react"; css({color:props.unknown});'); +let setups=0; +const events=[]; +const genuine=DevupUI(); +const observed={...genuine,async setup(build){ + await genuine.setup(new Proxy(build,{get(target,key){ + if(key==='onStart') return callback=>target.onStart(()=>{events.push('devup-onStart-enter');const result=callback();events.push('devup-onStart-exit');return result;}); + const value=Reflect.get(target,key,target);return typeof value==='function'?value.bind(target):value; + }}));events.push('devup-setup-end'); +}}; +const generator={name:'physical-generator',setup(){events.push('generator-setup');setups++;writeFileSync('src/0-Generated.tsx',generated);}}; +const result=await Bun.build({entrypoints:['./src/Entry.tsx'],target:'browser',external:['react'],plugins:[observed,generator]}); +assert(result.success,result.logs.map(String).join('\n')); +assert.equal(setups,1); +assert.equal(readFileSync('src/0-Generated.tsx','utf8'),generated); +assert.equal(engine.getUnreservedClassSlotCount(),0,JSON.stringify({events,files:engine.exportFileMap()})); +const files=JSON.parse(engine.exportFileMap()); +assert(files[resolve('src/0-Generated.tsx')]o.path.endsWith('.css')).map(o=>o.text()))).join('\n'); +assert(/color:\s*(?:green|#008000)/.test(css)); +assert(!css.includes('magenta')); +assert(result.outputs.some(o=>o.path.endsWith('.js'))); +process.stdout.write('qualified'); +`) +}, 40000) + +it('reserves eligible built-in compiled dist and resets between genuine builds', () => { + run(String.raw` +import assert from 'node:assert/strict'; +import {mkdirSync,writeFileSync} from 'node:fs'; +const {DevupUI}=await import(${JSON.stringify(pluginUrl)}); +const engine=await import(${JSON.stringify(wasmUrl)}); +mkdirSync('node_modules/@devup-ui/reservation-fixture/dist',{recursive:true}); +writeFileSync('node_modules/@devup-ui/reservation-fixture/package.json','{"name":"@devup-ui/reservation-fixture","exports":"./dist/index.js","type":"module"}'); +writeFileSync('node_modules/@devup-ui/reservation-fixture/dist/index.js','import {css} from "@devup-ui/react"; export const built=css({color:"green"});'); +writeFileSync('src/Entry.tsx','export {built} from "@devup-ui/reservation-fixture";'); +const outputs=[]; +for(let i=0;i<2;i++){ + const result=await Bun.build({entrypoints:['./src/Entry.tsx'],plugins:[DevupUI()]}); + assert(result.success,result.logs.map(String).join('\n')); + assert.equal(engine.getUnreservedClassSlotCount(),0); + const css=(await Promise.all(result.outputs.filter(o=>o.path.endsWith('.css')).map(o=>o.text()))).join('\n'); + assert(/color:\s*(?:green|#008000)/.test(css)); + outputs.push({css,classes:engine.exportClassMap(),files:engine.exportFileMap()}); +} +assert.deepEqual(outputs[0],outputs[1]); +process.stdout.write('qualified'); +`) +}, 40000) + +it.each(['default', 'short'] as const)( + 'preserves runtime default/eager naming through real module imports: %s', + (mode) => { + run(` +import assert from 'node:assert/strict'; +import {writeFileSync,readFileSync} from 'node:fs'; +const {register}=await import(${JSON.stringify(pluginUrl)}); +const engine=await import(${JSON.stringify(wasmUrl)}); +writeFileSync('src/A.ts','import {css} from "@devup-ui/react"; export const cls=css({color:"red"});'); +writeFileSync('src/B.ts','import {css} from "@devup-ui/react"; export const cls=css({background:"blue"});'); +writeFileSync('src/Unused.ts','import {css} from "@devup-ui/react"; export const cls=css({color:"magenta"});'); +await register(${mode === 'short' ? '{debug:false}' : '{}'}); +assert.equal(engine.isDebug(),${mode !== 'short'}); +const b=await import(process.cwd()+'/src/B.ts'); +const a=await import(process.cwd()+'/src/A.ts'); +assert.equal(engine.getUnreservedClassSlotCount(),0); +const css=readFileSync('df/devup-ui/devup-ui.css','utf8'); +assert(css.includes('color:red')&&css.includes('background:blue')&&!css.includes('magenta')); +assert(typeof a.cls==='string'&&typeof b.cls==='string'); +${mode === 'short' ? "assert(/^[a-z0-9_-]+$/.test(a.cls)); assert.equal(a.cls,'a');assert.equal(b.cls,'b');" : "assert(a.cls.includes('color-0-red'));assert(b.cls.includes('background-0-blue'));"} +process.stdout.write('qualified'); +`) + }, + 40000, +) diff --git a/packages/bun-plugin/src/__tests__/reservation-roots.cjs b/packages/bun-plugin/src/__tests__/reservation-roots.cjs new file mode 100644 index 000000000..c94f4ade7 --- /dev/null +++ b/packages/bun-plugin/src/__tests__/reservation-roots.cjs @@ -0,0 +1,64 @@ +const assert = require('node:assert/strict') +const { readFileSync, writeFileSync } = require('node:fs') +const { relative, resolve } = require('node:path') +const { build } = require('bun') +const wasm = require('@devup-ui/wasm') + +const [root, mode] = process.argv.slice(2) +process.chdir(root) +const batches = [] +const extracts = [] +const reserve = wasm.reserveClassSlots +wasm.reserveClassSlots = (...args) => { + reserve(...args) + batches.push(args[0]) +} +const extract = wasm.codeExtract +wasm.codeExtract = (...args) => { + const output = extract(...args) + extracts.push([args[0], output.code]) + return output +} +const { DevupUI, register } = require('../../dist/register.cjs') + +async function main() { + let assets = {} + if (mode === 'build') { + const result = await build({ + entrypoints: ['./index.ts'], + outdir: './out', + plugins: [DevupUI()], + }) + assert(result.success, result.logs.map(String).join('\n')) + for (const output of result.outputs) { + const bytes = await output.text() + assets[relative(root, output.path)] = bytes + writeFileSync(output.path, bytes) + } + } else { + await register(mode === 'default' ? {} : { debug: false }) + const module = await import( + resolve(mode === 'outside' ? 'outside.ts' : 'src/A.ts') + ) + assert.equal(typeof module.cls, 'string') + assets = { + css: readFileSync('df/devup-ui/devup-ui.css', 'utf8'), + cls: module.cls, + } + } + process.stdout.write( + JSON.stringify({ + assets, + batches, + extracts, + debug: wasm.isDebug(), + classes: wasm.exportClassMap(), + files: wasm.exportFileMap(), + count: wasm.getUnreservedClassSlotCount(), + }), + ) +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/bun-plugin/src/__tests__/reservation-roots.test.ts b/packages/bun-plugin/src/__tests__/reservation-roots.test.ts new file mode 100644 index 000000000..2f506dd6c --- /dev/null +++ b/packages/bun-plugin/src/__tests__/reservation-roots.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'devup-bun-exact-')) + mkdirSync(join(root, 'src')) + mkdirSync(join(root, 'out')) + writeFileSync(join(root, 'bunfig.toml'), '') + writeFileSync(join(root, 'devup.json'), '{}') + writeFileSync( + join(root, 'src/A.ts'), + 'import {css} from "@devup-ui/react"; export const cls=css({color:"red"});', + ) + writeFileSync( + join(root, 'raw.css.ts'), + 'import {createVar} from "@vanilla-extract/css"; export const variable=createVar();', + ) + writeFileSync( + join(root, 'index.ts'), + 'import {css} from "@devup-ui/react"; import {variable} from "./raw.css.ts"; export const cls=css({color:"blue"}); console.log(variable,"import {css} from @devup-ui/react");', + ) + return root +} + +function run(root: string, mode: string) { + const child = Bun.spawnSync( + [ + process.execPath, + join(import.meta.dir, 'reservation-roots.cjs'), + root, + mode, + ], + { + cwd: root, + stdout: 'pipe', + stderr: 'pipe', + timeout: 30000, + env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, + }, + ) + assert.equal( + child.exitCode, + 0, + child.stdout.toString() + child.stderr.toString(), + ) + const result: unknown = JSON.parse(child.stdout.toString()) + assert( + result && + typeof result === 'object' && + 'count' in result && + 'batches' in result && + 'debug' in result, + ) + return result +} + +it.each(['build', 'runtime'])( + 'keeps actual %s root output/allocator/work equal cold, warm and with stale siblings', + (mode) => { + // Given: exact native root entry/relative raw supplier, or D9 runtime input. + const root = fixture() + try { + const cold = run(root, mode) + const warm = run(root, mode) + expect(warm).toEqual(cold) + for (const directory of ['dist', '.next/server', 'unused']) { + mkdirSync(join(root, directory), { recursive: true }) + writeFileSync( + join(root, directory, 'stale.ts'), + 'import {css} from "@devup-ui/react"; export const stale=css({color:"chartreuse"});', + ) + } + writeFileSync( + join(root, 'bun.config.ts'), + 'import {css} from "@devup-ui/react"; export default css({margin:"99px"});', + ) + // When: fresh native processes build/import unchanged input. + const stale = run(root, mode) + // Then: emitted bytes, names, allocators and complete observed work are equal. + expect(stale).toEqual(cold) + expect(stale.count).toBe(0) + expect(stale.debug).toBe(false) + expect(Array.isArray(stale.batches) && stale.batches.length).toBe(1) + if (mode === 'build') + expect(JSON.stringify(stale.batches)).toContain('raw.css.ts') + else expect(JSON.stringify(stale.batches)).not.toContain('raw.css.ts') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 120000, +) + +it.each(['outside', 'default'])( + 'preserves real runtime residual/default behavior in %s mode', + (mode) => { + // Given: an outside-D9 runtime source never promised by a static build entry. + const root = fixture() + try { + writeFileSync( + join(root, 'outside.ts'), + 'import {css} from "@devup-ui/react"; export const cls=css({padding:"31px"});', + ) + // When: real runtime imports it in short mode, or uses default readable mode. + const result = run(root, mode) + // Then: outside short allocations are observable; default true has no reservation. + if (mode === 'outside') { + expect(result.count).toBeGreaterThan(0) + expect(JSON.stringify(result.batches)).not.toContain('outside.ts') + } else { + expect(result.debug).toBe(true) + expect(result.count).toBe(0) + expect(result.batches).toEqual([]) + } + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 40000, +) diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index a6f0c43e4..af359f0e4 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -7,6 +7,7 @@ import { collectNumberedFiles, createCompatTypes, createModuleResolver, + createSharedSlotReservation, createThemeInterfaceArgs, type CustomShorthands, loadDevupConfig, @@ -21,6 +22,7 @@ import { getThemeInterface, registerShorthands, registerTheme, + reserveClassSlots, resetBuildState, seedFileMap, setDebug, @@ -89,20 +91,6 @@ async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { registerShorthands(shorthands ?? {}) setPrefix(null) setModuleResolver(createModuleResolver()) - // Number every source file in path order, so class prefixes do not depend - // on the order Bun loads files in - try { - seedFileNumbers( - { seedFileMap }, - collectNumberedFiles({ - roots: [resolve('src')], - needles: compiledPackages, - toId: (path) => path, - }), - ) - } catch { - // Best-effort; numbering falls back to arrival order. - } if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') await writeFile( @@ -183,7 +171,54 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { const endBuild = beginBuild({ resetBuildState }) build.onEnd?.(endBuild) await initialize(options) - setDebug(options.debug ?? !bundling) + const debug = options.debug ?? !bundling + setDebug(debug) + const slots = createSharedSlotReservation({ reserveClassSlots }) + let reservationPending = bundling + const reserveFiles = () => { + slots.invalidate() + slots.reserve( + { + roots: [resolve('src'), resolve('app')], + entries: bundling + ? build.config.entrypoints.map((entry) => ({ + request: resolve(entry), + context: process.cwd(), + })) + : [], + cwd: process.cwd(), + package: libPackage, + importAliases, + toId: (path) => path, + }, + { + singleCss, + importMainCssInCode: true, + importMainCssInCss: false, + cssDir: ({ physicalPath }) => + relative(dirname(physicalPath), cssDir).replaceAll('\\', '/'), + }, + ) + } + if (bundling) + build.onStart(() => { + reservationPending = true + }) + else if (!debug) reserveFiles() + else { + try { + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve('src')], + needles: compiledPackages, + toId: (path) => path, + }), + ) + } catch (error) { + if (!(error instanceof Error)) throw error + } + } // Resolve devup-ui CSS files onto a path-free virtual id, so nothing // derived from this checkout's cwd can be baked into Bun's shared, @@ -211,7 +246,13 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { { filter: /\.(?:tsx?|jsx|mjs)$|[\\/]@devup-ui[\\/].*\.js$/, }, - ({ path }) => loadSourceFile(path, bundling), + ({ path }) => { + if (reservationPending) { + reserveFiles() + reservationPending = false + } + return loadSourceFile(path, bundling) + }, ) }, } satisfies BunPlugin diff --git a/packages/next-plugin/src/__tests__/atom-hoist-harness.ts b/packages/next-plugin/src/__tests__/atom-hoist-harness.ts new file mode 100644 index 000000000..4b562fd3d --- /dev/null +++ b/packages/next-plugin/src/__tests__/atom-hoist-harness.ts @@ -0,0 +1,131 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +export function builtPluginEntry(packageName: string, entry = 'index.mjs') { + return JSON.stringify( + pathToFileURL( + resolve(import.meta.dir, '../../../', packageName, 'dist', entry), + ).href, + ) +} + +// A fresh process keeps bun:test's package mocks and WASM globals out of the +// integration. The plugin, not the harness, configures route reach and hoisting. +export function runAtomHoistIntegration( + setup: string, + placement: 'hoisted' | 'per-file' = 'hoisted', +) { + const root = mkdtempSync(join(tmpdir(), 'devup-atom-hoist-')) + const run = (reverse: boolean) => + execFileSync( + process.execPath, + [ + '--eval', + String.raw` +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +const root = ${JSON.stringify(root)}; +const original = process.cwd(); +const sources = { + 'src/shared.tsx': 'import { Box } from "@devup-ui/react"; export const Shared = () => ;', + 'src/app/a/page.tsx': 'import { Box } from "@devup-ui/react"; import { Shared } from "../../shared"; export default function A() { return <>; }', + 'src/app/b/page.tsx': 'import { Box } from "@devup-ui/react"; import { Shared } from "../../shared"; export default function B() { return <>; }', +}; +const files = Object.keys(sources); +const cssDir = join(root, 'df/devup-ui'); +rmSync(join(root, 'df'), { recursive: true, force: true }); +for (const [file, code] of Object.entries(sources)) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), code); +} +mkdirSync(cssDir, { recursive: true }); +writeFileSync(join(root, 'devup.json'), '{}'); +writeFileSync(join(root, 'tsconfig.json'), '{}'); +writeFileSync(join(root, 'package.json'), '{"name":"atom-hoist-fixture","private":true}'); +process.chdir(root); +try { +${setup} + const outputs = {}; + for (const file of ${reverse} ? [...files].reverse() : files) { + outputs[file] = await transform(file, sources[file]); + if (file === 'src/shared.tsx' && ${JSON.stringify(placement)} === 'hoisted') { + const writtenBase = readFileSync(join(cssDir, 'devup-ui.css'), 'utf8'); + assert.match(writtenBase, /color:red(?:;|})/, 'shared CSS dirty signal must reach disk'); + assert.match(writtenBase, /padding:16px(?:;|})/); + } + } + const base = await loadCss('devup-ui.css'); + const sheets = {}; + const classes = code => [...code.matchAll(/className\s*=\s*(?:\{\s*)?["']([^"']+)["']/g)] + .flatMap(match => match[1].trim().split(/\s+/)); + const declarations = css => [...css.matchAll(/\.([^\s.{:]+)\{([^{}]+)\}/g)] + .map(match => [match[1], match[2].replace(/;$/, '')]); + const sharedClasses = classes(outputs['src/shared.tsx']); + assert.equal(sharedClasses.length, 2, 'shared JSX must really be transformed'); + if (${JSON.stringify(placement)} === 'hoisted') { + for (const name of sharedClasses) { + assert.equal(declarations(base).filter(([key]) => key === name).length, 1, + 'shared atom must be defined exactly once'); + } + assert.deepEqual(declarations(base).map(([, value]) => value).sort(), ['color:red', 'padding:16px']); + } else { + assert.deepEqual(declarations(base), [], 'unavailable hoisting must leave base atoms empty'); + } + for (const file of files) { + const code = outputs[file]; + const imports = [...code.matchAll(/["']([^"']*devup-ui(?:-\d+)?\.css(?:\?fileNum=\d+)?)["']/g)] + .map(match => match[1].split('/').pop()); + assert.ok(imports.length > 0, 'transformed JSX must import its CSS'); + const loaded = []; + for (const name of imports) { + sheets[name] = await loadCss(name); + if (name !== 'devup-ui.css' && ${JSON.stringify(placement)} === 'hoisted') { + assert.ok(declarations(sheets[name]).every(([, value]) => + value !== 'color:red' && value !== 'padding:16px'), + 'hoisted declarations must not be duplicated in local sheets'); + } + loaded.push(sheets[name]); + } + const local = loaded.join('\n'); + const expected = file === 'src/shared.tsx' ? ['color:red', 'padding:16px'] + : [file.includes('/a/') ? 'opacity:.25' : 'opacity:.75']; + const generated = classes(code); + assert.equal(generated.length, expected.length); + const values = generated.flatMap(name => { + const matches = declarations(base + '\n' + local).filter(([key]) => key === name); + assert.ok(matches.length > 0, 'every generated JSX class must exist in loaded CSS: ' + name); + return [...new Set(matches.map(([, value]) => value))]; + }); + assert.deepEqual(values.sort(), expected.sort()); + if (file !== 'src/shared.tsx' || ${JSON.stringify(placement)} === 'per-file') { + if (file !== 'src/shared.tsx') assert.ok(generated.every(name => !sharedClasses.includes(name))); + for (const value of expected) { + assert.ok(declarations(local).some(([, declaration]) => declaration === value), 'fallback/private styles stay local'); + } + } + } + const clean = css => css.replace(/\/\*[\s\S]*?\*\//g, '').trim(); + console.log(JSON.stringify({ + classes: files.map(file => classes(outputs[file])), + base: clean(base), + sheets: Object.fromEntries(Object.entries(sheets).sort().map(([name, css]) => [name, clean(css)])), + })); + await finish(); +} finally { + process.chdir(original); +} +process.exit(0); +`, + ], + { encoding: 'utf8', timeout: 30_000, env: process.env }, + ).trim() + try { + return [run(false), run(true), run(false)] as const + } finally { + rmSync(root, { recursive: true, force: true }) + } +} diff --git a/packages/next-plugin/src/__tests__/atom-hoist.test.ts b/packages/next-plugin/src/__tests__/atom-hoist.test.ts new file mode 100644 index 000000000..001706d41 --- /dev/null +++ b/packages/next-plugin/src/__tests__/atom-hoist.test.ts @@ -0,0 +1,42 @@ +import { expect, it } from 'bun:test' + +import { builtPluginEntry, runAtomHoistIntegration } from './atom-hoist-harness' + +it('loads real per-file fallback JSX atoms and local CSS in either loader order and fresh builds', () => { + // Given: two App Router pages reach the same styled module. + const setup = ` +process.env.TURBOPACK = '1'; +process.env.NODE_ENV = 'development'; +const { existsSync, symlinkSync } = await import('node:fs'); +const { fileURLToPath } = await import('node:url'); +mkdirSync(join(root, 'node_modules/@devup-ui'), { recursive: true }); +const installedPlugin = join(root, 'node_modules/@devup-ui/next-plugin'); +if (!existsSync(installedPlugin)) { + symlinkSync(dirname(dirname(fileURLToPath(${builtPluginEntry('next-plugin')}))), + installedPlugin, 'junction'); +} +const { DevupUI } = await import(${builtPluginEntry('next-plugin')}); +const { default: loader } = await import(${builtPluginEntry('next-plugin', 'loader.mjs')}); +const { default: cssLoader } = await import(${builtPluginEntry('next-plugin', 'css-loader.mjs')}); +const config = DevupUI({}, { atomHoist: 2, cssDir, distDir: join(root, 'df') }); +const rules = config.turbopack.rules; +const jsOptions = rules['*.{tsx,ts,jsx,js,mjs}'].loaders[0].options; +const cssOptions = rules['./df/devup-ui/*.css'][0].options; +const invoke = (fn, options, request) => new Promise((resolve, reject) => { + fn.call({ resourcePath: request.resourcePath, getOptions: () => options, addDependency() {}, + async: () => (error, result) => error ? reject(error) : resolve(result), + }, request.source); +}); +const transform = (file, source) => invoke(loader, jsOptions, { resourcePath: join(root, file), source }); +const loadCss = name => invoke(cssLoader, cssOptions, { resourcePath: join(cssDir, name), source: '' }); +const finish = () => {}; +` + // When: real plugin configuration and loaders run in isolated processes. + const [forward, backward, rebuilt] = runAtomHoistIntegration( + setup, + 'per-file', + ) + // Then: declarations match JSX; build ordering and fresh state change nothing. + expect(backward).toBe(forward) + expect(rebuilt).toBe(forward) +}, 120_000) diff --git a/packages/next-plugin/src/__tests__/availability-adapters.mjs b/packages/next-plugin/src/__tests__/availability-adapters.mjs new file mode 100644 index 000000000..001b1d0ef --- /dev/null +++ b/packages/next-plugin/src/__tests__/availability-adapters.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, symlinkSync } from 'node:fs' +import { Server } from 'node:http' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +export async function openAdapter({ adapter, worktree, root, files }) { + const cssDir = join(root, 'df/devup-ui') + const entry = (name, file = 'index.mjs') => + pathToFileURL(join(worktree, 'packages', name, 'dist', file)).href + const options = { + atomHoist: 2, + singleCss: false, + cssDir, + distDir: join(root, 'df'), + } + switch (adapter) { + case 'vite': { + const { DevupUI } = await import(entry('vite-plugin')) + const [plugin, restore] = DevupUI(options) + try { + await plugin.configResolved({ + root, + command: 'build', + build: { + rollupOptions: { + input: files.slice(2).map((file) => join(root, file)), + }, + }, + }) + } catch (error) { + plugin.closeBundle() + throw error + } + return { + transform: async (file, code) => { + const result = await plugin.transform.call( + { addWatchFile() {} }, + code, + join(root, file).replaceAll('\\', '/'), + ) + assert.equal(typeof result?.code, 'string') + return { code: result.code, map: result.map ?? null } + }, + loadCss: (name) => plugin.load(join(cssDir, name)), + finish: async (names) => { + const bundle = Object.fromEntries( + names.map((name) => [ + name, + { type: 'asset', name, source: 'stale', fileName: name }, + ]), + ) + await plugin.generateBundle.call({}, {}, bundle) + restore.generateBundle.handler({}, bundle) + return Object.fromEntries( + names.map((name) => [name, bundle[name].source]), + ) + }, + close: async () => plugin.closeBundle(), + } + } + case 'next': { + process.env.TURBOPACK = '1' + process.env.NODE_ENV = 'development' + mkdirSync(join(root, 'node_modules/@devup-ui'), { recursive: true }) + const installed = join(root, 'node_modules/@devup-ui/next-plugin') + if (!existsSync(installed)) { + symlinkSync( + join(worktree, 'packages/next-plugin'), + installed, + 'junction', + ) + } + const { DevupUI } = await import(entry('next-plugin')) + const { default: loader } = await import( + entry('next-plugin', 'loader.mjs') + ) + const { default: cssLoader } = await import( + entry('next-plugin', 'css-loader.mjs') + ) + const servers = new Set() + const listen = Server.prototype.listen + // Observe the real coordinator only to await its shutdown; inputs stay unchanged. + Server.prototype.listen = function (...args) { + servers.add(this) + return Reflect.apply(listen, this, args) + } + const close = async () => { + for (const server of servers) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + server.closeAllConnections() + }) + } + } + let config + try { + config = DevupUI({}, options) + } catch (error) { + await close() + throw error + } finally { + Server.prototype.listen = listen + } + if (servers.size !== 1) { + await close() + assert.fail('must observe one real coordinator') + } + const rules = config.turbopack.rules + const jsOptions = rules['*.{tsx,ts,jsx,js,mjs}'].loaders[0].options + const cssOptions = rules['./df/devup-ui/*.css'][0].options + const invoke = (fn, opts, input) => + new Promise((resolve, reject) => { + fn.call( + { + resourcePath: input.path, + getOptions: () => opts, + addDependency() {}, + async: () => (error, code, map) => + error ? reject(error) : resolve({ code, map: map ?? null }), + }, + input.code, + ) + }) + return { + transform: (file, code) => + invoke(loader, jsOptions, { path: join(root, file), code }), + loadCss: async (name) => + ( + await invoke(cssLoader, cssOptions, { + path: join(cssDir, name), + code: '', + }) + ).code, + finish: async () => ({}), + close, + } + } + default: + assert.fail('unsupported adapter: ' + adapter) + } +} diff --git a/packages/next-plugin/src/__tests__/availability-child.mjs b/packages/next-plugin/src/__tests__/availability-child.mjs new file mode 100644 index 000000000..a4131dde0 --- /dev/null +++ b/packages/next-plugin/src/__tests__/availability-child.mjs @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict' +import { + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' + +import { openAdapter } from './availability-adapters.mjs' + +const [worktree, adapter, order, root, receipt] = process.argv.slice(2) +assert.ok(worktree && root && receipt && ['vite', 'next'].includes(adapter)) +assert.ok(['AB', 'BA'].includes(order)) +const sources = { + 'src/shared-red.tsx': + 'import { Box } from "@devup-ui/react"; export const Red = () => ;', + 'src/shared-blue.tsx': + 'import { Box } from "@devup-ui/react"; export const Blue = () => ;', + 'src/app/a/page.tsx': + 'import { Box } from "@devup-ui/react"; import { Red } from "../../shared-red"; import { Blue } from "../../shared-blue"; export default function A() { return <>; }', + 'src/app/b/page.tsx': + 'import { Box } from "@devup-ui/react"; import { Red } from "../../shared-red"; import { Blue } from "../../shared-blue"; export default function B() { return <>; }', +} +const files = Object.keys(sources) +const cssDir = join(root, 'df/devup-ui') +rmSync(join(root, 'df'), { recursive: true, force: true }) +for (const [file, code] of Object.entries(sources)) { + mkdirSync(dirname(join(root, file)), { recursive: true }) + writeFileSync(join(root, file), code) +} +mkdirSync(cssDir, { recursive: true }) +writeFileSync(join(root, 'devup.json'), '{}') +writeFileSync(join(root, 'tsconfig.json'), '{}') +writeFileSync( + join(root, 'package.json'), + '{"name":"availability-fixture","private":true}', +) +process.chdir(root) +delete process.env.DEVUP_HOIST_V +delete process.env.DEVUP_UI_PROFILE +const require = createRequire( + join( + worktree, + 'packages', + `${adapter === 'next' ? 'next' : 'vite'}-plugin`, + 'package.json', + ), +) +const wasm = require('@devup-ui/wasm') +const artifacts = { js: {}, css: {}, emitted: {} } +const diagnostic = { adapter, order, root, disk: {} } +let seam +try { + seam = await openAdapter({ adapter, worktree, root, files }) + const canonical = JSON.parse(wasm.exportCanonicalMap()) + const id = (file) => + adapter === 'vite' ? join(root, file).replaceAll('\\', '/') : file + const buckets = files + .slice(0, 2) + .map((file) => canonical[id(file)] ?? id(file)) + assert.notEqual( + buckets[0], + buckets[1], + 'two physical suppliers must retain distinct buckets', + ) + assert.ok(buckets.every((bucket) => bucket !== '@global')) + diagnostic.buckets = buckets + for (const file of order === 'AB' ? files : [...files].reverse()) { + artifacts.js[file] = await seam.transform(file, sources[file]) + } + artifacts.js = Object.fromEntries( + files.map((file) => [file, artifacts.js[file]]), + ) + const imports = Object.fromEntries( + files.map((file) => [ + file, + [ + ...artifacts.js[file].code.matchAll( + /["']([^"']*devup-ui(?:-\d+)?\.css(?:\?fileNum=\d+)?)["']/g, + ), + ].map((match) => match[1].split('/').pop()), + ]), + ) + const names = [ + ...new Set(['devup-ui.css', ...Object.values(imports).flat()]), + ].sort() + for (const name of names) artifacts.css[name] = await seam.loadCss(name) + artifacts.emitted = await seam.finish(names) + const rules = (text) => + [...text.matchAll(/\.([^\s.{:]+)\{([^{}]+)\}/g)].map((match) => [ + match[1], + match[2].replace(/;$/, ''), + ]) + assert.deepEqual( + rules(artifacts.css['devup-ui.css']), + [], + 'fallback base must have no atoms', + ) + const expected = [ + 'color:red', + 'background:blue', + 'opacity:.25', + 'opacity:.75', + ] + for (const [index, file] of files.entries()) { + const generated = [ + ...artifacts.js[file].code.matchAll( + /className\s*=\s*(?:\{\s*)?["']([^"']+)["']/g, + ), + ].flatMap((match) => match[1].trim().split(/\s+/)) + assert.equal(generated.length, 1, 'real JSX extraction required') + const [name] = generated + assert.match( + name, + /^[a-z0-9]+-[a-z0-9]+$/, + 'short per-file counters required', + ) + assert.ok( + imports[file].some((sheet) => sheet !== 'devup-ui.css'), + 'own local CSS import required', + ) + const loaded = [...new Set(imports[file])].flatMap((sheet) => + rules(artifacts.css[sheet]), + ) + assert.deepEqual( + loaded.filter(([key]) => key === name), + [[name, expected[index]]], + 'each class resolves exactly once in its own loaded CSS', + ) + assert.deepEqual( + Object.values(artifacts.css) + .flatMap(rules) + .filter(([key]) => key === name), + [[name, expected[index]]], + 'no duplicate definitions in other sheets', + ) + } + for (const [name, text] of Object.entries(artifacts.emitted)) { + assert.equal( + text, + artifacts.css[name], + 'emitted bytes must equal actual loaded CSS', + ) + } +} finally { + if (seam) await seam.close() + for (const name of readdirSync(cssDir) + .filter((name) => name.endsWith('.css')) + .sort()) { + diagnostic.disk[name] = readFileSync(join(cssDir, name), 'utf8') + } + // Next disk comments are watch invalidation signals, not CSS-loader output. + writeFileSync(receipt, JSON.stringify(artifacts)) + writeFileSync(`${receipt}.diagnostic.json`, JSON.stringify(diagnostic)) +} diff --git a/packages/next-plugin/src/__tests__/availability-harness.ts b/packages/next-plugin/src/__tests__/availability-harness.ts new file mode 100644 index 000000000..76bb82677 --- /dev/null +++ b/packages/next-plugin/src/__tests__/availability-harness.ts @@ -0,0 +1,32 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +export function runAvailabilityIntegration(adapter: 'vite' | 'next') { + const receipts = process.env.W66_AVAILABILITY_RECEIPTS + const root = mkdtempSync( + join(receipts ?? tmpdir(), `devup-availability-${adapter}-`), + ) + try { + return ['AB', 'BA', 'BA', 'AB'].map((order, index) => { + const receipt = join(root, `receipt-${index}.json`) + execFileSync( + process.execPath, + [ + join(import.meta.dir, 'availability-child.mjs'), + resolve(import.meta.dir, '../../../..'), + adapter, + order, + root, + receipt, + ], + { encoding: 'utf8', timeout: 45_000, env: process.env }, + ) + // Compare complete JS/maps and loaded/emitted CSS, never watch-signal files. + return readFileSync(receipt, 'utf8') + }) + } finally { + if (!receipts) rmSync(root, { recursive: true, force: true }) + } +} diff --git a/packages/next-plugin/src/__tests__/availability.test.ts b/packages/next-plugin/src/__tests__/availability.test.ts new file mode 100644 index 000000000..8f3a38e06 --- /dev/null +++ b/packages/next-plugin/src/__tests__/availability.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from 'bun:test' + +import { runAvailabilityIntegration } from './availability-harness' + +it('W66 availability: keeps distinct shared buckets byte-stable through Next live loaders in AB/BA/BA/AB', () => { + // Given: two physical shared suppliers, both reached by two private pages. + // When: actual built adapter callbacks run in fresh processes at one fixed root. + const [ab, ba, baAgain, abAgain] = runAvailabilityIntegration('next') + // Then: complete compiled JS/maps and actual loaded CSS are byte-identical. + expect(ba).toEqual(ab) + expect(baAgain).toEqual(ab) + expect(abAgain).toEqual(ab) +}, 200_000) diff --git a/packages/next-plugin/src/__tests__/native-webpack-cancel.cjs b/packages/next-plugin/src/__tests__/native-webpack-cancel.cjs new file mode 100644 index 000000000..8e252470f --- /dev/null +++ b/packages/next-plugin/src/__tests__/native-webpack-cancel.cjs @@ -0,0 +1,139 @@ +const assert = require('node:assert/strict') +const { spawn } = require('node:child_process') +const { + closeSync, + mkdtempSync, + openSync, + readFileSync, + rmSync, +} = require('node:fs') +const { tmpdir } = require('node:os') +const { join } = require('node:path') +const { setTimeout: delay } = require('node:timers/promises') + +async function main() { + const signal = process.argv[2] + assert(signal === 'SIGTERM' || signal === 'SIGINT') + const closed = process.argv[3] === 'closed' + const root = mkdtempSync(join(tmpdir(), 'native-owner-cancel-')) + const output = join(root, 'stdout') + const fd = openSync(output, 'wx') + const owner = spawn( + process.execPath, + [ + join(__dirname, 'native-webpack-owned.cjs'), + '30000', + join(__dirname, 'webpack-lifecycle-child.cjs'), + ], + { stdio: ['ignore', closed ? 'pipe' : fd, 'pipe'] }, + ) + closeSync(fd) + let stdout = '' + let stderr = '' + /** @type {Set} */ + const pids = new Set() + /** @type {Promise<{code: number | null, signal: NodeJS.Signals | null}>} */ + const complete = new Promise((resolve, reject) => { + owner.once('error', reject) + owner.once('close', (code, signal) => resolve({ code, signal })) + }) + /** @type {ReturnType | undefined} */ + let timer + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error('Owner cancellation deadline')), + 12000, + ) + }) + if (closed) { + assert(owner.stdout) + owner.stdout.on('data', (chunk) => { + stdout += String(chunk) + }) + } + assert(owner.stderr) + owner.stderr.on('data', (chunk) => { + stderr += String(chunk) + }) + /** @param {number} pid @param {NodeJS.Signals | 0} signal */ + function send(pid, signal) { + try { + process.kill(pid, signal) + return true + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ESRCH') + return false + throw error + } + } + try { + // Given: both real fault descendants are alive before cancelling their owner. + const readyEnd = Date.now() + 10000 + while (pids.size !== 2) { + if (!closed) stdout = readFileSync(output, 'utf8') + for (const match of stdout.matchAll(/W65-FAULT-PID (\d+)/g)) + pids.add(Number(match[1])) + assert(Date.now() < readyEnd, 'Fault descendants did not become ready') + if (pids.size !== 2) await Promise.race([delay(20), timeout]) + } + assert.equal(pids.size, 2) + for (const pid of pids) assert(send(pid, 0)) + // When: signal only the owner, never the fault child or its process group. + assert(owner.kill(signal)) + if (closed) { + owner.stdout?.destroy() + owner.stderr.destroy() + } + const end = Date.now() + 10000 + const result = await Promise.race([ + complete, + timeout.then(() => assert.fail('Cancellation timed out')), + ]) + while ([...pids].some((pid) => send(pid, 0))) { + assert( + Date.now() < end, + 'Owner cancellation left a native descendant alive', + ) + await delay(20) + } + if (!closed) stdout = readFileSync(output, 'utf8') + // Then: cancellation is failure, not the 30s command timeout or success. + if (process.platform === 'win32') { + assert.equal(result.code, null) + assert.equal(result.signal, signal) + } else { + assert.equal(result.code, 125, stderr) + assert.equal(result.signal, null) + if (!closed) assert.match(stdout, /"active":\[\]/) + for (const pid of pids) + assert.equal(send(-pid, 0), false, 'Native process group remains') + } + console.info( + JSON.stringify({ + event: 'owner_cancel', + owner: owner.pid, + signal, + closed, + pids: [...pids], + active: [], + result, + stdout, + stderr, + }), + ) + } finally { + clearTimeout(timer) + // A failing regression must not strand the very descendants it detects. + if (process.platform !== 'win32') { + for (const pid of pids) send(-pid, 'SIGKILL') + } + for (const pid of pids) if (send(pid, 0)) send(pid, 'SIGKILL') + owner.kill('SIGKILL') + await complete + rmSync(root, { recursive: true, force: true }) + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/next-plugin/src/__tests__/native-webpack-checks.ts b/packages/next-plugin/src/__tests__/native-webpack-checks.ts new file mode 100644 index 000000000..024baae09 --- /dev/null +++ b/packages/next-plugin/src/__tests__/native-webpack-checks.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { resolve } from 'node:path' + +import type { NativeEvent, Row, Snapshot } from './native-webpack-types.ts' + +export function assertRetained(before: Snapshot, after: Snapshot): void { + for (const [bucket, slots] of Object.entries(before.classes)) + for (const [key, value] of Object.entries(slots)) + assert.equal( + after.classes[bucket]?.[key], + value, + `old class slot ${bucket}/${key}`, + ) + for (const [key, value] of Object.entries(before.files)) + assert.equal(after.files[key], value, `old file slot ${key}`) +} +export function assertPhysicalOrder(rows: readonly Row[], root: string): void { + const physical = rows.map(([id]) => resolve(root, id).replaceAll('\\', '/')) + assert.deepEqual(physical, [...physical].sort()) +} +export function assertReserved(records: readonly NativeEvent[]): void { + const reservations = records.filter((entry) => entry.kind === 'reserve') + assert(reservations.length > 0, 'genuine reservation must execute') + assert(reservations.some((entry) => entry.args[0].length > 0)) + for (const entry of records.filter((entry) => entry.kind === 'extract')) + assert.equal(entry.after, 0, `residual after ${entry.args[0]}`) +} diff --git a/packages/next-plugin/src/__tests__/native-webpack-fixture.cjs b/packages/next-plugin/src/__tests__/native-webpack-fixture.cjs new file mode 100644 index 000000000..b6b05a380 --- /dev/null +++ b/packages/next-plugin/src/__tests__/native-webpack-fixture.cjs @@ -0,0 +1,98 @@ +const { + mkdirSync, + realpathSync, + symlinkSync, + writeFileSync, +} = require('node:fs') +const { createRequire } = require('node:module') +const { dirname, join, resolve } = require('node:path') + +const repository = resolve(__dirname, '../../../..') +const fromNext = createRequire( + join(repository, 'packages/next-plugin/package.json'), +) +const fromFramework = createRequire(fromNext.resolve('next/package.json')) +/** @param {string} root @param {string} name @param {string} contents */ +function put(root, name, contents) { + const file = join(root, name) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, contents) +} +/** @param {string} root @param {string} name @param {string} target */ +function junction(root, name, target) { + const destination = join(root, 'node_modules', name) + mkdirSync(dirname(destination), { recursive: true }) + symlinkSync( + realpathSync(target), + destination, + process.platform === 'win32' ? 'junction' : 'dir', + ) +} +/** @param {string} root */ +function fixture(root) { + for (const name of ['next', 'react', 'react-dom']) + junction(root, name, dirname(fromFramework.resolve(`${name}/package.json`))) + junction(root, '@devup-ui/react', join(repository, 'packages/react')) + junction( + root, + '@devup-ui/next-plugin', + join(repository, 'packages/next-plugin'), + ) + put( + root, + 'package.json', + JSON.stringify({ name: 'native-next-reservation', private: true }), + ) + put(root, 'devup.json', '{}') + put( + root, + 'next.config.js', + `const observer = require(${JSON.stringify(join(__dirname, 'native-webpack-observer.cjs'))}); +const { DevupUI } = require('@devup-ui/next-plugin'); +module.exports = DevupUI({ webpack: observer.configure, experimental: { cpus: 1 } }, { singleCss: true });`, + ) + put( + root, + 'src/app/layout.js', + `export default function Layout({ children }) { return {children} }`, + ) + put( + root, + 'src/app/client.js', + `'use client'; +import { Box, keyframes } from '@devup-ui/react'; +const fade = keyframes({ from: { opacity: 0.25 }, to: { opacity: 1 } }); +export default function Client({ color }) { return client-proof }`, + ) + put(root, 'src/app/page.js', page(false)) + put( + root, + 'src/app/edge/route.js', + `import { css } from '@devup-ui/react'; +export const runtime = 'edge'; +const edge = css({ borderColor: 'goldenrod' }); +export function GET() { return Response.json({ edge }); }`, + ) +} +/** @param {boolean} revised */ +function page(revised) { + return `import { Box } from '@devup-ui/react'; +import Client from './client'; +${revised ? "import A from './A'; import B from './B';" : ''} +export default function Page() { return server-proof${revised ? '' : ''} }` +} +/** @param {string} root */ +function revision(root) { + put( + root, + 'src/app/B.js', + `import { Box } from '@devup-ui/react'; export default function B() { return new-b }`, + ) + put( + root, + 'src/app/A.js', + `import { Box } from '@devup-ui/react'; export default function A() { return new-a }`, + ) + put(root, 'src/app/page.js', page(true)) +} +module.exports = { fixture, revision, repository, fromNext } diff --git a/packages/next-plugin/src/__tests__/native-webpack-http.cjs b/packages/next-plugin/src/__tests__/native-webpack-http.cjs new file mode 100644 index 000000000..a03cc7e03 --- /dev/null +++ b/packages/next-plugin/src/__tests__/native-webpack-http.cjs @@ -0,0 +1,100 @@ +const assert = require('node:assert/strict') +const http = require('node:http') + +/** @param {string | URL} url @param {AbortSignal} signal @returns {Promise} */ +function request(url, signal) { + return new Promise((resolve, reject) => { + const req = http.get( + url, + { + agent: false, + signal: AbortSignal.any([signal, AbortSignal.timeout(90000)]), + }, + (res) => { + let text = '' + res.setEncoding('utf8') + res.on('data', (chunk) => { + text += chunk + }) + res.on('error', reject) + res.on('aborted', () => + reject(new Error(`HTTP response aborted: ${url}`)), + ) + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${url}\n${text}`)) + return + } + resolve(text) + }) + }, + ) + req.on('error', reject) + }) +} +/** @param {string} base @param {boolean} revised @param {AbortSignal} signal */ +async function surface(base, revised, signal) { + const html = await request(base, signal) + assert(html.includes('id="server-proof"')) + assert(html.includes('id="client-proof"')) + assert(html.includes('cyan')) + const links = [...html.matchAll(/]*>/g)] + .map((match) => match[0]) + .filter((tag) => /rel="stylesheet"/.test(tag)) + .flatMap((tag) => + [...tag.matchAll(/href="([^"]+)"/g)].map((match) => { + assert(match[1]) + return match[1] + }), + ) + assert(links.length > 0, 'first SSR response must link CSS') + const css = (await assets(links, base, signal)).join('\n') + for (const declaration of [ + 'background:maroon', + 'padding:13px', + 'background:navy', + '@keyframes', + 'color:var(--', + ]) + assert(css.includes(declaration), `first CSS missing ${declaration}`) + for (const [, classes] of html.matchAll(/class="([^"]+)"/g)) { + assert(classes) + for (const name of classes.split(/\s+/)) + assert(css.includes(`.${name}`), `SSR class ${name} has no actual CSS`) + } + const scripts = [...html.matchAll(/]*src="([^"]+)"/g)].map( + (match) => { + assert(match[1]) + return match[1] + }, + ) + assert(scripts.length > 0) + const js = (await assets(scripts, base, signal)).join('\n') + assert(!js.includes('Cannot run on the runtime')) + assert(!js.includes('@devup-ui/react/dist/components/Box')) + assert(!html.includes(' + request( + new URL(url.replaceAll('&', '&'), base), + AbortSignal.any([signal, siblings.signal]), + ), + ) + try { + return await Promise.all(requests) + } finally { + siblings.abort() + await Promise.allSettled(requests) + } +} +module.exports = { request, surface } diff --git a/packages/next-plugin/src/__tests__/native-webpack-lifecycle.test.js b/packages/next-plugin/src/__tests__/native-webpack-lifecycle.test.js new file mode 100644 index 000000000..945926fea --- /dev/null +++ b/packages/next-plugin/src/__tests__/native-webpack-lifecycle.test.js @@ -0,0 +1,45 @@ +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +it('drains native descendants when the owned command exceeds its deadline', () => { + // Given: a real parent and grandchild that both ignore graceful shutdown. + const owner = join(import.meta.dirname, 'native-webpack-owned.cjs') + const child = join(import.meta.dirname, 'webpack-lifecycle-child.cjs') + // When: the foreground owner reaches its bounded command deadline. + const result = spawnSync('node', [owner, '3000', child], { + encoding: 'utf8', + }) + // Then: timeout remains failure and every observed native member is gone. + console.info(result.stdout) + expect(result.error).toBeUndefined() + expect(result.status).toBe(124) + expect(result.stdout).toMatch(/"active":\[\]/) + const pids = [...result.stdout.matchAll(/W65-FAULT-PID (\d+)/g)] + expect(pids.length).toBe(2) + for (const match of pids) { + expect(() => process.kill(Number(match[1]), 0)).toThrow() + } +}, 20000) + +for (const signal of ['SIGTERM', 'SIGINT']) { + for (const pipes of ['open', 'closed']) { + it(`drains native descendants when the actual owner receives ${signal} with ${pipes} output`, () => { + // Given: a probe that waits for two live fault descendants. + const probe = join(import.meta.dirname, 'native-webpack-cancel.cjs') + // When: the probe cancels the actual command owner, not its child deadline. + const result = spawnSync('node', [probe, signal, pipes], { + encoding: 'utf8', + }) + // Then: the probe returns only after non-success cancellation and full drain. + console.info(result.stdout) + expect({ status: result.status, stderr: result.stderr }).toEqual({ + status: 0, + stderr: '', + }) + expect(result.error).toBeUndefined() + expect(result.stdout).toMatch(/"event":"owner_cancel"/) + }, 25000) + } +} diff --git a/packages/next-plugin/src/__tests__/native-webpack-observer.cjs b/packages/next-plugin/src/__tests__/native-webpack-observer.cjs new file mode 100644 index 000000000..5e34da6ac --- /dev/null +++ b/packages/next-plugin/src/__tests__/native-webpack-observer.cjs @@ -0,0 +1,150 @@ +const assert = require('node:assert/strict') +const { EventEmitter } = require('node:events') +const { appendFileSync } = require('node:fs') +const { join } = require('node:path') +const wasm = require('@devup-ui/wasm') +const { DevupUIWebpackPlugin } = require('@devup-ui/webpack-plugin') +const { parseClasses, parseSlots } = require('./native-webpack-receipt.ts') +const { assertReserved } = require('./native-webpack-checks.ts') + +const events = new EventEmitter() +/** @type {import('./native-webpack-types.ts').NativeEvent[]} */ +const records = [] +let epoch = 0 +let phase = 'initial' +let role = 'setup' +/** @param {import('./native-webpack-types.ts').Payload} value */ +function record(value) { + const entry = { pid: process.pid, epoch, phase, role, ...value } + records.push(entry) + appendFileSync( + join(process.cwd(), 'native-evidence.jsonl'), + `${JSON.stringify(entry)}\n`, + ) + events.emit('record', entry) +} +function snapshot() { + return { + classes: parseClasses(wasm.exportClassMap()), + files: parseSlots(wasm.exportFileMap()), + residual: wasm.getUnreservedClassSlotCount(), + css: wasm.getCss(null, false), + } +} +const reset = wasm.resetBuildState +wasm.resetBuildState = (...args) => { + record({ kind: 'reset', state: snapshot() }) + const result = reset(...args) + epoch += 1 + return result +} +const reserve = wasm.reserveClassSlots +/** @param {import('./native-webpack-types.ts').ReserveArgs} args */ +wasm.reserveClassSlots = (...args) => { + const before = snapshot() + const result = reserve(...args) + record({ kind: 'reserve', args, before, after: snapshot() }) + return result +} +const extract = wasm.codeExtract +/** @param {import('./native-webpack-types.ts').ExtractArgs} args */ +wasm.codeExtract = (...args) => { + const before = wasm.getUnreservedClassSlotCount() + record({ kind: 'extract-start', args, before }) + const result = extract(...args) + record({ + kind: 'extract', + args, + before, + after: wasm.getUnreservedClassSlotCount(), + output: { + code: result.code, + css: result.css, + cssFile: result.cssFile, + map: result.map, + dependencies: result.dependencies, + updatedBaseStyle: result.updatedBaseStyle, + }, + }) + return result +} +/** + * @param {import('./native-webpack-types.ts').NativeConfig} config + * @param {Parameters>[1]} options + */ +function configure(config, options) { + const compilerRole = options.isServer ? options.nextRuntime : 'client' + assert(compilerRole) + if (options.dev && process.platform === 'win32') + config.watchOptions = { ...config.watchOptions, poll: 100 } + record({ + kind: 'options', + compilerRole, + dev: options.dev, + buildId: options.buildId, + delegation: config.plugins.filter( + (plugin) => plugin instanceof DevupUIWebpackPlugin, + ).length, + }) + config.plugins.push({ + /** @param {import('webpack').Compiler} compiler */ + apply(compiler) { + const select = () => { + role = compilerRole + } + compiler.hooks.run.tap( + { name: 'NativeNextEvidence', stage: -10000 }, + select, + ) + compiler.hooks.watchRun.tap( + { name: 'NativeNextEvidence', stage: -10000 }, + select, + ) + compiler.hooks.thisCompilation.tap('NativeNextEvidence', () => { + select() + record({ kind: 'compilation', compilerName: compiler.name }) + }) + compiler.hooks.done.tap('NativeNextEvidence', (stats) => { + select() + record({ kind: 'done', errors: stats.hasErrors(), state: snapshot() }) + }) + }, + }) + return config +} +/** + * @param {(records: readonly import('./native-webpack-types.ts').NativeEvent[]) => boolean} predicate + * @param {AbortSignal} signal + * @returns {Promise} + */ +function until(predicate, signal) { + if (predicate(records)) return Promise.resolve() + return new Promise((resolve, reject) => { + const deadline = AbortSignal.any([signal, AbortSignal.timeout(90000)]) + function abort() { + events.off('record', listener) + reject(deadline.reason) + } + function listener() { + if (!predicate(records)) return + deadline.removeEventListener('abort', abort) + events.off('record', listener) + resolve() + } + deadline.throwIfAborted() + deadline.addEventListener('abort', abort, { once: true }) + events.on('record', listener) + }) +} +module.exports = { + configure, + records, + snapshot, + until, + assertReserved() { + assertReserved(records) + }, + revise() { + phase = 'revision' + }, +} diff --git a/packages/next-plugin/src/__tests__/native-webpack-owned.cjs b/packages/next-plugin/src/__tests__/native-webpack-owned.cjs new file mode 100644 index 000000000..a9a855f76 --- /dev/null +++ b/packages/next-plugin/src/__tests__/native-webpack-owned.cjs @@ -0,0 +1,123 @@ +const assert = require('node:assert/strict') +const { spawn } = require('node:child_process') +const { join } = require('node:path') +const { setTimeout: delay } = require('node:timers/promises') + +const deadline = Number(process.argv[2]) +const args = process.argv.slice(3) +assert(Number.isSafeInteger(deadline) && deadline > 0 && args.length > 0) + +async function main() { + if (process.platform === 'win32') { + /** @param {string} value */ + const quote = (value) => + `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1')}"` + const config = Buffer.from( + JSON.stringify({ + owner: process.pid, + caller: process.ppid, + cwd: process.cwd(), + command: [process.execPath, ...args].map(quote).join(' '), + deadline, + }), + ).toString('base64') + const supervisor = spawn( + 'powershell.exe', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + join(__dirname, 'native-webpack-supervise.ps1'), + '-Config', + config, + ], + { stdio: 'inherit', windowsHide: true }, + ) + process.exitCode = await new Promise((resolve, reject) => { + supervisor.once('error', reject) + supervisor.once('close', (code) => resolve(code ?? 125)) + }) + return + } + + // Referenced and awaited foreground group, never an unowned background task. + const child = spawn(process.execPath, args, { + detached: true, + stdio: 'inherit', + }) + /** @type {number | undefined} */ + let outcome + const cancel = () => { + outcome = 125 + } + process.on('SIGTERM', cancel) + process.on('SIGINT', cancel) + let outputClosed = false + /** @param {Error} error */ + const outputError = (error) => { + if (!('code' in error && error.code === 'EPIPE')) throw error + outputClosed = true + cancel() + process.exitCode = 125 + } + // A cancelled caller's closed pipes must not interrupt group cleanup. + process.stdout.on('error', outputError) + process.stderr.on('error', outputError) + const exited = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code) => { + outcome ??= code ?? 125 + resolve(undefined) + }) + }) + assert(child.pid) + const pgid = child.pid + /** @param {NodeJS.Signals | 0} signal */ + function send(signal) { + try { + process.kill(-pgid, signal) + return true + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ESRCH') + return false + throw error + } + } + const parent = process.ppid + const end = Date.now() + deadline + try { + while (outcome === undefined) { + if (process.ppid !== parent) { + outcome = 125 + break + } + if (Date.now() >= end) { + outcome = 124 + break + } + await delay(50) + } + } finally { + send('SIGKILL') + await exited + const drain = Date.now() + 10000 + while (send(0)) { + assert(Date.now() < drain, 'Native process group drain deadline') + await delay(20) + } + process.removeListener('SIGTERM', cancel) + process.removeListener('SIGINT', cancel) + if (!outputClosed) + console.info( + `NATIVE-LIFECYCLE ${JSON.stringify({ event: 'tree_exit', pgid, active: [], exitCode: outcome })}`, + ) + } + process.exitCode = outcome +} +main().catch((error) => { + console.error(error) + process.exitCode = 125 +}) diff --git a/packages/next-plugin/src/__tests__/native-webpack-receipt.ts b/packages/next-plugin/src/__tests__/native-webpack-receipt.ts new file mode 100644 index 000000000..d8f903fb2 --- /dev/null +++ b/packages/next-plugin/src/__tests__/native-webpack-receipt.ts @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict' + +import type { + Aliases, + ExtractArgs, + NativeEvent, + Payload, + ReserveArgs, + Row, + SlotMap, + Snapshot, +} from './native-webpack-types.ts' + +function object(value: unknown): Readonly> { + assert(value !== null && typeof value === 'object' && !Array.isArray(value)) + const entries: readonly (readonly [string, unknown])[] = Object.entries(value) + return Object.fromEntries(entries) +} +function list(value: unknown): readonly unknown[] { + assert(Array.isArray(value)) + return value +} +function text(value: unknown): string { + assert(typeof value === 'string') + return value +} +function number(value: unknown): number { + assert(typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) + return value +} +function boolean(value: unknown): boolean { + assert(typeof value === 'boolean') + return value +} +function optionalText(value: unknown): string | undefined { + return value === undefined ? undefined : text(value) +} +function slots(value: unknown): SlotMap { + return Object.fromEntries( + Object.entries(object(value)).map(([key, slot]) => [key, number(slot)]), + ) +} +function aliases(value: unknown): Aliases { + return Object.fromEntries( + Object.entries(object(value)).map(([key, alias]) => [ + key, + alias === null ? null : text(alias), + ]), + ) +} +export function parseSnapshot(value: unknown): Snapshot { + const fields = object(value) + return { + classes: Object.fromEntries( + Object.entries(object(fields.classes)).map(([key, bucket]) => [ + key, + slots(bucket), + ]), + ), + files: slots(fields.files), + residual: number(fields.residual), + css: text(fields.css), + } +} +function reserveArgs(value: unknown): ReserveArgs { + const args = list(value) + assert.equal(args.length, 6) + const rows = list(args[0]).map((value): Row => { + const row = list(value) + assert.equal(row.length, 3) + return [text(row[0]), text(row[1]), text(row[2])] + }) + return [ + rows, + text(args[1]), + boolean(args[2]), + boolean(args[3]), + boolean(args[4]), + aliases(args[5]), + ] +} +function extractArgs(value: unknown): ExtractArgs { + const args = list(value) + assert.equal(args.length, 8) + return [ + text(args[0]), + text(args[1]), + text(args[2]), + text(args[3]), + boolean(args[4]), + boolean(args[5]), + boolean(args[6]), + aliases(args[7]), + ] +} +function payload(value: Readonly>): Payload { + switch (value.kind) { + case 'reset': + return { kind: 'reset', state: parseSnapshot(value.state) } + case 'reserve': + return { + kind: 'reserve', + args: reserveArgs(value.args), + before: parseSnapshot(value.before), + after: parseSnapshot(value.after), + } + case 'extract-start': + return { + kind: 'extract-start', + args: extractArgs(value.args), + before: number(value.before), + } + case 'extract': { + const output = object(value.output) + return { + kind: 'extract', + args: extractArgs(value.args), + before: number(value.before), + after: number(value.after), + output: { + code: text(output.code), + css: optionalText(output.css), + cssFile: optionalText(output.cssFile), + map: optionalText(output.map), + dependencies: list(output.dependencies).map(text), + updatedBaseStyle: boolean(output.updatedBaseStyle), + }, + } + } + case 'options': + return { + kind: 'options', + compilerRole: text(value.compilerRole), + dev: boolean(value.dev), + buildId: text(value.buildId), + delegation: number(value.delegation), + } + case 'compilation': + return { + kind: 'compilation', + compilerName: optionalText(value.compilerName), + } + case 'done': + return { + kind: 'done', + errors: boolean(value.errors), + state: parseSnapshot(value.state), + } + default: + throw new TypeError(`Unknown native event kind: ${String(value.kind)}`) + } +} +export function parseEvents(contents: string): readonly NativeEvent[] { + return contents + .trim() + .split('\n') + .map((line) => { + const parsed: unknown = JSON.parse(line) + const value = object(parsed) + return { + ...payload(value), + pid: number(value.pid), + epoch: number(value.epoch), + phase: text(value.phase), + role: text(value.role), + } + }) +} +export function parseStateReceipt(contents: string) { + const parsed: unknown = JSON.parse(contents) + const value = object(parsed) + return { old: parseSnapshot(value.old), final: parseSnapshot(value.final) } +} +export function parseEdge(contents: string) { + const parsed: unknown = JSON.parse(contents) + const edge = text(object(parsed).edge) + assert(edge.length > 0) + return { edge } +} +export function parseSlots(contents: string): SlotMap { + const parsed: unknown = JSON.parse(contents) + return slots(parsed) +} +export function parseClasses(contents: string): Snapshot['classes'] { + const parsed: unknown = JSON.parse(contents) + return Object.fromEntries( + Object.entries(object(parsed)).map(([key, bucket]) => [key, slots(bucket)]), + ) +} diff --git a/packages/next-plugin/src/__tests__/native-webpack-run.cjs b/packages/next-plugin/src/__tests__/native-webpack-run.cjs new file mode 100644 index 000000000..f55fbf6b4 --- /dev/null +++ b/packages/next-plugin/src/__tests__/native-webpack-run.cjs @@ -0,0 +1,162 @@ +const assert = require('node:assert/strict') +const { spawnSync } = require('node:child_process') +const { + cpSync, + existsSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} = require('node:fs') +const { join } = require('node:path') +const { fixture, fromNext } = require('./native-webpack-fixture.cjs') +const { + parseEvents, + parseStateReceipt, +} = require('./native-webpack-receipt.ts') +const { + assertReserved, + assertRetained, + assertPhysicalOrder, +} = require('./native-webpack-checks.ts') + +const mode = process.argv[2] +assert(mode === 'production' || mode === 'development') +const evidence = process.env.DEVUP_NATIVE_EVIDENCE +const archive = evidence ? mkdtempSync(join(evidence, `${mode}-`)) : undefined +const root = realpathSync.native( + mkdtempSync( + join(realpathSync.native(join(__dirname, '../..')), '.native-next-'), + ), +) +/** @type {NodeJS.ProcessEnv} */ +const env = { + ...Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !['TURBOPACK', 'CI', 'NODE_ENV'].includes(key), + ), + ), + NEXT_TELEMETRY_DISABLED: '1', + NODE_ENV: mode, +} +/** @param {readonly string[]} args */ +function command(args) { + const result = spawnSync( + process.execPath, + [join(__dirname, 'native-webpack-owned.cjs'), '180000', ...args], + { + cwd: root, + env, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }, + ) + process.stdout.write(result.stdout ?? '') + process.stderr.write(result.stderr ?? '') + if (archive) { + const label = args.includes('build') ? 'build' : 'server' + writeFileSync(join(archive, `${label}-stdout.txt`), result.stdout ?? '') + writeFileSync(join(archive, `${label}-stderr.txt`), result.stderr ?? '') + } + assert.ifError(result.error) + assert.equal( + result.status, + 0, + `native Next command failed: ${args.join(' ')}`, + ) +} +function saveEvidence() { + const directory = archive + if (!directory) return + for (const name of ['.next', 'df', 'src', 'next.config.js', 'devup.json']) + if (existsSync(join(root, name))) + cpSync(join(root, name), join(directory, name), { recursive: true }) + for (const name of [ + 'native-evidence.jsonl', + `native-${mode}.json`, + 'native-cleanup.json', + ]) { + try { + writeFileSync( + join(directory, `${mode}-${name}`), + readFileSync(join(root, name)), + ) + } catch (error) { + if (!( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + )) + throw error + } + } +} +try { + fixture(root) + if (mode === 'production') + command([fromNext.resolve('next/dist/bin/next'), 'build', '--webpack']) + command([join(__dirname, 'native-webpack-server.cjs'), root, mode]) + const records = parseEvents( + readFileSync(join(root, 'native-evidence.jsonl'), 'utf8'), + ) + assertReserved(records) + const reservations = records.filter((entry) => entry.kind === 'reserve') + assert(reservations.length > 0) + assert( + reservations.every((entry) => + entry.args[0].some(([id]) => id === 'src/app/client.js'), + ), + ) + for (const entry of records.filter((entry) => entry.kind === 'extract')) + assert.equal(entry.after, 0, `residual from ${entry.args[0]}`) + for (const role of ['client', 'nodejs', 'edge']) { + assert( + records.some( + (entry) => + entry.kind === 'options' && + entry.compilerRole === role && + entry.delegation === 1, + ), + ) + assert( + records.some( + (entry) => entry.kind === 'compilation' && entry.role === role, + ), + ) + } + const receipt = parseStateReceipt( + readFileSync(join(root, `native-${mode}.json`), 'utf8'), + ) + if (mode === 'development') { + assertRetained(receipt.old, receipt.final) + const batch = reservations.find((entry) => + entry.args[0].some(([id]) => id === 'src/app/A.js'), + ) + assert(batch) + const ids = batch.args[0].map(([id]) => id) + assert(ids.includes('src/app/B.js')) + assertPhysicalOrder(batch.args[0], root) + } + console.info( + JSON.stringify({ + mode, + reservations: reservations.length, + extracts: records.filter((entry) => entry.kind === 'extract').length, + roles: [ + ...new Set( + records + .filter((entry) => entry.kind === 'compilation') + .map((entry) => entry.role), + ), + ], + residual: receipt.final.residual, + }), + ) +} finally { + try { + saveEvidence() + } finally { + rmSync(root, { recursive: true, force: true }) + } +} diff --git a/packages/next-plugin/src/__tests__/native-webpack-server.cjs b/packages/next-plugin/src/__tests__/native-webpack-server.cjs new file mode 100644 index 000000000..cb4ff8fba --- /dev/null +++ b/packages/next-plugin/src/__tests__/native-webpack-server.cjs @@ -0,0 +1,122 @@ +const assert = require('node:assert/strict') +const { createServer } = require('node:http') +const { writeFileSync } = require('node:fs') +const { join } = require('node:path') +const { revision } = require('./native-webpack-fixture.cjs') +const { request, surface } = require('./native-webpack-http.cjs') +const { parseEdge } = require('./native-webpack-receipt.ts') +const nextModule = require('next') +// Next declares an ESM default but its installed CJS entry exports the callable itself. +const next = nextModule.default ?? nextModule + +const root = process.argv[2] ?? '' +const mode = process.argv[3] +assert(root && (mode === 'production' || mode === 'development')) +process.chdir(root) +delete process.env.TURBOPACK +Object.assign(process.env, { NODE_ENV: mode }) +const observer = require('./native-webpack-observer.cjs') +async function run() { + const app = next({ dir: root, dev: mode === 'development', webpack: true }) + const server = createServer(app.getRequestHandler()) + const scope = new AbortController() + /** @type {unknown[]} */ + const failures = [] + try { + await app.prepare() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve(undefined) + }) + }) + const address = server.address() + assert(address && typeof address !== 'string') + const base = `http://127.0.0.1:${address.port}/` + const initial = await surface(base, false, scope.signal) + const edge = parseEdge(await request(`${base}edge`, scope.signal)) + const old = observer.snapshot() + let revised + if (mode === 'development') { + observer.revise() + const complete = observer.until( + (records) => + records.some( + (entry) => + entry.kind === 'done' && + entry.phase === 'revision' && + records.some( + (item) => + item.kind === 'extract' && item.args[0] === 'src/app/A.js', + ) && + records.some( + (item) => + item.kind === 'extract' && item.args[0] === 'src/app/B.js', + ), + ), + scope.signal, + ) + await Promise.all([ + complete, + Promise.resolve().then(() => revision(root)), + ]) + revised = await surface(base, true, scope.signal) + observer.assertReserved() + } + writeFileSync( + join(root, `native-${mode}.json`), + JSON.stringify({ + initial, + edge, + old, + revised, + final: observer.snapshot(), + }), + ) + } catch (error) { + failures.push(error) + } finally { + scope.abort() + const deadline = setTimeout(() => { + console.error(new Error('Native Next cleanup deadline exceeded')) + process.exit(1) + }, 15000) + const closed = await Promise.allSettled([ + new Promise((resolve, reject) => { + server.closeAllConnections() + if (!server.listening) { + resolve(undefined) + return + } + server.close((error) => (error ? reject(error) : resolve(undefined))) + }), + Promise.resolve().then(() => app.close()), + ]) + clearTimeout(deadline) + writeFileSync( + join(root, 'native-cleanup.json'), + JSON.stringify({ + pid: process.pid, + closed: closed.map((result) => result.status), + resources: process.getActiveResourcesInfo(), + }), + ) + failures.push( + ...closed.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ), + ) + } + if (failures.length > 0) + throw new AggregateError(failures, 'Native Next scenario or cleanup failed') +} +// This is an isolated test CLI: fulfillment includes the scenario and both closes. +// The external owner drains the entire native tree before returning to assertions. +run().then( + () => process.exit(0), + (error) => { + console.error(error) + process.exit(1) + }, +) diff --git a/packages/next-plugin/src/__tests__/native-webpack-supervise.ps1 b/packages/next-plugin/src/__tests__/native-webpack-supervise.ps1 new file mode 100644 index 000000000..bdcf9438d --- /dev/null +++ b/packages/next-plugin/src/__tests__/native-webpack-supervise.ps1 @@ -0,0 +1,40 @@ +param([string]$Config) +$ErrorActionPreference = 'Stop' +$job = $null +$owner = $null +$caller = $null +$result = 125 +$reason = 'setup_error' +try { + $settings = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Config)) | ConvertFrom-Json + $owner = [Diagnostics.Process]::GetProcessById($settings.owner) + $ownerHandle = $owner.Handle + $caller = [Diagnostics.Process]::GetProcessById($settings.caller) + $callerHandle = $caller.Handle + Add-Type -Path (Join-Path $PSScriptRoot 'webpack-job.cs') + $job = New-Object WebpackJob + if ($owner.HasExited -or $caller.HasExited) { throw 'Native command owner exited before launch' } + $job.Launch($settings.command, $settings.cwd) + [Console]::WriteLine('NATIVE-LIFECYCLE ' + (@{ event = 'launch'; pid = $job.RootPid } | ConvertTo-Json -Compress)) + $clock = [Diagnostics.Stopwatch]::StartNew() + while ($true) { + $members = @($job.Members()) + if ($job.RootExited) { $result = $job.ExitCode; $reason = 'command_exit'; break } + if ($owner.HasExited -or $caller.HasExited) { $result = 125; $reason = 'owner_exit'; break } + if ($clock.ElapsedMilliseconds -ge $settings.deadline) { $result = 124; $reason = 'deadline'; break } + Start-Sleep -Milliseconds 100 + } +} catch { + [Console]::Error.WriteLine($_.Exception.ToString()) +} finally { + if ($null -ne $job) { + try { + $job.KillAndWait() + [Console]::WriteLine('NATIVE-LIFECYCLE ' + (@{ event = 'tree_exit'; pids = @($job.Observed); active = @($job.Members()); reason = $reason; exitCode = $result } | ConvertTo-Json -Compress)) + } catch { $result = 125; [Console]::Error.WriteLine($_.Exception.ToString()) } + finally { $job.Dispose() } + } + if ($null -ne $owner) { $owner.Dispose() } + if ($null -ne $caller) { $caller.Dispose() } +} +exit $result diff --git a/packages/next-plugin/src/__tests__/native-webpack-types.ts b/packages/next-plugin/src/__tests__/native-webpack-types.ts new file mode 100644 index 000000000..1900d476b --- /dev/null +++ b/packages/next-plugin/src/__tests__/native-webpack-types.ts @@ -0,0 +1,79 @@ +import type { Configuration } from 'webpack' + +export type SlotMap = Readonly> +export type Snapshot = { + readonly classes: Readonly> + readonly files: SlotMap + readonly residual: number + readonly css: string +} +export type Row = readonly [string, string, string] +export type Aliases = Readonly> +export type ReserveArgs = [ + readonly Row[], + string, + boolean, + boolean, + boolean, + Aliases, +] +export type ExtractArgs = [ + string, + string, + string, + string, + boolean, + boolean, + boolean, + Aliases, +] +export type ExtractOutput = { + readonly code: string + readonly css: string | undefined + readonly cssFile: string | undefined + readonly map: string | undefined + readonly dependencies: readonly string[] + readonly updatedBaseStyle: boolean +} +export type Payload = + | { readonly kind: 'reset'; readonly state: Snapshot } + | { + readonly kind: 'reserve' + readonly args: ReserveArgs + readonly before: Snapshot + readonly after: Snapshot + } + | { + readonly kind: 'extract-start' + readonly args: ExtractArgs + readonly before: number + } + | { + readonly kind: 'extract' + readonly args: ExtractArgs + readonly before: number + readonly after: number + readonly output: ExtractOutput + } + | { + readonly kind: 'options' + readonly compilerRole: string + readonly dev: boolean + readonly buildId: string + readonly delegation: number + } + | { readonly kind: 'compilation'; readonly compilerName: string | undefined } + | { + readonly kind: 'done' + readonly errors: boolean + readonly state: Snapshot + } +export type NativeEvent = Payload & { + readonly pid: number + readonly epoch: number + readonly phase: string + readonly role: string +} +export type NativeConfig = Configuration & { + plugins: NonNullable +} diff --git a/packages/next-plugin/src/__tests__/native-webpack.test.js b/packages/next-plugin/src/__tests__/native-webpack.test.js new file mode 100644 index 000000000..a806a80fa --- /dev/null +++ b/packages/next-plugin/src/__tests__/native-webpack.test.js @@ -0,0 +1,28 @@ +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +for (const mode of ['production', 'development']) { + it(`qualifies genuine Next webpack ${mode} reservation and HTTP output`, () => { + // Given: a unique physical app using the unchanged installed delegation. + const driver = join(import.meta.dirname, 'native-webpack-run.cjs') + // When: actual Next builds/serves the app (and dev revises two files). + const result = spawnSync('node', [driver, mode], { + encoding: 'utf8', + timeout: 300000, + maxBuffer: 32 * 1024 * 1024, + }) + // Then: the native driver has checked engine receipts and HTTP assets. + expect(result.error).toBeUndefined() + expect({ + status: result.status, + stderr: result.stderr, + stdout: result.stdout, + }).toEqual({ + status: 0, + stderr: expect.any(String), + stdout: expect.any(String), + }) + }, 310000) +} diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index ce47e44f7..b088582cd 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -188,6 +188,30 @@ afterEach(() => { describe('DevupUINextPlugin', () => { describe('webpack', () => { + it.each([ + ['omitted', {}], + ['undefined', { atomHoist: undefined }], + ['OFF', { atomHoist: 0 }], + ['ON', { atomHoist: 2 }], + ['custom', { atomHoist: 3 }], + ])( + 'W66 default regressions: delegates %s atom policy to webpack', + (_label, options) => { + // Given: the webpack path, whose constructor owns the resolved default. + delete process.env.TURBOPACK + const ret = DevupUI({}, { ...options, singleCss: true }) + // When: Next configures its webpack compilation. + ret.webpack?.(createWebpackConfig(), createWebpackContext()) + // Then: explicit zero/thresholds and singleCss reach the real adapter seam. + expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ + ...options, + singleCss: true, + cssDir: resolve('.next/cache', 'devup-ui'), + productionPrewarmFiles: [], + }) + }, + ) + it('should apply webpack plugin', async () => { const ret = DevupUI({}) @@ -249,62 +273,68 @@ describe('DevupUINextPlugin', () => { }) }) describe('turbo', () => { - it('reuses production setup across Next config module reloads', () => { - setNodeEnv('production') - process.env.TURBOPACK = '1' - process.env.DEVUP_UI_PROFILE = '1' - getDefaultThemeSpy.mockReturnValue('dark') - const filename = resolve('src/app/page.tsx') - const graphSpy = spyOn( - importGraphModule, - 'buildStaticImportGraph', - ).mockReturnValue(createSingleFileGraph(filename)) - const compiledSpy = spyOn( - importGraphModule, - 'computeCompiledFiles', - ).mockReturnValue(['src/app/page.tsx']) - const profileSpy = spyOn(console, 'info').mockImplementation(() => {}) - const handoffFile = join('df', 'setup.bin') - let handoff: Parameters[1] | undefined - writeFileSyncSpy.mockImplementation((path, data) => { - if (String(path) === handoffFile) handoff = data - }) - readFileSyncSpy.mockImplementation((path) => { - if (String(path) === handoffFile && handoff !== undefined) { - return handoff as never - } - return '{}' as never - }) - unlinkSyncSpy.mockImplementation((path) => { - if (String(path) === handoffFile) throw new Error('cleanup failed') - }) + it.each([true, false])( + 'W66 availability: reuses production setup and warns once across config reloads (singleCss %s)', + (singleCss) => { + setNodeEnv('production') + process.env.TURBOPACK = '1' + process.env.DEVUP_UI_PROFILE = '1' + getDefaultThemeSpy.mockReturnValue('dark') + const filename = resolve('src/app/page.tsx') + const graphSpy = spyOn( + importGraphModule, + 'buildStaticImportGraph', + ).mockReturnValue(createSingleFileGraph(filename)) + const compiledSpy = spyOn( + importGraphModule, + 'computeCompiledFiles', + ).mockReturnValue(['src/app/page.tsx']) + const profileSpy = spyOn(console, 'info').mockImplementation(() => {}) + const warningSpy = spyOn(console, 'warn').mockImplementation(() => {}) + const handoffFile = join('df', 'setup.bin') + let handoff: Parameters[1] | undefined + writeFileSyncSpy.mockImplementation((path, data) => { + if (String(path) === handoffFile) handoff = data + }) + readFileSyncSpy.mockImplementation((path) => { + if (String(path) === handoffFile && handoff !== undefined) { + return handoff as never + } + return '{}' as never + }) + unlinkSyncSpy.mockImplementation((path) => { + if (String(path) === handoffFile) throw new Error('cleanup failed') + }) - try { - const first = DevupUI({}, { singleCss: true }) - reloadTurboSetupModuleForTesting() - const second = DevupUI( - { env: { EXISTING: 'value' } }, - { singleCss: true }, - ) + try { + const first = DevupUI({}, { singleCss, atomHoist: 2 }) + reloadTurboSetupModuleForTesting() + const second = DevupUI( + { env: { EXISTING: 'value' } }, + { singleCss, atomHoist: 2 }, + ) - expect(second.turbopack?.rules).toEqual(first.turbopack?.rules) - expect(second.env).toEqual({ - DEVUP_UI_DEFAULT_THEME: 'dark', - EXISTING: 'value', - }) - expect(graphSpy).toHaveBeenCalledTimes(1) - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(1) - expect(startCoordinatorSpy).toHaveBeenCalledTimes(1) - expect(unlinkSyncSpy).toHaveBeenCalledWith(handoffFile) - expect(profileSpy).toHaveBeenCalledWith( - expect.stringContaining('"cacheHit":true'), - ) - } finally { - profileSpy.mockRestore() - compiledSpy.mockRestore() - graphSpy.mockRestore() - } - }) + expect(second.turbopack?.rules).toEqual(first.turbopack?.rules) + expect(second.env).toEqual({ + DEVUP_UI_DEFAULT_THEME: 'dark', + EXISTING: 'value', + }) + expect(graphSpy).toHaveBeenCalledTimes(1) + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(1) + expect(startCoordinatorSpy).toHaveBeenCalledTimes(1) + expect(warningSpy).toHaveBeenCalledTimes(singleCss ? 0 : 1) + expect(unlinkSyncSpy).toHaveBeenCalledWith(handoffFile) + expect(profileSpy).toHaveBeenCalledWith( + expect.stringContaining('"cacheHit":true'), + ) + } finally { + warningSpy.mockRestore() + profileSpy.mockRestore() + compiledSpy.mockRestore() + graphSpy.mockRestore() + } + }, + ) it('falls back when a production setup handoff is not reusable', () => { setNodeEnv('production') @@ -982,7 +1012,7 @@ export const box = style({ color: 'red' })` return { close: mock() as () => void } }) try { - DevupUI({}) + DevupUI({}, { atomHoist: 0 }) expect(startCoordinatorSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -1154,7 +1184,7 @@ export const box = style({ color: 'red' })` } }) - it('does not enable atom hoisting when atomHoist option is unset', () => { + it('does not enable atom hoisting when atomHoist is explicitly zero', () => { process.env.TURBOPACK = '1' const setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue( undefined, @@ -1168,7 +1198,7 @@ export const box = style({ color: 'red' })` 'importCanonicalMap', ).mockReturnValue(undefined) try { - DevupUI({}) + DevupUI({}, { atomHoist: 0 }) expect(setAtomHoistSpy).not.toHaveBeenCalled() expect(importFileRoutesSpy).not.toHaveBeenCalled() // single-importer collapse still runs (it is the always-on default) @@ -1180,7 +1210,161 @@ export const box = style({ color: 'red' })` } }) - it('composes atom hoisting WITH single-importer collapse when atomHoist is set', () => { + describe('W66 default regressions', () => { + let canonicalSpy: ReturnType + let routesSpy: ReturnType + let compiledSpy: ReturnType + let importRoutesSpy: ReturnType + let thresholdSpy: ReturnType + + beforeEach(() => { + process.env.TURBOPACK = '1' + setNodeEnv('production') + canonicalSpy = spyOn( + importGraphModule, + 'buildCanonicalMap', + ).mockReturnValue({}) + routesSpy = spyOn( + importGraphModule, + 'computeFileRoutes', + ).mockReturnValue({ + 'src/app/layout.tsx': [0, 1], + 'src/app/a/page.tsx': [0], + 'src/app/b/page.tsx': [1], + }) + compiledSpy = spyOn( + importGraphModule, + 'computeCompiledFiles', + ).mockReturnValue(['src/app/a/page.tsx', 'src/app/b/page.tsx']) + importRoutesSpy = spyOn(wasm, 'importFileRoutes').mockReturnValue( + undefined, + ) + thresholdSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue(undefined) + }) + + afterEach(() => { + canonicalSpy.mockRestore() + routesSpy.mockRestore() + compiledSpy.mockRestore() + importRoutesSpy.mockRestore() + thresholdSpy.mockRestore() + }) + + it.each([undefined, 0, -1, NaN, Infinity, -Infinity, 0.5, 1, 2, 3])( + 'W66 availability: keeps per-file mode for request %s', + (atomHoist) => { + // Given: multi-route reach and legacy whole-file hoisting are available. + process.env.DEVUP_HOIST_V = '3' + const warningSpy = spyOn(console, 'warn').mockImplementation(() => {}) + try { + // When: Turbopack receives the numeric request. + DevupUI({}, { atomHoist }) + // Then: only explicit finite positive requests warn; none install atoms. + expect(routesSpy).not.toHaveBeenCalled() + expect(importRoutesSpy).not.toHaveBeenCalled() + expect(thresholdSpy).not.toHaveBeenCalled() + expect(canonicalSpy).toHaveBeenCalledWith( + expect.objectContaining({ hoistV: 3 }), + ) + expect(warningSpy).toHaveBeenCalledTimes( + atomHoist !== undefined && + Number.isFinite(atomHoist) && + atomHoist > 0 + ? 1 + : 0, + ) + } finally { + warningSpy.mockRestore() + } + }, + ) + + it.each([ + ['omitted', {}], + ['undefined', { atomHoist: undefined }], + ])( + 'keeps omitted atomHoist %s silent without consulting route reach', + (_label, options) => { + const warningSpy = spyOn(console, 'warn').mockImplementation(() => {}) + try { + DevupUI({}, options) + expect(routesSpy).not.toHaveBeenCalled() + expect(importRoutesSpy).not.toHaveBeenCalled() + expect(thresholdSpy).not.toHaveBeenCalled() + expect(warningSpy).not.toHaveBeenCalled() + } finally { + warningSpy.mockRestore() + } + }, + ) + + it.each([0, -1, NaN, Infinity, -Infinity])( + 'keeps legacy OFF for atomHoist %s', + (atomHoist) => { + // Given: a valid multi-route fixture, so fallback cannot hide enabling. + // When: an explicitly disabled numeric option is supplied. + DevupUI({}, { atomHoist }) + // Then: collapse remains active but atom reach is not consulted. + expect(canonicalSpy).toHaveBeenCalled() + expect(routesSpy).not.toHaveBeenCalled() + expect(importRoutesSpy).not.toHaveBeenCalled() + expect(thresholdSpy).not.toHaveBeenCalled() + }, + ) + + it('defers a custom threshold above the minimum until reservation lands', () => { + // Given: two routes; the threshold must not be replaced by the default. + // When: the caller requests three routes. + DevupUI({}, { atomHoist: 3 }) + // Then: unavailable atom hoisting never installs a threshold. + expect(thresholdSpy).not.toHaveBeenCalled() + expect(routesSpy).not.toHaveBeenCalled() + }) + + it.each([ + ['omitted', {}], + ['undefined', { atomHoist: undefined }], + ['enabled', { atomHoist: 2 }], + ])( + 'ignores %s atomHoist before planning in actual singleCss mode', + (_label, options) => { + // Given: legacy whole-file hoisting and an explicit-OFF single sheet. + process.env.DEVUP_HOIST_V = '3' + const off = DevupUI({}, { singleCss: true, atomHoist: 0 }) + const offExtractions = [...codeExtractWithoutSourceMapSpy.mock.calls] + expect(offExtractions).toHaveLength(2) + codeExtractWithoutSourceMapSpy.mockClear() + canonicalSpy.mockClear() + + // When: only the atom option changes. + const on = DevupUI({}, { singleCss: true, ...options }) + + // Then: reach/threshold, legacy policy, IDs, imports and loader config agree. + expect(routesSpy).not.toHaveBeenCalled() + expect(importRoutesSpy).not.toHaveBeenCalled() + expect(thresholdSpy).not.toHaveBeenCalled() + expect(canonicalSpy).toHaveBeenCalledWith( + expect.objectContaining({ hoistV: 3 }), + ) + expect(codeExtractWithoutSourceMapSpy.mock.calls).toEqual( + offExtractions, + ) + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( + 'src/app/a/page.tsx', + '{}', + '@devup-ui/react', + expect.any(String), + true, + false, + true, + expect.anything(), + ) + expect(on.turbopack?.rules).toEqual(off.turbopack?.rules) + }, + ) + }) + + it('preserves single-importer collapse but defers atom reach when atomHoist is set', () => { process.env.TURBOPACK = '1' // 4 distinct leaf routes (ids 0..3); layout shared by all four. const computeSpy = spyOn( @@ -1200,23 +1384,16 @@ export const box = style({ color: 'red' })` const setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue( undefined, ) - // Collapse and atom hoisting COMPOSE: importCanonicalMap must STILL run. + // Collapse must still run while atom hoisting is unavailable. const importCanonicalMapSpy = spyOn( wasm, 'importCanonicalMap', ).mockReturnValue(undefined) try { DevupUI({}, { atomHoist: 2 }) - // reach folded by bucket; mocked FS => empty canonical map => bucket==file - expect(importFileRoutesSpy).toHaveBeenCalledWith({ - 'src/app/layout.tsx': [0, 1, 2, 3], - 'src/app/a/page.tsx': [0], - 'src/app/b/page.tsx': [1], - 'src/app/c/page.tsx': [2], - 'src/app/d/page.tsx': [3], - }) - // direct threshold, clamped to >= 2 - expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + expect(computeSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() // composition: collapse pre-pass also ran expect(importCanonicalMapSpy).toHaveBeenCalled() } finally { @@ -1227,7 +1404,7 @@ export const box = style({ color: 'red' })` } }) - it('clamps the atomHoist threshold to a minimum of 2', () => { + it('defers even the former minimum-clamped atomHoist request', () => { process.env.TURBOPACK = '1' const computeSpy = spyOn( importGraphModule, @@ -1241,8 +1418,8 @@ export const box = style({ color: 'red' })` ) try { DevupUI({}, { atomHoist: 1 }) - // max(2, 1) === 2 - expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + expect(computeSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() } finally { computeSpy.mockRestore() setAtomHoistSpy.mockRestore() diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index f854ac1b2..b466605a4 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -13,7 +13,6 @@ import { buildStaticImportGraph, collectNumberedFiles, computeCompiledFiles, - computeFileRoutes, createCompatTypes, createNodeModulesExcludeRegex, createThemeInterfaceArgs, @@ -21,7 +20,6 @@ import { extractedNeedles, loadDevupConfigSync, mergeImportAliases, - planAtomHoist, seedFileNumbers, type StaticImportGraph, withTailwindCss, @@ -43,7 +41,11 @@ import { loadWasm, loadWebpackPlugin } from './wasm' /** Options accepted by the Next.js integration. */ export type DevupUINextPluginOptions = Partial & { - /** Share atoms reached by at least this many routes. */ + /** + * Atom-level route-aware hoisting threshold for webpack (default 2; 0 disables). + * Turbopack keeps per-file names until shared class-slot reservation lands in + * #752; explicit finite positive requests warn once. Ignored in singleCss mode. + */ atomHoist?: number } @@ -152,6 +154,11 @@ export function DevupUI( importAliases: userImportAliases, } = options + const requestedAtomHoist = + !singleCss && + atomHoist !== undefined && + Number.isFinite(atomHoist) && + atomHoist > 0 const importAliases = mergeImportAliases(userImportAliases) const watch = process.env.NODE_ENV === 'development' const sourceMap = watch || config.productionBrowserSourceMaps === true @@ -168,7 +175,7 @@ export function DevupUI( // the coordinator. Calls from the same module instance never reuse it, // which avoids turning this into a general cross-build cache. const productionSetupKey = JSON.stringify({ - atomHoist, + atomHoist: requestedAtomHoist ? atomHoist : 0, cwd: process.cwd(), cssDir, devupFile, @@ -204,6 +211,11 @@ export function DevupUI( }) return config } + if (requestedAtomHoist) { + console.warn( + '[devup-ui] Turbopack atomHoist is unavailable until shared class-slot reservation lands in #752; keeping per-file names, while existing shared slots remain arrival-ordered.', + ) + } if (!existsSync(distDir)) mkdirSync(distDir, { recursive: true, @@ -227,11 +239,9 @@ export function DevupUI( importCanonicalMap, importClassMap, importFileMap, - importFileRoutes, importSheet, registerShorthands, registerTheme, - setAtomHoist, setPrefix, } = wasm @@ -272,19 +282,8 @@ export function DevupUI( const coordinatorPortFile = resolveCoordinatorPortFile(distDir) - // Pre-pass: single-importer collapse ALWAYS runs (files with exactly one - // importer merge into that importer's bucket, so their identical atoms share - // one class). Atom-level hoisting COMPOSES on top: an atom reached by - // >= atomHoist distinct routes is emitted once into the shared devup-ui.css. - // - // The two compose because both are keyed by the canonical bucket: the engine - // keys property buckets by canonical(filename), and the route-reach map below - // is folded onto the SAME canonical bucket — so route_count_for_files() looks - // atoms up by bucket and the lookup hits. `atomHoist` must be configured - // BEFORE any extraction so atoms receive global (shared) class names; the - // coordinator shares this WASM instance, so it applies to every /extract. - const atomMode = - atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 + // Collapse and legacy whole-file hoisting remain independent of the deferred + // Turbopack atom reservation. Never install atom reach/threshold without it. const extract = sourceMap ? codeExtract : codeExtractWithoutSourceMap // Hoisted out of the try so the coordinator can receive it for per-bucket // completion. Stays `{}` if the best-effort pre-pass fails. @@ -302,13 +301,9 @@ export function DevupUI( // One scan+parse of the source tree, shared by all three consumers below. const graph = buildStaticImportGraph(srcDir, tsconfigPath) staticGraph = graph - // Atom hoisting owns the shared-chunk decision, so collapse runs WITHOUT - // the file-level @global hoist (DEVUP_HOIST_V) in atom mode. - const hoistV = atomMode - ? undefined - : process.env.DEVUP_HOIST_V - ? Number(process.env.DEVUP_HOIST_V) - : undefined + const hoistV = process.env.DEVUP_HOIST_V + ? Number(process.env.DEVUP_HOIST_V) + : undefined canonicalMap = buildCanonicalMap({ srcDir, tsconfigPath, @@ -331,25 +326,6 @@ export function DevupUI( graph, }) - if (atomMode) { - const fileRoutes = computeFileRoutes({ - srcDir, - tsconfigPath, - cwd, - graph, - }) - // Fold per-file route reach onto the canonical bucket so the keys match - // the engine's property bucket keys (canonical(filename)). - const plan = planAtomHoist(canonicalMap, fileRoutes, atomHoist) - if (plan) { - importFileRoutes(plan.reachByBucket) - setAtomHoist(plan.threshold) - } else { - console.info( - '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op.', - ) - } - } reportProfile('next.graph', { durationMs: elapsedMs(graphStartedAt), files: staticGraph.files.length, diff --git a/packages/plugin-utils/src/__tests__/audit-cjs.test.ts b/packages/plugin-utils/src/__tests__/audit-cjs.test.ts new file mode 100644 index 000000000..be5df4bb0 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/audit-cjs.test.ts @@ -0,0 +1,45 @@ +import { relative } from 'node:path' + +import { expect, it } from 'bun:test' + +import { collectReservationInputs } from '../reservation-discovery' +import { fixture } from './reservation-fixture' + +const red = + 'import {css} from "@devup-ui/react"; export const cls=css({color:"red"});' + +it('includes physical literal require leaves of an implicit allowed package', () => { + // Given + using fs = fixture() + fs.write( + 'src/entry.js', + 'import {A,B} from "@devup-ui/widgets"; console.log(A,B);', + ) + fs.write( + 'node_modules/@devup-ui/widgets/package.json', + '{"main":"dist/index.js"}', + ) + fs.write( + 'node_modules/@devup-ui/widgets/dist/index.js', + 'exports.A=require("./A.js"); exports.B=require("./B.js");', + ) + fs.write('node_modules/@devup-ui/widgets/dist/A.js', red) + fs.write( + 'node_modules/@devup-ui/widgets/dist/B.js', + red.replace('red', 'blue'), + ) + // When + const inventory = collectReservationInputs({ + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + }) + const paths = inventory.inputs.map(({ physicalPath }) => + relative(fs.root, physicalPath).replaceAll('\\', '/'), + ) + console.info(JSON.stringify({ audit: 'require-inventory', paths })) + // Then + expect(paths).toContain('node_modules/@devup-ui/widgets/dist/A.js') + expect(paths).toContain('node_modules/@devup-ui/widgets/dist/B.js') +}) diff --git a/packages/plugin-utils/src/__tests__/audit-manifest.test.ts b/packages/plugin-utils/src/__tests__/audit-manifest.test.ts new file mode 100644 index 000000000..98ac0fd2d --- /dev/null +++ b/packages/plugin-utils/src/__tests__/audit-manifest.test.ts @@ -0,0 +1,89 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { PackageManifestShapeError } from '../package-manifest' +import { collectReservationInputs } from '../reservation-discovery' +import { reservationSource } from '../reservation-source' +import { fixture } from './reservation-fixture' + +const red = + 'import {css} from "@devup-ui/react"; export const cls=css({color:"red"});' + +it('isolates an unused null manifest while retaining the ordinary reachable resolver error', () => { + // Given + using fs = fixture() + fs.write('src/entry.js', red) + fs.write('src/Unused.js', 'import "@devup-ui/broken";') + fs.write('node_modules/@devup-ui/broken/package.json', 'null') + const options = { + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + } + const resolver = createModuleResolver({ cwd: fs.root }) + expect(() => + resolver('@devup-ui/broken', join(fs.root, 'src/entry.js')), + ).toThrow(TypeError) + // When / Then: scratch discovery must not publish an unreachable manifest error. + expect(() => collectReservationInputs(options)).not.toThrow() +}) + +it.each(['null', '[]', '"text"', '1', 'true', 'false'])( + 'isolates manifest shape %s only at the explicit reservation boundary', + (manifest) => { + // Given + using fs = fixture() + fs.write('node_modules/@devup-ui/broken/package.json', manifest) + fs.write('node_modules/@devup-ui/broken/index.js', red) + const resolver = createModuleResolver({ cwd: fs.root }) + const read = () => resolver('@devup-ui/broken', 'src/entry.js') + // When / Then + expect(read).toThrow(PackageManifestShapeError) + expect(read).toThrow(TypeError) + expect(() => reservationSource(read)).toThrow(PackageManifestShapeError) + expect(() => reservationSource(read, false)).toThrow( + PackageManifestShapeError, + ) + expect(reservationSource(read, true)).toBeUndefined() + }, +) + +it('discovers a repaired manifest without hiding ordinary resolver failures', () => { + // Given + using fs = fixture() + fs.write('src/entry.js', 'import "@devup-ui/broken";') + fs.write('node_modules/@devup-ui/broken/package.json', 'null') + fs.write('node_modules/@devup-ui/broken/index.js', red) + const options = { + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + } + const resolver = createModuleResolver({ cwd: fs.root }) + expect(collectReservationInputs(options).inputs).toEqual([]) + expect(() => resolver('@devup-ui/broken', 'src/entry.js')).toThrow( + PackageManifestShapeError, + ) + fs.write('node_modules/@devup-ui/broken/package.json', '{}') + // When + const repaired = collectReservationInputs(options) + // Then + expect(repaired.inputs.map(({ code }) => code)).toEqual([red]) + expect(resolver('@devup-ui/broken', 'src/entry.js')?.code).toBe(red) +}) + +it.each([new TypeError('resolver logic failure'), new Error('logic failure')])( + 'preserves unrelated errors at the explicit manifest boundary: %s', + (error) => { + // Given + const read = () => { + throw error + } + // When / Then + expect(() => reservationSource(read, true)).toThrow(error) + }, +) diff --git a/packages/plugin-utils/src/__tests__/audit-scanner.test.ts b/packages/plugin-utils/src/__tests__/audit-scanner.test.ts new file mode 100644 index 000000000..3096c560f --- /dev/null +++ b/packages/plugin-utils/src/__tests__/audit-scanner.test.ts @@ -0,0 +1,38 @@ +import { relative } from 'node:path' + +import { expect, it } from 'bun:test' + +import { __setOxcParserForTest } from '../import-graph' +import { collectReservationInputs } from '../reservation-discovery' +import { fixture } from './reservation-fixture' + +const red = + 'import {css} from "@devup-ui/react"; export const cls=css({color:"red"});' + +it('ignores ordinary quoted import payloads in the existing fallback scanner seam', () => { + // Given + using fs = fixture() + fs.write( + 'src/entry.js', + `${red}\nexport const snippet = "import '../out/stale.js'";`, + ) + fs.write('out/stale.js', red.replace('red', 'chartreuse')) + __setOxcParserForTest(false) + try { + // When + const inventory = collectReservationInputs({ + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + }) + const paths = inventory.inputs.map(({ physicalPath }) => + relative(fs.root, physicalPath).replaceAll('\\', '/'), + ) + console.info(JSON.stringify({ audit: 'quoted-inventory', paths })) + // Then + expect(paths).toEqual(['src/entry.js']) + } finally { + __setOxcParserForTest(undefined) + } +}) diff --git a/packages/plugin-utils/src/__tests__/import-scanner.test.ts b/packages/plugin-utils/src/__tests__/import-scanner.test.ts new file mode 100644 index 000000000..fe1e1ae98 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-scanner.test.ts @@ -0,0 +1,107 @@ +import { join } from 'node:path' + +import { afterEach, expect, it } from 'bun:test' + +import { + __setOxcParserForTest, + buildStaticImportGraph, + createModuleResolver, + importedSpecifiers, +} from '../import-graph' +import { fixture } from './reservation-fixture' + +afterEach(() => __setOxcParserForTest(undefined)) + +it.each([ + [`"import './fake'"`, "import './real'"], + [`'import "./fake"'`, 'import "./real"'], + [`"import { value } from './fake'"`, "import { value } from './real'"], + [`'import value from "./fake"'`, 'import value from "./real"'], + [`"export { value } from './fake'"`, "export { value } from './real'"], + [`'export * from "./fake"'`, 'export * from "./real"'], + [`"import('./fake')"`, "import('./real')"], + [`'import("./fake")'`, 'import("./real")'], + [String.raw`"escaped \" import './fake'"`, "import './real'"], + [String.raw`'escaped \' import "./fake"'`, 'import "./real"'], + [String.raw`"backslash \\"`, "import './real'"], + ['`import "./fake"`', "import './real'"], + ['`export * from "./fake"`', 'export * from "./real"'], + ['`import("./fake")`', 'import("./real")'], +])('ignores a quoted %s while retaining genuine %s', (snippet, genuine) => { + // Given + __setOxcParserForTest(false) + const source = `const snippet = ${snippet};\n${genuine};` + // When + const specifiers = importedSpecifiers('entry.ts', source) + // Then + expect(specifiers).toEqual(['./real']) +}) + +it('retains real static, reexport and dynamic graph edges around comments', () => { + // Given + using fs = fixture() + __setOxcParserForTest(false) + fs.write( + 'src/entry.js', + [ + '// import "./fake"', + '/* export * from "./fake"; import("./fake") */', + 'import /* comment */ "./static";', + 'export { value } /* comment */ from "./reexport";', + 'import(/* comment */ "./dynamic");', + ].join('\n'), + ) + for (const name of ['static', 'reexport', 'dynamic', 'fake']) + fs.write(`src/${name}.js`, 'export const value = 1;') + // When + const graph = buildStaticImportGraph(join(fs.root, 'src')) + // Then + expect(graph.staticImports.get(join(fs.root, 'src/entry.js'))).toEqual( + new Set( + ['static', 'reexport'].map((name) => join(fs.root, `src/${name}.js`)), + ), + ) + expect(graph.dynamicImports.get(join(fs.root, 'src/entry.js'))).toEqual( + new Set([join(fs.root, 'src/dynamic.js')]), + ) +}) + +it('ignores quoted snippets when the optional AST parser fails', () => { + // Given + __setOxcParserForTest({ + parseSync() { + throw new SyntaxError('parser unavailable for this input') + }, + }) + // When + const specifiers = importedSpecifiers( + 'entry.ts', + `const snippet = "import './fake'"; import './real';`, + ) + // Then + expect(specifiers).toEqual(['./real']) +}) + +it('preserves JSON string contents when resolving commented tsconfig aliases', () => { + // Given + using fs = fixture() + fs.write( + 'tsconfig.json', + `{ + // JSON strings are configuration, not source snippets. + "compilerOptions": { + "baseUrl": ".", + "paths": { "@/*": ["src/*"], }, + }, + }`, + ) + fs.write('src/real.js', 'export const value = 1;') + const resolver = createModuleResolver({ + cwd: fs.root, + tsconfigPath: join(fs.root, 'tsconfig.json'), + }) + // When + const module = resolver('@/real', join(fs.root, 'src/entry.js')) + // Then + expect(module?.path).toBe(join(fs.root, 'src/real.js')) +}) diff --git a/packages/plugin-utils/src/__tests__/require-crlf.test.ts b/packages/plugin-utils/src/__tests__/require-crlf.test.ts new file mode 100644 index 000000000..07840cfbe --- /dev/null +++ b/packages/plugin-utils/src/__tests__/require-crlf.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it } from 'bun:test' + +import { __setOxcParserForTest, importedSpecifiers } from '../import-graph' + +afterEach(() => __setOxcParserForTest(undefined)) + +describe.each(['fallback', 'ast'])('%s CRLF require discovery', (mode) => { + it.each(['"', "'"])( + 'follows physical literals with CRLF continuations inside %s quotes', + (quote) => { + // Given + const source = ['A', 'B'] + .map((name) => `require(${quote}./\\\r\n${name}.js${quote});`) + .join('\n') + __setOxcParserForTest( + mode === 'ast' + ? { + parseSync: () => ({ + body: ['A', 'B'].map((name) => ({ + type: 'CallExpression', + callee: { type: 'Identifier', name: 'require' }, + arguments: [{ type: 'Literal', value: `./${name}.js` }], + })), + }), + } + : false, + ) + // When + const specifiers = importedSpecifiers('entry.js', source) + // Then + expect(specifiers).toEqual(['./A.js', './B.js']) + }, + ) +}) + +it.each([ + 'require("./\r\nA.js");', + "require('./\r\nB.js');", + 'const snippet = "require(\'./\\\r\nA.js\')";', + 'const snippet = \'require("./\\\r\nB.js")\';', +])('ignores invalid or quoted CRLF payload %s', (source) => { + // Given + __setOxcParserForTest(false) + // When + const specifiers = importedSpecifiers('entry.js', source) + // Then + expect(specifiers).toEqual([]) +}) diff --git a/packages/plugin-utils/src/__tests__/require-escaped.test.ts b/packages/plugin-utils/src/__tests__/require-escaped.test.ts new file mode 100644 index 000000000..ba01d075c --- /dev/null +++ b/packages/plugin-utils/src/__tests__/require-escaped.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from 'bun:test' + +import { __setOxcParserForTest, importedSpecifiers } from '../import-graph' + +afterEach(() => __setOxcParserForTest(undefined)) + +describe.each(['fallback', 'ast'])('%s escaped require discovery', (mode) => { + it.each([ + [String.raw`"./\x41.js"`, './A.js'], + [String.raw`'./\u0042.js'`, './B.js'], + [String.raw`'.\/\u{41}.js'`, './A.js'], + [String.raw`".\u002f\x42.js"`, './B.js'], + [String.raw`"./escaped\"quote.js"`, './escaped"quote.js'], + [String.raw`'./escaped\'quote.js'`, "./escaped'quote.js"], + [String.raw`"./back\\slash.js"`, './back\\slash.js'], + ['"./line\\\ncontinued.js"', './linecontinued.js'], + ])( + 'decodes static literal %s to its physical specifier', + (literal, value) => { + // Given + const source = `require(${literal});` + __setOxcParserForTest( + mode === 'ast' + ? { + parseSync: () => ({ + type: 'CallExpression', + callee: { type: 'Identifier', name: 'require' }, + arguments: [{ type: 'Literal', value }], + }), + } + : false, + ) + // When + const specifiers = importedSpecifiers('entry.js', source) + // Then + expect(specifiers).toEqual([value]) + }, + ) +}) + +it.each([ + String.raw`require("./\x4G.js");`, + String.raw`require('./\u00ZZ.js');`, + String.raw`require("./\u{110000}.js");`, + String.raw`require("./\u{}.js");`, + String.raw`require("./\x41.js" + name);`, + String.raw`require(name);`, + 'require(`./\\u0041.js`);', + String.raw`const text = "escaped \" require('./\x41.js')";`, + String.raw`const text = 'escaped \' require("./\u0042.js")';`, + String.raw`// require("./\x41.js")`, +])('ignores malformed, nonliteral or quoted escaped call %s', (source) => { + // Given + __setOxcParserForTest(false) + // When + const specifiers = importedSpecifiers('entry.js', source) + // Then + expect(specifiers).toEqual([]) +}) diff --git a/packages/plugin-utils/src/__tests__/require-scanner.test.ts b/packages/plugin-utils/src/__tests__/require-scanner.test.ts new file mode 100644 index 000000000..001512891 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/require-scanner.test.ts @@ -0,0 +1,170 @@ +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { + __setOxcParserForTest, + buildStaticImportGraph, + importedSpecifiers, +} from '../import-graph' +import { fixture } from './reservation-fixture' + +afterEach(() => __setOxcParserForTest(undefined)) + +describe.each(['fallback', 'ast'])('%s literal require discovery', (mode) => { + it.each([ + { + source: 'exports.A = require("./leaf");', + node: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'require' }, + arguments: [{ type: 'Literal', value: './leaf' }], + }, + expected: ['./leaf'], + }, + { + source: "require /* comment */ ( /* comment */ './leaf' /* comment */ );", + node: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'require' }, + arguments: [{ type: 'StringLiteral', value: './leaf' }], + }, + expected: ['./leaf'], + }, + { + source: `// require('./fake')\n/* require('./fake') */\nconst text = "require('./fake')";`, + node: { type: 'Literal', value: "require('./fake')" }, + expected: [], + }, + { + source: 'const text = `require("./fake")`;', + node: { type: 'TemplateLiteral', quasis: [], expressions: [] }, + expected: [], + }, + { + source: 'require(name);', + node: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'require' }, + arguments: [{ type: 'Identifier', name: 'name' }], + }, + expected: [], + }, + { + source: 'require("./" + name);', + node: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'require' }, + arguments: [{ type: 'BinaryExpression', operator: '+' }], + }, + expected: [], + }, + { + source: 'require(`./leaf`);', + node: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'require' }, + arguments: [{ type: 'TemplateLiteral', quasis: [], expressions: [] }], + }, + expected: [], + }, + { + source: 'require("./fake", extra);', + node: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'require' }, + arguments: [ + { type: 'Literal', value: './fake' }, + { type: 'Identifier', name: 'extra' }, + ], + }, + expected: [], + }, + { + source: 'object /* comment */ . /* comment */ require("./fake");', + node: { + type: 'CallExpression', + callee: { type: 'MemberExpression', property: { name: 'require' } }, + arguments: [{ type: 'Literal', value: './fake' }], + }, + expected: [], + }, + { + source: 'require?.("./fake");', + node: { + type: 'CallExpression', + optional: true, + callee: { type: 'Identifier', name: 'require' }, + arguments: [{ type: 'Literal', value: './fake' }], + }, + expected: [], + }, + { + source: 'new require("./fake");', + node: { + type: 'NewExpression', + callee: { type: 'Identifier', name: 'require' }, + arguments: [{ type: 'Literal', value: './fake' }], + }, + expected: [], + }, + { + source: 'otherrequire("./fake"); $require("./fake");', + node: { + type: 'CallExpression', + callee: { type: 'Identifier', name: '$require' }, + arguments: [{ type: 'Literal', value: './fake' }], + }, + expected: [], + }, + ])( + 'discovers only direct static calls in $source', + ({ source, node, expected }) => { + // Given + __setOxcParserForTest( + mode === 'ast' + ? { parseSync: () => ({ program: { body: [node] } }) } + : false, + ) + // When + const specifiers = importedSpecifiers('entry.js', source) + // Then + expect(specifiers).toEqual(expected) + }, + ) + + it('records literal require as static while retaining dynamic import edges', () => { + // Given + using fs = fixture() + fs.write('src/entry.js', 'require("./leaf"); import("./lazy");') + fs.write('src/leaf.js', 'export const leaf = 1;') + fs.write('src/lazy.js', 'export const lazy = 1;') + __setOxcParserForTest( + mode === 'ast' + ? { + parseSync: (filename) => ({ + body: filename.endsWith('entry.js') + ? [ + { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'require' }, + arguments: [{ type: 'Literal', value: './leaf' }], + }, + { type: 'ImportExpression', source: { value: './lazy' } }, + ] + : [], + }), + } + : false, + ) + // When + const graph = buildStaticImportGraph(join(fs.root, 'src')) + // Then + expect(graph.staticImports.get(join(fs.root, 'src/entry.js'))).toEqual( + new Set([join(fs.root, 'src/leaf.js')]), + ) + expect(graph.dynamicImports.get(join(fs.root, 'src/entry.js'))).toEqual( + new Set([join(fs.root, 'src/lazy.js')]), + ) + }) +}) diff --git a/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts b/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts new file mode 100644 index 000000000..757f2b0b9 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts @@ -0,0 +1,213 @@ +import { join, relative } from 'node:path' + +import { expect, it } from 'bun:test' + +import { + collectReservationInputs, + PreparedInputConflictError, +} from '../reservation-discovery' +import { fixture, STYLE } from './reservation-fixture' + +it('keeps D9 exclusions and includes explicit package dist when roots arrive reversed', () => { + // Given + using fs = fixture() + fs.write('src/Z.tsx', STYLE) + fs.write('src/A.tsx', STYLE) + fs.write('src/ignored.test.tsx', STYLE) + fs.write('src/readme.md', STYLE) + fs.write('src/plain.ts', 'export const plain=1') + fs.write('node_modules/@acme/ui/dist/Button.js', STYLE) + fs.write('node_modules/@acme/ui/src/Card.tsx', STYLE) + fs.write('node_modules/@acme/ui/node_modules/unused/skipped.js', STYLE) + const options = { + roots: ['src', 'missing'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + include: ['@acme/ui', 'absent'], + toId: (path: string) => relative(fs.root, path).replaceAll('\\', '/'), + } + // When + const forward = collectReservationInputs(options) + const reverse = collectReservationInputs({ + ...options, + roots: ['missing', 'src'], + }) + // Then + expect(forward.inputs).toEqual(reverse.inputs) + expect(forward.inputs.map(({ filename }) => filename)).toEqual([ + 'node_modules/@acme/ui/dist/Button.js', + 'node_modules/@acme/ui/src/Card.tsx', + 'src/A.tsx', + 'src/Z.tsx', + ]) + expect(forward.inputs.every(({ code }) => code === STYLE)).toBe(true) +}) + +it('discovers implicit allowed dist imports through a plain project module and relative cycles', () => { + // Given + using fs = fixture() + fs.write( + 'src/layout.ts', + "import {Button} from '@devup-ui/components'; import '@devup-ui/reset-css'; export {Button}", + ) + fs.write( + 'node_modules/@devup-ui/components/package.json', + JSON.stringify({ + exports: { '.': { import: './dist/index.js', default: './wrong.js' } }, + }), + ) + fs.write( + 'node_modules/@devup-ui/components/dist/index.js', + "export * from './Button'; export * from './barrel'", + ) + fs.write('node_modules/@devup-ui/components/dist/Button.js', STYLE) + fs.write( + 'node_modules/@devup-ui/components/dist/barrel.js', + "export * from './index'; export const n=1", + ) + fs.write('node_modules/@devup-ui/components/wrong.js', STYLE) + fs.write( + 'node_modules/@devup-ui/reset-css/package.json', + JSON.stringify({ module: './dist/index.js' }), + ) + fs.write( + 'node_modules/@devup-ui/reset-css/dist/index.js', + "import {globalCss} from '@devup-ui/react'; globalCss({body:{margin:0}})", + ) + fs.write('node_modules/@devup-ui/unrelated/dist/index.js', STYLE) + // When + const result = collectReservationInputs({ + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + }) + // Then + expect( + result.inputs.map(({ physicalPath }) => + relative(fs.root, physicalPath).replaceAll('\\', '/'), + ), + ).toEqual([ + 'node_modules/@devup-ui/components/dist/Button.js', + 'node_modules/@devup-ui/components/dist/barrel.js', + 'node_modules/@devup-ui/components/dist/index.js', + 'node_modules/@devup-ui/reset-css/dist/index.js', + ]) +}) + +it('includes aliased constant and stylesheet suppliers while retaining exact Windows-style engine IDs', () => { + // Given + using fs = fixture() + fs.write( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@tokens/*': ['shared/*'] } }, + }), + ) + fs.write( + 'src/A.tsx', + "import {Box} from '@devup-ui/react'; import {n} from '@tokens/constant'; import './styles.css'; export const A=()=> ", + ) + fs.write( + 'src/styles.css.ts', + "import * as stylex from '@stylexjs/stylex'; export {n} from '@tokens/constant';", + ) + fs.write('shared/constant.ts', 'export const n=4') + // When + const result = collectReservationInputs({ + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + toId: (path) => `raw\\${relative(fs.root, path).replaceAll('/', '\\')}`, + }) + // Then + expect(result.inputs.map(({ filename }) => filename)).toEqual([ + 'raw\\shared\\constant.ts', + 'raw\\src\\A.tsx', + 'raw\\src\\styles.css.ts', + ]) + expect(result.inputs.map(({ code }) => code)).toContain('export const n=4') +}) + +it('overlays owned compiled MDX records without reading raw MDX or deduplicating canonical engine IDs', () => { + // Given + using fs = fixture() + fs.write('src/A.tsx', STYLE) + fs.write('src/page.mdx', '# Not JavaScript " + const prepared = { + physicalPath: join(fs.root, 'src/page.mdx'), + filename: 'owned/page.mdx.tsx', + code: compiled, + } + // When + const result = collectReservationInputs({ + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + toId: () => 'bucket.tsx', + prepared: [prepared, prepared], + }) + // Then + expect(result.inputs.map(({ filename }) => filename)).toEqual([ + 'bucket.tsx', + 'bucket.tsx', + 'owned/page.mdx.tsx', + ]) + expect(result.inputs.map(({ code }) => code)).toEqual([ + 'export const n=4', + STYLE, + compiled, + ]) + expect( + result.inputs.some(({ code }) => code.includes('Not JavaScript')), + ).toBe(false) +}) + +it('rejects conflicting prepared bytes for one physical path before producing an inventory', () => { + // Given + using fs = fixture() + const prepared = { + physicalPath: join(fs.root, 'page.mdx'), + filename: 'owned.tsx', + code: STYLE, + } + // When / Then + expect(() => + collectReservationInputs({ + roots: [], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + prepared: [prepared, { ...prepared, code: 'different compiled code' }], + }), + ).toThrow(PreparedInputConflictError) +}) + +it('ignores missing allowed packages and excluded relative test files', () => { + // Given + using fs = fixture() + fs.write( + 'src/A.tsx', + `${STYLE}\nimport '@devup-editor/absent'; import './ignored.test';`, + ) + fs.write('src/ignored.test.ts', STYLE) + // When + const result = collectReservationInputs({ + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + }) + // Then + expect( + result.inputs.map(({ physicalPath }) => relative(fs.root, physicalPath)), + ).toEqual([ + 'src/A.tsx'.replaceAll('/', process.platform === 'win32' ? '\\' : '/'), + ]) +}) diff --git a/packages/plugin-utils/src/__tests__/reservation-entries.test.ts b/packages/plugin-utils/src/__tests__/reservation-entries.test.ts new file mode 100644 index 000000000..0bd758cb5 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/reservation-entries.test.ts @@ -0,0 +1,135 @@ +import { join, relative } from 'node:path' + +import { expect, it } from 'bun:test' + +import { collectReservationInputs } from '../reservation-discovery' +import { createSharedSlotReservation } from '../shared-slots' +import { fixture, flags, recorder, STYLE } from './reservation-fixture' + +it('discovers exact physical and extensionless aliased entries plus relative closure, never siblings', () => { + // Given + using fs = fixture() + fs.write( + 'tsconfig.json', + JSON.stringify({ compilerOptions: { paths: { '@entry': ['index'] } } }), + ) + fs.write( + 'index.tsx', + `${STYLE}\nimport {n} from './suppliers/constant'; import './raw.css';`, + ) + fs.write( + 'raw.css.ts', + 'import {createVar} from "@vanilla-extract/css"; export const v=createVar()', + ) + fs.write('suppliers/constant.ts', 'export const n=4') + fs.write('suppliers/unused.ts', STYLE) + fs.write('unused.ts', STYLE) + fs.write('dist/output.ts', STYLE) + fs.write('src/A.tsx', STYLE) + const options = { + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + toId: (path: string) => + `raw\\${relative(fs.root, path).replaceAll('/', '\\')}`, + } + // When + const exact = collectReservationInputs({ + ...options, + entries: [{ request: join(fs.root, 'index.tsx'), context: fs.root }], + }) + const extensionless = collectReservationInputs({ + ...options, + entries: [{ request: './index', context: fs.root }], + }) + const aliased = collectReservationInputs({ + ...options, + entries: [{ request: '@entry', context: fs.root }], + }) + // Then + expect(exact).toEqual(extensionless) + expect(exact).toEqual(aliased) + expect(exact.inputs.map(({ filename }) => filename)).toEqual([ + 'raw\\index.tsx', + 'raw\\raw.css.ts', + 'raw\\src\\A.tsx', + 'raw\\suppliers\\constant.ts', + ]) + expect(exact.dependencies).toHaveLength(4) +}) + +it('retries missing entries and broken manifests, and replaces a changed static entry closure per revision', () => { + // Given + using fs = fixture() + fs.write('src/A.tsx', STYLE) + fs.write('node_modules/entry-package/package.json', '{') + const recorded = recorder() + const slots = createSharedSlotReservation(recorded.engine) + const options = { + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + toId: (path: string) => relative(fs.root, path).replaceAll('\\', '/'), + entries: [ + { request: './missing', context: fs.root }, + { request: 'entry-package', context: fs.root }, + ], + } + slots.reserve(options, flags) + fs.write('missing.ts', `${STYLE}\nimport './constant'`) + fs.write('constant.ts', 'export const n=4') + fs.write('node_modules/entry-package/package.json', '{"main":"index.js"}') + fs.write('node_modules/entry-package/index.js', STYLE) + // When + slots.reserve(options, flags) + slots.reserve({ ...options, entries: [] }, flags) + // Then + expect(recorded.packets.map(({ rows }) => rows.map(([id]) => id))).toEqual([ + ['src/A.tsx'], + [ + 'constant.ts', + 'missing.ts', + 'node_modules/entry-package/index.js', + 'src/A.tsx', + ], + ['src/A.tsx'], + ]) +}) + +it('retains prepared compiled overlays for an exact physical entry and never feeds raw MDX', () => { + // Given + using fs = fixture() + fs.write('index.tsx', 'not the compiled source') + fs.write('page.mdx', '# Raw unparsed MDX') + fs.write('constant.ts', 'export const n=4') + const compiled = `${STYLE}\nimport './constant'` + // When + const result = collectReservationInputs({ + roots: [], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + entries: [ + { request: './index.tsx', context: fs.root }, + { request: './page.mdx', context: fs.root }, + ], + prepared: [ + { + physicalPath: 'index.tsx', + filename: 'owned\\compiled.tsx', + code: compiled, + }, + ], + }) + // Then + expect(result.inputs.map(({ code }) => code)).toEqual([ + 'export const n=4', + compiled, + ]) + expect(result.inputs.map(({ filename }) => filename)).toContain( + 'owned\\compiled.tsx', + ) + expect(result.dependencies).toHaveLength(2) +}) diff --git a/packages/plugin-utils/src/__tests__/reservation-errors.test.ts b/packages/plugin-utils/src/__tests__/reservation-errors.test.ts new file mode 100644 index 000000000..55e65dd31 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/reservation-errors.test.ts @@ -0,0 +1,172 @@ +import { readFileSync, rmSync } from 'node:fs' +import { join, relative } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { collectReservationInputs } from '../reservation-discovery' +import { + isReservationReadFailure, + reservationSource, +} from '../reservation-source' +import { createSharedSlotReservation } from '../shared-slots' +import { fixture, flags, recorder, STYLE } from './reservation-fixture' + +it('does not read an excluded broken bare package from an unused project module', () => { + // Given + using fs = fixture() + fs.write('src/A.tsx', STYLE) + fs.write('src/Unused.ts', "import 'unused-broken';") + fs.write('node_modules/unused-broken/package.json', '{') + // When + const inventory = collectReservationInputs({ + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + }) + // Then + expect( + inventory.inputs.map(({ physicalPath }) => + relative(fs.root, physicalPath).replaceAll('\\', '/'), + ), + ).toEqual(['src/A.tsx']) + expect( + inventory.dependencies.some((path) => path.includes('unused-broken')), + ).toBe(false) +}) + +it('keeps bare-looking local aliases and explicit included package import conditions complete', () => { + // Given + using fs = fixture() + fs.write( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@tokens/*': ['shared/*'] } }, + }), + ) + fs.write( + 'src/A.tsx', + `${STYLE}\nimport {n} from '@tokens/constant'; import '@acme/ui';`, + ) + fs.write('shared/constant.ts', 'export const n=4') + fs.write( + 'node_modules/@acme/ui/package.json', + JSON.stringify({ + exports: { '.': { import: './dist/index.js', default: './wrong.js' } }, + }), + ) + fs.write('node_modules/@acme/ui/dist/index.js', "export * from './Button';") + fs.write( + 'node_modules/@acme/ui/dist/Button.js', + `${STYLE}\nexport * from './index';`, + ) + fs.write('node_modules/@acme/ui/wrong.js', 'export const wrong=1') + // When + const inventory = collectReservationInputs({ + roots: ['src'], + cwd: fs.root, + include: ['@acme/ui'], + package: '@devup-ui/react', + importAliases: {}, + }) + // Then + expect( + inventory.inputs.map(({ physicalPath }) => + relative(fs.root, physicalPath).replaceAll('\\', '/'), + ), + ).toEqual([ + 'node_modules/@acme/ui/dist/Button.js', + 'node_modules/@acme/ui/dist/index.js', + 'shared/constant.ts', + 'src/A.tsx', + ]) +}) + +it('continues valid directories when an earlier source root changes from directory to file', () => { + // Given + using fs = fixture() + fs.write('broken-root', 'not a directory') + fs.write('src/Z.tsx', STYLE) + const recorded = recorder() + const manager = createSharedSlotReservation(recorded.engine) + const options = { + roots: ['broken-root', 'src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + toId: (path: string) => relative(fs.root, path).replaceAll('\\', '/'), + } + // When + manager.reserve(options, flags) + // Then + expect(recorded.packets.at(-1)?.rows).toEqual([ + ['src/Z.tsx', STYLE, '../df/devup-ui'], + ]) + rmSync(join(fs.root, 'broken-root')) + fs.write('broken-root/A.tsx', STYLE) + expect(manager.reserve(options, flags)).toBe(true) + expect(recorded.packets.at(-1)?.rows).toEqual([ + ['broken-root/A.tsx', STYLE, '../df/devup-ui'], + ['src/Z.tsx', STYLE, '../df/devup-ui'], + ]) +}) + +it('leaves the normal resolver as publisher of a reachable broken manifest error', () => { + // Given + using fs = fixture() + fs.write('src/A.tsx', `${STYLE}\nimport '@devup-ui/broken';`) + fs.write('node_modules/@devup-ui/broken/package.json', '{') + // When + const inventory = collectReservationInputs({ + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + }) + // Then + expect(inventory.inputs.map(({ physicalPath }) => physicalPath)).toEqual([ + join(fs.root, 'src/A.tsx'), + ]) + const realResolver = createModuleResolver({ cwd: fs.root }) + expect(() => + realResolver('@devup-ui/broken', join(fs.root, 'src/A.tsx')), + ).toThrow(SyntaxError) +}) + +it('isolates a reproducible missing-file read without caching its later repair', () => { + // Given + using fs = fixture() + const path = join(fs.root, 'missing.ts') + // When + const missing = reservationSource(() => readFileSync(path, 'utf-8')) + fs.write('missing.ts', 'export const n=2') + const repaired = reservationSource(() => readFileSync(path, 'utf-8')) + // Then + expect(missing).toBeUndefined() + expect(repaired).toBe('export const n=2') +}) + +it('rethrows logic errors and only isolates SyntaxError at the manifest boundary', () => { + // Given + const logic = new TypeError('resolver logic failure') + const syntax = new SyntaxError('malformed manifest') + // When / Then + expect(() => + reservationSource(() => { + throw logic + }, true), + ).toThrow(logic) + expect(() => + reservationSource(() => { + throw syntax + }), + ).toThrow(syntax) + expect( + reservationSource(() => { + throw syntax + }, true), + ).toBeUndefined() + expect(isReservationReadFailure(null)).toBe(false) + expect(isReservationReadFailure(logic)).toBe(false) +}) diff --git a/packages/plugin-utils/src/__tests__/reservation-fixture.ts b/packages/plugin-utils/src/__tests__/reservation-fixture.ts new file mode 100644 index 000000000..23d5c4b22 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/reservation-fixture.ts @@ -0,0 +1,68 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import type { + ReservationBatchOptions, + ReservationEngine, + ReservationRow, +} from '../shared-slots' +import type { WasmImportAliases } from '../types' + +export const STYLE = + "import {Box} from '@devup-ui/react'; export const A=()=> ;" + +export function fixture() { + const root = mkdtempSync(join(tmpdir(), 'devup-slot-inventory-')) + return { + root, + write(path: string, code: string) { + const file = join(root, path) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, code) + }, + [Symbol.dispose]() { + rmSync(root, { recursive: true, force: true }) + }, + } +} + +export type Packet = { + readonly rows: readonly ReservationRow[] + readonly packageName: string + readonly singleCss: boolean + readonly importMainCssInCode: boolean + readonly importMainCssInCss: boolean + readonly aliases: Readonly +} + +export function recorder() { + const packets: Packet[] = [] + const engine: ReservationEngine = { + reserveClassSlots( + rows, + packageName, + singleCss, + importMainCssInCode, + importMainCssInCss, + aliases, + ) { + packets.push({ + rows, + packageName, + singleCss, + importMainCssInCode, + importMainCssInCss, + aliases, + }) + }, + } + return { engine, packets } +} + +export const flags: ReservationBatchOptions = { + singleCss: true, + importMainCssInCode: true, + importMainCssInCss: false, + cssDir: () => '../df/devup-ui', +} diff --git a/packages/plugin-utils/src/__tests__/reservation-resolution.test.ts b/packages/plugin-utils/src/__tests__/reservation-resolution.test.ts new file mode 100644 index 000000000..fb3d7fc68 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/reservation-resolution.test.ts @@ -0,0 +1,159 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { collectReservationInputs } from '../reservation-discovery' +import { createSharedSlotReservation } from '../shared-slots' +import { fixture, flags, recorder, STYLE } from './reservation-fixture' + +function tokens() { + const fs = fixture() + fs.write('src/A.tsx', `${STYLE}\nimport '@devup-ui/tokens';`) + fs.write( + 'node_modules/@devup-ui/tokens/red.js', + `${STYLE}\nexport const color="red"`, + ) + fs.write( + 'node_modules/@devup-ui/tokens/blue.js', + `${STYLE}\nexport const color="blue"`, + ) + const options = { + roots: ['src', 'node_modules/@devup-ui/tokens'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + } + const target = (main: string) => + fs.write( + 'node_modules/@devup-ui/tokens/package.json', + JSON.stringify({ main }), + ) + target('red.js') + return { ...fs, options, target } +} + +it('skips changed manifest bytes when actual resolution stays unchanged', () => { + // Given + using fs = tokens() + const recorded = recorder() + const slots = createSharedSlotReservation(recorded.engine) + slots.reserve(fs.options, flags) + // When + fs.write( + 'node_modules/@devup-ui/tokens/package.json', + '{"main":"red.js","version":"2"}', + ) + const repeated = slots.reserve(fs.options, flags) + // Then + expect(repeated).toBe(false) + expect(recorded.packets).toHaveLength(1) +}) + +it('refreshes changed import targets with identical inventory then skips the repeated revision', () => { + // Given + using fs = tokens() + const recorded = recorder() + const slots = createSharedSlotReservation(recorded.engine) + const initial = collectReservationInputs(fs.options) + slots.reserve(fs.options, flags) + fs.target('blue.js') + // When + const refreshed = slots.reserve(fs.options, flags) + const repeated = slots.reserve(fs.options, flags) + // Then + const revised = collectReservationInputs(fs.options) + expect(revised.inputs).toEqual(initial.inputs) + expect(revised.sources).toEqual(initial.sources) + expect([refreshed, repeated]).toEqual([true, false]) + expect(recorded.packets).toHaveLength(2) + expect(recorded.packets[1]).toEqual(recorded.packets[0]) +}) + +it('refreshes repaired metadata even when the target was already selected', () => { + // Given + using fs = tokens() + fs.write('node_modules/@devup-ui/tokens/package.json', 'null') + const recorded = recorder() + const slots = createSharedSlotReservation(recorded.engine) + slots.reserve(fs.options, flags) + fs.target('red.js') + // When + const repaired = slots.reserve(fs.options, flags) + // Then + expect(repaired).toBe(true) + expect(recorded.packets).toHaveLength(2) + expect(recorded.packets[1]).toEqual(recorded.packets[0]) +}) + +it('detects changed static entry targets even when both physical inputs stay selected', () => { + // Given + using fs = tokens() + fs.write('src/A.tsx', STYLE) + const options = { + ...fs.options, + entries: [{ request: '@devup-ui/tokens', context: fs.root }], + } + const recorded = recorder() + const slots = createSharedSlotReservation(recorded.engine) + const initial = collectReservationInputs(options) + slots.reserve(options, flags) + fs.target('blue.js') + // When + const refreshed = slots.reserve(options, flags) + // Then + const revised = collectReservationInputs(options) + expect(revised.inputs).toEqual(initial.inputs) + expect(revised.sources).toEqual(initial.sources) + expect(refreshed).toBe(true) + expect(recorded.packets).toHaveLength(2) + expect(recorded.packets[1]).toEqual(recorded.packets[0]) +}) + +it('skips reordered or equivalent static entries with the same canonical targets', () => { + // Given + using fs = tokens() + const entry = { request: '@devup-ui/tokens', context: fs.root } + const source = { request: './src/A.tsx', context: fs.root } + const recorded = recorder() + const slots = createSharedSlotReservation(recorded.engine) + slots.reserve({ ...fs.options, entries: [entry, source] }, flags) + // When + const repeated = slots.reserve( + { + ...fs.options, + entries: [ + source, + { + ...entry, + request: join(fs.root, 'node_modules/@devup-ui/tokens/red.js'), + }, + entry, + ], + }, + flags, + ) + // Then + expect(repeated).toBe(false) + expect(recorded.packets).toHaveLength(1) +}) + +it('detects exchanged specifier targets even when the importer target set stays identical', () => { + // Given + using fs = tokens() + fs.write( + 'src/A.tsx', + `${STYLE}\nimport '@devup-ui/tokens/red'; import '@devup-ui/tokens/blue';`, + ) + const manifest = 'node_modules/@devup-ui/tokens/package.json' + fs.write(manifest, '{"exports":{"./red":"./red.js","./blue":"./blue.js"}}') + const recorded = recorder() + const slots = createSharedSlotReservation(recorded.engine) + slots.reserve(fs.options, flags) + fs.write(manifest, '{"exports":{"./red":"./blue.js","./blue":"./red.js"}}') + // When + const refreshed = slots.reserve(fs.options, flags) + // Then + expect(refreshed).toBe(true) + expect(recorded.packets).toHaveLength(2) + expect(recorded.packets[1]).toEqual(recorded.packets[0]) +}) diff --git a/packages/plugin-utils/src/__tests__/shared-slots.test.ts b/packages/plugin-utils/src/__tests__/shared-slots.test.ts new file mode 100644 index 000000000..24f2ad4d7 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/shared-slots.test.ts @@ -0,0 +1,284 @@ +import { rmSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' + +import { expect, it } from 'bun:test' + +import type { ReservationDiscoveryOptions } from '../reservation-discovery' +import { createSharedSlotReservation } from '../shared-slots' +import { fixture, flags, recorder, STYLE } from './reservation-fixture' + +function discovery(root: string): ReservationDiscoveryOptions { + return { + roots: ['src'], + cwd: root, + package: '@devup-ui/react', + importAliases: {}, + toId: (path) => relative(root, path).replaceAll('\\', '/'), + } +} + +it('reserves an initial empty batch but skips an unchanged committed inventory', () => { + // Given + using fs = fixture() + const recorded = recorder() + const manager = createSharedSlotReservation(recorded.engine) + // When + const initial = manager.reserve(discovery(fs.root), flags) + const repeated = manager.reserve(discovery(fs.root), flags) + // Then + expect([initial, repeated]).toEqual([true, false]) + expect(recorded.packets).toEqual([ + { + rows: [], + packageName: '@devup-ui/react', + singleCss: true, + importMainCssInCode: true, + importMainCssInCss: false, + aliases: {}, + }, + ]) +}) + +it('appends a whole physically sorted revision whichever new-file notification arrived first', () => { + // Given + using fs = fixture() + fs.write('src/Z.tsx', STYLE) + function revise(order: readonly string[]) { + const recorded = recorder() + const manager = createSharedSlotReservation(recorded.engine) + manager.reserve(discovery(fs.root), flags) + for (const path of order) { + fs.write(path, STYLE) + manager.invalidate() + } + fs.write('src/Z.tsx', STYLE.replace('red', 'blue')) + manager.reserve(discovery(fs.root), flags) + return recorded.packets.at(-1) + } + // When + const forward = revise(['src/A.tsx', 'src/B.tsx']) + rmSync(join(fs.root, 'src/A.tsx')) + rmSync(join(fs.root, 'src/B.tsx')) + fs.write('src/Z.tsx', STYLE) + const reverse = revise(['src/B.tsx', 'src/A.tsx']) + // Then + expect(forward).toEqual(reverse) + expect(forward?.rows).toEqual([ + ['src/A.tsx', STYLE, '../df/devup-ui'], + ['src/B.tsx', STYLE, '../df/devup-ui'], + ['src/Z.tsx', STYLE.replace('red', 'blue'), '../df/devup-ui'], + ]) +}) + +it('refreshes an unchanged importer when its non-Devup constant supplier changes', () => { + // Given + using fs = fixture() + const importer = `${STYLE}\nimport {n} from './constant';` + fs.write('src/A.tsx', importer) + fs.write('src/constant.ts', 'export const n=1') + const recorded = recorder() + const manager = createSharedSlotReservation(recorded.engine) + const options = discovery(fs.root) + manager.reserve(options, flags) + // When + fs.write('src/constant.ts', 'export const n=2') + manager.reserve(options, flags) + // Then + expect(recorded.packets.at(-1)?.rows).toEqual([ + ['src/A.tsx', importer, '../df/devup-ui'], + ['src/constant.ts', 'export const n=2', '../df/devup-ui'], + ]) + expect(recorded.packets).toHaveLength(2) +}) + +it('refreshes after explicit dependency or configuration invalidation with identical text', () => { + // Given + using fs = fixture() + fs.write('src/A.tsx', STYLE) + const recorded = recorder() + const manager = createSharedSlotReservation(recorded.engine) + const options = discovery(fs.root) + manager.reserve(options, flags) + // When + manager.invalidate() + const refreshed = manager.reserve(options, flags) + // Then + expect(refreshed).toBe(true) + expect(recorded.packets).toHaveLength(2) + expect(recorded.packets.at(-1)?.rows).toEqual([ + ['src/A.tsx', STYLE, '../df/devup-ui'], + ]) +}) + +it('detects alias and materialized filename/CSS-directory changes without a dirty notification', () => { + // Given + using fs = fixture() + fs.write('src/A.tsx', STYLE) + const recorded = recorder() + const manager = createSharedSlotReservation(recorded.engine) + const options = discovery(fs.root) + manager.reserve(options, flags) + // When + manager.reserve( + { ...options, importAliases: { '@emotion/styled': 'styled' } }, + flags, + ) + manager.reserve( + { + ...options, + importAliases: { '@emotion/styled': null }, + toId: () => 'exact/A.tsx', + }, + { ...flags, cssDir: () => './exact-css' }, + ) + // Then + expect(recorded.packets.map(({ aliases }) => aliases)).toEqual([ + {}, + { '@emotion/styled': 'styled' }, + { '@emotion/styled': null }, + ]) + expect(recorded.packets.at(-1)?.rows).toEqual([ + ['exact/A.tsx', STYLE, './exact-css'], + ]) +}) + +it('detects only a changed CSS-directory mapping with identical source and flags', () => { + // Given + using fs = fixture() + fs.write('src/A.tsx', STYLE) + const recorded = recorder() + const manager = createSharedSlotReservation(recorded.engine) + const options = discovery(fs.root) + manager.reserve(options, flags) + // When + const refreshed = manager.reserve(options, { + ...flags, + cssDir: () => './new-css', + }) + // Then + expect(refreshed).toBe(true) + expect(recorded.packets.at(-1)?.rows).toEqual([ + ['src/A.tsx', STYLE, './new-css'], + ]) +}) + +it('reserves deletions and reintroductions without resetting engine state', () => { + // Given + using fs = fixture() + fs.write('src/A.tsx', STYLE) + const recorded = recorder() + const manager = createSharedSlotReservation(recorded.engine) + const options = discovery(fs.root) + manager.reserve(options, flags) + // When + rmSync(join(fs.root, 'src/A.tsx')) + manager.reserve(options, flags) + fs.write('src/A.tsx', STYLE) + manager.reserve(options, flags) + // Then + expect(recorded.packets.map(({ rows }) => rows)).toEqual([ + [['src/A.tsx', STYLE, '../df/devup-ui']], + [], + [['src/A.tsx', STYLE, '../df/devup-ui']], + ]) +}) + +it('re-reserves prepared compiled bytes even when the raw MDX file is unchanged', () => { + // Given + using fs = fixture() + fs.write('src/page.mdx', '# Raw stays unchanged') + const prepared = { + physicalPath: join(fs.root, 'src/page.mdx'), + filename: 'owned/page.tsx', + code: STYLE, + } + const options = { ...discovery(fs.root), prepared: [prepared] } + const recorded = recorder() + const manager = createSharedSlotReservation(recorded.engine) + manager.reserve(options, flags) + // When + manager.reserve( + { + ...options, + prepared: [{ ...prepared, code: STYLE.replace('red', 'blue') }], + }, + flags, + ) + // Then + expect(recorded.packets.at(-1)?.rows).toEqual([ + ['owned/page.tsx', STYLE.replace('red', 'blue'), '../df/devup-ui'], + ]) + expect(recorded.packets).toHaveLength(2) +}) + +it('preserves exact CSS mappings and extraction flags and detects changed aliases or flags', () => { + // Given + using fs = fixture() + fs.write('src/A.tsx', STYLE) + const recorded = recorder() + const manager = createSharedSlotReservation(recorded.engine) + const options = { + ...discovery(fs.root), + importAliases: { '@emotion/styled': 'styled' }, + } + const mapped = { + singleCss: false, + importMainCssInCode: false, + importMainCssInCss: true, + cssDir: (input: { readonly physicalPath: string }) => + relative(dirname(input.physicalPath), join(fs.root, 'df/css')).replaceAll( + '\\', + '/', + ), + } + manager.reserve(options, mapped) + // When + manager.reserve( + { ...options, importAliases: { '@emotion/styled': null } }, + { ...mapped, importMainCssInCode: true }, + ) + // Then + expect(recorded.packets).toEqual([ + { + rows: [['src/A.tsx', STYLE, '../df/css']], + packageName: '@devup-ui/react', + singleCss: false, + importMainCssInCode: false, + importMainCssInCss: true, + aliases: { '@emotion/styled': 'styled' }, + }, + { + rows: [['src/A.tsx', STYLE, '../df/css']], + packageName: '@devup-ui/react', + singleCss: false, + importMainCssInCode: true, + importMainCssInCss: true, + aliases: { '@emotion/styled': null }, + }, + ]) +}) + +it('isolates a broken allowed package and reserves the valid suffix before retrying repaired inputs', () => { + // Given + using fs = fixture() + fs.write('src/A.tsx', STYLE) + fs.write('src/layout.ts', "import '@devup-ui/broken';") + fs.write('node_modules/@devup-ui/broken/package.json', '{') + const recorded = recorder() + const manager = createSharedSlotReservation(recorded.engine) + const options = discovery(fs.root) + // When + expect(manager.reserve(options, flags)).toBe(true) + // Then + expect(recorded.packets.at(-1)?.rows).toEqual([ + ['src/A.tsx', STYLE, '../df/devup-ui'], + ]) + fs.write('node_modules/@devup-ui/broken/package.json', '{"main":"index.js"}') + fs.write('node_modules/@devup-ui/broken/index.js', STYLE) + expect(manager.reserve(options, flags)).toBe(true) + expect(recorded.packets).toHaveLength(2) + expect(recorded.packets.at(-1)?.rows).toEqual([ + ['node_modules/@devup-ui/broken/index.js', STYLE, '../df/devup-ui'], + ['src/A.tsx', STYLE, '../df/devup-ui'], + ]) +}) diff --git a/packages/plugin-utils/src/__tests__/string-literal.test.ts b/packages/plugin-utils/src/__tests__/string-literal.test.ts new file mode 100644 index 000000000..37cb723f3 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/string-literal.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from 'bun:test' + +import { decodeStringLiteral } from '../string-literal' + +// Given +it.each([ + ['"plain"', 'plain'], + [String.raw`"\b\f\n\r\t\v"`, '\b\f\n\r\t\v'], + [String.raw`"\0\101\77\400\8\9\z"`, '\0A? 089z'], + ['"a\\\rb"', 'ab'], + ['"a\\\r\nb"', 'ab'], + ['"a\\\u2028b\\\u2029c"', 'abc'], + [String.raw`"\uD83D\uDE00"`, '😀'], + [String.raw`"\u{1F600}"`, '😀'], + [String.raw`"\u{10FFFF}"`, String.fromCodePoint(0x10ffff)], + [String.raw`"\xFf"`, 'ÿ'], + [String.raw`"\xF"`, undefined], + [String.raw`"\u00"`, undefined], + [String.raw`"\u{41"`, undefined], + ['"trailing\\"', undefined], +])( + 'decodes literal contents of %s without evaluating source', + (literal, expected) => { + // When + const value = decodeStringLiteral(literal) + // Then + expect(value).toBe(expected) + }, +) diff --git a/packages/plugin-utils/src/__tests__/wasm-abi.test.ts b/packages/plugin-utils/src/__tests__/wasm-abi.test.ts index 6862abf62..1a56f2d1e 100644 --- a/packages/plugin-utils/src/__tests__/wasm-abi.test.ts +++ b/packages/plugin-utils/src/__tests__/wasm-abi.test.ts @@ -62,6 +62,7 @@ describe('@devup-ui/wasm exports', () => { 'getDefaultTheme', 'getPrefix', 'getThemeInterface', + 'getUnreservedClassSlotCount', 'hasDevupUI', 'importCanonicalMap', 'importClassMap', @@ -71,6 +72,7 @@ describe('@devup-ui/wasm exports', () => { 'isDebug', 'registerShorthands', 'registerTheme', + 'reserveClassSlots', 'resetBuildState', 'seedFileMap', 'setAtomHoist', @@ -252,3 +254,107 @@ describe('@devup-ui/wasm state', () => { expect(wasm.getCss(null, false)).toBe(carried.css) }) }) + +describe('@devup-ui/wasm reservation', () => { + beforeEach(() => { + wasm.resetBuildState() + wasm.setDebug(false) + }) + + afterEach(() => { + // Map/sheet imports do not restore reservation bookkeeping. Leave it reset; + // restore the public state here too, independently of outer hook ordering. + wasm.resetBuildState() + restoreState(saved) + }) + + it('reserves class and file slots without publishing scratch styles', () => { + wasm.seedFileMap(['abi.tsx']) + extract(`import { Box } from '${PACKAGE}'\nexport const a = `) + const before = saveState() + const source = `import { Box } from '${PACKAGE}'\nexport const a = ` + + wasm.reserveClassSlots( + [['reserved.tsx', source, 'df']], + PACKAGE, + true, + false, + false, + {}, + ) + + expect(JSON.parse(wasm.exportSheet())).toEqual(before.sheet) + expect(wasm.getCss(null, false)).toBe(before.css) + expect(JSON.parse(wasm.exportClassMap())).not.toEqual(before.classMap) + expect(JSON.parse(wasm.exportFileMap())).toEqual({ + 'abi.tsx': 0, + 'reserved.tsx': 1, + }) + expect(wasm.getUnreservedClassSlotCount()).toBe(0) + const slots = wasm.exportClassMap() + const output = extract(source, 'reserved.tsx') + expect(output.code).toContain('className=') + expect(output.css).toContain('color:red') + expect(output.css).toContain('gap:var(--') + expect(wasm.exportClassMap()).toBe(slots) + expect(wasm.getUnreservedClassSlotCount()).toBe(0) + }) + + it('does not count live allocations before the first reservation', () => { + expect(wasm.getUnreservedClassSlotCount()).toBe(0) + + extract( + `import { Box } from '${PACKAGE}'\nexport const a = `, + ) + const output = extract( + `import { createVar } from '${PACKAGE}'\nexport const variable = createVar()`, + 'legacy.css.ts', + ) + + expect(output.code).toContain('var(--') + expect(output.code).not.toContain('createVar(') + expect(wasm.exportClassMap()).not.toBe('{}') + expect(wasm.exportFileMap()).not.toBe('{}') + expect(wasm.getUnreservedClassSlotCount()).toBe(0) + }) + + it('counts new live class and file slots once across later reservations', () => { + const red = `import { Box } from '${PACKAGE}'\nexport const a = ` + const blue = `import { Box } from '${PACKAGE}'\nexport const a = ` + wasm.reserveClassSlots( + [['reserved.tsx', red, 'df']], + PACKAGE, + true, + false, + false, + {}, + ) + extract(red, 'reserved.tsx') + expect(wasm.getUnreservedClassSlotCount()).toBe(0) + + const first = extract(blue, 'reserved.tsx') + + expect(wasm.getUnreservedClassSlotCount()).toBe(1) + expect(extract(blue, 'reserved.tsx').code).toBe(first.code) + expect(wasm.getUnreservedClassSlotCount()).toBe(1) + const raw = `import { createVar } from '${PACKAGE}'\nexport const variable = createVar()` + const variable = extract(raw, 'virtual.css.ts') + expect(variable.code).toContain('var(--') + expect(variable.code).not.toContain('createVar(') + expect(wasm.getUnreservedClassSlotCount()).toBe(2) + expect(extract(raw, 'virtual.css.ts').code).toBe(variable.code) + expect(wasm.getUnreservedClassSlotCount()).toBe(2) + wasm.reserveClassSlots( + [['watch.tsx', red, 'df']], + PACKAGE, + true, + false, + false, + {}, + ) + extract(red, 'watch.tsx') + expect(wasm.getUnreservedClassSlotCount()).toBe(2) + wasm.resetBuildState() + expect(wasm.getUnreservedClassSlotCount()).toBe(0) + }) +}) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index bc6600d89..823665036 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -1,4 +1,5 @@ import { + type Dirent, existsSync, readdirSync, readFileSync, @@ -16,6 +17,9 @@ import { resolve, } from 'node:path' +import { PackageManifestShapeError } from './package-manifest' +import { decodeStringLiteral } from './string-literal' + /** * How map keys (and bucket-root values) are stringified. * - `cwd-relative` (default): POSIX path relative to `cwd` — matches plugins @@ -611,14 +615,23 @@ export function compareCodePoints(a: string, b: string): number { * pre-warm the extractor over exactly the file set the canonical map was built * from. Returns absolute paths. */ -export function listSourceFiles(srcDir: string): string[] { +export function listSourceFiles( + srcDir: string, + onReadError?: (error: unknown) => boolean, +): string[] { const files: string[] = [] function visit(dir: string): void { if (!existsSync(dir)) return - const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => - compareCodePoints(a.name, b.name), - ) + let entries: readonly Dirent[] + try { + entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => + compareCodePoints(a.name, b.name), + ) + } catch (error) { + if (onReadError?.(error)) return + throw error + } for (const entry of entries) { const entryPath = join(dir, entry.name) if (entry.isDirectory()) { @@ -627,8 +640,7 @@ export function listSourceFiles(srcDir: string): string[] { continue } if (!entry.isFile()) continue - if (!jsFileRegex.test(entry.name)) continue - if (testFileRegex.test(entry.name)) continue + if (!isSourceFile(entry.name)) continue files.push(resolve(entryPath)) } } @@ -639,6 +651,17 @@ export function listSourceFiles(srcDir: string): string[] { ) } +export function isSourceFile(filename: string): boolean { + return jsFileRegex.test(filename) && !testFileRegex.test(filename) +} + +export function importedSpecifiers( + filename: string, + source: string, +): readonly string[] { + return parseImports(filename, source).map(({ specifier }) => specifier) +} + export function parseImports( filename: string, source: string, @@ -729,6 +752,22 @@ function collectAstImports( ? node.arguments[0] : undefined addAstImport(imports, 'dynamic', firstArgument) + } else if ( + type === 'CallExpression' && + node.optional !== true && + isRecord(node.callee) && + node.callee.type === 'Identifier' && + node.callee.name === 'require' && + Array.isArray(node.arguments) && + node.arguments.length === 1 + ) { + const argument: unknown = node.arguments[0] + if ( + isRecord(argument) && + (argument.type === 'Literal' || argument.type === 'StringLiteral') && + typeof argument.value === 'string' + ) + addAstImport(imports, 'static', argument) } for (const value of Object.values(node)) { @@ -813,22 +852,42 @@ function scanImports(source: string): ImportReference[] { // — which the bundler also erases — are dropped via // `isAllInlineTypeSpecifiers`. Mixed lists (`import { type A, b } from`) // keep importing the module for `b`, so they are kept. - const staticImportRegex = - /\bimport\s+(type\s+)?(?:([^'"`]*?)\s+from\s*)?(['"])([^'"]+)\3/gm - const exportFromRegex = - /\bexport\s+(type\s+)?(\*[^'"`]*?|\{[^}]*\})\s+from\s*(['"])([^'"]+)\3/gm - const dynamicImportRegex = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/gm + // Consume whole literals before looking for keywords inside their payloads. + // Real import/export matches start before their specifier's opening quote. + const quotedLiteral = + /"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\])*`/ + .source + const staticImportRegex = new RegExp( + `${quotedLiteral}|${/\bimport\s+(type\s+)?(?:([^'"`]*?)\s+from\s*)?(['"])([^'"]+)\3/.source}`, + 'gm', + ) + const exportFromRegex = new RegExp( + `${quotedLiteral}|${/\bexport\s+(type\s+)?(\*[^'"`]*?|\{[^}]*\})\s+from\s*(['"])([^'"]+)\3/.source}`, + 'gm', + ) + const dynamicImportRegex = new RegExp( + `${quotedLiteral}|${/\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/.source}`, + 'gm', + ) + const requireRegex = new RegExp( + `${quotedLiteral}|${/(? string + readonly allowPackage?: (specifier: string) => boolean } /** @@ -1012,6 +1072,7 @@ export function createModuleResolver({ cwd = process.cwd(), tsconfigPath = join(cwd, 'tsconfig.json'), toId = (path) => path, + allowPackage, }: CreateModuleResolverOptions = {}): ( specifier: string, importer: string, @@ -1028,7 +1089,10 @@ export function createModuleResolver({ aliasBaseDir: baseDir, }) .map(resolveSourceFile) - .find(Boolean) ?? resolvePackage(specifier, from)) + .find(Boolean) ?? + ((allowPackage?.(specifier) ?? true) + ? resolvePackage(specifier, from) + : undefined)) return path ? { path: toId(path), code: readFileSync(path, 'utf-8') } : undefined @@ -1061,11 +1125,14 @@ function resolvePackage( const name = parts.slice(0, nameLength).join('/') const packageDir = findPackage(dirname(importer), name) if (!packageDir) return undefined + const manifestPath = join(packageDir, 'package.json') + const manifest: unknown = JSON.parse(readFileSync(manifestPath, 'utf-8')) + if (!isRecord(manifest) || Array.isArray(manifest)) { + throw new PackageManifestShapeError(manifestPath) + } const found = resolvePackageEntry( packageDir, - JSON.parse( - readFileSync(join(packageDir, 'package.json'), 'utf-8'), - ) as Record, + manifest, ['.', ...parts.slice(nameLength)].join('/'), ) return found && realpathSync(found) diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index f938bb323..b376c77b8 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -28,6 +28,14 @@ export { type FileNumbering, seedFileNumbers, } from './numbering' +export { + collectReservationInputs, + PreparedInputConflictError, + type PreparedReservationInput, + type ReservationDiscoveryOptions, + type ReservationInput, + type ReservationInventory, +} from './reservation-discovery' export { createNodeModulesExcludeRegex, createThemeInterfaceArgs, @@ -36,6 +44,13 @@ export { type DevupUIBasePluginOptions, getFileNumByFilename, } from './shared' +export { + createSharedSlotReservation, + type ReservationBatchOptions, + type ReservationEngine, + type ReservationRow, + type SharedSlotReservation, +} from './shared-slots' export { createStateWriter, type StateWriter, diff --git a/packages/plugin-utils/src/numbering.ts b/packages/plugin-utils/src/numbering.ts index 213bbf9c7..f65d89ec9 100644 --- a/packages/plugin-utils/src/numbering.ts +++ b/packages/plugin-utils/src/numbering.ts @@ -25,7 +25,7 @@ function usesAny(file: string, needles: string[]): boolean { } /** Where an included package lives, found the way the bundler finds it. */ -function findPackageDir(cwd: string, name: string): string | undefined { +export function findPackageDir(cwd: string, name: string): string | undefined { let dir = resolve(cwd) for (;;) { const candidate = join(dir, 'node_modules', name) diff --git a/packages/plugin-utils/src/package-manifest.ts b/packages/plugin-utils/src/package-manifest.ts new file mode 100644 index 000000000..e0489c84e --- /dev/null +++ b/packages/plugin-utils/src/package-manifest.ts @@ -0,0 +1,7 @@ +export class PackageManifestShapeError extends TypeError { + readonly name = 'PackageManifestShapeError' + + constructor(readonly manifestPath: string) { + super(`Package manifest must contain a JSON object: ${manifestPath}`) + } +} diff --git a/packages/plugin-utils/src/reservation-discovery.ts b/packages/plugin-utils/src/reservation-discovery.ts new file mode 100644 index 000000000..1140d1de4 --- /dev/null +++ b/packages/plugin-utils/src/reservation-discovery.ts @@ -0,0 +1,192 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { + createModuleResolver, + importedSpecifiers, + isSourceFile, + listSourceFiles, +} from './import-graph' +import { extractedNeedles, findPackageDir } from './numbering' +import { + isReservationReadFailure, + reservationSource, +} from './reservation-source' +import type { WasmImportAliases } from './types' + +export type ReservationInput = { + readonly physicalPath: string + readonly filename: string + readonly code: string +} + +/** Internal compiled-input overlay for Devup-owned preparation, not a plugin option. */ +export type PreparedReservationInput = ReservationInput + +export type ReservationDiscoveryOptions = { + readonly roots: readonly string[] + readonly entries?: readonly { + readonly request: string + readonly context: string + }[] + readonly include?: readonly string[] + readonly cwd?: string + readonly tsconfigPath?: string + readonly package: string + readonly importAliases: Readonly + readonly toId?: (physicalPath: string) => string + readonly prepared?: readonly PreparedReservationInput[] +} + +export type ReservationInventory = { + readonly inputs: readonly ReservationInput[] + readonly dependencies: readonly string[] + readonly sources: ReadonlyMap + readonly resolutions: ReservationResolutions +} + +export type ReservationResolutions = { + readonly entries: readonly string[] + readonly imports: ReadonlyMap> +} + +export class PreparedInputConflictError extends Error { + readonly name = 'PreparedInputConflictError' + constructor(readonly physicalPath: string) { + super(`Conflicting prepared inputs for ${physicalPath}`) + } +} + +function physicalKey(path: string): string { + return resolve(path).replaceAll('\\', '/') +} + +function allowedPackage( + specifier: string, + include: readonly string[], +): boolean { + return ( + specifier.startsWith('@devup-ui/') || + specifier.startsWith('@devup-editor/') || + include.some( + (name) => specifier === name || specifier.startsWith(`${name}/`), + ) + ) +} + +/** Isolate source failures in the prepass; real transforms retain their errors. */ +export function collectReservationInputs( + options: ReservationDiscoveryOptions, +): ReservationInventory { + const cwd = resolve(options.cwd ?? process.cwd()) + const include = options.include ?? [] + const toId = options.toId ?? ((path: string) => path.replaceAll('\\', '/')) + const overlays = new Map() + for (const prepared of options.prepared ?? []) { + const key = physicalKey(resolve(cwd, prepared.physicalPath)) + const existing = overlays.get(key) + if ( + existing && + (existing.filename !== prepared.filename || + existing.code !== prepared.code) + ) + throw new PreparedInputConflictError(key) + overlays.set(key, { ...prepared, physicalPath: key }) + } + const roots = options.roots.map((root) => resolve(cwd, root)) + for (const name of include) { + const directory = reservationSource(() => findPackageDir(cwd, name)) + if (directory !== undefined) roots.push(directory) + } + const initial = roots.flatMap((root) => + listSourceFiles(root, isReservationReadFailure), + ) + const resolverOptions = { + cwd, + toId: (path: string) => path, + ...(options.tsconfigPath === undefined + ? {} + : { tsconfigPath: resolve(cwd, options.tsconfigPath) }), + } + const resolver = createModuleResolver({ + ...resolverOptions, + allowPackage: (specifier) => allowedPackage(specifier, include), + }) + const needles = extractedNeedles(options.package, options.importAliases) + const records = new Map() + const sources = new Map() + const entryTargets = new Set() + const imports = new Map>() + const edges = new Map>() + const seeds = new Set(overlays.keys()) + const pending = [...initial, ...overlays.keys()] + if (options.entries?.length) { + const resolveEntry = createModuleResolver(resolverOptions) + for (const { request, context } of options.entries) { + const module = reservationSource( + () => resolveEntry(request, resolve(cwd, context, '__devup_entry__')), + true, + ) + if (!module || !isSourceFile(module.path)) continue + entryTargets.add(physicalKey(module.path)) + sources.set(physicalKey(module.path), module.code) + pending.push(module.path) + } + } + for (const physicalPath of pending) { + const key = physicalKey(physicalPath) + if (records.has(key)) continue + const overlay = overlays.get(key) + const code = + overlay?.code ?? + sources.get(key) ?? + reservationSource(() => readFileSync(physicalPath, 'utf-8')) + if (code === undefined) continue + sources.set(key, code) + records.set(key, { + physicalPath, + filename: overlay?.filename ?? toId(physicalPath), + code, + }) + if (needles.some((needle) => code.includes(needle))) seeds.add(key) + const targets = new Set() + const resolutions = new Map() + imports.set(key, resolutions) + edges.set(key, targets) + for (const specifier of importedSpecifiers( + overlay?.filename ?? physicalPath, + code, + )) { + const allowed = allowedPackage(specifier, include) + const module = reservationSource( + () => resolver(specifier, physicalPath), + true, + ) + if (!module || !isSourceFile(module.path)) continue + const target = physicalKey(module.path) + resolutions.set(specifier, target) + targets.add(target) + if (allowed) seeds.add(target) + if (!sources.has(target)) sources.set(target, module.code) + pending.push(module.path) + } + } + const selected = new Set() + const closure = [...seeds] + for (const key of closure) { + if (selected.has(key)) continue + selected.add(key) + closure.push(...(edges.get(key) ?? [])) + } + const dependencies = [...records.keys()].sort() + const inputs = [...records] + .filter(([key]) => selected.has(key)) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([, record]) => record) + return { + inputs, + dependencies, + sources, + resolutions: { entries: [...entryTargets].sort(), imports }, + } +} diff --git a/packages/plugin-utils/src/reservation-source.ts b/packages/plugin-utils/src/reservation-source.ts new file mode 100644 index 000000000..fdea70fb7 --- /dev/null +++ b/packages/plugin-utils/src/reservation-source.ts @@ -0,0 +1,37 @@ +import { PackageManifestShapeError } from './package-manifest' + +const READ_FAILURES = new Set([ + 'ENOENT', + 'EACCES', + 'EPERM', + 'ENOTDIR', + 'EISDIR', +]) + +export function isReservationReadFailure(error: unknown): boolean { + return ( + error instanceof Error && + 'code' in error && + typeof error.code === 'string' && + READ_FAILURES.has(error.code) + ) +} + +export function reservationSource( + read: () => T, + manifestBoundary = false, +): T | undefined { + try { + return read() + } catch (error) { + if ( + isReservationReadFailure(error) || + (manifestBoundary && + (error instanceof SyntaxError || + error instanceof PackageManifestShapeError)) + ) { + return undefined + } + throw error + } +} diff --git a/packages/plugin-utils/src/shared-slots.ts b/packages/plugin-utils/src/shared-slots.ts new file mode 100644 index 000000000..33dbf135a --- /dev/null +++ b/packages/plugin-utils/src/shared-slots.ts @@ -0,0 +1,154 @@ +import type { + ReservationDiscoveryOptions, + ReservationInput, + ReservationInventory, + ReservationResolutions, +} from './reservation-discovery' +import { collectReservationInputs } from './reservation-discovery' +import type { WasmImportAliases } from './types' + +export type ReservationRow = readonly [ + filename: string, + code: string, + cssDir: string, +] + +export interface ReservationEngine { + reserveClassSlots( + rows: readonly ReservationRow[], + packageName: string, + singleCss: boolean, + importMainCssInCode: boolean, + importMainCssInCss: boolean, + importAliases: Readonly, + ): void +} + +export type ReservationBatchOptions = { + readonly singleCss: boolean + readonly importMainCssInCode: boolean + readonly importMainCssInCss: boolean + readonly cssDir: (input: ReservationInput) => string +} + +export interface SharedSlotReservation { + invalidate(): void + reserve( + discovery: ReservationDiscoveryOptions, + options: ReservationBatchOptions, + ): boolean +} + +type Committed = { + readonly inventory: ReservationInventory + readonly rows: readonly ReservationRow[] + readonly packageName: string + readonly aliases: Readonly + readonly singleCss: boolean + readonly importMainCssInCode: boolean + readonly importMainCssInCss: boolean +} + +function sameSources( + current: ReadonlyMap, + previous: ReadonlyMap, +): boolean { + return ( + current.size === previous.size && + [...current].every(([path, code]) => previous.get(path) === code) + ) +} + +function sameRows( + current: readonly ReservationRow[], + previous: readonly ReservationRow[], +): boolean { + return ( + current.length === previous.length && + current.every((row, index) => + row.every((value, field) => previous[index]?.[field] === value), + ) + ) +} + +function sameResolutions( + current: ReservationResolutions, + previous: ReservationResolutions, +): boolean { + return ( + current.entries.length === previous.entries.length && + current.entries.every( + (target, index) => previous.entries[index] === target, + ) && + current.imports.size === previous.imports.size && + [...current.imports].every(([importer, targets]) => { + const prior = previous.imports.get(importer) + return prior !== undefined && sameSources(targets, prior) + }) + ) +} + +function sameAliases( + current: Readonly, + previous: Readonly, +): boolean { + const keys = Object.keys(current) + return ( + keys.length === Object.keys(previous).length && + keys.every((key) => current[key] === previous[key]) + ) +} + +/** Adapter-local revisions; an invalidation refreshes the entire closed inventory. */ +export function createSharedSlotReservation( + engine: ReservationEngine, +): SharedSlotReservation { + let committed: Committed | undefined + let dirty = true + return { + invalidate() { + dirty = true + }, + reserve(discovery, options) { + const inventory = collectReservationInputs(discovery) + const rows: readonly ReservationRow[] = inventory.inputs.map( + (input) => [input.filename, input.code, options.cssDir(input)] as const, + ) + if ( + !dirty && + committed && + committed.packageName === discovery.package && + committed.singleCss === options.singleCss && + committed.importMainCssInCode === options.importMainCssInCode && + committed.importMainCssInCss === options.importMainCssInCss && + sameAliases(discovery.importAliases, committed.aliases) && + sameResolutions( + inventory.resolutions, + committed.inventory.resolutions, + ) && + sameSources(inventory.sources, committed.inventory.sources) && + sameRows(rows, committed.rows) + ) + return false + engine.reserveClassSlots( + rows, + discovery.package, + options.singleCss, + options.importMainCssInCode, + options.importMainCssInCss, + discovery.importAliases, + ) + committed = { + inventory, + rows, + packageName: discovery.package, + aliases: { ...discovery.importAliases }, + singleCss: options.singleCss, + importMainCssInCode: options.importMainCssInCode, + importMainCssInCss: options.importMainCssInCss, + } + dirty = false + return true + }, + } +} diff --git a/packages/plugin-utils/src/string-literal.ts b/packages/plugin-utils/src/string-literal.ts new file mode 100644 index 000000000..44d99e073 --- /dev/null +++ b/packages/plugin-utils/src/string-literal.ts @@ -0,0 +1,57 @@ +const escapes: Readonly> = { + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', +} + +/** Decode an already-tokenized quoted string without evaluating source. */ +export function decodeStringLiteral(literal: string): string | undefined { + const source = literal.slice(1, -1) + let result = '' + for (let index = 0; index < source.length; index++) { + const char = source.charAt(index) + if (char !== '\\') { + result += char + continue + } + index++ + const escape = source.charAt(index) + if (escape === '') return undefined + if (escape === 'x' || escape === 'u') { + const braced = escape === 'u' && source.charAt(index + 1) === '{' + const start = index + (braced ? 2 : 1) + const end = braced + ? source.indexOf('}', start) + : start + (escape === 'x' ? 2 : 4) + const hex = source.slice(start, end) + if (end < 0 || end > source.length || !/^[\da-f]+$/i.test(hex)) + return undefined + const value = Number.parseInt(hex, 16) + if (value > 0x10ffff) return undefined + result += String.fromCodePoint(value) + index = braced ? end : end - 1 + continue + } + if (escape === '\r') { + if (source.charAt(index + 1) === '\n') index++ + continue + } + if (escape === '\n' || escape === '\u2028' || escape === '\u2029') continue + if (/[0-7]/.test(escape)) { + let octal = escape + while ( + octal.length < (escape <= '3' ? 3 : 2) && + /[0-7]/.test(source.charAt(index + 1)) + ) { + octal += source.charAt(++index) + } + result += String.fromCharCode(Number.parseInt(octal, 8)) + continue + } + result += escapes[escape] ?? escape + } + return result +} diff --git a/packages/rsbuild-plugin/src/__tests__/atom-hoist.test.ts b/packages/rsbuild-plugin/src/__tests__/atom-hoist.test.ts new file mode 100644 index 000000000..a11716515 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/atom-hoist.test.ts @@ -0,0 +1,77 @@ +import { expect, it } from 'bun:test' + +import { + builtPluginEntry, + runAtomHoistIntegration, +} from '../../../next-plugin/src/__tests__/atom-hoist-harness' + +it('serves real hoisted JSX atoms and private sheets in either transform order and fresh builds', () => { + // Given: two entries reach shared atoms, while their opacity stays private. + const setup = ` +const { DevupUI } = await import(${builtPluginEntry('rsbuild-plugin')}); +const transforms = []; +const rspackConfigs = []; +const environment = { name: 'web', entry: { + a: join(root, files[1]), b: join(root, files[2]), +} }; +const hook = () => { + const callbacks = []; + return { + tap(name, fn) { callbacks.push(fn); }, + tapPromise(name, fn) { callbacks.push(fn); }, + call(value) { return callbacks.map(fn => fn(value)).at(-1); }, + }; +}; +let closeBuild; +let beforeBuild; +await DevupUI({ atomHoist: 2, cssDir, distDir: join(root, 'df') }).setup({ + context: { rootPath: root }, + onCloseBuild(fn) { closeBuild = fn; }, + onBeforeBuild(fn) { beforeBuild = fn; }, + transform(options, handler) { transforms.push({ options, handler }); }, + modifyRspackConfig(fn) { rspackConfigs.push(fn); }, + modifyRsbuildConfig() {}, +}); +const config = {}; +for (const modify of rspackConfigs) modify(config, { environment }); +const compiler = { + options: { context: root, entry: environment.entry }, + watchMode: false, + hooks: { run: hook(), invalid: hook(), watchRun: hook(), thisCompilation: hook() }, + rspack: { Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 } }, +}; +for (const plugin of config.plugins) plugin.apply(compiler); +await beforeBuild({ environments: { web: environment } }); +compiler.hooks.run.call(); +const compilation = { + hooks: { finishModules: hook(), processAssets: hook(), needAdditionalPass: hook() }, + assets: {}, + deleteAsset(name) { delete this.assets[name]; }, +}; +compiler.hooks.thisCompilation.call(compilation); +const jsTransform = transforms.find(item => item.options.test instanceof RegExp).handler; +const cssTransform = transforms.find(item => item.options.test === cssDir).handler; +const transform = async (file, code) => { + const result = await jsTransform({ code, resourcePath: join(root, file), addDependency() {} }); + return result.code; +}; +const loadCss = name => cssTransform({ resourcePath: join(cssDir, name), environment: { name: 'web' } }); +const finish = async () => { + compilation.hooks.finishModules.call(); + compilation.hooks.processAssets.call(); + assert.equal(compilation.hooks.needAdditionalPass.call(), false, + 'prewarmed compilation must already serve current CSS'); + const initial = await loadCss('devup-ui.css'); + beforeBuild({ environments: { web: { entry: { + a: join(root, files[1]), b: join(root, files[2]), + } } } }); + assert.equal(await loadCss('devup-ui.css'), initial, 'prewarm must not duplicate shared atoms'); + closeBuild(); +}; +` + // When: registered build hooks surround real source/CSS transforms and prewarm. + const [forward, backward, rebuilt] = runAtomHoistIntegration(setup) + // Then: loaded declarations match JSX regardless of ordering or fresh state. + expect(backward).toBe(forward) + expect(rebuilt).toBe(forward) +}, 120_000) diff --git a/packages/rsbuild-plugin/src/__tests__/audit-cjs-crlf.test.ts b/packages/rsbuild-plugin/src/__tests__/audit-cjs-crlf.test.ts new file mode 100644 index 000000000..6999a2e5a --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/audit-cjs-crlf.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' + +import { expect, it } from 'bun:test' + +import { fixture, record, red, run } from './audit-fixture' + +it('reserves CRLF literal continuations before genuine physical barrel AB/BA transforms', () => { + // Given + using fs = fixture() + fs.put('devup.json', '{}') + fs.put( + 'src/entry.js', + 'import {A,B} from "@devup-ui/widgets"; console.log(A,B);', + ) + fs.put( + 'node_modules/@devup-ui/widgets/package.json', + '{"main":"dist/index.js"}', + ) + const directory = 'node_modules/@devup-ui/widgets/dist' + const continuation = '\\\r\n' + fs.put( + `${directory}/index.js`, + `exports.A=require("./${continuation}A.js"); exports.B=require('./${continuation}B.js');`, + ) + fs.put(`${directory}/A.js`, red) + fs.put(`${directory}/B.js`, red.replace('red', 'blue')) + // When + const ab = run(fs.root, 'barrel', 'AB') + const ba = run(fs.root, 'barrel', 'BA') + // Then + expect(ab.ok).toBe(true) + expect(ba.ok).toBe(true) + expect(ab.css).toEqual(ba.css) + assert(Array.isArray(ab.revisions) && Array.isArray(ba.revisions)) + const a: unknown = ab.revisions.at(-1) + const b: unknown = ba.revisions.at(-1) + record(a) + record(b) + expect(a.assets).toEqual(b.assets) + expect(ab.count).toBe(0) + expect(ba.count).toBe(0) +}, 120000) diff --git a/packages/rsbuild-plugin/src/__tests__/audit-cjs-escaped.test.ts b/packages/rsbuild-plugin/src/__tests__/audit-cjs-escaped.test.ts new file mode 100644 index 000000000..d15e9787d --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/audit-cjs-escaped.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' + +import { expect, it } from 'bun:test' + +import { fixture, record, red, run } from './audit-fixture' + +it.each([ + { + name: 'hex-double-unicode-single', + barrel: String.raw`exports.A=require("./\x41.js"); exports.B=require('.\u002f\u0042.js');`, + }, + { + name: 'slash-codepoint-single-hex-double', + barrel: String.raw`exports.A=require('.\/\u{41}.js'); exports.B=require("./\x42.js");`, + }, +])( + 'reserves escaped physical leaves before genuine $name AB/BA transforms', + ({ barrel }) => { + // Given + using fs = fixture() + fs.put('devup.json', '{}') + fs.put( + 'src/entry.js', + 'import {A,B} from "@devup-ui/widgets"; console.log(A,B);', + ) + fs.put( + 'node_modules/@devup-ui/widgets/package.json', + '{"main":"dist/index.js"}', + ) + const directory = 'node_modules/@devup-ui/widgets/dist' + fs.put(`${directory}/index.js`, barrel) + fs.put(`${directory}/A.js`, red) + fs.put(`${directory}/B.js`, red.replace('red', 'blue')) + // When + const ab = run(fs.root, 'barrel', 'AB') + const ba = run(fs.root, 'barrel', 'BA') + // Then + expect(ab.ok).toBe(true) + expect(ba.ok).toBe(true) + expect(ab.css).toEqual(ba.css) + assert(Array.isArray(ab.revisions) && Array.isArray(ba.revisions)) + const a: unknown = ab.revisions.at(-1) + const b: unknown = ba.revisions.at(-1) + record(a) + record(b) + expect(a.assets).toEqual(b.assets) + expect(ab.count).toBe(0) + expect(ba.count).toBe(0) + }, + 120000, +) diff --git a/packages/rsbuild-plugin/src/__tests__/audit-cjs.test.ts b/packages/rsbuild-plugin/src/__tests__/audit-cjs.test.ts new file mode 100644 index 000000000..3f88bfeab --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/audit-cjs.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' + +import { expect, it } from 'bun:test' + +import { fixture, record, red, run } from './audit-fixture' + +it.each(['barrel'])( + 'reserves all known physical inputs before genuine %s AB/BA transforms', + (mode) => { + // Given + using fs = fixture() + fs.put('devup.json', '{}') + fs.put( + 'src/entry.js', + mode === 'barrel' + ? 'import {A,B} from "@devup-ui/widgets"; console.log(A,B);' + : 'console.log("old-source-entry");', + ) + const directory = + mode === 'barrel' ? 'node_modules/@devup-ui/widgets/dist' : 'outside' + if (mode === 'barrel') { + fs.put( + 'node_modules/@devup-ui/widgets/package.json', + '{"main":"dist/index.js"}', + ) + fs.put( + `${directory}/index.js`, + 'exports.A=require("./A.js"); exports.B=require("./B.js");', + ) + } + fs.put(`${directory}/A.js`, red) + fs.put(`${directory}/B.js`, red.replace('red', 'blue')) + // When: only callback release order changes, never native entry representation. + const ab = run(fs.root, mode, 'AB') + const ba = run(fs.root, mode, 'BA') + // Then + expect(ab.ok).toBe(true) + expect(ba.ok).toBe(true) + expect(ab.css).toEqual(ba.css) + assert(Array.isArray(ab.revisions) && Array.isArray(ba.revisions)) + const a: unknown = ab.revisions.at(-1) + const b: unknown = ba.revisions.at(-1) + record(a) + record(b) + expect(a.assets).toEqual(b.assets) + expect(ab.count).toBe(0) + expect(ba.count).toBe(0) + }, + 120000, +) diff --git a/packages/rsbuild-plugin/src/__tests__/audit-engine.cjs b/packages/rsbuild-plugin/src/__tests__/audit-engine.cjs new file mode 100644 index 000000000..1b04a7c0b --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/audit-engine.cjs @@ -0,0 +1,42 @@ +const { basename } = require('node:path') +/** @type {typeof import('@devup-ui/wasm')} */ +const wasm = require('@devup-ui/wasm') +/** @type {unknown[]} */ +const batches = [] +/** @type {{name:string,code:string,map:string|undefined,before:number,after:number}[]} */ +const extracts = [] +const reserve = wasm.reserveClassSlots +wasm.reserveClassSlots = (...args) => { + reserve(...args) + batches.push({ rows: args[0], ...state() }) +} +/** @type {() => void} */ +let release = () => {} +const gate = new Promise((resolve) => { + release = () => resolve(undefined) +}) +const extract = wasm.codeExtract +wasm.codeExtract = (...args) => { + const before = wasm.getUnreservedClassSlotCount() + const output = extract(...args) + extracts.push({ + name: args[0], + code: output.code, + map: output.map, + before, + after: wasm.getUnreservedClassSlotCount(), + }) + if (basename(args[0]) === `${process.argv[4]?.[0]}.js`) release() + return output +} + +function state() { + return { + css: wasm.getCss(null, false), + count: wasm.getUnreservedClassSlotCount(), + classes: wasm.exportClassMap(), + files: wasm.exportFileMap(), + } +} + +module.exports = { wasm, batches, extracts, gate, state } diff --git a/packages/rsbuild-plugin/src/__tests__/audit-entries.test.ts b/packages/rsbuild-plugin/src/__tests__/audit-entries.test.ts new file mode 100644 index 000000000..546afa074 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/audit-entries.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' + +import { expect, it } from 'bun:test' + +import { fixture, record, red, run } from './audit-fixture' + +it.each(['override-run', 'override-build'])( + 'reserves all known physical inputs before genuine %s AB/BA transforms', + (mode: string) => { + // Given + using fs = fixture() + fs.put('devup.json', '{}') + fs.put( + 'src/entry.js', + mode === 'barrel' + ? 'import {A,B} from "@devup-ui/widgets"; console.log(A,B);' + : 'console.log("old-source-entry");', + ) + const directory = + mode === 'barrel' ? 'node_modules/@devup-ui/widgets/dist' : 'outside' + if (mode === 'barrel') { + fs.put( + 'node_modules/@devup-ui/widgets/package.json', + '{"main":"dist/index.js"}', + ) + fs.put( + `${directory}/index.js`, + 'exports.A=require("./A.js"); exports.B=require("./B.js");', + ) + } + fs.put(`${directory}/A.js`, red) + fs.put(`${directory}/B.js`, red.replace('red', 'blue')) + // When: only callback release order changes, never native entry representation. + const ab = run(fs.root, mode, 'AB') + const ba = run(fs.root, mode, 'BA') + // Then + expect(ab.ok).toBe(true) + expect(ba.ok).toBe(true) + expect(ab.css).toEqual(ba.css) + assert(Array.isArray(ab.revisions) && Array.isArray(ba.revisions)) + const a: unknown = ab.revisions.at(-1) + const b: unknown = ba.revisions.at(-1) + record(a) + record(b) + expect(a.assets).toEqual(b.assets) + expect(ab.count).toBe(0) + expect(ba.count).toBe(0) + }, + 120000, +) diff --git a/packages/rsbuild-plugin/src/__tests__/audit-fixture.ts b/packages/rsbuild-plugin/src/__tests__/audit-fixture.ts new file mode 100644 index 000000000..846ca99e8 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/audit-fixture.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +export function fixture() { + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-w64c-audit-')), + ) + return { + root, + put(path: string, code: string) { + const file = join(root, path) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, code) + }, + [Symbol.dispose]() { + rmSync(root, { recursive: true, force: true }) + }, + } +} + +export function record( + value: unknown, +): asserts value is Record { + assert(value !== null && typeof value === 'object' && !Array.isArray(value)) +} + +export function run(root: string, mode: string, order = 'AB') { + const receipt = join(root, `audit-${mode}-${order}.json`) + const child = spawnSync( + 'node', + [join(import.meta.dir, 'audit-native.cjs'), root, mode, order, receipt], + { encoding: 'utf8', timeout: 45000 }, + ) + console.info( + JSON.stringify({ + audit: mode, + order, + status: child.status, + stdout: child.stdout, + stderr: child.stderr, + }), + ) + assert.equal(child.status, 0, child.stdout + child.stderr) + const result: unknown = JSON.parse(readFileSync(receipt, 'utf8')) + record(result) + console.info(JSON.stringify({ auditReceipt: mode, order, result })) + return result +} + +export const red = + 'import {css} from "@devup-ui/react"; export const cls=css({color:"red"}); console.log(cls);' + +export function tokenFixture(fs: ReturnType) { + fs.put('devup.json', '{}') + fs.put( + 'src/entry.js', + 'import {css} from "@devup-ui/react"; import {color} from "@devup-ui/tokens"; import "@devup-ui/tokens/red"; import "@devup-ui/tokens/blue"; export const cls=css({color}); console.log(cls);', + ) + fs.put('node_modules/@devup-ui/tokens/red.js', 'export const color="red";') + fs.put('node_modules/@devup-ui/tokens/blue.js', 'export const color="blue";') + fs.put( + 'node_modules/@devup-ui/tokens/package.json', + JSON.stringify({ + exports: { + '.': './red.js', + './red': './red.js', + './blue': './blue.js', + }, + }), + ) +} diff --git a/packages/rsbuild-plugin/src/__tests__/audit-manager.cjs b/packages/rsbuild-plugin/src/__tests__/audit-manager.cjs new file mode 100644 index 000000000..c1e5e702a --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/audit-manager.cjs @@ -0,0 +1,87 @@ +const { writeFileSync, readFileSync } = require('node:fs') +const { join } = require('node:path') +/** @type {typeof import('@devup-ui/plugin-utils')} */ +const utils = require('@devup-ui/plugin-utils') +const { wasm, batches, extracts, state } = require('./audit-engine.cjs') + +/** @param {string} root */ +function manager(root) { + wasm.resetBuildState() + wasm.setDebug(false) + wasm.setModuleResolver(utils.createModuleResolver({ cwd: root })) + const slots = utils.createSharedSlotReservation({ + reserveClassSlots: wasm.reserveClassSlots, + }) + const discovery = { + roots: ['src'], + cwd: root, + package: '@devup-ui/react', + importAliases: {}, + } + const flags = { + singleCss: true, + importMainCssInCode: false, + importMainCssInCss: true, + cssDir: () => './css', + } + const initialInventory = utils.collectReservationInputs(discovery) + const first = slots.reserve(discovery, flags) + const input = join(root, 'src/entry.js') + const code = readFileSync(input, 'utf8') + wasm.codeExtract( + input, + code, + '@devup-ui/react', + './css', + true, + false, + true, + {}, + ) + const before = state() + changeTarget(root) + const revisedInventory = utils.collectReservationInputs(discovery) + const second = slots.reserve(discovery, flags) + wasm.codeExtract( + input, + code, + '@devup-ui/react', + './css', + true, + false, + true, + {}, + ) + return { + first, + second, + before, + after: state(), + sameInputs: + JSON.stringify(initialInventory.inputs) === + JSON.stringify(revisedInventory.inputs), + sameSources: + initialInventory.sources.size === revisedInventory.sources.size && + [...initialInventory.sources].every( + ([path, text]) => revisedInventory.sources.get(path) === text, + ), + batches, + extracts, + } +} + +/** @param {string} root */ +function changeTarget(root) { + writeFileSync( + join(root, 'node_modules/@devup-ui/tokens/package.json'), + JSON.stringify({ + exports: { + '.': './blue.js', + './red': './red.js', + './blue': './blue.js', + }, + }), + ) +} + +module.exports = { manager, changeTarget } diff --git a/packages/rsbuild-plugin/src/__tests__/audit-manifest.test.ts b/packages/rsbuild-plugin/src/__tests__/audit-manifest.test.ts new file mode 100644 index 000000000..b03a3bdde --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/audit-manifest.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from 'bun:test' + +import { fixture, red, run } from './audit-fixture' + +it('builds valid reachable input when an unused physical import has a null manifest', () => { + // Given + using fs = fixture() + fs.put('devup.json', '{}') + fs.put('src/entry.js', red) + const control = run(fs.root, 'cold') + expect(control.ok).toBe(true) + fs.put('src/Unused.js', 'import "@devup-ui/broken";') + fs.put('node_modules/@devup-ui/broken/package.json', 'null') + // When + const unused = run(fs.root, 'unused') + // Then + expect(unused.ok).toBe(true) +}, 120000) + +it('retains a normal reachable malformed-package native error without a prepass', () => { + // Given + using fs = fixture() + fs.put('src/entry.js', 'import "@devup-ui/broken";') + fs.put('node_modules/@devup-ui/broken/package.json', 'null') + // When + const reachable = run(fs.root, 'reachable-native') + // Then + expect(reachable.ok).toBe(false) + expect(typeof reachable.error).toBe('string') +}, 60000) diff --git a/packages/rsbuild-plugin/src/__tests__/audit-native.cjs b/packages/rsbuild-plugin/src/__tests__/audit-native.cjs new file mode 100644 index 000000000..f8bcaab65 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/audit-native.cjs @@ -0,0 +1,185 @@ +const { writeFileSync } = require('node:fs') +const { basename } = require('node:path') +/** @type {typeof import('@rsbuild/core')} */ +const { createRsbuild } = require('@rsbuild/core') +const { batches, extracts, gate, state } = require('./audit-engine.cjs') +const { manager, changeTarget } = require('./audit-manager.cjs') +const [root = '', mode = '', order = '', receipt = ''] = process.argv.slice(2) +process.chdir(root) + +async function native() { + const compiled = '../../dist/index.cjs' + /** @type {typeof import('../../dist/plugin')} */ + const { DevupUI } = require(compiled) + /** @type {unknown[]} */ + const entries = [] + /** @type {unknown[]} */ + const capturedEntries = [] + let compilations = 0 + let beforeBuild = 0 + /** @type {import('@rsbuild/core').RsbuildPlugin} */ + const observer = { + name: 'audit-custody', + setup(api) { + api.onBeforeBuild(() => { + beforeBuild++ + }) + api.transform( + { test: /[\\/](?:outside|dist)[\\/][AB]\.js$/, order: 'pre' }, + async ({ code, resourcePath }) => { + if (basename(resourcePath) === `${order[1]}.js`) await gate + return code + }, + ) + api.modifyRspackConfig((config) => { + capturedEntries.push(config.entry) + config.plugins ??= [] + config.plugins.push({ + /** @param {import('@rsbuild/core').Rspack.Compiler} compiler */ + apply(compiler) { + entries.push(compiler.options.entry) + compiler.hooks.thisCompilation.tap('audit', () => { + compilations++ + }) + }, + }) + }) + }, + } + const rsbuild = await createRsbuild({ + cwd: root, + config: { + mode: 'production', + plugins: + mode === 'reachable-native' + ? [] + : [DevupUI({ singleCss: true }), observer], + source: { entry: { main: './src/entry.js' } }, + output: { + distPath: { root: 'out' }, + minify: false, + filenameHash: false, + sourceMap: { js: 'source-map', css: true }, + }, + performance: { printFileSize: false }, + tools: { + rspack(config) { + config.cache = false + config.optimization ??= {} + config.optimization.concatenateModules = false + config.optimization.minimize = false + if (mode.startsWith('override')) + config.entry = { + main: { import: ['./outside/A.js', './outside/B.js'] }, + } + }, + }, + }, + }) + /** @type {import('@rsbuild/core').Rspack.Compiler | import('@rsbuild/core').Rspack.MultiCompiler | undefined} */ + let compiler + /** @type {Awaited> | undefined} */ + let built + /** @type {unknown[]} */ + const revisions = [] + /** @param {import('@rsbuild/core').Rspack.Stats | import('@rsbuild/core').Rspack.MultiStats} stats */ + function capture(stats) { + const all = 'stats' in stats ? stats.stats : [stats] + const assets = Object.fromEntries( + all.flatMap((item) => + item.compilation + .getAssets() + .map((asset) => [asset.name, String(asset.source.source())]), + ), + ) + revisions.push({ + ...state(), + assets, + extracts: [...extracts], + batches: [...batches], + }) + } + try { + if ( + ['override-build', 'cold', 'unused', 'reachable-native'].includes(mode) + ) { + built = await rsbuild.build() + if (built.stats) capture(built.stats) + } else { + compiler = await rsbuild.createCompiler() + const active = compiler + const run = () => + new Promise((resolve, reject) => { + active.run((error, stats) => { + if (error) return reject(error) + if (!stats || stats.hasErrors()) + return reject( + new Error(stats?.toString({}) ?? 'Missing native stats'), + ) + capture(stats) + resolve(undefined) + }) + }) + await run() + if (mode === 'revision') { + changeTarget(root) + await run() + } + } + return { + ok: true, + ...state(), + batches, + extracts, + capturedEntries, + entries, + compilations, + beforeBuild, + revisions, + } + } catch (error) { + if (!(error instanceof Error)) throw error + return { + ok: false, + error: error.stack, + ...state(), + batches, + extracts, + capturedEntries, + entries, + compilations, + beforeBuild, + revisions, + } + } finally { + const active = compiler + if (active) + await new Promise((resolve, reject) => + active.close((error) => (error ? reject(error) : resolve(undefined))), + ) + if (built) await built.close() + } +} + +async function main() { + let parser + try { + parser = require.resolve('oxc-parser', { + paths: [require.resolve('@devup-ui/plugin-utils')], + }) + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'MODULE_NOT_FOUND' + ) + throw error + parser = null + } + const result = mode === 'manager' ? manager(root) : await native() + writeFileSync(receipt, JSON.stringify({ parser, ...result })) +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/rsbuild-plugin/src/__tests__/audit-revision-history.test.ts b/packages/rsbuild-plugin/src/__tests__/audit-revision-history.test.ts new file mode 100644 index 000000000..16de599a0 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/audit-revision-history.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' + +import { expect, it } from 'bun:test' + +import { fixture, record, run, tokenFixture } from './audit-fixture' + +it.each(['manager', 'revision'])( + 'retains prior allocator leaves and reserves blue before live %s revision extraction', + (mode) => { + // Given + using fs = fixture() + tokenFixture(fs) + // When + const result = run(fs.root, mode) + // Then + assert(Array.isArray(result.batches)) + expect(result.batches).toHaveLength(2) + const first: unknown = result.batches[0] + const second: unknown = result.batches[1] + record(first) + record(second) + expect(second.rows).toEqual(first.rows) + expect(second.count).toBe(0) + expect(second.files).toBe(first.files) + expect(second.css).toContain('color:red') + expect(second.css).not.toContain('color:blue') + assert( + typeof first.classes === 'string' && typeof second.classes === 'string', + ) + const initial: unknown = JSON.parse(first.classes) + const revised: unknown = JSON.parse(second.classes) + record(initial) + record(revised) + const oldGlobal = initial[''] + const newGlobal = revised[''] + record(oldGlobal) + record(newGlobal) + expect(oldGlobal).toEqual({ 'color-0-red--255': 0 }) + expect(newGlobal).toEqual({ 'color-0-red--255': 0, 'color-0-blue--255': 1 }) + assert(Array.isArray(result.extracts)) + for (const extract of result.extracts) { + record(extract) + expect(extract.before).toBe(0) + expect(extract.after).toBe(0) + } + }, + 120000, +) diff --git a/packages/rsbuild-plugin/src/__tests__/audit-revision.test.ts b/packages/rsbuild-plugin/src/__tests__/audit-revision.test.ts new file mode 100644 index 000000000..fe7b52b1d --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/audit-revision.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' + +import { expect, it } from 'bun:test' + +import { fixture, record, run, tokenFixture } from './audit-fixture' + +it('refreshes the shared manager on resolution-only changes before real engine extraction', () => { + // Given + using fs = fixture() + tokenFixture(fs) + // When: helper changes only default manifest target, with both leaves already selected. + const result = run(fs.root, 'manager') + // Then + expect(result.sameInputs).toBe(true) + expect(result.sameSources).toBe(true) + record(result.before) + record(result.after) + expect(result.before.count).toBe(0) + expect(result.after.count).toBe(0) + expect(result.second).toBe(true) +}, 60000) + +it('refreshes resolution-only revisions at repeated cache-disabled native run boundaries', () => { + // Given + using fs = fixture() + tokenFixture(fs) + // When + const result = run(fs.root, 'revision') + // Then + expect(result.ok).toBe(true) + expect(result.count).toBe(0) + assert(Array.isArray(result.revisions)) + expect(result.revisions).toHaveLength(2) +}, 120000) diff --git a/packages/rsbuild-plugin/src/__tests__/audit-scanner.test.ts b/packages/rsbuild-plugin/src/__tests__/audit-scanner.test.ts new file mode 100644 index 000000000..da4cd9472 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/audit-scanner.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { fixture, record, red, run } from './audit-fixture' + +it('keeps actual native bytes equal when only a quoted fake import target exists', () => { + // Given: ordinary double-quoted payload, never an actual import expression. + using fs = fixture() + fs.put('devup.json', '{}') + const input = `${red}\nexport const snippet="import '../out/stale.js'"; console.log(snippet);` + fs.put('src/entry.js', input) + const cold = run(fs.root, 'cold') + expect(cold.ok).toBe(true) + fs.put('out/stale.js', red.replace('red', 'chartreuse')) + // When + const warm = run(fs.root, 'cold') + // Then: source is unchanged and genuine native emission, not a projected name. + expect(readFileSync(join(fs.root, 'src/entry.js'), 'utf8')).toBe(input) + expect(warm.ok).toBe(true) + assert(Array.isArray(cold.revisions) && Array.isArray(warm.revisions)) + const a: unknown = cold.revisions.at(-1) + const b: unknown = warm.revisions.at(-1) + record(a) + record(b) + expect(b.assets).toEqual(a.assets) + expect(warm.css).toEqual(cold.css) +}, 120000) diff --git a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts index 7c61a4591..f607852db 100644 --- a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts @@ -71,6 +71,7 @@ async function extractedCssDirIn(checkout: string) { modifyRsbuildConfig: mock(), modifyRspackConfig: mock(), onBeforeBuild: mock(), + context: { rootPath: checkout }, } as unknown as RsbuildSetupContext) codeExtractSpy.mockClear() diff --git a/packages/rsbuild-plugin/src/__tests__/entry-revisions.cjs b/packages/rsbuild-plugin/src/__tests__/entry-revisions.cjs new file mode 100644 index 000000000..576394699 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/entry-revisions.cjs @@ -0,0 +1,121 @@ +const { writeFileSync } = require('node:fs') +const { basename } = require('node:path') +/** @type {typeof import('@rsbuild/core')} */ +const { createRsbuild } = require('@rsbuild/core') +const { batches, extracts, gate, state } = require('./audit-engine.cjs') +const [root = '', mode = '', order = '', receipt = ''] = process.argv.slice(2) +process.chdir(root) + +async function main() { + const compiled = '../../dist/index.cjs' + /** @type {typeof import('../../dist/plugin')} */ + const { DevupUI } = require(compiled) + let revision = 0 + let beforeBuild = 0 + let compilations = 0 + /** @type {unknown[]} */ + const entries = [] + /** @type {import('@rsbuild/core').RsbuildPlugin} */ + const observer = { + name: 'entry-revision-custody', + setup(api) { + api.onBeforeBuild(() => { + beforeBuild++ + }) + api.transform( + { test: /[\\/]outside[\\/][AB]\.js$/, order: 'pre' }, + async ({ code, resourcePath }) => { + if (basename(resourcePath) === `${order[1]}.js`) await gate + return code + }, + ) + api.onAfterCreateCompiler(({ compiler }) => { + const all = 'compilers' in compiler ? compiler.compilers : [compiler] + entries.push(all.map((item) => item.options.entry)) + for (const item of all) + item.hooks.thisCompilation.tap('entry-revision-custody', () => { + compilations++ + }) + }) + }, + } + const rsbuild = await createRsbuild({ + cwd: root, + config: { + mode: 'production', + plugins: [DevupUI({ singleCss: true }), observer], + source: { entry: { main: './src/entry.js' } }, + environments: { + first: { output: { distPath: { root: 'out-first' } } }, + second: { output: { distPath: { root: 'out-second' } } }, + }, + output: { + minify: false, + filenameHash: false, + sourceMap: { js: 'source-map', css: true }, + }, + performance: { printFileSize: false }, + tools: { + rspack(config, { environment }) { + config.cache = false + config.optimization ??= {} + config.optimization.concatenateModules = false + config.optimization.minimize = false + config.entry = { + main: { + import: [ + environment.name === 'first' + ? revision === 0 + ? './outside/A.js' + : './revised/A.js' + : './outside/B.js', + ], + }, + } + }, + }, + }, + }) + /** @type {unknown[]} */ + const revisions = [] + for (; revision < 2; revision++) { + const compiler = await rsbuild.createCompiler() + const batchStart = batches.length + const extractStart = extracts.length + try { + await new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) return reject(error) + if (!stats || stats.hasErrors()) + return reject(new Error(stats?.toString({}) ?? 'Missing stats')) + const all = 'stats' in stats ? stats.stats : [stats] + revisions.push({ + ...state(), + batches: batches.slice(batchStart), + extracts: extracts.slice(extractStart), + assets: all.map((item) => + Object.fromEntries( + item.compilation + .getAssets() + .map((asset) => [asset.name, String(asset.source.source())]), + ), + ), + }) + resolve(undefined) + }) + }) + } finally { + await new Promise((resolve, reject) => + compiler.close((error) => (error ? reject(error) : resolve(undefined))), + ) + } + } + writeFileSync( + receipt, + JSON.stringify({ mode, entries, revisions, beforeBuild, compilations }), + ) +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/rsbuild-plugin/src/__tests__/entry-revisions.test.ts b/packages/rsbuild-plugin/src/__tests__/entry-revisions.test.ts new file mode 100644 index 000000000..91ea27537 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/entry-revisions.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { fixture, record, red } from './audit-fixture' + +function run(root: string, order: string) { + const receipt = join(root, `entry-revisions-${order}.json`) + const child = spawnSync( + 'node', + [ + join(import.meta.dir, 'entry-revisions.cjs'), + root, + 'union', + order, + receipt, + ], + { encoding: 'utf8', timeout: 60000 }, + ) + assert.equal(child.status, 0, child.stdout + child.stderr) + const result: unknown = JSON.parse(readFileSync(receipt, 'utf8')) + record(result) + console.info(JSON.stringify({ entryRevisionReceipt: order, result })) + return result +} + +it('replaces final compiler revisions without losing the other environment before native extraction', () => { + // Given final tools.rspack entries outside D9 and one superseded physical source. + using fs = fixture() + fs.put('devup.json', '{}') + fs.put('src/entry.js', 'console.log("early-entry");') + fs.put('outside/A.js', red) + fs.put('outside/B.js', red.replace('red', 'blue')) + fs.put('revised/A.js', red.replace('red', 'green')) + fs.put('outside/stale.js', red.replace('red', 'chartreuse')) + // When two native compiler generations each build both environments in AB/BA order. + const ab = run(fs.root, 'AB') + const ba = run(fs.root, 'BA') + // Then current batches replace old entries, preserve the union and keep raw output equal. + expect(ab.beforeBuild).toBe(0) + expect(ba.beforeBuild).toBe(0) + expect(ab.entries).toEqual(ba.entries) + assert(Array.isArray(ab.revisions) && Array.isArray(ba.revisions)) + expect(ab.revisions).toHaveLength(2) + expect(ba.revisions).toHaveLength(2) + for (const [index, left] of ab.revisions.entries()) { + const right: unknown = ba.revisions[index] + record(left) + record(right) + expect(left.assets).toEqual(right.assets) + expect(left.css).toEqual(right.css) + expect(left.classes).toEqual(right.classes) + expect(left.files).toEqual(right.files) + for (const result of [left, right]) { + expect(result.count).toBe(0) + assert(Array.isArray(result.batches) && Array.isArray(result.extracts)) + expect(result.batches).toHaveLength(1) + expect(result.extracts.length).toBeGreaterThan(0) + const batch: unknown = result.batches[0] + record(batch) + assert(Array.isArray(batch.rows)) + const names = batch.rows.map((row: unknown) => { + assert(Array.isArray(row) && typeof row[0] === 'string') + return row[0] + }) + expect(names).toContain(join(fs.root, 'outside/B.js')) + expect(names).toContain( + join(fs.root, index === 0 ? 'outside/A.js' : 'revised/A.js'), + ) + expect(names).not.toContain( + join(fs.root, index === 0 ? 'revised/A.js' : 'outside/A.js'), + ) + expect(names).not.toContain(join(fs.root, 'outside/stale.js')) + for (const output of result.extracts) { + record(output) + expect(output.before).toBe(0) + expect(output.after).toBe(0) + } + } + } +}, 180000) diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 1b8bd6cc6..52f50039a 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict' import * as fs from 'node:fs' import * as fsPromises from 'node:fs/promises' import { join, resolve } from 'node:path' @@ -111,7 +112,7 @@ describe('DevupUIRsbuildPlugin', () => { 'setModuleResolver', ).mockReturnValue(undefined) try { - const plugin = DevupUI() + const plugin = DevupUI({ atomHoist: 0 }) const transform = mock() await plugin.setup(createSetupContext({ transform })) const resolveModule = setModuleResolverSpy.mock.calls.at(-1)?.[0] as ( @@ -250,7 +251,7 @@ describe('DevupUIRsbuildPlugin', () => { expect(plugin.name).toBe('devup-ui-rsbuild-plugin') }) it('should transform css', async () => { - const plugin = DevupUI() + const plugin = DevupUI({ atomHoist: 0 }) expect(plugin).toBeDefined() expect(plugin.setup).toBeDefined() const transform = mock() @@ -485,7 +486,7 @@ const App = () => `, if (path === join('df', 'fileMap.json')) return options.existsFileMapFile return false }) - const plugin = DevupUI({ singleCss: options.singleCss }) + const plugin = DevupUI({ singleCss: options.singleCss, atomHoist: 0 }) await plugin.setup(createSetupContext()) if (options.existsDevupFile) { expect(readFileSpy).toHaveBeenCalledWith('devup.json', 'utf-8') @@ -554,40 +555,6 @@ const App = () => `, }) describe('deterministic file numbering', () => { - it.each([ - ['relative', false], - ['posix', true], - ])('numbers the files the scan finds (%s ids)', async (_name, atomMode) => { - const collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') - const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) - const closeBuild = mock() - try { - collectSpy.mockImplementation((options: any) => { - expect(options.toId('C:\\p\\a.tsx')).toBe( - atomMode ? 'C:/p/a.tsx' : 'C:\\p\\a.tsx', - ) - return ['/p/a.tsx'] - }) - await DevupUI({ - include: ['@acme/ui'], - atomHoist: atomMode ? 2 : undefined, - }).setup(createSetupContext({ onCloseBuild: closeBuild })) - expect(seedSpy).toHaveBeenCalledWith(['/p/a.tsx']) - expect(collectSpy.mock.calls[0][0]).toMatchObject({ - roots: [resolve('src')], - include: ['@acme/ui'], - }) - closeBuild.mock.calls[0][0]() - collectSpy.mockImplementation(() => { - throw new Error('scan boom') - }) - await DevupUI().setup(createSetupContext()) - } finally { - collectSpy.mockRestore() - seedSpy.mockRestore() - } - }) - it('sets the prefix every time, even without one', async () => { setPrefixSpy.mockClear() await DevupUI().setup(createSetupContext()) @@ -629,9 +596,9 @@ const App = () => `, getCssSpy?.mockRestore() }) - it('does nothing when atomHoist is unset', async () => { + it('does nothing when atomHoist is explicitly zero', async () => { spies() - await DevupUI().setup( + await DevupUI({ atomHoist: 0 }).setup( createSetupContext({ transform: mock(), modifyRsbuildConfig: mock() }), ) expect(buildCanonicalMapSpy).not.toHaveBeenCalled() @@ -639,6 +606,132 @@ const App = () => `, expect(importFileRoutesSpy).not.toHaveBeenCalled() }) + describe('W66 default regressions', () => { + it.each([ + ['omitted', {}], + ['undefined', { atomHoist: undefined }], + ])( + 'matches explicit 2 on two routes when atomHoist is %s', + async (_label, options) => { + // Given: explicit 2 installs the known two-route reach. + spies() + const reach = { + '/p/src/shared.tsx': [0, 1], + '/p/src/a.tsx': [0], + '/p/src/b.tsx': [1], + } + computeFileReachSpy.mockReturnValue(reach) + await DevupUI({ atomHoist: 2 }).setup(createSetupContext()) + const explicitReach = [...importFileRoutesSpy.mock.calls] + expect(explicitReach).toEqual([[reach]]) + expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + importFileRoutesSpy.mockClear() + setAtomHoistSpy.mockClear() + // When: the option is defaulted. + await DevupUI(options).setup(createSetupContext()) + // Then: the same plan is installed at threshold 2. + expect(importFileRoutesSpy.mock.calls).toEqual(explicitReach) + expect(setAtomHoistSpy.mock.calls).toEqual([[2]]) + }, + ) + + it.each([0, -1, NaN, Infinity, -Infinity])( + 'keeps legacy OFF for atomHoist %s', + async (atomHoist) => { + // Given: a valid two-route fixture. + spies() + computeFileReachSpy.mockReturnValue({ + '/p/a.tsx': [0], + '/p/b.tsx': [1], + }) + // When: the caller explicitly disables atom mode. + await DevupUI({ atomHoist }).setup(createSetupContext()) + // Then: the atom-only pre-pass has no side effects. + expect(buildCanonicalMapSpy).not.toHaveBeenCalled() + expect(computeFileReachSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() + }, + ) + + it('preserves a custom threshold above the minimum', async () => { + // Given: two routes and a custom threshold. + spies() + computeFileReachSpy.mockReturnValue({ + '/p/a.tsx': [0], + '/p/b.tsx': [1], + }) + // When: setup requests threshold 3. + await DevupUI({ atomHoist: 3 }).setup(createSetupContext()) + // Then: the threshold is not replaced by the default. + expect(setAtomHoistSpy.mock.calls).toEqual([[3]]) + }) + + it.each([ + ['omitted', {}], + ['undefined', { atomHoist: undefined }], + ['enabled', { atomHoist: 2 }], + ])( + 'ignores %s atomHoist before IDs imports and splitChunks in actual singleCss mode', + async (_label, options) => { + // Given: real registered config/source/CSS hooks with a two-route fixture. + spies() + computeFileReachSpy.mockReturnValue({ + '/p/a.tsx': [0], + '/p/b.tsx': [1], + }) + codeExtractSpy.mockReturnValue( + createCodeExtractResult({ cssFile: '' }), + ) + const resourcePath = resolve('src', 'a.tsx') + const run = async (policy: Parameters[0]) => { + const transform = mock() + const modifyRsbuildConfig = mock() + await DevupUI({ ...policy, singleCss: true }).setup( + createSetupContext({ transform, modifyRsbuildConfig }), + ) + const config = { tools: { rspack: mock() } } + const userRspack = config.tools.rspack + modifyRsbuildConfig.mock.calls[0][0](config) + await transform.mock.calls[1][1]({ + code: 'source', + resourcePath, + addDependency: mock(), + }) + transform.mock.calls[0][1]({ + resourcePath: resolve('df', 'devup-ui', 'devup-ui-3.css'), + environment: { name: 'web' }, + }) + expect(config.tools.rspack).toBe(userRspack) + } + codeExtractSpy.mockClear() + await run({ atomHoist: 0 }) + const offExtractions = [...codeExtractSpy.mock.calls] + codeExtractSpy.mockClear() + // When: only the requested atom option changes from explicit OFF. + await run(options) + // Then: no planning, native IDs/import flags and legacy stylesheet policy remain. + expect(buildCanonicalMapSpy).not.toHaveBeenCalled() + expect(importCanonicalMapSpy).not.toHaveBeenCalled() + expect(computeFileReachSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() + expect(codeExtractSpy.mock.calls).toEqual(offExtractions) + expect(codeExtractSpy).toHaveBeenCalledWith( + resourcePath, + 'source', + '@devup-ui/react', + expect.stringMatching(/^\.\//), + true, + false, + true, + expect.anything(), + ) + expect(getCssSpy).toHaveBeenLastCalledWith(3, true) + }, + ) + }) + it('composes collapse + hoist and folds reach onto the canonical bucket', async () => { spies() buildCanonicalMapSpy.mockReturnValue({ @@ -858,10 +951,13 @@ const App = () => `, taps[hook] = fn } const compiler = { + options: {}, watchMode: false, rspack: { Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 } }, hooks: { run: { tap: tap('run') }, + watchRun: { tap: tap('watchRun') }, + invalid: { tap: tap('invalid') }, thisCompilation: { tap: tap('start') }, }, } @@ -884,7 +980,7 @@ const App = () => `, } it('extracts the files the entries reach before building', async () => { - const { onBeforeBuild } = await setup({ atomHoist: undefined }) + const { onBeforeBuild } = await setup({ atomHoist: 0 }) codeExtractSpy.mockClear() onBeforeBuild.mock.calls[0][0]({ environments: { @@ -934,6 +1030,30 @@ const App = () => `, ) }) + it('reserves public compiler revisions without adding watch prewarm', async () => { + const reserve = spyOn(wasm, 'reserveClassSlots').mockReturnValue( + undefined, + ) + try { + const { taps } = await setup() + codeExtractSpy.mockClear() + expect(reserve).not.toHaveBeenCalled() + assert(taps.watchRun && taps.invalid && taps.run) + taps.watchRun() + expect(reserve).toHaveBeenCalledTimes(1) + expect(codeExtractSpy).not.toHaveBeenCalled() + taps.invalid() + taps.watchRun() + expect(reserve).toHaveBeenCalledTimes(2) + taps.run() + expect(codeExtractSpy).not.toHaveBeenCalled() + taps.run() + expect(codeExtractSpy).not.toHaveBeenCalled() + } finally { + reserve.mockRestore() + } + }) + it('compiles once more, writing no file, when a stylesheet changed', async () => { const { compiler, compilation, taps, serve } = await setup() writeFileSyncSpy.mockClear() diff --git a/packages/rsbuild-plugin/src/__tests__/reservation-build.test.ts b/packages/rsbuild-plugin/src/__tests__/reservation-build.test.ts new file mode 100644 index 000000000..0cb99af7f --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/reservation-build.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { assertRetained, parseReceipt, record } from './reservation-receipt' + +const require = createRequire(import.meta.url) +const fixture: { + prepare(root: string, watch: boolean): void +} = require('./reservation-fixture.cjs') + +function run(root: string, order: string, mode: string) { + const receipt = join(root, `${order}.json`) + const child = spawnSync( + 'node', + [ + join(import.meta.dir, 'reservation-native.cjs'), + root, + order, + mode, + receipt, + ], + { encoding: 'utf8', timeout: 60000 }, + ) + assert.equal(child.status, 0, child.stdout + child.stderr) + const value: unknown = JSON.parse(readFileSync(receipt, 'utf8')) + return parseReceipt(value) +} + +for (const mode of ['build', 'run', 'watch', 'dev']) { + it(`emits identical real output when ${mode} extraction order reverses`, () => { + // Given the same physical sources and fixed initial watch history. + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-rsbuild-reserve-')), + ) + try { + const revision = mode === 'watch' || mode === 'dev' + fixture.prepare(root, revision) + // When native Rspack reaches the modules in opposite orders. + const ab = run(root, 'AB', mode) + if (revision) rmSync(join(root, 'app'), { recursive: true }) + fixture.prepare(root, revision) + const ba = run(root, 'BA', mode) + // Then actual source and stylesheet bytes agree (no name projection). + expect(ab.modules).toEqual(ba.modules) + expect(ab.css).toEqual(ba.css) + expect(ab.count).toBe(0) + expect(ba.count).toBe(0) + expect(ab.classMap).toEqual(ba.classMap) + expect(ab.fileMap).toEqual(ba.fileMap) + if (mode !== 'dev') expect(ab.assets).toEqual(ba.assets) + for (const result of [ab, ba]) { + if (mode === 'run') { + expect(result.css).not.toContain('padding:19px') + record(result.assets) + const emittedCss = Object.entries(result.assets) + .filter(([name]) => name.endsWith('.css')) + .map(([, source]) => { + assert(typeof source === 'string') + return source + }) + expect(emittedCss.length).toBeGreaterThan(0) + expect(emittedCss.join('\n')).not.toMatch(/padding:\s*19px/) + } + expect(result.batches.length).toBeGreaterThan(0) + expect( + result.outputs.every( + ({ before, after }) => before === 0 && after === 0, + ), + ).toBe(true) + expect(result.entries).toBe(result.compilations) + expect(result.beforeBuild).toBe(mode === 'build' ? 1 : 0) + expect(result.css).toContain( + mode === 'watch' ? 'darkmagenta' : revision ? 'crimson' : 'red', + ) + expect(result.css).toContain('17px') + expect(result.css).toContain('@keyframes') + const final = result.batches.at(-1) + assert(final) + for (const batch of result.batches) { + assert( + typeof batch.classMap === 'string' && + typeof batch.fileMap === 'string', + ) + assert( + typeof result.classMap === 'string' && + typeof result.fileMap === 'string', + ) + const oldClasses: unknown = JSON.parse(batch.classMap) + const classes: unknown = JSON.parse(result.classMap) + const oldFiles: unknown = JSON.parse(batch.fileMap) + const files: unknown = JSON.parse(result.fileMap) + assertRetained(oldClasses, classes) + assertRetained(oldFiles, files) + } + expect(final.rows.map((row) => row.filename)).toContain( + join(root, 'node_modules/included-ui/dist/index.js'), + ) + expect( + final.rows.some((row) => row.filename.endsWith('invalid.js')), + ).toBe(true) + expect( + final.rows.map((row) => row.filename.replaceAll('\\', '/')), + ).toEqual( + final.rows.map((row) => row.filename.replaceAll('\\', '/')).sort(), + ) + if (mode === 'build' || mode === 'run') { + expect(result.events[0]).toBe('reserve') + } + if (mode === 'build') { + expect(result.outputs[0]?.name).toBe(join(root, 'src/entry.js')) + } + } + console.info( + JSON.stringify({ + scenario: mode, + residualAB: ab.count, + residualBA: ba.count, + batches: [ab.batches.length, ba.batches.length], + compilations: [ab.compilations, ba.compilations], + }), + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, 120000) +} diff --git a/packages/rsbuild-plugin/src/__tests__/reservation-dev.cjs b/packages/rsbuild-plugin/src/__tests__/reservation-dev.cjs new file mode 100644 index 000000000..92ea8a51e --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/reservation-dev.cjs @@ -0,0 +1,62 @@ +const assert = require('node:assert/strict') +const { addPair, put } = require('./reservation-fixture.cjs') + +async function runDev(rsbuild, state) { + let complete + const nextCompile = () => + new Promise((resolve, reject) => { + complete = { resolve, reject } + }) + const first = nextCompile() + rsbuild.onAfterDevCompile(({ stats }) => { + if (stats.hasErrors()) complete.reject(new Error(stats.toString())) + else complete.resolve(stats) + }) + let server + try { + server = await rsbuild.createDevServer() + const { port } = await server.listen() + const initial = await first + assert.equal( + state.extractions.filter((item) => + /[\\/]app[\\/][AB]\.js$/.test(item.name), + ).length, + 0, + ) + const assets = initial.toJson({ all: false, assets: true }).assets + let source = '' + for (const asset of assets.filter((asset) => asset.name.endsWith('.js'))) { + const response = await fetch(`http://127.0.0.1:${port}/${asset.name}`, { + signal: AbortSignal.timeout(15000), + }) + assert.equal(response.status, 200) + source += await response.text() + } + const match = /var data = ("lazy-compilation-proxy\|(?:[^"\\]|\\.)*")/.exec( + source, + ) + assert(match, source) + const key = JSON.parse(match[1]) + addPair(state.root, state.order) + put(state.root, 'src/constants.js', 'export const tone = "crimson"') + const route = nextCompile() + const response = await fetch( + `http://127.0.0.1:${port}/_rspack/lazy/trigger`, + { + method: 'POST', + body: key, + headers: { 'content-type': 'text/plain' }, + signal: AbortSignal.timeout(15000), + }, + ) + assert.equal(response.status, 200) + await response.text() + await route + state.events.push('lazy-route-complete') + return { key, initialHash: initial.hash } + } finally { + if (server) await server.close() + } +} + +module.exports = { runDev } diff --git a/packages/rsbuild-plugin/src/__tests__/reservation-fixture.cjs b/packages/rsbuild-plugin/src/__tests__/reservation-fixture.cjs new file mode 100644 index 000000000..f4a3fe273 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/reservation-fixture.cjs @@ -0,0 +1,74 @@ +const { mkdirSync, writeFileSync } = require('node:fs') +const { dirname, join } = require('node:path') + +function put(root, path, code) { + const file = join(root, path) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, code) +} + +function prepare(root, watch = false) { + put(root, 'devup.json', '{}') + put( + root, + 'src/entry.js', + 'import {css} from "@devup-ui/react"; console.log(css({padding:"19px"}))', + ) + put( + root, + 'src/inventory.js', + 'export * from "../app/A.js"; export * from "../app/B.js"', + ) + put( + root, + 'src/unused.js', + 'import { globalCss } from "@devup-ui/react"; globalCss({".unused":{color:"chartreuse"}})', + ) + put( + root, + 'src/invalid.js', + 'import { css } from "@devup-ui/react"; export const broken = css({color: process.env.BAD})', + ) + put(root, 'src/constants.js', 'export const tone = "red"') + put( + root, + 'node_modules/included-ui/package.json', + '{"name":"included-ui","main":"dist/index.js"}', + ) + put( + root, + 'node_modules/included-ui/dist/index.js', + 'import {css} from "@devup-ui/react"; export const included = css({margin:"17px"})', + ) + put( + root, + 'app/Dynamic.jsx', + 'import {Box} from "@devup-ui/react"; export const dynamic = (color) => ', + ) + if (!watch) addPair(root) +} + +function addPair(root, order = 'AB') { + for (const name of order) { + put( + root, + `app/${name}.css.ts`, + `import { createVar, createContainer, layer } from '@vanilla-extract/css'; +export const variable = createVar(); export const container = createContainer(); export const layerName = layer();`, + ) + put( + root, + `app/${name}.js`, + `import { css, keyframes } from '@devup-ui/react'; +import { tone } from '../src/constants.js'; +import {variable, container, layerName} from './${name}.css.ts'; +import {included} from 'included-ui'; +import {dynamic} from './Dynamic.jsx'; +export const animation = keyframes({from:{opacity:${name === 'A' ? '0' : '0.25'}},to:{opacity:1}}); +export const value = css({color:${name === 'A' ? 'tone' : '"blue"'}, animationName:animation}); +console.log(${JSON.stringify(name)}, value, animation, variable, container, layerName, included, dynamic);`, + ) + } +} + +module.exports = { put, prepare, addPair } diff --git a/packages/rsbuild-plugin/src/__tests__/reservation-native.cjs b/packages/rsbuild-plugin/src/__tests__/reservation-native.cjs new file mode 100644 index 000000000..5c8e2d3e0 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/reservation-native.cjs @@ -0,0 +1,206 @@ +const assert = require('node:assert/strict') +const { writeFileSync } = require('node:fs') +const { basename } = require('node:path') +const { createRsbuild } = require('@rsbuild/core') +const wasm = require('@devup-ui/wasm') +const { put } = require('./reservation-fixture.cjs') +const { runDev } = require('./reservation-dev.cjs') +const { runWatch } = require('./reservation-watch.cjs') + +const [root, order, mode, receipt] = process.argv.slice(2) +process.chdir(root) +const events = [], + extractions = [], + reservations = [] +const originalReserve = wasm.reserveClassSlots +wasm.reserveClassSlots = (...args) => { + events.push('reserve') + const css = wasm.getCss(null, false) + originalReserve(...args) + assert.equal(wasm.getCss(null, false), css) + reservations.push({ + rows: args[0], + flags: args.slice(1), + count: wasm.getUnreservedClassSlotCount(), + classMap: wasm.exportClassMap(), + fileMap: wasm.exportFileMap(), + }) +} +let release +const gate = new Promise((resolve) => { + release = resolve +}) +const originalExtract = wasm.codeExtract +wasm.codeExtract = (...args) => { + const before = wasm.getUnreservedClassSlotCount() + const output = originalExtract(...args) + events.push(`extract:${basename(args[0])}`) + extractions.push({ + name: args[0], + code: output.code, + map: output.map, + before, + after: wasm.getUnreservedClassSlotCount(), + }) + if (basename(args[0]) === `${order[0]}.js`) release() + return output +} +const { DevupUI } = require('../../dist/index.cjs') +let compilations = 0, + entries = 0, + beforeBuild = 0, + revised = false +const observer = { + name: 'reservation-observer', + setup(api) { + api.onBeforeBuild(() => { + beforeBuild++ + }) + api.transform( + { test: /[\\/]app[\\/][AB]\.js$/, order: 'pre' }, + async ({ code, resourcePath }) => { + if (basename(resourcePath) === `${order[1]}.js`) await gate + return code + }, + ) + api.modifyRspackConfig((config) => { + config.entry = () => { + entries++ + if (mode === 'error') return { main: { import: ['./src/invalid.js'] } } + return mode === 'dev' || (mode === 'watch' && !revised) + ? { main: { import: ['./src/entry.js'] } } + : { main: { import: ['./app/A.js', './app/B.js'] } } + } + config.plugins.push({ + apply(compiler) { + compiler.options.watchOptions = { poll: 1000 } + compiler.hooks.thisCompilation.tap('observe', () => { + compilations++ + }) + }, + }) + }) + }, +} + +async function main() { + if (mode === 'dev') { + put( + root, + 'src/entry.js', + 'import {css} from "@devup-ui/react"; console.log(css({padding:"19px"})); export const load = () => import("../app/Route.js")', + ) + put( + root, + 'app/Route.js', + 'import "./A.js"; import "./B.js"; export const route = "ready"', + ) + } + const rsbuild = await createRsbuild({ + cwd: root, + config: { + mode: mode === 'dev' ? 'development' : 'production', + plugins: [ + DevupUI({ singleCss: true, include: ['included-ui'] }), + observer, + ], + source: { entry: { main: './src/entry.js' } }, + output: { + distPath: { root: 'out' }, + minify: false, + filenameHash: false, + sourceMap: { js: 'source-map', css: true }, + }, + performance: { printFileSize: false }, + html: { template: false }, + server: { port: 0, host: '127.0.0.1', printUrls: false }, + dev: { + lazyCompilation: + mode === 'dev' ? { entries: false, imports: true } : false, + hmr: mode === 'dev', + liveReload: false, + }, + tools: { + rspack: { + cache: false, + optimization: { concatenateModules: false, minimize: false }, + }, + }, + }, + }) + let compiler, result, lastStats + try { + if (mode === 'dev') + await runDev(rsbuild, { root, order, events, extractions }) + else if (mode === 'build' || mode === 'error') { + result = await rsbuild.build() + lastStats = result.stats + } else { + compiler = await rsbuild.createCompiler() + if (mode === 'run') + lastStats = await new Promise((resolve, reject) => + compiler.run((error, stats) => + error || stats.hasErrors() + ? reject(error || new Error(stats.toString())) + : resolve(stats), + ), + ) + else + lastStats = await runWatch(compiler, { + root, + order, + events, + revise() { + revised = true + }, + }) + } + const modules = Object.fromEntries( + extractions + .filter((item) => + /[\\/]app[\\/](?:[AB](?:\.css\.ts|\.js)|Dynamic\.jsx)$/.test( + item.name, + ), + ) + .map((item) => [basename(item.name), item.code]), + ) + assert.equal(Object.keys(modules).length, 5) + const css = wasm.getCss(null, false) + assert(css.includes('blue')) + assert(!css.includes('chartreuse')) + const assets = lastStats + ? Object.fromEntries( + lastStats.compilation + .getAssets() + .map((asset) => [asset.name, String(asset.source.source())]), + ) + : undefined + writeFileSync( + receipt, + JSON.stringify({ + modules, + css, + assets, + classMap: wasm.exportClassMap(), + fileMap: wasm.exportFileMap(), + count: wasm.getUnreservedClassSlotCount(), + events, + reservations, + extractions, + compilations, + entries, + beforeBuild, + }), + ) + } finally { + if (compiler) + await new Promise((resolve, reject) => + compiler.close((error) => (error ? reject(error) : resolve())), + ) + if (result) await result.close() + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/rsbuild-plugin/src/__tests__/reservation-receipt.ts b/packages/rsbuild-plugin/src/__tests__/reservation-receipt.ts new file mode 100644 index 000000000..d129f0108 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/reservation-receipt.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' + +export function record( + value: unknown, +): asserts value is Record { + assert(value !== null && typeof value === 'object') +} + +export function parseReceipt(value: unknown) { + record(value) + const { + modules, + css, + count, + reservations, + extractions, + events, + entries, + compilations, + beforeBuild, + } = value + record(modules) + assert(typeof css === 'string' && typeof count === 'number') + assert( + typeof entries === 'number' && + typeof compilations === 'number' && + typeof beforeBuild === 'number', + ) + assert( + Array.isArray(reservations) && + Array.isArray(extractions) && + Array.isArray(events), + ) + const batches = reservations.map((batch: unknown) => { + record(batch) + assert(Array.isArray(batch.rows)) + const rows = batch.rows.map((row: unknown) => { + assert(Array.isArray(row) && row.length === 3) + assert(row.every((field: unknown) => typeof field === 'string')) + const [filename, code, directory]: unknown[] = row + assert( + typeof filename === 'string' && + typeof code === 'string' && + typeof directory === 'string', + ) + return { filename, code, directory } + }) + return { + rows, + flags: batch.flags, + count: batch.count, + classMap: batch.classMap, + fileMap: batch.fileMap, + } + }) + const outputs = extractions.map((output: unknown) => { + record(output) + assert(typeof output.name === 'string' && typeof output.code === 'string') + assert( + typeof output.before === 'number' && typeof output.after === 'number', + ) + return { + name: output.name, + code: output.code, + before: output.before, + after: output.after, + map: output.map, + } + }) + return { + modules, + css, + count, + batches, + outputs, + events, + entries, + compilations, + beforeBuild, + classMap: value.classMap, + fileMap: value.fileMap, + assets: value.assets, + } +} + +export function assertRetained(previous: unknown, current: unknown): void { + record(previous) + record(current) + for (const [key, value] of Object.entries(previous)) { + if (value !== null && typeof value === 'object') + assertRetained(value, current[key]) + else assert.deepEqual(current[key], value) + } +} diff --git a/packages/rsbuild-plugin/src/__tests__/reservation-scenarios.cjs b/packages/rsbuild-plugin/src/__tests__/reservation-scenarios.cjs new file mode 100644 index 000000000..3b83bc435 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/reservation-scenarios.cjs @@ -0,0 +1,189 @@ +const assert = require('node:assert/strict') +const { writeFileSync } = require('node:fs') +const { join } = require('node:path') +const { createRsbuild } = require('@rsbuild/core') +const wasm = require('@devup-ui/wasm') +const { put } = require('./reservation-fixture.cjs') + +const [root, scenario, receipt] = process.argv.slice(2) +process.chdir(root) +const batches = [], + outputs = [], + events = [], + hoist = [] +const reserve = wasm.reserveClassSlots, + extract = wasm.codeExtract, + setHoist = wasm.setAtomHoist +wasm.reserveClassSlots = (...args) => { + const css = wasm.getCss(null, false) + reserve(...args) + assert.equal(wasm.getCss(null, false), css) + events.push('reserve') + batches.push(args[0]) +} +wasm.codeExtract = (...args) => { + const before = wasm.getUnreservedClassSlotCount() + const result = extract(...args) + events.push('extract') + outputs.push({ + filename: args[0], + code: result.code, + map: result.map, + before, + after: wasm.getUnreservedClassSlotCount(), + }) + return result +} +wasm.setAtomHoist = (value) => { + hoist.push(value) + setHoist(value) +} +const { DevupUI } = require('../../dist/index.cjs') +put(root, 'devup.json', '{}') +const atom = scenario === 'atom' || scenario === 'noop' +const options = { singleCss: !atom, atomHoist: atom ? 2 : undefined } +const config = { + mode: 'production', + plugins: [DevupUI(options)], + source: { entry: { main: './src/entry.js' } }, + output: { distPath: { root: 'out' }, minify: false, filenameHash: false }, + performance: { printFileSize: false }, + html: { template: false }, + tools: { rspack: { cache: false, optimization: { minimize: false } } }, +} +let entries = 0, + compilations = 0, + setups = 0 +if (atom) { + put( + root, + 'src/shared.jsx', + 'import {Box} from "@devup-ui/react"; export const Shared = () => ', + ) + put( + root, + 'src/entry.js', + 'import {Shared} from "./shared.jsx"; console.log(Shared)', + ) + if (scenario === 'atom') { + put( + root, + 'src/second.js', + 'import {Shared} from "./shared.jsx"; console.log(Shared)', + ) + config.source.entry.second = './src/second.js' + } +} else if (scenario === 'multi' || scenario === 'multi-root') { + put( + root, + 'outside-a/entry.js', + 'import {css} from "@devup-ui/react"; console.log(css({color:"olive"}))', + ) + put( + root, + 'outside-b/entry.js', + 'import {css} from "@devup-ui/react"; console.log(css({color:"coral"}))', + ) + config.environments = Object.fromEntries( + ['a', 'b'].map((name) => [ + name, + { + source: { + entry: { + main: + scenario === 'multi-root' + ? `./index-${name}.js` + : `./outside-${name}/entry.js`, + }, + }, + output: { distPath: { root: `out/${name}` } }, + }, + ]), + ) + if (scenario === 'multi-root') + for (const name of ['a', 'b']) + put(root, `index-${name}.js`, `import './outside-${name}/entry.js'`) +} else { + put( + root, + 'src/entry.js', + 'import {css} from "@devup-ui/react"; console.log(css({color:"teal"}))', + ) + put( + root, + 'external/entry.css.ts', + 'import {createVar} from "@vanilla-extract/css"; export const variable = createVar(); console.log(variable)', + ) + if (scenario === 'static') + config.source.entry.main = './external/entry.css.ts' + if (scenario === 'root') { + put( + root, + 'index.js', + 'import {css} from "@devup-ui/react"; import {variable} from "./external/entry.css.ts"; console.log(css({color:"red"}),variable,"import {css} from \'@devup-ui/react\'")', + ) + config.source.entry.main = './index.js' + } +} +config.plugins.push({ + name: 'native-observer', + setup(api) { + setups++ + api.modifyRspackConfig((rspackConfig) => { + if (scenario === 'opaque') + rspackConfig.entry = () => { + entries++ + return { main: { import: ['./external/entry.css.ts'] } } + } + rspackConfig.plugins.push({ + apply(compiler) { + compiler.hooks.thisCompilation.tap('native-observer', () => { + compilations++ + }) + }, + }) + }) + }, +}) + +async function main() { + const rsbuild = await createRsbuild({ cwd: root, config }) + let result + try { + result = await rsbuild.build() + assert(result.stats && !result.stats.hasErrors()) + const assets = Object.fromEntries( + (result.stats.stats || [result.stats]).flatMap((stats) => + stats.compilation + .getAssets() + .map((asset) => [ + join(stats.compilation.outputOptions.path, asset.name), + String(asset.source.source()), + ]), + ), + ) + writeFileSync( + receipt, + JSON.stringify({ + batches, + outputs, + events, + hoist, + css: wasm.getCss(null, false), + count: wasm.getUnreservedClassSlotCount(), + fileMap: wasm.exportFileMap(), + classMap: wasm.exportClassMap(), + assets, + entries, + compilations, + setups, + }), + ) + } finally { + if (result) await result.close() + } +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/rsbuild-plugin/src/__tests__/reservation-scenarios.test.ts b/packages/rsbuild-plugin/src/__tests__/reservation-scenarios.test.ts new file mode 100644 index 000000000..e494b53ee --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/reservation-scenarios.test.ts @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { record } from './reservation-receipt' + +const require = createRequire(import.meta.url) +const fixture: { + prepare(root: string): void +} = require('./reservation-fixture.cjs') + +function run(root: string, scenario: string) { + const receipt = join(root, 'result.json') + const child = spawnSync( + 'node', + [ + join(import.meta.dir, 'reservation-scenarios.cjs'), + root, + scenario, + receipt, + ], + { encoding: 'utf8', timeout: 60000 }, + ) + assert.equal(child.status, 0, child.stdout + child.stderr) + const result: unknown = JSON.parse(readFileSync(receipt, 'utf8')) + record(result) + assert( + Array.isArray(result.batches) && + Array.isArray(result.outputs) && + Array.isArray(result.events) && + Array.isArray(result.hoist), + ) + return { + count: result.count, + setups: result.setups, + css: result.css, + entries: result.entries, + compilations: result.compilations, + assets: result.assets, + classMap: result.classMap, + fileMap: result.fileMap, + batches: result.batches.map((batch: unknown) => batch), + outputs: result.outputs.map((output: unknown) => output), + events: result.events.map((event: unknown) => event), + hoist: result.hoist.map((threshold: unknown) => threshold), + } +} + +for (const scenario of ['atom', 'noop', 'multi', 'static', 'opaque']) { + it(`preserves native ${scenario} semantics with physical reservation`, () => { + // Given real Rsbuild inputs and public environment/native entry configuration. + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-rsbuild-scenario-')), + ) + try { + // When the installed Rsbuild/Rspack production compiler builds them. + const result = run(root, scenario) + // Then only genuinely outside-D9 opaque input has a residual slot. + expect(result.count).toBe(scenario === 'opaque' ? 1 : 0) + expect(result.events[0]).toBe('reserve') + expect(result.setups).toBe(1) + expect(result.batches).toHaveLength(1) + if (scenario === 'atom') { + expect(result.hoist).toEqual([2]) + expect(result.css).toContain('27px') + } + if (scenario === 'noop') expect(result.hoist).toEqual([]) + if (scenario === 'multi') { + expect(result.css).toContain('olive') + expect(result.css).toContain('coral') + expect(JSON.stringify(result.batches[0])).toContain('outside-a') + expect(JSON.stringify(result.batches[0])).toContain('outside-b') + } + if (scenario === 'static') + expect(JSON.stringify(result.batches[0])).toContain('external') + if (scenario === 'opaque') { + expect(result.entries).toBe(1) + expect(result.compilations).toBe(1) + expect(JSON.stringify(result.batches[0])).not.toContain('external') + expect(JSON.stringify(result.outputs)).toContain('--var-') + } + console.info( + JSON.stringify({ + scenario, + count: result.count, + batches: result.batches.length, + compilations: result.compilations, + }), + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, 120000) +} + +it('ignores cold/warm own output and stale unrelated output trees', () => { + // Given one source inventory, preserved own output, and stale generated sources. + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-rsbuild-warm-')), + ) + try { + const cold = run(root, 'scope') + const source = + 'import {css} from "@devup-ui/react"; console.log(css({color:"chartreuse"}))' + for (const directory of ['out', 'dist', '.next/server']) { + const file = join(root, directory, 'stale.js') + mkdirSync(join(root, directory), { recursive: true }) + writeFileSync(file, source) + } + // When a fresh native process builds the unchanged source. + const warm = run(root, 'scope') + // Then actual emitted assets, naming maps and inventory stay identical. + expect(warm.assets).toEqual(cold.assets) + expect(warm.classMap).toEqual(cold.classMap) + expect(warm.fileMap).toEqual(cold.fileMap) + expect(warm.batches).toEqual(cold.batches) + expect(warm.count).toBe(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 120000) + +it.each(['root', 'multi-root'])( + 'keeps exact static %s entries and closure independent of output/config/sibling trees', + (scenario) => { + // Given: actual root-level native entries and their physical import closure. + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-rsbuild-exact-')), + ) + try { + const cold = run(root, scenario) + const warm = run(root, scenario) + expect(warm.assets).toEqual(cold.assets) + expect(warm.batches).toEqual(cold.batches) + for (const directory of ['dist', '.next/server', 'unused']) { + mkdirSync(join(root, directory), { recursive: true }) + writeFileSync( + join(root, directory, 'stale.js'), + 'import {css} from "@devup-ui/react"; console.log(css({color:"chartreuse"}))', + ) + } + writeFileSync( + join(root, 'rsbuild.config.js'), + 'import {css} from "@devup-ui/react"; export default css({margin:"99px"})', + ) + // When: fresh real compilers rebuild the same known entry union. + const stale = run(root, scenario) + // Then: actual output/allocators and all discovery/extraction work remain equal. + expect(stale.assets).toEqual(cold.assets) + expect(stale.css).toEqual(cold.css) + expect(stale.fileMap).toEqual(cold.fileMap) + expect(stale.classMap).toEqual(cold.classMap) + expect(stale.batches).toEqual(cold.batches) + expect( + stale.outputs.map((output) => JSON.stringify(output)).sort(), + ).toEqual(cold.outputs.map((output) => JSON.stringify(output)).sort()) + expect(stale.events).toEqual(cold.events) + expect(stale.count).toBe(0) + expect(stale.batches).toHaveLength(1) + if (scenario === 'multi-root') { + expect(JSON.stringify(stale.batches[0])).toContain('outside-a') + expect(JSON.stringify(stale.batches[0])).toContain('outside-b') + } else { + expect(JSON.stringify(stale.batches[0])).toContain('entry.css.ts') + } + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 180000, +) + +it('reports a reachable extraction error only through the real native transform', () => { + // Given the same invalid source that is harmless while unreachable. + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-rsbuild-error-')), + ) + try { + fixture.prepare(root) + // When native Rspack actually loads that source. + const child = spawnSync( + 'node', + [ + join(import.meta.dir, 'reservation-native.cjs'), + root, + 'AB', + 'error', + join(root, 'error.json'), + ], + { encoding: 'utf8', timeout: 60000 }, + ) + // Then its original located build-time diagnostic is published. + expect(child.status).toBe(1) + expect(child.stdout + child.stderr).toContain('invalid.js:1:') + expect(child.stdout + child.stderr).toContain('cannot use') + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 120000) diff --git a/packages/rsbuild-plugin/src/__tests__/reservation-watch.cjs b/packages/rsbuild-plugin/src/__tests__/reservation-watch.cjs new file mode 100644 index 000000000..489ede137 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/reservation-watch.cjs @@ -0,0 +1,35 @@ +const { addPair, put } = require('./reservation-fixture.cjs') + +async function runWatch(compiler, state) { + let complete + const nextCompile = () => + new Promise((resolve, reject) => { + complete = { resolve, reject } + }) + const first = nextCompile() + const watching = compiler.watch({ poll: 1000 }, (error, stats) => { + error || stats.hasErrors() + ? complete.reject(error || new Error(stats.toString())) + : complete.resolve(stats) + }) + try { + await first + watching.suspend() + addPair(state.root, state.order) + state.revise() + put(state.root, 'src/constants.js', 'export const tone = "crimson"') + const next = nextCompile() + watching.resume() + watching.invalidate() + await next + const supplier = nextCompile() + put(state.root, 'src/constants.js', 'export const tone = "darkmagenta"') + const stats = await supplier + state.events.push('supplier-revision-complete') + return stats + } finally { + await new Promise((resolve) => watching.close(resolve)) + } +} + +module.exports = { runWatch } diff --git a/packages/rsbuild-plugin/src/__tests__/reservation.test.ts b/packages/rsbuild-plugin/src/__tests__/reservation.test.ts new file mode 100644 index 000000000..323760fbf --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/reservation.test.ts @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict' +import { dirname, relative, resolve } from 'node:path' + +import * as utils from '@devup-ui/plugin-utils' +import { expect, it, mock, spyOn } from 'bun:test' + +import { createReservation, staticEntries } from '../reservation' + +it('collects only static entry requests without executing opaque functions', () => { + // Given the public Rspack entry variants. + const opaque = mock(() => { + throw new Error('must stay opaque') + }) + const entries: Parameters[0][] = [ + undefined, + opaque, + './entry.js', + ['./a.js'], + { + a: './a.js', + b: ['./b.js'], + c: { import: './c.js' }, + d: { import: ['./d.js'] }, + e: { import: [] }, + }, + { f: { runtime: false as const } }, + ] + // When the adapter inspects them. + const result = entries.map(staticEntries) + // Then native function evaluation remains exclusively native. + expect(result).toEqual([ + [], + [], + ['./entry.js'], + ['./a.js'], + ['./a.js', './b.js', './c.js', './d.js'], + [], + ]) + expect(opaque).not.toHaveBeenCalled() +}) + +it.each([false, true])( + 'preserves exact reservation IDs and flags in atomMode=%s', + (atomMode) => { + // Given one manager shared by known environment roots. + const reserve = mock( + ( + discovery: utils.ReservationDiscoveryOptions, + batch: utils.ReservationBatchOptions, + ) => { + assert(discovery.toId) + expect(discovery.toId('C:\\project\\app\\A.js')).toBe( + atomMode ? 'C:/project/app/A.js' : 'C:\\project\\app\\A.js', + ) + expect(new Set(discovery.roots)).toEqual( + new Set([ + resolve('src'), + resolve('app'), + resolve('other/src'), + resolve('other/app'), + ]), + ) + expect(discovery.entries).toEqual([ + { request: './a.js', context: resolve('external') }, + ]) + expect(discovery).toMatchObject({ + cwd: process.cwd(), + include: ['included-ui'], + package: '@devup-ui/react', + importAliases: {}, + }) + const physicalPath = resolve('external/a.js') + expect( + batch.cssDir({ physicalPath, filename: physicalPath, code: '' }), + ).toBe( + `./${relative(dirname(physicalPath), resolve('df')).replaceAll('\\', '/')}`, + ) + expect(batch).toMatchObject({ + singleCss: true, + importMainCssInCode: atomMode, + importMainCssInCss: !atomMode, + }) + return true + }, + ) + const invalidate = mock() + const create = spyOn(utils, 'createSharedSlotReservation').mockReturnValue({ + reserve, + invalidate, + }) + try { + const reservation = createReservation({ + root: resolve('other'), + atomMode, + package: '@devup-ui/react', + include: ['included-ui'], + cssDir: resolve('df'), + singleCss: true, + importAliases: {}, + }) + reservation.trackCompiler('web', { + options: { entry: './a.js', context: resolve('external') }, + }) + // When native compilation commits the whole inventory. + reservation.invalidate() + reservation.reserve() + // Then the same manager receives the exact real extraction contract. + expect(create).toHaveBeenCalledTimes(1) + expect(invalidate).toHaveBeenCalledTimes(1) + expect(reserve).toHaveBeenCalledTimes(1) + } finally { + create.mockRestore() + } + }, +) + +it('replaces compiler revisions while reading the current union of all owners', () => { + // Given two known environments and an obsolete compiler for one owner. + const reserve = mock< + ( + discovery: utils.ReservationDiscoveryOptions, + batch: utils.ReservationBatchOptions, + ) => boolean + >(() => true) + const create = spyOn(utils, 'createSharedSlotReservation').mockReturnValue({ + reserve, + invalidate: mock(), + }) + try { + const reservation = createReservation({ + root: resolve('project'), + atomMode: false, + package: '@devup-ui/react', + include: [], + cssDir: resolve('df'), + singleCss: true, + importAliases: {}, + }) + const web: { options: { entry: string; context?: string } } = { + options: { entry: './early.js' }, + } + reservation.trackCompiler('web', web) + reservation.trackCompiler('node', { + options: { entry: { main: { import: ['./node.js'] } } }, + }) + reservation.reserve() + web.options = { entry: './final.js', context: resolve('external') } + // When a later compiler boundary reserves changed options without re-registering. + reservation.reserve() + // Then the current union replaces the obsolete snapshot, including its context. + expect(reserve.mock.calls[1]?.[0].entries).toEqual([ + { request: './final.js', context: resolve('external') }, + { request: './node.js', context: resolve('project') }, + ]) + reservation.trackCompiler('web', { options: { entry: './replacement.js' } }) + reservation.reserve() + expect(reserve.mock.calls[2]?.[0].entries).toEqual([ + { request: './replacement.js', context: resolve('project') }, + { request: './node.js', context: resolve('project') }, + ]) + const opaque = mock(() => ({ main: ['./opaque.js'] })) + reservation.trackCompiler('web', { options: { entry: opaque } }) + reservation.reserve() + expect(reserve.mock.calls[3]?.[0].entries).toEqual([ + { request: './node.js', context: resolve('project') }, + ]) + expect(opaque).not.toHaveBeenCalled() + } finally { + create.mockRestore() + } +}) diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 75e31c0b4..ca57a479d 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -5,7 +5,6 @@ import { basename, dirname, join, relative, resolve } from 'node:path' import { beginBuild, buildCanonicalMap, - collectNumberedFiles, computeFileReach, computeReachableFiles, createCompatTypes, @@ -14,13 +13,11 @@ import { createStateWriter, createThemeInterfaceArgs, type CustomShorthands, - extractedNeedles, getFileNumByFilename, type ImportAliases, loadDevupConfig, mergeImportAliases, planAtomHoist, - seedFileNumbers, tailwindCssFiles, withTailwindCss, } from '@devup-ui/plugin-utils' @@ -34,7 +31,6 @@ import { registerShorthands, registerTheme, resetBuildState, - seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -42,6 +38,8 @@ import { } from '@devup-ui/wasm' import type { RsbuildPlugin, Rspack } from '@rsbuild/core' +import { createReservation, staticEntries } from './reservation' + const PLUGIN_NAME = 'devup-ui-rsbuild-plugin' export interface DevupUIRsbuildPluginOptions { @@ -56,12 +54,14 @@ export interface DevupUIRsbuildPluginOptions { prefix?: string shorthands?: CustomShorthands /** - * Atom-level route-aware hoisting threshold (min routes sharing an atom for it - * to hoist into the shared devup-ui.css; clamped to >= 2; omit to disable). - * Opt-in: when set, single-importer collapse + atom hoisting are enabled and - * per-route CSS is served via getCss(fileNum). "Routes" are inferred from the - * import graph (entry points and dynamic-import targets). For a single-entry - * SPA (routeCount < 2) it is a no-op. + * Atom-level route-aware hoisting threshold. Defaults to 2; 0 disables it. + * Positive thresholds are clamped to >= 2. Canonical buckets reached by at + * least this many routes use global short counter names in devup-ui.css; + * other buckets keep per-file counter names. Composes with single-importer + * collapse and serves per-route CSS via getCss(fileNum). Routes are inferred + * from entry points and dynamic-import targets. Fewer than two routes yields + * no hoisted plan. Ignored when singleCss is true, including IDs/import flags + * and shared-chunk configuration. * * On MPA, the shared base devup-ui.css (hoisted atoms) is emitted as ONE * shared chunk via an injected rspack `splitChunks` cacheGroup @@ -137,7 +137,7 @@ export const DevupUI = ({ singleCss = false, prefix, shorthands, - atomHoist, + atomHoist = 2, importAliases: userImportAliases, }: Partial = {}): RsbuildPlugin => { registerShorthands(shorthands ?? {}) @@ -215,14 +215,15 @@ export const DevupUI = ({ }) }) - // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any - // transform so atoms receive global (shared) class names. Composes with + // Per-file atom hoisting defaults to 2; 0 disables it, singleCss ignores it. + // Before any transform, routes are imported, then the threshold is set; + // bucket eligibility freezes at the first extraction. Late reach cannot promote + // or rename a bucket until resetBuildState. Composes with // single-importer collapse (both keyed by the canonical bucket). rsbuild // passes the ABSOLUTE resourcePath to codeExtract, so the graph maps use // absolute keys (keyBy: 'absolute') and the extraction filename is // POSIX-normalized to match. - const atomMode = - atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 + const atomMode = !singleCss && Number.isFinite(atomHoist) && atomHoist > 0 setModuleResolver( createModuleResolver({ toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), @@ -252,7 +253,7 @@ export const DevupUI = ({ setAtomHoist(plan.threshold) } else { console.info( - '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + '[devup-ui] fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', ) } } catch { @@ -260,40 +261,27 @@ export const DevupUI = ({ } } - try { - // Number every file the build can extract in path order, so class - // prefixes do not depend on the order modules reach the transform - seedFileNumbers( - { seedFileMap }, - collectNumberedFiles({ - roots: [resolve(process.cwd(), 'src')], - include, - needles: extractedNeedles(libPackage, importAliases), - toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), - }), - ) - } catch { - // Best-effort; numbering falls back to arrival order. - } + const reservation = createReservation({ + root: api.context.rootPath, + package: libPackage, + include, + cssDir, + singleCss, + atomMode, + importAliases, + }) + const entryFiles = new Set() // Extract the source files under `src` that the entries reach, in path // order, the same way the transform does, so that a stylesheet built on // its first import already holds the styles of every one. Best-effort: // a stylesheet still missing styles is rebuilt by another pass. - api.onBeforeBuild(({ environments }) => { + const prewarm = () => { try { const root = api.context.rootPath - const entries = Object.values(environments).flatMap(({ entry }) => - Object.values(entry).flatMap((value) => - (typeof value === 'object' && !Array.isArray(value) - ? [value.import].flat() - : [value].flat() - ).map((request) => resolve(root, request)), - ), - ) for (const file of computeReachableFiles({ srcDir: resolve(root, 'src'), tsconfigPath: resolve(root, 'tsconfig.json'), - entries, + entries: [...entryFiles], })) { let extractCssDir = relative(dirname(file), cssDir).replaceAll( '\\', @@ -315,6 +303,14 @@ export const DevupUI = ({ } catch { // The transform reports the error of the file it cannot extract } + } + api.onBeforeBuild(({ environments }) => { + for (const { entry } of Object.values(environments)) { + for (const request of staticEntries(entry)) + entryFiles.add(resolve(api.context.rootPath, request)) + } + reservation.reserve() + prewarm() }) const servedCss = new Map>() @@ -341,12 +337,20 @@ export const DevupUI = ({ // once more: every module is extracted by then. The dev server rebuilds it // through the stylesheet files the transforms write instead. api.modifyRspackConfig((config, { environment }) => { + for (const request of staticEntries(environment.entry)) + entryFiles.add(resolve(api.context.rootPath, request)) config.plugins ??= [] config.plugins.push({ apply(compiler: Rspack.Compiler) { + reservation.trackCompiler(environment.name, compiler) let passes = 0 compiler.hooks.run.tap(PLUGIN_NAME, () => { passes = 0 + reservation.reserve() + }) + compiler.hooks.invalid.tap(PLUGIN_NAME, reservation.invalidate) + compiler.hooks.watchRun.tap(PLUGIN_NAME, () => { + reservation.reserve() }) compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { const served = new Map() diff --git a/packages/rsbuild-plugin/src/reservation.ts b/packages/rsbuild-plugin/src/reservation.ts new file mode 100644 index 000000000..829d04abb --- /dev/null +++ b/packages/rsbuild-plugin/src/reservation.ts @@ -0,0 +1,86 @@ +import { dirname, relative, resolve } from 'node:path' + +import { + createSharedSlotReservation, + type WasmImportAliases, +} from '@devup-ui/plugin-utils' +import { reserveClassSlots } from '@devup-ui/wasm' +import type { Rspack } from '@rsbuild/core' + +import type { DevupUIRsbuildPluginOptions } from './plugin' + +type CompilerEntry = + Rspack.Configuration['entry'] | Rspack.Compiler['options']['entry'] + +type EntryConfig = { + readonly entry?: CompilerEntry + readonly context?: string | undefined +} + +export function staticEntries(entry: CompilerEntry): string[] { + if (typeof entry === 'function' || entry === undefined) return [] + if (typeof entry === 'string') return [entry] + if (Array.isArray(entry)) return entry + return Object.values(entry).flatMap((value) => + typeof value === 'object' && !Array.isArray(value) + ? [value.import ?? []].flat() + : [value].flat(), + ) +} + +export function createReservation( + options: Pick< + DevupUIRsbuildPluginOptions, + 'package' | 'include' | 'cssDir' | 'singleCss' + > & { + readonly root: string + readonly atomMode: boolean + readonly importAliases: WasmImportAliases + }, +) { + const slots = createSharedSlotReservation({ reserveClassSlots }) + const roots = new Set( + [process.cwd(), options.root].flatMap((root) => + ['src', 'app'].map((name) => resolve(root, name)), + ), + ) + const compilers = new Map() + return { + trackCompiler(owner: string, compiler: { readonly options: EntryConfig }) { + compilers.set(owner, compiler) + }, + invalidate: slots.invalidate, + reserve() { + return slots.reserve( + { + roots: [...roots], + entries: [...compilers.values()].flatMap(({ options: config }) => + staticEntries(config.entry).map((request) => ({ + request, + context: config.context ?? options.root, + })), + ), + include: options.include, + cwd: process.cwd(), + tsconfigPath: resolve(process.cwd(), 'tsconfig.json'), + package: options.package, + importAliases: options.importAliases, + toId: (path) => + options.atomMode ? path.replaceAll('\\', '/') : path, + }, + { + singleCss: options.singleCss, + importMainCssInCode: options.atomMode, + importMainCssInCss: !options.atomMode, + cssDir: ({ physicalPath }) => { + const directory = relative( + dirname(physicalPath), + options.cssDir, + ).replaceAll('\\', '/') + return directory.startsWith('./') ? directory : `./${directory}` + }, + }, + ) + }, + } +} diff --git a/packages/vite-plugin/src/__tests__/atom-hoist.test.ts b/packages/vite-plugin/src/__tests__/atom-hoist.test.ts new file mode 100644 index 000000000..624aadd6b --- /dev/null +++ b/packages/vite-plugin/src/__tests__/atom-hoist.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from 'bun:test' + +import { + builtPluginEntry, + runAtomHoistIntegration, +} from '../../../next-plugin/src/__tests__/atom-hoist-harness' + +it('emits real per-file fallback JSX atoms and private CSS in either transform order and fresh builds', () => { + // Given: two explicit entries reach a shared styled module. + const setup = ` +const { DevupUI } = await import(${builtPluginEntry('vite-plugin')}); +const [plugin, restorePlugin] = DevupUI({ atomHoist: 2, cssDir, distDir: join(root, 'df') }); +await plugin.configResolved({ root, command: 'build', build: { + rollupOptions: { input: [join(root, files[1]), join(root, files[2])] }, +} }); +const context = { addWatchFile() {} }; +const transform = async (file, code) => { + const result = await plugin.transform.call(context, code, join(root, file).replaceAll('\\\\', '/')); + return result.code; +}; +const loadCss = name => plugin.load(join(cssDir, name)); +const finish = async () => { + const asset = { type: 'asset', name: 'devup-ui.css', source: 'stale', fileName: 'shared.css' }; + await plugin.generateBundle.call({}, {}, { 'shared.css': asset }); + restorePlugin.generateBundle.handler({}, { 'shared.css': asset }); + assert.equal(asset.source, await loadCss('devup-ui.css'), 'emitted asset must contain the finished shared sheet'); + plugin.closeBundle(); +}; +` + // When: real configResolved, transform, load, and generateBundle hooks run. + const [forward, backward, rebuilt] = runAtomHoistIntegration( + setup, + 'per-file', + ) + // Then: disk writes consume shared dirtiness and all loaded classes agree. + expect(backward).toBe(forward) + expect(rebuilt).toBe(forward) +}, 120_000) diff --git a/packages/vite-plugin/src/__tests__/availability.test.ts b/packages/vite-plugin/src/__tests__/availability.test.ts new file mode 100644 index 000000000..80f02706f --- /dev/null +++ b/packages/vite-plugin/src/__tests__/availability.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from 'bun:test' + +import { runAvailabilityIntegration } from '../../../next-plugin/src/__tests__/availability-harness' + +it('W66 availability: keeps distinct shared buckets byte-stable through Vite hooks in AB/BA/BA/AB', () => { + // Given: two physical shared suppliers, both reached by two private entries. + // When: actual built adapter callbacks run in fresh processes at one fixed root. + const [ab, ba, baAgain, abAgain] = runAvailabilityIntegration('vite') + // Then: complete compiled JS/maps and loaded/emitted CSS are byte-identical. + expect(ba).toEqual(ab) + expect(baAgain).toEqual(ab) + expect(abAgain).toEqual(ab) +}, 200_000) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index b3108f15f..51baddff9 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -1858,13 +1858,145 @@ describe('devupUIVitePlugin atom hoisting', () => { setAtomHoistSpy.mockRestore() }) - it('does nothing when atomHoist is unset', async () => { - await runConfigResolved({}, { root: '/p' }) + it('does nothing when atomHoist is explicitly zero', async () => { + await runConfigResolved({ atomHoist: 0 }, { root: '/p' }) expect(buildCanonicalMapSpy).not.toHaveBeenCalled() expect(setAtomHoistSpy).not.toHaveBeenCalled() }) - it('composes collapse + hoist and folds reach onto the canonical bucket', async () => { + describe('W66 default regressions', () => { + beforeEach(() => { + computeFileReachSpy.mockReturnValue({ + '/p/src/shared.tsx': [0, 1], + '/p/src/a.tsx': [0], + '/p/src/b.tsx': [1], + }) + }) + + it.each([undefined, 0, -1, NaN, Infinity, -Infinity, 0.5, 1, 2, 3])( + 'W66 availability: keeps per-file mode across environments for request %s', + async (atomHoist) => { + // Given: the same plugin is shared by Vite environment builds. + const warningSpy = spyOn(console, 'warn').mockImplementation(() => {}) + const plugin = createPlugin({ atomHoist }) + try { + // When: configuration and both environment transforms run. + await plugin.configResolved({ root: '/p', command: 'build' }) + for (const consumer of ['client', 'server'] as const) { + await plugin.transform.call( + { environment: { name: consumer, config: { consumer } } }, + 'code', + '/p/src/a.tsx', + ) + } + // Then: no atom-only pre-pass or repeated warning is possible. + expect(buildCanonicalMapSpy).not.toHaveBeenCalled() + expect(computeFileReachSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() + expect(warningSpy).toHaveBeenCalledTimes( + atomHoist !== undefined && + Number.isFinite(atomHoist) && + atomHoist > 0 + ? 1 + : 0, + ) + } finally { + plugin.closeBundle() + warningSpy.mockRestore() + } + }, + ) + + it.each([ + ['omitted', {}], + ['undefined', { atomHoist: undefined }], + ])( + 'keeps omitted atomHoist %s silent without consulting route reach', + async (_label, options) => { + const warningSpy = spyOn(console, 'warn').mockImplementation(() => {}) + try { + await runConfigResolved(options, { root: '/p' }) + expect(computeFileReachSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() + expect(warningSpy).not.toHaveBeenCalled() + } finally { + warningSpy.mockRestore() + } + }, + ) + + it.each([0, -1, NaN, Infinity, -Infinity])( + 'keeps legacy OFF for atomHoist %s', + async (atomHoist) => { + // Given: two routes, not a single-entry fallback. + // When: an explicitly disabled numeric option is supplied. + await runConfigResolved({ atomHoist }, { root: '/p' }) + // Then: the atom-only pre-pass has no effects. + expect(buildCanonicalMapSpy).not.toHaveBeenCalled() + expect(computeFileReachSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() + }, + ) + + it('defers a custom threshold above the minimum until reservation lands', async () => { + // Given: two routes and a custom threshold. + // When: config resolves. + await runConfigResolved({ atomHoist: 3 }, { root: '/p' }) + // Then: unavailable atom hoisting never installs a threshold. + expect(setAtomHoistSpy).not.toHaveBeenCalled() + expect(computeFileReachSpy).not.toHaveBeenCalled() + }) + + it.each([ + ['omitted', {}], + ['undefined', { atomHoist: undefined }], + ['enabled', { atomHoist: 2 }], + ])( + 'ignores %s atomHoist before planning in actual singleCss mode', + async (_label, options) => { + // Given: the real config/transform hooks under explicit OFF. + const off = createPlugin({ singleCss: true, atomHoist: 0 }) + await off.configResolved({ root: '/p' }) + const offConfig = callConfig(off, ROLLUP_META) + await off.transform('code', '/p/src/a.tsx') + const offExtractions = [...codeExtractSpy.mock.calls] + codeExtractSpy.mockClear() + // When: only the requested atom policy changes. + const on = createPlugin({ singleCss: true, ...options }) + await on.configResolved({ root: '/p' }) + await on.transform('code', '/p/src/a.tsx') + const onConfig = callConfig(on, ROLLUP_META) + // Then: no canonical/reach mutation and identical extraction IDs/import flags. + expect(buildCanonicalMapSpy).not.toHaveBeenCalled() + expect(importCanonicalMapSpy).not.toHaveBeenCalled() + expect(computeFileReachSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() + expect(codeExtractSpy.mock.calls).toEqual(offExtractions) + expect(codeExtractSpy).toHaveBeenCalledWith( + '/p/src/a.tsx', + 'code', + '@devup-ui/react', + expect.any(String), + true, + true, + false, + expect.anything(), + ) + const cssId = resolve('df', 'devup-ui', 'devup-ui.css') + expect( + onConfig.build?.rollupOptions?.output?.manualChunks?.(cssId, ''), + ).toBe( + offConfig.build?.rollupOptions?.output?.manualChunks?.(cssId, ''), + ) + }, + ) + }) + + it('defers atom-only collapse and bucket folding until reservation lands', async () => { buildCanonicalMapSpy.mockReturnValue({ '/p/src/child.tsx': '/p/src/parent.tsx', '/p/src/glob.tsx': '@global', @@ -1879,23 +2011,17 @@ describe('devupUIVitePlugin atom hoisting', () => { { atomHoist: 2 }, { root: '/p', build: { rollupOptions: { input: { a: 'src/a.tsx' } } } }, ) - // collapse runs (composition) with absolute keys - expect(buildCanonicalMapSpy).toHaveBeenCalledWith( - expect.objectContaining({ keyBy: 'absolute' }), - ) - expect(importCanonicalMapSpy).toHaveBeenCalled() - // reach folded by bucket: child -> parent, @global skipped - expect(importFileRoutesSpy).toHaveBeenCalledWith({ - '/p/src/parent.tsx': [0, 1], - '/p/src/r1.tsx': [1], - }) - expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + expect(buildCanonicalMapSpy).not.toHaveBeenCalled() + expect(importCanonicalMapSpy).not.toHaveBeenCalled() + expect(computeFileReachSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() }) it.each([ ['app', 'app'], ['src', 'src'], - ])('scans %s/ when that is where the sources live', async (_name, dir) => { + ])('defers atom reach in %s/ layouts', async (_name, dir) => { existsSyncSpy.mockImplementation( (path: string) => path === resolve('/p', dir), ) @@ -1906,12 +2032,10 @@ describe('devupUIVitePlugin atom hoisting', () => { await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) - expect(computeFileReachSpy).toHaveBeenCalledWith( - expect.objectContaining({ srcDir: resolve('/p', dir) }), - ) + expect(computeFileReachSpy).not.toHaveBeenCalled() }) - it('falls back to src/ when neither conventional dir exists', async () => { + it('does not infer atom reach when neither conventional dir exists', async () => { existsSyncSpy.mockReturnValue(false) computeFileReachSpy.mockReturnValue({ '/p/a.tsx': [0], @@ -1920,18 +2044,17 @@ describe('devupUIVitePlugin atom hoisting', () => { await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) - expect(computeFileReachSpy).toHaveBeenCalledWith( - expect.objectContaining({ srcDir: resolve('/p', 'src') }), - ) + expect(computeFileReachSpy).not.toHaveBeenCalled() }) - it('clamps the threshold to a minimum of 2', async () => { + it('defers even the former minimum-clamped threshold', async () => { computeFileReachSpy.mockReturnValue({ '/p/src/a.tsx': [0], '/p/src/b.tsx': [1], }) await runConfigResolved({ atomHoist: 1 }, { root: '/p' }) - expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + expect(setAtomHoistSpy).not.toHaveBeenCalled() + expect(computeFileReachSpy).not.toHaveBeenCalled() }) it('stays off when fewer than two routes are reachable', async () => { @@ -1940,7 +2063,7 @@ describe('devupUIVitePlugin atom hoisting', () => { expect(setAtomHoistSpy).not.toHaveBeenCalled() }) - it('falls back to the heuristic when input has no JS entries', async () => { + it('does not infer atom reach from HTML entries before PR B', async () => { computeFileReachSpy.mockReturnValue({ '/p/src/a.tsx': [0], '/p/src/b.tsx': [1], @@ -1949,15 +2072,11 @@ describe('devupUIVitePlugin atom hoisting', () => { { atomHoist: 2 }, { root: '/p', build: { rollupOptions: { input: 'index.html' } } }, ) - // html-only input => entries override omitted => computeFileReach called - // without an explicit entries list - expect(computeFileReachSpy).toHaveBeenCalledWith( - expect.objectContaining({ entries: undefined }), - ) - expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + expect(computeFileReachSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() }) - it('accepts array and string JS entries', async () => { + it('keeps atom reach deferred for array and string JS entries', async () => { computeFileReachSpy.mockReturnValue({ '/p/src/a.tsx': [0], '/p/src/b.tsx': [1], @@ -1970,24 +2089,27 @@ describe('devupUIVitePlugin atom hoisting', () => { { atomHoist: 2 }, { root: '/p', build: { rollupOptions: { input: 'src/a.tsx' } } }, ) - expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + expect(computeFileReachSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() }) - it('swallows pre-pass errors (atom hoisting stays off)', async () => { + it('never reaches the unavailable atom pre-pass even if it would fail', async () => { buildCanonicalMapSpy.mockImplementation(() => { throw new Error('boom') }) await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) + expect(buildCanonicalMapSpy).not.toHaveBeenCalled() expect(setAtomHoistSpy).not.toHaveBeenCalled() }) - it('uses process.cwd() when config.root is absent', async () => { + it('keeps atom reach deferred when config.root is absent', async () => { computeFileReachSpy.mockReturnValue({ '/p/src/a.tsx': [0], '/p/src/b.tsx': [1], }) await runConfigResolved({ atomHoist: 2 }, {}) - expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + expect(computeFileReachSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() }) }) diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 008a0e201..e56939930 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -4,9 +4,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path' import { beginBuild, - buildCanonicalMap, collectNumberedFiles, - computeFileReach, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, @@ -18,7 +16,6 @@ import { type ImportAliases, loadDevupConfig, mergeImportAliases, - planAtomHoist, seedFileNumbers, tailwindCssFiles, withTailwindCss, @@ -28,13 +25,10 @@ import { getCss, getDefaultTheme, getThemeInterface, - importCanonicalMap, - importFileRoutes, registerShorthands, registerTheme, resetBuildState, seedFileMap, - setAtomHoist, setDebug, setModuleResolver, setPrefix, @@ -242,11 +236,9 @@ export interface DevupUIPluginOptions { prefix?: string shorthands?: CustomShorthands /** - * Atom-level route-aware hoisting threshold (min routes sharing an atom for - * it to hoist into the shared devup-ui.css; clamped to >= 2; omit to disable). - * Opt-in: when set, single-importer collapse + atom hoisting are enabled for - * this build. "Routes" are inferred from the import graph (entry points and - * dynamic-import targets). + * Reserved for route-aware atom hoisting after PR B adds shared class-slot + * reservation. Until then, Vite keeps per-file names and warns + * once for explicit finite positive requests. Ignored in singleCss mode. */ atomHoist?: number /** @@ -311,6 +303,16 @@ export function DevupUI({ // process left in the engine (prefix, hoisting, routes, buckets, numbers, // styles) is gone unless another build is still running. const endBuild = beginBuild({ resetBuildState }) + if ( + !singleCss && + atomHoist !== undefined && + Number.isFinite(atomHoist) && + atomHoist > 0 + ) { + console.warn( + '[devup-ui] Vite atomHoist is unavailable until shared class-slot reservation lands in PR B; keeping per-file names, while existing shared slots remain arrival-ordered.', + ) + } registerShorthands(shorthands ?? {}) setDebug(debug) setPrefix(prefix ?? null) @@ -374,64 +376,6 @@ export function DevupUI({ singleCss, }) - // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any - // transform so atoms receive global (shared) class names. Composes with - // single-importer collapse: both are keyed by the canonical bucket. Vite - // passes the ABSOLUTE module id to codeExtract, so the graph maps use - // absolute keys (keyBy: 'absolute') to match the engine's bucket keys. - const atomMode = - atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 - if (atomMode) { - try { - const root = projectRoot - // App Router projects keep their sources in `app/`, so a hardcoded - // `src/` made the whole pre-pass a silent no-op for them. - const srcDir = sourceDirs[0] ?? resolve(root, 'src') - const tsconfigPath = resolve(root, 'tsconfig.json') - // C: prefer the bundler's real JS entries; fall back to the heuristic - // (files with no importer) when input is html-only / unavailable. - const input = config.build?.rollupOptions?.input - const rawEntries = - typeof input === 'string' - ? [input] - : Array.isArray(input) - ? input - : input && typeof input === 'object' - ? Object.values(input) - : [] - const entries = rawEntries - .filter((e): e is string => typeof e === 'string') - .filter((e) => /\.(tsx|ts|jsx|js|mjs)$/i.test(e)) - .map((e) => resolve(root, e)) - - const canonicalMap = buildCanonicalMap({ - srcDir, - tsconfigPath, - cwd: root, - keyBy: 'absolute', - }) - importCanonicalMap(canonicalMap) - - const fileReach = computeFileReach({ - srcDir, - tsconfigPath, - cwd: root, - keyBy: 'absolute', - entries: entries.length > 0 ? entries : undefined, - }) - const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) - if (plan) { - importFileRoutes(plan.reachByBucket) - setAtomHoist(plan.threshold) - } else { - console.info( - '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', - ) - } - } catch { - // Best-effort; on failure atom hoisting stays off (identity). - } - } try { // Numbers come from the sorted paths of every file the build can // extract (source and included packages), not from arrival order. diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 29a8095c0..a032e8f6f 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict' import type { Stats } from 'node:fs' import * as fs from 'node:fs' import * as fsPromises from 'node:fs/promises' @@ -87,8 +88,16 @@ let readFileSpy: ReturnType let statSpy: ReturnType let writeFileSpy: ReturnType let consoleErrorSpy: ReturnType +let reservationSpy: ReturnType beforeEach(() => { + reservationSpy = spyOn( + pluginUtils, + 'createSharedSlotReservation', + ).mockReturnValue({ + invalidate: mock(), + reserve: mock().mockReturnValue(true), + }) codeExtractSpy = spyOn(wasm, 'codeExtract').mockImplementation( (_path: string, contents: string) => createCodeExtractResult(contents), ) @@ -117,6 +126,7 @@ beforeEach(() => { }) afterEach(() => { + reservationSpy.mockRestore() codeExtractSpy.mockRestore() getCssSpy.mockRestore() getDefaultThemeSpy.mockRestore() @@ -189,7 +199,7 @@ describe('devupUIWebpackPlugin', () => { return ['src/a.tsx', 'src/b.tsx'] }) const compiler = createCompiler() - new DevupUIWebpackPlugin({ include: ['@acme/ui'] }).apply( + new DevupUIWebpackPlugin({ include: ['@acme/ui'], debug: true }).apply( asCompiler(compiler), ) expect(seedSpy).toHaveBeenCalledWith(['src/a.tsx', 'src/b.tsx']) @@ -202,7 +212,9 @@ describe('devupUIWebpackPlugin', () => { collectSpy.mockImplementation(() => { throw new Error('scan boom') }) - new DevupUIWebpackPlugin({}).apply(asCompiler(createCompiler())) + new DevupUIWebpackPlugin({ debug: true }).apply( + asCompiler(createCompiler()), + ) } finally { collectSpy.mockRestore() seedSpy.mockRestore() @@ -210,7 +222,7 @@ describe('devupUIWebpackPlugin', () => { } }) }) - it('should apply default options', () => { + it('W66 default regressions: should apply default options', () => { expect(new DevupUIWebpackPlugin({}).options).toEqual({ include: [], package: '@devup-ui/react', @@ -220,9 +232,22 @@ describe('devupUIWebpackPlugin', () => { watch: false, debug: false, singleCss: false, + atomHoist: 2, }) }) + it('also reserves at native watchRun when watch persistence is disabled', async () => { + const manager = { + invalidate: mock(), + reserve: mock().mockReturnValue(true), + } + reservationSpy.mockReturnValue(manager) + const compiler = createCompiler() + new DevupUIWebpackPlugin({}).apply(asCompiler(compiler)) + await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]() + expect(manager.reserve).toHaveBeenCalledTimes(1) + }) + describe.each( createTestMatrix({ watch: [true, false], @@ -236,7 +261,10 @@ describe('devupUIWebpackPlugin', () => { }), )('options', (options) => { it('should apply options', () => { - expect(new DevupUIWebpackPlugin(options).options).toEqual(options) + expect(new DevupUIWebpackPlugin(options).options).toEqual({ + ...options, + atomHoist: 2, + }) }) it.each( @@ -325,9 +353,13 @@ describe('devupUIWebpackPlugin', () => { await plugin.apply(asCompiler(compiler)) expect(compiler.options.module.rules.length).toBe(2) - expect(compiler.options.module.rules[0].exclude).toEqual( - options.include.output, + const sourceRule = compiler.options.module.rules[0] + assert( + typeof sourceRule === 'object' && + sourceRule !== null && + 'exclude' in sourceRule, ) + expect(sourceRule.exclude).toEqual(options.include.output) }) it.each( @@ -520,7 +552,7 @@ describe('devupUIWebpackPlugin', () => { } else { expect(statSpy).not.toHaveBeenCalled() } - } else expect(compiler.hooks.watchRun.tapPromise).not.toHaveBeenCalled() + } else expect(compiler.hooks.watchRun.tapPromise).toHaveBeenCalledTimes(1) if (options.existsDevupFile) { expect(compiler.hooks.afterCompile.tap).toHaveBeenCalled() const add = mock() @@ -619,24 +651,142 @@ describe('devupUIWebpackPlugin', () => { computeReachableFilesSpy.mockRestore() }) - it('runs single-importer collapse even when atomHoist is unset (always-on), without hoisting', () => { + it('runs single-importer collapse when atomHoist is explicitly zero, without hoisting', () => { // Constraint: single-importer collapse must ALWAYS be on. The canonical // map is built + imported unconditionally; only atom HOISTING is gated. buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - const plugin = new DevupUIWebpackPlugin({}) + const plugin = new DevupUIWebpackPlugin({ atomHoist: 0 }) plugin.apply(asCompiler(createCompiler())) expect(buildCanonicalMapSpy).toHaveBeenCalledWith( expect.objectContaining({ keyBy: 'cwd-relative' }), ) expect(importCanonicalMapSpy).toHaveBeenCalled() - // atom hoisting stays off without atomHoist + // Explicit zero disables atoms, not always-on collapse. expect(computeFileReachSpy).not.toHaveBeenCalled() expect(setAtomHoistSpy).not.toHaveBeenCalled() expect(importFileRoutesSpy).not.toHaveBeenCalled() }) + describe('W66 default regressions', () => { + beforeEach(() => { + computeFileReachSpy.mockReturnValue({ + 'src/shared.tsx': [0, 1], + 'src/a.tsx': [0], + 'src/b.tsx': [1], + }) + }) + + it.each([ + ['omitted', {}], + ['undefined', { atomHoist: undefined }], + ])( + 'matches explicit 2 on two routes when atomHoist is %s', + (_label, options) => { + // Given: explicit 2 installs a known two-route plan. + new DevupUIWebpackPlugin({ atomHoist: 2 }).apply( + asCompiler(createCompiler()), + ) + const explicitReach = [...importFileRoutesSpy.mock.calls] + expect(explicitReach).toEqual([ + [ + { + 'src/shared.tsx': [0, 1], + 'src/a.tsx': [0], + 'src/b.tsx': [1], + }, + ], + ]) + expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + importFileRoutesSpy.mockClear() + setAtomHoistSpy.mockClear() + // When: the public option is defaulted. + new DevupUIWebpackPlugin(options).apply(asCompiler(createCompiler())) + // Then: the same plan and threshold are installed. + expect(importFileRoutesSpy.mock.calls).toEqual(explicitReach) + expect(setAtomHoistSpy.mock.calls).toEqual([[2]]) + }, + ) + + it.each([0, -1, NaN, Infinity, -Infinity])( + 'keeps legacy OFF for atomHoist %s', + (atomHoist) => { + // Given: two routes, not the single-route fallback. + // When: a disabled numeric option is supplied. + new DevupUIWebpackPlugin({ atomHoist }).apply( + asCompiler(createCompiler()), + ) + // Then: collapse still runs but reach and threshold are untouched. + expect(buildCanonicalMapSpy).toHaveBeenCalled() + expect(computeFileReachSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() + }, + ) + + it('preserves a custom threshold above the minimum', () => { + // Given: two routes and a custom threshold. + // When: the plugin applies. + new DevupUIWebpackPlugin({ atomHoist: 3 }).apply( + asCompiler(createCompiler()), + ) + // Then: the threshold is not replaced by 2. + expect(setAtomHoistSpy.mock.calls).toEqual([[3]]) + }) + + it.each([ + ['omitted', {}], + ['undefined', { atomHoist: undefined }], + ['enabled', { atomHoist: 2 }], + ])( + 'ignores %s atomHoist before planning in actual singleCss mode', + (_label, options) => { + // Given: a two-entry compiler and prewarm inventory under explicit OFF. + computeReachableFilesSpy.mockReturnValue([ + resolve('src/a.tsx'), + resolve('src/b.tsx'), + ]) + const run = ( + policy: ConstructorParameters[0], + ) => { + const compiler = createCompiler() + compiler.options.entry = { + a: { import: ['./src/a.tsx'] }, + b: { import: ['./src/b.tsx'] }, + } + new DevupUIWebpackPlugin({ ...policy, singleCss: true }).apply( + asCompiler(compiler), + ) + compiler.hooks.run.tap.mock.calls[0][1]() + return compiler.options.module.rules + } + const offRules = run({ atomHoist: 0 }) + const offExtractions = [...codeExtractSpy.mock.calls] + expect(offExtractions).toHaveLength(2) + codeExtractSpy.mockClear() + // When: only the requested atom option changes. + const onRules = run(options) + // Then: no reach/threshold changes; loader and prewarm IDs/imports agree. + expect(computeFileReachSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() + expect(codeExtractSpy.mock.calls).toEqual(offExtractions) + expect(codeExtractSpy).toHaveBeenCalledWith( + 'src/a.tsx', + '{}', + '@devup-ui/react', + expect.any(String), + true, + false, + true, + expect.anything(), + ) + expect(onRules).toEqual(offRules) + }, + ) + }) + it('pre-warms the extractor over the files the entries reach in build mode', () => { // Without pre-warm, webpack builds each shared stylesheet ONCE at first // import — before later files are extracted — so their styles are @@ -666,6 +816,7 @@ describe('devupUIWebpackPlugin', () => { } compiler.options.context = '/project' plugin.apply(asCompiler(compiler)) + compiler.hooks.run.tap.mock.calls[0][1]() expect(computeReachableFilesSpy).toHaveBeenCalledWith({ srcDir: resolve(process.cwd(), 'src'), tsconfigPath: resolve(process.cwd(), 'tsconfig.json'), @@ -676,16 +827,28 @@ describe('devupUIWebpackPlugin', () => { }) // The loader's resolver is registered before the first extraction, so // imported constants inline as they do in the loader - expect(setModuleResolverSpy.mock.invocationCallOrder[0]).toBeLessThan( - codeExtractSpy.mock.invocationCallOrder[0]!, - ) - const resolveModule = setModuleResolverSpy.mock.calls[0]![0] as ( - specifier: string, - importer: string, - ) => { path: string } | undefined - expect(resolveModule('./plugin.test', import.meta.path)?.path).toBe( - relative(process.cwd(), import.meta.path).replaceAll('\\', '/'), - ) + expect(setModuleResolverSpy).toHaveBeenCalledTimes(2) + const firstExtraction = codeExtractSpy.mock.invocationCallOrder[0] + assert(firstExtraction !== undefined) + for (const order of setModuleResolverSpy.mock.invocationCallOrder) + expect(order).toBeLessThan(firstExtraction) + const resolvedModules: unknown[] = [] + for (const call of setModuleResolverSpy.mock.calls) { + const resolveModule: unknown = call[0] + assert(typeof resolveModule === 'function') + const resolved: unknown = resolveModule( + './plugin.test', + import.meta.path, + ) + expect(resolved).toMatchObject({ + path: relative(process.cwd(), import.meta.path).replaceAll( + '\\', + '/', + ), + }) + resolvedModules.push(resolved) + } + expect(resolvedModules[1]).toEqual(resolvedModules[0]) expect(codeExtractSpy).toHaveBeenCalledTimes(2) expect(codeExtractSpy).toHaveBeenCalledWith( 'src/parent.tsx', @@ -730,8 +893,10 @@ describe('devupUIWebpackPlugin', () => { const compiler = createCompiler() compiler.options.entry = { main: { import: ['next-app-loader?query!'] } } try { - // When the compiler applies after its ordinary configuration setup. + // When the compiler applies and its registered preparation runs. plugin.apply(asCompiler(compiler)) + expect(codeExtractSpy).not.toHaveBeenCalled() + compiler.hooks.run.tap.mock.calls[0][1]() // Then all files are extracted exactly once, in deterministic loader-ID order. expect(codeExtractSpy.mock.calls.map(([filename]) => filename)).toEqual( ['node_modules/ui/a.tsx', 'src/z.tsx'], @@ -759,8 +924,10 @@ describe('devupUIWebpackPlugin', () => { watch, productionPrewarmFiles: watch ? ['src/parent.tsx'] : [], }) - // When applying, then neither explicit watch files nor generic fallback are extracted. - plugin.apply(asCompiler(createCompiler())) + // When preparation runs, neither watch files nor generic fallback are extracted. + const compiler = createCompiler() + plugin.apply(asCompiler(compiler)) + compiler.hooks.run.tap.mock.calls[0][1]() expect(codeExtractSpy).not.toHaveBeenCalled() expect(computeReachableFilesSpy).not.toHaveBeenCalled() }, @@ -785,13 +952,22 @@ describe('devupUIWebpackPlugin', () => { if (stage === 'read') readFileSyncSpy.mockImplementation(fail) if (stage === 'extract') codeExtractSpy.mockImplementation(fail) const plugin = new DevupUIWebpackPlugin({ + debug: stage === 'numbering', productionPrewarmFiles: ['src/parent.tsx'], }) + const compiler = createCompiler() try { - // When applying, then the original error reaches webpack instead of partial CSS. - expect(() => plugin.apply(asCompiler(createCompiler()))).toThrow( - failure, - ) + // When its lifecycle stage runs, the original error reaches webpack. + if (stage === 'canonical' || stage === 'numbering') { + expect(() => plugin.apply(asCompiler(compiler))).toThrow(failure) + } else { + plugin.apply(asCompiler(compiler)) + expect(() => compiler.hooks.run.tap.mock.calls[0][1]()).toThrow( + failure, + ) + expect(endBuild).not.toHaveBeenCalled() + compiler.hooks.shutdown.tap.mock.calls[0][1]() + } expect(endBuild).toHaveBeenCalledTimes(1) } finally { begin.mockRestore() @@ -805,6 +981,7 @@ describe('devupUIWebpackPlugin', () => { const compiler = createCompiler() compiler.options.entry = () => ({}) new DevupUIWebpackPlugin({}).apply(asCompiler(compiler)) + compiler.hooks.run.tap.mock.calls[0][1]() expect(computeReachableFilesSpy).toHaveBeenCalledWith( expect.objectContaining({ entries: [] }), ) @@ -836,7 +1013,9 @@ describe('devupUIWebpackPlugin', () => { }) const plugin = new DevupUIWebpackPlugin({}) // apply must still complete without throwing - plugin.apply(asCompiler(createCompiler())) + const compiler = createCompiler() + plugin.apply(asCompiler(compiler)) + compiler.hooks.run.tap.mock.calls[0][1]() expect(codeExtractSpy).toHaveBeenCalled() }) @@ -931,7 +1110,9 @@ describe('devupUIWebpackPlugin', () => { needAdditionalPass: { tap: tap('needAdditionalPass') }, }, } - const run = compiler.hooks.run.tap.mock.calls[0]?.[1] as () => void + const run = compiler.hooks.run.tap.mock.calls.find( + ([name]) => name === 'DevupUIWebpackPlugin', + )?.[1] as () => void const start = () => { ;( compiler.hooks.thisCompilation.tap.mock.calls[0]![1] as ( @@ -942,10 +1123,14 @@ describe('devupUIWebpackPlugin', () => { return { compiler, plugin, compilation, taps, run, start } } - it('taps nothing in watch mode', () => { + it('uses same-compilation reconciliation instead of another pass in watch mode', () => { const { compiler } = compile(true) - expect(compiler.hooks.run.tap).not.toHaveBeenCalled() - expect(compiler.hooks.thisCompilation.tap).not.toHaveBeenCalled() + expect(compiler.hooks.run.tap.mock.calls.map(([name]) => name)).toEqual([ + 'DevupUIWebpackPluginReservation', + ]) + expect( + compiler.hooks.thisCompilation.tap.mock.calls.map(([name]) => name), + ).toEqual(['DevupUIWebpackPluginWatchCss']) }) it('writes the shared base to disk when it does not hold it yet', () => { diff --git a/packages/webpack-plugin/src/__tests__/production-prewarm.mjs b/packages/webpack-plugin/src/__tests__/production-prewarm.mjs index b1ffa6f3b..f45e6f86a 100644 --- a/packages/webpack-plugin/src/__tests__/production-prewarm.mjs +++ b/packages/webpack-plugin/src/__tests__/production-prewarm.mjs @@ -40,6 +40,15 @@ function apply(options = {}, entry = 'next-app-loader?query!') { compilers.add(compiler) return compiler } +async function prepare(compiler) { + // Unit boundary only: this synthetic Next request is not a runnable module graph. + // Invoke only our registered run preparation, which reserves scratch before extraction. + const taps = compiler.hooks.run.taps.filter( + ({ name }) => name === 'DevupUIWebpackPluginReservation', + ) + assert.equal(taps.length, 1) + await taps[0].fn(compiler) +} async function close(compiler) { try { await new Promise((resolve, reject) => @@ -130,6 +139,8 @@ try { 'node_modules/design-system/child.tsx', ] let compiler = apply({ productionPrewarmFiles, singleCss }) + assert.doesNotMatch(sheet(), /maroon|cyan|goldenrod/) + await prepare(compiler) const css = sheet() assert.match(css, /color:maroon/) assert.match(css, /color:cyan/) @@ -146,6 +157,7 @@ try { 'src\\page.tsx', ], }) + await prepare(compiler) // Then both CSS bytes and transformed class correspondence are identical. assert.equal(sheet(), css) assert.deepEqual( @@ -156,41 +168,49 @@ try { } // Given a generic webpack entry, unreachable source remains excluded. let compiler = apply({}, './src/page.tsx') + await prepare(compiler) assert.match(sheet(), /maroon/) assert.doesNotMatch(sheet(), /cyan|goldenrod/) await close(compiler) // When the complete set is explicitly empty, then even that entry is not prewarmed. compiler = apply({ productionPrewarmFiles: [] }, './src/page.tsx') + await prepare(compiler) assert.doesNotMatch(sheet(), /maroon|cyan|goldenrod/) await close(compiler) compiler = apply({ watch: true, productionPrewarmFiles: ['src/page.tsx'] }) + await prepare(compiler) assert.doesNotMatch(sheet(), /maroon/) await close(compiler) - assert.throws(() => apply({ productionPrewarmFiles: ['missing.tsx'] })) + compiler = apply({ productionPrewarmFiles: ['missing.tsx'] }) + await assert.rejects(prepare(compiler)) + await close(compiler) write( 'bad.tsx', 'import { Box } from "@devup-ui/react"; export const c = ', ) - assert.throws(() => apply({ productionPrewarmFiles: ['bad.tsx'] })) + compiler = apply({ productionPrewarmFiles: ['bad.tsx'] }) + await assert.rejects(prepare(compiler)) + await close(compiler) for (const invalid of ['missing.tsx', 'bad.tsx']) { // Given a valid file sorted before the failing file, initialization partially extracts. write( 'a-valid.tsx', 'import { Box } from "@devup-ui/react"; export const c = ', ) - // When webpack itself throws, no compiler is returned for the caller to close. - assert.throws(() => - apply({ - prefix: 'failed', - shorthands: { insetX: ['left', 'right'] }, - atomHoist: 2, - productionPrewarmFiles: ['a-valid.tsx', invalid], - }), - ) + // When run preparation rejects, the returned compiler still needs awaited shutdown. + compiler = apply({ + prefix: 'failed', + shorthands: { insetX: ['left', 'right'] }, + atomHoist: 2, + productionPrewarmFiles: ['a-valid.tsx', invalid], + }) + await assert.rejects(prepare(compiler)) assert.match(sheet(), /chartreuse/) assert.match(sheet(), /left:19px/) + await close(compiler) // Then an unrelated generic build resets the partial styles and engine options. compiler = apply({}, './src/page.tsx') + await prepare(compiler) assert.match(sheet(), /maroon/) assert.doesNotMatch(sheet(), /chartreuse|failed|left:19px/) const cleanCode = codeExtract( diff --git a/packages/webpack-plugin/src/__tests__/production-prewarm.test.js b/packages/webpack-plugin/src/__tests__/production-prewarm.test.js index afc664bbe..259e06ae9 100644 --- a/packages/webpack-plugin/src/__tests__/production-prewarm.test.js +++ b/packages/webpack-plugin/src/__tests__/production-prewarm.test.js @@ -5,7 +5,7 @@ import { expect, it } from 'bun:test' it('reconstructs complete production sheets across compiler resets without leaking to another build', () => { // Given an isolated real engine (other unit tests mock WASM exports). - // When applying and closing real webpack compilers sequentially. + // When applying real compilers, awaiting their own preparation tap, and closing them. const result = spawnSync( process.execPath, [join(import.meta.dir, 'production-prewarm.mjs')], diff --git a/packages/webpack-plugin/src/__tests__/reservation-build.cjs b/packages/webpack-plugin/src/__tests__/reservation-build.cjs new file mode 100644 index 000000000..d7f992269 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/reservation-build.cjs @@ -0,0 +1,221 @@ +const assert = require('node:assert/strict') +const { mkdirSync } = require('node:fs') +const { join } = require('node:path') +const { + webpack, +} = require('../../../next-plugin/node_modules/next/dist/compiled/webpack/webpack') +const wasm = require('@devup-ui/wasm') +const { state } = require('./reservation-loaders.cjs') +const fixtureSources = require('./reservation-fixture.cjs') +const snapshot = require('./reservation-output.cjs') +console.info = (...args) => console.error(...args) + +const [fixture, schedule, mode, cssMode] = process.argv.slice(2) +assert(fixture && ['AB', 'BA'].includes(schedule)) +mkdirSync(fixture, { recursive: true }) +process.chdir(fixture) +const watch = mode === 'watch' +const singleCss = cssMode === 'single' +const events = [] +const reservations = [] +const extracts = [] +const atomThresholds = [] +let compiling = false +let revision = 0 +const originalAtomHoist = wasm.setAtomHoist +wasm.setAtomHoist = (threshold) => { + atomThresholds.push(threshold) + return originalAtomHoist(threshold) +} +const originalReserve = wasm.reserveClassSlots +wasm.reserveClassSlots = (...args) => { + assert(!compiling, 'reservation must precede compilation') + events.push('reserve') + const beforeCss = wasm.exportSheet() + originalReserve(...args) + assert.equal(wasm.exportSheet(), beforeCss, 'scratch sheet restored exactly') + reservations.push({ + revision, + rows: args[0].map(([id]) => id), + classes: JSON.parse(wasm.exportClassMap()), + files: JSON.parse(wasm.exportFileMap()), + }) +} +const originalExtract = wasm.codeExtract +wasm.codeExtract = (...args) => { + const beforeClasses = wasm.exportClassMap() + const beforeFiles = wasm.exportFileMap() + const result = originalExtract(...args) + extracts.push({ + id: args[0], + prewarm: !compiling, + residual: wasm.getUnreservedClassSlotCount(), + sameClasses: beforeClasses === wasm.exportClassMap(), + sameFiles: beforeFiles === wasm.exportFileMap(), + code: result.code, + map: result.map, + dependencies: result.dependencies, + }) + events.push(compiling ? 'extract' : 'prewarm') + return result +} +const { DevupUIWebpackPlugin } = require('../../dist/index.cjs') +const { put, sources } = fixtureSources(fixture, mode) +if (!watch) for (const letter of ['B', 'A']) sources(letter) +let entryCalls = 0 +let setups = 0 +let compilations = 0 +const plugin = new DevupUIWebpackPlugin({ + watch, + singleCss, + include: ['@slot/ui'], + ...(mode === 'debug' ? { debug: true, prefix: 'slot' } : {}), + ...(['atom', 'noop'].includes(mode) ? { atomHoist: 2 } : {}), +}) +const compiler = webpack({ + mode: 'production', + context: fixture, + cache: false, + devtool: false, + entry: + mode === 'prewarm' + ? { main: './src/entry.js' } + : () => { + entryCalls += 1 + if (mode === 'once') return { main: ['./src/plain.js'] } + if (mode === 'invalid') return { main: ['./src/Invalid.js'] } + return { + main: [ + './src/entry.js', + ...(watch && revision === 0 + ? [] + : ['./physical/A.js', './physical/B.js']), + ], + } + }, + output: { path: join(fixture, 'out'), filename: '[name].js', clean: true }, + optimization: { + minimize: false, + concatenateModules: false, + usedExports: false, + }, + experiments: { css: true }, + module: { + rules: [ + { test: /\.css$/, type: 'css' }, + { test: /\.(js|ts)$/, use: [join(__dirname, 'reservation-observe.cjs')] }, + ], + }, + plugins: [ + plugin, + { + apply(compiler) { + setups += 1 + put( + 'src/0-Generated.js', + `import { css } from '@devup-ui/react'; export const generated = css({ outlineColor: 'lime' });`, + ) + compiler.options.module.rules.push({ + test: /\.(js|ts)$/, + enforce: 'pre', + use: [join(__dirname, 'reservation-loaders.cjs')], + }) + compiler.hooks.watchRun.tap({ name: 'custody', stage: -10000 }, () => { + compiling = false + }) + compiler.hooks.thisCompilation.tap('custody', () => { + compiling = true + compilations += 1 + }) + compiler.hooks.done.tap('custody', () => { + compiling = false + }) + }, + }, + ], +}) +assert(compiler) +async function run() { + state.order = schedule === 'AB' ? ['A.js', 'B.js'] : ['B.js', 'A.js'] + let initial + let final + if (watch) { + await new Promise((resolve, reject) => { + const watching = compiler.watch( + { aggregateTimeout: 0, poll: 100, ignored: /[\\/](df|out)[\\/]/ }, + (error, stats) => { + if (error) { + watching.close(() => reject(error)) + return + } + try { + assert(stats) + if (revision === 0) { + initial = snapshot(stats, fixture, extracts) + watching.suspend() + revision = 1 + for (const letter of schedule) sources(letter) + compiler.modifiedFiles = new Set( + [...schedule].map((letter) => + join(fixture, `physical/${letter}.js`), + ), + ) + watching.invalidate() + watching.resume() + } else { + final = snapshot(stats, fixture, extracts) + watching.close((error) => (error ? reject(error) : resolve())) + } + } catch (error) { + watching.close(() => reject(error)) + } + }, + ) + }) + } else { + final = await new Promise((resolve, reject) => + compiler.run((error, stats) => { + if (error) { + reject(error) + return + } + try { + assert(stats) + resolve(snapshot(stats, fixture, extracts)) + } catch (error) { + reject(error) + } + }), + ) + } + await new Promise((resolve, reject) => + compiler.close((error) => (error ? reject(error) : resolve())), + ) + assert.equal(setups, 1) + assert.equal( + entryCalls, + mode === 'prewarm' ? 0 : compilations, + 'only native compilation may call entry', + ) + if (!['prewarm', 'once'].includes(mode)) { + assert(state.dispatched.length >= 2) + for (let index = 0; index < state.dispatched.length; index += 2) + assert.deepEqual(state.dispatched.slice(index, index + 2), state.order) + } + process.stdout.write( + JSON.stringify({ + initial, + final, + reservations, + extracts, + events, + compilations, + entryCalls, + atomThresholds, + }), + ) +} +run().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/webpack-plugin/src/__tests__/reservation-build.test.ts b/packages/webpack-plugin/src/__tests__/reservation-build.test.ts new file mode 100644 index 000000000..67f7aa62a --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/reservation-build.test.ts @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { parseReservationReceipt } from './reservation-receipt' + +function run(fixture: string, schedule: string, mode: string, cssMode: string) { + const child = Bun.spawnSync( + [ + 'node', + join(import.meta.dir, 'reservation-build.cjs'), + fixture, + schedule, + mode, + cssMode, + ], + { stdout: 'pipe', stderr: 'pipe', timeout: 45000 }, + ) + assert.equal( + child.exitCode, + 0, + child.stdout.toString() + child.stderr.toString(), + ) + const receipt = parseReservationReceipt(child.stdout.toString()) + assert(receipt.final && Object.keys(receipt.final.assets).length > 0) + return receipt +} + +for (const cssMode of ['single', 'per-file']) { + for (const mode of ['production', 'watch']) { + it(`emits identical real webpack ${mode} output in ${cssMode} mode when loaders run AB or BA`, () => { + // Given: identical physical IDs, fresh engines, reverse loader/write notification order. + const fixture = mkdtempSync(join(tmpdir(), 'devup-webpack-slots-')) + try { + // When: native webpack runs, including a genuine suspended/resumed watch revision. + const ab = run(fixture, 'AB', mode, cssMode) + rmSync(fixture, { recursive: true, force: true }) + const ba = run(fixture, 'BA', mode, cssMode) + // Then: compare genuine bytes BEFORE checking the initially inactive residual seam. + expect(ba.final).toEqual(ab.final) + expect(ab.final.css).toContain('red') + expect(ab.final.css).toContain('blue') + expect(ab.final.css).toContain('@keyframes') + expect(ab.final.transformed['A.css.ts']).toMatch(/--[a-z0-9_-]+/) + expect(ab.final.residual).toBe(0) + expect(ab.reservations.length).toBeGreaterThanOrEqual( + mode === 'watch' ? 2 : 1, + ) + const initialBatch = ab.reservations[0] + assert(initialBatch) + expect(initialBatch.rows).toContain('src/0-Generated.js') + expect(initialBatch.rows).toContain( + 'node_modules/@slot/ui/dist/index.js', + ) + expect(initialBatch.rows).toContain('src/Invalid.js') + expect(ab.final.canonical['src/child.js']).toBe('src/entry.js') + if (mode === 'watch') { + assert(ab.initial) + for (const [id, slot] of Object.entries(ab.initial.files)) + expect(ab.final.files[id]).toEqual(slot) + const batch = ab.reservations.find((item) => item.revision === 1) + assert(batch) + expect(batch.rows.indexOf('physical/A.js')).toBeLessThan( + batch.rows.indexOf('physical/B.js'), + ) + } + } finally { + rmSync(fixture, { recursive: true, force: true }) + } + }, 100000) + } +} + +it('keeps reached-source prewarm first after scratch restoration with already reserved names', () => { + // Given: static entries that main already prewarms. + const fixture = mkdtempSync(join(tmpdir(), 'devup-webpack-prewarm-')) + try { + // When: the production compiler runs. + const receipt = run(fixture, 'AB', 'prewarm', 'per-file') + // Then: scratch precedes real prewarm, which inserts no class/file names. + expect(receipt.events[0]).toBe('reserve') + expect(receipt.events[1]).toBe('prewarm') + const prewarm = receipt.extracts.filter((item) => item.prewarm) + expect(prewarm.length).toBeGreaterThan(0) + for (const item of prewarm) { + expect(item.sameClasses).toBe(true) + expect(item.sameFiles).toBe(true) + expect(item.residual).toBe(0) + } + expect(receipt.compilations).toBe(1) + expect(receipt.final.residual).toBe(0) + } finally { + rmSync(fixture, { recursive: true, force: true }) + } +}, 50000) + +for (const mode of ['debug', 'atom', 'noop']) { + it(`preserves ${mode} configuration and zero residuals in genuine production output`, () => { + const fixture = mkdtempSync(join(tmpdir(), 'devup-webpack-config-')) + try { + const ab = run(fixture, 'AB', mode, 'per-file') + rmSync(fixture, { recursive: true, force: true }) + const ba = run(fixture, 'BA', mode, 'per-file') + expect(ba.final).toEqual(ab.final) + expect(ab.final.residual).toBe(0) + if (mode === 'debug') expect(ab.final.css).toContain('slot') + if (mode === 'atom') expect(ab.atomThresholds).toEqual([2]) + if (mode === 'noop') expect(ab.atomThresholds).toEqual([]) + } finally { + rmSync(fixture, { recursive: true, force: true }) + } + }, 100000) +} + +it('retains persisted watch file ordinals when a new compiler restores its maps', () => { + const fixture = mkdtempSync(join(tmpdir(), 'devup-webpack-restored-')) + try { + const first = run(fixture, 'AB', 'watch', 'per-file') + const restored = run(fixture, 'BA', 'watch', 'per-file') + for (const [id, slot] of Object.entries(first.final.files)) + expect(restored.final.files[id]).toEqual(slot) + expect(restored.final.raw.classes).toEqual(first.final.raw.classes) + expect(restored.final.residual).toBe(0) + } finally { + rmSync(fixture, { recursive: true, force: true }) + } +}, 100000) + +it('reports a reachable invalid source only through the genuine webpack transform', () => { + const fixture = mkdtempSync(join(tmpdir(), 'devup-webpack-error-')) + try { + const child = Bun.spawnSync( + [ + 'node', + join(import.meta.dir, 'reservation-build.cjs'), + fixture, + 'AB', + 'invalid', + 'single', + ], + { stdout: 'pipe', stderr: 'pipe', timeout: 45000 }, + ) + expect(child.exitCode).toBe(1) + expect(child.stderr.toString()).toContain('ERROR in ./src/Invalid.js') + expect(child.stderr.toString()).toContain('props.unknown') + expect(child.stderr.toString()).toContain('webpack-plugin/dist/loader.cjs') + } finally { + rmSync(fixture, { recursive: true, force: true }) + } +}, 50000) + +it('lets webpack invoke a function entry exactly once in a one-pass production build', () => { + const fixture = mkdtempSync(join(tmpdir(), 'devup-webpack-function-')) + try { + const receipt = run(fixture, 'AB', 'once', 'single') + expect(receipt.entryCalls).toBe(1) + expect(receipt.compilations).toBe(1) + expect(receipt.final.residual).toBe(0) + } finally { + rmSync(fixture, { recursive: true, force: true }) + } +}, 50000) diff --git a/packages/webpack-plugin/src/__tests__/reservation-fixture.cjs b/packages/webpack-plugin/src/__tests__/reservation-fixture.cjs new file mode 100644 index 000000000..e2ce09cb8 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/reservation-fixture.cjs @@ -0,0 +1,79 @@ +const { mkdirSync, rmSync, writeFileSync } = require('node:fs') +const { dirname, join } = require('node:path') + +module.exports = function fixtureSources(fixture, mode) { + function put(path, text) { + const absolute = join(fixture, path) + mkdirSync(dirname(absolute), { recursive: true }) + writeFileSync(absolute, text) + } + function rawSources(letter) { + put( + `physical/${letter}.css.ts`, + `import { createVar, createContainer, layer } from '@vanilla-extract/css'; +export const variable = createVar(); export const container = createContainer(); export const level = layer();`, + ) + } + function sources(letter) { + const color = letter === 'A' ? 'red' : 'blue' + if (mode !== 'watch') rawSources(letter) + put( + `physical/${letter}.js`, + `import { css, keyframes, globalCss } from '@devup-ui/react'; +import { variable, container, level } from './${letter}.css.ts'; +const animation = keyframes({ from: { opacity: ${letter === 'A' ? 0 : 0.5} }, to: { opacity: 1 } }); +globalCss({ '.${letter}-global': { color: '${color}' } }); +export const ${letter} = [css({ color: '${color}', animationName: animation }), variable, container, level];`, + ) + } + if (mode === 'watch') for (const letter of ['A', 'B']) rawSources(letter) + put('devup.json', '{}') + put('src/plain.js', 'export const plain = 1;') + const physicalImports = `export { A } from '../physical/A.js'; +export { B } from '../physical/B.js'; +export { variable as a } from '../physical/A.css.ts'; +export { variable as b } from '../physical/B.css.ts';` + put('src/reservation-inputs.js', physicalImports) + put( + 'src/entry.js', + `import { css } from '@devup-ui/react'; +import { child } from './child.js'; import { dist } from '@slot/ui'; +export const entry = [css({ display: 'grid' }), child, dist];`, + ) + put( + 'src/child.js', + `import { css } from '@devup-ui/react'; export const child = css({ padding: '12px' });`, + ) + put( + 'src/Unused.js', + `import { globalCss } from '@devup-ui/react'; globalCss({ '.unused': { color: 'magenta' } });`, + ) + put( + 'src/Invalid.js', + `import { css } from '@devup-ui/react'; export const invalid = css({ color: props.unknown });`, + ) + put( + 'node_modules/@slot/ui/package.json', + JSON.stringify({ name: '@slot/ui', main: 'dist/index.js' }), + ) + put( + 'node_modules/@slot/ui/dist/index.js', + `import { css } from '@devup-ui/react'; export const dist = css({ borderColor: 'orange' });`, + ) + if (mode === 'atom') { + for (const route of ['a', 'b']) + put( + `src/app/${route}/page.js`, + `import { child } from '../../child.js'; export const page = child;`, + ) + } + if (mode === 'noop') { + rmSync(join(fixture, 'src'), { recursive: true }) + put( + 'src/entry.js', + `import { css } from '@devup-ui/react'; export const entry = css({ display: 'grid' }); +${physicalImports}`, + ) + } + return { put, sources } +} diff --git a/packages/webpack-plugin/src/__tests__/reservation-loaders.cjs b/packages/webpack-plugin/src/__tests__/reservation-loaders.cjs new file mode 100644 index 000000000..9db034a80 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/reservation-loaders.cjs @@ -0,0 +1,34 @@ +const assert = require('node:assert/strict') +const { basename } = require('node:path') + +// Only release genuine loader invocations; never replace their source or output. +const state = { + order: [], + waiting: new Map(), + dispatched: [], + transformed: {}, +} + +function gate(source) { + const name = basename(this.resourcePath) + if (!state.order.includes(name)) return source + const callback = this.async() + assert(!state.waiting.has(name)) + state.waiting.set(name, () => { + state.dispatched.push(name) + callback(null, source) + }) + if (state.waiting.size === 2) state.waiting.get(state.order[0])() +} + +function observe(source, map) { + const name = basename(this.resourcePath) + state.transformed[name] = source.toString() + if (name === state.order[0]) state.waiting.get(state.order[1])() + if (name === state.order[1]) state.waiting.clear() + this.callback(null, source, map) +} + +module.exports = gate +module.exports.observe = observe +module.exports.state = state diff --git a/packages/webpack-plugin/src/__tests__/reservation-observe.cjs b/packages/webpack-plugin/src/__tests__/reservation-observe.cjs new file mode 100644 index 000000000..738790152 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/reservation-observe.cjs @@ -0,0 +1 @@ +module.exports = require('./reservation-loaders.cjs').observe diff --git a/packages/webpack-plugin/src/__tests__/reservation-output.cjs b/packages/webpack-plugin/src/__tests__/reservation-output.cjs new file mode 100644 index 000000000..98099d9e3 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/reservation-output.cjs @@ -0,0 +1,41 @@ +const assert = require('node:assert/strict') +const { readFileSync } = require('node:fs') +const { join } = require('node:path') +const wasm = require('@devup-ui/wasm') +const { state } = require('./reservation-loaders.cjs') + +module.exports = function snapshot(stats, fixture, extracts) { + assert(!stats.hasErrors(), stats.toString({ all: false, errors: true })) + const files = JSON.parse(wasm.exportFileMap()) + const css = [ + wasm.getCss(null, false), + ...[...new Set(Object.values(files))].map((number) => + wasm.getCss(number, false), + ), + ].join('\n') + assert(!css.includes('magenta'), 'unused scratch CSS must not leak') + return { + transformed: { ...state.transformed }, + classes: JSON.parse(wasm.exportClassMap()), + files, + canonical: JSON.parse(wasm.exportCanonicalMap()), + metadata: Object.fromEntries( + extracts + .filter((item) => !item.prewarm) + .map((item) => [ + item.id, + { map: item.map, dependencies: item.dependencies }, + ]), + ), + css, + assets: Object.fromEntries( + Object.keys(stats.compilation.assets) + .sort() + .map((name) => [ + name, + readFileSync(join(fixture, 'out', name), 'utf8'), + ]), + ), + residual: wasm.getUnreservedClassSlotCount(), + } +} diff --git a/packages/webpack-plugin/src/__tests__/reservation-receipt.ts b/packages/webpack-plugin/src/__tests__/reservation-receipt.ts new file mode 100644 index 000000000..38b40b68c --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/reservation-receipt.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' + +function record(value: unknown): Readonly> { + assert(value !== null && typeof value === 'object' && !Array.isArray(value)) + const entries: readonly (readonly [string, unknown])[] = Object.entries(value) + return Object.fromEntries(entries) +} + +function strings(value: unknown): Readonly> { + return Object.fromEntries( + Object.entries(record(value)).map(([key, text]) => { + assert(typeof text === 'string') + return [key, text] as const + }), + ) +} + +function number(value: unknown): number { + assert(typeof value === 'number') + return value +} + +function snapshot(value: unknown) { + const fields = record(value) + assert(typeof fields.css === 'string') + return { + raw: fields, + css: fields.css, + transformed: strings(fields.transformed), + assets: strings(fields.assets), + canonical: strings(fields.canonical), + files: record(fields.files), + residual: number(fields.residual), + } +} + +export function parseReservationReceipt(text: string) { + const parsed: unknown = JSON.parse(text) + const fields = record(parsed) + assert(Array.isArray(fields.reservations) && Array.isArray(fields.extracts)) + assert(Array.isArray(fields.events)) + assert(Array.isArray(fields.atomThresholds)) + return { + final: snapshot(fields.final), + initial: + fields.initial === undefined ? undefined : snapshot(fields.initial), + compilations: number(fields.compilations), + entryCalls: number(fields.entryCalls), + atomThresholds: fields.atomThresholds.map((value: unknown) => + number(value), + ), + events: fields.events.map((value: unknown) => { + assert(typeof value === 'string') + return value + }), + reservations: fields.reservations.map((value: unknown) => { + const batch = record(value) + assert(Array.isArray(batch.rows)) + return { + revision: number(batch.revision), + rows: batch.rows.map((id: unknown) => { + assert(typeof id === 'string') + return id + }), + } + }), + extracts: fields.extracts.map((value: unknown) => { + const item = record(value) + assert(typeof item.prewarm === 'boolean') + assert( + typeof item.sameClasses === 'boolean' && + typeof item.sameFiles === 'boolean', + ) + return { + prewarm: item.prewarm, + sameClasses: item.sameClasses, + sameFiles: item.sameFiles, + residual: number(item.residual), + } + }), + } +} diff --git a/packages/webpack-plugin/src/__tests__/reservation-roots.cjs b/packages/webpack-plugin/src/__tests__/reservation-roots.cjs new file mode 100644 index 000000000..53312855b --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/reservation-roots.cjs @@ -0,0 +1,117 @@ +const assert = require('node:assert/strict') +const { join } = require('node:path') +const { + webpack, +} = require('../../../next-plugin/node_modules/next/dist/compiled/webpack/webpack') +const wasm = require('@devup-ui/wasm') +const { state } = require('./reservation-loaders.cjs') +const snapshot = require('./reservation-output.cjs') + +const [fixture, mode = 'source'] = process.argv.slice(2) +assert(fixture) +process.chdir(fixture) +const reservations = [] +const extracts = [] +const events = [] +let compiling = false +let compilations = 0 +let entryCalls = 0 +const originalReserve = wasm.reserveClassSlots +wasm.reserveClassSlots = (...args) => { + assert(!compiling) + originalReserve(...args) + reservations.push({ revision: 0, rows: args[0].map(([id]) => id) }) + events.push('reserve') +} +const originalExtract = wasm.codeExtract +wasm.codeExtract = (...args) => { + const classes = wasm.exportClassMap() + const files = wasm.exportFileMap() + const output = originalExtract(...args) + state.transformed[args[0]] = output.code + extracts.push({ + id: args[0], + prewarm: !compiling, + sameClasses: classes === wasm.exportClassMap(), + sameFiles: files === wasm.exportFileMap(), + residual: wasm.getUnreservedClassSlotCount(), + map: output.map, + dependencies: output.dependencies, + }) + events.push(compiling ? 'extract' : 'prewarm') + return output +} +const { DevupUIWebpackPlugin } = require('../../dist/index.cjs') +const compiler = webpack({ + mode: 'production', + context: mode === 'context-app' ? join(fixture, 'project') : fixture, + entry: ['opaque', 'app', 'context-app'].includes(mode) + ? () => { + entryCalls += 1 + return { + main: + mode === 'opaque' ? './external/entry.css.ts' : './app/entry.js', + } + } + : { + main: + mode === 'root' + ? './index.js' + : mode === 'static' + ? './external/entry.js' + : './src/entry.js', + }, + cache: false, + devtool: false, + output: { path: join(fixture, 'out'), filename: '[name].js' }, + optimization: { + minimize: false, + concatenateModules: false, + usedExports: false, + }, + experiments: { css: true }, + module: { rules: [{ test: /\.css$/, type: 'css' }] }, + plugins: [new DevupUIWebpackPlugin({ atomHoist: 0 })], +}) +assert(compiler) +compiler.hooks.thisCompilation.tap('root-custody', () => { + compiling = true + compilations += 1 +}) +async function run() { + try { + const final = await new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) { + reject(error) + return + } + try { + assert(stats) + resolve(snapshot(stats, fixture, extracts)) + } catch (error) { + reject(error) + } + }) + }) + process.stdout.write( + JSON.stringify({ + final, + reservations, + extracts, + events, + compilations, + entryCalls, + atomThresholds: [], + }), + ) + } finally { + await new Promise((resolve, reject) => + compiler.close((error) => (error ? reject(error) : resolve())), + ) + } +} +run().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/webpack-plugin/src/__tests__/reservation-roots.test.ts b/packages/webpack-plugin/src/__tests__/reservation-roots.test.ts new file mode 100644 index 000000000..040ccd725 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/reservation-roots.test.ts @@ -0,0 +1,231 @@ +import assert from 'node:assert/strict' +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { parseReservationReceipt } from './reservation-receipt' + +function createFixture() { + const root = mkdtempSync(join(tmpdir(), 'devup-webpack-root-')) + mkdirSync(join(root, 'src')) + writeFileSync(join(root, 'devup.json'), '{}') + writeFileSync( + join(root, 'src/entry.css.ts'), + `import { createVar } from '@vanilla-extract/css'; export const variable = createVar();`, + ) + writeFileSync( + join(root, 'src/entry.js'), + `import { css } from '@devup-ui/react'; +import { variable } from './entry.css.ts'; +export const entry = [css({ color: 'red' }), variable]; +export const payload = 'import { css } from "@devup-ui/react";';`, + ) + return root +} + +function run(root: string, mode = 'source') { + const child = Bun.spawnSync( + ['node', join(import.meta.dir, 'reservation-roots.cjs'), root, mode], + { stdout: 'pipe', stderr: 'pipe', timeout: 45000 }, + ) + assert.equal( + child.exitCode, + 0, + child.stdout.toString() + child.stderr.toString(), + ) + return parseReservationReceipt(child.stdout.toString()) +} + +it('keeps unchanged source names and emitted bytes when its own output already exists', () => { + // Given: source whose genuine output retains a package-name string as data. + const root = createFixture() + try { + // When: fresh engines build the same project cold, then with its own output present. + const cold = run(root) + const warm = run(root) + // Then: output presence neither allocates phantom slots nor changes real bytes. + expect(warm.final).toEqual(cold.final) + expect(warm.reservations.flatMap((batch) => batch.rows)).not.toContain( + 'out/main.js', + ) + expect(warm.final.residual).toBe(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 100000) + +it('ignores stale .next and dist styles without additional reservation or extraction work', () => { + // Given: a cold build establishes actual source output and inventory. + const root = createFixture() + try { + const cold = run(root) + for (const directory of ['.next/server', 'dist']) { + mkdirSync(join(root, directory), { recursive: true }) + writeFileSync( + join(root, directory, 'stale.js'), + `import { css } from '@devup-ui/react'; export const stale = css({ color: 'teal' });`, + ) + } + // When: the same actual source builds with stale output trees present. + const stale = run(root) + // Then: neither genuine bytes nor prepass/transform work changes. + expect(stale.final).toEqual(cold.final) + expect(stale.reservations).toEqual(cold.reservations) + expect(stale.extracts).toEqual(cold.extracts) + expect(stale.events).toEqual(cold.events) + expect(stale.final.residual).toBe(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 100000) + +it('reserves outside-D9 physical sources through static entry parents', () => { + // Given: an explicitly declared static entry outside src/app. + const root = createFixture() + try { + mkdirSync(join(root, 'external')) + writeFileSync( + join(root, 'external/entry.js'), + `import { css } from '@devup-ui/react'; export const entry = css({ color: 'blue' });`, + ) + // When: native webpack builds the static entry. + const receipt = run(root, 'static') + // Then: its actual styles are reserved without residual allocation. + expect(receipt.final.css).toContain('blue') + expect(receipt.final.residual).toBe(0) + expect(receipt.reservations.flatMap((batch) => batch.rows)).toContain( + 'external/entry.js', + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 50000) + +it('counts opaque function-entry sources outside D9 while native extraction still succeeds', () => { + // Given: a raw variable supplier reachable only through an opaque function entry. + const root = createFixture() + try { + mkdirSync(join(root, 'external')) + writeFileSync( + join(root, 'external/entry.css.ts'), + `import { createVar } from '@vanilla-extract/css'; export const variable = createVar();`, + ) + // When: only webpack itself invokes the function and loads its source. + const receipt = run(root, 'opaque') + // Then: real output succeeds, the missed raw slot is observable, and entry ran once. + expect(receipt.final.residual).toBeGreaterThan(0) + expect(receipt.entryCalls).toBe(1) + expect(receipt.compilations).toBe(1) + expect(receipt.final.transformed['external/entry.css.ts']).toContain( + 'var(--', + ) + expect(receipt.reservations.flatMap((batch) => batch.rows)).not.toContain( + 'external/entry.css.ts', + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 50000) + +for (const project of ['', 'project']) { + it(`reserves app source automatically for a native function entry in ${project || 'cwd'}`, () => { + // Given: an app source root, optionally in a different compiler context. + const root = createFixture() + try { + mkdirSync(join(root, project, 'app'), { recursive: true }) + writeFileSync( + join(root, project, 'app/entry.js'), + `import { css } from '@devup-ui/react'; export const entry = css({ color: 'green' });`, + ) + // When: webpack natively evaluates its app entry. + const receipt = run(root, project ? 'context-app' : 'app') + // Then: conventional app discovery covers it independently of entry evaluation. + expect(receipt.final.residual).toBe(0) + expect(receipt.final.css).toContain('green') + expect(receipt.entryCalls).toBe(receipt.compilations) + expect(receipt.reservations.flatMap((batch) => batch.rows)).toContain( + project ? 'project/app/entry.js' : 'app/entry.js', + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, 50000) +} + +it('ignores a repo-style webpack config and unrelated package sources during reservation', () => { + // Given: only src is a project input; configuration imports the real plugin package. + const root = createFixture() + try { + const cold = run(root) + mkdirSync(join(root, 'node_modules/@devup-ui'), { recursive: true }) + symlinkSync( + resolve(import.meta.dir, '../..'), + join(root, 'node_modules/@devup-ui/webpack-plugin'), + 'junction', + ) + writeFileSync( + join(root, 'webpack.config.js'), + `import { DevupUIWebpackPlugin } from '@devup-ui/webpack-plugin'; export default { plugins: [new DevupUIWebpackPlugin()] };`, + ) + mkdirSync(join(root, 'packages/unrelated/src'), { recursive: true }) + writeFileSync( + join(root, 'packages/unrelated/src/card.js'), + `import { css } from '@devup-ui/react'; export const card = css({ color: 'purple' });`, + ) + // When: native webpack rebuilds unchanged actual inputs with that physical config present. + const configured = run(root) + // Then: neither config dependencies nor unrelated workspace inputs enter the packet. + expect(configured.final).toEqual(cold.final) + const rows = configured.reservations.flatMap((batch) => batch.rows) + expect(rows).not.toContain('webpack.config.js') + expect(rows).not.toContain('packages/unrelated/src/card.js') + expect(rows.some((id) => id.includes('webpack-plugin/dist/'))).toBe(false) + expect(configured.final.residual).toBe(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 100000) + +it('keeps exact root static entry closure unchanged across output, config and unused siblings', () => { + // Given: a root entry with a real relative raw supplier, outside D9. + const root = createFixture() + try { + writeFileSync(join(root, 'index.js'), `export * from './src/entry.js';`) + const cold = run(root, 'root') + const warm = run(root, 'root') + expect(warm.final).toEqual(cold.final) + for (const directory of ['.next/server', 'dist', 'unused']) { + mkdirSync(join(root, directory), { recursive: true }) + writeFileSync( + join(root, directory, 'stale.js'), + `import {css} from '@devup-ui/react'; export const stale=css({color:'teal'});`, + ) + } + writeFileSync( + join(root, 'webpack.config.js'), + `import {css} from '@devup-ui/react'; export default css({margin:'99px'});`, + ) + // When: native webpack rebuilds exactly the same root entry. + const stale = run(root, 'root') + // Then: actual bytes, allocator state, inventory and transform work are identical. + expect(stale.final).toEqual(cold.final) + expect(stale.reservations).toEqual(cold.reservations) + expect(stale.extracts).toEqual(cold.extracts) + expect(stale.events).toEqual(cold.events) + expect(stale.final.residual).toBe(0) + expect(stale.reservations.flatMap((batch) => batch.rows)).toContain( + 'src/entry.css.ts', + ) + expect(stale.reservations.length).toBeGreaterThan(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 150000) diff --git a/packages/webpack-plugin/src/__tests__/reservation.test.ts b/packages/webpack-plugin/src/__tests__/reservation.test.ts new file mode 100644 index 000000000..b43564b36 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/reservation.test.ts @@ -0,0 +1,65 @@ +import { dirname, relative, resolve } from 'node:path' + +import * as utils from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { expect, it, mock, spyOn } from 'bun:test' + +import { DevupUIWebpackPlugin } from '../plugin' +import { createWebpackReservation } from '../reservation' + +it('maps the physical inventory to the exact loader IDs, CSS directory and flags', () => { + const input = { + physicalPath: resolve('physical', 'A.js'), + filename: 'physical/A.js', + code: 'source', + } + const reserve = mock( + (discovery, options) => { + expect(discovery.roots).toEqual([ + resolve('src'), + resolve('app'), + resolve('context', 'src'), + resolve('context', 'app'), + ]) + expect(discovery.toId?.(input.physicalPath)).toBe('physical/A.js') + expect(discovery.include).toEqual(['@slot/ui']) + expect(discovery.package).toBe('@devup-ui/react') + expect(options.singleCss).toBe(true) + expect(options.importMainCssInCode).toBe(false) + expect(options.importMainCssInCss).toBe(true) + expect(options.cssDir(input)).toBe( + `./${relative(dirname(input.physicalPath), resolve('df', 'devup-ui')).replaceAll('\\', '/')}`, + ) + return true + }, + ) + const manager = { invalidate: mock(), reserve } + const create = spyOn(utils, 'createSharedSlotReservation').mockReturnValue( + manager, + ) + const setResolver = spyOn(wasm, 'setModuleResolver').mockReturnValue( + undefined, + ) + try { + const plugin = new DevupUIWebpackPlugin({ + include: ['@slot/ui'], + singleCss: true, + }) + const barrier = createWebpackReservation(plugin.options, {}) + barrier({ + context: resolve('context'), + entry: () => { + throw new Error('entry must stay opaque') + }, + }) + expect(create).toHaveBeenCalledWith({ + reserveClassSlots: wasm.reserveClassSlots, + }) + expect(manager.invalidate).toHaveBeenCalledTimes(1) + expect(reserve).toHaveBeenCalledTimes(1) + expect(setResolver).toHaveBeenCalledTimes(1) + } finally { + create.mockRestore() + setResolver.mockRestore() + } +}) diff --git a/packages/webpack-plugin/src/__tests__/watch-css.test.ts b/packages/webpack-plugin/src/__tests__/watch-css.test.ts new file mode 100644 index 000000000..f011b5659 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/watch-css.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' + +import * as wasm from '@devup-ui/wasm' +import { afterEach, expect, it, mock, spyOn } from 'bun:test' + +import { servedCss } from '../served-css' +import { reconcileWatchCss } from '../watch-css' + +type CssModule = { readonly resource?: unknown } +type Finish = (modules: Iterable) => Promise + +afterEach(() => mock.restore()) + +function compilation(error: Error | null = null) { + let finish: Finish | undefined + const rebuilt: CssModule[] = [] + const owner = { + hooks: { + finishModules: { + tapPromise( + _options: { readonly name: string; readonly stage: number }, + callback: Finish, + ) { + finish = callback + }, + }, + }, + rebuildModule(module: CssModule, callback: (error?: Error | null) => void) { + rebuilt.push(module) + callback(error) + }, + } + reconcileWatchCss(owner) + assert(finish) + return { owner, finish, rebuilt } +} + +it('leaves unrelated and already-current modules unchanged', async () => { + // Given: CSS that is current and modules outside the CSS loader record. + spyOn(wasm, 'getCss').mockReturnValue('current') + const { owner, finish, rebuilt } = compilation() + servedCss(owner).set('/devup-ui.css', 'current') + // When: native module completion reaches reconciliation. + await finish([ + {}, + { resource: 1 }, + { resource: '/other.css' }, + { resource: '/devup-ui.css' }, + ]) + // Then: no module needs rebuilding. + expect(rebuilt).toEqual([]) +}) + +it('rebuilds a stale CSS module before module completion resolves', async () => { + // Given: a module built before the final extraction updated the sheet. + spyOn(wasm, 'getCss').mockReturnValue('current') + const { owner, finish, rebuilt } = compilation() + const module = { resource: '/devup-ui.css' } + servedCss(owner).set(module.resource, 'stale') + // When: the final generation is reconciled. + await finish([module]) + // Then: native rebuilding completes for the stale module in this compilation. + expect(rebuilt).toEqual([module]) +}) + +it('rejects module completion when native CSS rebuilding fails', async () => { + // Given: a stale module whose genuine loader rebuild fails. + spyOn(wasm, 'getCss').mockReturnValue('current') + const error = new Error('CSS loader failed') + const { owner, finish } = compilation(error) + servedCss(owner).set('/devup-ui.css', 'stale') + // When: reconciliation awaits the failed rebuild. + const completion = finish([{ resource: '/devup-ui.css' }]) + // Then: the original failure propagates instead of publishing stale success. + await expect(completion).rejects.toBe(error) +}) diff --git a/packages/webpack-plugin/src/__tests__/watch-generation-observer.cjs b/packages/webpack-plugin/src/__tests__/watch-generation-observer.cjs new file mode 100644 index 000000000..80ad16e09 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/watch-generation-observer.cjs @@ -0,0 +1,137 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const Module = require('node:module') +const { basename, join } = require('node:path') +const wasm = require('@devup-ui/wasm') +const { getFileNumByFilename } = require('@devup-ui/plugin-utils') + +let generation = 0 +let baseBuilt = false +const pending = [] +let sequence = 0 +function record(event, data) { + if (process.env.DEVUP_WATCH_RECEIPT) + fs.appendFileSync( + process.env.DEVUP_WATCH_RECEIPT, + JSON.stringify( + { sequence: ++sequence, generation, event, ...data }, + (_, value) => (value === undefined ? { undefined: true } : value), + ) + '\n', + ) +} +function engine() { + const files = wasm.exportFileMap() + return { + sheet: wasm.exportSheet(), + classes: wasm.exportClassMap(), + files, + css: [null, ...new Set(Object.values(JSON.parse(files)))].map((number) => [ + number, + wasm.getCss(number, true), + ]), + residual: + typeof wasm.getUnreservedClassSlotCount === 'function' + ? wasm.getUnreservedClassSlotCount() + : null, + } +} +const extract = wasm.codeExtract +wasm.codeExtract = (...args) => { + const result = extract(...args) + record('extract', { + args, + output: Object.fromEntries( + ['code', 'css', 'map', 'dependencies', 'cssFile', 'updatedBaseStyle'].map( + (key) => [key, result[key]], + ), + ), + engine: engine(), + }) + return result +} +const load = Module._load +Module._load = function (request, parent, isMain) { + const result = load.call(this, request, parent, isMain) + if (request.endsWith('reservation-loaders.cjs')) { + const gate = function (source) { + if (!baseBuilt && /^[AB]\.css\.ts$/.test(basename(this.resourcePath))) { + const callback = this.async() + record('hold-layer', { + resource: this.resourcePath, + source: source.toString(), + }) + pending.push(() => callback(null, source)) + return + } + return result.call(this, source) + } + return Object.assign(gate, result) + } + if (!request.endsWith('/compiled/webpack/webpack')) return result + return { + ...result, + webpack(options) { + options.plugins.push({ + apply(compiler) { + compiler.hooks.thisCompilation.tap( + 'WatchGenerationObserver', + (compilation) => { + generation++ + baseBuilt = false + compilation.hooks.succeedModule.tap( + 'WatchGenerationObserver', + (module) => { + if (module.resource?.endsWith('.css')) { + record('css-module', { + resource: module.resource, + source: module.originalSource()?.source().toString(), + engine: engine(), + }) + if (basename(module.resource) === 'devup-ui.css') { + baseBuilt = true + for (const release of pending.splice(0)) release() + } + } + }, + ) + }, + ) + compiler.hooks.done.tap('WatchGenerationObserver', (stats) => { + const served = [ + ...(globalThis.__devupUiServedCss?.get(stats.compilation) ?? []), + ] + assert( + served.length > 0, + 'native generation must build CSS modules', + ) + const current = served.map(([path]) => [ + path, + wasm.getCss(getFileNumByFilename(path), true), + ]) + record('done', { + served, + current, + engine: engine(), + errors: stats.hasErrors(), + assets: Object.fromEntries( + Object.keys(stats.compilation.assets).map((name) => [ + name, + fs.readFileSync( + join(compiler.options.output.path, name), + 'utf8', + ), + ]), + ), + }) + assert.deepEqual( + served, + current, + 'every delivered watch generation must contain exact current CSS, including late layers', + ) + }) + }, + }) + return result.webpack(options) + }, + } +} diff --git a/packages/webpack-plugin/src/__tests__/watch-generation.test.ts b/packages/webpack-plugin/src/__tests__/watch-generation.test.ts new file mode 100644 index 000000000..5129e7526 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/watch-generation.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { it } from 'bun:test' + +for (const cssMode of ['single', 'per-file']) { + for (const order of ['AB', 'BA']) { + it(`delivers exact current ${cssMode} watch CSS with late layers in ${order} order`, () => { + // Given: the original native fixture and a genuine late .css.ts callback. + const archive = process.env.DEVUP_WATCH_ARCHIVE + const fixture = mkdtempSync( + join(archive ?? tmpdir(), `watch-${cssMode}-${order}-`), + ) + try { + // When: native webpack delivers the original initial and revised callbacks. + const child = Bun.spawnSync( + [ + 'node', + '--require', + join(import.meta.dir, 'watch-generation-observer.cjs'), + join(import.meta.dir, 'reservation-build.cjs'), + fixture, + order, + 'watch', + cssMode, + ], + { + stdout: 'pipe', + stderr: 'pipe', + timeout: 45000, + env: { + ...process.env, + DEVUP_WATCH_RECEIPT: join(fixture, 'receipt.jsonl'), + }, + }, + ) + if (archive) { + writeFileSync(`${fixture}.stdout.txt`, child.stdout) + writeFileSync(`${fixture}.stderr.txt`, child.stderr) + } + // Then: every callback's real CSS modules equal the complete current engine. + assert.equal( + child.exitCode, + 0, + child.stdout.toString() + child.stderr.toString(), + ) + } finally { + if (!archive) rmSync(fixture, { recursive: true, force: true }) + } + }, 50000) + } +} diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index f311537ce..175620b0f 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -46,7 +46,9 @@ import { } from '@devup-ui/wasm' import { type Compiler } from 'webpack' +import { createWebpackReservation } from './reservation' import { servedCss } from './served-css' +import { reconcileWatchCss } from './watch-css' export interface DevupUIWebpackPluginOptions { package: string @@ -60,20 +62,15 @@ export interface DevupUIWebpackPluginOptions { prefix?: string shorthands?: CustomShorthands /** - * Atom-level route-aware hoisting threshold. - * - * When set, a style atom whose content is reached by `>= atomHoist` distinct - * entries/routes is emitted once into the shared `devup-ui.css`; route-private - * atoms stay in their per-route chunk. Clamped to a minimum of 2 (an atom - * shared by `>= 2` routes is the smallest case worth hoisting). Omit to - * disable atom hoisting (identity behavior). + * Atom-level route-aware hoisting threshold. Defaults to 2; 0 disables it. + * Positive thresholds are clamped to >= 2. Canonical buckets reached by at + * least this many routes use global short counter names in devup-ui.css; + * other buckets keep per-file counter names in their per-route chunks. + * Fewer than two routes yields no hoisted plan. Ignored when singleCss is true. * * Composes with single-importer collapse: files used by exactly one importer * still merge into that importer's bucket (deduplicating their identical * atoms), and atom hoisting then shares atoms across the remaining buckets. - * - * Currently honored by the Next.js plugin; other bundlers wire it - * progressively. No effect where unsupported. */ atomHoist?: number /** @@ -104,7 +101,7 @@ export class DevupUIWebpackPlugin { singleCss = false, prefix, shorthands, - atomHoist, + atomHoist = 2, productionPrewarmFiles, importAliases: userImportAliases, }: Partial = {}) { @@ -281,20 +278,23 @@ export class DevupUIWebpackPlugin { } this.writeDataFiles() - // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any loader - // runs codeExtract (apply() body is synchronous, loaders run during - // compilation) so atoms receive global (shared) class names. The WASM + // Per-file atom hoisting defaults to 2; 0 disables it, singleCss ignores it. + // Configured before loaders run codeExtract (apply() is synchronous), so + // eligible atoms receive global short counter names. The WASM // instance is shared in-process with the loaders. Composes with // single-importer collapse: both keyed by the canonical bucket. The webpack // loader passes relative(process.cwd(), id) as the extraction filename, so // the graph maps use cwd-relative keys (keyBy: 'cwd-relative'). const atomHoist = this.options.atomHoist const atomMode = - atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 + !this.options.singleCss && + atomHoist !== undefined && + Number.isFinite(atomHoist) && + atomHoist > 0 // Single-importer collapse ALWAYS runs: files used by exactly one importer // merge into that importer's bucket, deduplicating their identical atoms. // The canonical map is built + imported unconditionally; only atom HOISTING - // composes on top when `atomHoist` is set. Mirrors next-plugin's pre-pass. + // composes on top in effective atom mode. Mirrors next-plugin's pre-pass. try { const srcDir = resolve(process.cwd(), 'src') const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') @@ -320,7 +320,7 @@ export class DevupUIWebpackPlugin { setAtomHoist(plan.threshold) } else { console.info( - '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + '[devup-ui] fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', ) } } @@ -337,26 +337,27 @@ export class DevupUIWebpackPlugin { // Number every file the build can extract in path order, so class // prefixes do not depend on which file a worker reaches first. Numbers // already handed out (a restored map in watch mode) stay. - try { - const cwd = process.cwd() - seedFileNumbers( - { seedFileMap }, - collectNumberedFiles({ - roots: [resolve(cwd, 'src')], - include: this.options.include, - cwd, - needles: extractedNeedles(this.options.package, this.importAliases), - toId: (path) => relative(cwd, path).replaceAll('\\', '/'), - }), - ) - } catch (error) { - if ( - !this.options.watch && - this.options.productionPrewarmFiles !== undefined - ) - throw error - // Best-effort; numbering falls back to arrival order. - } + if (this.options.debug) + try { + const cwd = process.cwd() + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve(cwd, 'src')], + include: this.options.include, + cwd, + needles: extractedNeedles(this.options.package, this.importAliases), + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch (error) { + if ( + !this.options.watch && + this.options.productionPrewarmFiles !== undefined + ) + throw error + // Best-effort; numbering falls back to arrival order. + } // Pre-warm the extractor so the css-loader serves COMPLETE CSS. // // Webpack builds a stylesheet module ONCE, at its FIRST import: the shared @@ -366,7 +367,10 @@ export class DevupUIWebpackPlugin { // (single shared WASM instance). Re-extraction by the per-file loader is // then idempotent (set-based atom dedup). Watch mode rebuilds stylesheets // through the files the loaders write instead. - if (!this.options.watch) { + const reserve = createWebpackReservation(this.options, this.importAliases) + const reserveBeforeCompile = () => { + reserve(compiler.options) + if (this.options.watch) return const { entry, context = process.cwd() } = compiler.options this.prewarmExtractor( typeof entry === 'function' @@ -376,8 +380,23 @@ export class DevupUIWebpackPlugin { ), ) } + compiler.hooks.run.tap( + 'DevupUIWebpackPluginReservation', + reserveBeforeCompile, + ) + if (!this.options.watch) + compiler.hooks.watchRun.tapPromise( + 'DevupUIWebpackPluginReservation', + async () => { + reserveBeforeCompile() + }, + ) if (this.options.watch) { + compiler.hooks.thisCompilation.tap( + 'DevupUIWebpackPluginWatchCss', + reconcileWatchCss, + ) let lastModifiedTime: string | null = null compiler.hooks.watchRun.tapPromise('DevupUIWebpackPlugin', async () => { const stamps: number[] = [] @@ -398,6 +417,7 @@ export class DevupUIWebpackPlugin { lastModifiedTime = modifiedTime } + reserve(compiler.options) }) } if (existsDevup || this.tailwindFiles.length > 0) diff --git a/packages/webpack-plugin/src/reservation.ts b/packages/webpack-plugin/src/reservation.ts new file mode 100644 index 000000000..8bc10b403 --- /dev/null +++ b/packages/webpack-plugin/src/reservation.ts @@ -0,0 +1,64 @@ +import { dirname, relative, resolve } from 'node:path' + +import { + createModuleResolver, + createSharedSlotReservation, + type WasmImportAliases, +} from '@devup-ui/plugin-utils' +import { reserveClassSlots, setModuleResolver } from '@devup-ui/wasm' +import type { Compiler } from 'webpack' + +import type { DevupUIWebpackPluginOptions } from './plugin' + +export function createWebpackReservation( + options: Omit, + importAliases: WasmImportAliases, +) { + const slots = createSharedSlotReservation({ reserveClassSlots }) + return ({ + context, + entry, + }: Pick) => { + const cwd = process.cwd() + const root = resolve(context ?? cwd) + const toId = (path: string) => relative(cwd, path).replaceAll('\\', '/') + setModuleResolver(createModuleResolver({ toId })) + slots.invalidate() + slots.reserve( + { + roots: [ + ...new Set([ + resolve(cwd, 'src'), + resolve(cwd, 'app'), + resolve(root, 'src'), + resolve(root, 'app'), + ]), + ], + entries: + typeof entry === 'function' + ? [] + : Object.values(entry ?? {}).flatMap(({ import: requests = [] }) => + requests.map((request) => ({ request, context: root })), + ), + include: options.include, + cwd, + tsconfigPath: resolve(cwd, 'tsconfig.json'), + package: options.package, + importAliases, + toId, + }, + { + singleCss: options.singleCss, + importMainCssInCode: false, + importMainCssInCss: true, + cssDir: ({ physicalPath }) => { + const directory = relative( + dirname(physicalPath), + options.cssDir, + ).replaceAll('\\', '/') + return directory.startsWith('./') ? directory : `./${directory}` + }, + }, + ) + } +} diff --git a/packages/webpack-plugin/src/watch-css.ts b/packages/webpack-plugin/src/watch-css.ts new file mode 100644 index 000000000..c3b6b4d7e --- /dev/null +++ b/packages/webpack-plugin/src/watch-css.ts @@ -0,0 +1,45 @@ +import { getFileNumByFilename } from '@devup-ui/plugin-utils' +import { getCss } from '@devup-ui/wasm' + +import { servedCss } from './served-css' + +interface WatchCssCompilation { + readonly hooks: { + readonly finishModules: { + tapPromise( + options: { readonly name: string; readonly stage: number }, + callback: (modules: Iterable) => Promise, + ): void + } + } + rebuildModule(module: Module, callback: (error?: Error | null) => void): void +} + +export function reconcileWatchCss( + compilation: WatchCssCompilation, +): void { + compilation.hooks.finishModules.tapPromise( + { name: 'DevupUIWebpackPluginWatchCss', stage: Number.MAX_SAFE_INTEGER }, + async (modules) => { + const served = servedCss(compilation) + await Promise.all( + [...modules].map(async (module) => { + if (!('resource' in module) || typeof module.resource !== 'string') + return + const css = served.get(module.resource) + if ( + css === undefined || + css === getCss(getFileNumByFilename(module.resource), true) + ) + return + await new Promise((resolve, reject) => { + compilation.rebuildModule(module, (error) => { + if (error) reject(error) + else resolve() + }) + }) + }), + ) + }, + ) +}