mirror of
https://github.com/oxc-project/oxc.git
synced 2026-09-14 19:36:11 +08:00
e74de61886
Match Babel’s default React Compiler diagnostic reporting and severity. AI-assisted.
720 lines
29 KiB
Rust
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'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'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}");
|
|
}
|