format!("{leading}'use client';\n{pragma}\n"),
+ "after" => format!("{leading}{pragma}\n'use client';\n"),
+ _ => format!("{leading}{pragma}\n"),
+ };
+ let source = format!(
+ "{header}import {{css}} from '@devup-ui/react';\nexport const cls=css({{width:'401px'}});\nexport const anchor=1;"
+ );
+ reset_class_map();
+ reset_file_map();
+
+ let output = if source_map {
+ extract(&filename, &source, ExtractOption::default())
+ } else {
+ extract_without_source_map(&filename, &source, ExtractOption::default())
+ }
+ .unwrap_or_else(|error| panic!("{error}"));
+
+ let arena = Allocator::default();
+ let parsed = Parser::new(
+ &arena,
+ &output.code,
+ SourceType::from_path(&filename).unwrap_or_else(|error| panic!("{error}")),
+ )
+ .parse();
+ assert_eq!(parsed.diagnostics.len(), 0, "{}", output.code);
+ let first_statement = parsed.program.body[0].span().start as usize;
+ assert!(
+ output.code[..first_statement].contains(pragma),
+ "{}",
+ output.code
+ );
+ assert_eq!(
+ parsed.program.directives.len(),
+ usize::from(directive != "none")
+ );
+ if let Some(map) = output.map {
+ let map = oxc_sourcemap::SourceMap::from_json_string(&map)
+ .unwrap_or_else(|error| panic!("{error}"));
+ assert_eq!(map.get_source_content(0), Some(source.as_str()));
+ let (line, generated) = output
+ .code
+ .lines()
+ .enumerate()
+ .find(|(_, text)| text.contains("anchor"))
+ .unwrap_or_else(|| panic!("missing generated anchor line"));
+ let column = generated
+ .find("anchor")
+ .unwrap_or_else(|| panic!("missing generated anchor column"));
+ let token = map
+ .get_tokens()
+ .find(|token| {
+ token.get_dst_line() as usize == line && token.get_dst_col() as usize == column
+ })
+ .unwrap_or_else(|| panic!("missing anchor source-map token"));
+ assert_eq!(
+ (token.get_src_line() as usize, token.get_src_col()),
+ (header.lines().count() + 2, 13)
+ );
+ } else {
+ assert!(!source_map);
+ }
+}
+
+#[rstest]
+#[serial]
+fn jsx_import_source_keeps_the_enabled_emotion_compatibility_bridge(
+ #[values("@emotion/react", "react", "preact", "custom")] import_source: &str,
+ #[values(true, false)] emotion_enabled: bool,
+) {
+ let source = format!(
+ "/** @jsxImportSource {import_source} */\nimport {{css}} from '@devup-ui/react';\nexport const cls=css({{width:'401px'}});\nexport const anchor=1;"
+ );
+ reset_class_map();
+ reset_file_map();
+ let aliases = if emotion_enabled {
+ HashMap::from([("@emotion/react".into(), ImportAlias::NamedToNamed)])
+ } else {
+ HashMap::new()
+ };
+
+ let output = extract(
+ "pragma.tsx",
+ &source,
+ ExtractOption {
+ import_aliases: aliases,
+ ..ExtractOption::default()
+ },
+ )
+ .unwrap_or_else(|error| panic!("{error}"));
+
+ let expected = if emotion_enabled && import_source == "@emotion/react" {
+ "react"
+ } else {
+ import_source
+ };
+ assert!(
+ output
+ .code
+ .starts_with(&format!("/** @jsxImportSource {expected} */\n")),
+ "{}",
+ output.code
+ );
+ let map = oxc_sourcemap::SourceMap::from_json_string(
+ output
+ .map
+ .as_deref()
+ .unwrap_or_else(|| panic!("missing source map")),
+ )
+ .unwrap_or_else(|error| panic!("{error}"));
+ assert_eq!(map.get_source_content(0), Some(source.as_str()));
+}
+
+#[test]
+#[serial]
+fn opted_out_emotion_only_file_is_unchanged() {
+ let source = "/** @jsxImportSource @emotion/react */\nimport {css} from '@emotion/react';\nexport const cls=css({color:'red'});";
+
+ let output = extract("pragma.tsx", source, ExtractOption::default())
+ .unwrap_or_else(|error| panic!("{error}"));
+
+ assert_eq!(output.code, source);
+ assert_eq!(output.map, None);
+}
+
+#[rstest]
+#[serial]
+fn enabled_emotion_pragma_is_rewritten_without_a_devup_import(
+ #[values(true, false)] namespace: bool,
+ #[values(true, false)] source_map: bool,
+) {
+ let namespace_code = if namespace {
+ "import * as E from '@emotion/css'; const N=E;\n"
+ } else {
+ ""
+ };
+ let source =
+ format!("/** @jsxImportSource @emotion/react */ {namespace_code}export const anchor=1;");
+ reset_class_map();
+ reset_file_map();
+ let options = ExtractOption {
+ import_aliases: HashMap::from([
+ ("@emotion/react".into(), ImportAlias::NamedToNamed),
+ ("@emotion/css".into(), ImportAlias::NamedToNamed),
+ ]),
+ ..ExtractOption::default()
+ };
+
+ let output = if source_map {
+ extract("pragma.tsx", &source, options)
+ } else {
+ extract_without_source_map("pragma.tsx", &source, options)
+ }
+ .unwrap_or_else(|error| panic!("{error}"));
+
+ assert!(
+ output.code.starts_with("/** @jsxImportSource react */\n"),
+ "{}",
+ output.code
+ );
+ if source_map {
+ crate::pragma_path_tests::assert_anchor_map(&source, &output);
+ } else {
+ assert_eq!(output.map, None);
+ }
+}
+
+#[test]
+#[serial]
+fn complete_pragma_header_keeps_directive_order() {
+ let source = "/*! License */\n/** @jsxRuntime classic */\n'use client';\n/** @jsx createElement */\n/** @jsxFrag Fragment */\n/** @jsxImportSource custom-jsx */\nimport {Box,css} from '@devup-ui/react';\nexport const cls=css({width:'401px'});\nexport const box=
;";
+ reset_class_map();
+ reset_file_map();
+
+ let output = extract(
+ "pragma.tsx",
+ source,
+ ExtractOption {
+ single_css: true,
+ ..ExtractOption::default()
+ },
+ )
+ .unwrap_or_else(|error| panic!("{error}"));
+
+ insta::assert_snapshot!(output.code);
+}
diff --git a/libs/extractor/src/snapshots/extractor__pragma_tests__complete_pragma_header_keeps_directive_order.snap b/libs/extractor/src/snapshots/extractor__pragma_tests__complete_pragma_header_keeps_directive_order.snap
new file mode 100644
index 000000000..847f87d7b
--- /dev/null
+++ b/libs/extractor/src/snapshots/extractor__pragma_tests__complete_pragma_header_keeps_directive_order.snap
@@ -0,0 +1,13 @@
+---
+source: libs/extractor/src/pragma_tests.rs
+expression: output.code
+---
+/*! License */
+/** @jsxRuntime classic */
+"use client";
+/** @jsx createElement */
+/** @jsxFrag Fragment */
+/** @jsxImportSource custom-jsx */
+import "@devup-ui/react/devup-ui.css";
+export const cls = "a";
+export const box =
;
diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs
index c659ddd38..a9a43a80e 100644
--- a/libs/extractor/src/utils.rs
+++ b/libs/extractor/src/utils.rs
@@ -281,50 +281,16 @@ pub(super) fn get_number_by_literal_expression(expr: &Expression) -> Option
get_number_by_literal_expression(&parenthesized.expression)
}
Expression::StringLiteral(sl) => sl.value.parse::().ok(),
- Expression::TemplateLiteral(tmp) => {
- // `f64::from_str` succeeds only when every byte belongs to the
- // float grammar: ASCII digits, sign/exponent punctuation, or the
- // letters of the `inf`/`infinity`/`nan` keywords. An allocation-free
- // scan that is STRICTLY more permissive than the parser lets us bail
- // out (returning `None`) on the common non-numeric case — e.g. a
- // `${x}px` template's quasis containing `p`/`x` — without collecting
- // the quasis into a throwaway `String`. Whenever the parser would
- // have returned `Some`, every byte passes this scan, so the result
- // stays byte-identical.
- let can_be_float = tmp.quasis.iter().all(|q| {
- q.value.raw.bytes().all(|b| {
- b.is_ascii_digit()
- || matches!(
- b,
- b'.' | b'+'
- | b'-'
- | b'e'
- | b'E'
- | b'i'
- | b'I'
- | b'n'
- | b'N'
- | b'f'
- | b'F'
- | b'a'
- | b'A'
- | b't'
- | b'T'
- | b'y'
- | b'Y'
- )
- })
- });
- if can_be_float {
- tmp.quasis
- .iter()
- .map(|q| q.value.raw.as_str())
- .collect::()
- .parse::()
- .ok()
- } else {
- None
+ Expression::TemplateLiteral(_) => {
+ let text = get_template_interpolation_string(expr)?;
+ let unsigned = text.strip_prefix(['+', '-']).unwrap_or(&text);
+ // Rust accepts infinity aliases that JavaScript Number rejects.
+ if unsigned.eq_ignore_ascii_case("inf")
+ || (unsigned.eq_ignore_ascii_case("infinity") && unsigned != "Infinity")
+ {
+ return None;
}
+ text.parse().ok()
}
Expression::NumericLiteral(num) => Some(num.value),
Expression::UnaryExpression(unary) => get_number_by_literal_expression(&unary.argument)
@@ -371,28 +337,42 @@ pub(crate) fn js_number_string(number: f64) -> String {
}
}
+/// Template substitutions preserve string spelling rather than CSS numeric normalization.
+fn get_template_interpolation_string<'a>(expr: &Expression<'a>) -> Option> {
+ match expr {
+ Expression::StringLiteral(value) => Some(Cow::Borrowed(value.value.as_str())),
+ Expression::ParenthesizedExpression(value) => {
+ get_template_interpolation_string(&value.expression)
+ }
+ Expression::TemplateLiteral(template) => {
+ let mut text = String::new();
+ for (index, quasi) in template.quasis.iter().enumerate() {
+ text.push_str(quasi.value.cooked.as_deref()?);
+ if let Some(expression) = template.expressions.get(index) {
+ text.push_str(&get_template_interpolation_string(expression)?);
+ }
+ }
+ Some(Cow::Owned(text))
+ }
+ _ => get_string_by_literal_expression(expr),
+ }
+}
+
pub(super) fn get_string_by_literal_expression<'a>(expr: &Expression<'a>) -> Option> {
+ match expr {
+ Expression::ParenthesizedExpression(value) => {
+ return get_string_by_literal_expression(&value.expression);
+ }
+ Expression::TemplateLiteral(_) => return get_template_interpolation_string(expr),
+ _ => {}
+ }
get_number_by_literal_expression(expr)
.map(|num| Cow::Owned(js_number_string(num)))
.or_else(|| match expr {
- Expression::ParenthesizedExpression(parenthesized) => {
- get_string_by_literal_expression(&parenthesized.expression)
- }
Expression::StringLiteral(str) => Some(Cow::Borrowed(str.value.as_str())),
Expression::BooleanLiteral(bool) => {
Some(Cow::Borrowed(if bool.value { "true" } else { "false" }))
}
- Expression::TemplateLiteral(tmp) => {
- let mut collect = String::new();
- for (idx, q) in tmp.quasis.iter().enumerate() {
- collect.push_str(q.value.raw.as_str());
- if idx < tmp.expressions.len() {
- let value = get_string_by_literal_expression(&tmp.expressions[idx])?;
- collect.push_str(&value);
- }
- }
- Some(Cow::Owned(collect))
- }
_ => None,
})
}
diff --git a/packages/bun-plugin/__regression__/jsx-pragmas.bun.ts b/packages/bun-plugin/__regression__/jsx-pragmas.bun.ts
new file mode 100644
index 000000000..6773acdf9
--- /dev/null
+++ b/packages/bun-plugin/__regression__/jsx-pragmas.bun.ts
@@ -0,0 +1,67 @@
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+
+import { expect, it } from 'bun:test'
+
+const registerEntry = resolve(import.meta.dir, '..', 'dist', 'register.mjs')
+
+it('honors classic JSX pragmas after extracting direct Devup imports', () => {
+ const cwd = mkdtempSync(join(tmpdir(), 'devup-jsx-pragmas-'))
+ try {
+ writeFileSync(join(cwd, 'bunfig.toml'), '')
+ writeFileSync(
+ join(cwd, 'consumer.tsx'),
+ `/** @jsxRuntime classic */
+/** @jsx createElement */
+import {Box,css} from '@devup-ui/react';
+function createElement(type,props){return {type,props};}
+export const cls=css({width:'401px'});
+export const box=;`,
+ )
+ writeFileSync(
+ join(cwd, 'check.ts'),
+ `import { expect } from 'bun:test'
+import { DevupUI } from ${JSON.stringify(registerEntry.replaceAll('\\', '/'))}
+
+const artifacts = []
+for (const attempt of [0, 1]) {
+ const result = await Bun.build({
+ entrypoints: ['./consumer.tsx'],
+ outdir: './out',
+ target: 'bun',
+ plugins: [DevupUI()],
+ })
+ expect(result.success, result.logs.map(String).join('\\n')).toBe(true)
+ const js = result.outputs.find(output => output.path.endsWith('.js'))
+ const css = result.outputs.find(output => output.path.endsWith('.css'))
+ expect(js).toBeDefined()
+ expect(css).toBeDefined()
+ const code = await js.text()
+ const styles = (await css.text()).replace(/\\s+/g, '')
+ expect(code).not.toMatch(/react\\/jsx|Cannot run on the runtime|@devup-ui\\/react/)
+ expect(styles).toContain('width:401px')
+ expect(styles).toContain('width:403px')
+ const { box, cls } = await import(js.path + '?attempt=' + attempt)
+ expect(box.type).toBe('div')
+ expect(typeof box.props.className).toBe('string')
+ expect(typeof cls).toBe('string')
+ artifacts.push([code, styles])
+}
+expect(artifacts[0]).toEqual(artifacts[1])
+`,
+ )
+ const result = Bun.spawnSync([process.execPath, 'run', 'check.ts'], {
+ cwd,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' },
+ })
+ expect(
+ result.exitCode,
+ result.stdout.toString() + result.stderr.toString(),
+ ).toBe(0)
+ } finally {
+ rmSync(cwd, { recursive: true, force: true })
+ }
+})
diff --git a/packages/bun-plugin/package.json b/packages/bun-plugin/package.json
index 0542861cf..43b088da0 100644
--- a/packages/bun-plugin/package.json
+++ b/packages/bun-plugin/package.json
@@ -22,7 +22,7 @@
"scripts": {
"lint": "eslint",
"build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.cjs.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external && bun build --target node src/register.ts --production --env=disable --outfile dist/register.cjs --format cjs --packages external && bun build --target node src/register.ts --production --env=disable --outfile dist/register.mjs --format esm --packages external",
- "test:regression": "cd __regression__ && bun test ./preload-race.bun.ts ./worktree-isolation.bun.ts ./css-emit.bun.ts"
+ "test:regression": "cd __regression__ && bun test ./preload-race.bun.ts ./worktree-isolation.bun.ts ./css-emit.bun.ts ./jsx-pragmas.bun.ts"
},
"publishConfig": {
"access": "public"
diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts
index 55783c0c0..ce47e44f7 100644
--- a/packages/next-plugin/src/__tests__/plugin.test.ts
+++ b/packages/next-plugin/src/__tests__/plugin.test.ts
@@ -194,7 +194,8 @@ describe('DevupUINextPlugin', () => {
ret.webpack!(createWebpackConfig(), createWebpackContext())
expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({
- cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'),
+ cssDir: resolve('.next/cache', 'devup-ui'),
+ productionPrewarmFiles: [],
})
})
@@ -221,7 +222,8 @@ describe('DevupUINextPlugin', () => {
expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({
package: 'new-package',
- cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'),
+ cssDir: resolve('.next/cache', 'devup-ui'),
+ productionPrewarmFiles: [],
})
})
@@ -240,7 +242,8 @@ describe('DevupUINextPlugin', () => {
expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({
package: 'new-package',
- cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'),
+ cssDir: resolve('.next/cache', 'devup-ui'),
+ productionPrewarmFiles: [],
})
expect(webpack).toHaveBeenCalled()
})
diff --git a/packages/next-plugin/src/__tests__/prewarm.test.ts b/packages/next-plugin/src/__tests__/prewarm.test.ts
index 9486a1a7c..1c2a5da48 100644
--- a/packages/next-plugin/src/__tests__/prewarm.test.ts
+++ b/packages/next-plugin/src/__tests__/prewarm.test.ts
@@ -119,4 +119,50 @@ describe('collectProductionPrewarmFiles', () => {
}),
).toEqual(['src/app/page.tsx'])
})
+
+ it('follows accepted barrels, relative cycles, lazy modules and importer-local accepted dependencies', () => {
+ // Given a package barrel with a nested dependency and a rejected sibling.
+ writePackage('design-system', './index.cjs', {
+ 'index.cjs': '',
+ 'index.mjs': 'export * from "./child.js"; import("./lazy.js")',
+ 'child.js':
+ 'export * from "./index.mjs"; import "@devup-ui/nested"; import "rejected"',
+ 'lazy.js': 'export const lazy = 1',
+ 'node_modules/@devup-ui/nested/package.json': '{"main":"index.js"}',
+ 'node_modules/@devup-ui/nested/index.js': 'export const nested = 1',
+ 'node_modules/rejected/package.json': '{"main":"index.js"}',
+ 'node_modules/rejected/index.js': 'export const rejected = 1',
+ })
+ // When collecting the complete extraction set.
+ const files = collectProductionPrewarmFiles({
+ cwd,
+ graph: makeGraph([], ['design-system']),
+ expectedBaseFiles: [],
+ libPackage: '@devup-ui/react',
+ include: ['design-system'],
+ })
+ // Then the closure is complete, deduplicated and policy-filtered.
+ expect(files).toEqual([
+ 'node_modules/design-system/child.js',
+ 'node_modules/design-system/index.mjs',
+ 'node_modules/design-system/lazy.js',
+ 'node_modules/design-system/node_modules/@devup-ui/nested/index.js',
+ ])
+ })
+
+ it('propagates a genuine accepted-package collection error', () => {
+ // Given a malformed accepted package manifest.
+ writePackage('design-system', './index.js', { 'index.js': '' })
+ writeFileSync(join(cwd, 'node_modules/design-system/package.json'), '{')
+ // When collecting it, then the build cannot silently omit its styles.
+ expect(() =>
+ collectProductionPrewarmFiles({
+ cwd,
+ graph: makeGraph([], ['design-system']),
+ expectedBaseFiles: [],
+ libPackage: '@devup-ui/react',
+ include: ['design-system'],
+ }),
+ ).toThrow()
+ })
})
diff --git a/packages/next-plugin/src/__tests__/webpack-delivery-fixture.cjs b/packages/next-plugin/src/__tests__/webpack-delivery-fixture.cjs
new file mode 100644
index 000000000..1d1ce8228
--- /dev/null
+++ b/packages/next-plugin/src/__tests__/webpack-delivery-fixture.cjs
@@ -0,0 +1,80 @@
+module.exports = function deliveryFixture(put) {
+ const singleCss = process.env.DEVUP_NEXT_SINGLE_CSS === '1'
+ put(
+ 'devup.json',
+ JSON.stringify({ theme: { colors: { default: { proof: '#123456' } } } }),
+ )
+ put(
+ 'next.config.js',
+ `
+ const { DevupUI } = require('@devup-ui/next-plugin');
+ module.exports = DevupUI({
+ experimental: { cpus: 1, webpackBuildWorker: true },
+ transpilePackages: ['delivery-library'],
+ webpack(config, context) {
+ console.info('W65-TOPOLOGY ' + JSON.stringify({ pid: process.pid, threadId: require('node:worker_threads').threadId, isServer: context.isServer, nextRuntime: context.nextRuntime || 'client' }));
+ return config;
+ }
+ }, { singleCss: ${singleCss}, include: ['delivery-library'] });
+ `,
+ )
+ put(
+ 'src/app/layout.js',
+ `
+ import { Box, globalCss } from '@devup-ui/react';
+ globalCss({ body: { background: '#040', color: '#248' } });
+ export default function Layout({ children }) {
+ return {children};
+ }
+ `,
+ )
+ put(
+ 'src/app/page.js',
+ `
+ import { Box } from '@devup-ui/react';
+ import { Child } from 'delivery-library';
+ import Client from './client';
+ export default function Page() {
+ return <>server-proofexternal-proof>;
+ }
+ `,
+ )
+ put(
+ 'src/app/client.js',
+ `
+ 'use client';
+ import { Box } from '@devup-ui/react';
+ export default function Client() { return client-proof; }
+ `,
+ )
+ put('src/app/client/page.js', `export { default } from '../client';`)
+ put(
+ 'src/pages/edge-page.js',
+ `
+ import { Box, css } from '@devup-ui/react';
+ export const config = { runtime: 'experimental-edge' };
+ const edgePage = css({ color: 'goldenrod', padding: '19px' });
+ export default function EdgePage() { return edge-page-proof
; }
+ `,
+ )
+ put(
+ 'node_modules/delivery-library/package.json',
+ JSON.stringify({
+ name: 'delivery-library',
+ version: '1.0.0',
+ type: 'module',
+ main: './index.mjs',
+ }),
+ )
+ put(
+ 'node_modules/delivery-library/index.mjs',
+ `export { Child } from './child.mjs';`,
+ )
+ put(
+ 'node_modules/delivery-library/child.mjs',
+ `
+ import { styled } from '@devup-ui/react';
+ export const Child = styled('strong', { color: 'rebeccapurple', marginLeft: '17px' });
+ `,
+ )
+}
diff --git a/packages/next-plugin/src/__tests__/webpack-delivery-http.cjs b/packages/next-plugin/src/__tests__/webpack-delivery-http.cjs
new file mode 100644
index 000000000..f954edf74
--- /dev/null
+++ b/packages/next-plugin/src/__tests__/webpack-delivery-http.cjs
@@ -0,0 +1,156 @@
+const assert = require('node:assert/strict')
+
+const colors = {
+ maroon: '#800000',
+ cyan: '#00ffff',
+ aqua: '#00ffff',
+ white: '#ffffff',
+ goldenrod: '#daa520',
+ rebeccapurple: '#663399',
+}
+function normalize(value) {
+ const lower = value.trim().toLowerCase()
+ if (colors[lower]) return colors[lower]
+ if (/^#[\da-f]{3}$/.test(lower))
+ return '#' + [...lower.slice(1)].map((digit) => digit + digit).join('')
+ const rgb = lower.match(
+ /^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:\s*[,/]\s*1)?\s*\)$/,
+ )
+ return rgb
+ ? '#' +
+ rgb
+ .slice(1)
+ .map((part) => Number(part).toString(16).padStart(2, '0'))
+ .join('')
+ : lower
+}
+function rules(css, parse) {
+ const result = new Map()
+ parse(css).walkRules((rule) => {
+ for (const selector of rule.selectors) {
+ const declarations = result.get(selector) || new Map()
+ rule.walkDecls((decl) =>
+ declarations.set(decl.prop, normalize(decl.value)),
+ )
+ result.set(selector, declarations)
+ }
+ })
+ return result
+}
+function declarationProof(parsed, selectors, expected) {
+ const declarations = new Map(
+ selectors.flatMap((selector) => [...(parsed.get(selector) || [])]),
+ )
+ for (const [property, value] of Object.entries(expected))
+ assert.equal(
+ declarations.get(property),
+ normalize(value),
+ `${selectors.join(' ')} missing/wrong ${property}; actual ${JSON.stringify([...declarations])}`,
+ )
+ return [...declarations].sort(([a], [b]) => a.localeCompare(b))
+}
+function elementProof(html, parsed, proof) {
+ const tag = [...html.matchAll(/<[a-z][^>]*>/gi)]
+ .map(([value]) => value)
+ .find((value) => value.includes(`id="${proof.id}"`))
+ assert.ok(tag, `missing SSR element ${proof.id}`)
+ const classes = tag.match(/\bclass="([^"]+)"/)?.[1].split(/\s+/)
+ assert.ok(classes?.length, `missing actual classes for ${proof.id}`)
+ for (const name of classes) {
+ assert.match(name, /^[\w-]+$/)
+ assert.ok(
+ parsed.get(`.${name}`)?.size,
+ `${proof.id}: actual class .${name} has no linked declarations`,
+ )
+ }
+ return {
+ id: proof.id,
+ classes: [...classes].sort(),
+ declarations: declarationProof(
+ parsed,
+ classes.map((name) => `.${name}`),
+ proof.expected,
+ ),
+ }
+}
+async function documentProof(base, parse, page) {
+ const response = await fetch(new URL(page.path, base), {
+ signal: AbortSignal.timeout(10000),
+ })
+ assert.equal(response.status, 200, `${page.path} HTML status`)
+ assert.match(response.headers.get('content-type') || '', /text\/html/)
+ const html = await response.text()
+ const links = [...html.matchAll(/]*>/g)]
+ .map(([tag]) => tag)
+ .filter((tag) => /\brel="stylesheet"/.test(tag))
+ .map((tag) => tag.match(/\bhref="([^"]+)"/)?.[1].replaceAll('&', '&'))
+ assert.ok(links.length > 0, `${page.path} must link CSS`)
+ const sheets = []
+ for (const link of [...new Set(links)]) {
+ assert.ok(link, 'stylesheet link must have href')
+ const response = await fetch(new URL(link, base), {
+ signal: AbortSignal.timeout(10000),
+ })
+ assert.equal(response.status, 200, `${page.path}: stylesheet ${link}`)
+ assert.match(response.headers.get('content-type') || '', /text\/css/)
+ const css = await response.text()
+ assert.ok(css.trim().length > 0, `${link} empty CSS`)
+ sheets.push({ url: link, css })
+ }
+ const parsed = rules(sheets.map(({ css }) => css).join('\n'), parse)
+ const elements = [
+ ...page.elements,
+ { id: 'layout-proof', expected: { color: 'white' } },
+ ].map((proof) => elementProof(html, parsed, proof))
+ const body = declarationProof(parsed, ['body'], {
+ background: '#040',
+ color: '#248',
+ })
+ const theme = [...parsed.values()].some(
+ (declarations) => declarations.get('--proof') === '#123456',
+ )
+ assert.ok(theme, `${page.path} linked theme --proof missing`)
+ return { path: page.path, elements, body, theme, sheets }
+}
+module.exports = async function deliveryHttp(base, parse) {
+ const pages = [
+ {
+ path: '/',
+ elements: [
+ {
+ id: 'server-proof',
+ expected: { background: 'maroon', padding: '13px' },
+ },
+ { id: 'client-proof', expected: { color: 'cyan' } },
+ {
+ id: 'external-proof',
+ expected: { color: 'rebeccapurple', 'margin-left': '17px' },
+ },
+ ],
+ },
+ {
+ path: '/client',
+ elements: [{ id: 'client-proof', expected: { color: 'cyan' } }],
+ },
+ {
+ path: '/edge-page',
+ elements: [
+ {
+ id: 'edge-page-proof',
+ expected: { color: 'goldenrod', padding: '19px' },
+ },
+ ],
+ },
+ ]
+ const documents = []
+ for (const page of pages)
+ documents.push(await documentProof(base, parse, page))
+ console.info('W65-HTTP ' + JSON.stringify(documents))
+ // Compare semantic stylesheet contents/class bindings, never Next build-id URLs/manifests.
+ return documents.map(({ sheets, ...document }) => ({
+ ...document,
+ css: sheets.map(({ css }) => css).sort(),
+ }))
+}
+module.exports.rules = rules
+module.exports.declarationProof = declarationProof
diff --git a/packages/next-plugin/src/__tests__/webpack-delivery-http.test.js b/packages/next-plugin/src/__tests__/webpack-delivery-http.test.js
new file mode 100644
index 000000000..d4a824994
--- /dev/null
+++ b/packages/next-plugin/src/__tests__/webpack-delivery-http.test.js
@@ -0,0 +1,18 @@
+import { spawnSync } from 'node:child_process'
+import { join } from 'node:path'
+
+import { expect, it } from 'bun:test'
+
+it('rejects broken HTTP delivery boundaries in the native Node surface', () => {
+ // Given native Node fetch, isolated from the suite DOM preload.
+ // When real HTTP documents or their linked CSS violate delivery contracts.
+ const result = spawnSync(
+ 'node',
+ [join(import.meta.dir, 'webpack-delivery-negative.cjs')],
+ { encoding: 'utf8' },
+ )
+ // Then each rejection must identify the broken boundary, not a later error.
+ console.info(result.stdout + result.stderr)
+ expect(result.error).toBeUndefined()
+ expect(result.status).toBe(0)
+})
diff --git a/packages/next-plugin/src/__tests__/webpack-delivery-negative.cjs b/packages/next-plugin/src/__tests__/webpack-delivery-negative.cjs
new file mode 100644
index 000000000..5a1fa6cc1
--- /dev/null
+++ b/packages/next-plugin/src/__tests__/webpack-delivery-negative.cjs
@@ -0,0 +1,55 @@
+const assert = require('node:assert/strict')
+const { once } = require('node:events')
+const { createServer } = require('node:http')
+const { createRequire } = require('node:module')
+const fromNext = createRequire(require.resolve('next/package.json'))
+const delivery = require('./webpack-delivery-http.cjs')
+const failures = {
+ 'document-status': /HTML status/,
+ 'css-status': /stylesheet \/proof.css/,
+ 'empty-css': /empty CSS/,
+ 'unbound-class': /actual class \.proof has no linked declarations/,
+ 'wrong-value': /missing\/wrong background/,
+}
+async function main() {
+ for (const [failure, message] of Object.entries(failures)) {
+ const server = createServer((request, response) => {
+ const css = request.url === '/proof.css'
+ response.statusCode =
+ (css && failure === 'css-status') ||
+ (!css && failure === 'document-status')
+ ? 404
+ : 200
+ response.setHeader('Content-Type', css ? 'text/css' : 'text/html')
+ response.end(
+ css
+ ? failure === 'empty-css'
+ ? ''
+ : failure === 'unbound-class'
+ ? '.other{background:maroon;padding:13px}'
+ : '.proof{background:cyan;padding:13px}'
+ : '',
+ )
+ })
+ server.listen(0, '127.0.0.1')
+ await once(server, 'listening')
+ try {
+ await assert.rejects(
+ delivery(
+ `http://127.0.0.1:${server.address().port}`,
+ fromNext('postcss').parse,
+ ),
+ message,
+ )
+ console.info('W65-HTTP-REJECTION ' + failure)
+ } finally {
+ await new Promise((resolve, reject) =>
+ server.close((error) => (error ? reject(error) : resolve())),
+ )
+ }
+ }
+}
+main().catch((error) => {
+ console.error(error)
+ process.exitCode = 1
+})
diff --git a/packages/next-plugin/src/__tests__/webpack-production.cjs b/packages/next-plugin/src/__tests__/webpack-production.cjs
index 76dd7f00a..379885aad 100644
--- a/packages/next-plugin/src/__tests__/webpack-production.cjs
+++ b/packages/next-plugin/src/__tests__/webpack-production.cjs
@@ -1,10 +1,13 @@
const assert = require('node:assert/strict')
const { spawn } = require('node:child_process')
const {
+ existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
+ rmSync,
+ statSync,
symlinkSync,
writeFileSync,
} = require('node:fs')
@@ -48,41 +51,15 @@ function contents(directory, extension) {
})
.join('\n')
}
-async function main() {
+async function run() {
let server
try {
- // Fixture shape and dependency junctions credit W64c's physical Next evidence.
- for (const name of ['next', 'react', 'react-dom'])
- link(name, dirname(fromNext.resolve(`${name}/package.json`)))
- for (const name of ['react', 'next-plugin'])
- link(`@devup-ui/${name}`, join(repository, 'packages', name))
- put(
- 'package.json',
- JSON.stringify({ name: 'w65-next-webpack', private: true }),
- )
- put('devup.json', '{}')
- put(
- 'next.config.js',
- `const { DevupUI } = require('@devup-ui/next-plugin'); module.exports = DevupUI({ experimental: { cpus: 1 } });`,
- )
if (fault === 'build-timeout') {
put(
'next.config.js',
`require('node:child_process').spawn(process.execPath, [${JSON.stringify(join(__dirname, 'webpack-lifecycle-child.cjs'))}], { stdio: 'inherit' }); module.exports = async () => new Promise(() => {});`,
)
}
- put(
- 'src/app/layout.js',
- 'export default function Layout({ children }) { return {children}; }',
- )
- put(
- 'src/app/page.js',
- `import { Box } from '@devup-ui/react'; export default function Page() { return server-proof; }`,
- )
- put(
- 'src/app/edge/route.js',
- `import { css } from '@devup-ui/react'; export const runtime = 'edge'; const edge = css({ borderColor: 'goldenrod' }); export function GET() { return Response.json({ edge }); }`,
- )
const build = spawn(
process.execPath,
[fromPlugin.resolve('next/dist/bin/next'), 'build', '--webpack'],
@@ -179,7 +156,26 @@ async function main() {
edge: edgeBody,
}),
)
- console.info('webpack production proof passed')
+ const delivery = require('./webpack-delivery-http.cjs')
+ const parse = fromNext('postcss').parse
+ const documents = await delivery(base, parse)
+ const emitted = delivery.rules(
+ contents(join(root, '.next/static/css'), '.css'),
+ parse,
+ )
+ delivery.declarationProof(
+ emitted,
+ edgeBody.edge.split(/\s+/).map((name) => `.${name}`),
+ { 'border-color': 'goldenrod' },
+ )
+ console.info(
+ 'W65-API-DISK ' +
+ JSON.stringify({
+ classes: edgeBody.edge,
+ scope: 'emitted CSS only; API has no HTML stylesheet contract',
+ }),
+ )
+ return documents
} finally {
if (
server?.pid !== undefined &&
@@ -200,6 +196,55 @@ async function main() {
// Only the external owner may remove root, after its entire tree has exited.
}
}
+async function main() {
+ // Fixture shape and dependency junctions credit W64c's physical Next evidence.
+ for (const name of ['next', 'react', 'react-dom'])
+ link(name, dirname(fromNext.resolve(`${name}/package.json`)))
+ for (const name of ['react', 'next-plugin'])
+ link(`@devup-ui/${name}`, join(repository, 'packages', name))
+ put(
+ 'package.json',
+ JSON.stringify({ name: 'w65-next-webpack', private: true }),
+ )
+ put(
+ 'src/app/edge/route.js',
+ `import { css } from '@devup-ui/react'; export const runtime = 'edge'; const edge = css({ borderColor: 'goldenrod' }); export function GET() { return Response.json({ edge }); }`,
+ )
+ require('./webpack-delivery-fixture.cjs')(put)
+ let baseline
+ for (const cache of fault ? ['cold'] : ['cold', 'cold-repeat', 'warm']) {
+ if (cache === 'cold-repeat') {
+ rmSync(join(root, '.next'), { recursive: true, force: true })
+ rmSync(join(root, 'df'), { recursive: true, force: true })
+ }
+ console.info('W65-CACHE ' + cache)
+ if (cache === 'warm') {
+ const directory = join(root, '.next/cache/webpack')
+ const files = readdirSync(directory, { recursive: true })
+ .filter((name) => /\.pack(?:\.gz|\.br)?$/.test(name))
+ .map((name) => ({ name, bytes: statSync(join(directory, name)).size }))
+ assert.ok(
+ files.some(({ bytes }) => bytes > 0),
+ 'warm run requires populated webpack cache',
+ )
+ console.info('W65-WARM-CACHE ' + JSON.stringify(files))
+ } else
+ assert.equal(
+ existsSync(join(root, '.next')),
+ false,
+ `${cache} must start without Next output/cache`,
+ )
+ const actual = await run()
+ if (baseline)
+ assert.deepEqual(
+ actual,
+ baseline,
+ `${cache}: HTTP CSS/class correspondence changed`,
+ )
+ else baseline = actual
+ }
+ console.info('webpack production proof passed')
+}
main()
.then(() => process.exit(0))
.catch((error) => {
diff --git a/packages/next-plugin/src/__tests__/webpack-production.test.js b/packages/next-plugin/src/__tests__/webpack-production.test.js
index 3a3fe74ba..b1e3d773e 100644
--- a/packages/next-plugin/src/__tests__/webpack-production.test.js
+++ b/packages/next-plugin/src/__tests__/webpack-production.test.js
@@ -4,6 +4,29 @@ import { join } from 'node:path'
import { expect, it } from 'bun:test'
+for (const singleCss of ['0', '1'])
+ it(`delivers actual HTML-linked CSS through cold/repeat/warm webpack builds (singleCss=${singleCss})`, () => {
+ // Given identical physical server, edge, client and accepted-package fixtures.
+ // When real production documents and their stylesheet URLs are requested.
+ const result = spawnSync(
+ 'node',
+ [join(import.meta.dir, 'webpack-owned.cjs')],
+ {
+ encoding: 'utf8',
+ maxBuffer: 16 * 1024 * 1024,
+ env: { ...process.env, DEVUP_NEXT_SINGLE_CSS: singleCss },
+ },
+ )
+ // Then the driver verifies class-bound declarations and cache stability.
+ console.info(result.stdout + result.stderr)
+ expect(result.error).toBeUndefined()
+ expect(result.status).toBe(0)
+ expect(result.stdout).toContain('webpack production proof passed')
+ expect(result.stdout).toContain('"event":"tree_exit"')
+ expect(result.stdout).toContain('"active":[]')
+ expect(result.stdout).toContain('"removed":true')
+ }, 130000)
+
for (const fault of [
'',
'build-timeout',
diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts
index 69749e0d5..f854ac1b2 100644
--- a/packages/next-plugin/src/plugin.ts
+++ b/packages/next-plugin/src/plugin.ts
@@ -643,16 +643,38 @@ export function DevupUI(
}
const { webpack } = config
+ let productionPrewarmFiles: readonly string[] | undefined
config.webpack = (config, _options) => {
const { DevupUIWebpackPlugin } = loadWebpackPlugin()
options.cssDir ??= resolve(
_options.dev ? (options.distDir ?? 'df') : '.next/cache',
- `devup-ui_${_options.buildId}`,
+ _options.dev ? `devup-ui_${_options.buildId}` : 'devup-ui',
)
+ if (!_options.dev && productionPrewarmFiles === undefined) {
+ const cwd = process.cwd()
+ const srcDir = resolve(cwd, 'src')
+ const tsconfigPath = resolve(cwd, 'tsconfig.json')
+ const graph = buildStaticImportGraph(srcDir, tsconfigPath)
+ productionPrewarmFiles = collectProductionPrewarmFiles({
+ cwd,
+ graph,
+ expectedBaseFiles: computeCompiledFiles({
+ cwd,
+ srcDir,
+ tsconfigPath,
+ graph,
+ }),
+ libPackage: options.package ?? '@devup-ui/react',
+ include: options.include ?? [],
+ })
+ }
config.plugins.push(
new DevupUIWebpackPlugin({
...options,
watch: _options.dev,
+ productionPrewarmFiles: _options.dev
+ ? undefined
+ : productionPrewarmFiles,
}),
)
if (typeof webpack === 'function') return webpack(config, _options)
diff --git a/packages/next-plugin/src/prewarm.ts b/packages/next-plugin/src/prewarm.ts
index 03354ff5c..04a9cfd38 100644
--- a/packages/next-plugin/src/prewarm.ts
+++ b/packages/next-plugin/src/prewarm.ts
@@ -1,8 +1,11 @@
-import { existsSync } from 'node:fs'
-import { createRequire } from 'node:module'
-import { extname, join, relative, resolve } from 'node:path'
+import { existsSync, readFileSync } from 'node:fs'
+import { extname, isAbsolute, relative, resolve } from 'node:path'
-import type { StaticImportGraph } from '@devup-ui/plugin-utils'
+import {
+ createModuleResolver,
+ parseImports,
+ type StaticImportGraph,
+} from '@devup-ui/plugin-utils'
const EXTRACTABLE_EXTENSION = /\.(?:tsx?|jsx?|mjs)$/
@@ -52,13 +55,13 @@ export interface CollectProductionPrewarmFilesOptions {
}
/**
- * Build the deterministic production extraction set used before Turbopack can
- * request its first CSS module.
+ * Build the complete deterministic production extraction set before a bundler
+ * requests its first CSS module.
*
* `computeCompiledFiles` is intentionally route-aware, but a bundler can also
* compile files hidden behind template imports / MDX and package entries that
* live outside `srcDir`. Prewarming every extractable source file plus the
- * external package entries accepted by the loader makes the first snapshot
+ * external package closures accepted by the loader makes the first snapshot
* independent of Turbopack scheduling. Per-file mode still only imports chunks
* that the bundler reaches; single-CSS mode intentionally contains the whole
* application stylesheet.
@@ -79,25 +82,29 @@ export function collectProductionPrewarmFiles({
files.add(toKey(resolvedCwd, filename))
}
- const externalSpecifiers = new Set()
- for (const specifiers of graph.externalImports?.values() ?? []) {
- for (const specifier of specifiers) externalSpecifiers.add(specifier)
- }
-
- const requireFromProject = createRequire(join(resolvedCwd, 'package.json'))
- for (const specifier of [...externalSpecifiers].sort()) {
- const packageName = packageNameFromSpecifier(specifier)
- if (!packageName || !isPrewarmPackage(packageName, libPackage, include)) {
- continue
+ const resolveModule = createModuleResolver({ cwd: resolvedCwd })
+ const visited = new Set()
+ const queue = [...(graph.externalImports ?? [])].flatMap(
+ ([importer, specifiers]) =>
+ [...specifiers].sort().map((specifier) => ({ importer, specifier })),
+ )
+ for (const { importer, specifier } of queue) {
+ const local = specifier.startsWith('.') || isAbsolute(specifier)
+ if (!local) {
+ const packageName = packageNameFromSpecifier(specifier)
+ if (!packageName || !isPrewarmPackage(packageName, libPackage, include))
+ continue
}
-
- try {
- const filename = preferEsmFile(requireFromProject.resolve(specifier))
- if (!EXTRACTABLE_EXTENSION.test(filename)) continue
- files.add(toKey(resolvedCwd, filename))
- } catch {
- // Resolution is best-effort, matching the static graph pre-pass. The
- // loader remains the fallback for packages resolved only by Turbopack.
+ const module = resolveModule(specifier, importer)
+ if (!module) continue
+ const filename = preferEsmFile(module.path)
+ if (!EXTRACTABLE_EXTENSION.test(filename) || visited.has(filename)) continue
+ visited.add(filename)
+ files.add(toKey(resolvedCwd, filename))
+ const source =
+ filename === module.path ? module.code : readFileSync(filename, 'utf-8')
+ for (const imported of parseImports(filename, source)) {
+ queue.push({ importer: filename, specifier: imported.specifier })
}
}
diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts
index 1602513f7..bc6600d89 100644
--- a/packages/plugin-utils/src/import-graph.ts
+++ b/packages/plugin-utils/src/import-graph.ts
@@ -639,7 +639,10 @@ export function listSourceFiles(srcDir: string): string[] {
)
}
-function parseImports(filename: string, source: string): ImportReference[] {
+export function parseImports(
+ filename: string,
+ source: string,
+): ImportReference[] {
const astImports = parseImportsWithOxc(filename, source)
if (astImports) return astImports
return scanImports(source)
diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts
index f90a0352f..f938bb323 100644
--- a/packages/plugin-utils/src/index.ts
+++ b/packages/plugin-utils/src/index.ts
@@ -15,6 +15,7 @@ export {
createModuleResolver,
type CreateModuleResolverOptions,
listSourceFiles,
+ parseImports,
planAtomHoist,
type ResolvedModule,
type StaticImportGraph,
diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts
index b88664c70..29a8095c0 100644
--- a/packages/webpack-plugin/src/__tests__/plugin.test.ts
+++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts
@@ -712,6 +712,94 @@ describe('devupUIWebpackPlugin', () => {
}
})
+ it('uses normalized complete files instead of Next loader requests, including external files', () => {
+ // Given an authoritative set with duplicate spellings and an external file.
+ const seed = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined)
+ const shorthands = spyOn(wasm, 'registerShorthands').mockReturnValue(
+ undefined,
+ )
+ const plugin = new DevupUIWebpackPlugin({
+ productionPrewarmFiles: [
+ 'src/z.tsx',
+ resolve('node_modules/ui/a.tsx'),
+ './src/z.tsx',
+ 'src\\z.tsx',
+ ],
+ shorthands: { insetX: ['left', 'right'] },
+ })
+ const compiler = createCompiler()
+ compiler.options.entry = { main: { import: ['next-app-loader?query!'] } }
+ try {
+ // When the compiler applies after its ordinary configuration setup.
+ plugin.apply(asCompiler(compiler))
+ // Then all files are extracted exactly once, in deterministic loader-ID order.
+ expect(codeExtractSpy.mock.calls.map(([filename]) => filename)).toEqual(
+ ['node_modules/ui/a.tsx', 'src/z.tsx'],
+ )
+ expect(computeReachableFilesSpy).not.toHaveBeenCalled()
+ expect(seed).toHaveBeenCalledWith([
+ 'node_modules/ui/a.tsx',
+ 'src/z.tsx',
+ ])
+ expect(shorthands).toHaveBeenLastCalledWith({
+ insetX: ['left', 'right'],
+ })
+ } finally {
+ seed.mockRestore()
+ shorthands.mockRestore()
+ }
+ })
+
+ it.each([false, true])(
+ 'does not prewarm when the complete set is empty or watch=%s',
+ (watch) => {
+ // Given a reachable generic entry which an explicit set supersedes.
+ computeReachableFilesSpy.mockReturnValue([resolve('src/parent.tsx')])
+ const plugin = new DevupUIWebpackPlugin({
+ watch,
+ productionPrewarmFiles: watch ? ['src/parent.tsx'] : [],
+ })
+ // When applying, then neither explicit watch files nor generic fallback are extracted.
+ plugin.apply(asCompiler(createCompiler()))
+ expect(codeExtractSpy).not.toHaveBeenCalled()
+ expect(computeReachableFilesSpy).not.toHaveBeenCalled()
+ },
+ )
+
+ it.each(['canonical', 'numbering', 'read', 'extract'])(
+ 'propagates genuine %s errors for an explicit complete set',
+ (stage) => {
+ // Given a failing correctness-prewarm dependency.
+ const failure = new Error(stage)
+ const endBuild = mock()
+ const begin = spyOn(pluginUtils, 'beginBuild').mockReturnValue(endBuild)
+ const fail = () => {
+ throw failure
+ }
+ const numbering = spyOn(
+ pluginUtils,
+ 'collectNumberedFiles',
+ ).mockReturnValue([])
+ if (stage === 'canonical') buildCanonicalMapSpy.mockImplementation(fail)
+ if (stage === 'numbering') numbering.mockImplementation(fail)
+ if (stage === 'read') readFileSyncSpy.mockImplementation(fail)
+ if (stage === 'extract') codeExtractSpy.mockImplementation(fail)
+ const plugin = new DevupUIWebpackPlugin({
+ productionPrewarmFiles: ['src/parent.tsx'],
+ })
+ try {
+ // When applying, then the original error reaches webpack instead of partial CSS.
+ expect(() => plugin.apply(asCompiler(createCompiler()))).toThrow(
+ failure,
+ )
+ expect(endBuild).toHaveBeenCalledTimes(1)
+ } finally {
+ begin.mockRestore()
+ numbering.mockRestore()
+ }
+ },
+ )
+
it('pre-warms without collapse, from no entries when they are a function', () => {
buildCanonicalMapSpy.mockReturnValue({})
const compiler = createCompiler()
diff --git a/packages/webpack-plugin/src/__tests__/production-prewarm.mjs b/packages/webpack-plugin/src/__tests__/production-prewarm.mjs
new file mode 100644
index 000000000..b1ffa6f3b
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/production-prewarm.mjs
@@ -0,0 +1,231 @@
+import assert from 'node:assert/strict'
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+import { dirname, join, relative } from 'node:path'
+
+import { codeExtract, exportFileMap, getCss } from '@devup-ui/wasm'
+
+import { DevupUI } from '../../../next-plugin/src/plugin.ts'
+import { setWebpackPluginForTesting } from '../../../next-plugin/src/wasm.ts'
+import { DevupUIWebpackPlugin } from '../plugin.ts'
+
+const originalCwd = process.cwd()
+const fromNextPlugin = createRequire(
+ new URL('../../../next-plugin/package.json', import.meta.url),
+)
+const { webpack } = fromNextPlugin('next/dist/compiled/webpack/webpack')
+const root = mkdtempSync(join(tmpdir(), 'devup-webpack-prewarm-'))
+const compilers = new Set()
+const failures = []
+function write(file, source) {
+ mkdirSync(dirname(join(root, file)), { recursive: true })
+ writeFileSync(join(root, file), source)
+}
+function apply(options = {}, entry = 'next-app-loader?query!') {
+ const compiler = webpack({
+ mode: 'production',
+ context: root,
+ entry,
+ optimization: { minimizer: [] },
+ plugins: [new DevupUIWebpackPlugin(options)],
+ })
+ compilers.add(compiler)
+ return compiler
+}
+async function close(compiler) {
+ try {
+ await new Promise((resolve, reject) =>
+ compiler.close((error) => (error ? reject(error) : resolve())),
+ )
+ } finally {
+ compilers.delete(compiler)
+ }
+}
+function extract(file, singleCss) {
+ return codeExtract(
+ file,
+ readFileSync(join(root, file), 'utf8'),
+ '@devup-ui/react',
+ `./${relative(dirname(join(root, file)), join(root, 'df/devup-ui')).replaceAll('\\', '/')}`,
+ singleCss,
+ false,
+ true,
+ {},
+ ).code
+}
+function sheet() {
+ return [
+ getCss(null, false),
+ ...Object.values(JSON.parse(exportFileMap())).map((number) =>
+ getCss(number, false),
+ ),
+ ].join('\n')
+}
+try {
+ process.chdir(root)
+ write('package.json', '{"private":true}')
+ write('devup.json', '{}')
+ write(
+ 'src/page.tsx',
+ 'import { Box } from "@devup-ui/react"; export const page = ',
+ )
+ write(
+ 'src/unreachable.tsx',
+ 'import { Box } from "@devup-ui/react"; export const unused = ',
+ )
+ write(
+ 'node_modules/design-system/child.tsx',
+ 'import { Box } from "@devup-ui/react"; export const child = ',
+ )
+ write('node_modules/design-system/package.json', '{"main":"index.js"}')
+ write('node_modules/design-system/index.js', 'export * from "./child"')
+ write('src/barrel.ts', 'export * from "design-system"')
+ if (process.env.DEVUP_PREWARM_CLEANUP_PROOF === '1') {
+ // Given outstanding compilers whose asynchronous shutdowns both fail.
+ for (const name of ['first', 'second']) {
+ const compiler = apply({ productionPrewarmFiles: [] })
+ compiler.hooks.shutdown.tapAsync('cleanup-proof', (callback) => {
+ setImmediate(() => {
+ assert.equal(existsSync(root), true)
+ console.info(`shutdown:${name}`)
+ callback(new Error(`cleanup:${name}`))
+ })
+ })
+ }
+ // When an assertion aborts the proof, finally must await both and preserve every error.
+ assert.fail('initialization-proof-failure')
+ }
+ // Given production Next callbacks, discovery supplies the same complete set without extracting.
+ setWebpackPluginForTesting({ DevupUIWebpackPlugin })
+ const next = DevupUI({}, { include: ['design-system'] })
+ const configured = ['server', 'edge-server', 'client'].map((name) => {
+ const config = next.webpack(
+ { plugins: [] },
+ { buildId: 'proof', dev: false, name },
+ )
+ return config.plugins[0].options.productionPrewarmFiles
+ })
+ assert.deepEqual(configured[0], [
+ 'node_modules/design-system/child.tsx',
+ 'node_modules/design-system/index.js',
+ 'src/barrel.ts',
+ 'src/page.tsx',
+ 'src/unreachable.tsx',
+ ])
+ assert.deepEqual(configured[1], configured[0])
+ assert.deepEqual(configured[2], configured[0])
+ assert.doesNotMatch(sheet(), /maroon|cyan|goldenrod/)
+ for (const singleCss of [false, true]) {
+ // Given the complete set, including an external file hidden by a Next query.
+ const productionPrewarmFiles = [
+ 'src/page.tsx',
+ 'node_modules/design-system/child.tsx',
+ ]
+ let compiler = apply({ productionPrewarmFiles, singleCss })
+ const css = sheet()
+ assert.match(css, /color:maroon/)
+ assert.match(css, /color:cyan/)
+ assert.doesNotMatch(css, /goldenrod/)
+ const code = productionPrewarmFiles.map((file) => extract(file, singleCss))
+ await close(compiler)
+ // When the same engine is reset by another compiler with reordered duplicate paths.
+ compiler = apply({
+ singleCss,
+ productionPrewarmFiles: [
+ join(root, 'node_modules/design-system/child.tsx'),
+ './src/../src/page.tsx',
+ 'src/page.tsx',
+ 'src\\page.tsx',
+ ],
+ })
+ // Then both CSS bytes and transformed class correspondence are identical.
+ assert.equal(sheet(), css)
+ assert.deepEqual(
+ productionPrewarmFiles.map((file) => extract(file, singleCss)),
+ code,
+ )
+ await close(compiler)
+ }
+ // Given a generic webpack entry, unreachable source remains excluded.
+ let compiler = apply({}, './src/page.tsx')
+ assert.match(sheet(), /maroon/)
+ assert.doesNotMatch(sheet(), /cyan|goldenrod/)
+ await close(compiler)
+ // When the complete set is explicitly empty, then even that entry is not prewarmed.
+ compiler = apply({ productionPrewarmFiles: [] }, './src/page.tsx')
+ assert.doesNotMatch(sheet(), /maroon|cyan|goldenrod/)
+ await close(compiler)
+ compiler = apply({ watch: true, productionPrewarmFiles: ['src/page.tsx'] })
+ assert.doesNotMatch(sheet(), /maroon/)
+ await close(compiler)
+ assert.throws(() => apply({ productionPrewarmFiles: ['missing.tsx'] }))
+ write(
+ 'bad.tsx',
+ 'import { Box } from "@devup-ui/react"; export const c = ',
+ )
+ assert.throws(() => apply({ productionPrewarmFiles: ['bad.tsx'] }))
+ for (const invalid of ['missing.tsx', 'bad.tsx']) {
+ // Given a valid file sorted before the failing file, initialization partially extracts.
+ write(
+ 'a-valid.tsx',
+ 'import { Box } from "@devup-ui/react"; export const c = ',
+ )
+ // When webpack itself throws, no compiler is returned for the caller to close.
+ assert.throws(() =>
+ apply({
+ prefix: 'failed',
+ shorthands: { insetX: ['left', 'right'] },
+ atomHoist: 2,
+ productionPrewarmFiles: ['a-valid.tsx', invalid],
+ }),
+ )
+ assert.match(sheet(), /chartreuse/)
+ assert.match(sheet(), /left:19px/)
+ // Then an unrelated generic build resets the partial styles and engine options.
+ compiler = apply({}, './src/page.tsx')
+ assert.match(sheet(), /maroon/)
+ assert.doesNotMatch(sheet(), /chartreuse|failed|left:19px/)
+ const cleanCode = codeExtract(
+ 'clean.tsx',
+ 'import { Box } from "@devup-ui/react"; export const c = ',
+ '@devup-ui/react',
+ './df/devup-ui',
+ false,
+ false,
+ true,
+ {},
+ ).code
+ assert.doesNotMatch(cleanCode, /failed/)
+ assert.match(sheet(), /inset-x:23px/)
+ assert.doesNotMatch(sheet(), /left:23px/)
+ await close(compiler)
+ }
+ console.info('production prewarm proof passed')
+} catch (error) {
+ failures.push(error)
+} finally {
+ const results = await Promise.allSettled([...compilers].map(close))
+ for (const result of results) {
+ if (result.status === 'rejected') failures.push(result.reason)
+ }
+ setWebpackPluginForTesting(undefined)
+ process.chdir(originalCwd)
+ try {
+ rmSync(root, { recursive: true, force: true })
+ if (process.env.DEVUP_PREWARM_CLEANUP_PROOF === '1')
+ console.info('fixture-removed')
+ } catch (error) {
+ failures.push(error)
+ }
+}
+if (failures.length === 1) throw failures[0]
+if (failures.length > 1)
+ throw new AggregateError(failures, 'Prewarm proof and cleanup failed')
diff --git a/packages/webpack-plugin/src/__tests__/production-prewarm.test.js b/packages/webpack-plugin/src/__tests__/production-prewarm.test.js
new file mode 100644
index 000000000..afc664bbe
--- /dev/null
+++ b/packages/webpack-plugin/src/__tests__/production-prewarm.test.js
@@ -0,0 +1,46 @@
+import { spawnSync } from 'node:child_process'
+import { join } from 'node:path'
+
+import { expect, it } from 'bun:test'
+
+it('reconstructs complete production sheets across compiler resets without leaking to another build', () => {
+ // Given an isolated real engine (other unit tests mock WASM exports).
+ // When applying and closing real webpack compilers sequentially.
+ const result = spawnSync(
+ process.execPath,
+ [join(import.meta.dir, 'production-prewarm.mjs')],
+ {
+ encoding: 'utf8',
+ },
+ )
+ // Then all adapter selection, reconstruction and isolation assertions pass.
+ expect(result.error).toBeUndefined()
+ expect(result.stdout + result.stderr).toContain(
+ 'production prewarm proof passed',
+ )
+ expect(result.status).toBe(0)
+})
+
+it('awaits every outstanding shutdown before removing fixtures and preserves proof plus cleanup errors', () => {
+ // Given asynchronous failing shutdowns and a proof assertion failure.
+ // When the real-engine harness unwinds.
+ const result = spawnSync(
+ process.execPath,
+ [join(import.meta.dir, 'production-prewarm.mjs')],
+ {
+ encoding: 'utf8',
+ env: { ...process.env, DEVUP_PREWARM_CLEANUP_PROOF: '1' },
+ },
+ )
+ // Then both callbacks finish with the fixture alive, and all three errors survive.
+ expect(result.error).toBeUndefined()
+ expect(result.status).toBe(1)
+ expect(result.stdout.trim().split(/\r?\n/)).toEqual([
+ 'shutdown:first',
+ 'shutdown:second',
+ 'fixture-removed',
+ ])
+ expect(result.stderr).toContain('initialization-proof-failure')
+ expect(result.stderr).toContain('cleanup:first')
+ expect(result.stderr).toContain('cleanup:second')
+})
diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts
index 6830fbfdd..f311537ce 100644
--- a/packages/webpack-plugin/src/plugin.ts
+++ b/packages/webpack-plugin/src/plugin.ts
@@ -82,6 +82,8 @@ export interface DevupUIWebpackPluginOptions {
* Set to `false` to disable specific aliases
*/
importAliases?: ImportAliases
+ /** Adapter-owned complete extraction set; undefined keeps generic entry reachability. */
+ readonly productionPrewarmFiles?: readonly string[]
}
export class DevupUIWebpackPlugin {
@@ -103,6 +105,7 @@ export class DevupUIWebpackPlugin {
prefix,
shorthands,
atomHoist,
+ productionPrewarmFiles,
importAliases: userImportAliases,
}: Partial = {}) {
registerShorthands(shorthands ?? {})
@@ -118,7 +121,21 @@ export class DevupUIWebpackPlugin {
include,
singleCss,
prefix,
+ shorthands,
atomHoist,
+ productionPrewarmFiles:
+ productionPrewarmFiles === undefined
+ ? undefined
+ : [
+ ...new Set(
+ productionPrewarmFiles.map((file) =>
+ relative(
+ process.cwd(),
+ resolve(process.cwd(), file.replaceAll('\\', '/')),
+ ).replaceAll('\\', '/'),
+ ),
+ ),
+ ].sort(),
}
this.sheetFile = join(this.options.distDir, 'sheet.json')
@@ -164,9 +181,9 @@ export class DevupUIWebpackPlugin {
* holds the styles of every one (all collapsed members of a bucket, and the
* shared base), not just those of the modules webpack happened to build
* first. Mirrors the loader's `codeExtract` call (same filename keying +
- * options) so re-extraction during compilation is idempotent. Best-effort:
- * extraction errors are swallowed so a single bad file never breaks the
- * build, and a stylesheet still missing styles is rebuilt by another pass.
+ * options) so re-extraction during compilation is idempotent. Generic entry
+ * discovery remains best-effort; an adapter's complete set is authoritative
+ * and its read/extraction failures must fail the build.
*/
private prewarmExtractor(entries: string[]) {
try {
@@ -178,11 +195,21 @@ export class DevupUIWebpackPlugin {
toId: (path) => relative(cwd, path).replaceAll('\\', '/'),
}),
)
- for (const file of computeReachableFiles({
- srcDir: resolve(cwd, 'src'),
- tsconfigPath: resolve(cwd, 'tsconfig.json'),
- entries,
- })) {
+ const files =
+ this.options.productionPrewarmFiles?.map((file) =>
+ resolve(cwd, file),
+ ) ??
+ computeReachableFiles({
+ srcDir: resolve(cwd, 'src'),
+ tsconfigPath: resolve(cwd, 'tsconfig.json'),
+ entries,
+ })
+ if (this.options.productionPrewarmFiles !== undefined) {
+ seedFileNumbers({ seedFileMap }, [
+ ...this.options.productionPrewarmFiles,
+ ])
+ }
+ for (const file of files) {
const relativePath = relative(cwd, file).replaceAll('\\', '/')
let relCssDir = relative(dirname(file), this.options.cssDir).replaceAll(
'\\',
@@ -200,7 +227,8 @@ export class DevupUIWebpackPlugin {
this.importAliases,
)
}
- } catch {
+ } catch (error) {
+ if (this.options.productionPrewarmFiles !== undefined) throw error
// Best-effort warm-up; on failure the css-loader still serves whatever
// atoms were extracted, matching pre-fix behavior.
}
@@ -210,9 +238,20 @@ export class DevupUIWebpackPlugin {
// A build starts from its own options, not from what an earlier build in
// this process left in the engine
const endBuild = beginBuild({ resetBuildState })
- compiler.hooks.shutdown?.tap('DevupUIWebpackPlugin', endBuild)
+ try {
+ compiler.hooks.shutdown?.tap('DevupUIWebpackPlugin', endBuild)
+ this.initialize(compiler)
+ } catch (error) {
+ // Webpack cannot return the compiler when apply throws, so no caller can close it.
+ endBuild()
+ throw error
+ }
+ }
+
+ private initialize(compiler: Compiler) {
setDebug(this.options.debug)
setPrefix(this.options.prefix ?? null)
+ registerShorthands(this.options.shorthands ?? {})
const existsDevup = existsSync(this.options.devupFile)
// read devup.json
if (!existsSync(this.options.distDir))
@@ -285,7 +324,12 @@ export class DevupUIWebpackPlugin {
)
}
}
- } catch {
+ } catch (error) {
+ if (
+ !this.options.watch &&
+ this.options.productionPrewarmFiles !== undefined
+ )
+ throw error
// Best-effort; on failure canonical() is the identity (no merge) and atom
// hoisting stays off.
}
@@ -305,7 +349,12 @@ export class DevupUIWebpackPlugin {
toId: (path) => relative(cwd, path).replaceAll('\\', '/'),
}),
)
- } catch {
+ } catch (error) {
+ if (
+ !this.options.watch &&
+ this.options.productionPrewarmFiles !== undefined
+ )
+ throw error
// Best-effort; numbering falls back to arrival order.
}
// Pre-warm the extractor so the css-loader serves COMPLETE CSS.