Files
oxc-project__oxc/crates/oxc_react_compiler/tests/transform.rs
Boshen e74de61886 fix(transform-react): match Babel diagnostic reporting (#26128)
Match Babel’s default React Compiler diagnostic reporting and severity.

AI-assisted.
2026-08-27 05:27:21 +00:00

720 lines
29 KiB
Rust

//! End-to-end integration tests: oxc parse + semantic -> compile -> codegen.
use cow_utils::CowUtils;
use oxc_allocator::Allocator;
use oxc_ast::ast::{ModuleExportName, Program, Statement};
use oxc_codegen::Codegen;
use oxc_diagnostics::{Diagnostics, Severity};
use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::SourceType;
use oxc_react_compiler::{
CompilationMode, CompileResult, ErrorCategory, PanicThreshold, PluginOptions, compile,
};
fn options() -> PluginOptions {
PluginOptions::default()
}
struct TransformResult {
changed: bool,
diagnostics: Diagnostics,
fatal: bool,
}
/// Parse `source_text` then run the compiler in place, returning the
/// (possibly rewritten) program together with the result.
fn transform_source<'a>(
source_text: &'a str,
source_type: SourceType,
allocator: &'a Allocator,
options: PluginOptions,
) -> (Program<'a>, TransformResult) {
let mut program = Parser::new(allocator, source_text, source_type).parse().program;
let result = {
let semantic = SemanticBuilder::new().with_build_nodes(true).build(&program).semantic;
compile(&program, &semantic, allocator, options)
};
let result = match result {
CompileResult::Success { output, diagnostics } => {
let changed = output.is_some();
if let Some(output) = output {
output.transform(&mut program);
}
TransformResult { changed, diagnostics, fatal: false }
}
CompileResult::Fatal { diagnostics } => {
TransformResult { changed: false, diagnostics, fatal: true }
}
};
(program, result)
}
#[test]
fn memoizes_a_component_end_to_end() {
let source = "function Component(props) {\n \
return <div onClick={() => props.onClick()}>{props.text}</div>;\n}\n";
let allocator = Allocator::default();
let (program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(!result.diagnostics.has_errors(), "unexpected errors: {:?}", result.diagnostics);
assert!(!result.diagnostics.has_warnings(), "unexpected warnings: {:?}", result.diagnostics);
assert!(result.changed, "React Compiler should have transformed the component");
let output = Codegen::new().build(&program).code;
assert!(
output.contains("react/compiler-runtime"),
"expected the compiler-runtime cache import in output:\n{output}"
);
assert!(output.contains("_c("), "expected memo cache reads (`_c(...)`) in output:\n{output}");
}
#[test]
fn allows_ref_access_in_a_returned_event_handler() {
let source = "\
function Component({ onChange, onInput }) {\n\
const lastValue = useRef(\"\");\n\
const wrappedEvent = (callback) => (event) => {\n\
const text = event.currentTarget.textContent || \"\";\n\
if (text !== lastValue.current) {\n\
lastValue.current = text;\n\
onChange?.(text);\n\
}\n\
callback?.(event);\n\
};\n\
return <div onInput={wrappedEvent(onInput)} />;\n\
}\n";
let allocator = Allocator::default();
let (program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(result.changed, "component should compile; diagnostics: {:?}", result.diagnostics);
assert!(result.diagnostics.is_empty(), "unexpected diagnostics: {:?}", result.diagnostics);
let output = Codegen::new().build(&program).code;
assert!(output.contains("react/compiler-runtime"), "component should memoize:\n{output}");
}
#[test]
fn preserves_manual_memoization_guarantees() {
let source = "\
import { useCallback, useMemo } from 'react';
export function Component({ value }) {
const callback = useCallback(() => value, [value]);
const memo = useMemo(() => ({ callback }), [callback]);
return <button onClick={callback}>{memo.callback()}</button>;
}
";
let allocator = Allocator::default();
let (program, result) =
transform_source(source, SourceType::tsx(), &allocator, PluginOptions::default());
assert!(result.changed, "component should compile: {:?}", result.diagnostics);
assert!(!result.diagnostics.has_errors(), "unexpected errors: {:?}", result.diagnostics);
let output = Codegen::new().build(&program).code;
assert!(
output.contains("if ($[0] !== value)"),
"useCallback must retain its source dependency:\n{output}"
);
assert!(
output.contains("if ($[2] !== callback)"),
"useMemo must retain its source dependency:\n{output}"
);
assert!(
!output.contains("useCallback(()") && !output.contains("useMemo(()"),
"manual memo calls should be lowered into compiler caches:\n{output}"
);
assert!(
output.contains("import { useCallback, useMemo } from \"react\""),
"the compiler must leave surrounding import cleanup to downstream transforms:\n{output}"
);
}
#[test]
fn skips_non_react_code() {
let source = "function add(a, b) {\n return a + b;\n}\n";
let allocator = Allocator::default();
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(!result.changed, "non-React code must not be transformed");
}
#[test]
fn default_lint_suppressions_bail_out() {
let fixtures = [
(
"eslint-disable-next-line",
include_str!("../fixtures/default-suppression-eslint-next-line.js"),
),
(
"eslint-disable block range",
include_str!("../fixtures/default-suppression-eslint-block-range.js"),
),
];
for prefix in ["eslint", "oxlint"] {
for (kind, source) in fixtures {
let source = source.cow_replace("eslint", prefix);
let allocator = Allocator::default();
let (_program, result) =
transform_source(&source, SourceType::tsx(), &allocator, PluginOptions::default());
assert!(!result.changed, "{prefix} {kind} must prevent compilation");
assert!(!result.fatal, "{prefix} {kind} must not produce a fatal result");
assert_eq!(result.diagnostics.len(), 1);
assert!(ErrorCategory::Suppression.matches(&result.diagnostics[0]));
}
}
}
#[test]
fn flow_suppressions_still_bail_out_by_default() {
let source = include_str!("../fixtures/default-suppression-flow.js");
let allocator = Allocator::default();
let (_program, result) =
transform_source(source, SourceType::tsx(), &allocator, PluginOptions::default());
assert!(!result.changed, "Flow suppression must prevent compilation");
assert!(!result.fatal, "Flow suppression must be a nonfatal bail-out by default");
assert_eq!(result.diagnostics.len(), 1);
assert!(ErrorCategory::Suppression.matches(&result.diagnostics[0]));
}
#[test]
fn empty_eslint_suppression_rules_disable_bailouts() {
let source = include_str!("../fixtures/default-suppression-eslint-next-line.js");
let options =
PluginOptions { eslint_suppression_rules: Some(Vec::new()), ..PluginOptions::default() };
let allocator = Allocator::default();
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options);
assert!(result.changed, "an empty suppression rule list must allow compilation");
assert!(!result.fatal);
assert!(!result.diagnostics.has_errors());
}
#[test]
fn eslint_suppressions_take_precedence_over_internal_validations() {
let cases = [
(
"memo dependencies",
"\
import { useMemo } from 'react';
function Component({ value }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled = useMemo(() => value * 2, []);
return <div>{doubled}</div>;
}
",
),
(
"hooks usage",
"\
import { useState } from 'react';
function Component({ condition }) {
if (condition) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useState(0);
}
return <div />;
}
",
),
];
for (kind, source) in cases {
let mut options = PluginOptions::default();
options.environment.validate_exhaustive_memoization_dependencies = true;
let allocator = Allocator::default();
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options);
assert!(!result.changed, "{kind} suppression must prevent compilation");
assert_eq!(result.diagnostics.len(), 1);
assert!(
ErrorCategory::Suppression.matches(&result.diagnostics[0]),
"expected a suppression diagnostic, got {:?}",
result.diagnostics
);
}
}
#[test]
fn custom_eslint_suppressions_bail_out() {
let source = "\
function Component({ value }) {
// eslint-disable-next-line custom/react-rule
const doubled = value * 2;
return <div>{doubled}</div>;
}
";
let allocator = Allocator::default();
let options = PluginOptions {
eslint_suppression_rules: Some(vec!["custom/react-rule".to_string()]),
..PluginOptions::default()
};
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options);
assert!(!result.changed, "custom suppression must bail out by default");
assert_eq!(result.diagnostics.len(), 1);
assert!(ErrorCategory::Suppression.matches(&result.diagnostics[0]));
let allocator = Allocator::default();
let mut options = PluginOptions {
eslint_suppression_rules: Some(vec!["custom/react-rule".to_string()]),
..PluginOptions::default()
};
options.environment.validate_exhaustive_memoization_dependencies = true;
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options);
assert!(!result.changed, "custom suppression must bail out with both validations enabled");
assert_eq!(result.diagnostics.len(), 1);
assert!(ErrorCategory::Suppression.matches(&result.diagnostics[0]));
}
#[test]
fn all_errors_makes_enabled_eslint_suppressions_fatal() {
let source = include_str!("../fixtures/default-suppression-eslint-next-line.js");
let allocator = Allocator::default();
let mut options =
PluginOptions { panic_threshold: PanicThreshold::AllErrors, ..PluginOptions::default() };
options.environment.validate_exhaustive_memoization_dependencies = true;
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options);
assert!(result.fatal, "all_errors must escalate suppression diagnostics");
assert!(!result.changed, "a fatal result must not produce a rewrite");
assert!(result.diagnostics.has_errors());
}
/// TypeScript-only constructs (`declare global`, `import =`, `export =`,
/// overload signatures, `#field in obj`) round-trip without panicking while the
/// component still compiles.
#[test]
fn typescript_only_constructs_round_trip() {
let source = "\
import legacy = require('legacy');\n\
declare global {\n interface Window { __APP__: number; }\n}\n\
declare function ambient(x: number): void;\n\
function overloaded(x: number): number;\n\
function overloaded(x: string): string;\n\
function overloaded(x: unknown): unknown { return x; }\n\
class Brand {\n #brand = 1;\n static isBrand(obj: object) { return #brand in obj; }\n}\n\
function Component(props) {\n return <div>{props.text}</div>;\n}\n\
export = legacy;\n";
let allocator = Allocator::default();
let (program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(result.changed, "component should be compiled; diagnostics: {:?}", result.diagnostics);
let output = Codegen::new().build(&program).code;
assert!(
output.contains("react/compiler-runtime"),
"expected compiler-runtime import in output:\n{output}"
);
assert!(output.contains("declare global"), "`declare global` lost:\n{output}");
assert!(output.contains("import legacy"), "`import =` lost:\n{output}");
assert!(
output.contains("function overloaded(x: number): number;"),
"overload signature lost:\n{output}"
);
assert!(
!output.contains("function overloaded(x: number): number {}"),
"overload signature gained an empty body:\n{output}"
);
assert!(output.contains("export = legacy"), "`export =` lost:\n{output}");
}
/// TS wrappers in assignment-target / for-head positions must not crash the
/// converter (the compiler may still decline to compile them).
#[test]
fn ts_wrapped_assignment_targets_do_not_panic() {
let cases = [
"function Component(props) {\n let x = 0;\n (x as number) = props.x;\n return <div>{x}</div>;\n}\n",
"function Component(props) {\n let x = 0;\n x! = props.x;\n return <div>{x}</div>;\n}\n",
"function Component(props) {\n const o = props.o;\n for ((o.k as string) in props.src) {}\n return <div />;\n}\n",
"function Component(props) {\n const o = props.o;\n for (o.k! of props.src) {}\n return <div />;\n}\n",
"function Component(props) {\n let [a] = props.p;\n ([a!] = props.q);\n return <div>{a}</div>;\n}\n",
];
let opts = options();
for source in cases {
let allocator = Allocator::default();
let _ = transform_source(source, SourceType::tsx(), &allocator, opts.clone());
}
}
#[test]
fn ts_type_assertion_assignment_target_compiles() {
let source = "function Component(props: {x: number}) {\n let x = 0;\n const obj = {x: 1};\n (<number>x) = props.x;\n (<number>x) += 1;\n (<number>obj.x) *= x;\n return x + obj.x;\n}\n";
let allocator = Allocator::default();
let mut opts = options();
opts.compilation_mode = CompilationMode::All;
let (program, result) = transform_source(source, SourceType::ts(), &allocator, opts);
assert!(result.changed, "component should compile; diagnostics: {:?}", result.diagnostics);
assert!(result.diagnostics.is_empty(), "unexpected diagnostics: {:?}", result.diagnostics);
let output = Codegen::new().build(&program).code;
assert!(output.contains("x = props.x"), "type assertion assignment was lost:\n{output}");
assert!(output.contains("x = x + 1"), "type assertion compound assignment was lost:\n{output}");
assert!(
output.contains("obj.x = obj.x * x"),
"type assertion member compound assignment was lost:\n{output}"
);
}
/// Class bodies are stubbed by the converter and re-parsed from source on the
/// way back, so members survive.
#[test]
fn class_body_is_preserved() {
let source = "\
class Store {\n count = 0;\n increment() {\n this.count++;\n }\n}\n\
function Component(props) {\n return <div>{props.text}</div>;\n}\n";
let allocator = Allocator::default();
let (program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(result.changed, "component should be compiled");
let output = Codegen::new().build(&program).code;
assert!(output.contains("react/compiler-runtime"), "component should memoize:\n{output}");
assert!(output.contains("count = 0"), "class field lost:\n{output}");
assert!(output.contains("increment("), "class method lost:\n{output}");
}
#[test]
fn unsupported_sibling_ast_forms_are_preserved() {
let source = "\
import './style.css';\n\
export * as ns from './mod';\n\
function helper() {\n\
const C = class {\n\
method() {\n\
return 1;\n\
}\n\
};\n\
return 123n + BigInt(new C().method());\n\
}\n\
function Component(props) {\n\
return <div>{props.text}</div>;\n\
}\n";
let allocator = Allocator::default();
let (program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(result.changed, "component should be compiled");
let output = Codegen::new().build(&program).code;
assert!(output.contains("react/compiler-runtime"), "component should memoize:\n{output}");
assert!(output.contains("import \"./style.css\";"), "bare import changed:\n{output}");
assert!(
!output.contains("import {} from \"./style.css\""),
"bare import became an empty named import:\n{output}"
);
assert!(output.contains("export * as ns from \"./mod\";"), "namespace export lost:\n{output}");
assert!(output.contains("method() {"), "class expression method lost:\n{output}");
assert!(output.contains("return 1;"), "class expression body lost:\n{output}");
assert!(output.contains("123n"), "BigInt literal lost:\n{output}");
assert!(!output.contains("123nn"), "BigInt literal gained an extra suffix:\n{output}");
}
#[test]
fn typescript_surface_syntax_is_preserved_around_compiled_code() {
let source = "\
import { createContext, forwardRef } from 'react';\n\
type Props = { text: string };\n\
declare const Generic: React.FC<Props>;\n\
declare const tag: <T>(strings: TemplateStringsArray) => string;\n\
class Box<T> {}\n\
const settings = { mode: 'dark' } as const;\n\
const Context = createContext<Props | undefined>(undefined);\n\
const typedValue: string = settings.mode as string;\n\
const checked = settings.mode satisfies string;\n\
const boxed = new Box<Props>();\n\
const tagged = tag<Props>`value`;\n\
const Wrapped = forwardRef<HTMLDivElement, Props>(({ text }: Props, ref): JSX.Element => {\n\
const label: string = text satisfies string;\n\
return <div ref={ref}>{label}</div>;\n\
});\n\
function renderGeneric(props: Props): JSX.Element {\n\
'use no memo';\n\
try {\n\
return <Generic<Props> text={props.text} />;\n\
} catch (error: unknown) {\n\
return <Generic<Props> text={String(error)} />;\n\
}\n\
}\n\
function Component(props: Props): JSX.Element {\n\
return <Context.Provider value={{ text: props.text } as Props}>\n\
<Wrapped text={props.text} />\n\
</Context.Provider>;\n\
}\n";
let allocator = Allocator::default();
let (program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(result.changed, "component should compile; diagnostics: {:?}", result.diagnostics);
let output = Codegen::new().build(&program).code;
assert!(output.contains("react/compiler-runtime"), "component should memoize:\n{output}");
assert!(output.contains("as const"), "`as const` lost:\n{output}");
assert!(
output.contains("declare const Generic: React.FC<Props>"),
"declared variable type annotation lost:\n{output}"
);
assert!(
output.contains("createContext<Props | undefined>"),
"call type arguments lost:\n{output}"
);
assert!(output.contains("typedValue: string"), "variable type annotation lost:\n{output}");
assert!(output.contains("as string"), "`as` expression lost:\n{output}");
assert!(output.contains("satisfies string"), "`satisfies` expression lost:\n{output}");
assert!(output.contains("new Box<Props>()"), "`new` type arguments lost:\n{output}");
assert!(output.contains("tag<Props>`value`"), "tagged template type arguments lost:\n{output}");
assert!(
output.contains("forwardRef<HTMLDivElement, Props>"),
"generic forwardRef call lost type arguments:\n{output}"
);
assert!(output.contains(": Props"), "parameter type lost:\n{output}");
assert!(output.contains(": JSX.Element"), "return type lost:\n{output}");
assert!(output.contains("catch (error: unknown)"), "catch type lost:\n{output}");
assert!(output.contains("<Generic<Props>"), "generic JSX type arguments lost:\n{output}");
}
#[test]
fn type_query_casts_are_renamed_with_value_bindings() {
let source = "\
type Field = { value?: string; optionsInputs?: Record<string, string> };\n\
function Component({ fields }: { fields: Field[] }) {\n\
const field = { value: 'outer', optionsInputs: {} };\n\
const nodes = fields.map((field, index) => {\n\
const options = [{ value: field.value }];\n\
const firstOptionInput = field.optionsInputs?.[options?.[0]?.value as keyof typeof field.optionsInputs];\n\
return <div key={index}>{firstOptionInput}{field.value}</div>;\n\
});\n\
return <>{nodes}{field.value}</>;\n\
}\n";
let allocator = Allocator::default();
let (program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(result.changed, "component should be compiled");
let output = Codegen::new().build(&program).code;
assert!(output.contains("react/compiler-runtime"), "component should memoize:\n{output}");
assert!(
!output.contains("typeof field.optionsInputs"),
"stale type query source was restored:\n{output}"
);
assert!(
output.contains("typeof field_0.optionsInputs"),
"type query binding was not renamed with the value binding:\n{output}"
);
}
#[test]
fn jsx_attribute_string_entities_are_decoded() {
let source = "\
function Component(props) {\n\
return <TemplateLinkSection label={props.label} piiWarning='Use the iframe&apos;s payload.' />;\n\
}\n";
let allocator = Allocator::default();
let (program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(result.changed, "component should be compiled");
let output = Codegen::new().build(&program).code;
assert!(output.contains("react/compiler-runtime"), "component should memoize:\n{output}");
assert!(
output.contains("iframe's payload"),
"JSX string attribute entity was not decoded:\n{output}"
);
assert!(
!output.contains("iframe&apos;s payload"),
"JSX string attribute entity leaked into runtime string:\n{output}"
);
}
#[test]
fn resource_management_declarations_bail_out() {
let cases = [
"\
function Component(props) {\n using x = sideEffect();\n return <div>{props.text}</div>;\n}\n",
"\
async function Component(props) {\n await using x = sideEffect();\n return <div>{props.text}</div>;\n}\n",
];
for source in cases {
let allocator = Allocator::default();
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(!result.changed, "resource management should skip React Compiler");
assert!(result.diagnostics.is_empty(), "unexpected diagnostics: {:?}", result.diagnostics);
}
}
#[test]
fn exported_typescript_runtime_declarations_are_preserved() {
let source = "\
export enum E { A }\n\
export namespace N {\n export const value = E.A;\n}\n\
function Component(props) {\n return <div>{E.A}{N.value}{props.text}</div>;\n}\n";
let allocator = Allocator::default();
let (program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(result.changed, "component should be compiled");
let output = Codegen::new().build(&program).code;
assert!(output.contains("export enum E"), "exported enum lost:\n{output}");
assert!(output.contains("A"), "enum member lost:\n{output}");
assert!(output.contains("export namespace N"), "exported namespace lost:\n{output}");
assert!(output.contains("value = E.A"), "namespace body lost:\n{output}");
assert!(
!output.contains("declare const"),
"exported TS declaration became a placeholder:\n{output}"
);
}
/// A local `export { x }` that re-exports an imported binding must keep its
/// `local` as an `IdentifierReference` after the round-trip, so semantic
/// analysis links it to the import and downstream TypeScript import elision
/// keeps the import alive instead of leaving a dangling export.
#[test]
fn local_reexport_keeps_its_import_binding() {
let source = "\
import { Foo } from './foo';\n\
export { Foo };\n\
function Component(props) {\n return <div>{props.text}</div>;\n}\n";
let allocator = Allocator::default();
let (program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(result.changed, "component should be compiled");
let export = program
.body
.iter()
.find_map(|stmt| match stmt {
Statement::ExportNamedDeclaration(decl) => Some(decl),
_ => None,
})
.expect("a local `export { Foo }` should round-trip");
let local = &export.specifiers.first().expect("export specifier").local;
assert!(
matches!(local, ModuleExportName::IdentifierReference(_)),
"local export `local` must be an IdentifierReference so semantic links it to the import",
);
// The freshly-built scoping for the compiled program must record the
// export's reference to the import, or import elision would drop it.
let semantic = SemanticBuilder::new().build(&program).semantic;
let scoping = semantic.scoping();
let foo = scoping
.symbol_ids()
.find(|&id| scoping.symbol_name(id) == "Foo")
.expect("`Foo` import binding should exist");
assert!(
scoping.get_resolved_references(foo).next().is_some(),
"the local re-export must reference the `Foo` import binding",
);
}
/// A `React.memo(...)` component is anonymous; the prefilter must still see it.
#[test]
fn memo_wrapped_component_compiles() {
let source = "React.memo((props) => {\n return <div>{props.text}</div>;\n});\n";
let allocator = Allocator::default();
let (program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(result.changed, "memo-wrapped component should compile");
let output = Codegen::new().build(&program).code;
assert!(output.contains("react/compiler-runtime"), "should memoize:\n{output}");
assert!(output.contains("_c("), "expected memo cache reads:\n{output}");
}
/// Diagnostics are surfaced at the compiler's own severity, not flattened.
#[test]
fn diagnostics_preserve_compiler_severity() {
// A Rules of Hooks violation is an `Error`-severity diagnostic.
let source = "function Component(props) {\n if (props.cond) {\n useState(0);\n }\n return <div>{props.text}</div>;\n}\n";
let allocator = Allocator::default();
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(
result.diagnostics.has_errors(),
"Rules of Hooks violation should be reported as an error: {:?}",
result.diagnostics
);
// A Todo bail-out is advice.
let source = "function Component() {\n const fbt = \"span\";\n return <fbt desc=\"label\">Hello</fbt>;\n}\n";
let allocator = Allocator::default();
let (_program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].severity, Severity::Advice);
}
/// A warning-level function bail-out must not be promoted to a fatal error:
/// sibling functions should still compile and downstream transforms should run.
#[test]
fn incompatible_library_bailout_remains_a_warning() {
let source = "\
import { useReactTable } from '@tanstack/react-table';\n\
function Table() {\n\
const table = useReactTable({});\n\
return <div>{table}</div>;\n\
}\n\
export function Component(props: { text: string }) {\n\
return <span>{props.text}</span>;\n\
}\n";
let allocator = Allocator::default();
let (program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(result.changed, "the unaffected component should compile");
assert!(
result.diagnostics.has_warnings(),
"the incompatible library should report a warning: {:?}",
result.diagnostics
);
assert!(
!result.diagnostics.has_errors(),
"a warning-level function bail-out must not become fatal: {:?}",
result.diagnostics
);
assert_eq!(result.diagnostics.len(), 1);
assert!(
ErrorCategory::IncompatibleLibrary.matches(&result.diagnostics[0]),
"expected the original compiler diagnostic: {:?}",
result.diagnostics
);
assert!(
result
.diagnostics
.iter()
.all(|diagnostic| !diagnostic.message.contains("Unexpected error")),
"the bail-out must not create a synthetic error: {:?}",
result.diagnostics
);
let output = Codegen::new().build(&program).code;
assert!(
output.contains("react/compiler-runtime"),
"expected the unaffected component to be transformed:\n{output}"
);
}
/// The transform only rewrites the compiled functions, so top-level comments
/// survive; comments inside a compiled function are pruned because their anchor no
/// longer resolves to a statement (see `prune_inner_comments`).
#[test]
fn top_level_comments_are_preserved() {
let source = "\
// keep: leading\n\
import { useState } from 'react';\n\
/** keep: jsdoc */\n\
function Component(props) {\n\
// drop: inner\n\
return <div onClick={() => props.onClick()}>{props.text}</div>;\n\
}\n\
// keep: trailing\n\
export default Component;\n";
let allocator = Allocator::default();
let (program, result) = transform_source(source, SourceType::tsx(), &allocator, options());
assert!(result.changed, "component should be compiled");
let output = Codegen::new().build(&program).code;
assert!(output.contains("react/compiler-runtime"), "component should memoize:\n{output}");
assert!(output.contains("// keep: leading"), "leading comment lost:\n{output}");
assert!(output.contains("/** keep: jsdoc */"), "jsdoc comment lost:\n{output}");
assert!(output.contains("// keep: trailing"), "trailing comment lost:\n{output}");
assert!(!output.contains("// drop: inner"), "inner comment should not be recovered:\n{output}");
}