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/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index da96d5bd1..e66ac1b12 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, @@ -332,6 +337,7 @@ pub fn seed_file_map(files: Vec) { /// and the module resolver, so the next build starts from its own options /// alone. Theme, shorthands and debug mode are set by every build, and stay. pub fn reset_build_state_internal() { + css::slot_reservation::reset(); css::class_map::reset_class_map(); css::file_map::reset_file_map(); css::file_map::reset_canonical_map(); diff --git a/bindings/devup-ui-wasm/src/reservation.rs b/bindings/devup-ui-wasm/src/reservation.rs new file mode 100644 index 000000000..bbc28022f --- /dev/null +++ b/bindings/devup-ui-wasm/src/reservation.rs @@ -0,0 +1,129 @@ +//! 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 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(&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..5844b0ec5 --- /dev/null +++ b/bindings/devup-ui-wasm/src/reservation/tests.rs @@ -0,0 +1,464 @@ +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), (false, false), (false, true)] { + assert_eq!(build([0, 1], single, hoist)?, build([1, 0], single, hoist)?); + } + 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 sheet_only_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'; ;", + ); + extract_without_source_map(&token.filename, &token.code, option.clone())?; + let extracted_slots: usize = css::class_map::get_class_map() + .values() + .map(HashMap::len) + .sum(); + // 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!(reserved_slots > extracted_slots); + let output = transform( + &input( + "tokens.tsx", + "import {Box} from '@devup-ui/react'; ;", + ), + &option, + )?; + assert!( + output + .css + .ok_or("expected token CSS")? + .contains("width:8px") + ); + 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/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/lib.rs b/libs/css/src/lib.rs index d830648b6..ea0385dae 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -11,6 +11,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 +418,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 +430,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) } }) 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/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 4628bc63c..b225216d6 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; 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/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__/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/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__/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..6d40b9257 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' @@ -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()) @@ -858,10 +825,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') }, }, } @@ -934,6 +904,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..0a9f99df6 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 { @@ -260,40 +258,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 +300,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 +334,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/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 29a8095c0..e1412c08b 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() @@ -223,6 +235,18 @@ describe('devupUIWebpackPlugin', () => { }) }) + 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], @@ -325,9 +349,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 +548,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() @@ -666,6 +694,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 +705,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 +771,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 +802,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 +830,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 +859,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 +891,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 +988,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 +1001,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..9996dfb2c --- /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()], +}) +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..34fbe993d 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 @@ -337,26 +339,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 +369,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 +382,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 +419,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() + }) + }) + }), + ) + }, + ) +}