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/benchmark.js b/benchmark.js index 55041a96..964e80d2 100644 --- a/benchmark.js +++ b/benchmark.js @@ -58,9 +58,14 @@ function benchmark(target) { performance.measure(target, target + '-start', target + '-end') const benchmarkDir = join('./benchmark', dir) - const outputDir = existsSync(join(benchmarkDir, '.next')) - ? join(benchmarkDir, '.next') - : join(benchmarkDir, 'dist') + // Resolve the real build-output dir. Next.js emits to `.next`; Vite emits to + // `dist`. vinext (Next-on-Vite) emits its real artifacts to `dist` but ALSO + // leaves a tiny vestigial `.next` stub (~988 B, no CSS) - so checking `.next` + // first measured the empty stub and reported "988 bytes (css 0 bytes)" even + // though dist held ~1.28 MB incl. the extracted CSS. Prefer `dist` when it + // exists; fall back to `.next` for pure Next.js apps (which never emit dist). + const distDir = join(benchmarkDir, 'dist') + const outputDir = existsSync(distDir) ? distDir : join(benchmarkDir, '.next') const duration = ( performance.getEntriesByName(target)[0].duration / 1000 ).toFixed(2) @@ -80,6 +85,7 @@ result.push(benchmark('devup-ui')) result.push(benchmark('devup-ui-single')) result.push(benchmark('tailwind-turbo')) result.push(benchmark('devup-ui-single-turbo')) +result.push(benchmark('devup-ui-turbo')) result.push(benchmark('vanilla-extract-devup-ui')) result.push(benchmark('tailwind-turbo-devup-ui')) result.push(benchmark('vinext-devup-ui')) diff --git a/benchmark/next-devup-ui-turbo/.gitignore b/benchmark/next-devup-ui-turbo/.gitignore new file mode 100644 index 00000000..5ef6a520 --- /dev/null +++ b/benchmark/next-devup-ui-turbo/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/benchmark/next-devup-ui-turbo/README.md b/benchmark/next-devup-ui-turbo/README.md new file mode 100644 index 00000000..665152ea --- /dev/null +++ b/benchmark/next-devup-ui-turbo/README.md @@ -0,0 +1 @@ +## Nextjs App diff --git a/benchmark/next-devup-ui-turbo/next.config.ts b/benchmark/next-devup-ui-turbo/next.config.ts new file mode 100644 index 00000000..e92a3459 --- /dev/null +++ b/benchmark/next-devup-ui-turbo/next.config.ts @@ -0,0 +1,7 @@ +import { DevupUI } from '@devup-ui/next-plugin' + +const nextConfig = { + /* config options here */ +} + +export default DevupUI(nextConfig, {}) diff --git a/benchmark/next-devup-ui-turbo/package.json b/benchmark/next-devup-ui-turbo/package.json new file mode 100644 index 00000000..45942f0b --- /dev/null +++ b/benchmark/next-devup-ui-turbo/package.json @@ -0,0 +1,25 @@ +{ + "name": "next-devup-ui-turbo-benchmark", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build --experimental-debug-memory-usage", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "react": "^19.2", + "react-dom": "^19.2", + "next": "^16.2", + "@devup-ui/react": "workspace:^" + }, + "devDependencies": { + "@devup-ui/next-plugin": "workspace:^", + "typescript": "^6", + "@types/node": "^25", + "@types/react": "^19", + "@types/react-dom": "^19" + } +} diff --git a/benchmark/next-devup-ui-turbo/public/file.svg b/benchmark/next-devup-ui-turbo/public/file.svg new file mode 100644 index 00000000..004145cd --- /dev/null +++ b/benchmark/next-devup-ui-turbo/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/benchmark/next-devup-ui-turbo/public/globe.svg b/benchmark/next-devup-ui-turbo/public/globe.svg new file mode 100644 index 00000000..567f17b0 --- /dev/null +++ b/benchmark/next-devup-ui-turbo/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/benchmark/next-devup-ui-turbo/public/next.svg b/benchmark/next-devup-ui-turbo/public/next.svg new file mode 100644 index 00000000..5174b28c --- /dev/null +++ b/benchmark/next-devup-ui-turbo/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/benchmark/next-devup-ui-turbo/public/vercel.svg b/benchmark/next-devup-ui-turbo/public/vercel.svg new file mode 100644 index 00000000..77053960 --- /dev/null +++ b/benchmark/next-devup-ui-turbo/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/benchmark/next-devup-ui-turbo/public/window.svg b/benchmark/next-devup-ui-turbo/public/window.svg new file mode 100644 index 00000000..b2b2a44f --- /dev/null +++ b/benchmark/next-devup-ui-turbo/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/benchmark/next-devup-ui-turbo/src/app/favicon.ico b/benchmark/next-devup-ui-turbo/src/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/benchmark/next-devup-ui-turbo/src/app/favicon.ico differ diff --git a/benchmark/next-devup-ui-turbo/src/app/layout.tsx b/benchmark/next-devup-ui-turbo/src/app/layout.tsx new file mode 100644 index 00000000..6b8b4518 --- /dev/null +++ b/benchmark/next-devup-ui-turbo/src/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Create Next App', + description: 'Generated by create next app', +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + {children} + + ) +} diff --git a/benchmark/next-devup-ui-turbo/src/app/page.tsx b/benchmark/next-devup-ui-turbo/src/app/page.tsx new file mode 100644 index 00000000..310fa7e7 --- /dev/null +++ b/benchmark/next-devup-ui-turbo/src/app/page.tsx @@ -0,0 +1,51 @@ +'use client' + +import { Box, Text } from '@devup-ui/react' +import { useState } from 'react' + +export default function HomePage() { + const [color, setColor] = useState('yellow') + const [enabled, setEnabled] = useState(false) + + return ( +
+

+ Track & field champions: +

+ + hello + hello + + text + + hello + + hello + +
+ ) +} diff --git a/benchmark/next-devup-ui-turbo/tsconfig.json b/benchmark/next-devup-ui-turbo/tsconfig.json new file mode 100644 index 00000000..a9788073 --- /dev/null +++ b/benchmark/next-devup-ui-turbo/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "df/*.d.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 436a9719..0b4df172 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,21 @@ 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); + // 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, &filename, single_css); + sheet.update_styles(&styles, &canonical_filename, global); Self { code, map, @@ -82,7 +95,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 +264,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 +481,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() { @@ -902,6 +1434,184 @@ 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 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/bun.lock b/bun.lock index c8aad25c..7052dc33 100644 --- a/bun.lock +++ b/bun.lock @@ -154,6 +154,23 @@ "typescript": "^6", }, }, + "benchmark/next-devup-ui-collapse": { + "name": "next-devup-ui-collapse-benchmark", + "version": "0.1.0", + "dependencies": { + "@devup-ui/react": "workspace:^", + "next": "^16.2", + "react": "^19.2", + "react-dom": "^19.2", + }, + "devDependencies": { + "@devup-ui/next-plugin": "workspace:^", + "@types/node": "^25", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^6", + }, + }, "benchmark/next-devup-ui-single": { "name": "next-devup-ui-single-benchmark", "version": "0.1.0", @@ -188,6 +205,23 @@ "typescript": "^6", }, }, + "benchmark/next-devup-ui-turbo": { + "name": "next-devup-ui-turbo-benchmark", + "version": "0.1.0", + "dependencies": { + "@devup-ui/react": "workspace:^", + "next": "^16.2", + "react": "^19.2", + "react-dom": "^19.2", + }, + "devDependencies": { + "@devup-ui/next-plugin": "workspace:^", + "@types/node": "^25", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^6", + }, + }, "benchmark/next-kuma-ui": { "name": "next-kuma-ui-benchmark", "version": "0.1.0", @@ -2708,10 +2742,14 @@ "next-devup-ui-benchmark": ["next-devup-ui-benchmark@workspace:benchmark/next-devup-ui"], + "next-devup-ui-collapse-benchmark": ["next-devup-ui-collapse-benchmark@workspace:benchmark/next-devup-ui-collapse"], + "next-devup-ui-single-benchmark": ["next-devup-ui-single-benchmark@workspace:benchmark/next-devup-ui-single"], "next-devup-ui-single-turbo-benchmark": ["next-devup-ui-single-turbo-benchmark@workspace:benchmark/next-devup-ui-single-turbo"], + "next-devup-ui-turbo-benchmark": ["next-devup-ui-turbo-benchmark@workspace:benchmark/next-devup-ui-turbo"], + "next-example": ["next-example@workspace:apps/next"], "next-kuma-ui-benchmark": ["next-kuma-ui-benchmark@workspace:benchmark/next-kuma-ui"], diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 5d409ccf..67cb9d7a 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -8,14 +8,32 @@ import type { Page } from '@playwright/test' * (page.evaluate is not available in JS-disabled contexts). */ export async function waitForFontsReady(page: Page): Promise { + // Always wait for the load event first (fires after CSS @font-face is parsed). + await page.waitForLoadState('load') try { await page.evaluate(async () => { + if (!document.fonts) return + // Explicitly load the webfont weights the snapshots are baselined on. + // The landing @font-face points at a CDN (jsdelivr); on a SLOW CDN the + // faces stay pending and `document.fonts.ready` can resolve while text is + // still painted in a fallback font, shifting layout height and producing + // flaky full-page/section screenshots. Forcing the loads makes the wait + // deterministic instead of racing first paint. + try { + await Promise.all( + [ + '400 16px Pretendard', + '700 16px Pretendard', + '800 16px Pretendard', + ].map((font) => document.fonts.load(font)), + ) + } catch { + // Ignore individual load failures; fall through to fonts.ready. + } await document.fonts.ready - await page.waitForLoadState('load') }) } catch { - // JS disabled — fall back to load event (fires after fonts in CSS are loaded) - await page.waitForLoadState('load') + // JS disabled (e.g. zero-runtime test) — the load event above is enough. } } diff --git a/e2e/landing-interactions.spec.ts b/e2e/landing-interactions.spec.ts index dcc2588e..c06efce3 100644 --- a/e2e/landing-interactions.spec.ts +++ b/e2e/landing-interactions.spec.ts @@ -1,6 +1,32 @@ +import type { Locator } from '@playwright/test' import { expect, test } from '@playwright/test' -import { waitForStyleSettle } from './helpers' +/** Read the background color of a button's inner bg-bearing
(or itself). */ +function readInnerBg(locator: Locator): Promise { + return locator.evaluate((el) => { + const inner = el.querySelector('div') || el + return getComputedStyle(inner).backgroundColor + }) +} + +/** + * Hover the link, then poll the inner background until it reaches `expectedHex`. + * + * `:hover` styles apply via the pointer, and a CSS transition means the computed + * color is not final on the next frame. A fixed wait races that transition and + * flakes; auto-retrying the assertion waits exactly as long as needed. + */ +async function expectHoverBg( + link: Locator, + expectedHex: string, +): Promise { + await link.hover() + await expect + .poll(async () => normalizeColor(await readInnerBg(link)), { + timeout: 5_000, + }) + .toBe(expectedHex) +} function normalizeColor(raw: string): string { const trimmed = raw.trim().toLowerCase() @@ -34,29 +60,13 @@ test.describe('Landing Page - Interactions', () => { const getStartedLink = page.locator('a', { hasText: 'Get started' }).first() await expect(getStartedLink).toBeVisible() - // Get the inner flex container that has the bg (first div child) - const bgBefore = await getStartedLink.evaluate((el) => { - const inner = el.querySelector('div') || el - return getComputedStyle(inner).backgroundColor - }) - - // Hover - await getStartedLink.hover() - await waitForStyleSettle(page) - - const bgAfter = await getStartedLink.evaluate((el) => { - const inner = el.querySelector('div') || el - return getComputedStyle(inner).backgroundColor - }) - expect( - normalizeColor(bgBefore), + normalizeColor(await readInnerBg(getStartedLink)), 'Before hover: bg should be $text (#2F2F2F)', ).toBe('#2f2f2f') - expect( - normalizeColor(bgAfter), - 'After hover: bg should change to $title (#1A1A1A)', - ).toBe('#1a1a1a') + + // After hover: bg should change to $title (#1A1A1A) + await expectHoverBg(getStartedLink, '#1a1a1a') }) test('Discord button background changes on hover', async ({ page }) => { @@ -65,21 +75,8 @@ test.describe('Landing Page - Interactions', () => { const discordLink = page.getByRole('link', { name: /Join our Discord/i }) await expect(discordLink).toBeVisible() - const bgBefore = await discordLink.evaluate((el) => { - const inner = el.querySelector('div') || el - return getComputedStyle(inner).backgroundColor - }) - - await discordLink.hover() - await waitForStyleSettle(page) - - const bgAfter = await discordLink.evaluate((el) => { - const inner = el.querySelector('div') || el - return getComputedStyle(inner).backgroundColor - }) - - expect(normalizeColor(bgBefore)).toBe('#266ccd') - expect(normalizeColor(bgAfter)).toBe('#1453ac') + expect(normalizeColor(await readInnerBg(discordLink))).toBe('#266ccd') + await expectHoverBg(discordLink, '#1453ac') }) test('KakaoTalk button background changes on hover', async ({ page }) => { @@ -88,21 +85,8 @@ test.describe('Landing Page - Interactions', () => { const kakaoLink = page.getByRole('link', { name: /Open KakaoTalk/i }) await expect(kakaoLink).toBeVisible() - const bgBefore = await kakaoLink.evaluate((el) => { - const inner = el.querySelector('div') || el - return getComputedStyle(inner).backgroundColor - }) - - await kakaoLink.hover() - await waitForStyleSettle(page) - - const bgAfter = await kakaoLink.evaluate((el) => { - const inner = el.querySelector('div') || el - return getComputedStyle(inner).backgroundColor - }) - - expect(normalizeColor(bgBefore)).toBe('#de9800') - expect(normalizeColor(bgAfter)).toBe('#c98900') + expect(normalizeColor(await readInnerBg(kakaoLink))).toBe('#de9800') + await expectHoverBg(kakaoLink, '#c98900') }) test('FigmaButton background changes on hover', async ({ page }) => { @@ -117,20 +101,8 @@ test.describe('Landing Page - Interactions', () => { }) await expect(figmaLink).toBeVisible() - const bgBefore = await figmaLink.evaluate((el) => { - const inner = el.querySelector('div') || el - return getComputedStyle(inner).backgroundColor - }) - - await figmaLink.hover() - await waitForStyleSettle(page) - - const bgAfter = await figmaLink.evaluate((el) => { - const inner = el.querySelector('div') || el - return getComputedStyle(inner).backgroundColor - }) - // Before hover should be transparent (rgba(0, 0, 0, 0)) + const bgBefore = await readInnerBg(figmaLink) const isTransparent = bgBefore === 'rgba(0, 0, 0, 0)' || bgBefore === 'transparent' expect( @@ -139,6 +111,6 @@ test.describe('Landing Page - Interactions', () => { ).toBeTruthy() // After hover should be $menuHover = #F6F4FF - expect(normalizeColor(bgAfter)).toBe('#f6f4ff') + await expectHoverBg(figmaLink, '#f6f4ff') }) }) 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..b655aa12 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -2,6 +2,9 @@ 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}, theme_tokens::set_theme_token_levels, @@ -11,7 +14,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; @@ -294,7 +297,19 @@ impl StyleSheet { self.css.remove(file); self.font_faces.remove(file); - let property_key = if single_css { "" } else { file }.to_string(); + // @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 + // 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() { @@ -340,6 +355,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 +397,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 +415,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 +429,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 +441,7 @@ impl StyleSheet { }, dy.selector(), dy.style_order(), - if single_css { None } else { Some(filename) }, + bucket_scope, ) { collected = true; @@ -428,9 +453,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 +472,7 @@ impl StyleSheet { ) }) .collect(), - if single_css { None } else { Some(filename) }, + bucket_scope, ) { collected = true; } @@ -717,6 +740,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 +786,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(); @@ -780,8 +840,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 { @@ -852,6 +919,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 +990,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 @@ -947,6 +1071,220 @@ 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()); } + + // Atom-level hoisting emission. Without an atom-hoist test these branches in + // compute_hoisted_atoms / create_css were uncovered: + // * compute_hoisted_atoms skips style_order 0 + // * the global hoist emission skips style_order 0 + // * the global hoist emission skips an aggregated order whose CSS is empty + // (a hoisted atom that is a *layered global* prop -> no direct output) + // * the global hoist emission wraps a hoisted order != 255 in `@layer o{N}` + // * the per-route emission skips a chunk whose atoms were all hoisted away + #[test] + #[serial] + fn create_css_atom_hoisting_emission() { + use css::atom_hoist::set_atom_hoist; + use css::file_routes::{reset_file_routes, set_file_routes}; + use std::collections::{HashMap, HashSet}; + + reset_class_map(); + reset_file_map(); + reset_file_routes(); + + // a.tsx and b.tsx each own one distinct route, so an atom referenced by + // BOTH is reached by 2 routes (>= threshold) and gets hoisted. + let mut routes = HashMap::new(); + routes.insert("a.tsx".to_string(), HashSet::from([0u32])); + routes.insert("b.tsx".to_string(), HashSet::from([1u32])); + set_file_routes(routes); + set_atom_hoist(Some(2)); + + let mut sheet = StyleSheet::default(); + // Hoisted user atom (style_order 255), in both files. + sheet.add_property("hu", "color", 0, "red", None, Some(255), Some("a.tsx")); + sheet.add_property("hu", "color", 0, "red", None, Some(255), Some("b.tsx")); + // Hoisted ordered atom (style_order 1) -> emitted as `@layer o1`. + sheet.add_property("ho", "padding", 0, "1px", None, Some(1), Some("a.tsx")); + sheet.add_property("ho", "padding", 0, "1px", None, Some(1), Some("b.tsx")); + // Base style (style_order 0) -> exercises the style_order == 0 skips. + sheet.add_property("hb", "margin", 0, "0", None, Some(0), Some("a.tsx")); + // Hoisted LAYERED GLOBAL atom (style_order 2): when aggregated, its + // create_style produces no direct CSS (it goes to the discarded layer + // map), so that aggregated order is skipped as empty. + let ga = StyleSelector::Global("div".to_string(), "a.tsx".to_string()); + sheet.add_property_with_layer( + "hg", + "border-radius", + 0, + "9px", + Some(&ga), + Some(2), + Some("a.tsx"), + Some("lyr"), + ); + let gb = StyleSelector::Global("div".to_string(), "b.tsx".to_string()); + sheet.add_property_with_layer( + "hg", + "border-radius", + 0, + "9px", + Some(&gb), + Some(2), + Some("b.tsx"), + Some("lyr"), + ); + // Non-hoisted responsive at-rule atom (only a.tsx): emitted in a.tsx's + // chunk via the break-point at-rule path (level != 0 -> break_point set). + let at = StyleSelector::At { + kind: AtRuleKind::Media, + query: "(hover:hover)".to_string(), + selector: None, + }; + sheet.add_property( + "atr", + "color", + 1, + "blue", + Some(&at), + Some(255), + Some("a.tsx"), + ); + + // Global stylesheet: runs compute_hoisted_atoms + the hoist emission. + let global_css = sheet.create_css(None, false); + assert!( + global_css.contains("@layer o1"), + "hoisted order-1 atom must emit @layer o1: {global_css}" + ); + + // Per-route chunk for a.tsx: every one of its atoms was hoisted, so the + // chunk keeps none of them (exercises the all-hoisted skip). + let chunk_css = sheet.create_css(Some("a.tsx"), false); + assert!( + !chunk_css.contains("@layer o1"), + "hoisted atoms must not duplicate into the per-route chunk: {chunk_css}" + ); + assert!( + !chunk_css.contains("padding:1px"), + "hoisted padding atom must not be in the chunk: {chunk_css}" + ); + // The responsive at-rule wrapper AND its property must both be emitted + // (exercises the break-point at-rule path). + assert!( + chunk_css.contains("hover:hover"), + "at-rule wrapper must be emitted: {chunk_css}" + ); + assert!( + chunk_css.contains("blue"), + "at-rule property must be written: {chunk_css}" + ); + + set_atom_hoist(None); + reset_file_routes(); + } + + // 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}" + ); + } + + // 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(); diff --git a/package.json b/package.json index 27922c51..63673dd0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "lint": "cargo fmt --all -- --check && cargo clippy --all-targets --all-features -- -D warnings && eslint", "lint:fix": "eslint --fix && cargo fmt", - "test": "cargo tarpaulin --out xml --out stdout --all-targets --engine llvm && bun test", + "test": "cargo tarpaulin --out xml --out stdout --all-targets --engine llvm $(node -p \"process.env.CI ? '--fail-under 100' : ''\") && bun test", "test:e2e": "bunx playwright test", "test:e2e:ui": "bunx playwright test --ui", "test:e2e:update": "bunx playwright test --update-snapshots", diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index 711540aa..07e14914 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -42,6 +42,7 @@ function makeOptions( fileMapFile: join(tmpDir, 'fileMap.json'), importAliases: {}, coordinatorPortFile: join(tmpDir, 'coordinator.port'), + canonicalMap: {}, ...overrides, } } @@ -950,3 +951,263 @@ describe('coordinator', () => { coordinator.close() }) }) + +describe('coordinator per-bucket completion', () => { + function extractResult(cssFile: string) { + return { + code: 'code', + map: undefined, + cssFile, + updatedBaseStyle: false, + free: mock(), + [Symbol.dispose]: mock(), + } + } + + async function startAndGetPort(options: CoordinatorOptions) { + const coordinator = startCoordinator(options) + await new Promise((r) => setTimeout(r, 100)) + const port = parseInt( + (writeFileSyncSpy.mock.calls[0] as [string, string])[1], + ) + return { coordinator, port } + } + + function extract(port: number, filename: string) { + return httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename, + code: 'c', + resourcePath: join(process.cwd(), filename), + }), + ) + } + + // T0: idleThresholdMs option is honored by the base-css idle wait. + it('honors idleThresholdMs for the base-css idle wait (fast when small)', async () => { + codeExtractSpy.mockReturnValue(extractResult('devup-ui.css')) + getCssSpy.mockReturnValue('base-css') + const { coordinator, port } = await startAndGetPort( + makeOptions({ idleThresholdMs: 50 }), + ) + await extract(port, 'src/A.tsx') + + const t0 = Date.now() + const res = await httpRequest( + port, + 'GET', + '/css?importMainCss=false&waitForIdle=true', + ) + const elapsed = Date.now() - t0 + + expect(res.status).toBe(200) + // 50ms threshold -> resolves fast; the previous hardcoded 2500ms idle + // would push elapsed past 1500ms. + expect(elapsed).toBeLessThan(1500) + + coordinator.close() + }) + + // T1: a collapsed bucket's CSS is not served until ALL its members are + // extracted (the race that flaked landing e2e on slow CI). + it('waits for all bucket members before serving a collapsed chunk', async () => { + codeExtractSpy.mockReturnValue(extractResult('devup-ui-1.css')) + getCssSpy.mockReturnValue('bucket-css') + // m1, m2 collapse into bucket.tsx; g is @global (must NOT be awaited). + const canonicalMap = { + 'src/m1.tsx': 'src/bucket.tsx', + 'src/m2.tsx': 'src/bucket.tsx', + 'src/g.tsx': '@global', + } + const { coordinator, port } = await startAndGetPort( + makeOptions({ canonicalMap, idleThresholdMs: 100 }), + ) + // Extract ONLY the bucket root; m1, m2 still pending. + await extract(port, 'src/bucket.tsx') + getCssSpy.mockClear() + + // Request the bucket chunk; it must NOT resolve while m1/m2 are missing, + // even though the (small) idle threshold has elapsed. + let resolved = false + const cssPromise = httpRequest( + port, + 'GET', + '/css?fileNum=1&importMainCss=true&waitForIdle=true', + ).then((r) => { + resolved = true + return r + }) + await new Promise((r) => setTimeout(r, 300)) + expect(resolved).toBe(false) + expect(getCssSpy).not.toHaveBeenCalled() + + // Extract the remaining members -> the bucket is now complete. + await extract(port, 'src/m1.tsx') + await extract(port, 'src/m2.tsx') + const res = await cssPromise + expect(res.status).toBe(200) + expect(getCssSpy).toHaveBeenCalledWith(1, true) + + coordinator.close() + }) + + // T2: a non-collapsed (singleton) bucket serves as soon as its own file is + // extracted, without waiting out the idle threshold. + it('serves a singleton bucket promptly without an idle wait', async () => { + codeExtractSpy.mockReturnValue(extractResult('devup-ui-1.css')) + getCssSpy.mockReturnValue('css') + const { coordinator, port } = await startAndGetPort( + makeOptions({ canonicalMap: {}, idleThresholdMs: 2000 }), + ) + await extract(port, 'src/f.tsx') + + const t0 = Date.now() + const res = await httpRequest( + port, + 'GET', + '/css?fileNum=1&importMainCss=true&waitForIdle=true', + ) + const elapsed = Date.now() - t0 + + expect(res.status).toBe(200) + // Members = {f} (already extracted) -> immediate; the 2000ms idle threshold + // would otherwise dominate on the old idle-only path. + expect(elapsed).toBeLessThan(1000) + expect(getCssSpy).toHaveBeenCalledWith(1, true) + + coordinator.close() + }) + + // T3: a bucket member that never arrives -> the per-bucket wait fails open + // after maxWaitMs (serves whatever exists) instead of hanging the build. + it('fails open and serves partial CSS when a bucket member never extracts', async () => { + codeExtractSpy.mockReturnValue(extractResult('devup-ui-1.css')) + getCssSpy.mockReturnValue('partial-css') + const warnSpy = spyOn(console, 'warn').mockReturnValue(undefined) + // m1 is a member of the bucket but is never POSTed to /extract. + const canonicalMap = { 'src/m1.tsx': 'src/bucket.tsx' } + const { coordinator, port } = await startAndGetPort( + makeOptions({ canonicalMap, idleThresholdMs: 100, maxWaitMs: 150 }), + ) + // Extract only the bucket root; src/m1.tsx stays missing forever. + await extract(port, 'src/bucket.tsx') + + const t0 = Date.now() + const res = await httpRequest( + port, + 'GET', + '/css?fileNum=1&importMainCss=true&waitForIdle=true', + ) + const elapsed = Date.now() - t0 + + // Resolves via the hard timeout (fail open) rather than hanging. + expect(res.status).toBe(200) + expect(res.body).toBe('partial-css') + expect(elapsed).toBeGreaterThanOrEqual(150) + expect(warnSpy).toHaveBeenCalled() + expect(getCssSpy).toHaveBeenCalledWith(1, true) + + warnSpy.mockRestore() + coordinator.close() + }) + + // T4: base css resolves DETERMINISTICALLY once every route-reachable runtime + // file (expectedBaseFiles) is extracted — NOT after an idle gap. Proven by a + // large idleThresholdMs that would dominate if the idle path were taken. + it('serves base css as soon as all expectedBaseFiles are extracted (no idle wait)', async () => { + codeExtractSpy.mockReturnValue(extractResult('devup-ui.css')) + getCssSpy.mockReturnValue('base-css') + const { coordinator, port } = await startAndGetPort( + makeOptions({ + expectedBaseFiles: ['src/a.tsx', 'src/b.tsx'], + idleThresholdMs: 5000, + }), + ) + await extract(port, 'src/a.tsx') + await extract(port, 'src/b.tsx') + + const t0 = Date.now() + const res = await httpRequest( + port, + 'GET', + '/css?importMainCss=false&waitForIdle=true', + ) + const elapsed = Date.now() - t0 + + expect(res.status).toBe(200) + expect(res.body).toBe('base-css') + // Both expected files extracted -> immediate; the 5000ms idle threshold is + // never consulted on the deterministic path. + expect(elapsed).toBeLessThan(1000) + + coordinator.close() + }) + + // T5: the deterministic wait blocks base css until a still-missing + // expectedBaseFile arrives — even after the idle threshold elapses with + // nothing in flight. This is exactly the gap-between-waves case the old idle + // heuristic resolved too early (dropping late files' styles). + it('blocks base css until a missing expectedBaseFile is extracted', async () => { + codeExtractSpy.mockReturnValue(extractResult('devup-ui.css')) + getCssSpy.mockReturnValue('base-css') + const { coordinator, port } = await startAndGetPort( + makeOptions({ + expectedBaseFiles: ['src/a.tsx', 'src/late.tsx'], + idleThresholdMs: 50, + }), + ) + await extract(port, 'src/a.tsx') + + let resolved = false + const cssPromise = httpRequest( + port, + 'GET', + '/css?importMainCss=false&waitForIdle=true', + ).then((r) => { + resolved = true + return r + }) + // Idle threshold (50ms) elapses and nothing is in flight, yet src/late.tsx + // is still missing -> must NOT resolve. + await new Promise((r) => setTimeout(r, 300)) + expect(resolved).toBe(false) + + await extract(port, 'src/late.tsx') + const res = await cssPromise + expect(res.status).toBe(200) + expect(res.body).toBe('base-css') + + coordinator.close() + }) + + // T6: a phantom expectedBaseFile that never extracts fails open via the + // dormant maxWaitMs backstop instead of hanging the build forever. + it('fails open on a phantom expectedBaseFile via maxWaitMs', async () => { + codeExtractSpy.mockReturnValue(extractResult('devup-ui.css')) + getCssSpy.mockReturnValue('base-css') + const { coordinator, port } = await startAndGetPort( + makeOptions({ + expectedBaseFiles: ['src/a.tsx', 'src/phantom.tsx'], + maxWaitMs: 150, + }), + ) + await extract(port, 'src/a.tsx') + + const t0 = Date.now() + const res = await httpRequest( + port, + 'GET', + '/css?importMainCss=false&waitForIdle=true', + ) + const elapsed = Date.now() - t0 + + expect(res.status).toBe(200) + expect(res.body).toBe('base-css') + expect(elapsed).toBeGreaterThanOrEqual(150) + + coordinator.close() + }) +}) diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 7b8f55c5..d391fd5f 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 { @@ -485,6 +486,8 @@ describe('DevupUINextPlugin', () => { 'styled-components': 'styled', }, coordinatorPortFile: join('df', 'coordinator.port'), + canonicalMap: expect.any(Object), + expectedBaseFiles: expect.any(Array), }) }) it('should create theme.d.ts file', async () => { @@ -671,6 +674,8 @@ describe('DevupUINextPlugin', () => { 'styled-components': 'styled', }, coordinatorPortFile: join('df', 'coordinator.port'), + canonicalMap: expect.any(Object), + expectedBaseFiles: expect.any(Array), }) // Verify initial CSS file is written @@ -690,5 +695,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/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 4719e511..ca664d4d 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -20,6 +20,35 @@ export interface CoordinatorOptions { fileMapFile: string importAliases: Record coordinatorPortFile: string + /** + * Canonical (single-importer collapse) map: cwd-relative POSIX source path -> + * its canonical bucket path (or the `@global` sentinel). Used to wait for ALL + * members of a shared CSS bucket before serving it, instead of guessing + * completion from idle time. Empty when collapse is disabled. + */ + canonicalMap: Record + /** + * Route-reachable runtime source files (cwd-relative POSIX), i.e. exactly the + * files the bundler will compile and POST to `/extract`. Used to resolve the + * base-css `/css` wait DETERMINISTICALLY — block until every one of these has + * been extracted, instead of guessing completion from an idle gap. Comes from + * `computeFileRoutes` (already type-filtered and orphan-free), so it can never + * contain a phantom file the bundler skips. Empty when no routes are detected + * (e.g. pages-router) or the best-effort pre-pass failed, in which case the + * legacy idle heuristic below is the fallback. + */ + expectedBaseFiles?: string[] + /** + * Idle threshold (ms) for the base-css `/css` wait. Defaults to 2500. + * FALLBACK ONLY — used when `expectedBaseFiles` is empty (no deterministic + * signal available). Exposed for tests; the plugin omits it. + */ + idleThresholdMs?: number + /** + * Hard timeout (ms) for both the idle and per-bucket waits before failing + * open. Defaults to 60000. Exposed for tests; the plugin omits it. + */ + maxWaitMs?: number } // Latest-Wins Coalescing Serializer. @@ -116,29 +145,131 @@ let activeExtractions = 0 let totalExtractions = 0 let lastCompletedAt = 0 let pendingExtractStarts = 0 -const IDLE_THRESHOLD_MS = 2500 -const MAX_WAIT_MS = 60_000 +let idleThresholdMs = 2500 +let maxWaitMs = 60_000 + +function baseFilesComplete(): boolean { + // Deterministic: the base sheet is complete once every route-reachable runtime + // file has been extracted. Each `/extract` (success OR failure) adds its file + // to `extractedFiles`, and `expectedBaseFiles` is phantom-free, so this is a + // device-independent superset check — no idle gap to guess. + if (expectedBaseFiles.size === 0) return false + for (const file of expectedBaseFiles) { + if (!extractedFiles.has(file)) return false + } + return true +} -function waitForIdle(): Promise { +function waitForBase(): Promise { const start = Date.now() return new Promise((resolve) => { const check = () => { const now = Date.now() - if (now - start > MAX_WAIT_MS) { - // Hard timeout — give up and return whatever we have. + if (now - start > maxWaitMs) { + // Last-resort backstop (see waitForBucket). Never fires on a healthy + // build: either every expected file extracts, or the idle fallback + // resolves first. + resolve() + return + } + // Primary, deterministic path. + if (baseFilesComplete()) { resolve() return } + // Fallback ONLY when no deterministic signal exists (no routes detected / + // pre-pass failed -> expectedBaseFiles empty): the legacy idle heuristic. if ( + expectedBaseFiles.size === 0 && totalExtractions > 0 && activeExtractions === 0 && pendingExtractStarts === 0 && - now - lastCompletedAt >= IDLE_THRESHOLD_MS + now - lastCompletedAt >= idleThresholdMs ) { resolve() return } - setTimeout(check, 50) + setTimeout(check, 25) + } + check() + }) +} + +// Per-bucket completion tracking (deterministic replacement for waitForIdle on +// collapsed chunks). +// +// Single-importer collapse merges several source files into ONE shared CSS +// chunk (a "bucket"). That chunk is only complete once EVERY member file has +// been extracted. Turbopack, however, may request the chunk as soon as ONE +// member's import resolves. The old global idle heuristic guessed completion +// and dropped late members' atoms when extraction "waves" exceeded the idle +// threshold (flaky CI rendering). Instead we wait for the bucket's KNOWN +// members (from the canonical map) — no guessing, no extra extraction. +const extractedFiles = new Set() +const fileNumToBucket = new Map() +let bucketToMembers = new Map>() +let canonicalMapRef: Record = {} +// Route-reachable runtime files the base sheet must wait for (cwd-relative +// POSIX). When populated, base-css completion is deterministic; empty falls back +// to the idle heuristic. See CoordinatorOptions.expectedBaseFiles. +let expectedBaseFiles = new Set() + +function buildBucketToMembers( + canonicalMap: Record, +): Map> { + const map = new Map>() + for (const [member, bucket] of Object.entries(canonicalMap)) { + // `@global` files contribute to the base sheet, not a numbered bucket. + if (bucket === '@global') continue + let members = map.get(bucket) + if (!members) { + // The bucket root is itself a member of its own chunk. + members = new Set([bucket]) + map.set(bucket, members) + } + members.add(member) + } + return map +} + +function waitForBucket(bucket: string): Promise { + const members = bucketToMembers.get(bucket) ?? new Set([bucket]) + const start = Date.now() + return new Promise((resolve) => { + const check = () => { + let allExtracted = true + for (const member of members) { + if (!extractedFiles.has(member)) { + allExtracted = false + break + } + } + if (allExtracted) { + resolve() + return + } + if (Date.now() - start > maxWaitMs) { + // Last-resort backstop only — NOT the primary completion mechanism. + // + // A bucket's member set comes from the import graph (`canonicalMap`), + // which now excludes type-only edges (`import type` / `export type`): + // those are erased by the bundler and never POST /extract, so before + // the fix they were phantom members that hung this wait until the + // wall clock expired. With runtime-only members, every member of a + // REQUESTED bucket is reachable and therefore extracted, so the loop + // above resolves deterministically and this timer never fires on a + // healthy build — its duration no longer affects correctness. It stays + // purely to fail open (serve partial CSS) on a pathological graph + // mismatch instead of hanging the build forever. Turbopack exposes no + // compilation-complete hook, so a timer is the only available backstop. + const missing = [...members].filter((m) => !extractedFiles.has(m)) + console.warn( + `[devup-ui] coordinator: bucket "${bucket}" not complete after ${maxWaitMs}ms; serving partial CSS (missing: ${missing.join(', ')})`, + ) + resolve() + return + } + setTimeout(check, 25) } check() }) @@ -158,6 +289,14 @@ export function startCoordinator(options: CoordinatorOptions): { coordinatorPortFile, } = options + idleThresholdMs = options.idleThresholdMs ?? 2500 + maxWaitMs = options.maxWaitMs ?? 60_000 + canonicalMapRef = options.canonicalMap + bucketToMembers = buildBucketToMembers(options.canonicalMap) + expectedBaseFiles = new Set(options.expectedBaseFiles ?? []) + extractedFiles.clear() + fileNumToBucket.clear() + server = createServer(async (req, res) => { const url = new URL(req.url ?? '/', `http://${req.headers.host}`) @@ -174,7 +313,16 @@ export function startCoordinator(options: CoordinatorOptions): { const fileNum = fileNumParam != null ? parseInt(fileNumParam) : undefined if (shouldWait) { - await waitForIdle() + if (fileNum != null && fileNumToBucket.has(fileNum)) { + // Deterministic: block until every member of this collapsed bucket + // has been extracted, then serve the complete chunk. + await waitForBucket(fileNumToBucket.get(fileNum)!) + } else { + // Base css (no fileNum) or a bucket no member has reported yet: + // wait for the deterministic route-reachable file set (idle fallback + // only when that set is unavailable). + await waitForBase() + } } res.writeHead(200, { 'Content-Type': 'text/css' }) @@ -190,6 +338,7 @@ export function startCoordinator(options: CoordinatorOptions): { // more extractions are imminent. pendingExtractStarts++ let promotedToActive = false + let extractedFilename: string | undefined try { const body = JSON.parse(await readBody(req)) activeExtractions++ @@ -200,6 +349,7 @@ export function startCoordinator(options: CoordinatorOptions): { code: string resourcePath: string } + extractedFilename = filename let relCssDir = relative(dirname(resourcePath), cssDir).replaceAll( '\\', @@ -243,6 +393,11 @@ export function startCoordinator(options: CoordinatorOptions): { if (result.cssFile) { const fileNum = getFileNumByFilename(result.cssFile) + if (fileNum != null) { + // Record this bucket's fileNum -> canonical bucket path so /css can + // wait for the bucket's members before serving it. + fileNumToBucket.set(fileNum, canonicalMapRef[filename] ?? filename) + } promises.push( safeWrite( join(cssDir, basename(result.cssFile)), @@ -295,6 +450,9 @@ export function startCoordinator(options: CoordinatorOptions): { // pending slot is still ours to release. pendingExtractStarts-- } + // Mark the file processed (success OR failure) so per-bucket waiters + // never hang on a file that errored — fail open, like the idle path. + if (extractedFilename != null) extractedFiles.add(extractedFilename) totalExtractions++ lastCompletedAt = Date.now() } @@ -344,6 +502,14 @@ export const resetCoordinator = () => { activeExtractions = 0 totalExtractions = 0 lastCompletedAt = 0 + pendingExtractStarts = 0 + idleThresholdMs = 2500 + maxWaitMs = 60_000 + extractedFiles.clear() + fileNumToBucket.clear() + bucketToMembers = new Map() + canonicalMapRef = {} + expectedBaseFiles = new Set() writeChain.clear() latestContent.clear() } 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..00c9cf81 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -8,10 +8,13 @@ import { import { join, relative, resolve } from 'node:path' import { + buildCanonicalMap, + computeFileRoutes, createNodeModulesExcludeRegex, createThemeInterfaceArgs, loadDevupConfigSync, mergeImportAliases, + planAtomHoist, } from '@devup-ui/plugin-utils' import { exportClassMap, @@ -20,10 +23,13 @@ import { getCss, getDefaultTheme, getThemeInterface, + importCanonicalMap, importClassMap, importFileMap, + importFileRoutes, importSheet, registerTheme, + setAtomHoist, setPrefix, } from '@devup-ui/wasm' import { @@ -64,6 +70,7 @@ export function DevupUI( devupFile = 'devup.json', include = [], prefix, + atomHoist, importAliases: userImportAliases, } = options @@ -76,6 +83,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 +123,69 @@ 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 + // Hoisted out of the try so the coordinator can receive it for per-bucket + // completion. Stays `{}` if the best-effort pre-pass fails. + let canonicalMap: Record = {} + // Route-reachable runtime files (cwd-relative POSIX) — the deterministic + // base-css completion signal handed to the coordinator. Stays `[]` (idle + // fallback) when no routes are detected or the pre-pass fails. + let expectedBaseFiles: string[] = [] + 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 + canonicalMap = buildCanonicalMap({ + srcDir, + tsconfigPath, + cwd, + hoistV, + }) + importCanonicalMap(canonicalMap) + writeFileSync(canonicalMapFile, JSON.stringify(canonicalMap)) + + // Route reachability drives BOTH the deterministic base-css wait and (in + // atom mode) the hoist plan, so compute it once and share. + const fileRoutes = computeFileRoutes({ srcDir, tsconfigPath, cwd }) + expectedBaseFiles = Object.keys(fileRoutes) + + if (atomMode) { + // Fold per-file route reach onto the canonical bucket so the keys match + // the engine's property bucket keys (canonical(filename)). + const plan = planAtomHoist(canonicalMap, fileRoutes, atomHoist) + if (plan) { + importFileRoutes(plan.reachByBucket) + setAtomHoist(plan.threshold) + } else { + console.info( + '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op.', + ) + } + } + } 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)) @@ -136,6 +207,8 @@ export function DevupUI( fileMapFile, importAliases: importAliases as unknown as Record, coordinatorPortFile, + canonicalMap, + expectedBaseFiles, }) // Cleanup on exit 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..05e0f7e0 --- /dev/null +++ b/packages/plugin-utils/src/import-graph.test.ts @@ -0,0 +1,950 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test' + +import { + __setOxcParserForTest, + buildCanonicalMap, + computeFileReach, + computeFileRoutes, + planAtomHoist, + runImportGraphCli, +} 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', + }) + }) + + // Type-only imports/exports are erased by the bundler and produce NO runtime + // module. Counting them as static graph edges merged phantom members into a + // bucket the bundler never compiles, which forced the next-plugin coordinator + // to wait out its wall-clock fail-open. They must NOT become graph edges. + it('should not treat a named `import type` target as a bucket member', () => { + writeFixture( + 'src/a.tsx', + "import type { M } from './m'\nexport const a = 1\n", + ) + writeFixture('src/m.tsx', 'export type M = number\nexport const m = 1\n') + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({}) + }) + + it('should not treat a default `import type Foo` target as a member', () => { + writeFixture( + 'src/a.tsx', + "import type Foo from './foo'\nexport const a = 1\n", + ) + writeFixture('src/foo.tsx', 'const foo = 1\nexport default foo\n') + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({}) + }) + + it('should not treat a namespace `import type * as NS` target as a member', () => { + writeFixture( + 'src/a.tsx', + "import type * as NS from './ns'\nexport const a = 1\n", + ) + writeFixture('src/ns.tsx', 'export const ns = 1\n') + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({}) + }) + + it('should not treat an `export type ... from` target as a member', () => { + writeFixture( + 'src/b.tsx', + "export type { M } from './m'\nexport const b = 1\n", + ) + writeFixture('src/m.tsx', 'export type M = number\nexport const m = 1\n') + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({}) + }) + + it('should keep an inline `import { type T, val }` target (module still imported)', () => { + writeFixture( + 'src/a.tsx', + "import { type T, val } from './b'\nexport const a: T = val\n", + ) + writeFixture('src/b.tsx', 'export type T = number\nexport const val = 1\n') + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ + 'src/b.tsx': 'src/a.tsx', + }) + }) + + it('should collapse a shared dep into its only VALUE importer when others import it type-only', () => { + // `a` imports `shared` at runtime; `b` only `import type`s it. Erasing the + // type edge leaves `shared` with a single real importer -> it collapses into + // `a`, which is both correct (b never loads it at runtime) and tighter CSS. + writeFixture('src/a.tsx', "import './shared'\n") + writeFixture( + 'src/b.tsx', + "import type { S } from './shared'\nexport const b = 1\n", + ) + writeFixture( + 'src/shared.tsx', + 'export type S = number\nexport const s = 1\n', + ) + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ + 'src/shared.tsx': 'src/a.tsx', + }) + }) + + it('treats a value `export { x } from` as a static import (collapses)', () => { + writeFixture('src/a.tsx', "export { x } from './b'\n") + writeFixture('src/b.tsx', 'export const x = 1\n') + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ + 'src/b.tsx': 'src/a.tsx', + }) + }) + + it('parses imports while stripping comments and string escapes', () => { + writeFixture( + 'src/a.tsx', + [ + 'const s = "a\\\\b\\tc" // line comment with import \'./not-real\'', + '/* block comment', + ' spanning lines with import "./also-not" */', + "import './b'", + 'export const a = 1', + ].join('\n'), + ) + writeFixture('src/b.tsx', 'export const b = 1\n') + + // Imports inside comments/strings are ignored; only the real one collapses. + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ + 'src/b.tsx': 'src/a.tsx', + }) + }) + + it('returns no aliases when tsconfig has no compilerOptions', () => { + writeFixture('tsconfig.json', '{}') + writeFixture('src/a.tsx', "import '@/foo'\n") + writeFixture('src/foo.tsx', 'export const foo = 1\n') + + expect( + buildCanonicalMap({ + cwd, + srcDir, + tsconfigPath: join(cwd, 'tsconfig.json'), + }), + ).toEqual({}) + }) + + it('returns no aliases when tsconfig compilerOptions has no paths', () => { + writeFixture( + 'tsconfig.json', + JSON.stringify({ compilerOptions: { baseUrl: '.' } }), + ) + writeFixture('src/a.tsx', "import '@/foo'\n") + writeFixture('src/foo.tsx', 'export const foo = 1\n') + + expect( + buildCanonicalMap({ + cwd, + srcDir, + tsconfigPath: join(cwd, 'tsconfig.json'), + }), + ).toEqual({}) + }) + + it('ignores a malformed tsconfig (JSON parse error)', () => { + writeFixture('tsconfig.json', '{ this is not json') + writeFixture('src/a.tsx', "import './b'\n") + writeFixture('src/b.tsx', 'export const b = 1\n') + + expect( + buildCanonicalMap({ + cwd, + srcDir, + tsconfigPath: join(cwd, 'tsconfig.json'), + }), + ).toEqual({ 'src/b.tsx': 'src/a.tsx' }) + }) + + it('prefers the longest-prefix alias when multiple tsconfig paths overlap', () => { + // Two aliases -> the prefix-length sort comparator runs; the longer prefix + // (`@components/`) must win over the broader `@/`. + writeFixture( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { + '@/*': ['src/*'], + '@components/*': ['src/components/*'], + }, + }, + }), + ) + writeFixture('src/a.tsx', "import '@components/x'\n") + writeFixture('src/components/x.tsx', 'export const x = 1\n') + + expect( + buildCanonicalMap({ + cwd, + srcDir, + tsconfigPath: join(cwd, 'tsconfig.json'), + }), + ).toEqual({ 'src/components/x.tsx': 'src/a.tsx' }) + }) + + it('handles a root-absolute import specifier', () => { + writeFixture('src/a.tsx', "import '/abs/thing'\n") + + // The `/`-prefixed branch runs; it resolves outside srcDir -> unresolved. + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({}) + }) + + it('resolves an import that includes an explicit .tsx extension', () => { + writeFixture('src/a.tsx', "import './b.tsx'\n") + writeFixture('src/b.tsx', 'export const b = 1\n') + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ + 'src/b.tsx': 'src/a.tsx', + }) + }) + + it('resolves a directory import to its index file', () => { + writeFixture('src/a.tsx', "import './dir'\n") + writeFixture('src/dir/index.tsx', 'export const d = 1\n') + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ + 'src/dir/index.tsx': 'src/a.tsx', + }) + }) + + it('leaves an import that resolves to no file unresolved', () => { + writeFixture('src/a.tsx', "import './ghost'\n") + + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({}) + }) + + it('keys the map by absolute POSIX path when keyBy is "absolute"', () => { + writeFixture('src/a.tsx', "import './b'\n") + writeFixture('src/b.tsx', 'export const b = 1\n') + + const map = buildCanonicalMap({ cwd, srcDir, keyBy: 'absolute' }) + const [key, value] = Object.entries(map)[0] + + // Absolute POSIX (backslashes normalized), not cwd-relative. + expect(key).not.toContain('\\') + expect(key.endsWith('/src/b.tsx')).toBe(true) + expect(value.endsWith('/src/a.tsx')).toBe(true) + expect(key).not.toBe('src/b.tsx') + }) +}) + +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() + }) +}) + +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() + }) +}) + +// The oxc AST path is the fast parser used when `oxc-parser` is installed in +// the host project. It is absent in this repo, so we inject a fake parser to +// exercise the AST walk (module state is shared across test files, so this is +// reset after each test back to the regex fallback). +describe('oxc AST parsing path', () => { + let tempRoot: string + let cwd: string + let srcDir: string + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'devup-ui-oxc-')) + cwd = join(tempRoot, 'project') + srcDir = join(cwd, 'src') + mkdirSync(srcDir, { recursive: true }) + }) + + afterEach(() => { + __setOxcParserForTest(undefined) + 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('collects every import/export node kind from the AST (type-only excluded)', () => { + const circular: Record = { type: 'SelfRef' } + circular.self = circular // self-reference -> exercises the `seen` guard + const richProgram = { + type: 'Program', + body: [ + // value import -> static edge (getStringLiteralValue via `.value`) + { + type: 'ImportDeclaration', + importKind: 'value', + source: { value: './val' }, + }, + // `import type` -> skipped (importKind 'type') + { + type: 'ImportDeclaration', + importKind: 'type', + source: { value: './t1' }, + }, + // value re-export -> static edge + { + type: 'ExportNamedDeclaration', + exportKind: 'value', + source: { value: './exp' }, + }, + // `export type` -> skipped (exportKind 'type') + { + type: 'ExportNamedDeclaration', + exportKind: 'type', + source: { value: './t2' }, + }, + // export-all -> static edge + { type: 'ExportAllDeclaration', source: { value: './all' } }, + // dynamic import expression with `.source` + { type: 'ImportExpression', source: { value: './dyn1' } }, + // dynamic import expression falling back to `.argument` + { type: 'ImportExpression', argument: { value: './dyn2' } }, + // import() call via callee.type === 'Import', specifier via `.raw` + { + type: 'CallExpression', + callee: { type: 'Import' }, + arguments: [{ raw: "'./dyn3'" }], + }, + // import() call via callee.name === 'import' + { + type: 'CallExpression', + callee: { name: 'import' }, + arguments: [{ value: './dyn4' }], + }, + // import() with non-array arguments -> first arg undefined -> no push + { + type: 'CallExpression', + callee: { type: 'Import' }, + arguments: 'not-an-array', + }, + // non-import call (isImportCallee false via name) -> falls through + { type: 'CallExpression', callee: { name: 'other' }, arguments: [] }, + // non-record callee -> isImportCallee returns false + { type: 'CallExpression', callee: null, arguments: [] }, + // source literal with neither `.value` nor `.raw` -> no push + { + type: 'ImportDeclaration', + importKind: 'value', + source: { kind: 'no-literal' }, + }, + circular, + 'primitive-child', + 7, + null, + ] as unknown[], + } + __setOxcParserForTest({ + parseSync: (filename: string) => + filename.endsWith('a.tsx') + ? { program: richProgram } + : { program: { type: 'Program', body: [] as unknown[] } }, + }) + + writeFixture('src/a.tsx', 'parsed by the fake oxc parser, content ignored') + writeFixture( + 'src/val.tsx', + 'parsed by the fake oxc parser, content ignored', + ) + + // Proof the AST path ran: `a` statically imports `./val` -> val collapses + // into a. The regex fallback would parse the literal content -> no imports. + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ + 'src/val.tsx': 'src/a.tsx', + }) + }) + + it('falls back to the regex scan when the oxc parser throws', () => { + __setOxcParserForTest({ + parseSync: () => { + throw new Error('boom') + }, + }) + + writeFixture('src/a.tsx', "import './b'\n") + writeFixture('src/b.tsx', 'export const b = 1\n') + + // parseSync throws -> parseImportsWithOxc returns undefined -> scanImports. + expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ + 'src/b.tsx': 'src/a.tsx', + }) + }) +}) + +describe('runImportGraphCli', () => { + function makeProject(): { root: string; cwd: string } { + const root = mkdtempSync(join(tmpdir(), 'devup-ui-cli-')) + const cwd = join(root, 'project') + const srcDir = join(cwd, 'src') + mkdirSync(srcDir, { recursive: true }) + writeFileSync(join(srcDir, 'a.tsx'), "import './b'\n") + writeFileSync(join(srcDir, 'b.tsx'), 'export const b = 1\n') + return { root, cwd } + } + + it('prints usage and exits when the srcDir arg is missing', () => { + const errorSpy = spyOn(console, 'error').mockReturnValue(undefined) + const exitSpy = spyOn(process, 'exit').mockImplementation( + (() => undefined) as never, + ) + + runImportGraphCli([]) + + expect(errorSpy).toHaveBeenCalled() + expect(exitSpy).toHaveBeenCalledWith(1) + + errorSpy.mockRestore() + exitSpy.mockRestore() + }) + + it('prints the canonical map JSON to stdout when no outFile is given', () => { + const { root, cwd } = makeProject() + const infoSpy = spyOn(console, 'info').mockReturnValue(undefined) + + runImportGraphCli(['src', cwd]) + + const printed = (infoSpy.mock.calls[0] as [string])[0] + expect(printed).toContain('src/b.tsx') + + infoSpy.mockRestore() + rmSync(root, { recursive: true, force: true }) + }) + + it('writes the canonical map JSON to outFile (with a tsconfig arg)', () => { + const { root, cwd } = makeProject() + writeFileSync( + join(cwd, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { baseUrl: '.' } }), + ) + + runImportGraphCli(['src', cwd, 'tsconfig.json', 'out.json']) + + const written = JSON.parse(readFileSync(join(cwd, 'out.json'), 'utf-8')) + expect(written).toEqual({ 'src/b.tsx': 'src/a.tsx' }) + + rmSync(root, { recursive: true, force: true }) + }) + + it('defaults cwd to process.cwd() when only srcDir is given', () => { + const infoSpy = spyOn(console, 'info').mockReturnValue(undefined) + + // A non-existent srcDir -> empty map -> prints "{}" without touching files. + runImportGraphCli(['__devup_nonexistent_src__']) + + expect(infoSpy).toHaveBeenCalledWith('{}') + + infoSpy.mockRestore() + }) +}) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts new file mode 100644 index 00000000..da191a45 --- /dev/null +++ b/packages/plugin-utils/src/import-graph.ts @@ -0,0 +1,954 @@ +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 +} + +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, + 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 +} + +/** + * Enumerate every extractable source file under `srcDir`, sorted by POSIX path + * (deterministic order). Skips `node_modules`, test/spec files, and non-JS/TS + * files — the SAME filter `buildCanonicalMap` uses internally, so a plugin can + * pre-warm the extractor over exactly the file set the canonical map was built + * from. Returns absolute paths. + */ +export 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 +} + +/** + * @internal test-only: force the cached oxc parser. oxc-parser is an optional + * peer that is absent in this repo, so the AST path is otherwise unreachable + * from tests; module state is shared across test files (no per-file reset), so + * `mock.module` cannot toggle it deterministically. Pass `undefined` to clear + * the cache and re-detect (back to the regex fallback). + */ +export function __setOxcParserForTest( + parser: OxcParser | false | undefined, +): void { + cachedOxcParser = parser +} + +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' + ) { + // `import type`/`export type ... from` carry importKind/exportKind 'type'. + // They are erased at build time (no runtime module), so they must NOT + // become static graph edges — see the regex fallback in `scanImports`. + if (node.importKind !== 'type' && node.exportKind !== 'type') { + 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) + // The leading `(type\s+)?` is CAPTURED (not skipped) so we can drop type-only + // statements: `import type ... from` / `export type ... from` are erased by + // the bundler and produce NO runtime module — counting them as static graph + // edges merges phantom members into a bucket that the bundler never compiles, + // which is exactly what forced the coordinator's wall-clock fail-open to fire. + // Inline specifier types (`import { type A, b } from`) keep importing the + // module for `b`, so the leading group stays undefined and they are kept. + const staticImportRegex = + /\bimport\s+(type\s+)?(?:[^'"`]*?\s+from\s*)?(['"])([^'"]+)\2/gm + const exportFromRegex = + /\bexport\s+(type\s+)?(?:\*[^'"`]*?|\{[^}]*\})\s+from\s*(['"])([^'"]+)\2/gm + const dynamicImportRegex = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/gm + + for (const match of code.matchAll(staticImportRegex)) { + if (match[1]) continue + imports.push({ kind: 'static', specifier: match[3] }) + } + for (const match of code.matchAll(exportFromRegex)) { + if (match[1]) continue + imports.push({ kind: 'static', specifier: match[3] }) + } + 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 +} + +/** @internal CLI entry, extracted from the `import.meta.main` guard so it is + * reachable from tests (the guard itself never runs under the test runner). */ +export function runImportGraphCli(argv: string[]): void { + const [srcDirArg, cwdArg = process.cwd(), tsconfigPathArg, outFileArg] = argv + + if (!srcDirArg) { + console.error( + 'Usage: bun packages/next-plugin/src/import-graph.ts [cwd] [tsconfigPath] [outFile]', + ) + process.exit(1) + return + } + + 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()) + } +} + +if (import.meta.main) runImportGraphCli(process.argv.slice(2)) diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 03a24b63..f008a3da 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -1,3 +1,14 @@ +export { + type AtomHoistPlan, + buildCanonicalMap, + type BuildCanonicalMapOptions, + computeFileReach, + type ComputeFileReachOptions, + computeFileRoutes, + type ComputeFileRoutesOptions, + listSourceFiles, + planAtomHoist, +} from './import-graph' export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' export { createNodeModulesExcludeRegex, diff --git a/packages/plugin-utils/src/shared.test.ts b/packages/plugin-utils/src/shared.test.ts new file mode 100644 index 00000000..0adebfb8 --- /dev/null +++ b/packages/plugin-utils/src/shared.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'bun:test' + +import { getFileNumByFilename } from './shared' + +describe('getFileNumByFilename', () => { + it('parses the file number from a standard filename', () => { + expect(getFileNumByFilename('devup-ui-5.css')).toBe(5) + expect(getFileNumByFilename('devup-ui-0.css')).toBe(0) + expect(getFileNumByFilename('devup-ui-123.css')).toBe(123) + }) + + it('returns null for the base devup-ui.css', () => { + expect(getFileNumByFilename('devup-ui.css')).toBeNull() + expect(getFileNumByFilename('df/devup-ui/devup-ui.css')).toBeNull() + }) + + it('parses the Turbopack query-parameter format', () => { + expect(getFileNumByFilename('devup-ui.css?fileNum=79')).toBe(79) + }) + + it('strips trailing queries (e.g. Next assetPrefix ?dpl=...) before matching', () => { + expect(getFileNumByFilename('devup-ui.css?dpl=DEPLOYMENT_ID')).toBeNull() + expect(getFileNumByFilename('devup-ui-7.css?dpl=DEPLOYMENT_ID')).toBe(7) + }) + + it('returns null for unrelated css files', () => { + expect(getFileNumByFilename('styles.css')).toBeNull() + expect(getFileNumByFilename('foo/bar.css')).toBeNull() + }) + + // Regression: the filename must be parsed from the BASENAME, not by splitting + // the whole path on "devup-ui-". A project path/dir that itself contains + // "devup-ui-" (e.g. a folder named `next-devup-ui-collapse`, or the cssDir + // `.next/cache/devup-ui_` sitting under such a folder) previously caused + // split('devup-ui-')[1] to grab the WRONG segment -> NaN -> null, so the + // css-loader served the base sheet instead of the bucket. Webpack uses the + // `devup-ui-N.css` filename form, so this silently dropped collapsed-bucket + // atoms; Turbopack was immune because it uses the `?fileNum=` query form. + it('parses the basename even when an ancestor directory contains "devup-ui-"', () => { + expect( + getFileNumByFilename( + 'C:\\repo\\next-devup-ui-collapse\\.next\\cache\\devup-ui_DML7Ct3\\devup-ui-0.css', + ), + ).toBe(0) + expect( + getFileNumByFilename( + '/repo/my-devup-ui-app/.next/cache/devup-ui_abc/devup-ui-12.css', + ), + ).toBe(12) + // base file inside a "devup-ui-" ancestor must still resolve to null + expect( + getFileNumByFilename( + '/repo/next-devup-ui-collapse/df/devup-ui/devup-ui.css', + ), + ).toBeNull() + }) +}) diff --git a/packages/plugin-utils/src/shared.ts b/packages/plugin-utils/src/shared.ts index d02faa20..61411ad7 100644 --- a/packages/plugin-utils/src/shared.ts +++ b/packages/plugin-utils/src/shared.ts @@ -25,11 +25,20 @@ export function getFileNumByFilename(filename: string): number | null { // append arbitrary queries (e.g. `?dpl=...`) when assetPrefix is set, and // those must not interfere with base CSS detection. const pathOnly = filename.split('?')[0] - if (pathOnly.endsWith('devup-ui.css')) return null - const numericPart = pathOnly.split('devup-ui-')[1]?.split('.')[0] - if (numericPart === undefined) return null - const num = parseInt(numericPart, 10) + // Match against the BASENAME only. Splitting the whole path on "devup-ui-" + // breaks when an ancestor directory itself contains "devup-ui-" (e.g. a + // folder named `next-devup-ui-collapse`, or a project path containing the + // package name): `split('devup-ui-')[1]` then grabs the wrong segment and + // yields NaN -> null, so the css-loader silently serves the base sheet + // instead of the per-file bucket (dropping collapsed-bucket atoms in + // webpack, which uses the `devup-ui-N.css` filename form). + const base = pathOnly.split(/[\\/]/).pop() ?? pathOnly + if (base === 'devup-ui.css') return null + + const match = base.match(/^devup-ui-(\d+)\.css$/) + if (!match) return null + const num = parseInt(match[1], 10) return Number.isNaN(num) ? null : num } diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 51b2782b..1a6d8e17 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,230 @@ 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) + }) + + 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 f448cc1f..f7afea1d 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -1,20 +1,27 @@ 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, + planAtomHoist, } from '@devup-ui/plugin-utils' import { codeExtract, getCss, getDefaultTheme, getThemeInterface, + importCanonicalMap, + importFileRoutes, registerTheme, + setAtomHoist, setDebug, setPrefix, } from '@devup-ui/wasm' @@ -30,6 +37,20 @@ 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. + * + * 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 /** * Import aliases for redirecting imports from other CSS-in-JS libraries * Merged with defaults: @emotion/styled, styled-components, @vanilla-extract/css @@ -86,6 +107,7 @@ export const DevupUI = ({ debug = false, singleCss = false, prefix, + atomHoist, importAliases: userImportAliases, }: Partial = {}): RsbuildPlugin => { const importAliases = mergeImportAliases(userImportAliases) @@ -110,11 +132,61 @@ 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 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). + } + } + 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); the injected splitChunks + // cacheGroup (see modifyRsbuildConfig) emits the base once. + return getCss(getFileNumByFilename(basename(resourcePath)), false) + }, ) api.modifyRsbuildConfig((config) => { @@ -127,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 }) @@ -137,6 +243,23 @@ 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 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 = '', @@ -144,13 +267,13 @@ export const DevupUI = ({ cssFile, updatedBaseStyle, } = codeExtract( - resourcePath, + extractName, code, libPackage, - cssDir, + extractCssDir, 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..710c3a2b 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -3,19 +3,25 @@ import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { + buildCanonicalMap, + computeFileReach, createNodeModulesExcludeRegex, createThemeInterfaceArgs, getFileNumByFilename, type ImportAliases, loadDevupConfig, mergeImportAliases, + planAtomHoist, } from '@devup-ui/plugin-utils' import { codeExtract, getCss, getDefaultTheme, getThemeInterface, + importCanonicalMap, + importFileRoutes, registerTheme, + setAtomHoist, setDebug, setPrefix, } from '@devup-ui/wasm' @@ -31,6 +37,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 +96,7 @@ export function DevupUI({ include = [], singleCss = false, prefix, + atomHoist, importAliases: userImportAliases, }: Partial = {}): PluginOption { setDebug(debug) @@ -92,7 +107,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 +117,63 @@ 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 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). + } + } }, 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..8bac5c02 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -452,4 +452,217 @@ describe('devupUIWebpackPlugin', () => { plugin.apply(asCompiler(compiler)) expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') }) + + describe('collapse + atomHoist pre-pass', () => { + let buildCanonicalMapSpy: ReturnType + let computeFileReachSpy: ReturnType + let importCanonicalMapSpy: ReturnType + let importFileRoutesSpy: ReturnType + let setAtomHoistSpy: ReturnType + let listSourceFilesSpy: 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) + // Default: no source files so pre-warm is a no-op unless a test opts in. + listSourceFilesSpy = spyOn( + pluginUtils, + 'listSourceFiles', + ).mockReturnValue([]) + }) + + afterEach(() => { + buildCanonicalMapSpy.mockRestore() + computeFileReachSpy.mockRestore() + importCanonicalMapSpy.mockRestore() + importFileRoutesSpy.mockRestore() + setAtomHoistSpy.mockRestore() + listSourceFilesSpy.mockRestore() + }) + + it('runs single-importer collapse even when atomHoist is unset (always-on), without hoisting', () => { + // Constraint: single-importer collapse must ALWAYS be on. The canonical + // map is built + imported unconditionally; only atom HOISTING is gated. + buildCanonicalMapSpy.mockReturnValue({ + 'src/child.tsx': 'src/parent.tsx', + }) + const plugin = new DevupUIWebpackPlugin({}) + plugin.apply(asCompiler(createCompiler())) + expect(buildCanonicalMapSpy).toHaveBeenCalledWith( + expect.objectContaining({ keyBy: 'cwd-relative' }), + ) + expect(importCanonicalMapSpy).toHaveBeenCalled() + // atom hoisting stays off without atomHoist + expect(computeFileReachSpy).not.toHaveBeenCalled() + expect(setAtomHoistSpy).not.toHaveBeenCalled() + expect(importFileRoutesSpy).not.toHaveBeenCalled() + }) + + it('pre-warms the extractor over all source files in build mode when collapse is active', () => { + // Without pre-warm, webpack builds each shared devup-ui-N.css ONCE at + // first import — before later bucket members are extracted — so their + // atoms are dropped. Pre-warming the sheet makes getCss(N) complete from + // the first css-loader call. + buildCanonicalMapSpy.mockReturnValue({ + 'src/child.tsx': 'src/parent.tsx', + }) + listSourceFilesSpy.mockReturnValue([ + resolve(process.cwd(), 'src', 'parent.tsx'), + resolve(process.cwd(), 'src', 'child.tsx'), + ]) + readFileSyncSpy.mockReturnValue('source') + const plugin = new DevupUIWebpackPlugin({ + package: '@devup-ui/react', + singleCss: true, + }) + plugin.apply(asCompiler(createCompiler())) + expect(listSourceFilesSpy).toHaveBeenCalled() + expect(codeExtractSpy).toHaveBeenCalledTimes(2) + expect(codeExtractSpy).toHaveBeenCalledWith( + 'src/parent.tsx', + 'source', + '@devup-ui/react', + expect.any(String), + true, + false, + true, + expect.anything(), + ) + expect(codeExtractSpy).toHaveBeenCalledWith( + 'src/child.tsx', + 'source', + '@devup-ui/react', + expect.any(String), + true, + false, + true, + expect.anything(), + ) + }) + + it('skips pre-warm when the canonical map is empty (no collapse, no race)', () => { + buildCanonicalMapSpy.mockReturnValue({}) + listSourceFilesSpy.mockReturnValue([ + resolve(process.cwd(), 'src', 'a.tsx'), + ]) + readFileSyncSpy.mockReturnValue('source') + const plugin = new DevupUIWebpackPlugin({}) + plugin.apply(asCompiler(createCompiler())) + expect(codeExtractSpy).not.toHaveBeenCalled() + }) + + it('skips pre-warm in watch mode (race only affects one-shot builds)', () => { + buildCanonicalMapSpy.mockReturnValue({ + 'src/child.tsx': 'src/parent.tsx', + }) + listSourceFilesSpy.mockReturnValue([ + resolve(process.cwd(), 'src', 'parent.tsx'), + ]) + readFileSyncSpy.mockReturnValue('source') + const plugin = new DevupUIWebpackPlugin({ watch: true }) + plugin.apply(asCompiler(createCompiler())) + expect(codeExtractSpy).not.toHaveBeenCalled() + }) + + it('swallows pre-warm errors (extraction failure does not break apply)', () => { + buildCanonicalMapSpy.mockReturnValue({ + 'src/child.tsx': 'src/parent.tsx', + }) + listSourceFilesSpy.mockReturnValue([ + resolve(process.cwd(), 'src', 'parent.tsx'), + ]) + readFileSyncSpy.mockReturnValue('source') + codeExtractSpy.mockImplementation(() => { + throw new Error('extract boom') + }) + const plugin = new DevupUIWebpackPlugin({}) + // apply must still complete without throwing + plugin.apply(asCompiler(createCompiler())) + expect(codeExtractSpy).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..bbfa22f5 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -1,24 +1,32 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' -import { join, resolve } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { + buildCanonicalMap, + computeFileReach, createNodeModulesExcludeRegex, createThemeInterfaceArgs, type ImportAliases, + listSourceFiles, loadDevupConfigSync, mergeImportAliases, + planAtomHoist, type WasmImportAliases, } from '@devup-ui/plugin-utils' import { + codeExtract, getCss, getDefaultTheme, getThemeInterface, + importCanonicalMap, importClassMap, importFileMap, + importFileRoutes, importSheet, registerTheme, + setAtomHoist, setDebug, setPrefix, } from '@devup-ui/wasm' @@ -34,6 +42,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 +84,7 @@ export class DevupUIWebpackPlugin { include = [], singleCss = false, prefix, + atomHoist, importAliases: userImportAliases, }: Partial = {}) { this.importAliases = mergeImportAliases(userImportAliases) @@ -73,6 +99,7 @@ export class DevupUIWebpackPlugin { include, singleCss, prefix, + atomHoist, } this.sheetFile = join(this.options.distDir, 'sheet.json') @@ -108,6 +135,42 @@ export class DevupUIWebpackPlugin { ) } + /** + * Extract every source file under `src` into the shared WASM sheet so that a + * later `getCss(fileNum)` call returns the COMPLETE bucket (all collapsed + * members), not just the first member webpack happened to build. Mirrors the + * loader's `codeExtract` call (same filename keying + options) so re-extraction + * during compilation is idempotent. Best-effort: extraction errors are + * swallowed so a single bad file never breaks the build. + */ + private prewarmExtractor() { + try { + const cwd = process.cwd() + const srcDir = resolve(cwd, 'src') + for (const file of listSourceFiles(srcDir)) { + const relativePath = relative(cwd, file).replaceAll('\\', '/') + let relCssDir = relative(dirname(file), this.options.cssDir).replaceAll( + '\\', + '/', + ) + if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` + codeExtract( + relativePath, + readFileSync(file, 'utf-8'), + this.options.package, + relCssDir, + this.options.singleCss, + false, + true, + this.importAliases, + ) + } + } catch { + // Best-effort warm-up; on failure the css-loader still serves whatever + // atoms were extracted, matching pre-fix behavior. + } + } + apply(compiler: Compiler) { setDebug(this.options.debug) if (this.options.prefix) { @@ -137,6 +200,70 @@ 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 + // Single-importer collapse ALWAYS runs: files used by exactly one importer + // merge into that importer's bucket, deduplicating their identical atoms. + // The canonical map is built + imported unconditionally; only atom HOISTING + // composes on top when `atomHoist` is set. Mirrors next-plugin's pre-pass. + let canonicalMap: Record = {} + try { + const srcDir = resolve(process.cwd(), 'src') + const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') + const cwd = process.cwd() + canonicalMap = buildCanonicalMap({ + srcDir, + tsconfigPath, + cwd, + keyBy: 'cwd-relative', + }) + importCanonicalMap(canonicalMap) + + if (atomMode) { + const fileReach = computeFileReach({ + srcDir, + tsconfigPath, + cwd, + keyBy: 'cwd-relative', + }) + 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 canonical() is the identity (no merge) and atom + // hoisting stays off. + } + + // Pre-warm the extractor so the css-loader serves COMPLETE bucket CSS. + // + // Under collapse, several source files share ONE devup-ui-N.css (the + // importer's bucket). The css-loader serves `getCss(N, true)`, but webpack + // builds that shared .css module ONCE — at the FIRST import resolution, + // before the bucket's other members have been extracted — so their atoms + // would be dropped. Turbopack avoids this via its idle coordinator; webpack + // has no such re-serve, so we extract every source file up front (single + // shared WASM instance) to populate the bucket fully BEFORE any css-loader + // runs. Re-extraction by the per-file loader is then idempotent (set-based + // atom dedup). Only needed for one-shot builds when collapse is active. + if (!this.options.watch && Object.keys(canonicalMap).length > 0) { + this.prewarmExtractor() + } + if (this.options.watch) { let lastModifiedTime: number | null = null compiler.hooks.watchRun.tapPromise('DevupUIWebpackPlugin', async () => {