diff --git a/.changepacks/changepack_log_deterministic_numbering.json b/.changepacks/changepack_log_deterministic_numbering.json new file mode 100644 index 000000000..053739d3d --- /dev/null +++ b/.changepacks/changepack_log_deterministic_numbering.json @@ -0,0 +1,13 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch" + }, + "note": "Class names and file numbers no longer depend on the order files are processed in. Every plugin walks the files it can extract (the project's source and the included packages) before the first transform and numbers them in path order (a file collapsed into a bucket is numbered as the bucket, and only files that mention the package, StyleX or an aliased package take numbers) through the new seedFileMap, so identical builds give identical class prefixes, CSS file names and output hashes; in development, files that appear later are numbered after the existing ones. Styles are given class names in a fixed order, not the order of a hash set. The exported class, file and canonical maps are canonical JSON (sorted keys). Stylesheet evaluation (vanilla-extract) refuses Math.random(), Date.now(), new Date() and Date(), which differ between builds, with a build error; fixed dates such as new Date(0) are unchanged. resetBuildState starts a build from its own options: the prefix, atom hoisting, routes, buckets, names, numbers and styles an earlier build in the process left are cleared, and the prefix is set on every setup even when it is absent. Engine snapshots from concurrent transforms are written one after another per file, so an older snapshot is never written last. File lists are ordered by code point rather than the locale", + "date": "2026-10-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/apps/landing/src/app/(detail)/docs/build-errors/page.mdx b/apps/landing/src/app/(detail)/docs/build-errors/page.mdx index 753b69bbc..9dabc9992 100644 --- a/apps/landing/src/app/(detail)/docs/build-errors/page.mdx +++ b/apps/landing/src/app/(detail)/docs/build-errors/page.mdx @@ -1902,15 +1902,14 @@ export function f() { const c = css; c = () => 1; return c({ color: "red" }); } **Why:** A followed alias cannot be reassigned after the rewrite removes its declaration. -**Fix:** Keep the const alias unchanged and call it. +**Fix:** Call the imported css directly instead of creating a local runtime alias. {/* probe: mutated-const-alias after */} ```tsx import { css } from '@devup-ui/react' export function f() { - const c = css - return c({ color: 'red' }) + return css({ color: 'red' }) } ``` @@ -3926,7 +3925,7 @@ The `devup/no-runtime-read` rule reports compile-time bindings read outside supp `` is read at runtime, where it does not exist: the build compiles it only where it is called or rendered. ``` -```tsx +```tsx-error import { css } from '@devup-ui/react' consume(css) ``` diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 0ffb62ce1..c2546d26c 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -243,7 +243,14 @@ pub fn export_sheet() -> Result { /// Internal function to export class map as JSON string (testable without `JsValue`) pub fn export_class_map_internal() -> Result { - with_class_map(serde_json::to_string).map_err(|e| e.to_string()) + with_class_map(|map| { + let sorted: BTreeMap<&String, BTreeMap<&String, &usize>> = map + .iter() + .map(|(file, classes)| (file, classes.iter().collect())) + .collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importClassMap")] @@ -261,7 +268,11 @@ pub fn export_class_map() -> Result { /// Internal function to export file map as JSON string (testable without `JsValue`) pub fn export_file_map_internal() -> Result { - with_file_map(serde_json::to_string).map_err(|e| e.to_string()) + with_file_map(|map| { + let sorted: BTreeMap<&String, &usize> = map.iter().collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importFileMap")] @@ -284,7 +295,11 @@ pub fn import_canonical_map_internal(map: HashMap) { /// Internal function to export the canonical map as JSON string (testable without `JsValue`) pub fn export_canonical_map_internal() -> Result { - with_canonical_map(serde_json::to_string).map_err(|e| e.to_string()) + with_canonical_map(|map| { + let sorted: BTreeMap<&String, &String> = map.iter().collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importCanonicalMap")] @@ -300,6 +315,34 @@ pub fn export_canonical_map() -> Result { export_canonical_map_internal().map_err(js_error) } +/// Number every file in `files` now, in path order, keeping numbers files +/// already hold, so class prefixes depend on the paths and not on the order +/// workers reach files in. +#[wasm_bindgen(js_name = "seedFileMap")] +pub fn seed_file_map(files: Vec) { + css::file_map::seed_file_numbers(&files); +} + +/// Forget everything one build left in the engine. +/// +/// That is names, numbers, styles, buckets, routes, the prefix, atom hoisting +/// and the module resolver, so the next build starts from its own options +/// alone. Theme, shorthands and debug mode are set by every build, and stay. +pub fn reset_build_state_internal() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::file_routes::set_file_routes(HashMap::new()); + css::atom_hoist::set_atom_hoist(None); + css::set_prefix(None); + with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); + MODULE_RESOLVER.with_borrow_mut(|current| *current = None); +} + +#[wasm_bindgen(js_name = "resetBuildState")] +pub fn reset_build_state() { + reset_build_state_internal(); +} /// Set the atom-level hoist threshold. /// /// When set to `Some(n)`, a style atom whose content is used by `>= n` distinct @@ -1929,6 +1972,60 @@ mod tests { assert!(json_str.starts_with('{') || json_str.starts_with("[]")); } + #[test] + #[serial] + fn test_exported_maps_are_canonical_json() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::class_map::set_class_map(HashMap::from([ + ( + "b.tsx".to_string(), + HashMap::from([("z".to_string(), 1), ("a".to_string(), 0)]), + ), + ("a.tsx".to_string(), HashMap::from([("k".to_string(), 0)])), + ])); + assert_eq!( + export_class_map_internal().unwrap(), + r#"{"a.tsx":{"k":0},"b.tsx":{"a":0,"z":1}}"# + ); + seed_file_map(vec!["b.tsx".to_string(), "a.tsx".to_string()]); + assert_eq!( + export_file_map_internal().unwrap(), + r#"{"a.tsx":0,"b.tsx":1}"# + ); + import_canonical_map_internal(HashMap::from([ + ("y".to_string(), "b".to_string()), + ("x".to_string(), "a".to_string()), + ])); + assert_eq!( + export_canonical_map_internal().unwrap(), + r#"{"x":"a","y":"b"}"# + ); + reset_build_state(); + assert_eq!(export_class_map_internal().unwrap(), "{}"); + assert_eq!(export_file_map_internal().unwrap(), "{}"); + assert_eq!(export_canonical_map_internal().unwrap(), "{}"); + assert_eq!(get_prefix(), None); + } + + #[test] + #[serial] + fn test_numbers_do_not_depend_on_the_order_files_are_seen() { + let files = ["src/b.tsx", "src/a.tsx", "src/c.tsx"]; + let mut maps = Vec::new(); + for order in [[0, 1, 2], [2, 1, 0], [1, 2, 0]] { + reset_build_state_internal(); + seed_file_map(files.iter().map(ToString::to_string).collect()); + for index in order { + let _ = css::file_map::get_file_num_by_filename(files[index]); + } + maps.push(export_file_map_internal().unwrap()); + } + assert_eq!(maps[0], r#"{"src/a.tsx":0,"src/b.tsx":1,"src/c.tsx":2}"#); + assert_eq!(maps[0], maps[1]); + assert_eq!(maps[0], maps[2]); + } #[test] #[serial] fn test_code_extract_internal_success() { diff --git a/e2e/tailwind-hover-order.spec.ts b/e2e/tailwind-hover-order.spec.ts index 803106f74..a3514b427 100644 --- a/e2e/tailwind-hover-order.spec.ts +++ b/e2e/tailwind-hover-order.spec.ts @@ -154,6 +154,7 @@ for (const singleCss of [true, false]) { for (const fixture of cases) { for (const [index, width] of [640, 1024].entries()) { const label = 'props' in fixture ? 'explicit Devup guard: ' : '' + const devupColorIndex = 'props' in fixture ? 1 : 0 test(`${label}${fixture.classes}, ${width}px, singleCss=${singleCss}`, async ({ page, }, testInfo) => { @@ -191,7 +192,7 @@ for (const singleCss of [true, false]) { const observed = [] for (const [name, classes, css, color] of [ ['Tailwind', fixture.classes, oracleCss, expected[0]], - ['Devup', matched?.[1], devupCss, expected[1]], + ['Devup', matched?.[1], devupCss, expected[devupColorIndex]], ]) { await page.setContent( ``, @@ -207,14 +208,14 @@ for (const singleCss of [true, false]) { } else if (!disabled) { await expect(target).toHaveCSS( 'background-color', - colors.red[name === 'Tailwind' ? 0 : 1], + colors.red[name === 'Tailwind' ? 0 : devupColorIndex], ) } if (fixture.classes.includes('focus:')) { await target.focus() await expect(target).toHaveCSS( 'background-color', - colors.green[name === 'Tailwind' ? 0 : 1], + colors.green[name === 'Tailwind' ? 0 : devupColorIndex], ) } await page.mouse.down() diff --git a/libs/css/src/file_map.rs b/libs/css/src/file_map.rs index 2a8c86a94..f0a20a939 100644 --- a/libs/css/src/file_map.rs +++ b/libs/css/src/file_map.rs @@ -93,6 +93,19 @@ pub fn get_file_num_by_filename(filename: &str) -> usize { }) } +/// Give every file in `files` a number now, in path order. +/// +/// Numbers files already hold are kept, and a file collapsed into a bucket is numbered as the bucket. The numbers files get then depend on the paths alone, +/// not on which file a worker reaches first; files that appear later (in +/// development) are numbered after the existing ones. +pub fn seed_file_numbers(files: &[String]) { + let mut sorted: Vec = files.iter().map(|file| canonical(file)).collect(); + sorted.sort_unstable(); + sorted.dedup(); + for file in sorted { + let _ = get_file_num_by_filename(&file); + } +} #[must_use] pub fn get_filename_by_file_num(file_num: usize) -> String { with_file_map(|map| { @@ -196,6 +209,47 @@ mod tests { use super::*; + #[test] + #[serial] + fn test_seed_file_numbers_numbers_buckets_not_their_members() { + reset_file_map(); + set_canonical_map(std::collections::HashMap::from([( + "x.tsx".to_string(), + "a.tsx".to_string(), + )])); + seed_file_numbers(&[ + "x.tsx".to_string(), + "b.tsx".to_string(), + "a.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("b.tsx"), 1); + assert_eq!(get_file_map().len(), 2); + reset_canonical_map(); + reset_file_map(); + } + + #[test] + #[serial] + fn test_seed_file_numbers_is_independent_of_order() { + reset_file_map(); + seed_file_numbers(&[ + "b.tsx".to_string(), + "a.tsx".to_string(), + "b.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("b.tsx"), 1); + seed_file_numbers(&[ + "c.tsx".to_string(), + "0.tsx".to_string(), + "a.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("0.tsx"), 2); + assert_eq!(get_file_num_by_filename("c.tsx"), 3); + reset_file_map(); + } #[test] #[serial] fn test_set_and_get_file_map() { diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 3e4fb9c6a..398cf643c 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -13716,6 +13716,30 @@ globalCss({ assert!(!output.code.contains("css("), "{}", output.code); } + #[test] + #[serial] + fn test_stylesheets_read_nothing_that_differs_between_builds() { + for (read, fails) in [ + ("Math.random()", true), + ("Date.now()", true), + ("new Date()", true), + ("Date()", true), + ("new Date(0).getTime()", false), + ("Date.UTC(2020, 0, 1)", false), + ] { + reset_class_map(); + reset_file_map(); + let result = extract( + "when.css.ts", + &format!( + "import {{ style }} from '@devup-ui/react';\nconst n = {read};\nexport const a = style({{ opacity: String(n) }});" + ), + ExtractOption::default(), + ); + assert_eq!(result.is_err(), fails, "{read}"); + } + } + #[test] #[serial] fn test_vanilla_extract_execution_fallback() { diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index 4082ff395..f64390a8f 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -21,6 +21,21 @@ pub(crate) const PACKAGE_BINDING: &str = "__vanilla_extract__"; /// A console whose calls do nothing, as what a module logs while it runs /// changes no value +/// Run before a stylesheet: what differs between builds fails the evaluation, +/// so a stylesheet never gives different rules for the same source. `new +/// Date(0)` and other fixed dates stay. +pub(crate) const DETERMINISM: &str = r#"(() => { + const refuse = (name) => () => { throw new Error(`${name} gives a different value each build: a stylesheet cannot read it`); }; + Math.random = refuse("Math.random"); + const RealDate = Date; + globalThis.Date = new Proxy(RealDate, { + construct: (target, args, newTarget) => args.length === 0 ? refuse("new Date()")() : Reflect.construct(target, args, newTarget), + apply: refuse("Date()"), + get: (target, key, receiver) => key === "now" ? refuse("Date.now") : Reflect.get(target, key, receiver), + }); +})(); +"#; + pub(crate) const CONSOLE: &str = "if (typeof console === \"undefined\") globalThis.console = new Proxy({}, { get: () => () => undefined });\n"; /// Loop iterations an evaluation may run before it fails instead of hanging diff --git a/libs/extractor/src/tailwind_children.rs b/libs/extractor/src/tailwind_children.rs index 78d266b05..6ae20a798 100644 --- a/libs/extractor/src/tailwind_children.rs +++ b/libs/extractor/src/tailwind_children.rs @@ -124,6 +124,7 @@ pub fn children_utility(name: &str) -> Option> { #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use rstest::rstest; + use serial_test::serial; use super::*; use crate::tailwind::{declarations_of, parse_class}; @@ -177,6 +178,7 @@ mod tests { ])] #[case("space-x-reverse", &[("--tw-space-x-reverse", "1")])] #[case("space-y-reverse", &[("--tw-space-y-reverse", "1")])] + #[serial] fn space_adds_margins_between_the_children( #[case] class: &str, #[case] expected: &[(&str, &str)], @@ -229,6 +231,7 @@ mod tests { } #[test] + #[serial] fn divide_reverse_style_and_color() { expect("divide-x-reverse", &[("--tw-divide-x-reverse", "1")]); expect("divide-y-reverse", &[("--tw-divide-y-reverse", "1")]); @@ -268,11 +271,13 @@ mod tests { #[case("space-x-huge")] #[case("-space-x-reverse")] #[case("space-z-4")] + #[serial] fn unknown_children_utilities_stay_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } #[test] + #[serial] fn children_utilities_apply_under_variants_to_the_children() { let class = parse_class("space-x-4").unwrap(); assert_eq!(class.conditions.len(), 1); @@ -310,6 +315,7 @@ mod tests { } #[test] + #[serial] fn children_rules_register_their_variables() { assert_eq!( parse_class("space-x-4").unwrap().rules(), diff --git a/libs/extractor/src/tailwind_color.rs b/libs/extractor/src/tailwind_color.rs index fa48bf6b0..cca7c4ddd 100644 --- a/libs/extractor/src/tailwind_color.rs +++ b/libs/extractor/src/tailwind_color.rs @@ -437,6 +437,7 @@ mod tests { #[case("caret-fuchsia-100", "oklch(95.2% 0.037 318.852)")] #[case("fill-zinc-900", "oklch(21% 0.006 285.885)")] #[case("stroke-stone-200", "oklch(92.3% 0.003 48.717)")] + #[serial] fn palette_is_the_default_theme_of_v4(#[case] class: &str, #[case] expected: &str) { assert_eq!(color(class).as_deref(), Some(expected)); } @@ -474,6 +475,7 @@ mod tests { )] #[case("bg-black/10", "color-mix(in oklab, #000 10%, transparent)")] #[case("text-white/75", "color-mix(in oklab, #fff 75%, transparent)")] + #[serial] fn opacity_modifier_mixes_with_transparent(#[case] class: &str, #[case] expected: &str) { assert_eq!(color(class).as_deref(), Some(expected)); } @@ -489,6 +491,7 @@ mod tests { #[case("bg-red-500/50/50")] #[case("bg-red-501")] #[case("bg-unknown")] + #[serial] fn invalid_color_stays_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } @@ -541,6 +544,7 @@ mod tests { ("--tw-blur", "blur(8px) !important"), ("filter", "var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,) !important"), ])] + #[serial] fn important_marks_every_declaration(#[case] class: &str, #[case] expected: &[(&str, &str)]) { let expected: Vec<(String, String)> = expected .iter() @@ -550,6 +554,7 @@ mod tests { } #[test] + #[serial] fn important_applies_under_variants() { let class = crate::tailwind::parse_class("hover:p-4!").unwrap(); assert_eq!(class.declarations, vec![decl("padding", "1rem !important")]); diff --git a/libs/extractor/src/tailwind_effects.rs b/libs/extractor/src/tailwind_effects.rs index 8b55e4290..90ffd4c4b 100644 --- a/libs/extractor/src/tailwind_effects.rs +++ b/libs/extractor/src/tailwind_effects.rs @@ -705,6 +705,7 @@ mod tests { #[case("blur-none", " ")] #[case("blur-[2px]", "blur(2px)")] #[case("blur-(--radius)", "blur(var(--radius))")] + #[serial] fn blur_composes_through_its_variable(#[case] class: &str, #[case] value: &str) { assert_eq!(written(class), filter("--tw-blur", value)); } @@ -729,6 +730,7 @@ mod tests { #[case("saturate-150", "--tw-saturate", "saturate(150%)")] #[case("sepia", "--tw-sepia", "sepia(100%)")] #[case("sepia-50", "--tw-sepia", "sepia(50%)")] + #[serial] fn filter_functions_set_their_own_variable( #[case] class: &str, #[case] variable: &str, @@ -764,6 +766,7 @@ mod tests { #[case("backdrop-opacity-12.5", "--tw-backdrop-opacity", "opacity(12.5%)")] #[case("backdrop-saturate-200", "--tw-backdrop-saturate", "saturate(200%)")] #[case("backdrop-sepia", "--tw-backdrop-sepia", "sepia(100%)")] + #[serial] fn backdrop_filter_functions_compose_too( #[case] class: &str, #[case] variable: &str, @@ -773,6 +776,7 @@ mod tests { } #[test] + #[serial] fn filter_and_backdrop_filter_read_every_function() { expect("filter", &[("filter", FILTER)]); expect("filter-none", &[("filter", "none")]); @@ -804,6 +808,7 @@ mod tests { } #[test] + #[serial] fn two_filters_add_up_instead_of_overwriting() { let blur = written("blur-sm"); let grayscale = written("grayscale"); @@ -852,6 +857,7 @@ mod tests { #[case("-filter-none")] #[case("backdrop-")] #[case("blurry")] + #[serial] fn unknown_filters_stay_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } @@ -884,6 +890,7 @@ mod tests { "0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25))" )] #[case("shadow-none", "0 0 #0000")] + #[serial] fn shadow_sizes_follow_the_default_theme(#[case] class: &str, #[case] shadow: &str) { expect( class, @@ -927,11 +934,13 @@ mod tests { "shadow-current", "color-mix(in oklab, currentcolor var(--tw-shadow-alpha), transparent)" )] + #[serial] fn shadow_colors_set_the_color_the_shadows_read(#[case] class: &str, #[case] color: &str) { expect(class, &[("--tw-shadow-color", color)]); } #[test] + #[serial] fn shadow_inherit_and_unknown_shadows() { expect("shadow-inherit", &[("--tw-shadow-color", "inherit")]); for class in [ @@ -984,6 +993,7 @@ mod tests { #[case("bg-linear-to-r/increasing", "to right in oklch increasing hue")] #[case("bg-linear-to-r/decreasing", "to right in oklch decreasing hue")] #[case("bg-linear-90/[in_hsl_longer_hue]", "90deg in hsl longer hue")] + #[serial] fn linear_gradients_use_the_syntax_of_v4(#[case] class: &str, #[case] position: &str) { expect( class, @@ -998,6 +1008,7 @@ mod tests { } #[test] + #[serial] fn conic_and_radial_gradients() { for (class, position, image) in [ ( @@ -1047,6 +1058,7 @@ mod tests { } #[test] + #[serial] fn arbitrary_gradients_carry_their_own_stops() { expect( "bg-linear-[25deg,red_5%,yellow_60%,lime_90%,teal]", @@ -1102,6 +1114,7 @@ mod tests { #[case("bg-radial-45")] #[case("bg-conic-x")] #[case("bg-sweep-45")] + #[serial] fn gradients_v4_does_not_define_stay_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } @@ -1110,6 +1123,7 @@ mod tests { const VIA_STOPS: &str = "var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position)"; #[test] + #[serial] fn gradient_stops_compose_through_variables() { expect( "from-red-500", @@ -1157,6 +1171,7 @@ mod tests { } #[test] + #[serial] fn gradient_stop_positions() { expect("from-10%", &[("--tw-gradient-from-position", "10%")]); expect("via-30%", &[("--tw-gradient-via-position", "30%")]); @@ -1178,11 +1193,13 @@ mod tests { #[case("-from-red-500")] #[case("via-none/50")] #[case("up-red-500")] + #[serial] fn unknown_gradient_stops_stay_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } #[test] + #[serial] fn gradient_rules_register_the_stop_variables() { let rules = parse_class("from-red-500").unwrap().rules(); for name in [ @@ -1211,6 +1228,7 @@ mod tests { } #[test] + #[serial] fn modifiers_and_variants_apply_to_composed_utilities() { let class = parse_class("hover:blur-sm").unwrap(); assert_eq!(class.declarations, written_pairs("blur-sm")); @@ -1237,6 +1255,7 @@ mod tests { #[case("ring-2", "2px")] #[case("ring-8", "8px")] #[case("ring-[3px]", "3px")] + #[serial] fn ring_widths_draw_the_ring_composed_with_the_shadows( #[case] class: &str, #[case] width: &str, @@ -1256,6 +1275,7 @@ mod tests { } #[test] + #[serial] fn ring_colors_inset_rings_and_offsets() { expect("ring-inset", &[("--tw-ring-inset", "inset")]); expect( @@ -1327,6 +1347,7 @@ mod tests { #[case("ring-offset-nothing")] #[case("inset-ring-nothing")] #[case("-ring-2")] + #[serial] fn unknown_rings_stay_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } @@ -1338,6 +1359,7 @@ mod tests { #[case("drop-shadow-lg", "0 4px 4px", "rgb(0 0 0 / 0.15)")] #[case("drop-shadow-xl", "0 9px 7px", "rgb(0 0 0 / 0.1)")] #[case("drop-shadow-2xl", "0 25px 25px", "rgb(0 0 0 / 0.15)")] + #[serial] fn drop_shadow_sizes_compose_into_filter( #[case] class: &str, #[case] offsets: &str, @@ -1359,6 +1381,7 @@ mod tests { ); } #[test] + #[serial] fn drop_shadow_without_a_size_none_colors_and_arbitrary_values() { expect( "drop-shadow", diff --git a/libs/extractor/src/tailwind_misc.rs b/libs/extractor/src/tailwind_misc.rs index 2b05db026..d4ef73a18 100644 --- a/libs/extractor/src/tailwind_misc.rs +++ b/libs/extractor/src/tailwind_misc.rs @@ -328,6 +328,7 @@ fn plain(name: &str) -> Option> { #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use rstest::rstest; + use serial_test::serial; use crate::tailwind::declarations_of; @@ -365,6 +366,7 @@ mod tests { #[case("outline-double", &[("--tw-outline-style", "double"), ("outline-style", "double")])] #[case("outline-none", &[("--tw-outline-style", "none"), ("outline-style", "none")])] #[case("line-clamp-none", &[("overflow", "visible"), ("display", "block"), ("-webkit-box-orient", "horizontal"), ("-webkit-line-clamp", "unset")])] + #[serial] fn fixed_utilities(#[case] class: &str, #[case] expected: &[(&str, &str)]) { expect(class, expected); } @@ -373,6 +375,7 @@ mod tests { #[case("line-clamp-3", "3")] #[case("line-clamp-(--lines)", "var(--lines)")] #[case("line-clamp-[calc(1+1)]", "calc(1 + 1)")] + #[serial] fn line_clamp_clamps_to_a_number_of_lines(#[case] class: &str, #[case] lines: &str) { expect( class, @@ -386,6 +389,7 @@ mod tests { } #[test] + #[serial] fn text_decoration_offsets_and_outlines() { expect("underline-offset-4", &[("text-underline-offset", "4px")]); expect( @@ -451,6 +455,7 @@ mod tests { #[case("border-r-8", "border-right-style", "border-right-width", "8px")] #[case("border-b-[2px]", "border-bottom-style", "border-bottom-width", "2px")] #[case("border-l-2", "border-left-style", "border-left-width", "2px")] + #[serial] fn border_widths_draw_in_the_border_style( #[case] class: &str, #[case] style: &str, @@ -464,6 +469,7 @@ mod tests { } #[test] + #[serial] fn border_styles_set_the_style_the_widths_draw_in() { for style in ["solid", "dashed", "dotted", "double", "hidden", "none"] { expect( @@ -474,6 +480,7 @@ mod tests { } #[test] + #[serial] fn border_spacing_sets_both_axes_unless_one_is_named() { expect( "border-spacing-2", @@ -509,6 +516,7 @@ mod tests { } #[test] + #[serial] fn logical_sides_scroll_spacing_and_indent() { expect("start-4", &[("inset-inline-start", "1rem")]); expect("end-0", &[("inset-inline-end", "0px")]); @@ -574,6 +582,7 @@ mod tests { #[case("-indent")] #[case("-table-auto")] #[case("antialiased-")] + #[serial] fn unknown_utilities_stay_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } diff --git a/libs/extractor/src/tailwind_motion.rs b/libs/extractor/src/tailwind_motion.rs index 7f8ba011c..40330277d 100644 --- a/libs/extractor/src/tailwind_motion.rs +++ b/libs/extractor/src/tailwind_motion.rs @@ -160,6 +160,7 @@ pub fn keyframes_of(declarations: &[Declaration]) -> impl Iterator = styles.iter().collect(); + ordered.sort_unstable(); + for style in ordered { match style { // A conditional `typography` preset: its class is the atom, and the // preset's declarations are emitted under the atom's selector. @@ -2935,6 +2939,44 @@ mod tests { assert_debug_snapshot!(sheet.create_css(None, true).split("*/").nth(1).unwrap()); } + #[test] + #[serial] + fn test_class_names_do_not_depend_on_the_order_styles_are_inserted() { + use extractor::extract_style::extract_static_style::ExtractStaticStyle; + use extractor::extract_style::extract_style_value::ExtractStyleValue; + + let values: Vec = (0..40) + .map(|index| { + ExtractStyleValue::Static(ExtractStaticStyle::new( + ["color", "margin", "padding", "width"][index % 4], + &format!("{index}px"), + 0, + None, + )) + }) + .collect(); + let mut outputs = Vec::new(); + for reverse in [false, true] { + css::class_map::reset_class_map(); + let mut styles = FxHashSet::default(); + if reverse { + values.iter().rev().for_each(|value| { + styles.insert(value.clone()); + }); + } else { + values.iter().for_each(|value| { + styles.insert(value.clone()); + }); + } + let mut sheet = StyleSheet::default(); + sheet.update_styles(&styles, "index.tsx", true); + outputs.push(( + sheet.create_css(None, true), + css::class_map::get_class_map(), + )); + } + assert_eq!(outputs[0], outputs[1]); + } #[test] #[serial] fn test_update_styles_with_typography() { @@ -3412,23 +3454,23 @@ mod tests { #[rstest] #[case( "hover:bg-red-500 active:bg-blue-500", - "@media(hover:hover){.c0:hover{background-color:#EF4444}}.c1:active{background-color:#3B82F6}" + "@media(hover:hover){.c0:hover{background-color:oklch(63.7% .237 25.331)}}.c1:active{background-color:oklch(62.3% .214 259.815)}" )] #[case( "md:hover:bg-red-500 active:bg-blue-500", - ".c0:active{background-color:#3B82F6}@media(min-width:768px)and (hover:hover){.c1:hover{background-color:#EF4444}}" + ".c0:active{background-color:oklch(62.3% .214 259.815)}@media(min-width:768px)and (hover:hover){.c1:hover{background-color:oklch(63.7% .237 25.331)}}" )] #[case( "hover:bg-red-500 focus:bg-green-500 active:bg-blue-500", - "@media(hover:hover){.c0:hover{background-color:#EF4444}}.c1:focus{background-color:#22C55E}.c2:active{background-color:#3B82F6}" + "@media(hover:hover){.c0:hover{background-color:oklch(63.7% .237 25.331)}}.c1:focus{background-color:oklch(72.3% .219 149.579)}.c2:active{background-color:oklch(62.3% .214 259.815)}" )] #[case( "hover:bg-red-500 disabled:bg-gray-500", - "@media(hover:hover){.c0:hover{background-color:#EF4444}}.c1:disabled{background-color:#6B7280}" + "@media(hover:hover){.c0:hover{background-color:oklch(63.7% .237 25.331)}}.c1:disabled{background-color:oklch(55.1% .027 264.364)}" )] #[case( "dark:hover:bg-red-500 active:bg-blue-500", - "@media(hover:hover){:root[data-theme=dark] .c0:hover{background-color:#EF4444}}.c1:active{background-color:#3B82F6}" + "@media(hover:hover){:root[data-theme=dark] .c0:hover{background-color:oklch(63.7% .237 25.331)}}.c1:active{background-color:oklch(62.3% .214 259.815)}" )] #[serial] fn tailwind_states_keep_css_order_when_hover_is_guarded( diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 226f98be4..7d0025330 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -3,12 +3,15 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join, relative, resolve } from 'node:path' import { + beginBuild, + collectNumberedFiles, createCompatTypes, createModuleResolver, createThemeInterfaceArgs, type CustomShorthands, loadDevupConfig, mergeImportAliases, + seedFileNumbers, withTailwindCss, } from '@devup-ui/plugin-utils' import { @@ -17,8 +20,11 @@ import { getThemeInterface, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setDebug, setModuleResolver, + setPrefix, } from '@devup-ui/wasm' import { type BunPlugin, plugin, type PluginBuilder } from 'bun' @@ -74,7 +80,22 @@ async function writeDataFiles() { async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { registerShorthands(shorthands ?? {}) + setPrefix(null) setModuleResolver(createModuleResolver()) + // Number every source file in path order, so class prefixes do not depend + // on the order Bun loads files in + try { + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve('src')], + 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( @@ -150,6 +171,10 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { async setup(build: PluginBuilder) { // `Bun.build` hands its config to plugins; the runtime has none const bundling = build.config !== undefined + // A build starts from its own options, not from what an earlier build in + // this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + build.onEnd?.(endBuild) await initialize(options) setDebug(options.debug ?? !bundling) diff --git a/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts new file mode 100644 index 000000000..b18a334c8 --- /dev/null +++ b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts @@ -0,0 +1,145 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' + +import { collectNumberedFiles, seedFileNumbers } from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' + +const sources: Record = { + 'src/a.tsx': `import { Box } from '@devup-ui/react'\nexport const A = () => `, + 'src/b.tsx': `import { Box } from '@devup-ui/react'\nexport const B = () => `, + 'src/nested/c.tsx': `import { Box } from '@devup-ui/react'\nexport const C = () => `, + 'src/nested/d.tsx': `import { Box, Flex } from '@devup-ui/react'\nexport const D = () => `, + 'node_modules/@acme/ui/e.tsx': `import { Box } from '@devup-ui/react'\nexport const E = () => `, +} + +/** How each plugin names the files it extracts */ +const schemes: Record (path: string) => string> = { + 'vite (absolute, posix)': () => (path) => path.replaceAll('\\', '/'), + 'webpack and next (relative to cwd)': (root) => (path) => + relative(root, path).replaceAll('\\', '/'), + 'rsbuild and bun (absolute, as found)': () => (path) => path, +} + +describe('class and file numbers do not depend on the order files are seen', () => { + let root: string + const original = process.cwd() + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'devup-ui-numbering-order-')) + for (const [path, code] of Object.entries(sources)) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), code) + } + process.chdir(root) + }) + + afterAll(() => { + wasm.resetBuildState() + process.chdir(original) + rmSync(root, { recursive: true, force: true }) + }) + + function build( + scheme: (path: string) => string, + order: string[], + parallel: boolean, + ) { + wasm.resetBuildState() + seedFileNumbers( + wasm, + collectNumberedFiles({ + roots: [join(root, 'src')], + include: ['@acme/ui'], + needles: ['@devup-ui/react'], + cwd: root, + toId: scheme, + }), + ) + const extract = (file: string) => { + const output = wasm.codeExtractWithoutSourceMap( + scheme(join(root, file)), + readFileSync(join(root, file), 'utf-8'), + '@devup-ui/react', + '../css', + false, + false, + true, + {}, + ) + return { file, code: output.code, cssFile: output.cssFile } + } + const outputs = parallel + ? Promise.all(order.map(async (file) => extract(file))) + : Promise.resolve(order.map(extract)) + return outputs.then((results) => { + const byFile = Object.fromEntries( + results + .sort((a, b) => a.file.localeCompare(b.file)) + .map((r) => [r.file, r]), + ) + const fileMap = JSON.parse(wasm.exportFileMap()) as Record + const sheets = Object.values(fileMap) + .sort((a, b) => a - b) + .map((number) => wasm.getCss(number, false)) + return { + byFile, + fileMap: wasm.exportFileMap(), + classMap: wasm.exportClassMap(), + sheets, + base: wasm.getCss(null, false), + } + }) + } + + const files = Object.keys(sources) + const orders = [ + files, + [...files].reverse(), + [files[2], files[4], files[0], files[3], files[1]], + ] + + it.each(Object.entries(schemes))( + 'gives the same output for any order, one at a time or at once: %s', + async (_name, makeScheme) => { + const scheme = makeScheme(root) + const results = [] + for (const order of orders) { + results.push(await build(scheme, order, false)) + results.push(await build(scheme, order, true)) + } + for (const result of results.slice(1)) { + expect(result.fileMap).toBe(results[0].fileMap) + expect(result.byFile).toEqual(results[0].byFile) + expect(result.sheets).toEqual(results[0].sheets) + expect(result.base).toBe(results[0].base) + } + const numbers = Object.values( + JSON.parse(results[0].fileMap) as Record, + ).sort((a, b) => a - b) + expect(numbers).toEqual([0, 1, 2, 3, 4]) + }, + ) + + it('numbers the files in path order, and files that appear later after them', () => { + wasm.resetBuildState() + seedFileNumbers(wasm, ['b.tsx', 'a.tsx']) + seedFileNumbers(wasm, ['0.tsx', 'a.tsx', 'c.tsx']) + expect(JSON.parse(wasm.exportFileMap())).toEqual({ + 'a.tsx': 0, + 'b.tsx': 1, + '0.tsx': 2, + 'c.tsx': 3, + }) + expect(wasm.exportFileMap()).toBe( + '{"0.tsx":2,"a.tsx":0,"b.tsx":1,"c.tsx":3}', + ) + }) +}) diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index ff27a64d8..15a8bf7af 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -614,6 +614,67 @@ describe('devupUILoader', () => { } }) + it('should report a coordinator that no longer answers', async () => { + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('12346') + + const requestSpy = spyOn(http, 'request').mockImplementation( + (_options: any, _callback?: any) => { + const fakeReq = { + on: mock((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'error') { + setTimeout( + () => + handler( + Object.assign(new Error('refused'), { + code: 'ECONNREFUSED', + }), + ), + 0, + ) + } + return fakeReq + }), + write: mock(), + end: mock(), + } + return asClientRequest(fakeReq) + }, + ) + + const asyncCallback = mock() + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssDir', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + coordinatorPortFile: 'coordinator.port', + }), + async: mock().mockReturnValue(asyncCallback), + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + addDependency: mock(), + } + + devupUILoader.bind(asLoaderContext(t))( + Buffer.from('source code'), + 'src/App.tsx', + ) + + await waitFor(() => { + expect(asyncCallback).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('coordinator.port'), + }), + ) + }) + + requestSpy.mockRestore() + }) it('should handle coordinator non-200 response', async () => { existsSyncSpy.mockReturnValue(true) readFileSyncSpy.mockReturnValue('12345') diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index a58ca74c8..d1b78afa6 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -790,6 +790,32 @@ export const box = style({ color: 'red' })` DevupUI({}, { prefix: 'my-prefix' }) expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') }) + it('numbers the files the scan finds, and survives a failing scan', () => { + process.env.TURBOPACK = '1' + const collectSpy = spyOn(importGraphModule, 'collectNumberedFiles') + const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) + try { + collectSpy.mockImplementation((options: any) => { + expect(options.roots).toEqual( + ['src', 'app', 'pages'].map((dir) => resolve(dir)), + ) + expect(options.toId(resolve('src', 'a.tsx'))).toBe('src/a.tsx') + return ['src/a.tsx'] + }) + DevupUI({}, { include: ['@acme/ui'] }) + expect(seedSpy).toHaveBeenCalledWith(['src/a.tsx']) + expect(collectSpy.mock.calls[0][0]).toMatchObject({ + include: ['@acme/ui'], + }) + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) + DevupUI({}, {}) + } finally { + collectSpy.mockRestore() + seedSpy.mockRestore() + } + }) it('should import previous session state on restart', () => { process.env.TURBOPACK = '1' existsSyncSpy diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index 75e68a3d9..1843997fb 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -3,7 +3,11 @@ import { writeFile } from 'node:fs/promises' import { Agent, request } from 'node:http' import { basename, dirname, join, relative, resolve } from 'node:path' -import { tailwindCssFiles, withTailwindCss } from '@devup-ui/plugin-utils' +import { + createStateWriter, + tailwindCssFiles, + withTailwindCss, +} from '@devup-ui/plugin-utils' import type { RawLoaderDefinitionFunction } from 'webpack' import { @@ -14,6 +18,10 @@ import { } from './coordinator-port' import { loadWasm } from './wasm' +const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), +) + export interface DevupUILoaderOptions { package: string cssDir: string @@ -284,19 +292,23 @@ const devupUILoader: RawLoaderDefinitionFunction = if (updatedBaseStyle && watch) { // update base style promises.push( - writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'), + stateWriter.write( + join(cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + ), ) } if (cssFile && watch) { // don't write file when build promises.push( - writeFile( + stateWriter.write( join(cssDir, basename(cssFile)), `/* ${this.resourcePath} ${Date.now()} */`, ), - writeFile(sheetFile, exportSheet()), - writeFile(classMapFile, exportClassMap()), - writeFile(fileMapFile, exportFileMap()), + stateWriter.write(sheetFile, exportSheet()), + stateWriter.write(classMapFile, exportClassMap()), + stateWriter.write(fileMapFile, exportFileMap()), ) } Promise.all(promises).then( diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index c21f669e0..69749e0d5 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -11,15 +11,18 @@ import { deserialize, serialize } from 'node:v8' import { buildCanonicalMap, buildStaticImportGraph, + collectNumberedFiles, computeCompiledFiles, computeFileRoutes, createCompatTypes, createNodeModulesExcludeRegex, createThemeInterfaceArgs, type DevupUIBasePluginOptions, + extractedNeedles, loadDevupConfigSync, mergeImportAliases, planAtomHoist, + seedFileNumbers, type StaticImportGraph, withTailwindCss, } from '@devup-ui/plugin-utils' @@ -234,9 +237,7 @@ export function DevupUI( registerShorthands(shorthands ?? {}) - if (prefix) { - setPrefix(prefix) - } + setPrefix(prefix ?? null) writeFileSync( join(distDir, 'compat.d.ts'), @@ -251,7 +252,7 @@ export function DevupUI( importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8'))) importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8'))) } catch { - // No previous session state (first run) or corrupt files — start fresh + // No previous session state (first run) or corrupt files, start fresh } const devupConfig = loadDevupConfigSync(devupFile) @@ -363,6 +364,24 @@ export function DevupUI( }) } + // Number every file the build can extract in path order, so class prefixes + // do not depend on the order modules reach a loader. Numbers restored above + // stay; files that appear later get the numbers after them. + try { + const cwd = process.cwd() + seedFileNumbers( + { seedFileMap: wasm.seedFileMap }, + collectNumberedFiles({ + roots: ['src', 'app', 'pages'].map((dir) => resolve(cwd, dir)), + include, + cwd, + needles: extractedNeedles(libPackage, importAliases), + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } // Turbopack can request a CSS module before it has scheduled every source // loader. Waiting for a quiet window is not a compilation-complete signal: // a CSS request can itself hold up the next extraction wave. In one-shot diff --git a/packages/plugin-utils/src/__tests__/numbering.test.ts b/packages/plugin-utils/src/__tests__/numbering.test.ts new file mode 100644 index 000000000..e64ad5716 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/numbering.test.ts @@ -0,0 +1,107 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' + +import { + collectNumberedFiles, + extractedNeedles, + seedFileNumbers, +} from '../numbering' + +describe('collectNumberedFiles', () => { + let root: string + + const write = (path: string) => { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), 'export {}') + } + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'devup-ui-numbering-')) + write('src/b.tsx') + write('src/a.tsx') + write('src/nested/c.ts') + write('src/readme.md') + write('node_modules/@acme/ui/Button.tsx') + write('node_modules/@acme/ui/node_modules/dep/skipped.tsx') + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('lists the project source and the included packages in path order', () => { + const files = collectNumberedFiles({ + roots: [join(root, 'src'), join(root, 'missing')], + include: ['@acme/ui', 'not-installed'], + cwd: root, + }) + expect( + files.map((file) => file.slice(file.indexOf('devup-ui-numbering-'))), + ).toEqual( + [...files] + .sort() + .map((file) => file.slice(file.indexOf('devup-ui-numbering-'))), + ) + expect(files.filter((file) => file.includes('/src/'))).toHaveLength(3) + expect(files.some((file) => file.endsWith('/@acme/ui/Button.tsx'))).toBe( + true, + ) + expect(files.some((file) => file.includes('skipped'))).toBe(false) + }) + + it('numbers only the files that mention what the build extracts', () => { + writeFileSync(join(root, 'src/a.tsx'), "import '@devup-ui/react'") + const files = collectNumberedFiles({ + roots: [join(root, 'src')], + cwd: root, + needles: extractedNeedles('@devup-ui/react', { + '@emotion/styled': 'styled', + }), + toId: (path) => path.slice(root.length).replaceAll('\\', '/'), + }) + expect(files).toEqual(['/src/a.tsx']) + expect(extractedNeedles('p', { a: 1, b: 2 })).toEqual([ + 'p', + '@stylexjs/stylex', + 'a', + 'b', + ]) + }) + it('does not depend on the order of the roots', () => { + const first = collectNumberedFiles({ + roots: [join(root, 'src'), join(root, 'node_modules/@acme/ui')], + cwd: root, + }) + const second = collectNumberedFiles({ + roots: [join(root, 'node_modules/@acme/ui'), join(root, 'src')], + cwd: root, + }) + expect(first).toEqual(second) + }) + + it('names files the way the plugin extracts them', () => { + const files = collectNumberedFiles({ + roots: [join(root, 'src')], + cwd: root, + toId: (path) => `id:${path.slice(root.length).replaceAll('\\', '/')}`, + }) + expect(files).toEqual([ + 'id:/src/a.tsx', + 'id:/src/b.tsx', + 'id:/src/nested/c.ts', + ]) + }) +}) + +describe('seedFileNumbers', () => { + it('seeds files, and nothing when there are none', () => { + const seedFileMap = mock() + seedFileNumbers({ seedFileMap }, []) + expect(seedFileMap).not.toHaveBeenCalled() + seedFileNumbers({ seedFileMap }, ['a']) + expect(seedFileMap).toHaveBeenCalledWith(['a']) + }) +}) diff --git a/packages/plugin-utils/src/__tests__/state-writer.test.ts b/packages/plugin-utils/src/__tests__/state-writer.test.ts new file mode 100644 index 000000000..661189cf2 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/state-writer.test.ts @@ -0,0 +1,106 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { beginBuild } from '../build-session' +import { createStateWriter, writeFileAtomically } from '../state-writer' + +describe('createStateWriter', () => { + it('leaves the newest snapshot, whichever write would finish last', async () => { + const written: string[] = [] + const release: Array<() => void> = [] + const writer = createStateWriter( + (_path, content) => + new Promise((done) => { + release.push(() => { + written.push(content) + done() + }) + }), + ) + const first = writer.write('state', 'one') + const second = writer.write('state', 'two') + const third = writer.write('state', 'three', 'utf-8') + release[0]() + await first + await new Promise((resolve) => setTimeout(resolve, 0)) + release[1]() + await Promise.all([second, third]) + // the second snapshot was overtaken before it started + expect(written).toEqual(['one', 'three']) + expect(release).toHaveLength(2) + }) + + it('keeps writing after a write fails, and keeps paths apart', async () => { + const calls: string[] = [] + const writer = createStateWriter(async (path, content) => { + calls.push(`${path}=${content}`) + if (content === 'bad') throw new Error('disk full') + }) + const bad = writer.write('a', 'bad') + const good = writer.write('a', 'good') + await expect(bad).rejects.toThrow('disk full') + await good + await writer.write('b', 'other') + expect(calls).toEqual(['a=bad', 'a=good', 'b=other']) + }) +}) + +describe('writeFileAtomically', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'devup-ui-writer-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('replaces the file whole and leaves no temporary file', async () => { + const path = join(dir, 'deep', 'state.json') + await writeFileAtomically(path, '1') + await writeFileAtomically(path, '22') + expect(readFileSync(path, 'utf-8')).toBe('22') + expect(readdirSync(join(dir, 'deep'))).toEqual(['state.json']) + }) + + it('is what the writer uses unless told otherwise', async () => { + const path = join(dir, 'state.json') + await createStateWriter().write(path, 'first') + await createStateWriter().write(path, 'second', 'utf-8') + expect(readFileSync(path, 'utf-8')).toBe('second') + }) + + it('cleans up when the file cannot be replaced', async () => { + const path = join(dir, 'target') + await writeFileAtomically(join(path, 'inner'), 'x') + await expect(writeFileAtomically(path, 'y')).rejects.toBeDefined() + expect(readdirSync(dir)).toEqual(['target']) + }) +}) + +describe('beginBuild', () => { + it('resets the engine only when no other build is running', () => { + let resets = 0 + const engine = { + resetBuildState: () => { + resets += 1 + }, + } + const first = beginBuild(engine) + expect(resets).toBeLessThanOrEqual(1) + const before = resets + const second = beginBuild(engine) + expect(resets).toBe(before) + second() + second() + first() + beginBuild({})() + const third = beginBuild(engine) + expect(resets).toBe(before + 1) + third() + }) +}) diff --git a/packages/plugin-utils/src/build-session.ts b/packages/plugin-utils/src/build-session.ts new file mode 100644 index 000000000..bd16e68f3 --- /dev/null +++ b/packages/plugin-utils/src/build-session.ts @@ -0,0 +1,23 @@ +export interface ResettableEngine { + resetBuildState(): void +} + +let running = 0 + +/** + * Start a build in this process. The engine is shared by every plugin instance + * in the process, so what an earlier build left in it (prefix, atom hoisting, + * routes, buckets, names, numbers, styles) would otherwise leak into this one: + * it is reset unless another build is still running, whose state it would + * wipe. Returns the function that ends the build. + */ +export function beginBuild(engine: Partial): () => void { + if (running === 0) engine.resetBuildState?.() + running += 1 + let ended = false + return () => { + if (ended) return + ended = true + running -= 1 + } +} diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index b221bbc1e..1602513f7 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -257,7 +257,7 @@ export function computeFileRoutes( const leafRoutes = files .filter((file) => leafRouteFileRegex.test(toPosixRelative(srcDir, file))) .sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) const routeShellFilesByDir = getRouteShellFilesByDir(files, srcDir) @@ -426,7 +426,7 @@ export function computeFileReach( ) } entries = [...new Set(entries)].sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) const toKey = makeToKey(cwd, opts.keyBy ?? 'cwd-relative') @@ -599,6 +599,11 @@ function getStaticClosure( return closure } +/** Order that is the same on every machine, unlike localeCompare. */ +export function compareCodePoints(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + /** * Enumerate every extractable source file under `srcDir`, sorted by POSIX path * (deterministic order). Skips `node_modules`, test/spec files, and non-JS/TS @@ -612,7 +617,7 @@ export function listSourceFiles(srcDir: string): string[] { function visit(dir: string): void { if (!existsSync(dir)) return const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => - a.name.localeCompare(b.name), + compareCodePoints(a.name, b.name), ) for (const entry of entries) { const entryPath = join(dir, entry.name) @@ -630,7 +635,7 @@ export function listSourceFiles(srcDir: string): string[] { visit(srcDir) return files.sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) } diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 56290a8b6..f90a0352f 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -1,3 +1,4 @@ +export { beginBuild, type ResettableEngine } from './build-session' export { type AtomHoistPlan, buildCanonicalMap, @@ -19,6 +20,13 @@ export { type StaticImportGraph, } from './import-graph' export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' +export { + collectNumberedFiles, + type CollectNumberedFilesOptions, + extractedNeedles, + type FileNumbering, + seedFileNumbers, +} from './numbering' export { createNodeModulesExcludeRegex, createThemeInterfaceArgs, @@ -27,6 +35,11 @@ export { type DevupUIBasePluginOptions, getFileNumByFilename, } from './shared' +export { + createStateWriter, + type StateWriter, + writeFileAtomically, +} from './state-writer' export { findTailwindCss, TAILWIND_CSS_CANDIDATES, diff --git a/packages/plugin-utils/src/numbering.ts b/packages/plugin-utils/src/numbering.ts new file mode 100644 index 000000000..213bbf9c7 --- /dev/null +++ b/packages/plugin-utils/src/numbering.ts @@ -0,0 +1,92 @@ +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { listSourceFiles } from './import-graph' + +export interface CollectNumberedFilesOptions { + /** Directories holding the project's own source files */ + roots: string[] + /** Packages whose source the build extracts too (the `include` option) */ + include?: string[] + cwd?: string + /** + * Only files whose text contains one of these are numbered, so files the + * build never extracts do not take numbers (and lengthen class prefixes) + */ + needles?: string[] + /** The name the plugin extracts a file under, given its absolute path */ + toId?: (path: string) => string +} + +/** Whether the text of the file contains one of the needles */ +function usesAny(file: string, needles: string[]): boolean { + const text = readFileSync(file, 'utf-8') + return needles.some((needle) => text.includes(needle)) +} + +/** Where an included package lives, found the way the bundler finds it. */ +function findPackageDir(cwd: string, name: string): string | undefined { + let dir = resolve(cwd) + for (;;) { + const candidate = join(dir, 'node_modules', name) + if (existsSync(candidate)) return realpathSync(candidate) + const parent = resolve(dir, '..') + if (parent === dir) return undefined + dir = parent + } +} + +/** + * Every file the build can extract, as the names the plugin extracts them + * under: the project's source, then the source of each included package. The + * list is the same on every run for the same files, and the engine numbers it + * in path order, so file numbers (and the class prefixes they make) do not + * depend on which file a bundler worker reaches first. + */ +export function collectNumberedFiles({ + roots, + include = [], + cwd = process.cwd(), + needles, + toId = (path) => path.replaceAll('\\', '/'), +}: CollectNumberedFilesOptions): string[] { + const directories = [ + ...roots, + ...include + .map((name) => findPackageDir(cwd, name)) + .filter((dir): dir is string => dir !== undefined), + ] + return [ + ...new Set( + directories.flatMap((dir) => + listSourceFiles(dir) + .filter((file) => !needles || usesAny(file, needles)) + .map(toId), + ), + ), + ].sort() +} + +export interface FileNumbering { + seedFileMap(files: string[]): void +} + +/** + * Number `files` in path order. Files numbered before keep their numbers, so + * running it again as files appear (in development) numbers only the new ones, + * after the existing ones. + */ +export function seedFileNumbers( + engine: FileNumbering, + files: Parameters[0], +): void { + if (files.length > 0) engine.seedFileMap(files) +} + +/** What a file mentions when the build extracts it: the package, StyleX, and the packages Devup UI takes the place of */ +export function extractedNeedles( + libPackage: string, + importAliases: object, +): string[] { + return [libPackage, '@stylexjs/stylex', ...Object.keys(importAliases)] +} diff --git a/packages/plugin-utils/src/state-writer.ts b/packages/plugin-utils/src/state-writer.ts new file mode 100644 index 000000000..45a21ad49 --- /dev/null +++ b/packages/plugin-utils/src/state-writer.ts @@ -0,0 +1,70 @@ +import { mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +let temporaryFiles = 0 + +/** Write `content` so a reader sees the old file or the new one, never half. */ +export async function writeFileAtomically( + path: string, + content: string, +): Promise { + const temporary = `${path}.${process.pid}.${++temporaryFiles}.tmp` + await mkdir(dirname(path), { recursive: true }) + try { + await writeFile(temporary, content, 'utf-8') + await rename(temporary, path) + } catch (error) { + await rm(temporary, { force: true }) + throw error + } +} + +export type WriteOne = ( + path: string, + content: string, + encoding?: 'utf-8', +) => Promise + +export interface StateWriter { + /** + * Write `content` to `path` after every earlier write to it. A write that a + * newer one for the same path overtakes before it starts is dropped, so the + * file never ends up holding an older snapshot than one already handed in. + */ + write(path: string, content: string, encoding?: 'utf-8'): Promise +} + +/** + * Persist engine snapshots from concurrent transforms. Each call hands in the + * state as it was when the call was made, and the last call made decides what + * the file holds, whichever write the operating system finishes last. + */ +export function createStateWriter( + writeOne: WriteOne = (path, content) => writeFileAtomically(path, content), +): StateWriter { + const revisions = new Map() + const tails = new Map>() + return { + write(path, content, encoding) { + const revision = (revisions.get(path) ?? 0) + 1 + revisions.set(path, revision) + const start = async () => { + if (revisions.get(path) !== revision) return + await (encoding + ? writeOne(path, content, encoding) + : writeOne(path, content)) + } + const previous = tails.get(path) + // With nothing in flight the write starts at once + const run = previous + ? previous.catch(() => undefined).then(start) + : start() + tails.set(path, run) + const done = () => { + if (tails.get(path) === run) tails.delete(path) + } + run.then(done, done) + return run + }, + } +} diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index dcb7a50f7..a863efbfb 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -375,13 +375,6 @@ const App = () => `, map: undefined, }) - if (options.updatedBaseStyle) { - expect(writeFileSpy).toHaveBeenCalledWith( - resolve('df', 'devup-ui', 'devup-ui.css'), - expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), - 'utf-8', - ) - } expect(writeFileSpy).toHaveBeenCalledWith( resolve('df', 'devup-ui', 'devup-ui.css'), expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), @@ -510,6 +503,47 @@ const App = () => `, expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') }) + describe('deterministic file numbering', () => { + it.each([ + ['relative', false], + ['posix', true], + ])('numbers the files the scan finds (%s ids)', async (_name, atomMode) => { + const collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) + const closeBuild = mock() + try { + collectSpy.mockImplementation((options: any) => { + expect(options.toId('C:\\p\\a.tsx')).toBe( + atomMode ? 'C:/p/a.tsx' : 'C:\\p\\a.tsx', + ) + return ['/p/a.tsx'] + }) + await DevupUI({ + include: ['@acme/ui'], + atomHoist: atomMode ? 2 : undefined, + }).setup(createSetupContext({ onCloseBuild: closeBuild })) + expect(seedSpy).toHaveBeenCalledWith(['/p/a.tsx']) + expect(collectSpy.mock.calls[0][0]).toMatchObject({ + roots: [resolve('src')], + include: ['@acme/ui'], + }) + closeBuild.mock.calls[0][0]() + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) + await DevupUI().setup(createSetupContext()) + } finally { + collectSpy.mockRestore() + seedSpy.mockRestore() + } + }) + + it('sets the prefix every time, even without one', async () => { + setPrefixSpy.mockClear() + await DevupUI().setup(createSetupContext()) + expect(setPrefixSpy).toHaveBeenCalledWith(null) + }) + }) describe('atomHoist pre-pass', () => { let buildCanonicalMapSpy: ReturnType let computeFileReachSpy: ReturnType diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index f8ac82ad4..b23f29dfa 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -3,19 +3,24 @@ import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + collectNumberedFiles, computeFileReach, computeReachableFiles, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, + createStateWriter, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, loadDevupConfig, mergeImportAliases, planAtomHoist, + seedFileNumbers, withTailwindCss, } from '@devup-ui/plugin-utils' import { @@ -27,6 +32,8 @@ import { importFileRoutes, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -131,14 +138,19 @@ export const DevupUI = ({ }: Partial = {}): RsbuildPlugin => { registerShorthands(shorthands ?? {}) const importAliases = mergeImportAliases(userImportAliases) + const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), + ) return { name: PLUGIN_NAME, async setup(api) { + // A build starts from its own options, not from what an earlier build + // in this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + api.onCloseBuild?.(endBuild) setDebug(debug) - if (prefix) { - setPrefix(prefix) - } + setPrefix(prefix ?? null) if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') @@ -202,6 +214,21 @@ export const DevupUI = ({ } } + try { + // Number every file the build can extract in path order, so class + // prefixes do not depend on the order modules reach the transform + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve(process.cwd(), 'src')], + include, + needles: extractedNeedles(libPackage, importAliases), + toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } // Extract the source files under `src` that the entries reach, in path // order, the same way the transform does, so that a stylesheet built on // its first import already holds the styles of every one. Best-effort: @@ -414,7 +441,7 @@ export const DevupUI = ({ if (updatedBaseStyle) { // update base style promises.push( - writeFile( + stateWriter.write( join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8', @@ -424,7 +451,7 @@ export const DevupUI = ({ if (cssFile) { promises.push( - writeFile( + stateWriter.write( join(cssDir, basename(cssFile)), `/* ${resourcePath} ${Date.now()} */`, 'utf-8', diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index f2f261146..abb58c4c5 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -49,10 +49,18 @@ interface HotUpdateEnvironment { } interface ViteTestPlugin { + environment?: { + name: string + config: { + consumer: 'client' | 'server' + build?: { write?: boolean } + } + } name: string sharedDuringBuild: true enforce: 'pre' apply: () => boolean + closeBundle: () => void config: ( this: { meta?: ConfigHookMeta } | void, userConfig?: ViteConfig, @@ -60,7 +68,7 @@ interface ViteTestPlugin { configResolved: (config?: { command?: 'serve' | 'build' root?: string - plugins?: object[] + plugins?: readonly object[] }) => Promise watchChange: (id: string) => Promise hotUpdate: ( @@ -233,6 +241,7 @@ describe('devupUIVitePlugin', () => { expect(plugin).toEqual({ name: 'devup-ui', sharedDuringBuild: true, + closeBundle: expect.any(Function), config: expect.any(Function), load: expect.any(Function), watchChange: expect.any(Function), @@ -453,20 +462,19 @@ describe('devupUIVitePlugin', () => { }) describe('deterministic file numbering', () => { - let listSourceFilesSpy: ReturnType - let importFileMapSpy: ReturnType - let exportFileMapSpy: ReturnType + const collectNumberedFiles = pluginUtils.collectNumberedFiles + const seedFileMap = wasm.seedFileMap + let collectSpy: ReturnType + let seedFileMapSpy: ReturnType beforeEach(() => { - listSourceFilesSpy = spyOn(pluginUtils, 'listSourceFiles') - importFileMapSpy = spyOn(wasm, 'importFileMap').mockReturnValue(undefined) - exportFileMapSpy = spyOn(wasm, 'exportFileMap').mockReturnValue('{}') + collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + seedFileMapSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) }) afterEach(() => { - listSourceFilesSpy.mockRestore() - importFileMapSpy.mockRestore() - exportFileMapSpy.mockRestore() + collectSpy.mockRestore() + seedFileMapSpy.mockRestore() }) function onlyDirs(...dirs: string[]) { @@ -474,90 +482,160 @@ describe('devupUIVitePlugin', () => { existsSyncSpy.mockImplementation((path: string) => wanted.has(path)) } - it('numbers files by sorted path, not by transform arrival order', async () => { - onlyDirs('src') - // returned out of order on purpose: arrival order must not leak through - listSourceFilesSpy.mockReturnValue([ - '/p/src/z.tsx', - '/p/src/a.tsx', - '/p/src/m.tsx', - ]) + it('numbers the files the scan finds, in the order it returns them', async () => { + onlyDirs('src', 'app') + collectSpy.mockReturnValue(['/p/app/page.tsx', '/p/src/b.tsx']) - await createPlugin({}).configResolved({ root: '/p' }) + await createPlugin({ include: ['@acme/ui'] }).configResolved({ + root: '/p', + }) - expect(importFileMapSpy).toHaveBeenCalledWith({ - '/p/src/a.tsx': 0, - '/p/src/m.tsx': 1, - '/p/src/z.tsx': 2, + expect(collectSpy).toHaveBeenCalledWith({ + roots: [resolve('/p', 'src'), resolve('/p', 'app')], + include: ['@acme/ui'], + cwd: '/p', + needles: expect.arrayContaining([ + '@devup-ui/react', + '@stylexjs/stylex', + ]), }) + expect(seedFileMapSpy).toHaveBeenCalledWith([ + '/p/app/page.tsx', + '/p/src/b.tsx', + ]) }) - it('normalizes windows separators to match vite module ids', async () => { + // A framework plugin resolves the config once per environment. Seeding + // keeps the numbers files hold, so a second pass numbers only new files. + it('seeds again on a second configResolved', async () => { onlyDirs('src') - listSourceFilesSpy.mockReturnValue(['C:\\p\\src\\a.tsx']) + collectSpy.mockReturnValue(['/p/src/a.tsx']) + const plugin = createPlugin({}) - await createPlugin({}).configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) + + expect(seedFileMapSpy).toHaveBeenCalledTimes(2) + }) - expect(importFileMapSpy).toHaveBeenCalledWith({ 'C:/p/src/a.tsx': 0 }) + it('leaves numbering alone when there is nothing to number', async () => { + onlyDirs() + collectSpy.mockReturnValue([]) + await createPlugin({}).configResolved({ root: '/p' }) + expect(seedFileMapSpy).not.toHaveBeenCalled() }) - // A framework plugin resolves the config once per environment. Re-seeding - // drops the numbers already given to files outside src/ and app/, and since - // the sheet does not reset with the map, the next such file reuses a live - // number and its atoms overwrite the previous owner's. - it('leaves an already-populated map alone on a second configResolved', async () => { + it('keeps building when the scan fails', async () => { onlyDirs('src') - listSourceFilesSpy.mockReturnValue(['/p/src/a.tsx']) - exportFileMapSpy.mockReturnValue( - '{"/p/src/a.tsx":0,"/monorepo/packages/ui/X.tsx":1}', - ) + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) await createPlugin({}).configResolved({ root: '/p' }) - expect(importFileMapSpy).not.toHaveBeenCalled() + expect(seedFileMapSpy).not.toHaveBeenCalled() }) - it('scans app/ for App Router projects and dedupes across roots', async () => { - onlyDirs('src', 'app') - listSourceFilesSpy.mockImplementation((dir: string) => - dir === resolve('/p', 'app') - ? ['/p/app/page.tsx', '/p/shared.tsx'] - : ['/p/src/b.tsx', '/p/shared.tsx'], - ) + describe('source collection before seeding', () => { + let listSourceFilesSpy: ReturnType + let readFileSyncSpy: ReturnType - await createPlugin({}).configResolved({ root: '/p' }) + beforeEach(() => { + collectSpy.mockImplementation(collectNumberedFiles) + listSourceFilesSpy = spyOn(pluginUtils, 'listSourceFiles') + readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue( + "import { Box } from '@devup-ui/react'", + ) + }) - expect(importFileMapSpy).toHaveBeenCalledWith({ - '/p/app/page.tsx': 0, - '/p/shared.tsx': 1, - '/p/src/b.tsx': 2, + afterEach(() => { + listSourceFilesSpy.mockRestore() + readFileSyncSpy.mockRestore() }) - }) - it.each([ - ['no conventional source dir exists', () => onlyDirs()], - [ - 'the source dir is empty', - () => { - onlyDirs('src') - listSourceFilesSpy.mockReturnValue([]) - }, - ], - ])('leaves numbering alone when %s', async (_name, setup) => { - setup() - await createPlugin({}).configResolved({ root: '/p' }) - expect(importFileMapSpy).not.toHaveBeenCalled() - }) + it('numbers files by sorted path, not by transform arrival order', async () => { + onlyDirs('src') + listSourceFilesSpy.mockReturnValue([ + '/p/src/z.tsx', + '/p/src/a.tsx', + '/p/src/m.tsx', + ]) - it('keeps building when the scan fails', async () => { - onlyDirs('src') - listSourceFilesSpy.mockImplementation(() => { - throw new Error('scan boom') + await createPlugin({}).configResolved({ root: '/p' }) + + expect(seedFileMapSpy).toHaveBeenCalledWith([ + '/p/src/a.tsx', + '/p/src/m.tsx', + '/p/src/z.tsx', + ]) }) - await createPlugin({}).configResolved({ root: '/p' }) + it('normalizes windows separators to match vite module ids', async () => { + onlyDirs('src') + listSourceFilesSpy.mockReturnValue(['C:\\p\\src\\a.tsx']) + + await createPlugin({}).configResolved({ root: '/p' }) + + expect(seedFileMapSpy).toHaveBeenCalledWith(['C:/p/src/a.tsx']) + }) + + it('leaves an already-populated map alone on a second configResolved', async () => { + onlyDirs('src') + listSourceFilesSpy.mockReturnValue(['/p/src/a.tsx']) + const previousMap = wasm.exportFileMap() + const existingMap = { + '/p/src/a.tsx': 0, + '/monorepo/packages/ui/X.tsx': 1, + } + seedFileMapSpy.mockImplementation(seedFileMap) + const plugin = createPlugin({}) + wasm.importFileMap(existingMap) + try { + await plugin.configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) + + expect(seedFileMapSpy).toHaveBeenCalledTimes(2) + expect(JSON.parse(wasm.exportFileMap())).toEqual(existingMap) + } finally { + wasm.importFileMap(JSON.parse(previousMap)) + } + }) + + it('scans app/ for App Router projects and dedupes across roots', async () => { + onlyDirs('src', 'app') + listSourceFilesSpy.mockImplementation((dir: string) => + dir === resolve('/p', 'app') + ? ['/p/app/page.tsx', '/p/shared.tsx'] + : ['/p/src/b.tsx', '/p/shared.tsx'], + ) + + await createPlugin({}).configResolved({ root: '/p' }) + + expect(listSourceFilesSpy).toHaveBeenCalledWith(resolve('/p', 'src')) + expect(listSourceFilesSpy).toHaveBeenCalledWith(resolve('/p', 'app')) + expect(seedFileMapSpy).toHaveBeenCalledWith([ + '/p/app/page.tsx', + '/p/shared.tsx', + '/p/src/b.tsx', + ]) + }) + + it.each([ + ['no conventional source dir exists', () => onlyDirs()], + [ + 'the source dir is empty', + () => { + onlyDirs('src') + listSourceFilesSpy.mockReturnValue([]) + }, + ], + ])('leaves numbering alone when %s', async (_name, setup) => { + setup() + + await createPlugin({}).configResolved({ root: '/p' }) - expect(importFileMapSpy).not.toHaveBeenCalled() + expect(seedFileMapSpy).not.toHaveBeenCalled() + }) }) }) @@ -896,6 +974,146 @@ describe('devupUIVitePlugin', () => { expect(serverBundle['base.css'].fileName).toBe('base.css') }) + + it('does not forward server css that the client already emits', async () => { + const [plugin, restore] = createPlugins({}) + const base = asset('base.css', 'devup-ui.css') + const file = asset('file.css', 'devup-ui-3.css') + const entry = chunk('entry.js', [ + 'base.css', + 'file.css', + 'server-only.css', + ]) + const serverBundle: Bundle = { + 'base.css': base, + 'file.css': file, + 'server-only.css': asset('server-only.css', 'server-only.css'), + 'entry.js': entry, + } + const clientBase = asset('base.css', 'devup-ui.css') + const clientFile = asset('file.css', 'devup-ui-3.css') + const clientBundle: Bundle = { + 'base.css': clientBase, + 'file.css': clientFile, + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + + await plugin.generateBundle.call(serverEnv, {}, serverBundle) + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + const forwarded = forwardLikePluginRsc(serverBundle) + + expect(base.source).toEqual('base sheet') + expect(file.source).toEqual('file sheet') + expect(clientBase.source).toEqual('base sheet') + expect(clientFile.source).toEqual('file sheet') + expect(entry.viteMetadata?.importedCss).toEqual( + new Set(['base.css', 'file.css', 'server-only.css']), + ) + expect(forwarded.emitted).toEqual([ + 'base.css.devup-forwarded', + 'file.css.devup-forwarded', + 'server-only.css', + ]) + for (const name of forwarded.emitted) { + clientBundle[name] = asset(name, name) + } + restore.generateBundle.handler({}, clientBundle) + + expect(base.fileName).toBe('base.css') + expect(file.fileName).toBe('file.css') + expect(clientBundle['base.css']).toBe(clientBase) + expect(clientBundle['file.css']).toBe(clientFile) + expect(Object.keys(clientBundle).sort()).toEqual([ + 'base.css', + 'file.css', + 'server-only.css', + ]) + expect(entry.viteMetadata?.importedCss).toEqual( + new Set(['base.css', 'file.css', 'server-only.css']), + ) + }) + + it('ignores no-write analysis bundles when tracking server css', async () => { + const [plugin, restore] = createPlugins({}) + const file = asset('file.css', 'devup-ui-3.css') + const entry = chunk('entry.js', ['file.css']) + const serverBundle: Bundle = { + 'file.css': file, + 'entry.js': entry, + } + await plugin.configResolved(rscConfig({})) + await plugin.generateBundle.call( + { + environment: { + name: 'rsc', + config: { consumer: 'server', build: { write: false } }, + }, + }, + {}, + serverBundle, + ) + const clientBundle: Bundle = { + 'file.css': asset('file.css', 'devup-ui-3.css'), + } + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + restore.generateBundle.handler({}, clientBundle) + + expect(entry.viteMetadata?.importedCss).toEqual(new Set(['file.css'])) + expect(file.fileName).toBe('file.css') + expect(file.source).toBe('file sheet') + expect(Object.keys(clientBundle)).toEqual(['file.css']) + }) + + it('keeps server forwarding for a different output file name', async () => { + const [plugin, restore] = createPlugins({}) + const file = asset('devup-ui-3.server.css', 'devup-ui-3.css') + const entry = chunk('entry.js', ['devup-ui-3.server.css']) + const serverBundle: Bundle = { + 'devup-ui-3.server.css': file, + 'entry.js': entry, + } + const clientBundle: Bundle = { + 'devup-ui-3.client.css': asset( + 'devup-ui-3.client.css', + 'devup-ui-3.css', + ), + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + await plugin.generateBundle.call(serverEnv, {}, serverBundle) + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + const forwarded = forwardLikePluginRsc(serverBundle) + for (const name of forwarded.emitted) clientBundle[name] = file + restore.generateBundle.handler({}, clientBundle) + + expect(entry.viteMetadata?.importedCss).toEqual( + new Set(['devup-ui-3.server.css']), + ) + expect(forwarded.emitted).toEqual(['devup-ui-3.server.css']) + expect(file.fileName).toBe('devup-ui-3.server.css') + expect(file.source).toBe('file sheet') + expect(Object.keys(clientBundle).sort()).toEqual([ + 'devup-ui-3.client.css', + 'devup-ui-3.server.css', + ]) + }) + }) + + it('starts a build from its own options and ends it at closeBundle', async () => { + const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue(undefined) + try { + const first = createPlugin({}) + resetSpy.mockClear() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + first.closeBundle() + first.closeBundle() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + } finally { + resetSpy.mockRestore() + } }) it('resolves a stable id during build', async () => { diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index f6d50ad48..008a0e201 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -3,33 +3,37 @@ import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + collectNumberedFiles, computeFileReach, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, + createStateWriter, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, - listSourceFiles, loadDevupConfig, mergeImportAliases, planAtomHoist, + seedFileNumbers, tailwindCssFiles, withTailwindCss, } from '@devup-ui/plugin-utils' import { codeExtract, - exportFileMap, getCss, getDefaultTheme, getThemeInterface, importCanonicalMap, - importFileMap, importFileRoutes, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -67,45 +71,6 @@ function resolveSourceDirs(root: string): string[] { ) } -/** - * Assigns each source file its devup file number up front, ordered by path. - * - * The engine otherwise hands numbers out on first sight, and the bundler - * transforms in parallel, so two identical builds produce different per-file - * class prefixes and therefore different CSS *and* JS asset hashes. Seeding - * from a sorted scan makes the numbering a pure function of the file paths. - * Every source file now holds a slot, where before only the ones that emitted - * styles consumed a number. Prefix length is a step function of the highest - * number handed out (1 char up to 26, 2 up to 1025, 3 beyond), so this is free - * until a project passes 1026 files under the scanned roots, at which point - * prefixes that used to be 2 chars become 3. - * - * Vite reports module ids as absolute POSIX-style paths even on Windows, so the - * scanned paths are normalized to match the keys `codeExtract` will look up. - * - * `importFileMap` REPLACES the engine's map, and a framework plugin resolves the - * config once per environment, so seeding unconditionally would wipe the numbers - * already handed to files outside `sourceDirs`: a monorepo sibling, or anything - * reached through `include`. The style sheet does not reset with the map, so the - * next such file reuses a live number and its atoms overwrite the previous - * owner's. Seeding only into an empty map keeps numbering deterministic on the - * first pass and stable for every later one. - */ -function seedFileMap(sourceDirs: string[]): void { - if (Object.keys(JSON.parse(exportFileMap())).length > 0) return - const sorted = [ - ...new Set( - sourceDirs - .flatMap((dir) => listSourceFiles(dir)) - .map((file) => file.replaceAll('\\', '/')), - ), - ].sort() - if (sorted.length === 0) return - const fileMap: Record = {} - for (const [index, file] of sorted.entries()) fileMap[file] = index - importFileMap(fileMap) -} - /** * Names each devup CSS module after its own file, so every module is emitted * once and shared by all of its importers. @@ -342,11 +307,13 @@ export function DevupUI({ atomHoist, importAliases: userImportAliases, }: Partial = {}): PluginOption { + // A build starts from its own options: whatever an earlier build in this + // process left in the engine (prefix, hoisting, routes, buckets, numbers, + // styles) is gone unless another build is still running. + const endBuild = beginBuild({ resetBuildState }) registerShorthands(shorthands ?? {}) setDebug(debug) - if (prefix) { - setPrefix(prefix) - } + setPrefix(prefix ?? null) const importAliases = mergeImportAliases(userImportAliases) const cssMap = new Map() let resolvedConfig: ResolvedConfig | undefined @@ -359,6 +326,9 @@ export function DevupUI({ // module transformed again writes its sheet again, and the reload that // signal causes transforms it once more: signal only a changed sheet. const writtenCss = new Map() + const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), + ) // The files the project's Tailwind CSS is read from, which change what a // Tailwind class compiles to let tailwindFiles: string[] = [] @@ -369,7 +339,7 @@ export function DevupUI({ function writeCssFile(fileName: string, css: string): Promise { if (writtenCss.get(fileName) === css) return Promise.resolve() writtenCss.set(fileName, css) - return writeFile(join(cssDir, fileName), css, 'utf-8') + return stateWriter.write(join(cssDir, fileName), css, 'utf-8') } const plugin: Plugin = { name: 'devup-ui', @@ -389,11 +359,6 @@ export function DevupUI({ }), ) const sourceDirs = resolveSourceDirs(projectRoot) - try { - seedFileMap(sourceDirs) - } catch { - // Best-effort; on failure numbering falls back to arrival order. - } if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') await writeFile( @@ -467,6 +432,23 @@ export function DevupUI({ // Best-effort; on failure atom hoisting stays off (identity). } } + try { + // Numbers come from the sorted paths of every file the build can + // extract (source and included packages), not from arrival order. + // Files numbered before keep their numbers, so a later pass in the + // dev server only numbers new files after the existing ones. + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: sourceDirs, + include, + cwd: projectRoot, + needles: extractedNeedles(libPackage, importAliases), + }), + ) + } catch { + // Best-effort; on failure numbering falls back to arrival order. + } }, config(this: { meta?: ConfigHookMeta } | void, userConfig: UserConfig) { const theme = getDefaultTheme() @@ -496,6 +478,9 @@ export function DevupUI({ apply() { return true }, + closeBundle() { + endBuild() + }, async watchChange(id) { if ( (resolve(id) === resolve(devupFile) && existsSync(devupFile)) || diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 882881af9..b88664c70 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -39,6 +39,7 @@ interface MockCompiler { afterCompile: { tap: ReturnType } run: { tap: ReturnType } thisCompilation: { tap: ReturnType } + shutdown: { tap: ReturnType } } } @@ -169,11 +170,46 @@ function createCompiler(): MockCompiler { thisCompilation: { tap: mock(), }, + shutdown: { + tap: mock(), + }, }, } } describe('devupUIWebpackPlugin', () => { + describe('deterministic file numbering', () => { + it('numbers the files the scan finds and ends the build at shutdown', () => { + const collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) + const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue(undefined) + try { + collectSpy.mockImplementation((options: any) => { + expect(options.toId(resolve('src', 'a.tsx'))).toBe('src/a.tsx') + return ['src/a.tsx', 'src/b.tsx'] + }) + const compiler = createCompiler() + new DevupUIWebpackPlugin({ include: ['@acme/ui'] }).apply( + asCompiler(compiler), + ) + expect(seedSpy).toHaveBeenCalledWith(['src/a.tsx', 'src/b.tsx']) + expect(collectSpy.mock.calls[0][0]).toMatchObject({ + roots: [resolve('src')], + include: ['@acme/ui'], + }) + compiler.hooks.shutdown.tap.mock.calls[0][1]() + compiler.hooks.shutdown.tap.mock.calls[0][1]() + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) + new DevupUIWebpackPlugin({}).apply(asCompiler(createCompiler())) + } finally { + collectSpy.mockRestore() + seedSpy.mockRestore() + resetSpy.mockRestore() + } + }) + }) it('should apply default options', () => { expect(new DevupUIWebpackPlugin({}).options).toEqual({ include: [], diff --git a/packages/webpack-plugin/src/loader.ts b/packages/webpack-plugin/src/loader.ts index 7b7f5e758..0c88b8737 100644 --- a/packages/webpack-plugin/src/loader.ts +++ b/packages/webpack-plugin/src/loader.ts @@ -1,7 +1,7 @@ import { writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' -import { createModuleResolver } from '@devup-ui/plugin-utils' +import { createModuleResolver, createStateWriter } from '@devup-ui/plugin-utils' import { codeExtract, exportClassMap, @@ -34,6 +34,9 @@ function parseSourceMap(sourceMap: string | undefined): string | null { return sourceMap } +const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), +) let moduleResolver: ReturnType | undefined /** Resolve imports to the cwd-relative ids this loader extracts files under */ @@ -101,23 +104,27 @@ const devupUILoader: RawLoaderDefinitionFunction = if (updatedBaseStyle) { // update base style promises.push( - writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'), + stateWriter.write( + join(cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + ), ) } if (cssFile) { const content = `${this.resourcePath} ${Date.now()}` // should be reset css promises.push( - writeFile( + stateWriter.write( join(cssDir, basename(cssFile)), watch ? `/* ${content} */` : css, ), ) if (watch) { promises.push( - writeFile(sheetFile, exportSheet()), - writeFile(classMapFile, exportClassMap()), - writeFile(fileMapFile, exportFileMap()), + stateWriter.write(sheetFile, exportSheet()), + stateWriter.write(classMapFile, exportClassMap()), + stateWriter.write(fileMapFile, exportFileMap()), ) } } diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index b96f2e176..6830fbfdd 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -4,7 +4,9 @@ import { createRequire } from 'node:module' import { dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + collectNumberedFiles, computeFileReach, computeReachableFiles, createCompatTypes, @@ -12,11 +14,13 @@ import { createNodeModulesExcludeRegex, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, loadDevupConfigSync, mergeImportAliases, planAtomHoist, + seedFileNumbers, tailwindCssFiles, type WasmImportAliases, withTailwindCss, @@ -33,6 +37,8 @@ import { importSheet, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -201,10 +207,12 @@ export class DevupUIWebpackPlugin { } apply(compiler: Compiler) { + // A build starts from its own options, not from what an earlier build in + // this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + compiler.hooks.shutdown?.tap('DevupUIWebpackPlugin', endBuild) setDebug(this.options.debug) - if (this.options.prefix) { - setPrefix(this.options.prefix) - } + setPrefix(this.options.prefix ?? null) const existsDevup = existsSync(this.options.devupFile) // read devup.json if (!existsSync(this.options.distDir)) @@ -282,6 +290,24 @@ export class DevupUIWebpackPlugin { // hoisting stays off. } + // Number every file the build can extract in path order, so class + // prefixes do not depend on which file a worker reaches first. Numbers + // already handed out (a restored map in watch mode) stay. + try { + const cwd = process.cwd() + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve(cwd, 'src')], + include: this.options.include, + cwd, + needles: extractedNeedles(this.options.package, this.importAliases), + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } // Pre-warm the extractor so the css-loader serves COMPLETE CSS. // // Webpack builds a stylesheet module ONCE, at its FIRST import: the shared