From 0f7ad7e25a54c834ede3ca6eec455035acafc444 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 3 Jun 2026 18:34:19 +0900 Subject: [PATCH 1/6] Apply new strategy --- .../changepack_log_oYGrO6CecMlSLwgUx0i3p.json | 1 + bindings/devup-ui-wasm/src/lib.rs | 536 ++++++++++- libs/css/src/atom_hoist.rs | 45 + libs/css/src/file_map.rs | 133 +++ libs/css/src/file_routes.rs | 123 +++ libs/css/src/lib.rs | 2 + libs/extractor/src/lib.rs | 107 ++- libs/sheet/src/lib.rs | 126 ++- .../next-plugin/src/__tests__/plugin.test.ts | 114 +++ packages/next-plugin/src/loader.ts | 12 +- packages/next-plugin/src/plugin.ts | 62 ++ .../plugin-utils/src/import-graph.test.ts | 483 ++++++++++ packages/plugin-utils/src/import-graph.ts | 879 ++++++++++++++++++ packages/plugin-utils/src/index.ts | 8 + .../src/__tests__/plugin.test.ts | 136 +++ packages/rsbuild-plugin/src/plugin.ts | 104 ++- .../vite-plugin/src/__tests__/plugin.test.ts | 145 +++ packages/vite-plugin/src/plugin.ts | 76 +- .../src/__tests__/loader.test.ts | 36 + .../src/__tests__/plugin.test.ts | 113 +++ packages/webpack-plugin/src/loader.ts | 6 +- packages/webpack-plugin/src/plugin.ts | 70 ++ 22 files changed, 3276 insertions(+), 41 deletions(-) create mode 100644 .changepacks/changepack_log_oYGrO6CecMlSLwgUx0i3p.json create mode 100644 libs/css/src/atom_hoist.rs create mode 100644 libs/css/src/file_routes.rs create mode 100644 packages/plugin-utils/src/import-graph.test.ts create mode 100644 packages/plugin-utils/src/import-graph.ts diff --git a/.changepacks/changepack_log_oYGrO6CecMlSLwgUx0i3p.json b/.changepacks/changepack_log_oYGrO6CecMlSLwgUx0i3p.json new file mode 100644 index 00000000..6fe43780 --- /dev/null +++ b/.changepacks/changepack_log_oYGrO6CecMlSLwgUx0i3p.json @@ -0,0 +1 @@ +{"changes":{"packages/next-plugin/package.json":"Patch","packages/rsbuild-plugin/package.json":"Patch","packages/plugin-utils/package.json":"Patch","packages/bun-plugin/package.json":"Patch","packages/eslint-plugin/package.json":"Patch","packages/components/package.json":"Patch","packages/webpack-plugin/package.json":"Patch","bindings/devup-ui-wasm/package.json":"Patch","packages/vite-plugin/package.json":"Patch"},"note":"Apply new strategy","date":"2026-06-03T09:34:04.566505700Z"} \ No newline at end of file diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 436a9719..591f496e 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1,5 +1,7 @@ use css::class_map::{set_class_map, with_class_map}; -use css::file_map::{set_file_map, with_file_map}; +use css::file_map::{ + canonical, is_global, set_canonical_map, set_file_map, with_canonical_map, with_file_map, +}; use extractor::extract_style::extract_style_value::ExtractStyleValue; use extractor::{ExtractOption, ImportAlias, extract, has_devup_ui}; use rustc_hash::FxHashSet; @@ -68,10 +70,17 @@ impl Output { css_file: Option, import_main_css: bool, ) -> Self { + // Use the bucket identity (single-importer collapse) so the sheet's CSS + // naming + property bucket + emitted chunk match the canonical class names + // the extractor already baked into `code`. Identity when no map is loaded. + // Global (shared-chunk) files are treated like single-css: emitted into the + // global bucket (devup-ui.css) with prefix-less naming. + let canonical_filename = canonical(&filename); + let global = single_css || is_global(&filename); with_style_sheet_mut(|sheet| { - let default_collected = sheet.rm_global_css(&filename, single_css); + let default_collected = sheet.rm_global_css(&canonical_filename, global); let (collected, updated_base_style) = - sheet.update_styles(&styles, &filename, single_css); + sheet.update_styles(&styles, &canonical_filename, global); Self { code, map, @@ -82,7 +91,11 @@ impl Output { None } else { Some(sheet.create_css( - if single_css { None } else { Some(&filename) }, + if global { + None + } else { + Some(&canonical_filename) + }, import_main_css, )) } @@ -247,6 +260,63 @@ pub fn export_file_map() -> Result { export_file_map_internal().map_err(js_error) } +/// Internal function to import the canonical (bucket) map (testable without `JsValue`) +pub fn import_canonical_map_internal(map: HashMap) { + set_canonical_map(map); +} + +/// 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()) +} + +#[wasm_bindgen(js_name = "importCanonicalMap")] +#[cfg(not(tarpaulin_include))] +pub fn import_canonical_map(map_object: JsValue) -> Result<(), JsValue> { + set_canonical_map(serde_wasm_bindgen::from_value(map_object).map_err(js_error)?); + Ok(()) +} + +#[wasm_bindgen(js_name = "exportCanonicalMap")] +#[cfg(not(tarpaulin_include))] +pub fn export_canonical_map() -> Result { + export_canonical_map_internal().map_err(js_error) +} + +/// Set the atom-level hoist threshold. +/// +/// When set to `Some(n)`, a style atom whose content is used by `>= n` distinct +/// routes is emitted into the shared global `devup-ui.css` (shipped once) instead +/// of duplicated into each per-route chunk. `None` (the default) disables atom +/// hoisting entirely (identity behavior). +/// +/// MUST be called BEFORE `codeExtract` so atoms receive global (shared) class +/// names; enabling it afterwards leaves per-file names and nothing hoists. +/// Pair with `importFileRoutes` to provide the file -> routes mapping. +#[wasm_bindgen(js_name = "setAtomHoist")] +pub fn set_atom_hoist(threshold: Option) { + css::atom_hoist::set_atom_hoist(threshold); +} + +/// Internal function to import the file -> routes map (testable without `JsValue`) +pub fn import_file_routes_internal(map: HashMap>) { + css::file_routes::set_file_routes(map); +} + +/// Import the file -> set-of-route-ids mapping used to decide atom hoisting. +/// +/// Accepts a JS object like `{ "src/page.tsx": [0, 3], "src/card.tsx": [0] }` +/// where each value is the set of leaf-route ids whose render closure includes +/// that file. Populated by the build-time pre-pass. +#[wasm_bindgen(js_name = "importFileRoutes")] +#[cfg(not(tarpaulin_include))] +pub fn import_file_routes(map_object: JsValue) -> Result<(), JsValue> { + css::file_routes::set_file_routes( + serde_wasm_bindgen::from_value(map_object).map_err(js_error)?, + ); + Ok(()) +} + /// Internal function to extract code (testable without `JsValue`) #[allow(clippy::too_many_arguments)] pub fn code_extract_internal( @@ -407,6 +477,464 @@ mod tests { ct } + #[test] + #[serial] + fn atom_hoist_splits_global_and_private() { + use css::atom_hoist::set_atom_hoist; + use css::class_map::reset_class_map; + use css::file_map::reset_file_map; + use css::file_routes::{reset_file_routes, set_file_routes}; + use std::collections::{HashMap, HashSet}; + + { + let mut s = GLOBAL_STYLE_SHEET.lock().unwrap(); + *s = StyleSheet::default(); + } + reset_class_map(); + reset_file_map(); + reset_file_routes(); + register_theme_internal(sheet::theme::Theme::default()); + + // a.tsx -> route 0, b.tsx -> route 1. bg:red is in BOTH (routes {0,1}, count 2 => HOIST). + // width:11px only in a (route {0}, private). width:22px only in b (private). + let mut fr = HashMap::new(); + fr.insert("a.tsx".to_string(), HashSet::from([0u32])); + fr.insert("b.tsx".to_string(), HashSet::from([1u32])); + set_file_routes(fr); + set_atom_hoist(Some(2)); + + let srca = r#"import { Box } from "@devup-ui/react"; const x = ;"#; + let srcb = r#"import { Box } from "@devup-ui/react"; const x = ;"#; + code_extract_internal( + "a.tsx", + srca, + "@devup-ui/react", + "df".to_string(), + false, + false, + false, + HashMap::new(), + ) + .unwrap(); + code_extract_internal( + "b.tsx", + srcb, + "@devup-ui/react", + "df".to_string(), + false, + false, + false, + HashMap::new(), + ) + .unwrap(); + + let global = with_style_sheet(|s| s.create_css(None, false)); + let chunk_a = with_style_sheet(|s| s.create_css(Some("a.tsx"), false)); + let chunk_b = with_style_sheet(|s| s.create_css(Some("b.tsx"), false)); + + // hoisted shared atom in the global file, ONCE; not the private ones + assert!( + global.contains("background:red"), + "global must have hoisted bg:red" + ); + assert_eq!( + global.matches("background:red").count(), + 1, + "bg:red deduped once in global" + ); + assert!( + !global.contains("width:11px") && !global.contains("width:22px"), + "private atoms not in global" + ); + + // private atoms in their route chunk; hoisted atom NOT duplicated there + assert!( + chunk_a.contains("width:11px") && !chunk_a.contains("background:red"), + "chunk a: private only" + ); + assert!( + chunk_b.contains("width:22px") && !chunk_b.contains("background:red"), + "chunk b: private only" + ); + + set_atom_hoist(None); + reset_file_routes(); + } + + /// Env-gated artifact emitter for split-native measurement. Writes REAL + /// devup CSS output (header, `@layer`, naming, dedup all authentic) for three + /// delivery models across several workloads, so an external script can + /// measure gzip/brotli + multi-route session + incremental-invalidation + /// bytes. Set `DEVUP_EMIT_MEASURE=1` to run; no-op (and zero cost) otherwise + /// so the normal test suite stays clean. + #[test] + #[serial] + #[allow(clippy::items_after_statements, clippy::format_push_string)] + fn emit_split_measurement_artifacts() { + use css::atom_hoist::set_atom_hoist; + use css::class_map::reset_class_map; + use css::file_map::reset_file_map; + use css::file_routes::{reset_file_routes, set_file_routes}; + use std::collections::{HashMap, HashSet}; + use std::fs; + + if std::env::var("DEVUP_EMIT_MEASURE").is_err() { + return; + } + + let props = [ + "w", + "h", + "p", + "m", + "minW", + "minH", + "maxW", + "maxH", + "fontSize", + "lineHeight", + "borderRadius", + "gap", + ]; + let atom = |key: &str, px: usize| format!(""); + let build = |els: &[String]| { + format!( + "import {{ Box }} from \"@devup-ui/react\"; const x = <>{};", + els.join("") + ) + }; + let reset = || { + { + let mut s = GLOBAL_STYLE_SHEET.lock().unwrap(); + *s = StyleSheet::default(); + } + reset_class_map(); + reset_file_map(); + reset_file_routes(); + register_theme_internal(sheet::theme::Theme::default()); + }; + + let out = std::env::temp_dir().join("devup-split-measure"); + let _ = fs::remove_dir_all(&out); + fs::create_dir_all(&out).unwrap(); + + // (name, routes, universal atoms, private atoms/route) + let workloads = [ + ("shared_heavy", 8usize, 80usize, 25usize), + ("balanced", 8usize, 50usize, 50usize), + ("disjoint", 8usize, 20usize, 60usize), + ]; + let mut manifest = String::from("["); + for (wi, (name, n, u, p)) in workloads.iter().enumerate() { + let (n, u, p) = (*n, *u, *p); + let universal: Vec = (0..u) + .map(|i| atom(props[i % props.len()], 100_000 + i)) + .collect(); + let make_priv = |r: usize| -> Vec { + (0..p) + .map(|i| { + atom( + props[i % props.len()], + 1_000_000 + wi * 1_000_000 + r * p + i, + ) + }) + .collect() + }; + let sources: Vec = (0..n) + .map(|r| { + let mut e = universal.clone(); + e.extend(make_priv(r)); + build(&e) + }) + .collect(); + let run = |single: bool| { + reset(); + for (r, src) in sources.iter().enumerate() { + code_extract_internal( + &format!("r{r}.tsx"), + src, + "@devup-ui/react", + "df".to_string(), + single, + false, + false, + HashMap::new(), + ) + .unwrap(); + } + }; + + // single-css: one shared file with every atom. + run(true); + fs::write( + out.join(format!("{name}_single.css")), + with_style_sheet(|s| s.create_css(None, false)), + ) + .unwrap(); + + // per-file: shared base (theme/base only) + one full chunk per route. + run(false); + fs::write( + out.join(format!("{name}_perfile_base.css")), + with_style_sheet(|s| s.create_css(None, false)), + ) + .unwrap(); + for r in 0..n { + fs::write( + out.join(format!("{name}_perfile_r{r}.css")), + with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)), + ) + .unwrap(); + } + + // atom-B: hoisted shared base (universal atoms) + per-route delta. + // CRITICAL: atom_hoist must be enabled BEFORE extraction so atoms get + // GLOBAL names (shared identity across files). Enabling it only at + // create_css time leaves per-file names, so the same universal atom + // looks like N distinct atoms (one per file) and never hoists. + reset(); + let mut fr = HashMap::new(); + for r in 0..n { + fr.insert(format!("r{r}.tsx"), HashSet::from([r as u32])); + } + set_file_routes(fr); + set_atom_hoist(Some(n)); + for (r, src) in sources.iter().enumerate() { + code_extract_internal( + &format!("r{r}.tsx"), + src, + "@devup-ui/react", + "df".to_string(), + false, + false, + false, + HashMap::new(), + ) + .unwrap(); + } + fs::write( + out.join(format!("{name}_atomb_base.css")), + with_style_sheet(|s| s.create_css(None, false)), + ) + .unwrap(); + for r in 0..n { + fs::write( + out.join(format!("{name}_atomb_r{r}.css")), + with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)), + ) + .unwrap(); + } + set_atom_hoist(None); + reset_file_routes(); + + manifest.push_str(&format!( + "{}{{\"name\":\"{name}\",\"n\":{n},\"u\":{u},\"p\":{p}}}", + if wi > 0 { "," } else { "" } + )); + } + manifest.push(']'); + fs::write(out.join("manifest.json"), manifest).unwrap(); + reset(); + set_atom_hoist(None); + println!("[EMIT] artifacts -> {}", out.display()); + } + + /// SPLIT-NATIVE LOCK: atom-level route-aware hoisting (global-named + /// shared-base + per-route delta) is a STRICT upgrade over the per-file mode + /// on the metrics that split actually competes on -- multi-route SESSION + /// bytes and incremental-deploy INVALIDATION bytes -- NOT on fresh-single- + /// route bytes (where per-file already hits the theoretical floor). + /// + /// This test supersedes an earlier "no win" lock that was built on a + /// measurement bug: enabling atom_hoist AFTER extraction left per-file class + /// names, so the same universal atom looked like N distinct atoms and never + /// hoisted -- making atom-B byte-identical to per-file (a no-op, not a + /// truth). The fix, asserted here, is that atom_hoist MUST be enabled BEFORE + /// extraction so atoms get GLOBAL (shared) names. + #[test] + #[serial] + // byte sizes are tiny so ratios are exact; doc prose names models literally + #[allow(clippy::cast_precision_loss, clippy::doc_markdown)] + fn atom_b_beats_per_file_on_session_and_invalidation() { + use css::atom_hoist::set_atom_hoist; + use css::class_map::reset_class_map; + use css::file_map::reset_file_map; + use css::file_routes::{reset_file_routes, set_file_routes}; + use std::collections::{HashMap, HashSet}; + + // Realistic design-system workload: many shared primitives, fewer + // route-private atoms. Routes are disjoint on private atoms. + const ROUTES: usize = 8; + const UNIVERSAL: usize = 80; + const PRIVATE: usize = 25; + + let props = ["w", "h", "p", "m", "minW", "minH", "maxW", "maxH"]; + let atom = |key: &str, px: usize| format!(""); + let build_source = |elements: &[String]| -> String { + let body = elements.join(""); + format!("import {{ Box }} from \"@devup-ui/react\"; const x = <>{body};") + }; + let reset_engine = || { + { + let mut s = GLOBAL_STYLE_SHEET.lock().unwrap(); + *s = StyleSheet::default(); + } + reset_class_map(); + reset_file_map(); + reset_file_routes(); + register_theme_internal(sheet::theme::Theme::default()); + }; + + let universal_atoms: Vec = (0..UNIVERSAL) + .map(|i| atom(props[i % props.len()], 100_000 + i)) + .collect(); + let make_private = |route: usize| -> Vec { + (0..PRIVATE) + .map(|i| atom(props[i % props.len()], 1_000_000 + route * PRIVATE + i)) + .collect() + }; + let sources: Vec = (0..ROUTES) + .map(|r| { + let mut e = universal_atoms.clone(); + e.extend(make_private(r)); + build_source(&e) + }) + .collect(); + let extract_all = |single_css: bool| { + for (r, src) in sources.iter().enumerate() { + code_extract_internal( + &format!("r{r}.tsx"), + src, + "@devup-ui/react", + "df".to_string(), + single_css, + false, + false, + HashMap::new(), + ) + .unwrap(); + } + }; + + // ---- per-file: atom_hoist OFF, multi-css. Each chunk carries all of + // its route's atoms (universals duplicated into every chunk). ---- + reset_engine(); + extract_all(false); + let pf_base = with_style_sheet(|s| s.create_css(None, false)).len(); + let pf_chunks: Vec = (0..ROUTES) + .map(|r| with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)).len()) + .collect(); + + // ---- atom-B: enable hoist + routes BEFORE extraction so atoms get + // GLOBAL names; universals (used by all ROUTES) hoist into the base, + // privates stay in their per-route delta. ---- + reset_engine(); + let mut fr = HashMap::new(); + for r in 0..ROUTES { + fr.insert(format!("r{r}.tsx"), HashSet::from([r as u32])); + } + set_file_routes(fr); + set_atom_hoist(Some(ROUTES)); + extract_all(false); + let ab_base = with_style_sheet(|s| s.create_css(None, false)).len(); + let ab_deltas: Vec = (0..ROUTES) + .map(|r| with_style_sheet(|s| s.create_css(Some(&format!("r{r}.tsx")), false)).len()) + .collect(); + set_atom_hoist(None); + reset_file_routes(); + reset_engine(); + + // Session = visit every route once (base cached after the first route). + let pf_session = pf_base + pf_chunks.iter().sum::(); + let ab_session = ab_base + ab_deltas.iter().sum::(); + // Invalidation = one route's styles change; returning user re-downloads + // only the file(s) whose hash changed. + let pf_invalidation = pf_chunks[0]; + let ab_invalidation = ab_deltas[0]; + + let session_margin = (pf_session as f64 - ab_session as f64) / pf_session as f64 * 100.0; + let invalidation_margin = + (pf_invalidation as f64 - ab_invalidation as f64) / pf_invalidation as f64 * 100.0; + println!( + "[SPLIT] base: per-file={pf_base}B atom-B={ab_base}B | chunk: per-file={}B atom-B-delta={}B", + pf_chunks[0], ab_deltas[0] + ); + println!( + "[SPLIT] session: per-file={pf_session}B atom-B={ab_session}B ({session_margin:.1}% smaller) | invalidation: per-file={pf_invalidation}B atom-B={ab_invalidation}B ({invalidation_margin:.1}% smaller)" + ); + + // Regression guard against the no-op-hoist bug: hoisting MUST have moved + // the universal atoms into the base, so the base is large and the delta + // is much smaller than a full per-file chunk. + assert!( + ab_base > pf_base + 500, + "hoist no-op: atom-B base ({ab_base}B) should hold the universal atoms, \ + but is barely larger than the empty per-file base ({pf_base}B). \ + atom_hoist was likely enabled AFTER extraction." + ); + assert!( + (ab_deltas[0] as f64) < (pf_chunks[0] as f64) * 0.6, + "hoist no-op: atom-B delta ({}B) should be far smaller than the full \ + per-file chunk ({}B) once universals are hoisted out", + ab_deltas[0], + pf_chunks[0] + ); + // The split-native wins this whole investigation hinges on. + assert!( + session_margin >= 15.0, + "atom-B should beat per-file on multi-route session bytes by >=15% \ + (got {session_margin:.1}%)" + ); + assert!( + invalidation_margin >= 30.0, + "atom-B should beat per-file on incremental-deploy invalidation by \ + >=30% (got {invalidation_margin:.1}%)" + ); + } + + #[test] + #[serial] + fn test_atom_hoist_and_file_routes_bindings() { + use css::atom_hoist::atom_hoist_threshold; + use css::file_routes::{get_file_routes, reset_file_routes}; + use std::collections::{HashMap, HashSet}; + + // setAtomHoist binding controls the global threshold. + set_atom_hoist(None); + assert_eq!(atom_hoist_threshold(), None); + set_atom_hoist(Some(4)); + assert_eq!(atom_hoist_threshold(), Some(4)); + set_atom_hoist(None); + assert_eq!(atom_hoist_threshold(), None); + + // importFileRoutes binding populates the file->routes map. + reset_file_routes(); + let mut m = HashMap::new(); + m.insert("a.tsx".to_string(), HashSet::from([0u32, 1])); + m.insert("b.tsx".to_string(), HashSet::from([2u32])); + import_file_routes_internal(m.clone()); + assert_eq!(get_file_routes(), m); + reset_file_routes(); + } + + #[test] + #[serial] + fn test_canonical_map_import_export_roundtrip() { + use css::file_map::{get_canonical_map, reset_canonical_map}; + reset_canonical_map(); + let mut m = HashMap::new(); + m.insert("src/child.tsx".to_string(), "src/parent.tsx".to_string()); + import_canonical_map_internal(m.clone()); + assert_eq!(get_canonical_map(), m); + let json = export_canonical_map_internal().expect("export canonical map"); + assert!(json.contains("src/child.tsx")); + assert!(json.contains("src/parent.tsx")); + // canonical() resolves via the imported map; unmapped is identity. + assert_eq!(canonical("src/child.tsx"), "src/parent.tsx"); + assert_eq!(canonical("src/other.tsx"), "src/other.tsx"); + reset_canonical_map(); + } + #[test] #[serial] fn test_code_extract() { diff --git a/libs/css/src/atom_hoist.rs b/libs/css/src/atom_hoist.rs new file mode 100644 index 00000000..259e4a6a --- /dev/null +++ b/libs/css/src/atom_hoist.rs @@ -0,0 +1,45 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + +// Atom-level hoist threshold. 0 = disabled (default). N = a style atom whose +// content is used by >= N distinct routes is emitted into the shared global +// devup-ui.css (shipped once) instead of duplicated across per-route chunks. +static ATOM_HOIST_THRESHOLD: AtomicUsize = AtomicUsize::new(0); + +#[inline(always)] +pub fn set_atom_hoist(threshold: Option) { + ATOM_HOIST_THRESHOLD.store(threshold.unwrap_or(0), Ordering::Relaxed); +} + +#[inline(always)] +#[must_use] +pub fn atom_hoist_threshold() -> Option { + match ATOM_HOIST_THRESHOLD.load(Ordering::Relaxed) { + 0 => None, + v => Some(v), + } +} + +#[inline(always)] +#[must_use] +pub fn is_atom_hoist() -> bool { + ATOM_HOIST_THRESHOLD.load(Ordering::Relaxed) != 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + #[serial] + fn test_atom_hoist() { + set_atom_hoist(None); + assert!(!is_atom_hoist()); + assert_eq!(atom_hoist_threshold(), None); + set_atom_hoist(Some(3)); + assert!(is_atom_hoist()); + assert_eq!(atom_hoist_threshold(), Some(3)); + set_atom_hoist(None); + assert!(!is_atom_hoist()); + } +} diff --git a/libs/css/src/file_map.rs b/libs/css/src/file_map.rs index 01617d71..95059578 100644 --- a/libs/css/src/file_map.rs +++ b/libs/css/src/file_map.rs @@ -93,6 +93,93 @@ pub fn get_filename_by_file_num(file_num: usize) -> String { }) } +// CANONICAL_MAP: real filename -> bucket-root filename. Populated by a build-time +// pre-pass (single-importer collapse). When empty, `canonical()` is the identity +// so existing behavior (and all snapshots) is unchanged — the dedup is opt-in. +#[cfg(target_arch = "wasm32")] +thread_local! { + static GLOBAL_CANONICAL_MAP: RefCell> = + RefCell::new(std::collections::HashMap::new()); +} + +#[cfg(not(target_arch = "wasm32"))] +static GLOBAL_CANONICAL_MAP: LazyLock>> = + LazyLock::new(|| Mutex::new(std::collections::HashMap::new())); + +#[inline] +pub fn with_canonical_map(f: F) -> R +where + F: FnOnce(&std::collections::HashMap) -> R, +{ + #[cfg(target_arch = "wasm32")] + #[cfg(not(tarpaulin_include))] + { + GLOBAL_CANONICAL_MAP.with(|map| f(&map.borrow())) + } + #[cfg(not(target_arch = "wasm32"))] + { + let guard = GLOBAL_CANONICAL_MAP + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(&guard) + } +} + +#[inline] +fn with_canonical_map_mut(f: F) -> R +where + F: FnOnce(&mut std::collections::HashMap) -> R, +{ + #[cfg(target_arch = "wasm32")] + #[cfg(not(tarpaulin_include))] + { + GLOBAL_CANONICAL_MAP.with(|map| f(&mut map.borrow_mut())) + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut guard = GLOBAL_CANONICAL_MAP + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(&mut guard) + } +} + +/// for test +pub fn reset_canonical_map() { + with_canonical_map_mut(std::collections::HashMap::clear); +} + +pub fn set_canonical_map(new_map: std::collections::HashMap) { + with_canonical_map_mut(|map| *map = new_map); +} + +#[must_use] +pub fn get_canonical_map() -> std::collections::HashMap { + with_canonical_map(Clone::clone) +} + +/// Resolve a filename to its bucket-root via `CANONICAL_MAP`, or identity when absent. +#[must_use] +pub fn canonical(filename: &str) -> String { + with_canonical_map(|map| { + map.get(filename) + .map_or_else(|| filename.to_string(), Clone::clone) + }) +} + +/// Sentinel `CANONICAL_MAP` value marking a file for global emission. +/// +/// Such a file's styles are hoisted into the global `devup-ui.css` (shared chunk) +/// with single-css naming, so styles shared across many routes ship once. Set by +/// the route-reachability pre-pass. +pub const GLOBAL_BUCKET: &str = "@global"; + +/// Whether a file is marked for global (shared-chunk) emission. +#[must_use] +pub fn is_global(filename: &str) -> bool { + with_canonical_map(|map| map.get(filename).map(String::as_str) == Some(GLOBAL_BUCKET)) +} + #[cfg(test)] mod tests { use serial_test::serial; @@ -122,4 +209,50 @@ mod tests { let got = get_file_map(); assert!(got.is_empty()); } + + #[test] + #[serial] + fn test_canonical_identity_when_empty() { + reset_canonical_map(); + assert_eq!(canonical("a.tsx"), "a.tsx"); + } + + #[test] + #[serial] + fn test_canonical_mapped_and_unmapped() { + let mut m = std::collections::HashMap::new(); + m.insert("child.tsx".to_string(), "parent.tsx".to_string()); + set_canonical_map(m); + // mapped -> bucket root + assert_eq!(canonical("child.tsx"), "parent.tsx"); + // unmapped -> identity + assert_eq!(canonical("other.tsx"), "other.tsx"); + reset_canonical_map(); + } + + #[test] + #[serial] + fn test_canonical_map_roundtrip() { + let mut m = std::collections::HashMap::new(); + m.insert("a".to_string(), "b".to_string()); + set_canonical_map(m.clone()); + assert_eq!(get_canonical_map(), m); + reset_canonical_map(); + assert!(get_canonical_map().is_empty()); + } + + #[test] + #[serial] + fn test_is_global() { + reset_canonical_map(); + assert!(!is_global("shared.tsx")); + let mut m = std::collections::HashMap::new(); + m.insert("shared.tsx".to_string(), GLOBAL_BUCKET.to_string()); + m.insert("child.tsx".to_string(), "parent.tsx".to_string()); + set_canonical_map(m); + assert!(is_global("shared.tsx")); // sentinel => global + assert!(!is_global("child.tsx")); // normal collapse => not global + assert!(!is_global("absent.tsx")); // absent => not global + reset_canonical_map(); + } } diff --git a/libs/css/src/file_routes.rs b/libs/css/src/file_routes.rs new file mode 100644 index 00000000..e2a7945c --- /dev/null +++ b/libs/css/src/file_routes.rs @@ -0,0 +1,123 @@ +use std::collections::{HashMap, HashSet}; + +#[cfg(target_arch = "wasm32")] +use std::cell::RefCell; + +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Mutex; + +#[cfg(not(target_arch = "wasm32"))] +use std::sync::LazyLock; + +// FILE_ROUTES: source filename -> set of leaf-route ids whose render closure +// includes that file. Populated by the build-time pre-pass. Used to decide, +// per atom, how many routes use it (for atom-level hoisting). +#[cfg(target_arch = "wasm32")] +thread_local! { + static GLOBAL_FILE_ROUTES: RefCell>> = + RefCell::new(HashMap::new()); +} + +#[cfg(not(target_arch = "wasm32"))] +static GLOBAL_FILE_ROUTES: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +#[inline] +pub fn with_file_routes(f: F) -> R +where + F: FnOnce(&HashMap>) -> R, +{ + #[cfg(target_arch = "wasm32")] + #[cfg(not(tarpaulin_include))] + { + GLOBAL_FILE_ROUTES.with(|map| f(&map.borrow())) + } + #[cfg(not(target_arch = "wasm32"))] + { + let guard = GLOBAL_FILE_ROUTES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(&guard) + } +} + +#[inline] +fn with_file_routes_mut(f: F) -> R +where + F: FnOnce(&mut HashMap>) -> R, +{ + #[cfg(target_arch = "wasm32")] + #[cfg(not(tarpaulin_include))] + { + GLOBAL_FILE_ROUTES.with(|map| f(&mut map.borrow_mut())) + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut guard = GLOBAL_FILE_ROUTES + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(&mut guard) + } +} + +/// for test +pub fn reset_file_routes() { + with_file_routes_mut(HashMap::clear); +} + +pub fn set_file_routes(new_map: HashMap>) { + with_file_routes_mut(|map| *map = new_map); +} + +#[must_use] +pub fn get_file_routes() -> HashMap> { + with_file_routes(Clone::clone) +} + +/// Number of DISTINCT routes across the given files (union of their route sets). +#[must_use] +pub fn route_count_for_files<'a>(files: impl IntoIterator) -> usize { + with_file_routes(|map| { + let mut routes: HashSet = HashSet::new(); + for file in files { + if let Some(set) = map.get(file) { + routes.extend(set.iter().copied()); + } + } + routes.len() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + #[serial] + fn test_set_get_reset_file_routes() { + let mut m = HashMap::new(); + m.insert("a.tsx".to_string(), HashSet::from([0u32, 1])); + set_file_routes(m.clone()); + assert_eq!(get_file_routes(), m); + reset_file_routes(); + assert!(get_file_routes().is_empty()); + } + + #[test] + #[serial] + fn test_route_count_for_files_union() { + let mut m = HashMap::new(); + m.insert("a.tsx".to_string(), HashSet::from([0u32, 1])); + m.insert("b.tsx".to_string(), HashSet::from([1u32, 2])); + m.insert("c.tsx".to_string(), HashSet::from([5u32])); + set_file_routes(m); + // union of {0,1} and {1,2} = {0,1,2} -> 3 + assert_eq!(route_count_for_files(["a.tsx", "b.tsx"]), 3); + // single file + assert_eq!(route_count_for_files(["c.tsx"]), 1); + // unknown file contributes nothing + assert_eq!(route_count_for_files(["a.tsx", "zzz.tsx"]), 2); + reset_file_routes(); + } +} diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 4caf9f9c..1d07b716 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -1,7 +1,9 @@ +pub mod atom_hoist; pub mod class_map; mod constant; pub mod debug; pub mod file_map; +pub mod file_routes; pub mod is_special_property; mod num_to_nm_base; pub mod optimize_multi_css_value; diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index e38c0c09..efeb820e 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -15,7 +15,7 @@ mod vanilla_extract; mod visit; use crate::extract_style::extract_style_value::ExtractStyleValue; use crate::visit::DevupVisitor; -use css::file_map::get_file_num_by_filename; +use css::file_map::{canonical, get_file_num_by_filename, is_global}; use oxc_allocator::{Allocator, CloneIn}; use oxc_ast::ast::Expression; use oxc_ast_visit::VisitMut; @@ -257,17 +257,23 @@ pub fn extract( }; let source_type = SourceType::from_path(filename)?; - let css_file = if option.single_css { + // Bucket identity for CSS naming/emission (single-importer collapse). Identity + // when no canonical map is loaded. Real `filename` is kept for parse/sourcemap. + let bucket = canonical(filename); + // Global (shared-chunk) files are emitted like single-css: into devup-ui.css + // with prefix-less global naming, so styles shared across routes ship once. + let global = option.single_css || is_global(filename); + let css_file = if global { format!("{}/devup-ui.css", option.css_dir) } else { format!( "{}/devup-ui-{}.css", option.css_dir, - get_file_num_by_filename(filename) + get_file_num_by_filename(&bucket) ) }; let mut css_files = vec![css_file.clone()]; - if option.import_main_css && !option.single_css { + if option.import_main_css && !global { css_files.insert(0, format!("{}/devup-ui.css", option.css_dir)); } let allocator = Allocator::default(); @@ -285,11 +291,7 @@ pub fn extract( filename, &option.package, css_files, - if option.single_css { - None - } else { - Some(filename.to_string()) - }, + if global { None } else { Some(bucket) }, ); visitor.visit_program(&mut program); let result = Codegen::new() @@ -316,13 +318,15 @@ fn extract_class_map_from_code( style_names: &FxHashSet, ) -> Result, Box> { let source_type = SourceType::from_path(filename)?; - let css_file = if option.single_css { + let bucket = canonical(filename); + let global = option.single_css || is_global(filename); + let css_file = if global { format!("{}/devup-ui.css", option.css_dir) } else { format!( "{}/devup-ui-{}.css", option.css_dir, - get_file_num_by_filename(filename) + get_file_num_by_filename(&bucket) ) }; let css_files = vec![css_file]; @@ -341,11 +345,7 @@ fn extract_class_map_from_code( filename, &option.package, css_files, - if option.single_css { - None - } else { - Some(filename.to_string()) - }, + if global { None } else { Some(bucket) }, ); visitor.visit_program(&mut program); @@ -461,6 +461,81 @@ mod tests { assert!(option.import_aliases.is_empty()); } + #[test] + #[serial] + fn extract_canonical_bucket_merge() { + use css::file_map::{reset_canonical_map, set_canonical_map}; + reset_class_map(); + reset_file_map(); + reset_canonical_map(); + // child.tsx is a single-importer leaf collapsed into parent.tsx's bucket. + let mut m = std::collections::HashMap::new(); + m.insert("child.tsx".to_string(), "parent.tsx".to_string()); + set_canonical_map(m); + + let opt = || ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "df/devup-ui".to_string(), + single_css: false, + import_main_css: false, + import_aliases: HashMap::new(), + }; + let src = r#"import { Box } from "@devup-ui/react"; const a = ;"#; + let parent = extract("parent.tsx", src, opt()).unwrap(); + let child = extract("child.tsx", src, opt()).unwrap(); + + // co-bucketed -> same css chunk file (child uses parent's file_num) + assert_eq!(parent.css_file, child.css_file); + // co-bucketed -> identical class naming (dedup) -> identical transformed code + assert_eq!(parent.code, child.code); + + reset_canonical_map(); + reset_class_map(); + reset_file_map(); + } + + #[test] + #[serial] + fn extract_global_hoist() { + use css::file_map::{GLOBAL_BUCKET, reset_canonical_map, set_canonical_map}; + reset_class_map(); + reset_file_map(); + reset_canonical_map(); + // shared.tsx is hoisted to the global chunk; normal.tsx is a per-file bucket. + let mut m = std::collections::HashMap::new(); + m.insert("shared.tsx".to_string(), GLOBAL_BUCKET.to_string()); + set_canonical_map(m); + + let opt = || ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "df/devup-ui".to_string(), + single_css: false, + import_main_css: false, + import_aliases: HashMap::new(), + }; + let src = r#"import { Box } from "@devup-ui/react"; const a = ;"#; + let global_out = extract("shared.tsx", src, opt()).unwrap(); + let normal_out = extract("normal.tsx", src, opt()).unwrap(); + + // global file emits into the shared devup-ui.css (loaded once) + assert_eq!( + global_out.css_file.as_deref(), + Some("df/devup-ui/devup-ui.css") + ); + // non-global file stays in a per-file chunk + assert!( + normal_out + .css_file + .as_deref() + .unwrap() + .contains("devup-ui-") + ); + + reset_canonical_map(); + reset_class_map(); + reset_file_map(); + } + #[test] #[serial] fn extract_just_tsx() { diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 8a7cefec..93dbeab4 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -2,6 +2,8 @@ pub mod theme; use crate::theme::Theme; use css::{ + atom_hoist::{atom_hoist_threshold, is_atom_hoist}, + file_routes::route_count_for_files, merge_selector, sheet_to_classname, style_selector::{AtRuleKind, StyleSelector}, theme_tokens::set_theme_token_levels, @@ -11,7 +13,7 @@ use extractor::extract_style::extract_static_style::ThemeTokenResolution; use extractor::extract_style::extract_style_value::ExtractStyleValue; use extractor::extract_style::style_property::StyleProperty; use regex_lite::Regex; -use rustc_hash::FxHashSet; +use rustc_hash::{FxHashMap, FxHashSet}; use serde::de::Error; use serde::{Deserialize, Deserializer, Serialize}; use std::borrow::Cow; @@ -340,6 +342,16 @@ impl StyleSheet { ) -> (bool, bool) { let mut collected = false; let mut updated_base_style = false; + // Decouple class NAMING from property BUCKETING. atom_hoist uses GLOBAL + // (prefix-less, shared-registry) names like single_css, but still keeps + // per-file property buckets so create_css can route each atom to the + // global chunk or a per-route chunk based on its route usage. + let name_scope = if single_css || is_atom_hoist() { + None + } else { + Some(filename) + }; + let bucket_scope = if single_css { None } else { Some(filename) }; for style in styles { match style { ExtractStyleValue::Static(st) => { @@ -372,10 +384,10 @@ impl StyleSheet { Some(&resolved_value), selector.as_deref(), st.style_order(), - if single_css { None } else { Some(filename) }, + name_scope, ) } else { - match st.extract(if single_css { None } else { Some(filename) }) { + match st.extract(name_scope) { StyleProperty::ClassName(cls) | StyleProperty::Variable { class_name: cls, .. @@ -390,7 +402,7 @@ impl StyleSheet { &resolved_value, st.selector(), st.style_order(), - if single_css { None } else { Some(filename) }, + bucket_scope, st.layer(), ) { collected = true; @@ -404,7 +416,7 @@ impl StyleSheet { class_name, variable_name, .. - }) = style.extract(if single_css { None } else { Some(filename) }) + }) = style.extract(name_scope) && self.add_property( &class_name, dy.property(), @@ -416,7 +428,7 @@ impl StyleSheet { }, dy.selector(), dy.style_order(), - if single_css { None } else { Some(filename) }, + bucket_scope, ) { collected = true; @@ -428,9 +440,7 @@ impl StyleSheet { ExtractStyleValue::Keyframes(keyframes) => { if self.add_keyframes( - &keyframes - .extract(if single_css { None } else { Some(filename) }) - .to_string(), + &keyframes.extract(name_scope).to_string(), keyframes .keyframes .iter() @@ -449,7 +459,7 @@ impl StyleSheet { ) }) .collect(), - if single_css { None } else { Some(filename) }, + bucket_scope, ) { collected = true; } @@ -717,6 +727,38 @@ impl StyleSheet { &HEADER } + /// Compute the set of atom class names that should be hoisted into the + /// global stylesheet under atom-level hoisting. + /// + /// An atom (uniquely identified by its `class_name` under global naming) is + /// hoisted when the number of routes that transitively use it reaches the + /// configured threshold. Base styles (`style_order == 0`) are excluded + /// because they are already emitted globally and shared by every chunk. + fn compute_hoisted_atoms(&self, threshold: usize) -> FxHashSet { + // atom class_name -> set of files that reference it (order != 0) + let mut atom_files: FxHashMap> = FxHashMap::default(); + for (filename, property_map) in &self.properties { + for (style_order, level_map) in property_map { + if *style_order == 0 { + continue; + } + for props in level_map.values() { + for prop in props { + atom_files + .entry(prop.class_name.clone()) + .or_default() + .insert(filename.as_str()); + } + } + } + } + atom_files + .into_iter() + .filter(|(_, files)| route_count_for_files(files.iter().copied()) >= threshold) + .map(|(class_name, _)| class_name) + .collect() + } + #[must_use] pub fn create_css(&self, filename: Option<&str>, import_main_css: bool) -> String { let mut css = String::with_capacity(4096); @@ -731,6 +773,11 @@ impl StyleSheet { let write_global = filename.is_none(); + // Under atom-level hoisting, decide which atoms (order != 0) live in the + // shared global stylesheet vs. their per-route chunk. + let hoisted_atoms: Option> = + atom_hoist_threshold().map(|threshold| self.compute_hoisted_atoms(threshold)); + if write_global { let mut style_orders: BTreeSet = BTreeSet::new(); let mut base_styles = BTreeMap::>::new(); @@ -852,6 +899,43 @@ impl StyleSheet { css.push('}'); } } + // Atom hoisting: emit shared (hoisted) order!=0 atoms into the global + // stylesheet, aggregated across every file and deduplicated by atom + // identity (class_name). + if let Some(hoisted) = &hoisted_atoms { + let mut aggregated: BTreeMap>> = + BTreeMap::new(); + for property_map in self.properties.values() { + for (style_order, level_map) in property_map { + if *style_order == 0 { + continue; + } + for (level, props) in level_map { + for prop in props { + if hoisted.contains(&prop.class_name) { + aggregated + .entry(*style_order) + .or_default() + .entry(*level) + .or_default() + .insert(prop.clone()); + } + } + } + } + } + for (style_order, map) in aggregated { + let current_css = self.create_style(&map); + if current_css.is_empty() { + continue; + } + if style_order == 255 { + css.push_str(¤t_css); + } else { + push_fmt!(&mut css, "@layer o{style_order}{{{current_css}}}"); + } + } + } } else { // avoid inline import issue (vite plugin) if import_main_css { @@ -886,7 +970,27 @@ impl StyleSheet { // base style was created in global css continue; } - let current_css = self.create_style(map); + // Under atom hoisting, hoisted atoms were emitted globally; the + // per-route chunk keeps only its route-private atoms. + let current_css = if let Some(hoisted) = &hoisted_atoms { + let filtered: BTreeMap> = map + .iter() + .filter_map(|(level, props)| { + let kept: FxHashSet = props + .iter() + .filter(|prop| !hoisted.contains(&prop.class_name)) + .cloned() + .collect(); + (!kept.is_empty()).then_some((*level, kept)) + }) + .collect(); + if filtered.is_empty() { + continue; + } + self.create_style(&filtered) + } else { + self.create_style(map) + }; if !current_css.is_empty() { // order style 255 is user css diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 7b8f55c5..9e55fa1a 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -1,6 +1,7 @@ import * as fs from 'node:fs' import { join, resolve } from 'node:path' +import * as importGraphModule from '@devup-ui/plugin-utils' import * as wasm from '@devup-ui/wasm' import * as webpackPluginModule from '@devup-ui/webpack-plugin' import { @@ -690,5 +691,118 @@ describe('DevupUINextPlugin', () => { processOnSpy.mockRestore() }) + + it('does not enable atom hoisting when atomHoist option is unset', () => { + process.env.TURBOPACK = '1' + const setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue( + undefined, + ) + const importFileRoutesSpy = spyOn( + wasm, + 'importFileRoutes', + ).mockReturnValue(undefined) + const importCanonicalMapSpy = spyOn( + wasm, + 'importCanonicalMap', + ).mockReturnValue(undefined) + try { + DevupUI({}) + expect(setAtomHoistSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + // single-importer collapse still runs (it is the always-on default) + expect(importCanonicalMapSpy).toHaveBeenCalled() + } finally { + setAtomHoistSpy.mockRestore() + importFileRoutesSpy.mockRestore() + importCanonicalMapSpy.mockRestore() + } + }) + + it('composes atom hoisting WITH single-importer collapse when atomHoist is set', () => { + process.env.TURBOPACK = '1' + // 4 distinct leaf routes (ids 0..3); layout shared by all four. + const computeSpy = spyOn( + importGraphModule, + 'computeFileRoutes', + ).mockReturnValue({ + 'src/app/layout.tsx': [0, 1, 2, 3], + 'src/app/a/page.tsx': [0], + 'src/app/b/page.tsx': [1], + 'src/app/c/page.tsx': [2], + 'src/app/d/page.tsx': [3], + }) + const importFileRoutesSpy = spyOn( + wasm, + 'importFileRoutes', + ).mockReturnValue(undefined) + const setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue( + undefined, + ) + // Collapse and atom hoisting COMPOSE: importCanonicalMap must STILL run. + const importCanonicalMapSpy = spyOn( + wasm, + 'importCanonicalMap', + ).mockReturnValue(undefined) + try { + DevupUI({}, { atomHoist: 2 }) + // reach folded by bucket; mocked FS => empty canonical map => bucket==file + expect(importFileRoutesSpy).toHaveBeenCalledWith({ + 'src/app/layout.tsx': [0, 1, 2, 3], + 'src/app/a/page.tsx': [0], + 'src/app/b/page.tsx': [1], + 'src/app/c/page.tsx': [2], + 'src/app/d/page.tsx': [3], + }) + // direct threshold, clamped to >= 2 + expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + // composition: collapse pre-pass also ran + expect(importCanonicalMapSpy).toHaveBeenCalled() + } finally { + computeSpy.mockRestore() + importFileRoutesSpy.mockRestore() + setAtomHoistSpy.mockRestore() + importCanonicalMapSpy.mockRestore() + } + }) + + it('clamps the atomHoist threshold to a minimum of 2', () => { + process.env.TURBOPACK = '1' + const computeSpy = spyOn( + importGraphModule, + 'computeFileRoutes', + ).mockReturnValue({ + 'src/app/a/page.tsx': [0], + 'src/app/b/page.tsx': [1], + }) + const setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue( + undefined, + ) + try { + DevupUI({}, { atomHoist: 1 }) + // max(2, 1) === 2 + expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + } finally { + computeSpy.mockRestore() + setAtomHoistSpy.mockRestore() + } + }) + + it('keeps atom hoisting off when fewer than two routes exist', () => { + process.env.TURBOPACK = '1' + const computeSpy = spyOn( + importGraphModule, + 'computeFileRoutes', + ).mockReturnValue({ 'src/app/a/page.tsx': [0] }) + const setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue( + undefined, + ) + try { + DevupUI({}, { atomHoist: 2 }) + expect(setAtomHoistSpy).not.toHaveBeenCalled() + } finally { + computeSpy.mockRestore() + setAtomHoistSpy.mockRestore() + } + }) }) }) diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index d8163e5e..1110699b 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -160,7 +160,13 @@ const devupUILoader: RawLoaderDefinitionFunction = } try { const port = readCoordinatorPort(coordinatorPortFile) - const relativePath = relative(process.cwd(), this.resourcePath) + // POSIX-normalize so the engine's bucket key matches the canonical map + // and FILE_ROUTES keys (both built with forward slashes). Without this, + // canonical collapse and atom hoisting silently no-op on Windows. + const relativePath = relative( + process.cwd(), + this.resourcePath, + ).replaceAll('\\', '/') const body = JSON.stringify({ filename: relativePath, code: source.toString(), @@ -212,7 +218,9 @@ const devupUILoader: RawLoaderDefinitionFunction = const id = this.resourcePath let relCssDir = relative(dirname(id), cssDir).replaceAll('\\', '/') - const relativePath = relative(process.cwd(), id) + // POSIX-normalize (see coordinator-mode note above) so bucket keys match + // the canonical map / FILE_ROUTES on Windows. + const relativePath = relative(process.cwd(), id).replaceAll('\\', '/') if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` const { code, map, cssFile, updatedBaseStyle } = codeExtract( diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 0011ef9c..613b8ddc 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -8,6 +8,8 @@ import { import { join, relative, resolve } from 'node:path' import { + buildCanonicalMap, + computeFileRoutes, createNodeModulesExcludeRegex, createThemeInterfaceArgs, loadDevupConfigSync, @@ -20,10 +22,13 @@ import { getCss, getDefaultTheme, getThemeInterface, + importCanonicalMap, importClassMap, importFileMap, + importFileRoutes, importSheet, registerTheme, + setAtomHoist, setPrefix, } from '@devup-ui/wasm' import { @@ -64,6 +69,7 @@ export function DevupUI( devupFile = 'devup.json', include = [], prefix, + atomHoist, importAliases: userImportAliases, } = options @@ -76,6 +82,7 @@ export function DevupUI( const sheetFile = join(distDir, 'sheet.json') const classMapFile = join(distDir, 'classMap.json') const fileMapFile = join(distDir, 'fileMap.json') + const canonicalMapFile = join(distDir, 'canonicalMap.json') const gitignoreFile = join(distDir, '.gitignore') if (!existsSync(distDir)) mkdirSync(distDir, { @@ -115,6 +122,61 @@ export function DevupUI( const coordinatorPortFile = join(distDir, 'coordinator.port') + // Pre-pass: single-importer collapse ALWAYS runs (files with exactly one + // importer merge into that importer's bucket, so their identical atoms share + // one class). Atom-level hoisting COMPOSES on top: an atom reached by + // >= atomHoist distinct routes is emitted once into the shared devup-ui.css. + // + // The two compose because both are keyed by the canonical bucket: the engine + // keys property buckets by canonical(filename), and the route-reach map below + // is folded onto the SAME canonical bucket — so route_count_for_files() looks + // atoms up by bucket and the lookup hits. `atomHoist` must be configured + // BEFORE any extraction so atoms receive global (shared) class names; the + // coordinator shares this WASM instance, so it applies to every /extract. + const atomMode = + atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 + try { + const srcDir = resolve(process.cwd(), 'src') + const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') + const cwd = process.cwd() + // Atom hoisting owns the shared-chunk decision, so collapse runs WITHOUT + // the file-level @global hoist (DEVUP_HOIST_V) in atom mode. + const hoistV = atomMode + ? undefined + : process.env.DEVUP_HOIST_V + ? Number(process.env.DEVUP_HOIST_V) + : undefined + const canonicalMap = buildCanonicalMap({ + srcDir, + tsconfigPath, + cwd, + hoistV, + }) + importCanonicalMap(canonicalMap) + writeFileSync(canonicalMapFile, JSON.stringify(canonicalMap)) + + if (atomMode) { + // Fold per-file route reach onto the canonical bucket so the keys match + // the engine's property bucket keys (canonical(filename)). + const fileRoutes = computeFileRoutes({ srcDir, tsconfigPath, cwd }) + const reachByBucket: Record = {} + for (const [file, ids] of Object.entries(fileRoutes)) { + const bucket = canonicalMap[file] ?? file + if (bucket === '@global') continue + const set = (reachByBucket[bucket] ??= []) + for (const id of ids) if (!set.includes(id)) set.push(id) + } + const routeCount = new Set(Object.values(fileRoutes).flat()).size + if (routeCount >= 2) { + importFileRoutes(reachByBucket) + setAtomHoist(Math.max(2, atomHoist)) + } + } + } catch { + // Pre-pass is best-effort; on failure canonical() is the identity (no + // merge) and atom hoisting stays off. + } + // create devup-ui.css file writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false)) diff --git a/packages/plugin-utils/src/import-graph.test.ts b/packages/plugin-utils/src/import-graph.test.ts new file mode 100644 index 00000000..93152926 --- /dev/null +++ b/packages/plugin-utils/src/import-graph.test.ts @@ -0,0 +1,483 @@ +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 } from 'bun:test' + +import { + buildCanonicalMap, + computeFileReach, + computeFileRoutes, +} from './import-graph' + +describe('buildCanonicalMap', () => { + let tempRoot: string + let cwd: string + let srcDir: string + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'devup-ui-import-graph-')) + cwd = join(tempRoot, 'project') + srcDir = join(cwd, 'src') + mkdirSync(srcDir, { recursive: true }) + }) + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }) + }) + + function writeFixture(path: string, code: string): void { + const filePath = join(cwd, path) + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, code) + } + + it('should collapse a file with a single static importer', () => { + writeFixture('src/a.tsx', "import './b'\n") + writeFixture('src/b.tsx', 'export const b = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir }) + + expect(map).toEqual({ + 'src/b.tsx': 'src/a.tsx', + }) + }) + + it('should keep files with at least two static importers split', () => { + writeFixture('src/a.tsx', "import './c'\n") + writeFixture('src/d.tsx', "import './c'\n") + writeFixture('src/c.tsx', 'export const c = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir }) + + expect(map).toEqual({}) + }) + + it('should keep dynamic import targets split', () => { + writeFixture( + 'src/a.tsx', + "export async function load() { return import('./e') }\n", + ) + writeFixture('src/e.tsx', 'export const e = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir }) + + expect(map).toEqual({}) + }) + + it('should keep Next App Router special files as roots', () => { + const routeFiles = [ + 'page', + 'layout', + 'template', + 'default', + 'loading', + 'error', + 'not-found', + 'global-error', + ] + writeFixture( + 'src/app/importer.tsx', + routeFiles.map((file) => `import './${file}'`).join('\n'), + ) + for (const file of routeFiles) { + writeFixture(`src/app/${file}.tsx`, `export const name = '${file}'\n`) + } + + const map = buildCanonicalMap({ cwd, srcDir }) + + expect(map).toEqual({}) + }) + + it('should collapse single-importer chains to the top bucket root', () => { + writeFixture('src/a.tsx', "import './b'\n") + writeFixture('src/b.tsx', "import './c'\n") + writeFixture('src/c.tsx', "import './d'\n") + writeFixture('src/d.tsx', 'export const d = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir }) + + expect(map).toEqual({ + 'src/b.tsx': 'src/a.tsx', + 'src/c.tsx': 'src/a.tsx', + 'src/d.tsx': 'src/a.tsx', + }) + }) + + it('should keep closed cycles split without hanging', () => { + writeFixture('src/a.tsx', "import './b'\n") + writeFixture('src/b.tsx', "import './a'\n") + + const map = buildCanonicalMap({ cwd, srcDir }) + + expect(map).toEqual({}) + }) + + it('should return cwd-relative POSIX paths for keys and values', () => { + writeFixture('src/app/a.tsx', "import '../shared/b'\n") + writeFixture('src/shared/b.tsx', 'export const b = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir }) + + const [key, value] = Object.entries(map)[0] + + expect(key).toBe('src/shared/b.tsx') + expect(value).toBe('src/app/a.tsx') + }) + + it('should resolve tsconfig paths aliases inside srcDir', () => { + writeFixture( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { + '@/*': ['src/*'], + }, + }, + }), + ) + writeFixture('src/a.tsx', "import '@/foo'\n") + writeFixture('src/foo.tsx', 'export const foo = 1\n') + + const map = buildCanonicalMap({ + cwd, + srcDir, + tsconfigPath: join(cwd, 'tsconfig.json'), + }) + + expect(map).toEqual({ + 'src/foo.tsx': 'src/a.tsx', + }) + }) + + it('should ignore external package imports', () => { + writeFixture('src/a.tsx', "import React from 'react'\n") + + const map = buildCanonicalMap({ cwd, srcDir }) + + expect(map).toEqual({}) + }) + + it('should ignore non-JavaScript import targets', () => { + writeFixture('src/a.tsx', "import './x.css'\n") + writeFixture('src/x.css', '.x { color: red }\n') + + const map = buildCanonicalMap({ cwd, srcDir }) + expect(map).toEqual({}) + }) + + it('should not hoist shared route imports when hoistV is undefined', () => { + writeFixture( + 'src/app/alpha/page.tsx', + "import '../../components/shared'\nimport './private'\n", + ) + writeFixture('src/app/alpha/private.tsx', 'export const privateValue = 1\n') + writeFixture('src/app/beta/page.tsx', "import '../../components/shared'\n") + writeFixture('src/components/shared.tsx', 'export const shared = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir }) + + expect(map).toEqual({ + 'src/app/alpha/private.tsx': 'src/app/alpha/page.tsx', + }) + }) + + it('should hoist a component reached by three routes when hoistV is large', () => { + writeFixture('src/app/alpha/page.tsx', "import '../../components/shared'\n") + writeFixture('src/app/beta/page.tsx', "import '../../components/shared'\n") + writeFixture('src/app/gamma/page.tsx', "import '../../components/shared'\n") + writeFixture('src/components/shared.tsx', 'export const shared = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir, hoistV: 100 }) + + expect(map).toEqual({ + 'src/components/shared.tsx': '@global', + }) + }) + + it('should not hoist a component below the hoistV one threshold', () => { + writeFixture('src/app/alpha/page.tsx', "import '../../components/shared'\n") + writeFixture('src/app/beta/page.tsx', "import '../../components/shared'\n") + writeFixture('src/app/gamma/page.tsx', 'export const gamma = 1\n') + writeFixture('src/components/shared.tsx', 'export const shared = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir, hoistV: 1 }) + + expect(map).toEqual({}) + }) + + it('should keep a component reached by one route private regardless of hoistV', () => { + writeFixture('src/app/alpha/page.tsx', "import './private'\n") + writeFixture('src/app/alpha/private.tsx', 'export const privateValue = 1\n') + writeFixture('src/app/beta/page.tsx', 'export const beta = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir, hoistV: 100 }) + + expect(map).toEqual({ + 'src/app/alpha/private.tsx': 'src/app/alpha/page.tsx', + }) + }) + + it('should hoist files at the route reachability threshold boundary', () => { + writeFixture( + 'src/app/alpha/page.tsx', + "import '../../components/shared'\nimport './private'\n", + ) + writeFixture('src/app/alpha/private.tsx', 'export const privateValue = 1\n') + writeFixture('src/app/beta/page.tsx', "import '../../components/shared'\n") + writeFixture('src/app/gamma/page.tsx', 'export const gamma = 1\n') + writeFixture('src/app/delta/page.tsx', 'export const delta = 1\n') + writeFixture('src/components/shared.tsx', 'export const shared = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir, hoistV: 2 }) + + expect(map).toEqual({ + 'src/app/alpha/private.tsx': 'src/app/alpha/page.tsx', + 'src/components/shared.tsx': '@global', + }) + }) + + it('should not count dynamic import targets as statically reached for hoist', () => { + writeFixture( + 'src/app/alpha/page.tsx', + "export async function load() { return import('./dynamic') }\n", + ) + writeFixture('src/app/beta/page.tsx', 'export const beta = 1\n') + writeFixture('src/app/alpha/dynamic.tsx', 'export const dynamic = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir, hoistV: 100 }) + + expect(map).toEqual({}) + }) + + it('should prefer @global over single-importer collapse for shared descendants', () => { + writeFixture( + 'src/app/alpha/page.tsx', + "import '../../components/shared-parent'\n", + ) + writeFixture( + 'src/app/beta/page.tsx', + "import '../../components/shared-parent'\n", + ) + writeFixture('src/components/shared-parent.tsx', "import './shared-leaf'\n") + writeFixture( + 'src/components/shared-leaf.tsx', + 'export const sharedLeaf = 1\n', + ) + + const map = buildCanonicalMap({ cwd, srcDir, hoistV: 100 }) + + expect(map).toEqual({ + 'src/components/shared-leaf.tsx': '@global', + 'src/components/shared-parent.tsx': '@global', + }) + }) + + it('should hoist components imported by ancestor layouts across leaf routes', () => { + writeFixture('src/app/layout.tsx', "import './header'\n") + writeFixture('src/app/header.tsx', 'export const Header = 1\n') + writeFixture('src/app/a/page.tsx', 'export const A = 1\n') + writeFixture('src/app/b/page.tsx', 'export const B = 1\n') + writeFixture('src/app/c/page.tsx', 'export const C = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir, hoistV: 2 }) + + expect(map).toEqual({ + 'src/app/header.tsx': '@global', + 'src/app/layout.tsx': '@global', + }) + }) + + it('should keep one leaf route imports private under leaf route reachability', () => { + writeFixture('src/app/a/page.tsx', "import './private'\n") + writeFixture('src/app/a/private.tsx', 'export const privateValue = 1\n') + writeFixture('src/app/b/page.tsx', 'export const B = 1\n') + writeFixture('src/app/c/page.tsx', 'export const C = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir, hoistV: 100 }) + + expect(map).toEqual({ + 'src/app/a/private.tsx': 'src/app/a/page.tsx', + }) + }) + + it('should hoist nested layout imports for every leaf route they wrap', () => { + writeFixture('src/app/layout.tsx', "import './header'\n") + writeFixture('src/app/header.tsx', 'export const Header = 1\n') + writeFixture('src/app/docs/layout.tsx', "import './sidebar'\n") + writeFixture('src/app/docs/sidebar.tsx', 'export const Sidebar = 1\n') + writeFixture('src/app/docs/x/page.tsx', "import './x-only'\n") + writeFixture('src/app/docs/x/x-only.tsx', 'export const XOnly = 1\n') + writeFixture('src/app/docs/y/page.tsx', 'export const DocsY = 1\n') + writeFixture('src/app/other/page.tsx', 'export const Other = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir, hoistV: 100 }) + + expect(map).toEqual({ + 'src/app/docs/layout.tsx': '@global', + 'src/app/docs/sidebar.tsx': '@global', + 'src/app/docs/x/x-only.tsx': 'src/app/docs/x/page.tsx', + 'src/app/header.tsx': '@global', + 'src/app/layout.tsx': '@global', + }) + }) +}) + +describe('computeFileRoutes', () => { + let tempRoot: string + let cwd: string + let srcDir: string + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'devup-ui-file-routes-')) + cwd = join(tempRoot, 'project') + srcDir = join(cwd, 'src') + mkdirSync(srcDir, { recursive: true }) + }) + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }) + }) + + function writeFixture(path: string, code: string): void { + const filePath = join(cwd, path) + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, code) + } + + it('maps each route-private file to only its own leaf route id', () => { + // route ids assigned by sorted leaf-route order: a/page=0, b/page=1 + writeFixture('src/app/a/page.tsx', "import './a-only'\n") + writeFixture('src/app/a/a-only.tsx', 'export const A = 1\n') + writeFixture('src/app/b/page.tsx', "import './b-only'\n") + writeFixture('src/app/b/b-only.tsx', 'export const B = 1\n') + + const routes = computeFileRoutes({ cwd, srcDir }) + + expect(routes['src/app/a/page.tsx']).toEqual([0]) + expect(routes['src/app/a/a-only.tsx']).toEqual([0]) + expect(routes['src/app/b/page.tsx']).toEqual([1]) + expect(routes['src/app/b/b-only.tsx']).toEqual([1]) + }) + + it('assigns a shared layout/component to every leaf route it wraps', () => { + writeFixture('src/app/layout.tsx', "import './shared'\n") + writeFixture('src/app/shared.tsx', 'export const Shared = 1\n') + writeFixture('src/app/a/page.tsx', 'export const A = 1\n') + writeFixture('src/app/b/page.tsx', 'export const B = 1\n') + + const routes = computeFileRoutes({ cwd, srcDir }) + + // layout + its import wrap BOTH leaf routes (0 and 1) -> hoist candidates + expect(routes['src/app/layout.tsx']).toEqual([0, 1]) + expect(routes['src/app/shared.tsx']).toEqual([0, 1]) + // leaf-private files stay single-route + expect(routes['src/app/a/page.tsx']).toEqual([0]) + expect(routes['src/app/b/page.tsx']).toEqual([1]) + }) + + it('unions route ids for a component imported by multiple routes', () => { + writeFixture('src/app/a/page.tsx', "import '../../shared/card'\n") + writeFixture('src/app/b/page.tsx', "import '../../shared/card'\n") + writeFixture('src/app/c/page.tsx', 'export const C = 1\n') + writeFixture('src/shared/card.tsx', 'export const Card = 1\n') + + const routes = computeFileRoutes({ cwd, srcDir }) + + // card is used by a/page (0) and b/page (1), not c/page (2) + expect(routes['src/shared/card.tsx']).toEqual([0, 1]) + expect(routes['src/app/c/page.tsx']).toEqual([2]) + }) + + it('omits files reachable from no leaf route', () => { + writeFixture('src/app/a/page.tsx', 'export const A = 1\n') + writeFixture('src/orphan.tsx', 'export const Orphan = 1\n') + + const routes = computeFileRoutes({ cwd, srcDir }) + + expect(routes['src/orphan.tsx']).toBeUndefined() + expect(routes['src/app/a/page.tsx']).toEqual([0]) + }) +}) + +describe('computeFileReach', () => { + let tempRoot: string + let cwd: string + let srcDir: string + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'devup-ui-file-reach-')) + cwd = join(tempRoot, 'project') + srcDir = join(cwd, 'src') + mkdirSync(srcDir, { recursive: true }) + }) + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }) + }) + + function writeFixture(path: string, code: string): void { + const filePath = join(cwd, path) + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, code) + } + + it('treats 0-importer files as entries and shares a common dep across them', () => { + // entries (no importer): main-a, main-b (sorted -> a=0, b=1). shared imported by both. + writeFixture('src/main-a.tsx', "import './shared'\n") + writeFixture('src/main-b.tsx', "import './shared'\n") + writeFixture('src/shared.tsx', 'export const S = 1\n') + + const reach = computeFileReach({ cwd, srcDir }) + + expect(reach['src/main-a.tsx']).toEqual([0]) + expect(reach['src/main-b.tsx']).toEqual([1]) + // shared dep reached by BOTH entries -> hoist candidate + expect(reach['src/shared.tsx']).toEqual([0, 1]) + }) + + it('gives single-entry SPA reach 1 for everything (nothing hoists)', () => { + writeFixture('src/index.tsx', "import './a'\n") + writeFixture('src/a.tsx', "import './b'\n") + writeFixture('src/b.tsx', 'export const B = 1\n') + + const reach = computeFileReach({ cwd, srcDir }) + + expect(reach['src/index.tsx']).toEqual([0]) + expect(reach['src/a.tsx']).toEqual([0]) + expect(reach['src/b.tsx']).toEqual([0]) + }) + + it('treats dynamic-import targets as their own entries', () => { + writeFixture( + 'src/index.tsx', + "export const load = () => import('./lazy')\n", + ) + writeFixture('src/lazy.tsx', 'export const L = 1\n') + + const reach = computeFileReach({ cwd, srcDir }) + + // index (0-importer) and lazy (dynamic target) are both entries + expect(Object.keys(reach)).toContain('src/index.tsx') + expect(Object.keys(reach)).toContain('src/lazy.tsx') + }) + + it('honors an explicit entries override', () => { + writeFixture('src/index.tsx', "import './a'\nimport './b'\n") + writeFixture('src/a.tsx', 'export const A = 1\n') + writeFixture('src/b.tsx', 'export const B = 1\n') + + // override: pretend a and b are the real entries (e.g. MPA inputs) + const reach = computeFileReach({ + cwd, + srcDir, + entries: ['src/a.tsx', 'src/b.tsx'], + }) + + expect(reach['src/a.tsx']).toEqual([0]) + expect(reach['src/b.tsx']).toEqual([1]) + // index is not an entry now and nobody it imports... it's not in any entry closure + expect(reach['src/index.tsx']).toBeUndefined() + }) +}) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts new file mode 100644 index 00000000..b9821478 --- /dev/null +++ b/packages/plugin-utils/src/import-graph.ts @@ -0,0 +1,879 @@ +import { + existsSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { + dirname, + extname, + isAbsolute, + join, + relative, + resolve, +} from 'node:path' + +/** + * How map keys (and bucket-root values) are stringified. + * - `cwd-relative` (default): POSIX path relative to `cwd` — matches plugins + * that pass a cwd-relative filename to `codeExtract` (e.g. next-plugin). + * - `absolute`: POSIX absolute path — matches plugins that pass the absolute + * module id to `codeExtract` (e.g. vite-plugin). Using the wrong mode makes + * the engine's bucket lookup miss, silently disabling collapse/hoisting. + */ +export type GraphKeyMode = 'cwd-relative' | 'absolute' + +function makeToKey(cwd: string, keyBy: GraphKeyMode): (file: string) => string { + return keyBy === 'absolute' + ? (file: string) => file.replaceAll('\\', '/') + : (file: string) => toPosixRelative(cwd, file) +} + +export interface BuildCanonicalMapOptions { + srcDir: string + tsconfigPath?: string + cwd: string + hoistV?: number + keyBy?: GraphKeyMode +} + +interface ImportReference { + kind: 'static' | 'dynamic' + specifier: string +} + +interface OxcParser { + parseSync: ( + filename: string, + source: string, + options?: Record, + ) => unknown +} + +interface PathAlias { + prefix: string + suffix: string + targets: string[] +} + +interface ResolveContext { + aliases: PathAlias[] + aliasBaseDir: string + files: Set + srcDir: string +} + +const jsExtensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs'] +const jsFileRegex = /\.(?:tsx?|jsx?|mjs)$/ +const testFileRegex = /\.(?:test|spec)\.[mc]?[jt]sx?$/ +const routeFileRegex = + /(^|\/)(page|layout|template|default|loading|error|not-found|global-error)\.(tsx|ts|jsx|js)$/ +const leafRouteFileRegex = /(^|\/)page\.(tsx|ts|jsx|js)$/ + +let cachedOxcParser: false | OxcParser | undefined + +export function buildCanonicalMap( + opts: BuildCanonicalMapOptions, +): Record { + const cwd = resolve(opts.cwd) + const srcDir = resolve(opts.srcDir) + const files = listSourceFiles(srcDir) + const fileSet = new Set(files) + const aliases = readPathAliases(opts.tsconfigPath) + const context: ResolveContext = { + aliases: aliases.aliases, + aliasBaseDir: aliases.baseDir, + files: fileSet, + srcDir, + } + const staticImporters = new Map>() + const staticImports = new Map>() + const dynamicTargets = new Set() + + for (const file of files) { + staticImporters.set(file, new Set()) + staticImports.set(file, new Set()) + } + + for (const file of files) { + const imports = parseImports(file, readFileSync(file, 'utf-8')) + for (const importRef of imports) { + const target = resolveImport(importRef.specifier, file, context) + if (!target) continue + if (importRef.kind === 'dynamic') { + dynamicTargets.add(target) + continue + } + staticImporters.get(target)?.add(file) + staticImports.get(file)?.add(target) + } + } + + const globalFiles = getRouteReachableGlobalFiles( + files, + srcDir, + staticImports, + opts.hoistV, + ) + + const roots = new Set() + for (const file of files) { + const relPath = toPosixRelative(srcDir, file) + const importerCount = staticImporters.get(file)?.size ?? 0 + if ( + routeFileRegex.test(relPath) || + importerCount !== 1 || + dynamicTargets.has(file) + ) { + roots.add(file) + } + } + + for (const cycleRoot of findClosedCycles(files, roots, staticImporters)) { + roots.add(cycleRoot) + } + + const parents = new Map() + for (const file of files) { + if (roots.has(file)) continue + const importers = staticImporters.get(file) + if (importers?.size !== 1) continue + const [importer] = importers + parents.set(file, importer) + } + + const toKey = makeToKey(cwd, opts.keyBy ?? 'cwd-relative') + const map: Record = {} + for (const file of files) { + if (globalFiles.has(file)) { + map[toKey(file)] = '@global' + continue + } + if (roots.has(file)) continue + const bucketRoot = findBucketRoot(file, parents, roots) + if (bucketRoot === file) continue + map[toKey(file)] = toKey(bucketRoot) + } + + return map +} + +export interface ComputeFileRoutesOptions { + srcDir: string + tsconfigPath?: string + cwd: string +} + +/** + * Map every source file to the set of leaf-route ids whose render closure + * includes it. This is the input the atom-level hoisting engine needs + * (`importFileRoutes`): an atom used by `>= threshold` distinct routes is + * hoisted into the shared `devup-ui.css`, the rest stay in per-route chunks. + * + * Keys are POSIX paths relative to `cwd` (the same convention as + * `buildCanonicalMap`, which matches the extraction filename the loader passes). + * Route ids are assigned by sorted leaf-route order, so they are stable across + * runs. A file reachable from no leaf route is omitted (it contributes no route + * count and therefore never hoists on its own). + */ +export function computeFileRoutes( + opts: ComputeFileRoutesOptions, +): Record { + const cwd = resolve(opts.cwd) + const srcDir = resolve(opts.srcDir) + const files = listSourceFiles(srcDir) + const fileSet = new Set(files) + const aliases = readPathAliases(opts.tsconfigPath) + const context: ResolveContext = { + aliases: aliases.aliases, + aliasBaseDir: aliases.baseDir, + files: fileSet, + srcDir, + } + + const staticImports = new Map>() + for (const file of files) staticImports.set(file, new Set()) + for (const file of files) { + for (const importRef of parseImports(file, readFileSync(file, 'utf-8'))) { + if (importRef.kind !== 'static') continue + const target = resolveImport(importRef.specifier, file, context) + if (target) staticImports.get(file)?.add(target) + } + } + + const leafRoutes = files + .filter((file) => leafRouteFileRegex.test(toPosixRelative(srcDir, file))) + .sort((a, b) => + toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + ) + const routeShellFilesByDir = getRouteShellFilesByDir(files, srcDir) + + const fileRoutes: Record = {} + leafRoutes.forEach((leafRoute, routeId) => { + const closure = getLeafRouteClosure( + leafRoute, + srcDir, + staticImports, + routeShellFilesByDir, + ) + for (const file of closure) { + const key = toPosixRelative(cwd, file) + ;(fileRoutes[key] ??= []).push(routeId) + } + }) + + return fileRoutes +} + +export interface ComputeFileReachOptions { + srcDir: string + tsconfigPath?: string + cwd: string + /** + * Optional explicit entry files (absolute or `cwd`-relative). When provided, + * these override the default heuristic. Use this when the bundler knows its + * real entry points (e.g. `rollupOptions.input`); otherwise the heuristic + * (files with no importer within `srcDir`, plus dynamic-import targets) is + * used as a fallback. + */ + entries?: string[] + keyBy?: GraphKeyMode +} + +/** + * Bundler-agnostic generalization of `computeFileRoutes`: map every source file + * to the set of ENTRY ids whose static import closure includes it. + * + * "Entries" are the independently-loaded boundaries: files with no importer + * within `srcDir` plus dynamic-import targets, OR an explicit `entries` + * override. This is the importer-graph signal that replaces Next's route + * concept, so atom hoisting works for any bundler. + * + * Keys are POSIX paths relative to `cwd` (matching the extraction filename and + * `buildCanonicalMap` keys). Entry ids are assigned by sorted entry order + * (stable). A file reached by no entry is omitted. A single-entry app yields + * reach 1 for everything, so nothing hoists — correct, since one bucket is + * already optimal there. + */ +export function computeFileReach( + opts: ComputeFileReachOptions, +): Record { + const cwd = resolve(opts.cwd) + const srcDir = resolve(opts.srcDir) + const files = listSourceFiles(srcDir) + const fileSet = new Set(files) + const aliases = readPathAliases(opts.tsconfigPath) + const context: ResolveContext = { + aliases: aliases.aliases, + aliasBaseDir: aliases.baseDir, + files: fileSet, + srcDir, + } + + const staticImporters = new Map>() + const staticImports = new Map>() + for (const file of files) { + staticImporters.set(file, new Set()) + staticImports.set(file, new Set()) + } + const dynamicTargets = new Set() + for (const file of files) { + for (const importRef of parseImports(file, readFileSync(file, 'utf-8'))) { + const target = resolveImport(importRef.specifier, file, context) + if (!target) continue + if (importRef.kind === 'dynamic') { + dynamicTargets.add(target) + continue + } + staticImporters.get(target)?.add(file) + staticImports.get(file)?.add(target) + } + } + + let entries: string[] + if (opts.entries && opts.entries.length > 0) { + entries = opts.entries + .map((entry) => resolve(cwd, entry)) + .filter((entry) => fileSet.has(entry)) + } else { + entries = files.filter( + (file) => + (staticImporters.get(file)?.size ?? 0) === 0 || + dynamicTargets.has(file), + ) + } + entries = [...new Set(entries)].sort((a, b) => + toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + ) + + const toKey = makeToKey(cwd, opts.keyBy ?? 'cwd-relative') + const fileReach: Record = {} + entries.forEach((entry, entryId) => { + for (const file of getStaticClosure(entry, staticImports)) { + const key = toKey(file) + ;(fileReach[key] ??= []).push(entryId) + } + }) + + return fileReach +} + +function getRouteReachableGlobalFiles( + files: string[], + srcDir: string, + staticImports: Map>, + hoistV: number | undefined, +): Set { + if (hoistV === undefined || hoistV <= 0) return new Set() + + const leafRoutes = files.filter((file) => + leafRouteFileRegex.test(toPosixRelative(srcDir, file)), + ) + const routeShellFilesByDir = getRouteShellFilesByDir(files, srcDir) + const threshold = leafRoutes.length / hoistV + const reachedBy = new Map() + + for (const leafRoute of leafRoutes) { + const closure = getLeafRouteClosure( + leafRoute, + srcDir, + staticImports, + routeShellFilesByDir, + ) + for (const file of closure) { + reachedBy.set(file, (reachedBy.get(file) ?? 0) + 1) + } + } + + const globalFiles = new Set() + for (const [file, routeCount] of reachedBy) { + if (routeCount >= threshold && routeCount >= 2) { + globalFiles.add(file) + } + } + + return globalFiles +} + +function getRouteShellFilesByDir( + files: string[], + srcDir: string, +): Map { + const routeShellFilesByDir = new Map() + + for (const file of files) { + const relPath = toPosixRelative(srcDir, file) + if (!routeFileRegex.test(relPath) || leafRouteFileRegex.test(relPath)) { + continue + } + + const dir = dirname(file) + const routeShellFiles = routeShellFilesByDir.get(dir) ?? [] + routeShellFiles.push(file) + routeShellFilesByDir.set(dir, routeShellFiles) + } + + return routeShellFilesByDir +} + +function getLeafRouteClosure( + leafRoute: string, + srcDir: string, + staticImports: Map>, + routeShellFilesByDir: Map, +): Set { + const closure = getStaticClosure(leafRoute, staticImports) + + for (const routeShellFile of getAncestorRouteShellFiles( + leafRoute, + srcDir, + routeShellFilesByDir, + )) { + for (const file of getStaticClosure(routeShellFile, staticImports)) { + closure.add(file) + } + } + + return closure +} + +function getAncestorRouteShellFiles( + leafRoute: string, + srcDir: string, + routeShellFilesByDir: Map, +): string[] { + const routeShellFiles: string[] = [] + let currentDir = dirname(leafRoute) + + while (isInsideDir(srcDir, currentDir)) { + const currentRouteShellFiles = routeShellFilesByDir.get(currentDir) + if (currentRouteShellFiles) routeShellFiles.push(...currentRouteShellFiles) + if (currentDir === srcDir) break + const parentDir = dirname(currentDir) + if (parentDir === currentDir) break + currentDir = parentDir + } + + return routeShellFiles +} + +function getStaticClosure( + routeEntry: string, + staticImports: Map>, +): Set { + const closure = new Set() + const queue = [routeEntry] + + for (let index = 0; index < queue.length; index += 1) { + const file = queue[index] + if (closure.has(file)) continue + closure.add(file) + + const importedFiles = staticImports.get(file) + if (!importedFiles) continue + for (const importedFile of importedFiles) { + if (!closure.has(importedFile)) queue.push(importedFile) + } + } + + return closure +} + +function listSourceFiles(srcDir: string): string[] { + const files: string[] = [] + + function visit(dir: string): void { + if (!existsSync(dir)) return + const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + ) + for (const entry of entries) { + const entryPath = join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === 'node_modules') continue + visit(entryPath) + continue + } + if (!entry.isFile()) continue + if (!jsFileRegex.test(entry.name)) continue + if (testFileRegex.test(entry.name)) continue + files.push(resolve(entryPath)) + } + } + + visit(srcDir) + return files.sort((a, b) => + toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + ) +} + +function parseImports(filename: string, source: string): ImportReference[] { + const astImports = parseImportsWithOxc(filename, source) + if (astImports) return astImports + return scanImports(source) +} + +function parseImportsWithOxc( + filename: string, + source: string, +): ImportReference[] | undefined { + const parser = getOxcParser() + if (!parser) return undefined + + try { + const ast = parser.parseSync(filename, source, { sourceType: 'module' }) + const imports: ImportReference[] = [] + collectAstImports(ast, imports) + return imports + } catch { + return undefined + } +} + +function getOxcParser(): OxcParser | undefined { + if (cachedOxcParser !== undefined) { + return cachedOxcParser || undefined + } + + try { + const require = createRequire(import.meta.url) + const parser = require('oxc-parser') as Partial + cachedOxcParser = + typeof parser.parseSync === 'function' ? (parser as OxcParser) : false + } catch { + cachedOxcParser = false + } + + return cachedOxcParser || undefined +} + +function collectAstImports( + node: unknown, + imports: ImportReference[], + seen = new WeakSet(), +): void { + if (!isRecord(node)) return + if (seen.has(node)) return + seen.add(node) + + const type = typeof node.type === 'string' ? node.type : undefined + if ( + type === 'ImportDeclaration' || + type === 'ExportNamedDeclaration' || + type === 'ExportAllDeclaration' + ) { + addAstImport(imports, 'static', node.source) + } else if (type === 'ImportExpression') { + addAstImport(imports, 'dynamic', node.source ?? node.argument) + } else if (type === 'CallExpression' && isImportCallee(node.callee)) { + const firstArgument = Array.isArray(node.arguments) + ? node.arguments[0] + : undefined + addAstImport(imports, 'dynamic', firstArgument) + } + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const child of value) { + collectAstImports(child, imports, seen) + } + continue + } + collectAstImports(value, imports, seen) + } +} + +function addAstImport( + imports: ImportReference[], + kind: ImportReference['kind'], + node: unknown, +): void { + const specifier = getStringLiteralValue(node) + if (specifier) imports.push({ kind, specifier }) +} + +function getStringLiteralValue(node: unknown): string | undefined { + if (!isRecord(node)) return undefined + if (typeof node.value === 'string') return node.value + if (typeof node.raw === 'string') return node.raw.slice(1, -1) + return undefined +} + +function isImportCallee(node: unknown): boolean { + if (!isRecord(node)) return false + return node.type === 'Import' || node.name === 'import' +} + +function scanImports(source: string): ImportReference[] { + const imports: ImportReference[] = [] + const code = stripComments(source) + const staticImportRegex = + /\bimport\s+(?:type\s+)?(?:[^'"`]*?\s+from\s*)?(['"])([^'"]+)\1/gm + const exportFromRegex = + /\bexport\s+(?:type\s+)?(?:\*[^'"`]*?|\{[^}]*\})\s+from\s*(['"])([^'"]+)\1/gm + const dynamicImportRegex = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/gm + + for (const match of code.matchAll(staticImportRegex)) { + imports.push({ kind: 'static', specifier: match[2] }) + } + for (const match of code.matchAll(exportFromRegex)) { + imports.push({ kind: 'static', specifier: match[2] }) + } + for (const match of code.matchAll(dynamicImportRegex)) { + imports.push({ kind: 'dynamic', specifier: match[2] }) + } + + return imports +} + +function stripComments(source: string): string { + let result = '' + let index = 0 + let quote: false | '"' | "'" | '`' = false + + while (index < source.length) { + const char = source[index] + const next = source[index + 1] + + if (quote) { + result += char + if (char === '\\') { + result += next ?? '' + index += 2 + continue + } + if (char === quote) quote = false + index += 1 + continue + } + + if (char === '"' || char === "'" || char === '`') { + quote = char + result += char + index += 1 + continue + } + + if (char === '/' && next === '/') { + while (index < source.length && source[index] !== '\n') { + result += ' ' + index += 1 + } + continue + } + + if (char === '/' && next === '*') { + result += ' ' + index += 2 + while ( + index < source.length && + !(source[index] === '*' && source[index + 1] === '/') + ) { + result += source[index] === '\n' ? '\n' : ' ' + index += 1 + } + result += ' ' + index += 2 + continue + } + + result += char + index += 1 + } + + return result +} + +function readPathAliases(tsconfigPath: string | undefined): { + aliases: PathAlias[] + baseDir: string +} { + if (!tsconfigPath || !existsSync(tsconfigPath)) { + return { aliases: [], baseDir: process.cwd() } + } + + const configPath = resolve(tsconfigPath) + const configDir = dirname(configPath) + try { + const config = JSON.parse( + stripTrailingCommas(stripComments(readFileSync(configPath, 'utf-8'))), + ) + if (!isRecord(config) || !isRecord(config.compilerOptions)) { + return { aliases: [], baseDir: configDir } + } + + const baseUrl = + typeof config.compilerOptions.baseUrl === 'string' + ? config.compilerOptions.baseUrl + : '.' + const paths = config.compilerOptions.paths + if (!isRecord(paths)) { + return { aliases: [], baseDir: resolve(configDir, baseUrl) } + } + + const aliases: PathAlias[] = [] + for (const [alias, targetList] of Object.entries(paths)) { + if (!Array.isArray(targetList)) continue + const starIndex = alias.indexOf('*') + aliases.push({ + prefix: starIndex === -1 ? alias : alias.slice(0, starIndex), + suffix: starIndex === -1 ? '' : alias.slice(starIndex + 1), + targets: targetList.filter( + (target): target is string => typeof target === 'string', + ), + }) + } + + aliases.sort((a, b) => b.prefix.length - a.prefix.length) + return { aliases, baseDir: resolve(configDir, baseUrl) } + } catch { + return { aliases: [], baseDir: configDir } + } +} + +function stripTrailingCommas(json: string): string { + return json.replace(/,\s*([}\]])/g, '$1') +} + +function resolveImport( + specifier: string, + importer: string, + context: ResolveContext, +): string | undefined { + const candidateBases: string[] = [] + + if (specifier.startsWith('.')) { + candidateBases.push(resolve(dirname(importer), specifier)) + } else if (specifier.startsWith('/')) { + candidateBases.push(resolve(specifier)) + } else { + candidateBases.push(...resolveAliasCandidates(specifier, context)) + } + + for (const candidateBase of candidateBases) { + const resolvedFile = resolveFile(candidateBase) + if (!resolvedFile) continue + if (!context.files.has(resolvedFile)) continue + if (!isInsideDir(context.srcDir, resolvedFile)) continue + return resolvedFile + } + + return undefined +} + +function resolveAliasCandidates( + specifier: string, + context: ResolveContext, +): string[] { + const candidates: string[] = [] + for (const alias of context.aliases) { + if ( + !specifier.startsWith(alias.prefix) || + !specifier.endsWith(alias.suffix) + ) { + continue + } + const matched = specifier.slice( + alias.prefix.length, + specifier.length - alias.suffix.length, + ) + for (const target of alias.targets) { + candidates.push( + resolve(context.aliasBaseDir, target.replace('*', matched)), + ) + } + } + return candidates +} + +function resolveFile(candidateBase: string): string | undefined { + const ext = extname(candidateBase) + if (ext) { + if (!jsExtensions.includes(ext)) return undefined + return isFile(candidateBase) ? resolve(candidateBase) : undefined + } + + for (const jsExtension of jsExtensions) { + const candidate = `${candidateBase}${jsExtension}` + if (isFile(candidate)) return resolve(candidate) + } + for (const jsExtension of jsExtensions) { + const candidate = join(candidateBase, `index${jsExtension}`) + if (isFile(candidate)) return resolve(candidate) + } + + return undefined +} + +function isFile(path: string): boolean { + try { + return statSync(path).isFile() + } catch { + return false + } +} + +function isInsideDir(dir: string, file: string): boolean { + const relPath = relative(dir, file) + return relPath === '' || (!relPath.startsWith('..') && !isAbsolute(relPath)) +} + +function findClosedCycles( + files: string[], + roots: Set, + staticImporters: Map>, +): Set { + const parents = new Map() + for (const file of files) { + if (roots.has(file)) continue + const importers = staticImporters.get(file) + if (importers?.size !== 1) continue + const [importer] = importers + parents.set(file, importer) + } + + const cycleRoots = new Set() + const visiting = new Set() + const visited = new Set() + const stack: string[] = [] + + function visit(file: string): void { + if (visited.has(file) || roots.has(file)) return + if (visiting.has(file)) { + const cycleStart = stack.indexOf(file) + for (const cycleFile of stack.slice(cycleStart)) { + cycleRoots.add(cycleFile) + } + return + } + + visiting.add(file) + stack.push(file) + const parent = parents.get(file) + if (parent && parents.has(parent)) visit(parent) + stack.pop() + visiting.delete(file) + visited.add(file) + } + + for (const file of files) { + visit(file) + } + + return cycleRoots +} + +function findBucketRoot( + file: string, + parents: Map, + roots: Set, +): string { + let current = file + const seen = new Set() + + while (!roots.has(current)) { + if (seen.has(current)) return file + seen.add(current) + const parent = parents.get(current) + if (!parent) return current + current = parent + } + + return current +} + +function toPosixRelative(from: string, to: string): string { + return relative(from, to).replaceAll('\\', '/') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +if (import.meta.main) { + const [srcDirArg, cwdArg = process.cwd(), tsconfigPathArg, outFileArg] = + process.argv.slice(2) + + if (!srcDirArg) { + console.error( + 'Usage: bun packages/next-plugin/src/import-graph.ts [cwd] [tsconfigPath] [outFile]', + ) + process.exit(1) + } + + const cwd = resolve(cwdArg) + const srcDir = resolve(cwd, srcDirArg) + const tsconfigPath = tsconfigPathArg + ? resolve(cwd, tsconfigPathArg) + : undefined + const map = buildCanonicalMap({ cwd, srcDir, tsconfigPath }) + const json = `${JSON.stringify(map, null, 2)}\n` + + if (outFileArg) { + writeFileSync(resolve(cwd, outFileArg), json) + } else { + console.info(json.trimEnd()) + } +} diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 03a24b63..863761aa 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -1,3 +1,11 @@ +export { + buildCanonicalMap, + type BuildCanonicalMapOptions, + computeFileReach, + type ComputeFileReachOptions, + computeFileRoutes, + type ComputeFileRoutesOptions, +} from './import-graph' export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' export { createNodeModulesExcludeRegex, diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 51b2782b..85543db1 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -2,9 +2,11 @@ import * as fs from 'node:fs' import * as fsPromises from 'node:fs/promises' import { join, resolve } from 'node:path' +import * as pluginUtils from '@devup-ui/plugin-utils' import * as wasm from '@devup-ui/wasm' import { afterAll, + afterEach, beforeAll, describe, expect, @@ -454,4 +456,138 @@ const App = () => `, ) expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') }) + + describe('atomHoist pre-pass', () => { + let buildCanonicalMapSpy: ReturnType + let computeFileReachSpy: ReturnType + let importCanonicalMapSpy: ReturnType + let importFileRoutesSpy: ReturnType + let setAtomHoistSpy: ReturnType + let getCssSpy: ReturnType + + function spies() { + buildCanonicalMapSpy = spyOn( + pluginUtils, + 'buildCanonicalMap', + ).mockReturnValue({}) + computeFileReachSpy = spyOn( + pluginUtils, + 'computeFileReach', + ).mockReturnValue({}) + importCanonicalMapSpy = spyOn(wasm, 'importCanonicalMap').mockReturnValue( + undefined, + ) + importFileRoutesSpy = spyOn(wasm, 'importFileRoutes').mockReturnValue( + undefined, + ) + setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue(undefined) + getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('CSS') + } + afterEach(() => { + buildCanonicalMapSpy?.mockRestore() + computeFileReachSpy?.mockRestore() + importCanonicalMapSpy?.mockRestore() + importFileRoutesSpy?.mockRestore() + setAtomHoistSpy?.mockRestore() + getCssSpy?.mockRestore() + }) + + it('does nothing when atomHoist is unset', async () => { + spies() + await DevupUI().setup( + createSetupContext({ transform: mock(), modifyRsbuildConfig: mock() }), + ) + expect(buildCanonicalMapSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + }) + + it('composes collapse + hoist and folds reach onto the canonical bucket', async () => { + spies() + buildCanonicalMapSpy.mockReturnValue({ + '/p/src/child.tsx': '/p/src/parent.tsx', + '/p/src/glob.tsx': '@global', + }) + computeFileReachSpy.mockReturnValue({ + '/p/src/parent.tsx': [0, 1], + '/p/src/child.tsx': [0], + '/p/src/glob.tsx': [0, 1], + '/p/src/r1.tsx': [1], + }) + await DevupUI({ atomHoist: 2 }).setup( + createSetupContext({ transform: mock(), modifyRsbuildConfig: mock() }), + ) + // rsbuild passes absolute resourcePath -> keyBy absolute + expect(buildCanonicalMapSpy).toHaveBeenCalledWith( + expect.objectContaining({ keyBy: 'absolute' }), + ) + expect(importCanonicalMapSpy).toHaveBeenCalled() + expect(importFileRoutesSpy).toHaveBeenCalledWith({ + '/p/src/parent.tsx': [0, 1], + '/p/src/r1.tsx': [1], + }) + expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + }) + + it('clamps the threshold to a minimum of 2', async () => { + spies() + computeFileReachSpy.mockReturnValue({ + '/p/src/a.tsx': [0], + '/p/src/b.tsx': [1], + }) + await DevupUI({ atomHoist: 1 }).setup( + createSetupContext({ transform: mock(), modifyRsbuildConfig: mock() }), + ) + expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + }) + + it('stays off when fewer than two routes are reachable', async () => { + spies() + computeFileReachSpy.mockReturnValue({ '/p/src/a.tsx': [0] }) + await DevupUI({ atomHoist: 2 }).setup( + createSetupContext({ transform: mock(), modifyRsbuildConfig: mock() }), + ) + expect(setAtomHoistSpy).not.toHaveBeenCalled() + }) + + it('swallows pre-pass errors (atom hoisting stays off)', async () => { + spies() + buildCanonicalMapSpy.mockImplementation(() => { + throw new Error('boom') + }) + await DevupUI({ atomHoist: 2 }).setup( + createSetupContext({ transform: mock(), modifyRsbuildConfig: mock() }), + ) + expect(setAtomHoistSpy).not.toHaveBeenCalled() + }) + + it('serves per-route getCss(fileNum) for css imports in atom mode', async () => { + spies() + computeFileReachSpy.mockReturnValue({ + '/p/src/a.tsx': [0], + '/p/src/b.tsx': [1], + }) + getCssSpy.mockImplementation( + (fileNum: number | null) => `CSS_FOR_${String(fileNum)}`, + ) + const transform = mock() + await DevupUI({ atomHoist: 2 }).setup( + createSetupContext({ transform, modifyRsbuildConfig: mock() }), + ) + // calls[0] is the cssDir transform; route chunk + base served as separate + // modules (the entry code imports both), so each is getCss(fileNum, false). + const servedChunk = transform.mock.calls[0][1]({ + code: '', + resourcePath: resolve('df', 'devup-ui', 'devup-ui-3.css'), + }) + expect(servedChunk).toBe('CSS_FOR_3') + expect(getCssSpy).toHaveBeenCalledWith(3, false) + const servedBase = transform.mock.calls[0][1]({ + code: '', + resourcePath: resolve('df', 'devup-ui', 'devup-ui.css'), + }) + expect(servedBase).toBe('CSS_FOR_null') + expect(getCssSpy).toHaveBeenCalledWith(null, false) + }) + }) }) diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index f448cc1f..c7061bc6 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -1,10 +1,13 @@ import { existsSync } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' -import { basename, join, resolve } from 'node:path' +import { basename, dirname, join, relative, resolve } from 'node:path' import { + buildCanonicalMap, + computeFileReach, createNodeModulesExcludeRegex, createThemeInterfaceArgs, + getFileNumByFilename, type ImportAliases, loadDevupConfig, mergeImportAliases, @@ -14,7 +17,10 @@ import { getCss, getDefaultTheme, getThemeInterface, + importCanonicalMap, + importFileRoutes, registerTheme, + setAtomHoist, setDebug, setPrefix, } from '@devup-ui/wasm' @@ -30,6 +36,23 @@ export interface DevupUIRsbuildPluginOptions { include: string[] singleCss: boolean prefix?: string + /** + * Atom-level route-aware hoisting threshold (min routes sharing an atom for it + * to hoist into the shared devup-ui.css; clamped to >= 2; omit to disable). + * Opt-in: when set, single-importer collapse + atom hoisting are enabled and + * per-route CSS is served via getCss(fileNum). "Routes" are inferred from the + * import graph (entry points and dynamic-import targets). For a single-entry + * SPA (routeCount < 2) it is a no-op. + * + * KNOWN LIMITATION (rsbuild MPA): hoisted atoms get a single class name + * (correct), but rspack's default chunk strategy INLINES the shared base CSS + * into each entry bundle, so the base is duplicated across entries rather than + * emitted as one shared chunk. Rendering is correct; the cross-entry dedup + * benefit is not yet realized. Deduping requires an rspack `splitChunks` + * cacheGroup (type `css/mini-extract`, `minChunks >= 2`) — tracked as a + * follow-up. See the cssDir transform TODO below. + */ + atomHoist?: number /** * Import aliases for redirecting imports from other CSS-in-JS libraries * Merged with defaults: @emotion/styled, styled-components, @vanilla-extract/css @@ -86,6 +109,7 @@ export const DevupUI = ({ debug = false, singleCss = false, prefix, + atomHoist, importAliases: userImportAliases, }: Partial = {}): RsbuildPlugin => { const importAliases = mergeImportAliases(userImportAliases) @@ -110,11 +134,68 @@ export const DevupUI = ({ }) if (!extractCss) return + // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any + // transform so atoms receive global (shared) class names. Composes with + // single-importer collapse (both keyed by the canonical bucket). rsbuild + // passes the ABSOLUTE resourcePath to codeExtract, so the graph maps use + // absolute keys (keyBy: 'absolute') and the extraction filename is + // POSIX-normalized to match. + const atomMode = + atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 + if (atomMode) { + try { + const root = process.cwd() + const srcDir = resolve(root, 'src') + const tsconfigPath = resolve(root, 'tsconfig.json') + const canonicalMap = buildCanonicalMap({ + srcDir, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + }) + importCanonicalMap(canonicalMap) + const fileReach = computeFileReach({ + srcDir, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + }) + const reachByBucket: Record = {} + for (const [file, ids] of Object.entries(fileReach)) { + const bucket = canonicalMap[file] ?? file + if (bucket === '@global') continue + const set = (reachByBucket[bucket] ??= []) + for (const id of ids) if (!set.includes(id)) set.push(id) + } + const routeCount = new Set(Object.values(fileReach).flat()).size + if (routeCount >= 2) { + importFileRoutes(reachByBucket) + setAtomHoist(Math.max(2, atomHoist)) + } + } catch { + // Best-effort; on failure atom hoisting stays off (identity). + } + } + api.transform( { test: cssDir, }, - () => globalCss, + ({ resourcePath }) => { + // Non-atom: keep the existing single-string behavior (no regression). + if (!atomMode) return globalCss + // Atom mode: serve the route-specific chunk and have it @import the + // shared base (devup-ui.css) so hoisted atoms load ONCE and are not + // inlined per chunk. The base file itself imports nothing. + // Route chunk and base are SEPARATE modules (the transformed entry + // code imports both via import_main_css). + // TODO(atom-B follow-up): rspack's default chunk strategy inlines the + // shared base CSS into each entry bundle (duplicated across MPA + // entries). Add an rspack `splitChunks` cacheGroup (type + // 'css/mini-extract', minChunks >= 2) to emit the base as one shared + // chunk and realize the cross-entry dedup benefit. + return getCss(getFileNumByFilename(basename(resourcePath)), false) + }, ) api.modifyRsbuildConfig((config) => { @@ -137,6 +218,17 @@ export const DevupUI = ({ async ({ code, resourcePath }) => { if (createNodeModulesExcludeRegex(include).test(resourcePath)) return code + // Atom mode mirrors vite: the entry CODE imports the shared base + // (import_main_css_in_code=true) so rspack emits devup-ui.css once and + // links it from every entry (hoisted atoms shared, not inlined). A + // relative cssDir is required for that code import to resolve, and the + // extraction filename is POSIX-normalized to match the absolute-keyed + // canonical map / FILE_ROUTES. Non-atom keeps the prior behavior. + let relCssDir = relative(dirname(resourcePath), cssDir).replaceAll( + '\\', + '/', + ) + if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` const { code: retCode, css = '', @@ -144,13 +236,13 @@ export const DevupUI = ({ cssFile, updatedBaseStyle, } = codeExtract( - resourcePath, + atomMode ? resourcePath.replaceAll('\\', '/') : resourcePath, code, libPackage, - cssDir, + atomMode ? relCssDir : cssDir, singleCss, - false, - true, + atomMode, + !atomMode, importAliases, ) const promises: Promise[] = [] diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 9f538ac3..9cde51bb 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs' import * as fsPromises from 'node:fs/promises' import * as nodePath from 'node:path' +import * as pluginUtils from '@devup-ui/plugin-utils' import * as wasm from '@devup-ui/wasm' import { afterEach, @@ -580,3 +581,147 @@ describe('devupUIVitePlugin', () => { expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') }) }) + +describe('devupUIVitePlugin atom hoisting', () => { + type ConfigResolved = (config: unknown) => Promise + const runConfigResolved = async ( + options: Parameters[0], + config: unknown, + ) => { + const plugin = DevupUI(options) as unknown as { + configResolved: ConfigResolved + } + await plugin.configResolved(config) + } + + let buildCanonicalMapSpy: ReturnType + let computeFileReachSpy: ReturnType + let importCanonicalMapSpy: ReturnType + let importFileRoutesSpy: ReturnType + let setAtomHoistSpy: ReturnType + + beforeEach(() => { + buildCanonicalMapSpy = spyOn( + pluginUtils, + 'buildCanonicalMap', + ).mockReturnValue({}) + computeFileReachSpy = spyOn( + pluginUtils, + 'computeFileReach', + ).mockReturnValue({}) + importCanonicalMapSpy = spyOn(wasm, 'importCanonicalMap').mockReturnValue( + undefined, + ) + importFileRoutesSpy = spyOn(wasm, 'importFileRoutes').mockReturnValue( + undefined, + ) + setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue(undefined) + }) + + afterEach(() => { + buildCanonicalMapSpy.mockRestore() + computeFileReachSpy.mockRestore() + importCanonicalMapSpy.mockRestore() + importFileRoutesSpy.mockRestore() + setAtomHoistSpy.mockRestore() + }) + + it('does nothing when atomHoist is unset', async () => { + await runConfigResolved({}, { root: '/p' }) + expect(buildCanonicalMapSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() + }) + + it('composes collapse + hoist and folds reach onto the canonical bucket', async () => { + buildCanonicalMapSpy.mockReturnValue({ + '/p/src/child.tsx': '/p/src/parent.tsx', + '/p/src/glob.tsx': '@global', + }) + computeFileReachSpy.mockReturnValue({ + '/p/src/parent.tsx': [0, 1], + '/p/src/child.tsx': [0], + '/p/src/glob.tsx': [0, 1], + '/p/src/r1.tsx': [1], + }) + await runConfigResolved( + { atomHoist: 2 }, + { root: '/p', build: { rollupOptions: { input: { a: 'src/a.tsx' } } } }, + ) + // collapse runs (composition) with absolute keys + expect(buildCanonicalMapSpy).toHaveBeenCalledWith( + expect.objectContaining({ keyBy: 'absolute' }), + ) + expect(importCanonicalMapSpy).toHaveBeenCalled() + // reach folded by bucket: child -> parent, @global skipped + expect(importFileRoutesSpy).toHaveBeenCalledWith({ + '/p/src/parent.tsx': [0, 1], + '/p/src/r1.tsx': [1], + }) + expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + }) + + it('clamps the threshold to a minimum of 2', async () => { + computeFileReachSpy.mockReturnValue({ + '/p/src/a.tsx': [0], + '/p/src/b.tsx': [1], + }) + await runConfigResolved({ atomHoist: 1 }, { root: '/p' }) + expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + }) + + it('stays off when fewer than two routes are reachable', async () => { + computeFileReachSpy.mockReturnValue({ '/p/src/a.tsx': [0] }) + await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) + expect(setAtomHoistSpy).not.toHaveBeenCalled() + }) + + it('falls back to the heuristic when input has no JS entries', async () => { + computeFileReachSpy.mockReturnValue({ + '/p/src/a.tsx': [0], + '/p/src/b.tsx': [1], + }) + await runConfigResolved( + { atomHoist: 2 }, + { root: '/p', build: { rollupOptions: { input: 'index.html' } } }, + ) + // html-only input => entries override omitted => computeFileReach called + // without an explicit entries list + expect(computeFileReachSpy).toHaveBeenCalledWith( + expect.objectContaining({ entries: undefined }), + ) + expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + }) + + it('accepts array and string JS entries', async () => { + computeFileReachSpy.mockReturnValue({ + '/p/src/a.tsx': [0], + '/p/src/b.tsx': [1], + }) + await runConfigResolved( + { atomHoist: 2 }, + { root: '/p', build: { rollupOptions: { input: ['src/a.tsx'] } } }, + ) + await runConfigResolved( + { atomHoist: 2 }, + { root: '/p', build: { rollupOptions: { input: 'src/a.tsx' } } }, + ) + expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + }) + + it('swallows pre-pass errors (atom hoisting stays off)', async () => { + buildCanonicalMapSpy.mockImplementation(() => { + throw new Error('boom') + }) + await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) + expect(setAtomHoistSpy).not.toHaveBeenCalled() + }) + + it('uses process.cwd() when config.root is absent', async () => { + computeFileReachSpy.mockReturnValue({ + '/p/src/a.tsx': [0], + '/p/src/b.tsx': [1], + }) + await runConfigResolved({ atomHoist: 2 }, {}) + expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + }) +}) diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 5da1ce10..0da37bb4 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -3,6 +3,8 @@ import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { + buildCanonicalMap, + computeFileReach, createNodeModulesExcludeRegex, createThemeInterfaceArgs, getFileNumByFilename, @@ -15,7 +17,10 @@ import { getCss, getDefaultTheme, getThemeInterface, + importCanonicalMap, + importFileRoutes, registerTheme, + setAtomHoist, setDebug, setPrefix, } from '@devup-ui/wasm' @@ -31,6 +36,14 @@ export interface DevupUIPluginOptions { include: string[] singleCss: boolean prefix?: string + /** + * Atom-level route-aware hoisting threshold (min routes sharing an atom for + * it to hoist into the shared devup-ui.css; clamped to >= 2; omit to disable). + * Opt-in: when set, single-importer collapse + atom hoisting are enabled for + * this build. "Routes" are inferred from the import graph (entry points and + * dynamic-import targets). + */ + atomHoist?: number /** * Import aliases for redirecting imports from other CSS-in-JS libraries * Merged with defaults: @emotion/styled, styled-components, @vanilla-extract/css @@ -82,6 +95,7 @@ export function DevupUI({ include = [], singleCss = false, prefix, + atomHoist, importAliases: userImportAliases, }: Partial = {}): PluginOption { setDebug(debug) @@ -92,7 +106,7 @@ export function DevupUI({ const cssMap = new Map() return { name: 'devup-ui', - async configResolved() { + async configResolved(config) { if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') await writeDataFiles({ @@ -102,6 +116,66 @@ export function DevupUI({ distDir, singleCss, }) + + // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any + // transform so atoms receive global (shared) class names. Composes with + // single-importer collapse: both are keyed by the canonical bucket. Vite + // passes the ABSOLUTE module id to codeExtract, so the graph maps use + // absolute keys (keyBy: 'absolute') to match the engine's bucket keys. + const atomMode = + atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 + if (atomMode) { + try { + const root = config.root ?? process.cwd() + const srcDir = resolve(root, 'src') + const tsconfigPath = resolve(root, 'tsconfig.json') + // C: prefer the bundler's real JS entries; fall back to the heuristic + // (files with no importer) when input is html-only / unavailable. + const input = config.build?.rollupOptions?.input + const rawEntries = + typeof input === 'string' + ? [input] + : Array.isArray(input) + ? input + : input && typeof input === 'object' + ? Object.values(input) + : [] + const entries = rawEntries + .filter((e): e is string => typeof e === 'string') + .filter((e) => /\.(tsx|ts|jsx|js|mjs)$/i.test(e)) + .map((e) => resolve(root, e)) + + const canonicalMap = buildCanonicalMap({ + srcDir, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + }) + importCanonicalMap(canonicalMap) + + const fileReach = computeFileReach({ + srcDir, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + entries: entries.length > 0 ? entries : undefined, + }) + const reachByBucket: Record = {} + for (const [file, ids] of Object.entries(fileReach)) { + const bucket = canonicalMap[file] ?? file + if (bucket === '@global') continue + const set = (reachByBucket[bucket] ??= []) + for (const id of ids) if (!set.includes(id)) set.push(id) + } + const routeCount = new Set(Object.values(fileReach).flat()).size + if (routeCount >= 2) { + importFileRoutes(reachByBucket) + setAtomHoist(Math.max(2, atomHoist)) + } + } catch { + // Best-effort; on failure atom hoisting stays off (identity). + } + } }, config() { const theme = getDefaultTheme() diff --git a/packages/webpack-plugin/src/__tests__/loader.test.ts b/packages/webpack-plugin/src/__tests__/loader.test.ts index 0cfb947f..ccb1e32f 100644 --- a/packages/webpack-plugin/src/__tests__/loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/loader.test.ts @@ -1,4 +1,5 @@ import * as fsPromises from 'node:fs/promises' +import * as nodePath from 'node:path' import { join } from 'node:path' import * as wasm from '@devup-ui/wasm' @@ -379,4 +380,39 @@ describe('devupUILoader', () => { ) devupUILoader.bind(t)(Buffer.from('code'), 'index.tsx') }) + + it('posix-normalizes the extraction filename (Windows path safety)', () => { + // Simulate Windows: force path.relative to emit backslashes. The loader + // MUST normalize to forward slashes so the engine bucket key matches the + // canonical map / FILE_ROUTES (built posix by plugin-utils). + const relativeSpy = spyOn(nodePath, 'relative').mockImplementation( + (from: string, to: string) => + ( + nodePath as { posix: { relative: (a: string, b: string) => string } } + ).posix + .relative(from, to) + .replaceAll('/', '\\'), + ) + const asyncCallback = mock() + const t = createLoaderContext( + { + package: 'package', + cssDir: 'df/devup-ui', + sheetFile: 's', + classMapFile: 'c', + fileMapFile: 'f', + watch: false, + singleCss: true, + }, + asyncCallback, + 'src/Card.tsx', + ) + try { + devupUILoader.bind(t)(Buffer.from('code'), 'src/Card.tsx') + const filenameArg = codeExtractSpy.mock.calls[0]?.[0] as string + expect(filenameArg).not.toContain('\\') + } finally { + relativeSpy.mockRestore() + } + }) }) diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 80c59ff1..4663f64b 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -452,4 +452,117 @@ describe('devupUIWebpackPlugin', () => { plugin.apply(asCompiler(compiler)) expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') }) + + describe('atomHoist pre-pass', () => { + let buildCanonicalMapSpy: ReturnType + let computeFileReachSpy: ReturnType + let importCanonicalMapSpy: ReturnType + let importFileRoutesSpy: ReturnType + let setAtomHoistSpy: ReturnType + + beforeEach(() => { + buildCanonicalMapSpy = spyOn( + pluginUtils, + 'buildCanonicalMap', + ).mockReturnValue({}) + computeFileReachSpy = spyOn( + pluginUtils, + 'computeFileReach', + ).mockReturnValue({}) + importCanonicalMapSpy = spyOn(wasm, 'importCanonicalMap').mockReturnValue( + undefined, + ) + importFileRoutesSpy = spyOn(wasm, 'importFileRoutes').mockReturnValue( + undefined, + ) + setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue(undefined) + }) + + afterEach(() => { + buildCanonicalMapSpy.mockRestore() + computeFileReachSpy.mockRestore() + importCanonicalMapSpy.mockRestore() + importFileRoutesSpy.mockRestore() + setAtomHoistSpy.mockRestore() + }) + + it('does nothing when atomHoist is unset', () => { + const plugin = new DevupUIWebpackPlugin({}) + plugin.apply(asCompiler(createCompiler())) + expect(buildCanonicalMapSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + }) + + it('composes collapse + hoist and folds reach onto the canonical bucket', () => { + buildCanonicalMapSpy.mockReturnValue({ + 'src/child.tsx': 'src/parent.tsx', + 'src/glob.tsx': '@global', + }) + computeFileReachSpy.mockReturnValue({ + 'src/parent.tsx': [0, 1], + 'src/child.tsx': [0], + 'src/glob.tsx': [0, 1], + 'src/r1.tsx': [1], + }) + const plugin = new DevupUIWebpackPlugin({ atomHoist: 2 }) + plugin.apply(asCompiler(createCompiler())) + // collapse runs with cwd-relative keys (webpack loader passes relative path) + expect(buildCanonicalMapSpy).toHaveBeenCalledWith( + expect.objectContaining({ keyBy: 'cwd-relative' }), + ) + expect(importCanonicalMapSpy).toHaveBeenCalled() + // reach folded by bucket: child -> parent, @global skipped + expect(importFileRoutesSpy).toHaveBeenCalledWith({ + 'src/parent.tsx': [0, 1], + 'src/r1.tsx': [1], + }) + expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + }) + + it('clamps the threshold to a minimum of 2', () => { + computeFileReachSpy.mockReturnValue({ + 'src/a.tsx': [0], + 'src/b.tsx': [1], + }) + const plugin = new DevupUIWebpackPlugin({ atomHoist: 1 }) + plugin.apply(asCompiler(createCompiler())) + expect(setAtomHoistSpy).toHaveBeenCalledWith(2) + }) + + it('stays off when fewer than two routes are reachable', () => { + computeFileReachSpy.mockReturnValue({ 'src/a.tsx': [0] }) + const plugin = new DevupUIWebpackPlugin({ atomHoist: 2 }) + plugin.apply(asCompiler(createCompiler())) + expect(setAtomHoistSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + }) + + it('swallows pre-pass errors (atom hoisting stays off)', () => { + buildCanonicalMapSpy.mockImplementation(() => { + throw new Error('boom') + }) + const plugin = new DevupUIWebpackPlugin({ atomHoist: 2 }) + // apply must still complete without throwing + plugin.apply(asCompiler(createCompiler())) + expect(setAtomHoistSpy).not.toHaveBeenCalled() + }) + + it('configures atom hoisting BEFORE registering loader rules', () => { + computeFileReachSpy.mockReturnValue({ + 'src/a.tsx': [0], + 'src/b.tsx': [1], + }) + const compiler = createCompiler() + let rulesLenAtSetAtomHoist = -1 + setAtomHoistSpy.mockImplementation(() => { + rulesLenAtSetAtomHoist = compiler.options.module.rules.length + }) + const plugin = new DevupUIWebpackPlugin({ atomHoist: 2 }) + plugin.apply(asCompiler(compiler)) + // pre-pass must run before module rules are pushed (single WASM instance) + expect(rulesLenAtSetAtomHoist).toBe(0) + expect(compiler.options.module.rules.length).toBeGreaterThan(0) + }) + }) }) diff --git a/packages/webpack-plugin/src/loader.ts b/packages/webpack-plugin/src/loader.ts index b06147dc..c5907607 100644 --- a/packages/webpack-plugin/src/loader.ts +++ b/packages/webpack-plugin/src/loader.ts @@ -56,7 +56,11 @@ const devupUILoader: RawLoaderDefinitionFunction = try { let relCssDir = relative(dirname(id), cssDir).replaceAll('\\', '/') - const relativePath = relative(process.cwd(), id) + // POSIX-normalize so the engine's bucket key matches the canonical map / + // FILE_ROUTES keys (built with forward slashes by plugin-utils). Without + // this, single-importer collapse and atom hoisting silently no-op on + // Windows. No-op on POSIX. + const relativePath = relative(process.cwd(), id).replaceAll('\\', '/') if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` const { diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index eabdd36f..a638c00a 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -4,6 +4,8 @@ import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { + buildCanonicalMap, + computeFileReach, createNodeModulesExcludeRegex, createThemeInterfaceArgs, type ImportAliases, @@ -15,10 +17,13 @@ import { getCss, getDefaultTheme, getThemeInterface, + importCanonicalMap, importClassMap, importFileMap, + importFileRoutes, importSheet, registerTheme, + setAtomHoist, setDebug, setPrefix, } from '@devup-ui/wasm' @@ -34,6 +39,23 @@ export interface DevupUIWebpackPluginOptions { include: string[] singleCss: boolean prefix?: string + /** + * Atom-level route-aware hoisting threshold. + * + * When set, a style atom whose content is reached by `>= atomHoist` distinct + * entries/routes is emitted once into the shared `devup-ui.css`; route-private + * atoms stay in their per-route chunk. Clamped to a minimum of 2 (an atom + * shared by `>= 2` routes is the smallest case worth hoisting). Omit to + * disable atom hoisting (identity behavior). + * + * Composes with single-importer collapse: files used by exactly one importer + * still merge into that importer's bucket (deduplicating their identical + * atoms), and atom hoisting then shares atoms across the remaining buckets. + * + * Currently honored by the Next.js plugin; other bundlers wire it + * progressively. No effect where unsupported. + */ + atomHoist?: number /** * Import aliases for redirecting imports from other CSS-in-JS libraries * Merged with defaults: @emotion/styled, styled-components, @vanilla-extract/css @@ -59,6 +81,7 @@ export class DevupUIWebpackPlugin { include = [], singleCss = false, prefix, + atomHoist, importAliases: userImportAliases, }: Partial = {}) { this.importAliases = mergeImportAliases(userImportAliases) @@ -73,6 +96,7 @@ export class DevupUIWebpackPlugin { include, singleCss, prefix, + atomHoist, } this.sheetFile = join(this.options.distDir, 'sheet.json') @@ -137,6 +161,52 @@ export class DevupUIWebpackPlugin { } this.writeDataFiles() + // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any loader + // runs codeExtract (apply() body is synchronous, loaders run during + // compilation) so atoms receive global (shared) class names. The WASM + // instance is shared in-process with the loaders. Composes with + // single-importer collapse: both keyed by the canonical bucket. The webpack + // loader passes relative(process.cwd(), id) as the extraction filename, so + // the graph maps use cwd-relative keys (keyBy: 'cwd-relative'). + const atomHoist = this.options.atomHoist + const atomMode = + atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 + if (atomMode) { + try { + const srcDir = resolve(process.cwd(), 'src') + const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') + const cwd = process.cwd() + const canonicalMap = buildCanonicalMap({ + srcDir, + tsconfigPath, + cwd, + keyBy: 'cwd-relative', + }) + importCanonicalMap(canonicalMap) + + const fileReach = computeFileReach({ + srcDir, + tsconfigPath, + cwd, + keyBy: 'cwd-relative', + }) + const reachByBucket: Record = {} + for (const [file, ids] of Object.entries(fileReach)) { + const bucket = canonicalMap[file] ?? file + if (bucket === '@global') continue + const set = (reachByBucket[bucket] ??= []) + for (const id of ids) if (!set.includes(id)) set.push(id) + } + const routeCount = new Set(Object.values(fileReach).flat()).size + if (routeCount >= 2) { + importFileRoutes(reachByBucket) + setAtomHoist(Math.max(2, atomHoist)) + } + } catch { + // Best-effort; on failure atom hoisting stays off (identity). + } + } + if (this.options.watch) { let lastModifiedTime: number | null = null compiler.hooks.watchRun.tapPromise('DevupUIWebpackPlugin', async () => { From bfe42d3f9f2184f9bb52da03df357f7d66be400d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 3 Jun 2026 19:08:14 +0900 Subject: [PATCH 2/6] Apply new strategy --- packages/next-plugin/src/plugin.ts | 20 ++-- .../plugin-utils/src/import-graph.test.ts | 39 ++++++++ packages/plugin-utils/src/import-graph.ts | 37 ++++++++ packages/plugin-utils/src/index.ts | 2 + .../src/__tests__/plugin.test.ts | 92 ++++++++++++++++++ packages/rsbuild-plugin/src/plugin.ts | 93 ++++++++++++------- packages/vite-plugin/src/plugin.ts | 20 ++-- packages/webpack-plugin/src/plugin.ts | 20 ++-- 8 files changed, 259 insertions(+), 64 deletions(-) diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 613b8ddc..f3e92d30 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -14,6 +14,7 @@ import { createThemeInterfaceArgs, loadDevupConfigSync, mergeImportAliases, + planAtomHoist, } from '@devup-ui/plugin-utils' import { exportClassMap, @@ -159,17 +160,14 @@ export function DevupUI( // Fold per-file route reach onto the canonical bucket so the keys match // the engine's property bucket keys (canonical(filename)). const fileRoutes = computeFileRoutes({ srcDir, tsconfigPath, cwd }) - const reachByBucket: Record = {} - for (const [file, ids] of Object.entries(fileRoutes)) { - const bucket = canonicalMap[file] ?? file - if (bucket === '@global') continue - const set = (reachByBucket[bucket] ??= []) - for (const id of ids) if (!set.includes(id)) set.push(id) - } - const routeCount = new Set(Object.values(fileRoutes).flat()).size - if (routeCount >= 2) { - importFileRoutes(reachByBucket) - setAtomHoist(Math.max(2, atomHoist)) + const plan = planAtomHoist(canonicalMap, fileRoutes, atomHoist) + if (plan) { + importFileRoutes(plan.reachByBucket) + setAtomHoist(plan.threshold) + } else { + console.info( + '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op.', + ) } } } catch { diff --git a/packages/plugin-utils/src/import-graph.test.ts b/packages/plugin-utils/src/import-graph.test.ts index 93152926..fd2e35da 100644 --- a/packages/plugin-utils/src/import-graph.test.ts +++ b/packages/plugin-utils/src/import-graph.test.ts @@ -8,6 +8,7 @@ import { buildCanonicalMap, computeFileReach, computeFileRoutes, + planAtomHoist, } from './import-graph' describe('buildCanonicalMap', () => { @@ -481,3 +482,41 @@ describe('computeFileReach', () => { expect(reach['src/index.tsx']).toBeUndefined() }) }) + +describe('planAtomHoist', () => { + it('folds reach onto the canonical bucket and skips @global', () => { + const plan = planAtomHoist( + { 'src/child.tsx': 'src/parent.tsx', 'src/glob.tsx': '@global' }, + { + 'src/parent.tsx': [0, 1], + 'src/child.tsx': [0], + 'src/glob.tsx': [0, 1], + 'src/r1.tsx': [1], + }, + 2, + ) + expect(plan).toEqual({ + threshold: 2, + // child folded into parent (deduped); @global dropped; r1 kept + reachByBucket: { + 'src/parent.tsx': [0, 1], + 'src/r1.tsx': [1], + }, + }) + }) + + it('clamps the threshold to a minimum of 2', () => { + const plan = planAtomHoist({}, { 'a.tsx': [0], 'b.tsx': [1] }, 1) + expect(plan?.threshold).toBe(2) + }) + + it('honors a threshold above 2', () => { + const plan = planAtomHoist({}, { 'a.tsx': [0], 'b.tsx': [1] }, 5) + expect(plan?.threshold).toBe(5) + }) + + it('returns null when fewer than two distinct routes exist', () => { + expect(planAtomHoist({}, { 'a.tsx': [0] }, 2)).toBeNull() + expect(planAtomHoist({}, {}, 2)).toBeNull() + }) +}) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index b9821478..b0e7963e 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -320,6 +320,43 @@ export function computeFileReach( return fileReach } +export interface AtomHoistPlan { + /** atom-hoist threshold to pass to setAtomHoist (clamped to >= 2). */ + threshold: number + /** canonical bucket -> route ids reaching it (input to importFileRoutes). */ + reachByBucket: Record +} + +/** + * Shared fold + gate + clamp for atom-level hoisting, used identically by every + * bundler plugin (next/vite/webpack/rsbuild). Given the canonical (collapse) map + * and a file -> route-ids reach map, it folds reach onto the canonical bucket + * (the engine keys property buckets by `canonical(filename)`), skips the + * `@global` bucket, and returns the hoist plan — or `null` when fewer than two + * distinct routes exist (atom hoisting is then a no-op; a single bucket is + * already optimal). + * + * Extracting this removes a subtle, error-prone block (fold / `@global` skip / + * id dedupe / `>= 2` gate / `max(2, n)` clamp) from four plugin copies into one + * tested place. + */ +export function planAtomHoist( + canonicalMap: Record, + fileReach: Record, + atomHoist: number, +): AtomHoistPlan | null { + const reachByBucket: Record = {} + for (const [file, ids] of Object.entries(fileReach)) { + const bucket = canonicalMap[file] ?? file + if (bucket === '@global') continue + const set = (reachByBucket[bucket] ??= []) + for (const id of ids) if (!set.includes(id)) set.push(id) + } + const routeCount = new Set(Object.values(fileReach).flat()).size + if (routeCount < 2) return null + return { threshold: Math.max(2, atomHoist), reachByBucket } +} + function getRouteReachableGlobalFiles( files: string[], srcDir: string, diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 863761aa..cba2cd3d 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -1,10 +1,12 @@ export { + type AtomHoistPlan, buildCanonicalMap, type BuildCanonicalMapOptions, computeFileReach, type ComputeFileReachOptions, computeFileRoutes, type ComputeFileRoutesOptions, + planAtomHoist, } from './import-graph' export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' export { diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 85543db1..1a6d8e17 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -589,5 +589,97 @@ const App = () => `, expect(servedBase).toBe('CSS_FOR_null') expect(getCssSpy).toHaveBeenCalledWith(null, false) }) + + it('extracts with posix filename + relative cssDir in atom mode', async () => { + spies() + computeFileReachSpy.mockReturnValue({ + '/p/src/a.tsx': [0], + '/p/src/b.tsx': [1], + }) + codeExtractSpy.mockReturnValue( + createCodeExtractResult({ code: '
', cssFile: '' }), + ) + const transform = mock() + await DevupUI({ atomHoist: 2 }).setup( + createSetupContext({ transform, modifyRsbuildConfig: mock() }), + ) + // calls[1] is the source transform; atom mode posix-normalizes the + // filename and passes a relative cssDir + import_main_css_in_code=true. + await transform.mock.calls[1][1]({ + code: `import { Box } from '@devup-ui/react'\nconst A = () => `, + resourcePath: 'src/App.tsx', + }) + const call = codeExtractSpy.mock.calls.at(-1)! + expect(call[0]).toBe('src/App.tsx') // posix-normalized (already posix here) + expect(typeof call[3]).toBe('string') + expect((call[3] as string).startsWith('./')).toBe(true) // relative cssDir + expect(call[5]).toBe(true) // import_main_css_in_code + expect(call[6]).toBe(false) // import_main_css_in_css + }) + + it('injects a shared-css splitChunks cacheGroup in atom mode', async () => { + spies() + computeFileReachSpy.mockReturnValue({ + '/p/src/a.tsx': [0], + '/p/src/b.tsx': [1], + }) + const modifyRsbuildConfig = mock() + await DevupUI({ atomHoist: 2 }).setup( + createSetupContext({ transform: mock(), modifyRsbuildConfig }), + ) + // prev undefined -> tools.rspack is the single injector function + const cfg = {} as { tools?: { rspack?: unknown } } + modifyRsbuildConfig.mock.calls[0][0](cfg) + const inject = cfg.tools?.rspack as (c: unknown) => void + expect(typeof inject).toBe('function') + // applying it adds the cacheGroup when splitChunks is an object + const rspackCfg = { + optimization: { + splitChunks: {} as { + cacheGroups?: Record + }, + }, + } + inject(rspackCfg) + expect( + rspackCfg.optimization.splitChunks.cacheGroups?.devupUiShared.type, + ).toBe('css/mini-extract') + // splitChunks missing/false -> no cacheGroup added, no throw + const rspackCfg2 = {} as { optimization?: { splitChunks?: unknown } } + inject(rspackCfg2) + expect(rspackCfg2.optimization?.splitChunks).toBeUndefined() + }) + + it('composes the cacheGroup with existing tools.rspack (function then array)', async () => { + spies() + computeFileReachSpy.mockReturnValue({ + '/p/src/a.tsx': [0], + '/p/src/b.tsx': [1], + }) + const modifyFn = mock() + await DevupUI({ atomHoist: 2 }).setup( + createSetupContext({ + transform: mock(), + modifyRsbuildConfig: modifyFn, + }), + ) + const prevFn = mock() + const cfgFn = { tools: { rspack: prevFn as unknown } } + modifyFn.mock.calls[0][0](cfgFn) + expect(Array.isArray(cfgFn.tools.rspack)).toBe(true) + expect((cfgFn.tools.rspack as unknown[])[0]).toBe(prevFn) + + const modifyArr = mock() + await DevupUI({ atomHoist: 2 }).setup( + createSetupContext({ + transform: mock(), + modifyRsbuildConfig: modifyArr, + }), + ) + const prevArr = [mock()] as unknown[] + const cfgArr = { tools: { rspack: prevArr as unknown } } + modifyArr.mock.calls[0][0](cfgArr) + expect((cfgArr.tools.rspack as unknown[]).length).toBe(2) + }) }) }) diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index c7061bc6..f7afea1d 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -11,6 +11,7 @@ import { type ImportAliases, loadDevupConfig, mergeImportAliases, + planAtomHoist, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -44,13 +45,10 @@ export interface DevupUIRsbuildPluginOptions { * import graph (entry points and dynamic-import targets). For a single-entry * SPA (routeCount < 2) it is a no-op. * - * KNOWN LIMITATION (rsbuild MPA): hoisted atoms get a single class name - * (correct), but rspack's default chunk strategy INLINES the shared base CSS - * into each entry bundle, so the base is duplicated across entries rather than - * emitted as one shared chunk. Rendering is correct; the cross-entry dedup - * benefit is not yet realized. Deduping requires an rspack `splitChunks` - * cacheGroup (type `css/mini-extract`, `minChunks >= 2`) — tracked as a - * follow-up. See the cssDir transform TODO below. + * On MPA, the shared base devup-ui.css (hoisted atoms) is emitted as ONE + * shared chunk via an injected rspack `splitChunks` cacheGroup + * (`type: 'css/mini-extract'`), so hoisting actually deduplicates across + * entries rather than being inlined per entry. */ atomHoist?: number /** @@ -160,17 +158,14 @@ export const DevupUI = ({ cwd: root, keyBy: 'absolute', }) - const reachByBucket: Record = {} - for (const [file, ids] of Object.entries(fileReach)) { - const bucket = canonicalMap[file] ?? file - if (bucket === '@global') continue - const set = (reachByBucket[bucket] ??= []) - for (const id of ids) if (!set.includes(id)) set.push(id) - } - const routeCount = new Set(Object.values(fileReach).flat()).size - if (routeCount >= 2) { - importFileRoutes(reachByBucket) - setAtomHoist(Math.max(2, atomHoist)) + const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) + if (plan) { + importFileRoutes(plan.reachByBucket) + setAtomHoist(plan.threshold) + } else { + console.info( + '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + ) } } catch { // Best-effort; on failure atom hoisting stays off (identity). @@ -188,12 +183,8 @@ export const DevupUI = ({ // shared base (devup-ui.css) so hoisted atoms load ONCE and are not // inlined per chunk. The base file itself imports nothing. // Route chunk and base are SEPARATE modules (the transformed entry - // code imports both via import_main_css). - // TODO(atom-B follow-up): rspack's default chunk strategy inlines the - // shared base CSS into each entry bundle (duplicated across MPA - // entries). Add an rspack `splitChunks` cacheGroup (type - // 'css/mini-extract', minChunks >= 2) to emit the base as one shared - // chunk and realize the cross-entry dedup benefit. + // code imports both via import_main_css); the injected splitChunks + // cacheGroup (see modifyRsbuildConfig) emits the base once. return getCss(getFileNumByFilename(basename(resourcePath)), false) }, ) @@ -208,6 +199,40 @@ export const DevupUI = ({ ...config.source.define, } } + if (atomMode) { + // Emit the shared base devup-ui.css (hoisted atoms) as ONE chunk + // instead of rspack's default per-entry inlining, so hoisting actually + // deduplicates across MPA entries. Composed (not overwritten) with any + // user `tools.rspack`. + config.tools ??= {} + const prev = config.tools.rspack + const addSharedCssGroup = (rspackConfig: { + optimization?: { + splitChunks?: + | false + | { cacheGroups?: Record } + | undefined + } + }) => { + rspackConfig.optimization ??= {} + const sc = rspackConfig.optimization.splitChunks + if (sc && typeof sc === 'object') { + sc.cacheGroups ??= {} + sc.cacheGroups['devupUiShared'] = { + type: 'css/mini-extract', + name: 'devup-ui-shared', + test: /[\\/]devup-ui\.css$/, + chunks: 'all', + enforce: true, + } + } + } + config.tools.rspack = Array.isArray(prev) + ? [...prev, addSharedCssGroup] + : prev != null + ? [prev, addSharedCssGroup] + : addSharedCssGroup + } return config }) @@ -224,11 +249,17 @@ export const DevupUI = ({ // relative cssDir is required for that code import to resolve, and the // extraction filename is POSIX-normalized to match the absolute-keyed // canonical map / FILE_ROUTES. Non-atom keeps the prior behavior. - let relCssDir = relative(dirname(resourcePath), cssDir).replaceAll( - '\\', - '/', - ) - if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` + let extractCssDir = cssDir + let extractName = resourcePath + if (atomMode) { + let relCssDir = relative(dirname(resourcePath), cssDir).replaceAll( + '\\', + '/', + ) + if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` + extractCssDir = relCssDir + extractName = resourcePath.replaceAll('\\', '/') + } const { code: retCode, css = '', @@ -236,10 +267,10 @@ export const DevupUI = ({ cssFile, updatedBaseStyle, } = codeExtract( - atomMode ? resourcePath.replaceAll('\\', '/') : resourcePath, + extractName, code, libPackage, - atomMode ? relCssDir : cssDir, + extractCssDir, singleCss, atomMode, !atomMode, diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 0da37bb4..710c3a2b 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -11,6 +11,7 @@ import { type ImportAliases, loadDevupConfig, mergeImportAliases, + planAtomHoist, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -160,17 +161,14 @@ export function DevupUI({ keyBy: 'absolute', entries: entries.length > 0 ? entries : undefined, }) - const reachByBucket: Record = {} - for (const [file, ids] of Object.entries(fileReach)) { - const bucket = canonicalMap[file] ?? file - if (bucket === '@global') continue - const set = (reachByBucket[bucket] ??= []) - for (const id of ids) if (!set.includes(id)) set.push(id) - } - const routeCount = new Set(Object.values(fileReach).flat()).size - if (routeCount >= 2) { - importFileRoutes(reachByBucket) - setAtomHoist(Math.max(2, atomHoist)) + const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) + if (plan) { + importFileRoutes(plan.reachByBucket) + setAtomHoist(plan.threshold) + } else { + console.info( + '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + ) } } catch { // Best-effort; on failure atom hoisting stays off (identity). diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index a638c00a..ca2d88e6 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -11,6 +11,7 @@ import { type ImportAliases, loadDevupConfigSync, mergeImportAliases, + planAtomHoist, type WasmImportAliases, } from '@devup-ui/plugin-utils' import { @@ -190,17 +191,14 @@ export class DevupUIWebpackPlugin { cwd, keyBy: 'cwd-relative', }) - const reachByBucket: Record = {} - for (const [file, ids] of Object.entries(fileReach)) { - const bucket = canonicalMap[file] ?? file - if (bucket === '@global') continue - const set = (reachByBucket[bucket] ??= []) - for (const id of ids) if (!set.includes(id)) set.push(id) - } - const routeCount = new Set(Object.values(fileReach).flat()).size - if (routeCount >= 2) { - importFileRoutes(reachByBucket) - setAtomHoist(Math.max(2, atomHoist)) + const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) + if (plan) { + importFileRoutes(plan.reachByBucket) + setAtomHoist(plan.threshold) + } else { + console.info( + '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + ) } } catch { // Best-effort; on failure atom hoisting stays off (identity). From d0e15c9c98684be5bdcde1d27929fbf31566ef58 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 3 Jun 2026 22:16:06 +0900 Subject: [PATCH 3/6] ci(debug): upload per-file landing build + e2e report artifacts; add workflow_dispatch --- .github/workflows/publish.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1ecc3e0c..7825bc90 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - main + workflow_dispatch: permissions: write-all concurrency: @@ -149,6 +150,20 @@ jobs: file_pattern: "e2e/**/*-snapshots/*.png" - name: Run E2E Tests run: bun run test:e2e + - name: "[DEBUG] Upload Landing build (per-file)" + uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: debug-landing-out-perfile + path: apps/landing/out + retention-days: 7 + - name: "[DEBUG] Upload E2E report (per-file)" + uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: debug-report-perfile + path: playwright-report/ + retention-days: 7 - name: Build Landing (singleCss) run: | rm -rf apps/landing/out apps/landing/.next apps/landing/df @@ -177,6 +192,7 @@ jobs: files: ./coverage/lcov.info - uses: changepacks/action@main + if: github.event_name != 'workflow_dispatch' id: changepacks with: publish: true From b0716ebd2cc1e3cb5c4d5315fa2d6a72876ecb01 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 3 Jun 2026 23:09:43 +0900 Subject: [PATCH 4/6] fix(sheet,wasm): single-importer collapse no longer wipes globalCss/@font-face rm_global_css used the canonical bucket key, so extracting a collapsed member (e.g. Footer.tsx -> layout.tsx) deleted the bucket-root's globalCss (@font-face, global selectors). Order-dependent: CI hit the bad order and the landing buttons rendered in a fallback font (246 -> 234). - bindings: rm_global_css uses the RAW filename (globalCss is per-source-file). - sheet: rm_global_css derives the property bucket via canonical(file) so a collapsed file that DOES declare globalCss still clears its own atoms. - sheet: dedup identical @font-face across file keys (server+client extraction). --- bindings/devup-ui-wasm/src/lib.rs | 120 +++++++++++++++++++++++++++++- libs/sheet/src/lib.rs | 105 +++++++++++++++++++++++++- 2 files changed, 223 insertions(+), 2 deletions(-) diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 591f496e..03906f71 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -78,7 +78,11 @@ impl Output { let canonical_filename = canonical(&filename); let global = single_css || is_global(&filename); with_style_sheet_mut(|sheet| { - let default_collected = sheet.rm_global_css(&canonical_filename, global); + // globalCss (@font-face / global selectors) is per-SOURCE-file, never + // collapsed. rm_global_css MUST use the RAW filename so a collapsed + // member (sharing the bucket-root's canonical) never wipes the root's + // globalCss. Atom property bucketing still uses canonical_filename. + let default_collected = sheet.rm_global_css(&filename, global); let (collected, updated_base_style) = sheet.update_styles(&styles, &canonical_filename, global); Self { @@ -1430,6 +1434,120 @@ mod tests { assert!(output.updated_base_style()); } + // Regression: single-importer collapse must NOT wipe a bucket-root file's + // globalCss (@font-face / global selectors). When child.tsx collapses into + // parent.tsx, extracting child must not delete parent's globalCss. + fn collapse_setup() { + use css::class_map::reset_class_map; + use css::file_map::{reset_canonical_map, reset_file_map}; + { + let mut s = GLOBAL_STYLE_SHEET.lock().unwrap(); + *s = StyleSheet::default(); + } + reset_class_map(); + reset_file_map(); + reset_canonical_map(); + register_theme_internal(sheet::theme::Theme::default()); + } + + fn extract_for_collapse(filename: &str, code: &str) { + code_extract_internal( + filename, + code, + "@devup-ui/react", + "df".to_string(), + false, + false, + false, + HashMap::new(), + ) + .unwrap(); + } + + const LAYOUT_GLOBAL: &str = r#"import { globalCss } from "@devup-ui/react"; globalCss({ pre: { borderRadius: "10px" }, fontFaces: [{ fontFamily: "Pretendard", src: "url(/p.woff2)", fontWeight: 800 }] });"#; + const MEMBER_BOX: &str = + r#"import { Box } from "@devup-ui/react"; const x = ;"#; + + #[test] + #[serial] + fn collapse_member_after_root_keeps_global_css() { + collapse_setup(); + let mut m = HashMap::new(); + m.insert("footer.tsx".to_string(), "layout.tsx".to_string()); + import_canonical_map_internal(m); + + extract_for_collapse("layout.tsx", LAYOUT_GLOBAL); + // member collapses into layout.tsx, extracted AFTER the root -> must NOT + // wipe layout's @font-face / pre{} globalCss. + extract_for_collapse("footer.tsx", MEMBER_BOX); + + let css = with_style_sheet(|s| s.create_css(None, false)); + css::file_map::reset_canonical_map(); + assert!( + css.contains("@font-face"), + "collapse wiped @font-face. css=\n{css}" + ); + assert!( + css.contains("Pretendard"), + "collapse wiped Pretendard font-family. css=\n{css}" + ); + assert!( + css.contains("border-radius:10px"), + "collapse wiped pre{{}} global selector. css=\n{css}" + ); + } + + #[test] + #[serial] + fn collapse_member_before_root_keeps_global_css() { + collapse_setup(); + let mut m = HashMap::new(); + m.insert("footer.tsx".to_string(), "layout.tsx".to_string()); + import_canonical_map_internal(m); + + // member first, then root -> root still re-adds its globalCss. + extract_for_collapse("footer.tsx", MEMBER_BOX); + extract_for_collapse("layout.tsx", LAYOUT_GLOBAL); + + let css = with_style_sheet(|s| s.create_css(None, false)); + css::file_map::reset_canonical_map(); + assert!( + css.contains("@font-face"), + "missing @font-face. css=\n{css}" + ); + assert!( + css.contains("Pretendard"), + "missing Pretendard. css=\n{css}" + ); + } + + #[test] + #[serial] + fn collapse_member_with_own_global_css_preserves_both() { + collapse_setup(); + let mut m = HashMap::new(); + m.insert("footer.tsx".to_string(), "layout.tsx".to_string()); + import_canonical_map_internal(m); + + extract_for_collapse("layout.tsx", LAYOUT_GLOBAL); + // member has its OWN globalCss with a distinct font family. + extract_for_collapse( + "footer.tsx", + r#"import { globalCss } from "@devup-ui/react"; globalCss({ fontFaces: [{ fontFamily: "D2Coding", src: "url(/d.woff2)" }] });"#, + ); + + let css = with_style_sheet(|s| s.create_css(None, false)); + css::file_map::reset_canonical_map(); + assert!( + css.contains("Pretendard"), + "collapse wiped root's Pretendard. css=\n{css}" + ); + assert!( + css.contains("D2Coding"), + "member's own font-family missing. css=\n{css}" + ); + } + #[test] #[serial] fn test_import_sheet_internal() { diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 93dbeab4..4f9a4950 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -3,6 +3,7 @@ pub mod theme; use crate::theme::Theme; use css::{ atom_hoist::{atom_hoist_threshold, is_atom_hoist}, + file_map::canonical, file_routes::route_count_for_files, merge_selector, sheet_to_classname, style_selector::{AtRuleKind, StyleSelector}, @@ -296,7 +297,15 @@ impl StyleSheet { self.css.remove(file); self.font_faces.remove(file); - let property_key = if single_css { "" } else { file }.to_string(); + // `file` is the RAW source filename (globalCss is per-source-file). Atoms + // were bucketed by canonical(file) in update_styles, so global-selector + // atom removal must read from the canonical bucket while still matching + // the raw owner via `f == file` below. + let property_key = if single_css { + String::new() + } else { + canonical(file) + }; if let Some(prop_map) = self.properties.get_mut(&property_key) { for map in prop_map.values_mut() { @@ -827,8 +836,15 @@ impl StyleSheet { if !theme_css.is_empty() { push_fmt!(&mut css, "@layer t{{{theme_css}}}"); } + // One source file extracted under multiple passes (e.g. Next + // server + client compilations) registers identical @font-face rules + // under multiple file keys; emit each distinct rule only once. + let mut seen_font_faces: BTreeSet<&BTreeMap> = BTreeSet::new(); for font_faces in self.font_faces.values() { for font_face in font_faces { + if !seen_font_faces.insert(font_face) { + continue; + } css.push_str("@font-face{"); let mut first = true; for (key, value) in font_face { @@ -1051,6 +1067,93 @@ mod tests { sheet.add_property("test", "border-color", 0, "red", None, None, None); assert_debug_snapshot!(sheet.create_css(None, false).split("*/").nth(1).unwrap()); } + + // Under single-importer collapse, a collapsed file's globalCss atoms are + // bucketed by canonical(file). rm_global_css(raw) must therefore clear them + // from the CANONICAL bucket (matching the raw owner via f == file), and must + // NOT touch the bucket-root's own global atoms. + #[test] + #[serial] + fn rm_global_css_clears_collapsed_globals_from_canonical_bucket() { + use css::file_map::{reset_canonical_map, set_canonical_map}; + reset_class_map(); + reset_file_map(); + reset_canonical_map(); + let mut m = std::collections::HashMap::new(); + m.insert("child.tsx".to_string(), "parent.tsx".to_string()); + set_canonical_map(m); + + let mut sheet = StyleSheet::default(); + // child's own globalCss: @font-face + a global selector, bucketed by + // canonical(child) == "parent.tsx". + sheet.add_font_face( + "child.tsx", + &BTreeMap::from([("font-family".to_string(), "D2Coding".to_string())]), + ); + sheet.add_property( + "c1", + "border-radius", + 0, + "10px", + Some(&StyleSelector::Global( + "pre".to_string(), + "child.tsx".to_string(), + )), + Some(0), + Some("parent.tsx"), + ); + // parent's own global selector in the SAME canonical bucket. + sheet.add_property( + "p1", + "border-radius", + 0, + "5px", + Some(&StyleSelector::Global( + "div".to_string(), + "parent.tsx".to_string(), + )), + Some(0), + Some("parent.tsx"), + ); + + // Clearing child's globalCss must remove ONLY child's contributions. + sheet.rm_global_css("child.tsx", false); + let css = sheet.create_css(None, false); + reset_canonical_map(); + + assert!( + !css.contains("D2Coding"), + "child @font-face not cleared: {css}" + ); + assert!( + !css.contains("border-radius:10px"), + "child global atom not cleared from canonical bucket: {css}" + ); + assert!( + css.contains("border-radius:5px"), + "parent global atom wrongly cleared: {css}" + ); + } + + // A single source file extracted under multiple passes (e.g. Next server + + // client compilations) registers the SAME @font-face under multiple file + // keys. The emitted CSS must contain each distinct @font-face only ONCE. + #[test] + fn font_faces_deduplicated_across_file_keys() { + let props = BTreeMap::from([ + ("font-family".to_string(), "Roboto".to_string()), + ("src".to_string(), "url(/r.woff2)".to_string()), + ]); + let mut sheet = StyleSheet::default(); + sheet.add_font_face("a.tsx", &props); + sheet.add_font_face("b.tsx", &props); + let css = sheet.create_css(None, false); + assert_eq!( + css.matches("@font-face{").count(), + 1, + "duplicate @font-face must be emitted once: {css}" + ); + } #[test] fn test_create_css_with_selector_sort_test() { let mut sheet = StyleSheet::default(); From ddbf61bb433207542bf013db92bec90d23e5af8c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 4 Jun 2026 00:13:14 +0900 Subject: [PATCH 5/6] fix issue --- .github/workflows/publish.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7825bc90..1ecc3e0c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,7 +7,6 @@ on: pull_request: branches: - main - workflow_dispatch: permissions: write-all concurrency: @@ -150,20 +149,6 @@ jobs: file_pattern: "e2e/**/*-snapshots/*.png" - name: Run E2E Tests run: bun run test:e2e - - name: "[DEBUG] Upload Landing build (per-file)" - uses: actions/upload-artifact@v7 - if: ${{ !cancelled() }} - with: - name: debug-landing-out-perfile - path: apps/landing/out - retention-days: 7 - - name: "[DEBUG] Upload E2E report (per-file)" - uses: actions/upload-artifact@v7 - if: ${{ !cancelled() }} - with: - name: debug-report-perfile - path: playwright-report/ - retention-days: 7 - name: Build Landing (singleCss) run: | rm -rf apps/landing/out apps/landing/.next apps/landing/df @@ -192,7 +177,6 @@ jobs: files: ./coverage/lcov.info - uses: changepacks/action@main - if: github.event_name != 'workflow_dispatch' id: changepacks with: publish: true From cdc7ed62ef2ad1fd9630c8636bc1fafb32da5041 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 4 Jun 2026 00:29:12 +0900 Subject: [PATCH 6/6] test(sheet,wasm): harden globalCss collapse regression net + clear stale @import --- bindings/devup-ui-wasm/src/lib.rs | 64 +++++++++++++++++++++++++++++++ libs/sheet/src/lib.rs | 20 ++++++++++ 2 files changed, 84 insertions(+) diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 03906f71..0b4df172 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1548,6 +1548,70 @@ mod tests { ); } + #[test] + #[serial] + fn collapse_multiple_members_keep_root_global_css() { + collapse_setup(); + let mut m = HashMap::new(); + m.insert("footer.tsx".to_string(), "layout.tsx".to_string()); + m.insert("header.tsx".to_string(), "layout.tsx".to_string()); + import_canonical_map_internal(m); + + extract_for_collapse("layout.tsx", LAYOUT_GLOBAL); + // multiple members collapse into the same root; none may wipe its globalCss. + extract_for_collapse("footer.tsx", MEMBER_BOX); + extract_for_collapse("header.tsx", MEMBER_BOX); + + let css = with_style_sheet(|s| s.create_css(None, false)); + css::file_map::reset_canonical_map(); + assert!( + css.contains("Pretendard") && css.contains("border-radius:10px"), + "multiple collapsed members wiped root globalCss. css=\n{css}" + ); + } + + #[test] + #[serial] + fn collapse_member_reextract_clears_only_its_own_global_css() { + collapse_setup(); + let mut m = HashMap::new(); + m.insert("footer.tsx".to_string(), "layout.tsx".to_string()); + import_canonical_map_internal(m); + + extract_for_collapse("layout.tsx", LAYOUT_GLOBAL); + // member's OWN globalCss v1: a global selector + a distinct font. + extract_for_collapse( + "footer.tsx", + r#"import { globalCss } from "@devup-ui/react"; globalCss({ code: { color: "red" }, fontFaces: [{ fontFamily: "D2Coding", src: "url(/d.woff2)" }] });"#, + ); + // member re-extracted (HMR) with DIFFERENT globalCss. + extract_for_collapse( + "footer.tsx", + r#"import { globalCss } from "@devup-ui/react"; globalCss({ samp: { color: "blue" } });"#, + ); + + let css = with_style_sheet(|s| s.create_css(None, false)); + css::file_map::reset_canonical_map(); + // member's NEW globalCss present; its STALE globalCss cleared from the + // canonical bucket (selector) and raw maps (font); root untouched. + assert!( + css.contains("color:blue"), + "member new globalCss missing. css=\n{css}" + ); + assert!( + !css.contains("color:red"), + "stale member global selector not cleared from canonical bucket. css=\n{css}" + ); + assert!( + !css.contains("D2Coding"), + "stale member @font-face not cleared. css=\n{css}" + ); + assert!( + css.contains("Pretendard"), + "root globalCss wiped by member re-extract. css=\n{css}" + ); + } + #[test] #[serial] fn test_import_sheet_internal() { diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 4f9a4950..3af5f789 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -297,6 +297,10 @@ impl StyleSheet { self.css.remove(file); self.font_faces.remove(file); + // @import rules are per-source-file globalCss (keyed by raw filename), + // like `css`/`font_faces`; clear them so an @import removed from source + // does not linger across re-extraction (HMR). + self.imports.remove(file); // `file` is the RAW source filename (globalCss is per-source-file). Atoms // were bucketed by canonical(file) in update_styles, so global-selector // atom removal must read from the canonical bucket while still matching @@ -1154,6 +1158,22 @@ mod tests { "duplicate @font-face must be emitted once: {css}" ); } + + // rm_global_css clears a file's globalCss before it is re-added on the next + // extraction (HMR). It must also drop the file's @import rules, otherwise an + // @import removed from source lingers until restart. + #[test] + fn rm_global_css_clears_imports() { + let mut sheet = StyleSheet::default(); + sheet.add_import("a.tsx", "\"https://example.com/stale.css\""); + assert!(sheet.create_css(None, false).contains("stale.css")); + sheet.rm_global_css("a.tsx", false); + let css = sheet.create_css(None, false); + assert!( + !css.contains("stale.css"), + "rm_global_css must clear stale @import: {css}" + ); + } #[test] fn test_create_css_with_selector_sort_test() { let mut sheet = StyleSheet::default();