diff --git a/Cargo.toml b/Cargo.toml index 26cba5dc..c9933381 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,8 +50,8 @@ use_self = "allow" used_underscore_binding = "allow" [profile.release] -# Optimize for small code size (critical for WASM binary) -opt-level = "s" +# Minimize the WASM that every build must read, compile, and retain in memory. +opt-level = "z" # Link-time optimization: enables cross-crate inlining and dead code elimination lto = true # Single codegen unit: maximizes optimization at cost of compile time diff --git a/benchmark.js b/benchmark.js index abe9a0f6..708d3714 100644 --- a/benchmark.js +++ b/benchmark.js @@ -1,97 +1,131 @@ -import { existsSync, readdirSync, rmSync, statSync } from 'node:fs' -import { join } from 'node:path' - -import { execSync } from 'child_process' - -function clearBuildFile() { - const dirs = readdirSync('./benchmark') - for (const dir of dirs) { - const base = join('./benchmark', dir) - if (!statSync(base).isDirectory()) continue - for (const output of ['.next', 'dist', 'df']) { - const target = join(base, output) - if (existsSync(target)) rmSync(target, { recursive: true, force: true }) - } - } -} - -function checkDirSize(path, filter) { - let totalSize = 0 - - function calculateSize(directory) { - const entries = readdirSync(directory) - for (const entry of entries) { - const entryPath = join(directory, entry) - if (statSync(entryPath).isDirectory()) { - calculateSize(entryPath) // 재귀적으로 하위 폴더 크기 계산 - } else if (!filter || filter(entryPath)) { - const stats = statSync(entryPath) - totalSize += stats.size // 파일 크기 합산 - } - } - } - - calculateSize(path) - return totalSize -} - -// Sum only the size of emitted CSS files. Build-size totals are dominated by -// JS/assets and hide CSS-only differences (e.g. single-importer collapse). -function checkCssSize(path) { - return checkDirSize(path, (p) => p.endsWith('.css')) -} - -clearBuildFile() - -function benchmark(target) { - // Support both short names ('tailwind' -> next-tailwind) and full names ('vinext-devup-ui') - const hasDir = existsSync(join('./benchmark', target, 'package.json')) - const dir = hasDir ? target : 'next-' + target - - performance.mark(target + '-start') - console.profile(target) - execSync('bun run --filter ' + dir + '-benchmark build', { - stdio: 'inherit', - }) - console.profileEnd(target) - performance.mark(target + '-end') - performance.measure(target, target + '-start', target + '-end') - - const benchmarkDir = join('./benchmark', dir) - // 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) - return `${target} ${duration}s ${checkDirSize(outputDir).toLocaleString()} bytes (css ${checkCssSize(outputDir).toLocaleString()} bytes)` -} - -let result = [] - -result.push(benchmark('tailwind')) -result.push(benchmark('stylex')) -result.push(benchmark('stylex-turbo')) -result.push(benchmark('stylex-turbo-devup-ui')) -result.push(benchmark('vanilla-extract')) -result.push(benchmark('kuma-ui')) -result.push(benchmark('panda-css')) -result.push(benchmark('chakra-ui')) -result.push(benchmark('mui')) -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')) -// Multi-component app exercising single-importer collapse (atom dedup). -result.push(benchmark('devup-ui-collapse')) - -console.info(result.join('\n')) +import { existsSync, readdirSync, rmSync, statSync } from 'node:fs' +import { join } from 'node:path' + +import { execSync } from 'child_process' + +function clearBuildFile(dir) { + const base = join('./benchmark', dir) + for (const output of ['.next', 'dist', 'df']) { + const target = join(base, output) + if (existsSync(target)) rmSync(target, { recursive: true, force: true }) + } +} + +function checkDirSize(path, filter) { + let totalSize = 0 + + function calculateSize(directory) { + const entries = readdirSync(directory) + for (const entry of entries) { + const entryPath = join(directory, entry) + if (statSync(entryPath).isDirectory()) { + calculateSize(entryPath) // 재귀적으로 하위 폴더 크기 계산 + } else if (!filter || filter(entryPath)) { + const stats = statSync(entryPath) + totalSize += stats.size // 파일 크기 합산 + } + } + } + + calculateSize(path) + return totalSize +} + +// Sum only the size of emitted CSS files. Build-size totals are dominated by +// JS/assets and hide CSS-only differences (e.g. single-importer collapse). +function checkCssSize(path) { + return checkDirSize(path, (p) => p.endsWith('.css')) +} + +let benchmarkRun = 0 + +function benchmark(target) { + // Support both short names ('tailwind' -> next-tailwind) and full names ('vinext-devup-ui') + const hasDir = existsSync(join('./benchmark', target, 'package.json')) + const dir = hasDir ? target : 'next-' + target + const run = `${target}-${benchmarkRun++}` + + clearBuildFile(dir) + performance.mark(run + '-start') + console.profile(run) + execSync('bun run --filter ' + dir + '-benchmark build', { + stdio: 'inherit', + }) + console.profileEnd(run) + performance.mark(run + '-end') + performance.measure(run, run + '-start', run + '-end') + + const benchmarkDir = join('./benchmark', dir) + // 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(run)[0].duration / 1000 + return { + duration, + result: `${target} ${duration.toFixed(2)}s ${checkDirSize(outputDir).toLocaleString()} bytes (css ${checkCssSize(outputDir).toLocaleString()} bytes)`, + } +} + +let result = [] +const turboSamples = new Map([ + ['tailwind-turbo', []], + ['devup-ui-single-turbo', []], +]) + +function record(target) { + const sample = benchmark(target) + const samples = turboSamples.get(target) + if (samples) samples.push(sample.duration) + result.push(sample.result) +} + +record('tailwind') +record('stylex') +record('stylex-turbo') +record('stylex-turbo-devup-ui') +record('vanilla-extract') +record('kuma-ui') +record('panda-css') +record('chakra-ui') +record('mui') +record('devup-ui') +record('devup-ui-single') +record('tailwind-turbo') +record('devup-ui-single-turbo') +record('devup-ui-turbo') +record('vanilla-extract-devup-ui') +record('tailwind-turbo-devup-ui') +record('vinext-devup-ui') +// Multi-component app exercising single-importer collapse (atom dedup). +record('devup-ui-collapse') + +// A single fixed-order result on a shared CI runner is too noisy for the two +// Turbopack builds we compare directly. Run six cold samples in alternating +// order so each target runs first three times, then report their medians. +const turboTargets = ['tailwind-turbo', 'devup-ui-single-turbo'] +for (let sample = 1; sample < 6; sample++) { + const order = sample % 2 === 0 ? turboTargets : turboTargets.toReversed() + for (const target of order) { + turboSamples.get(target).push(benchmark(target).duration) + } +} + +function median(samples) { + const sorted = samples.toSorted((a, b) => a - b) + const middle = sorted.length / 2 + return (sorted[middle - 1] + sorted[middle]) / 2 +} + +for (const target of turboTargets) { + const samples = turboSamples.get(target) + result.push( + `${target} median ${median(samples).toFixed(2)}s (${samples.length} cold samples: ${samples.map((sample) => sample.toFixed(2) + 's').join(', ')})`, + ) +} + +console.info(result.join('\n')) diff --git a/benchmark/next-chakra-ui/package.json b/benchmark/next-chakra-ui/package.json index 02fc3eb3..cceff3f9 100644 --- a/benchmark/next-chakra-ui/package.json +++ b/benchmark/next-chakra-ui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-devup-ui-collapse/package.json b/benchmark/next-devup-ui-collapse/package.json index 4e1a9def..433c0ddf 100644 --- a/benchmark/next-devup-ui-collapse/package.json +++ b/benchmark/next-devup-ui-collapse/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage", + "build": "next build", "start": "next start", "lint": "eslint" }, diff --git a/benchmark/next-devup-ui-single-turbo/package.json b/benchmark/next-devup-ui-single-turbo/package.json index de2da464..85b646ed 100644 --- a/benchmark/next-devup-ui-single-turbo/package.json +++ b/benchmark/next-devup-ui-single-turbo/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage", + "build": "next build", "start": "next start", "lint": "eslint" }, diff --git a/benchmark/next-devup-ui-single/package.json b/benchmark/next-devup-ui-single/package.json index 5b19bc77..b6c6bf54 100644 --- a/benchmark/next-devup-ui-single/package.json +++ b/benchmark/next-devup-ui-single/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "eslint" }, diff --git a/benchmark/next-devup-ui-turbo/package.json b/benchmark/next-devup-ui-turbo/package.json index 1dbaeeff..209e30ef 100644 --- a/benchmark/next-devup-ui-turbo/package.json +++ b/benchmark/next-devup-ui-turbo/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage", + "build": "next build", "start": "next start", "lint": "eslint" }, diff --git a/benchmark/next-devup-ui/package.json b/benchmark/next-devup-ui/package.json index 676a1adb..4bd25854 100644 --- a/benchmark/next-devup-ui/package.json +++ b/benchmark/next-devup-ui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "eslint" }, diff --git a/benchmark/next-kuma-ui/package.json b/benchmark/next-kuma-ui/package.json index 25fa09c7..17d5648e 100644 --- a/benchmark/next-kuma-ui/package.json +++ b/benchmark/next-kuma-ui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-mui/package.json b/benchmark/next-mui/package.json index 60324edc..5bed6878 100644 --- a/benchmark/next-mui/package.json +++ b/benchmark/next-mui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-panda-css/package.json b/benchmark/next-panda-css/package.json index a3f39c98..b96cee5c 100644 --- a/benchmark/next-panda-css/package.json +++ b/benchmark/next-panda-css/package.json @@ -6,7 +6,7 @@ "scripts": { "prepare": "panda codegen", "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-stylex-turbo-devup-ui/package.json b/benchmark/next-stylex-turbo-devup-ui/package.json index 398b2e55..db9debac 100644 --- a/benchmark/next-stylex-turbo-devup-ui/package.json +++ b/benchmark/next-stylex-turbo-devup-ui/package.json @@ -7,7 +7,7 @@ "predev": "rimraf .next df", "prebuild": "rimraf .next df", "dev": "next dev", - "build": "next build --experimental-debug-memory-usage", + "build": "next build", "start": "next start" }, "dependencies": { diff --git a/benchmark/next-stylex-turbo/package.json b/benchmark/next-stylex-turbo/package.json index 1dd2cc0c..2c7cdbdf 100644 --- a/benchmark/next-stylex-turbo/package.json +++ b/benchmark/next-stylex-turbo/package.json @@ -6,7 +6,7 @@ "predev": "rimraf .next", "prebuild": "rimraf .next", "dev": "next dev", - "build": "next build --experimental-debug-memory-usage", + "build": "next build", "start": "next start" }, "dependencies": { diff --git a/benchmark/next-stylex/package.json b/benchmark/next-stylex/package.json index 2a2ca50b..c57cfe38 100644 --- a/benchmark/next-stylex/package.json +++ b/benchmark/next-stylex/package.json @@ -6,7 +6,7 @@ "predev": "rimraf .next", "prebuild": "rimraf .next", "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-tailwind-turbo-devup-ui/package.json b/benchmark/next-tailwind-turbo-devup-ui/package.json index ac60a615..105a0c26 100644 --- a/benchmark/next-tailwind-turbo-devup-ui/package.json +++ b/benchmark/next-tailwind-turbo-devup-ui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage", + "build": "next build", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-tailwind-turbo/package.json b/benchmark/next-tailwind-turbo/package.json index 4dfa6736..5f82baa2 100644 --- a/benchmark/next-tailwind-turbo/package.json +++ b/benchmark/next-tailwind-turbo/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage", + "build": "next build", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-tailwind/package.json b/benchmark/next-tailwind/package.json index 24d7589c..07e9af58 100644 --- a/benchmark/next-tailwind/package.json +++ b/benchmark/next-tailwind/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-vanilla-extract-devup-ui/package.json b/benchmark/next-vanilla-extract-devup-ui/package.json index 99c7e3af..49b232e9 100644 --- a/benchmark/next-vanilla-extract-devup-ui/package.json +++ b/benchmark/next-vanilla-extract-devup-ui/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/benchmark/next-vanilla-extract/package.json b/benchmark/next-vanilla-extract/package.json index 497864c0..b73225c5 100644 --- a/benchmark/next-vanilla-extract/package.json +++ b/benchmark/next-vanilla-extract/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --experimental-debug-memory-usage --webpack", + "build": "next build --webpack", "start": "next start", "lint": "next lint" }, diff --git a/bindings/devup-ui-wasm/Cargo.toml b/bindings/devup-ui-wasm/Cargo.toml index cd8419bc..2546871b 100644 --- a/bindings/devup-ui-wasm/Cargo.toml +++ b/bindings/devup-ui-wasm/Cargo.toml @@ -14,12 +14,13 @@ categories = ["development-tools", "wasm", "web-programming"] crate-type = ["cdylib"] [features] -default = [] +default = ["vanilla-extract"] +vanilla-extract = ["extractor/vanilla-extract", "sheet/vanilla-extract"] [dependencies] wasm-bindgen = "0.2.127" -extractor = { path = "../../libs/extractor" } -sheet = { path = "../../libs/sheet" } +extractor = { path = "../../libs/extractor", default-features = false } +sheet = { path = "../../libs/sheet", default-features = false } css = { path = "../../libs/css" } rustc-hash = "2" diff --git a/bindings/devup-ui-wasm/package.json b/bindings/devup-ui-wasm/package.json index 1643e880..c6a17d44 100644 --- a/bindings/devup-ui-wasm/package.json +++ b/bindings/devup-ui-wasm/package.json @@ -19,7 +19,7 @@ ], "version": "1.0.78", "scripts": { - "build": "wasm-pack build --target nodejs --out-dir ./pkg --out-name index && node script.js", + "build": "wasm-pack build --target nodejs --out-dir ./pkg --out-name index && wasm-pack build --target nodejs --out-dir ./pkg/lite --out-name index --no-default-features && node script.js", "test": "wasm-pack test --node" }, "publishConfig": { @@ -33,7 +33,12 @@ "pkg/index.js", "pkg/package.json", "pkg/index_bg.wasm", - "pkg/index_bg.wasm.d.ts" + "pkg/index_bg.wasm.d.ts", + "pkg/lite/index.d.ts", + "pkg/lite/index.js", + "pkg/lite/package.json", + "pkg/lite/index_bg.wasm", + "pkg/lite/index_bg.wasm.d.ts" ], "type": "module", "exports": { @@ -41,6 +46,11 @@ "types": "./pkg/index.d.ts", "import": "./pkg/index.js", "require": "./pkg/index.js" + }, + "./lite": { + "types": "./pkg/lite/index.d.ts", + "import": "./pkg/lite/index.js", + "require": "./pkg/lite/index.js" } }, "types": "./pkg/index.d.ts" diff --git a/bindings/devup-ui-wasm/script.js b/bindings/devup-ui-wasm/script.js index 27b3139c..9fafac1b 100644 --- a/bindings/devup-ui-wasm/script.js +++ b/bindings/devup-ui-wasm/script.js @@ -2,3 +2,4 @@ import { writeFileSync } from 'node:fs' // support mjs config writeFileSync('pkg/package.json', JSON.stringify({}), 'utf8') +writeFileSync('pkg/lite/package.json', JSON.stringify({}), 'utf8') diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index b039fad1..6e7a0b73 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -3,7 +3,7 @@ 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 extractor::{ExtractOption, ImportAlias, extract, extract_without_source_map, has_devup_ui}; use rustc_hash::FxHashSet; use sheet::StyleSheet; use std::collections::{BTreeMap, HashMap}; @@ -14,6 +14,12 @@ use wasm_bindgen::prelude::*; static GLOBAL_STYLE_SHEET: LazyLock> = LazyLock::new(|| Mutex::new(StyleSheet::default())); +#[derive(Clone, Copy)] +enum SourceMapMode { + Generate, + Skip, +} + fn with_style_sheet(f: F) -> R where F: FnOnce(&StyleSheet) -> R, @@ -321,17 +327,68 @@ pub fn code_extract_internal( import_main_css_in_css: bool, import_aliases: HashMap, ) -> Result { - match extract( + code_extract_internal_impl( filename, code, - ExtractOption { - package: package.to_string(), - css_dir, - single_css, - import_main_css: import_main_css_in_code, - import_aliases, - }, - ) { + package, + css_dir, + single_css, + import_main_css_in_code, + import_main_css_in_css, + import_aliases, + SourceMapMode::Generate, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn code_extract_without_source_map_internal( + filename: &str, + code: &str, + package: &str, + css_dir: String, + single_css: bool, + import_main_css_in_code: bool, + import_main_css_in_css: bool, + import_aliases: HashMap, +) -> Result { + code_extract_internal_impl( + filename, + code, + package, + css_dir, + single_css, + import_main_css_in_code, + import_main_css_in_css, + import_aliases, + SourceMapMode::Skip, + ) +} + +#[allow(clippy::too_many_arguments)] +fn code_extract_internal_impl( + filename: &str, + code: &str, + package: &str, + css_dir: String, + single_css: bool, + import_main_css_in_code: bool, + import_main_css_in_css: bool, + import_aliases: HashMap, + source_map: SourceMapMode, +) -> Result { + let option = ExtractOption { + package: package.to_string(), + css_dir, + single_css, + import_main_css: import_main_css_in_code, + import_aliases, + }; + let extracted = match source_map { + SourceMapMode::Generate => extract(filename, code, option), + SourceMapMode::Skip => extract_without_source_map(filename, code, option), + }; + + match extracted { Ok(output) => Ok(Output::new( output.code, output.styles, @@ -345,6 +402,24 @@ pub fn code_extract_internal( } } +#[cfg(not(tarpaulin_include))] +fn import_aliases_from_js( + import_aliases: JsValue, +) -> Result, JsValue> { + let aliases: HashMap> = + serde_wasm_bindgen::from_value(import_aliases).map_err(js_error)?; + Ok(aliases + .into_iter() + .map(|(key, value)| { + let alias = match value { + Some(name) => ImportAlias::DefaultToNamed(name), + None => ImportAlias::NamedToNamed, + }; + (key, alias) + }) + .collect()) +} + #[cfg(not(tarpaulin_include))] #[wasm_bindgen(js_name = "codeExtract")] #[allow(clippy::too_many_arguments)] @@ -358,23 +433,6 @@ pub fn code_extract( import_main_css_in_css: bool, import_aliases: JsValue, ) -> Result { - // Deserialize import_aliases from JsValue - // Format: { "package": "namedExport" } or { "package": null } for named exports - let aliases: HashMap> = - serde_wasm_bindgen::from_value(import_aliases).map_err(js_error)?; - - // Convert to ImportAlias enum - let import_aliases: HashMap = aliases - .into_iter() - .map(|(k, v)| { - let alias = match v { - Some(name) => ImportAlias::DefaultToNamed(name), - None => ImportAlias::NamedToNamed, - }; - (k, alias) - }) - .collect(); - code_extract_internal( filename, code, @@ -383,7 +441,33 @@ pub fn code_extract( single_css, import_main_css_in_code, import_main_css_in_css, - import_aliases, + import_aliases_from_js(import_aliases)?, + ) + .map_err(js_error) +} + +#[cfg(not(tarpaulin_include))] +#[wasm_bindgen(js_name = "codeExtractWithoutSourceMap")] +#[allow(clippy::too_many_arguments)] +pub fn code_extract_without_source_map( + filename: &str, + code: &str, + package: &str, + css_dir: String, + single_css: bool, + import_main_css_in_code: bool, + import_main_css_in_css: bool, + import_aliases: JsValue, +) -> Result { + code_extract_without_source_map_internal( + filename, + code, + package, + css_dir, + single_css, + import_main_css_in_code, + import_main_css_in_css, + import_aliases_from_js(import_aliases)?, ) .map_err(js_error) } @@ -1710,6 +1794,31 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); assert!(!output.code().is_empty()); + assert!(output.map().is_some()); + } + + #[test] + #[serial] + fn test_code_extract_without_source_map_internal() { + *GLOBAL_STYLE_SHEET.lock().unwrap() = StyleSheet::default(); + css::class_map::reset_class_map(); + + let result = code_extract_without_source_map_internal( + "test.tsx", + r#"import {Box} from '@devup-ui/react' +"#, + "@devup-ui/react", + "@devup-ui/react".to_string(), + false, + false, + false, + HashMap::new(), + ); + + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(!output.code().is_empty()); + assert!(output.map().is_none()); } #[test] diff --git a/libs/extractor/Cargo.toml b/libs/extractor/Cargo.toml index d3bf12d7..86ae3ac4 100644 --- a/libs/extractor/Cargo.toml +++ b/libs/extractor/Cargo.toml @@ -11,6 +11,10 @@ categories = ["development-tools", "wasm", "web-programming"] [lints] workspace = true +[features] +default = ["vanilla-extract"] +vanilla-extract = ["dep:boa_engine"] + [dependencies] oxc_parser = "0.146.0" oxc_syntax = "0.146.0" @@ -26,7 +30,7 @@ phf = "0.14" strum = "0.28.0" strum_macros = "0.28.0" serde_json = "1.0" -boa_engine = "0.21" +boa_engine = { version = "0.21", optional = true } rustc-hash = "2" smallvec = "1" diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 79a91e27..391f2371 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -11,6 +11,7 @@ mod stylex; mod tailwind; mod util_type; mod utils; +#[cfg(feature = "vanilla-extract")] mod vanilla_extract; mod visit; use crate::extract_style::extract_style_value::ExtractStyleValue; @@ -22,7 +23,9 @@ use oxc_ast_visit::VisitMut; use oxc_codegen::{Codegen, CodegenOptions}; use oxc_parser::{Parser, ParserReturn}; use oxc_span::SourceType; -use rustc_hash::{FxHashMap, FxHashSet}; +#[cfg(feature = "vanilla-extract")] +use rustc_hash::FxHashMap; +use rustc_hash::FxHashSet; use std::collections::{BTreeMap, HashMap}; use std::error::Error; use std::path::PathBuf; @@ -215,6 +218,23 @@ pub fn extract( filename: &str, code: &str, option: ExtractOption, +) -> Result> { + extract_with_source_map(filename, code, option, true) +} + +pub fn extract_without_source_map( + filename: &str, + code: &str, + option: ExtractOption, +) -> Result> { + extract_with_source_map(filename, code, option, false) +} + +fn extract_with_source_map( + filename: &str, + code: &str, + option: ExtractOption, + source_map: bool, ) -> Result> { // Step 1: Transform import aliases // e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'` @@ -243,8 +263,8 @@ pub fn extract( // Step 3: Handle vanilla-extract style files (.css.ts, .css.js) // `processed_code` is Some only when vanilla-extract generation succeeded; // otherwise the untouched `transformed_code` is parsed directly (no copy). - let is_ve_file = vanilla_extract::is_vanilla_extract_file(filename); - let processed_code: Option = if is_ve_file { + #[cfg(feature = "vanilla-extract")] + let processed_code: Option = if vanilla_extract::is_vanilla_extract_file(filename) { // Use transformed code (with imports already pointing to @devup-ui/react) match vanilla_extract::execute_vanilla_extract(&transformed_code, &option.package, filename) { @@ -287,6 +307,8 @@ pub fn extract( } else { None }; + #[cfg(not(feature = "vanilla-extract"))] + let processed_code: Option = None; // For vanilla-extract files, if no styles were collected, return early if processed_code.as_deref() == Some("") { return Ok(ExtractOutput { @@ -328,12 +350,15 @@ pub fn extract( if global { None } else { Some(bucket) }, ); visitor.visit_program(&mut program); - let result = Codegen::new() - .with_options(CodegenOptions { + let codegen_options = if source_map { + CodegenOptions { source_map_path: Some(PathBuf::from(filename)), ..Default::default() - }) - .build(&program); + } + } else { + CodegenOptions::default() + }; + let result = Codegen::new().with_options(codegen_options).build(&program); Ok(ExtractOutput { styles: visitor.styles, @@ -378,6 +403,7 @@ fn resolve_css_target(filename: &str, option: &ExtractOption) -> (String, bool, /// Extract class names from generated code for specific style names /// Used for two-pass vanilla-extract processing to resolve selector references +#[cfg(feature = "vanilla-extract")] fn extract_class_map_from_code( filename: &str, partial_code: &str, diff --git a/libs/sheet/Cargo.toml b/libs/sheet/Cargo.toml index 4ca2a9c2..724c2751 100644 --- a/libs/sheet/Cargo.toml +++ b/libs/sheet/Cargo.toml @@ -11,12 +11,16 @@ categories = ["development-tools", "wasm", "web-programming"] [lints] workspace = true +[features] +default = ["vanilla-extract"] +vanilla-extract = ["extractor/vanilla-extract"] + [dependencies] css = { path = "../css" } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" regex-lite = "0.1" -extractor = { path = "../extractor" } +extractor = { path = "../extractor", default-features = false } rustc-hash = "2" [dev-dependencies] diff --git a/packages/next-plugin/README.md b/packages/next-plugin/README.md index 95e9221f..8eb44d97 100644 --- a/packages/next-plugin/README.md +++ b/packages/next-plugin/README.md @@ -192,3 +192,23 @@ custom `distDir`) to `include`. ```tsx ``` + +## Turbopack build profiling + +Set `DEVUP_UI_PROFILE=1` for an opt-in, structured timing log during a +Turbopack build. + +```bash +DEVUP_UI_PROFILE=1 bun run build +``` + +```powershell +$env:DEVUP_UI_PROFILE = '1'; bun run build +``` + +Each `[devup-ui:profile]` JSON entry reports one phase. `next.graph` measures +the static import-graph pre-pass, `next.prewarm` measures production extraction +before Turbopack starts loaders, and `coordinator.extract` separates request, +WASM extraction, CSS/state serialization, and write time for each loaded +module. The setting is disabled by default and does not collect timings or +write logs when it is absent. diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index dbdf00d3..dda073b4 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -21,6 +21,7 @@ import { } from '../coordinator' let codeExtractSpy: ReturnType +let codeExtractWithoutSourceMapSpy: ReturnType let getCssSpy: ReturnType let exportSheetSpy: ReturnType let exportClassMapSpy: ReturnType @@ -34,6 +35,7 @@ function makeOptions( overrides: Partial = {}, ): CoordinatorOptions { return { + wasm, package: '@devup-ui/react', cssDir: join(tmpDir, 'css'), singleCss: false, @@ -81,6 +83,7 @@ function httpRequest( beforeEach(() => { codeExtractSpy = spyOn(wasm, 'codeExtract') + codeExtractWithoutSourceMapSpy = spyOn(wasm, 'codeExtractWithoutSourceMap') getCssSpy = spyOn(wasm, 'getCss') exportSheetSpy = spyOn(wasm, 'exportSheet') exportClassMapSpy = spyOn(wasm, 'exportClassMap') @@ -97,6 +100,7 @@ beforeEach(() => { afterEach(() => { resetCoordinator() codeExtractSpy.mockRestore() + codeExtractWithoutSourceMapSpy.mockRestore() getCssSpy.mockRestore() exportSheetSpy.mockRestore() exportClassMapSpy.mockRestore() @@ -124,14 +128,15 @@ describe('coordinator', () => { }) it('should handle /extract endpoint', async () => { - codeExtractSpy.mockReturnValue({ + const extractOutput = { code: 'transformed code', map: '{"version":3}', cssFile: 'devup-ui-1.css', updatedBaseStyle: true, free: mock(), [Symbol.dispose]: mock(), - }) + } + codeExtractSpy.mockReturnValue(extractOutput) getCssSpy.mockImplementation( (fileNum: number | null, _importMainCss: boolean) => { if (fileNum === null) return 'base-css' @@ -170,6 +175,7 @@ describe('coordinator', () => { // Verify WASM was called expect(codeExtractSpy).toHaveBeenCalledTimes(1) + expect(extractOutput.free).toHaveBeenCalledTimes(1) // Verify files were written (base CSS + per-file CSS + sheet + classmap + filemap) expect(writeFileSpy).toHaveBeenCalledTimes(5) @@ -177,6 +183,207 @@ describe('coordinator', () => { coordinator.close() }) + it('skips source-map generation when requested', async () => { + const extractOutput = { + code: 'transformed code', + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + free: mock(), + [Symbol.dispose]: mock(), + } + codeExtractWithoutSourceMapSpy.mockReturnValue(extractOutput) + const coordinator = startCoordinator(makeOptions({ sourceMap: false })) + await new Promise((r) => setTimeout(r, 100)) + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + + const res = await httpRequest( + parseInt(portStr), + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/App.tsx', + code: 'const x = ', + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + }), + ) + + expect(res.status).toBe(200) + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(1) + expect(codeExtractSpy).not.toHaveBeenCalled() + expect(extractOutput.free).toHaveBeenCalledTimes(1) + coordinator.close() + }) + + it('reuses byte-identical singleCss prewarm output', async () => { + const source = 'const x = ' + const options = makeOptions({ + singleCss: true, + prewarmedOutputs: new Map([ + [ + 'src/App.tsx', + { + code: 'transformed prewarm code', + cssFile: 'devup-ui.css', + map: '{"version":3}', + source, + updatedBaseStyle: true, + }, + ], + ]), + }) + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + const res = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/App.tsx', + code: source, + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + }), + ) + + expect(res.status).toBe(200) + expect(JSON.parse(res.body)).toMatchObject({ + code: 'transformed prewarm code', + map: '{"version":3}', + cssFile: 'devup-ui.css', + updatedBaseStyle: true, + }) + expect(codeExtractSpy).not.toHaveBeenCalled() + expect(writeFileSpy).not.toHaveBeenCalled() + + coordinator.close() + }) + + it('profiles both base CSS serialization paths separately from writes', async () => { + const originalProfile = process.env.DEVUP_UI_PROFILE + process.env.DEVUP_UI_PROFILE = '1' + const infoSpy = spyOn(console, 'info').mockImplementation(() => {}) + codeExtractSpy.mockReturnValue({ + code: 'transformed code', + map: undefined, + css: 'collected css', + cssFile: 'devup-ui-1.css', + updatedBaseStyle: false, + free: mock(), + [Symbol.dispose]: mock(), + }) + getCssSpy.mockImplementation((fileNum: number | null) => + fileNum === null ? 'base-css' : `file-css-${fileNum}`, + ) + exportSheetSpy.mockReturnValue('sheet-json') + exportClassMapSpy.mockReturnValue('classmap-json') + exportFileMapSpy.mockReturnValue('filemap-json') + + const coordinator = startCoordinator(makeOptions()) + try { + await new Promise((resolve) => setTimeout(resolve, 100)) + const port = parseInt( + (writeFileSyncSpy.mock.calls[0] as [string, string])[1], + ) + + const res = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/profile.tsx', + code: 'const profile = true', + resourcePath: join(process.cwd(), 'src', 'profile.tsx'), + }), + ) + + expect(res.status).toBe(200) + const message = infoSpy.mock.calls + .map(([value]) => value) + .find( + (value): value is string => + typeof value === 'string' && + value.startsWith( + '[devup-ui:profile] {"phase":"coordinator.extract"', + ), + ) + if (message === undefined) throw new Error('missing extract profile') + const profile = JSON.parse( + message.slice('[devup-ui:profile] '.length), + ) as Record + + expect(profile).toMatchObject({ + cacheHit: false, + classMapSnapshotBytes: Buffer.byteLength('classmap-json'), + cssSnapshotBytes: expect.any(Number), + fileMapSnapshotBytes: Buffer.byteLength('filemap-json'), + phase: 'coordinator.extract', + scheduledWrites: 5, + sheetSnapshotBytes: Buffer.byteLength('sheet-json'), + sourceBytes: Buffer.byteLength('const profile = true'), + }) + expect(profile.classMapSnapshotMs).toBeTypeOf('number') + expect(profile.cssSnapshotMs).toBeTypeOf('number') + expect(profile.fileMapSnapshotMs).toBeTypeOf('number') + expect(profile.sheetSnapshotMs).toBeTypeOf('number') + + codeExtractSpy.mockReturnValue({ + code: 'transformed base code', + map: undefined, + css: 'collected base css', + cssFile: 'devup-ui-2.css', + updatedBaseStyle: true, + free: mock(), + [Symbol.dispose]: mock(), + }) + const baseRes = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/profile-base.tsx', + code: 'const profileBase = true', + resourcePath: join(process.cwd(), 'src', 'profile-base.tsx'), + }), + ) + + expect(baseRes.status).toBe(200) + const baseMessage = infoSpy.mock.calls + .map(([value]) => value) + .find( + (value): value is string => + typeof value === 'string' && + value.includes('"filename":"src/profile-base.tsx"'), + ) + if (baseMessage === undefined) { + throw new Error('missing base CSS extract profile') + } + const baseProfile = JSON.parse( + baseMessage.slice('[devup-ui:profile] '.length), + ) as Record + + expect(baseProfile).toMatchObject({ + cacheHit: false, + cssSnapshotBytes: expect.any(Number), + filename: 'src/profile-base.tsx', + phase: 'coordinator.extract', + scheduledWrites: 5, + }) + expect(baseProfile.cssSnapshotMs).toBeTypeOf('number') + } finally { + coordinator.close() + infoSpy.mockRestore() + if (originalProfile === undefined) { + delete process.env.DEVUP_UI_PROFILE + } else { + process.env.DEVUP_UI_PROFILE = originalProfile + } + } + }) + it('should rewrite per-file CSS imports when singleCss=false', async () => { codeExtractSpy.mockReturnValue({ code: 'import "./../../df/devup-ui/devup-ui-79.css";\nimport "./../../df/devup-ui/devup-ui-3.css";\nconst x = 1;', @@ -556,6 +763,36 @@ describe('coordinator', () => { coordinator.close() }) + it('replaces an existing coordinator without retaining its server', async () => { + const options = makeOptions() + const first = startCoordinator(options) + await new Promise((resolve) => setTimeout(resolve, 100)) + const firstPort = parseInt( + (writeFileSyncSpy.mock.calls.at(-1) as [string, string])[1], + ) + + const second = startCoordinator(options) + await new Promise((resolve) => setTimeout(resolve, 100)) + const secondPort = parseInt( + (writeFileSyncSpy.mock.calls.at(-1) as [string, string])[1], + ) + + let firstClosed = false + try { + await httpRequest(firstPort, 'GET', '/health') + } catch { + firstClosed = true + } + expect(firstClosed).toBe(true) + + // Closing the superseded handle must not close the replacement server. + first.close() + const res = await httpRequest(secondPort, 'GET', '/health') + expect(res).toEqual({ status: 200, body: 'ok' }) + + second.close() + }) + it('should touch devup-ui.css to invalidate Turbopack cache when singleCss=false and new CSS collected', async () => { codeExtractSpy.mockReturnValue({ code: 'import "./../../df/devup-ui/devup-ui-5.css";\nconst x = 1;', diff --git a/packages/next-plugin/src/__tests__/css-loader.test.ts b/packages/next-plugin/src/__tests__/css-loader.test.ts index 98d9e95d..1ff6f718 100644 --- a/packages/next-plugin/src/__tests__/css-loader.test.ts +++ b/packages/next-plugin/src/__tests__/css-loader.test.ts @@ -13,7 +13,7 @@ import { spyOn, } from 'bun:test' -import devupUICssLoader, { resetInit } from '../css-loader' +import devupUICssLoader, { resetInit, setWasmForTesting } from '../css-loader' type CssLoaderThis = ThisParameterType @@ -45,6 +45,7 @@ beforeAll(() => { importFileMapSpy = spyOn(wasm, 'importFileMap').mockReturnValue(undefined) existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue('{}') + setWasmForTesting(wasm) }) afterEach(() => { diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index 551111a0..c7f33b49 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -15,7 +15,7 @@ import { } from 'bun:test' import type { DevupUILoaderOptions } from '../loader' -import devupUILoader, { resetInit } from '../loader' +import devupUILoader, { resetInit, setWasmForTesting } from '../loader' type LoaderThis = ThisParameterType @@ -68,6 +68,7 @@ beforeEach(() => { importFileMapSpy = spyOn(wasm, 'importFileMap').mockImplementation(() => {}) importSheetSpy = spyOn(wasm, 'importSheet').mockImplementation(() => {}) registerThemeSpy = spyOn(wasm, 'registerTheme').mockImplementation(() => {}) + setWasmForTesting(wasm) dateNowSpy = spyOn(Date, 'now').mockReturnValue(0) }) diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 19d60c89..e823f2a6 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 type { StaticImportGraph } from '@devup-ui/plugin-utils' import * as importGraphModule from '@devup-ui/plugin-utils' import * as wasm from '@devup-ui/wasm' import * as webpackPluginModule from '@devup-ui/webpack-plugin' @@ -15,7 +16,8 @@ import { } from 'bun:test' import * as coordinatorModule from '../coordinator' -import { DevupUI } from '../plugin' +import { DevupUI, selectWasmVariant } from '../plugin' +import { setWasmForTesting, setWebpackPluginForTesting } from '../wasm' type CodeExtractResult = ReturnType type NextWebpackConfig = Parameters< @@ -68,6 +70,7 @@ let exportSheetSpy: ReturnType let exportClassMapSpy: ReturnType let exportFileMapSpy: ReturnType let codeExtractSpy: ReturnType +let codeExtractWithoutSourceMapSpy: ReturnType let devupUIWebpackPluginSpy: ReturnType let startCoordinatorSpy: ReturnType @@ -108,6 +111,12 @@ beforeEach(() => { codeExtractSpy = spyOn(wasm, 'codeExtract').mockImplementation( (_path: string, contents: string) => createCodeExtractResult(contents), ) + codeExtractWithoutSourceMapSpy = spyOn( + wasm, + 'codeExtractWithoutSourceMap', + ).mockImplementation((_path: string, contents: string) => + createCodeExtractResult(contents), + ) devupUIWebpackPluginSpy = spyOn( webpackPluginModule, 'DevupUIWebpackPlugin', @@ -116,6 +125,8 @@ beforeEach(() => { coordinatorModule, 'startCoordinator', ).mockReturnValue({ close: mock() as () => void }) + setWasmForTesting(wasm) + setWebpackPluginForTesting(webpackPluginModule) originalEnv = { ...process.env } originalFetch = global.fetch @@ -124,6 +135,8 @@ beforeEach(() => { }) afterEach(() => { + setWasmForTesting(undefined) + setWebpackPluginForTesting(undefined) process.env = originalEnv global.fetch = originalFetch process.debugPort = originalDebugPort @@ -144,11 +157,30 @@ afterEach(() => { exportClassMapSpy.mockRestore() exportFileMapSpy.mockRestore() codeExtractSpy.mockRestore() + codeExtractWithoutSourceMapSpy.mockRestore() devupUIWebpackPluginSpy.mockRestore() startCoordinatorSpy.mockRestore() }) describe('DevupUINextPlugin', () => { + it('selects the lite engine only when the graph has no vanilla-extract file', () => { + expect(selectWasmVariant(undefined)).toBe('full') + expect( + selectWasmVariant({ files: ['src/page.tsx'] } as StaticImportGraph), + ).toBe('lite') + expect( + selectWasmVariant({ files: ['src/theme.css.ts'] } as StaticImportGraph), + ).toBe('full') + expect( + selectWasmVariant({ files: ['src/theme.css.js'] } as StaticImportGraph), + ).toBe('full') + expect( + selectWasmVariant({ files: ['src/page.tsx'] } as StaticImportGraph, [ + 'node_modules/design-system/theme.css.ts', + ]), + ).toBe('full') + }) + describe('webpack', () => { it('should apply webpack plugin', async () => { const ret = DevupUI({}) @@ -492,6 +524,7 @@ describe('DevupUINextPlugin', () => { }, }) expect(startCoordinatorSpy).toHaveBeenCalledWith({ + wasm, package: '@devup-ui/react', cssDir: resolve('df', 'devup-ui'), singleCss: false, @@ -507,8 +540,20 @@ describe('DevupUINextPlugin', () => { canonicalMap: expect.any(Object), expectedBaseFiles: expect.any(Array), prewarmedFiles: expect.any(Array), + prewarmedOutputs: expect.any(Map), + sourceMap: false, }) }) + it('keeps source maps when Next production browser source maps are enabled', () => { + setNodeEnv('production') + process.env.TURBOPACK = '1' + + DevupUI({ productionBrowserSourceMaps: true }) + + expect(startCoordinatorSpy).toHaveBeenCalledWith( + expect.objectContaining({ sourceMap: true }), + ) + }) it('should create theme.d.ts file', async () => { process.env.TURBOPACK = '1' existsSyncSpy.mockReturnValue(true) @@ -681,6 +726,7 @@ describe('DevupUINextPlugin', () => { // Verify coordinator was started with correct options expect(startCoordinatorSpy).toHaveBeenCalledWith({ + wasm, package: '@devup-ui/react', cssDir: resolve('df', 'devup-ui'), singleCss: false, @@ -696,8 +742,11 @@ describe('DevupUINextPlugin', () => { canonicalMap: expect.any(Object), expectedBaseFiles: expect.any(Array), prewarmedFiles: [], + prewarmedOutputs: expect.any(Map), + sourceMap: true, }) expect(codeExtractSpy).not.toHaveBeenCalled() + expect(codeExtractWithoutSourceMapSpy).not.toHaveBeenCalled() // Verify initial CSS file is written expect(writeFileSyncSpy).toHaveBeenCalledWith( @@ -719,6 +768,8 @@ describe('DevupUINextPlugin', () => { it('hands the coordinator the full compiled-file set, not the static-only route map', () => { process.env.TURBOPACK = '1' + process.env.DEVUP_UI_PROFILE = '1' + const profileSpy = spyOn(console, 'info').mockImplementation(() => {}) // The base sheet must wait for lazily-loaded modules too, so // expectedBaseFiles comes from computeCompiledFiles (static + dynamic // edges) rather than computeFileRoutes (static edges only). Using the @@ -732,7 +783,7 @@ describe('DevupUINextPlugin', () => { 'computeFileRoutes', ).mockReturnValue({ 'src/app/page.tsx': [0] }) const events: string[] = [] - codeExtractSpy.mockImplementation( + codeExtractWithoutSourceMapSpy.mockImplementation( (filename: string, contents: string) => { events.push(`extract:${filename}`) return createCodeExtractResult(contents) @@ -751,8 +802,8 @@ describe('DevupUINextPlugin', () => { prewarmedFiles: ['src/app/page.tsx', 'src/lazy/panel.tsx'], }), ) - expect(codeExtractSpy).toHaveBeenCalledTimes(2) - expect(codeExtractSpy).toHaveBeenCalledWith( + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(2) + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( 'src/app/page.tsx', '{}', '@devup-ui/react', @@ -762,7 +813,7 @@ describe('DevupUINextPlugin', () => { true, expect.anything(), ) - expect(codeExtractSpy).toHaveBeenCalledWith( + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( 'src/lazy/panel.tsx', '{}', '@devup-ui/react', @@ -777,9 +828,36 @@ describe('DevupUINextPlugin', () => { 'extract:src/lazy/panel.tsx', 'startCoordinator', ]) + const profiles: Record[] = profileSpy.mock.calls + .map(([value]) => value) + .filter( + (value): value is string => + typeof value === 'string' && + value.startsWith('[devup-ui:profile] '), + ) + .map((value) => JSON.parse(value.slice('[devup-ui:profile] '.length))) + const prewarmProfile = profiles.find( + ({ phase }) => phase === 'next.prewarm', + ) + expect(prewarmProfile).toMatchObject({ + collectMs: expect.any(Number), + extractMs: expect.any(Number), + files: 2, + phase: 'next.prewarm', + readMs: expect.any(Number), + sourceBytes: Buffer.byteLength('{}') * 2, + }) + expect(profiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ phase: 'next.initialCss' }), + expect.objectContaining({ phase: 'next.stateSnapshot' }), + expect.objectContaining({ phase: 'next.setup' }), + ]), + ) // the static-only route map is not consulted outside atom-hoist mode expect(routesSpy).not.toHaveBeenCalled() } finally { + profileSpy.mockRestore() compiledSpy.mockRestore() routesSpy.mockRestore() } @@ -829,7 +907,7 @@ describe('DevupUINextPlugin', () => { ], }), ) - expect(codeExtractSpy).toHaveBeenCalledTimes(2) + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(2) } finally { graphSpy.mockRestore() compiledSpy.mockRestore() @@ -845,8 +923,8 @@ describe('DevupUINextPlugin', () => { try { DevupUI({}, { singleCss: true }) - expect(codeExtractSpy).toHaveBeenCalledTimes(2) - expect(codeExtractSpy).toHaveBeenCalledWith( + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(2) + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( 'src/app/card.tsx', '{}', '@devup-ui/react', diff --git a/packages/next-plugin/src/__tests__/profile.test.ts b/packages/next-plugin/src/__tests__/profile.test.ts new file mode 100644 index 00000000..b2e902e5 --- /dev/null +++ b/packages/next-plugin/src/__tests__/profile.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test' + +import { + elapsedMs, + isProfileEnabled, + profileStart, + reportProfile, +} from '../profile' + +let originalEnv: NodeJS.ProcessEnv + +beforeEach(() => { + originalEnv = { ...process.env } +}) + +afterEach(() => { + process.env = originalEnv +}) + +describe('profile', () => { + it('is disabled unless explicitly enabled', () => { + delete process.env.DEVUP_UI_PROFILE + const consoleSpy = spyOn(console, 'info').mockImplementation(() => {}) + + expect(isProfileEnabled()).toBe(false) + reportProfile('next.prewarm') + expect(consoleSpy).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('reports structured measurements when enabled', () => { + process.env.DEVUP_UI_PROFILE = '1' + const consoleSpy = spyOn(console, 'info').mockImplementation(() => {}) + + reportProfile('next.prewarm', { durationMs: 12.34, files: 2 }) + + expect(consoleSpy).toHaveBeenCalledWith( + '[devup-ui:profile] {"phase":"next.prewarm","durationMs":12.34,"files":2}', + ) + consoleSpy.mockRestore() + }) + + it('returns a non-negative elapsed duration', () => { + expect(elapsedMs(performance.now())).toBeGreaterThanOrEqual(0) + }) + + it('does not start a timer while disabled', () => { + delete process.env.DEVUP_UI_PROFILE + + expect(profileStart()).toBeUndefined() + expect(elapsedMs(undefined)).toBeUndefined() + }) + + it('starts a timer when enabled', () => { + process.env.DEVUP_UI_PROFILE = '1' + + expect(profileStart()).toBeTypeOf('number') + }) +}) diff --git a/packages/next-plugin/src/__tests__/wasm.test.ts b/packages/next-plugin/src/__tests__/wasm.test.ts new file mode 100644 index 00000000..1f5d34ee --- /dev/null +++ b/packages/next-plugin/src/__tests__/wasm.test.ts @@ -0,0 +1,34 @@ +import * as wasm from '@devup-ui/wasm' +import * as webpackPlugin from '@devup-ui/webpack-plugin' +import { afterEach, describe, expect, it } from 'bun:test' + +import { + loadWasm, + loadWebpackPlugin, + setWasmForTesting, + setWebpackPluginForTesting, +} from '../wasm' + +afterEach(() => { + setWasmForTesting(undefined) + setWebpackPluginForTesting(undefined) +}) + +describe('WASM selection', () => { + it('uses an injected namespace in tests', () => { + setWasmForTesting(wasm) + expect(loadWasm(true)).toBe(wasm) + expect(loadWasm(false)).toBe(wasm) + }) + + it('loads the full and lite package exports', () => { + expect(typeof loadWasm(false).codeExtract).toBe('function') + expect(typeof loadWasm(true).codeExtract).toBe('function') + }) + + it('loads or injects the Webpack plugin without a static dependency', () => { + expect(typeof loadWebpackPlugin().DevupUIWebpackPlugin).toBe('function') + setWebpackPluginForTesting(webpackPlugin) + expect(loadWebpackPlugin()).toBe(webpackPlugin) + }) +}) diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 31866fdd..f6d20c5b 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -3,15 +3,12 @@ import { createServer, type IncomingMessage, type Server } from 'node:http' import { basename, dirname, join, relative } from 'node:path' import { getFileNumByFilename } from '@devup-ui/plugin-utils' -import { - codeExtract, - exportClassMap, - exportFileMap, - exportSheet, - getCss, -} from '@devup-ui/wasm' + +import { elapsedMs, profileStart, reportProfile } from './profile' +import type { DevupWasm } from './wasm' export interface CoordinatorOptions { + wasm: DevupWasm package: string cssDir: string singleCss: boolean @@ -42,6 +39,14 @@ export interface CoordinatorOptions { * shared WASM sheet even though their loaders have not POSTed `/extract` yet. */ prewarmedFiles?: string[] + /** + * Production `singleCss` outputs extracted before Turbopack starts. A loader + * that receives byte-identical source can return this result without a + * second WASM extraction; the shared sheet is already populated. + */ + prewarmedOutputs?: Map + /** Generate transform source maps. Defaults to true for existing callers. */ + sourceMap?: boolean /** * Idle threshold (ms) for the base-css `/css` wait. Defaults to 2500. * FALLBACK ONLY — used when `expectedBaseFiles` is empty (no deterministic @@ -63,6 +68,35 @@ export interface CoordinatorOptions { maxWaitMs?: number } +export interface PrewarmedOutput { + code: string + cssFile?: string + map?: string + source: string + updatedBaseStyle: boolean +} + +interface ExtractOutputSnapshot extends Omit { + css?: string +} + +/** Copy every WASM-backed getter once, then release its Rust allocation. */ +export function takeExtractOutput( + output: ReturnType, +): ExtractOutputSnapshot { + try { + return { + code: output.code, + css: output.css, + cssFile: output.cssFile, + map: output.map, + updatedBaseStyle: output.updatedBaseStyle, + } + } finally { + output.free() + } +} + // Latest-Wins Coalescing Serializer. // // Multiple Turbopack workers may call /extract concurrently, each producing @@ -313,7 +347,15 @@ function waitForBucket(bucket: string): Promise { export function startCoordinator(options: CoordinatorOptions): { close: () => void } { + // Next may evaluate its config more than once in the same process. Close the + // previous listener before replacing it so its HTTP server and request + // closure do not remain live for the rest of the build. + if (server) { + server.close() + server = null + } const { + wasm, package: libPackage, cssDir, singleCss, @@ -323,6 +365,17 @@ export function startCoordinator(options: CoordinatorOptions): { importAliases, coordinatorPortFile, } = options + const { + codeExtract, + codeExtractWithoutSourceMap, + exportClassMap, + exportFileMap, + exportSheet, + getCss, + } = wasm + const prewarmedOutputs = options.prewarmedOutputs ?? new Map() + const extract = + options.sourceMap === false ? codeExtractWithoutSourceMap : codeExtract idleThresholdMs = options.idleThresholdMs ?? 2500 quietMs = options.quietMs ?? 10_000 @@ -334,7 +387,7 @@ export function startCoordinator(options: CoordinatorOptions): { for (const file of options.prewarmedFiles ?? []) extractedFiles.add(file) fileNumToBucket.clear() - server = createServer(async (req, res) => { + const coordinatorServer = createServer(async (req, res) => { const url = new URL(req.url ?? '/', `http://${req.headers.host}`) if (req.method === 'GET' && url.pathname === '/health') { @@ -344,6 +397,7 @@ export function startCoordinator(options: CoordinatorOptions): { } if (req.method === 'GET' && url.pathname === '/css') { + const cssStartedAt = profileStart() const fileNumParam = url.searchParams.get('fileNum') const importMainCss = url.searchParams.get('importMainCss') === 'true' const shouldWait = url.searchParams.get('waitForIdle') === 'true' @@ -364,10 +418,16 @@ export function startCoordinator(options: CoordinatorOptions): { res.writeHead(200, { 'Content-Type': 'text/css' }) res.end(getCss(fileNum ?? null, importMainCss)) + reportProfile('coordinator.css', { + durationMs: elapsedMs(cssStartedAt), + fileNum, + waitForIdle: shouldWait, + }) return } if (req.method === 'POST' && url.pathname === '/extract') { + const requestStartedAt = profileStart() // Reserve a "start slot" before yielding on `await readBody`. Without // this counter, `waitForIdle` could observe activeExtractions=0 in the // window between the request hitting this handler and `activeExtractions++` @@ -377,7 +437,9 @@ export function startCoordinator(options: CoordinatorOptions): { let promotedToActive = false let extractedFilename: string | undefined try { + const bodyStartedAt = profileStart() const body = JSON.parse(await readBody(req)) + const bodyDurationMs = elapsedMs(bodyStartedAt) activeExtractions++ pendingExtractStarts-- promotedToActive = true @@ -394,16 +456,29 @@ export function startCoordinator(options: CoordinatorOptions): { ) if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` - const result = codeExtract( - filename, - code, - libPackage, - relCssDir, - singleCss, - false, - true, - importAliases, - ) + // The production prewarm exists to make the CSS snapshot complete + // before Turbopack requests it. In single-CSS mode the generated CSS + // is already in that snapshot, so re-running WASM here is pure work. + // Require exact source equality because Turbopack may hand a loader + // code modified by an earlier transform. + const prewarmed = singleCss ? prewarmedOutputs.get(filename) : undefined + const cacheHit = prewarmed?.source === code + const extractStartedAt = profileStart() + const result = cacheHit + ? prewarmed + : takeExtractOutput( + extract( + filename, + code, + libPackage, + relCssDir, + singleCss, + false, + true, + importAliases, + ), + ) + const extractDurationMs = elapsedMs(extractStartedAt) // When singleCss=false, rewrite per-file CSS imports so Turbopack can resolve them. // Instead of importing "devup-ui-79.css" (which doesn't exist as a resolvable module), @@ -417,32 +492,82 @@ export function startCoordinator(options: CoordinatorOptions): { ) } + const snapshotStartedAt = profileStart() + let classMapSnapshotBytes: number | undefined + let classMapSnapshotMs: number | undefined + let cssSnapshotBytes: number | undefined + let cssSnapshotMs: number | undefined + let fileMapSnapshotBytes: number | undefined + let fileMapSnapshotMs: number | undefined + let sheetSnapshotBytes: number | undefined + let sheetSnapshotMs: number | undefined const promises: Promise[] = [] - if (result.updatedBaseStyle) { - promises.push( - safeWrite( - join(cssDir, 'devup-ui.css'), - `${getCss(null, false)}\n/* ${Date.now()} */`, - ), - ) + if (!cacheHit && result.updatedBaseStyle) { + const cssStartedAt = + snapshotStartedAt === undefined ? undefined : performance.now() + const css = `${getCss(null, false)}\n/* ${Date.now()} */` + const cssDurationMs = + cssStartedAt === undefined ? undefined : elapsedMs(cssStartedAt) + if (cssDurationMs !== undefined) { + cssSnapshotMs = (cssSnapshotMs ?? 0) + cssDurationMs + cssSnapshotBytes = (cssSnapshotBytes ?? 0) + Buffer.byteLength(css) + } + promises.push(safeWrite(join(cssDir, 'devup-ui.css'), css)) } - if (result.cssFile) { + if (!cacheHit && 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) } + const cssStartedAt = + snapshotStartedAt === undefined ? undefined : performance.now() + const css = getCss(fileNum, true) + const cssDurationMs = + cssStartedAt === undefined ? undefined : elapsedMs(cssStartedAt) + if (cssDurationMs !== undefined) { + cssSnapshotMs = (cssSnapshotMs ?? 0) + cssDurationMs + cssSnapshotBytes = (cssSnapshotBytes ?? 0) + Buffer.byteLength(css) + } + + const sheetStartedAt = + snapshotStartedAt === undefined ? undefined : performance.now() + const sheet = exportSheet() + sheetSnapshotMs = + sheetStartedAt === undefined ? undefined : elapsedMs(sheetStartedAt) + if (snapshotStartedAt !== undefined) { + sheetSnapshotBytes = Buffer.byteLength(sheet) + } + + const classMapStartedAt = + snapshotStartedAt === undefined ? undefined : performance.now() + const classMap = exportClassMap() + classMapSnapshotMs = + classMapStartedAt === undefined + ? undefined + : elapsedMs(classMapStartedAt) + if (snapshotStartedAt !== undefined) { + classMapSnapshotBytes = Buffer.byteLength(classMap) + } + + const fileMapStartedAt = + snapshotStartedAt === undefined ? undefined : performance.now() + const fileMap = exportFileMap() + fileMapSnapshotMs = + fileMapStartedAt === undefined + ? undefined + : elapsedMs(fileMapStartedAt) + if (snapshotStartedAt !== undefined) { + fileMapSnapshotBytes = Buffer.byteLength(fileMap) + } promises.push( - safeWrite( - join(cssDir, basename(result.cssFile)), - getCss(fileNum, true), - ), - safeWrite(sheetFile, exportSheet()), - safeWrite(classMapFile, exportClassMap()), - safeWrite(fileMapFile, exportFileMap()), + safeWrite(join(cssDir, basename(result.cssFile)), css), + safeWrite(sheetFile, sheet), + safeWrite(classMapFile, classMap), + safeWrite(fileMapFile, fileMap), ) // In non-singleCss mode, imports are rewritten from devup-ui-N.css to @@ -452,15 +577,22 @@ export function startCoordinator(options: CoordinatorOptions): { // new CSS rules are invisible to the browser. // When updatedBaseStyle is true, devup-ui.css is already written above. if (!singleCss && !result.updatedBaseStyle && result.css != null) { - promises.push( - safeWrite( - join(cssDir, 'devup-ui.css'), - `${getCss(null, false)}\n/* ${Date.now()} */`, - ), - ) + const cssStartedAt = + snapshotStartedAt === undefined ? undefined : performance.now() + const baseCss = `${getCss(null, false)}\n/* ${Date.now()} */` + const cssDurationMs = + cssStartedAt === undefined ? undefined : elapsedMs(cssStartedAt) + if (cssDurationMs !== undefined) { + cssSnapshotMs = (cssSnapshotMs ?? 0) + cssDurationMs + cssSnapshotBytes = + (cssSnapshotBytes ?? 0) + Buffer.byteLength(baseCss) + } + promises.push(safeWrite(join(cssDir, 'devup-ui.css'), baseCss)) } } + const snapshotDurationMs = elapsedMs(snapshotStartedAt) + const writeStartedAt = profileStart() await Promise.all(promises) res.writeHead(200, { 'Content-Type': 'application/json' }) @@ -472,6 +604,28 @@ export function startCoordinator(options: CoordinatorOptions): { updatedBaseStyle: result.updatedBaseStyle, }), ) + reportProfile('coordinator.extract', { + bodyMs: bodyDurationMs, + cacheHit, + classMapSnapshotBytes, + classMapSnapshotMs, + cssSnapshotBytes, + cssSnapshotMs, + durationMs: elapsedMs(requestStartedAt), + extractMs: extractDurationMs, + fileMapSnapshotBytes, + fileMapSnapshotMs, + filename, + sheetSnapshotBytes, + sheetSnapshotMs, + sourceBytes: + requestStartedAt === undefined + ? undefined + : Buffer.byteLength(code), + scheduledWrites: promises.length, + snapshotMs: snapshotDurationMs, + writeMs: elapsedMs(writeStartedAt), + }) } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end( @@ -500,8 +654,9 @@ export function startCoordinator(options: CoordinatorOptions): { res.end('Not Found') }) - server.listen(0, '127.0.0.1', () => { - const addr = server!.address() + server = coordinatorServer + coordinatorServer.listen(0, '127.0.0.1', () => { + const addr = coordinatorServer.address() if (addr && typeof addr !== 'string') { writeFileSync(coordinatorPortFile, String(addr.port), 'utf-8') } @@ -514,8 +669,8 @@ export function startCoordinator(options: CoordinatorOptions): { // `close` itself returns synchronously (it is invoked from // `process.on('exit', ...)` where awaiting is not possible). void flushPendingWrites() - if (server) { - server.close() + coordinatorServer.close() + if (server === coordinatorServer) { server = null try { unlinkSync(coordinatorPortFile) diff --git a/packages/next-plugin/src/css-loader.ts b/packages/next-plugin/src/css-loader.ts index c928e277..a404c277 100644 --- a/packages/next-plugin/src/css-loader.ts +++ b/packages/next-plugin/src/css-loader.ts @@ -2,15 +2,10 @@ import { existsSync, readFileSync } from 'node:fs' import { Agent, request } from 'node:http' import { getFileNumByFilename } from '@devup-ui/plugin-utils' -import { - getCss, - importClassMap, - importFileMap, - importSheet, - registerTheme, -} from '@devup-ui/wasm' import type { RawLoaderDefinitionFunction } from 'webpack' +import { loadWasm } from './wasm' + export interface DevupUICssLoaderOptions { // turbo watch: boolean @@ -124,6 +119,13 @@ const devupUICssLoader: RawLoaderDefinitionFunction = return } + const { + getCss, + importClassMap, + importFileMap, + importSheet, + registerTheme, + } = loadWasm(false) if (!init) { init = true if (watch) { @@ -161,3 +163,5 @@ export const resetInit = () => { init = false cachedPort = null } + +export { setWasmForTesting } from './wasm' diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index 1110699b..de9f1515 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -3,19 +3,10 @@ import { writeFile } from 'node:fs/promises' import { Agent, request } from 'node:http' import { basename, dirname, join, relative } from 'node:path' -import { - codeExtract, - exportClassMap, - exportFileMap, - exportSheet, - getCss, - importClassMap, - importFileMap, - importSheet, - registerTheme, -} from '@devup-ui/wasm' import type { RawLoaderDefinitionFunction } from 'webpack' +import { loadWasm } from './wasm' + export interface DevupUILoaderOptions { package: string cssDir: string @@ -185,6 +176,17 @@ const devupUILoader: RawLoaderDefinitionFunction = } // Non-coordinator mode: local WASM extraction + const { + codeExtract, + exportClassMap, + exportFileMap, + exportSheet, + getCss, + importClassMap, + importFileMap, + importSheet, + registerTheme, + } = loadWasm(false) const promises: Promise[] = [] if (!init) { init = true @@ -268,3 +270,5 @@ export const resetInit = () => { init = false cachedPorts.clear() } + +export { setWasmForTesting } from './wasm' diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 4dca4ff7..a1d9b779 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -19,38 +19,33 @@ import { planAtomHoist, type StaticImportGraph, } from '@devup-ui/plugin-utils' -import { - codeExtract, - exportClassMap, - exportFileMap, - exportSheet, - getCss, - getDefaultTheme, - getThemeInterface, - importCanonicalMap, - importClassMap, - importFileMap, - importFileRoutes, - importSheet, - registerShorthands, - registerTheme, - setAtomHoist, - setPrefix, -} from '@devup-ui/wasm' -import { - DevupUIWebpackPlugin, - type DevupUIWebpackPluginOptions, -} from '@devup-ui/webpack-plugin' +import type { DevupUIWebpackPluginOptions } from '@devup-ui/webpack-plugin' import { type NextConfig } from 'next' -import { startCoordinator } from './coordinator' +import { + type PrewarmedOutput, + startCoordinator, + takeExtractOutput, +} from './coordinator' import { collectProductionPrewarmFiles } from './prewarm' +import { elapsedMs, profileStart, reportProfile } from './profile' +import { loadWasm, loadWebpackPlugin } from './wasm' type DevupUiNextPluginOptions = Omit< Partial, 'watch' > +export function selectWasmVariant( + graph: StaticImportGraph | undefined, + candidateFiles: string[] = graph?.files ?? [], +): 'lite' | 'full' { + return graph && + !candidateFiles.some((filename) => /\.css\.(?:ts|js)$/.test(filename)) + ? 'lite' + : 'full' +} + /** * Devup UI Next Plugin * @param config @@ -61,6 +56,7 @@ export function DevupUI( config: NextConfig, options: DevupUiNextPluginOptions = {}, ): NextConfig { + const pluginStartedAt = profileStart() const isTurbo = process.env.TURBOPACK === '1' || process.env.TURBOPACK === 'auto' // turbopack is now stable, TURBOPACK is set to auto without any flags @@ -81,14 +77,7 @@ export function DevupUI( importAliases: userImportAliases, } = options - registerShorthands(shorthands ?? {}) - - if (prefix) { - setPrefix(prefix) - } - const importAliases = mergeImportAliases(userImportAliases) - const sheetFile = join(distDir, 'sheet.json') const classMapFile = join(distDir, 'classMap.json') const fileMapFile = join(distDir, 'fileMap.json') @@ -103,6 +92,62 @@ export function DevupUI( recursive: true, }) if (!existsSync(gitignoreFile)) writeFileSync(gitignoreFile, '*') + + // Boa is only needed to execute vanilla-extract-style `.css.ts`/`.css.js` + // modules. Build the graph before touching WASM so ordinary applications + // instantiate the much smaller engine, while vanilla-extract users retain + // the full evaluator automatically. If graph discovery fails, fail safe to + // the full engine. + const graphStartedAt = profileStart() + const srcDir = resolve(process.cwd(), 'src') + const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') + let staticGraph: StaticImportGraph | undefined + try { + staticGraph = buildStaticImportGraph(srcDir, tsconfigPath) + } catch { + // The mapping pass below reports the graph failure and keeps its legacy + // best-effort behavior. + } + const candidateCollectStartedAt = + graphStartedAt === undefined ? undefined : performance.now() + const wasmCandidateFiles = staticGraph + ? collectProductionPrewarmFiles({ + cwd: process.cwd(), + graph: staticGraph, + expectedBaseFiles: [], + libPackage, + include, + }) + : [] + const candidateCollectMs = elapsedMs(candidateCollectStartedAt) + const wasmVariant = selectWasmVariant(staticGraph, wasmCandidateFiles) + const wasm = loadWasm(wasmVariant === 'lite') + const { + codeExtract, + codeExtractWithoutSourceMap, + exportClassMap, + exportFileMap, + exportSheet, + getCss, + getDefaultTheme, + getThemeInterface, + importCanonicalMap, + importClassMap, + importFileMap, + importFileRoutes, + importSheet, + registerShorthands, + registerTheme, + setAtomHoist, + setPrefix, + } = wasm + + registerShorthands(shorthands ?? {}) + + if (prefix) { + setPrefix(prefix) + } + // Import previous session state to handle Turbopack persistent cache. // When the dev server restarts, Turbopack may skip re-running loaders for // unchanged files. Without importing previous state, the coordinator's WASM @@ -146,6 +191,8 @@ export function DevupUI( const atomMode = atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 const watch = process.env.NODE_ENV === 'development' + const sourceMap = watch || config.productionBrowserSourceMaps === true + const extract = sourceMap ? codeExtract : codeExtractWithoutSourceMap // 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 = {} @@ -153,14 +200,11 @@ export function DevupUI( // 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[] = [] - let staticGraph: StaticImportGraph | undefined try { - const srcDir = resolve(process.cwd(), 'src') - const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') + if (!staticGraph) throw new Error('Static import graph unavailable') const cwd = process.cwd() // One scan+parse of the source tree, shared by all three consumers below. - const graph = buildStaticImportGraph(srcDir, tsconfigPath) - staticGraph = graph + const graph = staticGraph // 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 @@ -209,9 +253,19 @@ export function DevupUI( ) } } + reportProfile('next.graph', { + durationMs: elapsedMs(graphStartedAt), + files: staticGraph.files.length, + expectedBaseFiles: expectedBaseFiles.length, + wasmVariant, + }) } catch { // Pre-pass is best-effort; on failure canonical() is the identity (no // merge) and atom hoisting stays off. + reportProfile('next.graph', { + durationMs: elapsedMs(graphStartedAt), + failed: true, + }) } // Turbopack can request a CSS module before it has scheduled every source @@ -223,37 +277,94 @@ export function DevupUI( // route graph cannot represent. Loader-time extraction uses the same // keys/options and is idempotent. const prewarmedFiles: string[] = [] + const prewarmedOutputs = new Map() if (!watch && staticGraph) { + const prewarmStartedAt = profileStart() + let prewarmExtractMs = 0 + let prewarmReadMs = 0 + let prewarmSourceBytes = 0 const cwd = process.cwd() - const prewarmFiles = collectProductionPrewarmFiles({ - cwd, - graph: staticGraph, - expectedBaseFiles, - libPackage, - include, - }) + // The same complete candidate set selected the WASM variant above. Reuse + // it here instead of resolving source/package entries a second time, + // while retaining any compiled-file fallback supplied by the graph pass. + const prewarmFiles = [ + ...new Set([...wasmCandidateFiles, ...expectedBaseFiles]), + ].sort() for (const filename of prewarmFiles) { const resourcePath = resolve(cwd, filename) const relCssDir = `./${relative( dirname(resourcePath), cssDir, ).replaceAll('\\', '/')}` - codeExtract( - filename, - readFileSync(resourcePath, 'utf-8'), - libPackage, - relCssDir, - singleCss, - false, - true, - importAliases as unknown as Record, + const readStartedAt = + prewarmStartedAt === undefined ? undefined : performance.now() + const source = readFileSync(resourcePath, 'utf-8') + if (readStartedAt !== undefined) { + prewarmReadMs += performance.now() - readStartedAt + prewarmSourceBytes += Buffer.byteLength(source) + } + const extractStartedAt = + prewarmStartedAt === undefined ? undefined : performance.now() + const output = takeExtractOutput( + extract( + filename, + source, + libPackage, + relCssDir, + singleCss, + false, + true, + importAliases as unknown as Record, + ), ) + if (extractStartedAt !== undefined) { + prewarmExtractMs += performance.now() - extractStartedAt + } + if (singleCss) { + prewarmedOutputs.set(filename, { + code: output.code, + cssFile: output.cssFile, + map: output.map, + source, + updatedBaseStyle: output.updatedBaseStyle, + }) + } prewarmedFiles.push(filename) } + reportProfile('next.prewarm', { + collectMs: candidateCollectMs, + durationMs: elapsedMs(prewarmStartedAt), + extractMs: + prewarmStartedAt === undefined + ? undefined + : Number(prewarmExtractMs.toFixed(2)), + files: prewarmedFiles.length, + readMs: + prewarmStartedAt === undefined + ? undefined + : Number(prewarmReadMs.toFixed(2)), + sourceBytes: prewarmSourceBytes, + }) } // create devup-ui.css file - writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false)) + const initialCssStartedAt = profileStart() + const initialCssSerializeStartedAt = + initialCssStartedAt === undefined ? undefined : performance.now() + const initialCss = getCss(null, false) + const initialCssSerializeMs = elapsedMs(initialCssSerializeStartedAt) + const initialCssWriteStartedAt = + initialCssStartedAt === undefined ? undefined : performance.now() + writeFileSync(join(cssDir, 'devup-ui.css'), initialCss) + reportProfile('next.initialCss', { + bytes: + initialCssStartedAt === undefined + ? undefined + : Buffer.byteLength(initialCss), + durationMs: elapsedMs(initialCssStartedAt), + serializeMs: initialCssSerializeMs, + writeMs: elapsedMs(initialCssWriteStartedAt), + }) // Delete stale port file from previous session so loaders don't connect // to a dead coordinator port. The new coordinator writes a fresh port file @@ -265,6 +376,7 @@ export function DevupUI( } const coordinator = startCoordinator({ + wasm, package: libPackage, cssDir, singleCss, @@ -276,15 +388,62 @@ export function DevupUI( canonicalMap, expectedBaseFiles, prewarmedFiles, + prewarmedOutputs, + sourceMap, }) // Cleanup on exit process.on('exit', () => { coordinator.close() }) - const defaultSheet = JSON.parse(exportSheet()) - const defaultClassMap = JSON.parse(exportClassMap()) - const defaultFileMap = JSON.parse(exportFileMap()) + const stateSnapshotStartedAt = profileStart() + const sheetSerializeStartedAt = + stateSnapshotStartedAt === undefined ? undefined : performance.now() + const defaultSheetJson = exportSheet() + const sheetSerializeMs = elapsedMs(sheetSerializeStartedAt) + const sheetParseStartedAt = + stateSnapshotStartedAt === undefined ? undefined : performance.now() + const defaultSheet = JSON.parse(defaultSheetJson) + const sheetParseMs = elapsedMs(sheetParseStartedAt) + + const classMapSerializeStartedAt = + stateSnapshotStartedAt === undefined ? undefined : performance.now() + const defaultClassMapJson = exportClassMap() + const classMapSerializeMs = elapsedMs(classMapSerializeStartedAt) + const classMapParseStartedAt = + stateSnapshotStartedAt === undefined ? undefined : performance.now() + const defaultClassMap = JSON.parse(defaultClassMapJson) + const classMapParseMs = elapsedMs(classMapParseStartedAt) + + const fileMapSerializeStartedAt = + stateSnapshotStartedAt === undefined ? undefined : performance.now() + const defaultFileMapJson = exportFileMap() + const fileMapSerializeMs = elapsedMs(fileMapSerializeStartedAt) + const fileMapParseStartedAt = + stateSnapshotStartedAt === undefined ? undefined : performance.now() + const defaultFileMap = JSON.parse(defaultFileMapJson) + const fileMapParseMs = elapsedMs(fileMapParseStartedAt) + reportProfile('next.stateSnapshot', { + classMapBytes: + stateSnapshotStartedAt === undefined + ? undefined + : Buffer.byteLength(defaultClassMapJson), + classMapParseMs, + classMapSerializeMs, + durationMs: elapsedMs(stateSnapshotStartedAt), + fileMapBytes: + stateSnapshotStartedAt === undefined + ? undefined + : Buffer.byteLength(defaultFileMapJson), + fileMapParseMs, + fileMapSerializeMs, + sheetBytes: + stateSnapshotStartedAt === undefined + ? undefined + : Buffer.byteLength(defaultSheetJson), + sheetParseMs, + sheetSerializeMs, + }) // for theme script const defaultTheme = getDefaultTheme() if (defaultTheme) { @@ -349,11 +508,19 @@ export function DevupUI( }, } Object.assign(config.turbopack.rules, rules) + reportProfile('next.setup', { + durationMs: elapsedMs(pluginStartedAt), + prewarmedFiles: prewarmedFiles.length, + singleCss, + wasmVariant, + watch, + }) return config } const { webpack } = config config.webpack = (config, _options) => { + const { DevupUIWebpackPlugin } = loadWebpackPlugin() options.cssDir ??= resolve( _options.dev ? (options.distDir ?? 'df') : '.next/cache', `devup-ui_${_options.buildId}`, diff --git a/packages/next-plugin/src/profile.ts b/packages/next-plugin/src/profile.ts new file mode 100644 index 00000000..35f75aa4 --- /dev/null +++ b/packages/next-plugin/src/profile.ts @@ -0,0 +1,26 @@ +type ProfileFields = Record + +export function isProfileEnabled(): boolean { + return process.env.DEVUP_UI_PROFILE === '1' +} + +export function reportProfile(phase: string, fields: ProfileFields = {}): void { + if (!isProfileEnabled()) return + + console.info( + `[devup-ui:profile] ${JSON.stringify({ + phase, + ...fields, + })}`, + ) +} + +export function profileStart(): number | undefined { + return isProfileEnabled() ? performance.now() : undefined +} + +export function elapsedMs(start: number | undefined): number | undefined { + if (start === undefined) return undefined + + return Number((performance.now() - start).toFixed(2)) +} diff --git a/packages/next-plugin/src/wasm.ts b/packages/next-plugin/src/wasm.ts new file mode 100644 index 00000000..c7d3e7cf --- /dev/null +++ b/packages/next-plugin/src/wasm.ts @@ -0,0 +1,58 @@ +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' + +export type DevupWasm = typeof import('@devup-ui/wasm') +export type DevupWebpackPlugin = typeof import('@devup-ui/webpack-plugin') + +let wasmForTesting: DevupWasm | undefined +let webpackPluginForTesting: DevupWebpackPlugin | undefined +let fullWasm: DevupWasm | undefined +let liteWasm: DevupWasm | undefined +let webpackPlugin: DevupWebpackPlugin | undefined + +function requireFromPlugin(specifier: string): T { + const installedPackage = join( + process.cwd(), + 'node_modules/@devup-ui/next-plugin/package.json', + ) + const workspacePackage = join( + process.cwd(), + 'packages/next-plugin/package.json', + ) + const requireBase = existsSync(installedPackage) + ? installedPackage + : existsSync(workspacePackage) + ? workspacePackage + : join(process.cwd(), 'package.json') + return createRequire(requireBase)(specifier) as T +} + +/** Load exactly one extraction engine for the lifetime of a Next config. */ +export function loadWasm(lite: boolean): DevupWasm { + if (wasmForTesting) return wasmForTesting + if (lite) { + return (liteWasm ??= requireFromPlugin('@devup-ui/wasm/lite')) + } + return (fullWasm ??= requireFromPlugin('@devup-ui/wasm')) +} + +/** Keep the Webpack adapter (and its full WASM) out of Turbopack startup. */ +export function loadWebpackPlugin(): DevupWebpackPlugin { + if (webpackPluginForTesting) return webpackPluginForTesting + return (webpackPlugin ??= requireFromPlugin( + '@devup-ui/webpack-plugin', + )) +} + +/** @internal Inject the WASM namespace for unit tests. */ +export function setWasmForTesting(value: DevupWasm | undefined): void { + wasmForTesting = value +} + +/** @internal Inject the Webpack namespace for unit tests. */ +export function setWebpackPluginForTesting( + value: DevupWebpackPlugin | undefined, +): void { + webpackPluginForTesting = value +} diff --git a/packages/react/src/types/props/index.ts b/packages/react/src/types/props/index.ts index 89761a6b..b77f0d5b 100644 --- a/packages/react/src/types/props/index.ts +++ b/packages/react/src/types/props/index.ts @@ -48,7 +48,11 @@ export interface DevupComponentProps< styleVars?: Record } export type DevupComponentBaseProps = - DevupElementTypeProps & DevupComponentAdditionalProps + NoInfer extends string + ? React.ComponentProps> & { + props?: FilterChildren>> + } + : DevupComponentAdditionalProps> export type DevupElementTypeProps = T extends string ? React.ComponentProps : object diff --git a/packages/react/type-tests/custom-shorthand.ts b/packages/react/type-tests/custom-shorthand.ts index e49b7a6a..ba6a6d32 100644 --- a/packages/react/type-tests/custom-shorthand.ts +++ b/packages/react/type-tests/custom-shorthand.ts @@ -1,4 +1,9 @@ import { Box, type DevupProps } from '../src' +import type { + DevupComponentAdditionalProps, + DevupComponentBaseProps, + DevupElementTypeProps, +} from '../src/types/props' // Mirrors the module augmentation emitted to /theme.d.ts. declare module '../src' { @@ -26,4 +31,48 @@ const boxProps: Parameters[0] = { }, } +// Polymorphic inference must come from `as` while preserving the exact native +// element props and their contextual event types. +Box({ + as: 'a', + href: '/docs', + onClick(event) { + return event.currentTarget.href + }, +}) + +Box({ + as: 'button', + onClick(event) { + return event.currentTarget.disabled + }, +}) + +// @ts-expect-error href is not a button prop +Box({ as: 'button', href: '/docs' }) + +function CustomLink(_props: { to: string }) { + return null +} + +Box({ as: CustomLink, props: { to: '/docs' } }) + +// @ts-expect-error required custom-component props stay required +Box({ as: CustomLink }) + +type Assert = T +type LegacyBaseProps = DevupElementTypeProps & + DevupComponentAdditionalProps +type IsEquivalent = + DevupComponentBaseProps extends LegacyBaseProps + ? LegacyBaseProps extends DevupComponentBaseProps + ? true + : false + : false + +type _DivPropsStayEquivalent = Assert> +type _AnchorPropsStayEquivalent = Assert> +type _ButtonPropsStayEquivalent = Assert> +type _CustomPropsStayEquivalent = Assert> + export { boxProps, customShorthandProps }