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