From 8f0bd89a8bb11d42904aad6f11694ee78cdb0163 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 15 Oct 2026 21:22:16 +0900 Subject: [PATCH 1/2] fix(next): support JavaScript JSX and webpack source map objects Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...angepack_log_next_webpack_js_jsx_maps.json | 9 + libs/extractor/src/build_time_values.rs | 4 +- .../extractor/src/emotion_namespace/driver.rs | 4 +- .../extractor/src/emotion_namespace/output.rs | 4 +- .../emotion_namespace_tests/diagnostics.rs | 8 +- libs/extractor/src/extractor/styled_jsx.rs | 9 +- libs/extractor/src/import_alias_visit.rs | 10 +- libs/extractor/src/imported_constants.rs | 2 +- libs/extractor/src/lib.rs | 42 +-- libs/extractor/src/parsing.rs | 273 ++++++++++++++++++ libs/extractor/src/vanilla_extract.rs | 2 +- .../next-plugin/src/__tests__/webpack-job.cs | 104 +++++++ .../src/__tests__/webpack-lifecycle-child.cjs | 8 + .../src/__tests__/webpack-owned.cjs | 141 +++++++++ .../src/__tests__/webpack-production.cjs | 208 +++++++++++++ .../src/__tests__/webpack-production.test.js | 56 ++++ .../src/__tests__/webpack-remove.cjs | 1 + .../src/__tests__/webpack-supervise.ps1 | 57 ++++ .../src/__tests__/loader.test.ts | 9 +- packages/webpack-plugin/src/loader.ts | 14 +- 20 files changed, 920 insertions(+), 45 deletions(-) create mode 100644 .changepacks/changepack_log_next_webpack_js_jsx_maps.json create mode 100644 libs/extractor/src/parsing.rs create mode 100644 packages/next-plugin/src/__tests__/webpack-job.cs create mode 100644 packages/next-plugin/src/__tests__/webpack-lifecycle-child.cjs create mode 100644 packages/next-plugin/src/__tests__/webpack-owned.cjs create mode 100644 packages/next-plugin/src/__tests__/webpack-production.cjs create mode 100644 packages/next-plugin/src/__tests__/webpack-production.test.js create mode 100644 packages/next-plugin/src/__tests__/webpack-remove.cjs create mode 100644 packages/next-plugin/src/__tests__/webpack-supervise.ps1 diff --git a/.changepacks/changepack_log_next_webpack_js_jsx_maps.json b/.changepacks/changepack_log_next_webpack_js_jsx_maps.json new file mode 100644 index 000000000..3eb36b376 --- /dev/null +++ b/.changepacks/changepack_log_next_webpack_js_jsx_maps.json @@ -0,0 +1,9 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch" + }, + "note": "Accept JSX in .js, .mjs and .cjs throughout extraction without changing TypeScript dialects or plain JavaScript output, and pass parsed source-map objects to webpack callbacks for Next SWC compatibility. Malformed relevant source now fails with located parser diagnostics instead of unlocated parser-panic messages or silently recovered output.", + "date": "2026-10-15T00:00:00.000Z" +} diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 1e8bdd7e3..6f95de3ab 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -15,7 +15,7 @@ use oxc_ast::ast::{ use oxc_ast_visit::{Visit, walk}; use oxc_parser::Parser; use oxc_semantic::{Scoping, SemanticBuilder}; -use oxc_span::{GetSpan, SourceType, Span}; +use oxc_span::{GetSpan, Span}; use oxc_syntax::operator::LogicalOperator; use oxc_syntax::reference::ReferenceId; use oxc_syntax::symbol::SymbolId; @@ -131,7 +131,7 @@ const UNCERTAIN_MEMBERS: [&str; 9] = [ ]; fn parse<'a>(allocator: &'a Allocator, filename: &str, code: &'a str) -> Option> { - let source_type = SourceType::from_path(filename).ok()?; + let source_type = crate::parsing::source_type(filename).ok()?; let parsed = Parser::new(allocator, code, source_type).parse(); (!parsed.fatal_error).then_some(parsed.program) } diff --git a/libs/extractor/src/emotion_namespace/driver.rs b/libs/extractor/src/emotion_namespace/driver.rs index 58c5900b3..dc282f268 100644 --- a/libs/extractor/src/emotion_namespace/driver.rs +++ b/libs/extractor/src/emotion_namespace/driver.rs @@ -1,6 +1,6 @@ use super::{ Allocator, BTreeMap, Binding, Cow, Errors, GetSpan, HashMap, HashSet, ImportAlias, Normalized, - Normalizer, Parser, Root, SemanticBuilder, SourceType, Span, identifier_names, macro_name, + Normalizer, Parser, Root, SemanticBuilder, Span, identifier_names, macro_name, }; use oxc_ast::ast::{ Declaration, ImportDeclaration, ImportDeclarationSpecifier, Statement, @@ -117,7 +117,7 @@ pub(crate) fn normalize<'a>( let parsed = Parser::new( &allocator, code, - SourceType::from_path(filename).unwrap_or_default(), + crate::parsing::source_type(filename).unwrap_or_default(), ) .parse(); let semantic = SemanticBuilder::new() diff --git a/libs/extractor/src/emotion_namespace/output.rs b/libs/extractor/src/emotion_namespace/output.rs index 6fcb7df6e..5a4ecda2d 100644 --- a/libs/extractor/src/emotion_namespace/output.rs +++ b/libs/extractor/src/emotion_namespace/output.rs @@ -1,4 +1,4 @@ -use super::{Allocator, Edit, Parser, SourceType}; +use super::{Allocator, Edit, Parser}; use crate::{ExtractOutput, source_map}; use oxc_codegen::{Codegen, CodegenOptions}; use rustc_hash::FxHashSet; @@ -21,7 +21,7 @@ impl Rewritten<'_> { let program = Parser::new( &allocator, self.code, - SourceType::from_path(self.filename).unwrap_or_default(), + crate::parsing::source_type(self.filename).unwrap_or_default(), ) .parse() .program; diff --git a/libs/extractor/src/emotion_namespace_tests/diagnostics.rs b/libs/extractor/src/emotion_namespace_tests/diagnostics.rs index 38cd1ea87..6bd51e42f 100644 --- a/libs/extractor/src/emotion_namespace_tests/diagnostics.rs +++ b/libs/extractor/src/emotion_namespace_tests/diagnostics.rs @@ -3,17 +3,15 @@ use rstest::rstest; #[test] #[serial] -fn emotion_restoration_reports_malformed_source_when_normalization_rejects_a_read() { - // Given: normalization can reject a namespace read before the main parser runs. +fn syntax_errors_are_reported_before_namespace_normalization() { + // Given: malformed source also contains an unsupported namespace read. let source = "import * as E from '@emotion/css';\nE.__emotion_user(); const broken;"; // When let error = compile(source).unwrap_err(); // Then - assert!(error.starts_with("namespace.tsx:2:1:"), "{error}"); - assert!(error.contains("E.__emotion_user"), "{error}"); - assert!(error.contains("Emotion diagnostic restoration failed:"), "{error}"); + assert_eq!(error, "namespace.tsx:2:27: Missing initializer in const declaration"); } #[test] diff --git a/libs/extractor/src/extractor/styled_jsx.rs b/libs/extractor/src/extractor/styled_jsx.rs index 75af546ef..465732f8a 100644 --- a/libs/extractor/src/extractor/styled_jsx.rs +++ b/libs/extractor/src/extractor/styled_jsx.rs @@ -9,7 +9,7 @@ use oxc_ast::{ builder::AstBuilder, }; use oxc_ast_visit::Visit; -use oxc_span::SPAN; +use oxc_span::{FileExtension, SPAN}; use rustc_hash::FxHashSet; #[cfg(test)] @@ -38,7 +38,12 @@ impl<'a> Visit<'a> for Names<'a> { impl StyledJsx { /// Reserve a name across all scopes before extraction removes or rewrites nodes. pub(crate) fn reserve(program: &Program<'_>) -> Option { - if program.source_type.is_jsx() { + if program.source_type.is_jsx() + && !matches!( + program.source_type.extension(), + Some(FileExtension::Js | FileExtension::Mjs | FileExtension::Cjs) + ) + { return None; } let mut names = Names::default(); diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index c5eb83c46..56bc40c9e 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -22,7 +22,9 @@ use oxc_ast_visit::{ walk::{walk_call_expression, walk_jsx_opening_element}, }; use oxc_parser::Parser; -use oxc_span::{GetSpan, SourceType}; +use oxc_span::GetSpan; +#[cfg(test)] +use oxc_span::SourceType; use std::borrow::Cow; use std::collections::HashMap; @@ -252,7 +254,7 @@ pub fn companion_errors(code: &str, filename: &str) -> Vec<(u32, String)> { return Vec::new(); } let allocator = Allocator::default(); - let source_type = SourceType::from_path(filename).unwrap_or_default(); + let source_type = crate::parsing::source_type(filename).unwrap_or_default(); let program = Parser::new(&allocator, code, source_type).parse().program; program .body @@ -302,7 +304,7 @@ pub fn transform_import_aliases_with_edits<'a>( } let allocator = Allocator::default(); - let source_type = SourceType::from_path(filename).unwrap_or_default(); + let source_type = crate::parsing::source_type(filename).unwrap_or_default(); // Parse the code let parser_ret = Parser::new(&allocator, code, source_type).parse(); @@ -401,7 +403,7 @@ pub(super) fn pixelify_emotion_values<'a>( original: &str, ) -> (Cow<'a, str>, Vec) { let allocator = Allocator::default(); - let source_type = SourceType::from_path(filename).unwrap_or_default(); + let source_type = crate::parsing::source_type(filename).unwrap_or_default(); let original_program = Parser::new(&allocator, original, source_type) .parse() .program; diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index a600d1deb..44a6b126b 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -743,7 +743,7 @@ impl Modules<'_> { fn read(&mut self, path: &str, code: &str) -> FxHashMap { let allocator = Allocator::default(); - let source_type = SourceType::from_path(path).unwrap_or_else(|_| SourceType::ts()); + let source_type = crate::parsing::source_type(path).unwrap_or_else(|_| SourceType::ts()); let program = Parser::new(&allocator, code, source_type).parse().program; let mut scope = ModuleScope::new(path, &program, Some(code)); let mut exports = FxHashMap::default(); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index a5d5afa82..6a8433573 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -12,6 +12,7 @@ mod import_alias_visit; mod imported_constants; mod module_loader; mod mutations; +mod parsing; mod prop_modify_utils; mod source_map; mod style_values; @@ -37,6 +38,7 @@ use oxc_ast::ast::Expression; use oxc_ast_visit::VisitMut; use oxc_codegen::{Codegen, CodegenOptions}; use oxc_parser::{Parser, ParserReturn}; +#[cfg(test)] use oxc_span::SourceType; use rustc_hash::FxHashMap; use rustc_hash::FxHashSet; @@ -299,6 +301,16 @@ fn extract_with_source_map( source_map: bool, resolver: Option<&ModuleResolver>, ) -> Result> { + if code.contains(&option.package) + || code.contains(STYLEX_PACKAGE) + || option + .import_aliases + .keys() + .any(|alias| code.contains(alias)) + { + let allocator = Allocator::default(); + parsing::parse(&allocator, filename, code)?; + } extract_source(filename, code, None, option, source_map, resolver) } @@ -440,7 +452,6 @@ fn extract_source( let code_to_parse = processed_code.as_deref().unwrap_or(&transformed_code); - let source_type = SourceType::from_path(filename)?; let (bucket, global, css_file) = resolve_css_target(filename, &option); let import_main = option.import_main_css && !global; // Presize to the exact final length (1 target + optional main-css entry) and @@ -453,14 +464,7 @@ fn extract_source( css_files.push(css_file.clone()); let allocator = Allocator::default(); - let ParserReturn { - mut program, // AST - fatal_error, // Parser encountered an error it couldn't recover from - .. - } = Parser::new(&allocator, code_to_parse, source_type).parse(); - if fatal_error { - return Err("Parser panicked".into()); - } + let mut program = parsing::parse(&allocator, filename, code_to_parse)?; let inlined = if processed_code.is_none() { imported_constants::inline_constants( &oxc_ast::builder::AstBuilder::new(&allocator), @@ -626,7 +630,7 @@ fn located_errors( source, offset, message, - SourceType::from_path(filename).unwrap_or_default(), + parsing::source_type(filename).unwrap_or_default(), ); format!("{}: {message}", locate(filename, source, offset)) }) @@ -709,7 +713,7 @@ fn extract_class_map_from_code( option: &ExtractOption, style_names: &FxHashSet, ) -> Result, Box> { - let source_type = SourceType::from_path(filename)?; + let source_type = parsing::source_type(filename)?; let (bucket, global, css_file) = resolve_css_target(filename, option); let css_files = vec![css_file]; let allocator = Allocator::default(); @@ -775,7 +779,7 @@ pub fn has_devup_ui(filename: &str, code: &str, package: &str) -> bool { return false; } - let source_type = match SourceType::from_path(filename) { + let source_type = match parsing::source_type(filename) { Ok(st) => st, Err(_) => return false, }; @@ -1498,7 +1502,7 @@ mod tests { extract( "test.tsx", r"import {Box} from '@devup-ui/core' - /> + /> ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -1747,7 +1751,7 @@ mod tests { extract( "test.tsx", r"import {Box as C} from '@devup-ui/core' - + ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -4869,7 +4873,7 @@ import clsx from 'clsx' ) .unwrap_err() .to_string(), - "Parser panicked" + "test.tsx:1:11: Expected `from` but found `string`" ); } @@ -7002,7 +7006,7 @@ export { extract( "test.jsx", r#"import {Box, css} from '@devup-ui/core' - + "#, ExtractOption { package: "@devup-ui/core".to_string(), @@ -7745,7 +7749,7 @@ export { c as Lib };"#, extract( "test.jsx", r"import {Box} from '@devup-ui/core' - + ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -9505,7 +9509,7 @@ keyframes({ extract( "test.tsx", r"import {Box} from '@devup-ui/core' - /> + /> ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -9538,7 +9542,7 @@ keyframes({ extract( "test1.tsx", r"import {Box} from '@devup-ui/core' - /> + /> ", ExtractOption { package: "@devup-ui/core".to_string(), diff --git a/libs/extractor/src/parsing.rs b/libs/extractor/src/parsing.rs new file mode 100644 index 000000000..1215af170 --- /dev/null +++ b/libs/extractor/src/parsing.rs @@ -0,0 +1,273 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::Program; +use oxc_parser::Parser; +use oxc_span::{SourceType, UnknownExtension}; +use std::error::Error; + +/// Accept JavaScript JSX without changing the path's TypeScript or module dialect. +pub(super) fn source_type(filename: &str) -> Result { + SourceType::from_path(filename) + .map(|source_type| source_type.with_jsx(source_type.is_javascript())) +} + +/// Parse source, reporting syntax errors at their primary source locations. +pub(super) fn parse<'a>( + allocator: &'a Allocator, + filename: &str, + code: &'a str, +) -> Result, Box> { + let parsed = Parser::new(allocator, code, source_type(filename)?).parse(); + if parsed.diagnostics.has_errors() { + let mut errors: Vec<_> = parsed + .diagnostics + .errors() + .map(|error| { + let offset = error + .labels + .iter() + .find(|label| label.primary()) + .or_else(|| error.labels.first()) + .map_or(0, oxc_span::LabeledSpan::offset); + (offset, error.to_string()) + }) + .collect(); + errors.sort_unstable(); + errors.dedup(); + return Err(errors + .into_iter() + .map(|(offset, message)| { + format!( + "{}: {message}", + crate::locate(filename, code, offset as usize) + ) + }) + .collect::>() + .join("\n") + .into()); + } + Ok(parsed.program) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::{parse, source_type}; + use crate::extract_style::extract_style_value::ExtractStyleValue; + use crate::{ + ExtractOption, ImportAlias, ResolvedModule, extract, extract_with_modules, has_devup_ui, + }; + use css::{class_map::reset_class_map, file_map::reset_file_map}; + use oxc_allocator::Allocator; + use oxc_span::SourceType; + use rstest::rstest; + use serial_test::serial; + use std::collections::HashMap; + + #[rstest] + #[case("page.js", true)] + #[case("page.mjs", true)] + #[case("page.cjs", true)] + #[case("page.jsx", true)] + #[case("page.ts", false)] + #[case("page.mts", false)] + #[case("page.cts", false)] + #[case("page.tsx", true)] + #[case("page.d.ts", false)] + #[case("page.d.mts", false)] + #[case("page.d.cts", false)] + fn preserves_path_dialect(#[case] filename: &str, #[case] jsx: bool) { + // Given the parser's original path-derived language and module kind. + let original = SourceType::from_path(filename).unwrap(); + // When JavaScript JSX permission is applied. + let actual = source_type(filename).unwrap(); + // Then no other dialect metadata changes. + assert_eq!(actual.is_jsx(), jsx); + assert_eq!(actual.is_typescript(), original.is_typescript()); + assert_eq!( + actual.is_typescript_definition(), + original.is_typescript_definition() + ); + assert_eq!(actual.module_kind(), original.module_kind()); + assert_eq!(actual.extension(), original.extension()); + } + + #[rstest] + #[case( + "page.ts", + "const value = 1; const identity = (value: T): T => value;" + )] + #[case( + "page.mts", + "const value = 1 as number; const identity = (value: T): T => value;" + )] + #[case( + "page.cts", + "const value = 1 as number; const identity = (value: T): T => value;" + )] + fn preserves_typescript_angle_syntax(#[case] filename: &str, #[case] source: &str) { + // Given syntax that TSX cannot interpret as TypeScript assertions. + let allocator = Allocator::default(); + // When parsing a non-TSX TypeScript extension. + let program = parse(&allocator, filename, source).unwrap(); + // Then both declarations remain TypeScript, not JSX. + assert_eq!(program.body.len(), 2); + assert!(!program.source_type.is_jsx()); + } + + #[rstest] + #[case("src/app/page.js")] + #[case("src/app/page.mjs")] + #[case("src/app/page.cjs")] + #[serial] + fn extracts_jsx_in_javascript(#[case] filename: &str) { + // Given a JavaScript module accepted by React toolchains. + reset_class_map(); + reset_file_map(); + let source = "import { Box } from '@devup-ui/react';\nexport default function Page() { return proof; }"; + + // When extraction uses the physical JavaScript filename. + let output = extract(filename, source, ExtractOption::default()).unwrap(); + + // Then styling is compiled away and package detection sees the import. + assert!(output.code.contains("
;")] + #[case("mjs", "export const color = 'teal'; export const view =
;")] + #[case("cjs", "exports.color = 'teal'; exports.view =
;")] + #[serial] + fn reads_constants_from_javascript_with_jsx( + #[case] extension: &str, + #[case] module: &'static str, + ) { + // Given a dependency with a static constant and unrelated JSX. + reset_class_map(); + reset_file_map(); + let path = format!("tokens.{extension}"); + let dependency = path.clone(); + let resolver = move |specifier: &str, _: &str| { + (specifier == "./tokens").then(|| ResolvedModule { + path: path.clone(), + code: module.to_string(), + }) + }; + // When a style reads the constant without executing the JSX export. + let output = extract_with_modules("page.jsx", "import { Box } from '@devup-ui/react'; import { color } from './tokens'; export const view = ;", ExtractOption::default(), false, &resolver).unwrap(); + // Then extraction emits the known color and tracks the dependency. + assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Static(style) if style.property == "color" && style.value == "teal"))); + assert_eq!(output.dependencies, [dependency]); + } + + #[rstest] + #[case("page.js")] + #[case("page.mjs")] + #[case("page.cjs")] + #[serial] + fn preserves_jsx_in_namespace_only_output(#[case] filename: &str) { + // Given an unused namespace import beside unrelated JSX. + let source = "import * as E from '@emotion/css'; export const view =
;"; + let option = ExtractOption { + import_aliases: HashMap::from([( + "@emotion/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + // When namespace-only output takes the no-style extraction path. + let output = extract(filename, source, option).unwrap(); + // Then unrelated JSX and its original source map survive. + assert!(output.code.contains("
")); + assert_eq!(output.styles.len(), 0); + let map: serde_json::Value = serde_json::from_str(&output.map.unwrap()).unwrap(); + assert_eq!(map["sourcesContent"][0].as_str(), Some(source)); + } + + #[test] + #[serial] + fn rejects_recoverable_syntax_before_alias_rewriting() { + // Given a recoverable syntax error in an aliased module. + let source = "import * as E from '@emotion/css';\nE.hydrate([]); const broken;"; + let option = ExtractOption { + import_aliases: HashMap::from([( + "@emotion/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + // When a preprocessing pass would otherwise hide the invalid input. + let error = extract("page.js", source, option).unwrap_err().to_string(); + // Then the syntax diagnostic refers to the original physical source. + assert!(error.starts_with("page.js:2:"), "{error}"); + assert!(error.contains("Missing initializer"), "{error}"); + } + + #[rstest] + #[case("wrong")] + #[case("className")] + #[case("styleOrder")] + #[case("styleVars")] + #[serial] + fn rejects_empty_jsx_attributes(#[case] attribute: &str) { + // Given an empty attribute expression, which is invalid JSX. + let source = format!( + "import {{ Box }} from '@devup-ui/react';\nconst view = ;" + ); + // When extraction encounters a recoverable parser diagnostic. + let error = extract("page.js", &source, ExtractOption::default()) + .unwrap_err() + .to_string(); + // Then invalid source is not silently emitted or compiled as an absent prop. + assert!(error.starts_with("page.js:2:"), "{error}"); + assert!(error.contains("non-empty 'expression'"), "{error}"); + } +} diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 169996fd7..9dad512f1 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -578,7 +578,7 @@ pub(crate) fn strip_typescript(code: &str, filename: &str) -> String { fn strip(code: &str, filename: &str) -> String { let allocator = Allocator::default(); - let source_type = SourceType::from_path(filename).unwrap_or_else(|_| SourceType::ts()); + let source_type = crate::parsing::source_type(filename).unwrap_or_else(|_| SourceType::ts()); let mut program = Parser::new(&allocator, code, source_type).parse().program; let scoping = SemanticBuilder::new() .with_enum_eval(true) diff --git a/packages/next-plugin/src/__tests__/webpack-job.cs b/packages/next-plugin/src/__tests__/webpack-job.cs new file mode 100644 index 000000000..41eba4852 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-job.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; + +// Test-only adaptation of W31ac's suspended-assignment, kill-on-close job. +public sealed class WebpackJob : IDisposable { + [StructLayout(LayoutKind.Sequential)] struct BasicLimit { + public long UserTime, JobTime; public uint Flags; + public UIntPtr MinWorking, MaxWorking; public uint Active; + public UIntPtr Affinity; public uint Priority, Scheduling; + } + [StructLayout(LayoutKind.Sequential)] struct IoCounters { + public ulong ReadOps, WriteOps, OtherOps, ReadBytes, WriteBytes, OtherBytes; + } + [StructLayout(LayoutKind.Sequential)] struct ExtendedLimit { + public BasicLimit Basic; public IoCounters Io; + public UIntPtr ProcessMemory, JobMemory, PeakProcess, PeakJob; + } + [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct Startup { + public uint Size; public string Reserved, Desktop, Title; + public uint X, Y, Width, Height, CharsX, CharsY, Fill, Flags; + public ushort Show, ReservedSize; public IntPtr ReservedBytes, Input, Output, Error; + } + [StructLayout(LayoutKind.Sequential)] struct ProcessInfo { + public IntPtr Process, Thread; public uint Pid, Tid; + } + [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr attributes, string name); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int kind, ref ExtendedLimit info, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job, int kind, IntPtr info, uint size, IntPtr returned); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool CreateProcess(string app, StringBuilder command, IntPtr pa, IntPtr ta, bool inherit, uint flags, IntPtr env, string cwd, ref Startup startup, out ProcessInfo info); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateProcess(IntPtr process, uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetExitCodeProcess(IntPtr process, out uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int kind); + readonly HashSet observed = new HashSet(); + IntPtr job, root; + public uint RootPid { get; private set; } + public uint[] Observed { get { var result = new uint[observed.Count]; observed.CopyTo(result); return result; } } + static void Check(bool success) { if (!success) throw new Win32Exception(Marshal.GetLastWin32Error()); } + public WebpackJob() { + job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); + var limit = new ExtendedLimit(); limit.Basic.Flags = 0x2000; + try { Check(SetInformationJobObject(job, 9, ref limit, (uint)Marshal.SizeOf(limit))); } + catch { CloseHandle(job); job = IntPtr.Zero; throw; } + } + public void Launch(string command, string cwd) { + var startup = new Startup(); startup.Size = (uint)Marshal.SizeOf(startup); + startup.Flags = 0x100; startup.Input = GetStdHandle(-10); + startup.Output = GetStdHandle(-11); startup.Error = GetStdHandle(-12); + ProcessInfo info; + Check(CreateProcess(null, new StringBuilder(command), IntPtr.Zero, IntPtr.Zero, true, 4, IntPtr.Zero, cwd, ref startup, out info)); + try { + Check(AssignProcessToJobObject(job, info.Process)); + root = info.Process; RootPid = info.Pid; observed.Add(info.Pid); + if (ResumeThread(info.Thread) == UInt32.MaxValue) throw new Win32Exception(Marshal.GetLastWin32Error()); + } catch { + TerminateProcess(info.Process, 125); + WaitForSingleObject(info.Process, 10000); + if (root == IntPtr.Zero) CloseHandle(info.Process); + throw; + } finally { CloseHandle(info.Thread); } + } + public bool RootExited { get { return root != IntPtr.Zero && WaitForSingleObject(root, 0) == 0; } } + public uint ExitCode { get { uint code; Check(GetExitCodeProcess(root, out code)); return code; } } + public uint[] Members() { + int capacity = 64; IntPtr buffer; + while (true) { + buffer = Marshal.AllocHGlobal(8 + capacity * IntPtr.Size); + if (QueryInformationJobObject(job, 3, buffer, (uint)(8 + capacity * IntPtr.Size), IntPtr.Zero)) break; + int error = Marshal.GetLastWin32Error(); Marshal.FreeHGlobal(buffer); + if (error != 234 || capacity >= 1048576) throw new Win32Exception(error); + capacity *= 2; + } + try { + int count = Marshal.ReadInt32(buffer, 4); + var result = new uint[count]; + for (int i = 0; i < count; i++) { + result[i] = checked((uint)Marshal.ReadIntPtr(buffer, 8 + i * IntPtr.Size).ToInt64()); + observed.Add(result[i]); + } + return result; + } finally { Marshal.FreeHGlobal(buffer); } + } + public void KillAndWait() { + Members(); Check(TerminateJobObject(job, 125)); + var clock = System.Diagnostics.Stopwatch.StartNew(); + while (Members().Length != 0) { + if (clock.ElapsedMilliseconds >= 10000) throw new TimeoutException("Job cleanup exceeded 10s"); + System.Threading.Thread.Sleep(20); + } + if (root != IntPtr.Zero && WaitForSingleObject(root, 1000) != 0) throw new TimeoutException("Root not reaped"); + } + public void Dispose() { + if (job != IntPtr.Zero) { Check(CloseHandle(job)); job = IntPtr.Zero; } + if (root != IntPtr.Zero) { Check(CloseHandle(root)); root = IntPtr.Zero; } + } +} diff --git a/packages/next-plugin/src/__tests__/webpack-lifecycle-child.cjs b/packages/next-plugin/src/__tests__/webpack-lifecycle-child.cjs new file mode 100644 index 000000000..7257b8ef2 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-lifecycle-child.cjs @@ -0,0 +1,8 @@ +const { spawn } = require('node:child_process') + +// Fault-only descendants deliberately ignore graceful shutdown. +process.on('SIGTERM', () => {}) +if (process.argv[2] !== 'leaf') + spawn(process.execPath, [__filename, 'leaf'], { stdio: 'inherit' }) +console.info(`W65-FAULT-PID ${process.pid}`) +setInterval(() => {}, 1000) diff --git a/packages/next-plugin/src/__tests__/webpack-owned.cjs b/packages/next-plugin/src/__tests__/webpack-owned.cjs new file mode 100644 index 000000000..d122a0ed8 --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-owned.cjs @@ -0,0 +1,141 @@ +const { spawn } = require('node:child_process') +const { existsSync, mkdtempSync, rmSync } = require('node:fs') +const { tmpdir } = require('node:os') +const { join } = require('node:path') +const { setTimeout: delay } = require('node:timers/promises') + +const root = mkdtempSync( + join(process.env.DEVUP_NEXT_FIXTURE_DIR || tmpdir(), 'w65-next-webpack-'), +) +const driver = join(__dirname, 'webpack-production.cjs') +const fault = process.argv[2] || '' +let outputClosed = false +// A cancelled caller's closed output pipes must not terminate the cleanup owner. +for (const stream of [process.stdout, process.stderr]) + stream.on('error', () => { + outputClosed = true + }) +const record = (event, data) => { + if (!outputClosed) + console.info(`W65-LIFECYCLE ${JSON.stringify({ event, data })}`) +} + +async function main() { + if (process.platform === 'win32') { + const quote = (value) => + `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1')}"` + const config = Buffer.from( + JSON.stringify({ + root, + fault, + owner: process.pid, + caller: process.ppid, + node: process.execPath, + cwd: process.cwd(), + command: [process.execPath, driver, fault].map(quote).join(' '), + }), + ).toString('base64') + const supervisor = spawn( + 'powershell.exe', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + join(__dirname, 'webpack-supervise.ps1'), + '-Config', + config, + ], + { stdio: 'inherit', windowsHide: true }, + ) + // The foreground PS owner has its own deadline and monitors this handle. + // Never time out/kill the sole job owner from spawnSync in the Bun test. + process.exitCode = await new Promise((resolve, reject) => { + supervisor.once('error', reject) + supervisor.once('exit', (code) => resolve(code ?? 125)) + }) + return + } + + // setsid is only for this referenced, awaited foreground process tree. + const child = spawn(process.execPath, [driver, fault], { + detached: true, + stdio: 'inherit', + env: { ...process.env, DEVUP_NEXT_FIXTURE_ROOT: root }, + }) + let outcome + child.once('error', (error) => { + outcome = 125 + console.error(error) + }) + const exited = new Promise((resolve) => + child.once('exit', (code) => { + outcome = code ?? 125 + resolve() + }), + ) + const signal = () => { + outcome = 125 + } + process.on('SIGTERM', signal) + process.on('SIGINT', signal) + const parent = process.ppid + const started = Date.now() + let deadline = started + 110000 + let reason = 'driver_exit' + record('launch', { root, pid: child.pid }) + const send = (signal) => { + try { + process.kill(-child.pid, signal) + return true + } catch (error) { + if (error.code === 'ESRCH') return false + throw error + } + } + try { + while (outcome === undefined) { + if (process.ppid !== parent || outputClosed) { + outcome = 125 + reason = 'owner_exit' + break + } + if (Date.now() >= deadline) { + outcome = 124 + reason = 'deadline' + break + } + if (fault === 'post-start-timeout' && existsSync(join(root, 'ready'))) + deadline = Math.min(deadline, Date.now() + 500) + await delay(50) + } + } finally { + send('SIGTERM') + await delay(500) + send('SIGKILL') + const cleanupDeadline = Date.now() + 10000 + let active = send(0) + while (active && Date.now() < cleanupDeadline) { + await delay(20) + active = send(0) + } + if (active) { + console.error('Process group cleanup exceeded 10s; preserving fixture') + outcome = 125 + } else { + await exited + record('tree_exit', { pgid: child.pid, active: [], reason }) + rmSync(root, { recursive: true, force: true }) + record('cleanup', { root, removed: true, exitCode: outcome }) + } + process.removeListener('SIGTERM', signal) + process.removeListener('SIGINT', signal) + } + process.exitCode = outcome +} +main().catch((error) => { + console.error(error) + process.exitCode = 125 +}) diff --git a/packages/next-plugin/src/__tests__/webpack-production.cjs b/packages/next-plugin/src/__tests__/webpack-production.cjs new file mode 100644 index 000000000..76dd7f00a --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-production.cjs @@ -0,0 +1,208 @@ +const assert = require('node:assert/strict') +const { spawn } = require('node:child_process') +const { + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + symlinkSync, + writeFileSync, +} = require('node:fs') +const { createRequire } = require('node:module') +const { dirname, join, resolve } = require('node:path') + +const repository = resolve(__dirname, '../../../..') +const fromPlugin = createRequire( + join(repository, 'packages/next-plugin/package.json'), +) +const fromNext = createRequire(fromPlugin.resolve('next/package.json')) +const root = process.env.DEVUP_NEXT_FIXTURE_ROOT +assert.ok( + root, + 'run through webpack-owned.cjs so cleanup has an external owner', +) +const fault = process.argv[2] || '' +function put(name, source) { + const path = join(root, name) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, source) +} +function link(name, target) { + const destination = join(root, 'node_modules', name) + mkdirSync(dirname(destination), { recursive: true }) + symlinkSync( + realpathSync(target), + destination, + process.platform === 'win32' ? 'junction' : 'dir', + ) +} +function contents(directory, extension) { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const path = join(directory, entry.name) + return entry.isDirectory() + ? contents(path, extension) + : entry.name.endsWith(extension) + ? [readFileSync(path, 'utf8')] + : [] + }) + .join('\n') +} +async function main() { + 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'], + { + cwd: root, + stdio: 'inherit', + env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1' }, + }, + ) + console.info(`W65-BUILD-PID ${build.pid}`) + const status = await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Next build deadline exceeded')), + fault === 'build-timeout' ? 5000 : 60000, + ) + build.once('error', (error) => { + clearTimeout(timeout) + reject(error) + }) + build.once('exit', (code) => { + clearTimeout(timeout) + resolve(code) + }) + }) + assert.equal(status, 0, 'actual next build --webpack failed') + assert.doesNotMatch( + contents(join(root, '.next/static/chunks'), '.js'), + /Cannot run on the runtime/, + ) + server = spawn( + process.execPath, + [ + fromPlugin.resolve('next/dist/bin/next'), + 'start', + '--hostname', + '127.0.0.1', + '--port', + '0', + ], + { + cwd: root, + env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + console.info(`W65-SERVER-PID ${server.pid}`) + const base = await new Promise((resolve, reject) => { + let output = '' + const timeout = setTimeout( + () => reject(new Error('Next production server did not become ready')), + 15000, + ) + server.once('error', (error) => { + clearTimeout(timeout) + reject(error) + }) + server.once('exit', (code, signal) => { + clearTimeout(timeout) + reject( + new Error( + `Next production server exited before readiness: ${code}/${signal}`, + ), + ) + }) + server.stderr.on('data', (chunk) => process.stderr.write(chunk)) + server.stdout.on('data', (chunk) => { + process.stdout.write(chunk) + output += chunk.toString() + const address = output.match(/http:\/\/(?:127\.0\.0\.1|localhost):\d+/) + if (address && output.includes('Ready in')) { + clearTimeout(timeout) + resolve(address[0]) + } + }) + }) + writeFileSync(join(root, 'ready'), String(server.pid)) + if (fault === 'post-start-failure') + throw new Error('Injected failure after server readiness') + if (fault === 'driver-death') process.kill(process.pid, 'SIGKILL') + if (fault === 'post-start-timeout') await new Promise(() => {}) + const response = await fetch(base, { signal: AbortSignal.timeout(10000) }) + assert.equal(response.status, 200) + const html = await response.text() + assert.match(html, /id="server-proof" class="[^"]+"/) + const edge = await fetch(`${base}/edge`, { + signal: AbortSignal.timeout(10000), + }) + assert.equal(edge.status, 200) + const edgeBody = await edge.json() + assert.match(edgeBody.edge, /^[\w -]+$/) + console.info( + JSON.stringify({ + ssr: 'server-proof has compiled classes', + edge: edgeBody, + }), + ) + console.info('webpack production proof passed') + } finally { + if ( + server?.pid !== undefined && + server.exitCode === null && + server.signalCode === null + ) { + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 1000) + server.once('exit', () => { + clearTimeout(timeout) + resolve() + }) + server.kill('SIGTERM') + }) + } + server?.stdout.destroy() + server?.stderr.destroy() + // Only the external owner may remove root, after its entire tree has exited. + } +} +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error) + process.exit(1) + }) diff --git a/packages/next-plugin/src/__tests__/webpack-production.test.js b/packages/next-plugin/src/__tests__/webpack-production.test.js new file mode 100644 index 000000000..3a3fe74ba --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-production.test.js @@ -0,0 +1,56 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +for (const fault of [ + '', + 'build-timeout', + 'post-start-failure', + 'post-start-timeout', + 'driver-death', +]) + it(`owns genuine Next webpack processes through ${fault || 'success'}`, () => { + // Given fresh built packages and a physical Next app created by the driver. + // When the installed Next CLI builds it through the real WASM/webpack loader. + const result = spawnSync( + 'node', + [join(import.meta.dir, 'webpack-owned.cjs'), fault], + { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + ) + // Then production compilation and real SSR/edge requests succeed. + expect(result.error).toBeUndefined() + const output = result.stdout + result.stderr + console.info(output) + const events = output + .split(/\r?\n/) + .filter((line) => line.startsWith('W65-LIFECYCLE ')) + .map((line) => JSON.parse(line.slice(14))) + const tree = events.find((entry) => entry.event === 'tree_exit') + const cleanup = events.find((entry) => entry.event === 'cleanup') + expect(tree.data.active).toEqual([]) + expect(cleanup.data.removed).toBe(true) + expect(existsSync(cleanup.data.root)).toBe(false) + if (process.platform === 'win32') { + expect(tree.data.pids.length).toBeGreaterThan(1) + for (const pid of tree.data.pids) { + expect(() => process.kill(pid, 0)).toThrow() + } + for (const match of output.matchAll(/W65-FAULT-PID (\d+)/g)) + expect(tree.data.pids).toContain(Number(match[1])) + } + if (fault) { + expect(result.status).not.toBe(0) + expect(output).not.toContain('webpack production proof passed') + if (fault === 'build-timeout') { + expect(output.match(/W65-FAULT-PID \d+/g)?.length).toBe(2) + expect(output).toContain('Next build deadline exceeded') + } else expect(output).toContain('W65-SERVER-PID ') + if (fault === 'post-start-timeout') + expect(tree.data.reason).toBe('deadline') + } else { + expect(output).toContain('webpack production proof passed') + expect(result.status).toBe(0) + } + }, 130000) diff --git a/packages/next-plugin/src/__tests__/webpack-remove.cjs b/packages/next-plugin/src/__tests__/webpack-remove.cjs new file mode 100644 index 000000000..9a3be149b --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-remove.cjs @@ -0,0 +1 @@ +require('node:fs').rmSync(process.argv[2], { recursive: true, force: true }) diff --git a/packages/next-plugin/src/__tests__/webpack-supervise.ps1 b/packages/next-plugin/src/__tests__/webpack-supervise.ps1 new file mode 100644 index 000000000..2cd5bfd9b --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-supervise.ps1 @@ -0,0 +1,57 @@ +param([string]$Config) +$ErrorActionPreference = 'Stop' +$job = $null +$result = 125 +$reason = 'setup_error' +$clean = $false +$script:outputClosed = $false +function Record($Event, $Data) { + if ($script:outputClosed) { return } + try { [Console]::WriteLine('W65-LIFECYCLE ' + (@{ event = $Event; data = $Data } | ConvertTo-Json -Depth 6 -Compress)) } + catch [System.IO.IOException] { $script:outputClosed = $true } +} +try { + $settings = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Config)) | ConvertFrom-Json + $owner = [Diagnostics.Process]::GetProcessById($settings.owner) + $ownerHandle = $owner.Handle + $caller = [Diagnostics.Process]::GetProcessById($settings.caller) + $callerHandle = $caller.Handle + Add-Type -Path (Join-Path $PSScriptRoot 'webpack-job.cs') + $env:DEVUP_NEXT_FIXTURE_ROOT = $settings.root + $job = New-Object WebpackJob + if ($owner.HasExited) { throw 'Owner exited before launch' } + $job.Launch($settings.command, $settings.cwd) + $clock = [Diagnostics.Stopwatch]::StartNew() + $deadline = 110000 + Record 'launch' @{ root = $settings.root; pid = $job.RootPid } + while ($true) { + $members = @($job.Members()) + Record 'members' @{ pids = $members } + if ($job.RootExited) { $result = $job.ExitCode; $reason = 'driver_exit'; break } + if ($owner.HasExited -or $caller.HasExited) { $result = 125; $reason = 'owner_exit'; break } + if ($clock.ElapsedMilliseconds -ge $deadline) { $result = 124; $reason = 'deadline'; break } + if (($settings.fault -eq 'post-start-timeout') -and (Test-Path -LiteralPath (Join-Path $settings.root 'ready'))) { + $deadline = [Math]::Min($deadline, $clock.ElapsedMilliseconds + 500) + } + Start-Sleep -Milliseconds 100 + } +} catch { + Record 'error' @{ message = $_.Exception.Message } +} finally { + if ($null -ne $job) { + try { + $job.KillAndWait() + Record 'tree_exit' @{ pids = @($job.Observed); active = @($job.Members()); reason = $reason } + $clean = $true + } catch { $result = 125; Record 'cleanup_error' @{ message = $_.Exception.Message } } + finally { $job.Dispose() } + } + if ($clean) { + & $settings.node (Join-Path $PSScriptRoot 'webpack-remove.cjs') $settings.root + if ($LASTEXITCODE -ne 0) { throw 'Fixture removal failed' } + Record 'cleanup' @{ root = $settings.root; removed = $true; exitCode = $result } + } + if ($null -ne $owner) { $owner.Dispose() } + if ($null -ne $caller) { $caller.Dispose() } +} +exit $result diff --git a/packages/webpack-plugin/src/__tests__/loader.test.ts b/packages/webpack-plugin/src/__tests__/loader.test.ts index 57de275aa..e5b1d07c7 100644 --- a/packages/webpack-plugin/src/__tests__/loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/loader.test.ts @@ -196,7 +196,7 @@ describe('devupUILoader', () => { createCodeExtractResult({ code: 'code', css: 'css', - map: '{}', + map: '{"version":3,"names":[],"sources":["index.tsx"],"mappings":"AAAA"}', cssFile: 'cssFile', updatedBaseStyle: options.updatedBaseStyle, }), @@ -228,7 +228,12 @@ describe('devupUILoader', () => { ) } await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith(null, 'code', '{}') + expect(asyncCallback).toHaveBeenCalledWith(null, 'code', { + version: 3, + names: [], + sources: ['index.tsx'], + mappings: 'AAAA', + }) expect(writeFileSpy).toHaveBeenCalledWith( join('cssFile', 'cssFile'), '/* index.tsx 0 */', diff --git a/packages/webpack-plugin/src/loader.ts b/packages/webpack-plugin/src/loader.ts index 0c88b8737..9463fd081 100644 --- a/packages/webpack-plugin/src/loader.ts +++ b/packages/webpack-plugin/src/loader.ts @@ -10,7 +10,7 @@ import { getCss, setModuleResolver, } from '@devup-ui/wasm' -import type { RawLoaderDefinitionFunction } from 'webpack' +import type { LoaderContext, RawLoaderDefinitionFunction } from 'webpack' export interface DevupUILoaderOptions { package: string @@ -27,11 +27,15 @@ function toLoaderError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } -function parseSourceMap(sourceMap: string | undefined): string | null { +type LoaderSourceMap = Exclude< + Parameters['callback']>[2], + string | undefined +> + +function parseSourceMap(sourceMap: string | undefined): LoaderSourceMap { if (!sourceMap) return null - JSON.parse(sourceMap) - return sourceMap + return JSON.parse(sourceMap) } const stateWriter = createStateWriter((path, content, encoding) => @@ -129,7 +133,7 @@ const devupUILoader: RawLoaderDefinitionFunction = } } Promise.all(promises).then( - () => callback(null, code, sourceMap as Parameters[2]), + () => callback(null, code, sourceMap), (error) => callback(toLoaderError(error)), ) } catch (error) { From fd4bb5d48fb9d08208163013744ca6a03b173ad1 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 15 Oct 2026 21:50:33 +0900 Subject: [PATCH 2/2] test(extractor): cover malformed diagnostic restoration Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/emotion_namespace_tests/diagnostics.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/libs/extractor/src/emotion_namespace_tests/diagnostics.rs b/libs/extractor/src/emotion_namespace_tests/diagnostics.rs index 6bd51e42f..89b9ee7ec 100644 --- a/libs/extractor/src/emotion_namespace_tests/diagnostics.rs +++ b/libs/extractor/src/emotion_namespace_tests/diagnostics.rs @@ -14,6 +14,21 @@ fn syntax_errors_are_reported_before_namespace_normalization() { assert_eq!(error, "namespace.tsx:2:27: Missing initializer in const declaration"); } +#[test] +fn restoration_preserves_the_diagnostic_when_its_source_is_malformed() { + // Given: an internal generated-name diagnostic and source the parser rejects. + let source = "import * as E from '@emotion/css';\nE.__emotion_user(); const broken;"; + let message = utils::build_time_error("css", "__emotion_css_0()", "pass a static style object"); + + // When: diagnostic restoration cannot safely inspect the original AST. + let error = emotion_namespace::original_error_code(source, source.find("E.__emotion_user").unwrap(), message.clone(), SourceType::tsx()); + + // Then: the original diagnostic survives together with the parser failure. + assert_eq!(error.lines().next(), Some(message.as_str())); + assert!(error.contains("Emotion diagnostic restoration failed:")); + assert!(error.contains("Missing initializer in const declaration")); +} + #[test] #[serial] fn emotion_restoration_preserves_generic_runtime_read_errors_when_user_names_match_prefix() {