mirror of
https://github.com/oxc-project/oxc.git
synced 2026-09-14 19:36:11 +08:00
dec0a86412
Standardize React Compiler diagnostics with concise titles, primary labels, actionable help, notes, structured categories, and official guidance links. Preserve React guidance when diagnostics are surfaced through Oxlint, and refresh affected snapshots. AI-assisted; reviewed and validated by the contributor.
707 lines
28 KiB
Rust
707 lines
28 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;
|
|
use oxc_parser::Parser;
|
|
use oxc_semantic::SemanticBuilder;
|
|
use oxc_span::SourceType;
|
|
|
|
use oxc_react_compiler::{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());
|
|
}
|
|
}
|
|
|
|
/// 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 local named `fbt` is an unsupported-syntax bail-out — a warning, not an error.
|
|
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!(
|
|
result.diagnostics.has_warnings(),
|
|
"fbt bail-out should be reported as a warning: {:?}",
|
|
result.diagnostics
|
|
);
|
|
assert!(
|
|
!result.diagnostics.has_errors(),
|
|
"fbt warning must not be reported as an error: {:?}",
|
|
result.diagnostics
|
|
);
|
|
}
|
|
|
|
/// 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}");
|
|
}
|