Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/preserve-flow-component-type-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
swc: patch
swc_ecma_codegen: patch
swc_ecma_transforms_typescript: patch
---

fix(es/typescript): Preserve Flow component type semantics
55 changes: 55 additions & 0 deletions crates/swc/tests/flow_strip_correctness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use swc::{
use swc_common::FileName;
use swc_ecma_ast::EsVersion;
use swc_ecma_parser::{parse_file_as_program, EsSyntax, FlowSyntax, Syntax};
use swc_ecma_testing::{exec_node_js, JsExecOptions};
use testing::Tester;

#[testing::fixture("../swc_ecma_parser/tests/flow/**/*.js")]
Expand Down Expand Up @@ -97,6 +98,60 @@ fn flow_strip_correctness(input: PathBuf) {
.unwrap();
}

#[test]
fn issue_12045_component_arrow_supports_react_native_mock_access() {
Comment on lines +101 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move Flow strip regression into a fixture

This adds the React Native mock regression as an inline #[test], but the repository asks new coverage to prefer fixture suites; this case can live with the existing Flow strip fixtures instead of embedding source and assertions directly in the harness, and the same pattern also appears in the transform crate test added by this change.

AGENTS.md reference: AGENTS.md:L47-L47

Useful? React with 👍 / 👎.

Tester::new()
.print_errors(|cm, handler| {
let compiler = Compiler::new(cm.clone());
let fm = cm.new_source_file(
FileName::Custom("issue-12045.js".into()).into(),
"const MyComponent: component(ref?: mixed, ...props: mixed) = ({ ref, ...rest }) \
=> null;",
);
let output = compiler
.process_js_file(
fm,
&handler,
&Options {
swcrc: false,
config: Config {
jsc: JscConfig {
syntax: Some(Syntax::Flow(FlowSyntax {
components: true,
..Default::default()
})),
target: Some(EsVersion::Es2022),
..Default::default()
},
..Default::default()
},
..Default::default()
},
)
.expect("failed to compile Flow component arrow");

assert!(
output
.code
.contains("const MyComponent = function MyComponent("),
"expected a named component function, got: {}",
output.code
);

let runtime = format!(
"{}\nconst RealComponent = MyComponent;\nconst constructor = \
RealComponent.prototype.constructor;\nconsole.log(constructor === MyComponent);",
output.code
);
let stdout = exec_node_js(&runtime, JsExecOptions::default())
.expect("React Native mock prototype access should execute");
assert_eq!(stdout.trim(), "true");

Ok(())
})
.unwrap();
}

fn load_flow_syntax(config_path: PathBuf, is_jsx: bool) -> FlowSyntax {
let mut flow_syntax = FlowSyntax {
jsx: is_jsx,
Expand Down
2 changes: 1 addition & 1 deletion crates/swc_ecma_codegen/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ swc_allocator = { version = "5.0.0", path = "../swc_allocator" }
swc_common = { version = "24.0.0", path = "../swc_common", features = [
"sourcemap",
] }
swc_ecma_parser = { version = "43.0.0", path = "../swc_ecma_parser" }
swc_ecma_parser = { version = "43.0.0", path = "../swc_ecma_parser", features = ["flow"] }
swc_ecma_testing = { version = "25.0.0", path = "../swc_ecma_testing" }
swc_malloc = { version = "1.2.5", path = "../swc_malloc" }
swc_sourcemap = { workspace = true }
Expand Down
82 changes: 82 additions & 0 deletions crates/swc_ecma_codegen/src/typescript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,71 @@ impl MacroNode for TsFnType {
fn emit(&mut self, emitter: &mut Macro) -> Result {
emitter.emit_leading_comments_of_span(self.span(), false)?;

if is_flow_component_type(self) {
keyword!(emitter, "component");
emit!(self.type_params);

punct!(emitter, "(");
let [TsFnParam::Object(props)] = self.params.as_slice() else {
unreachable!("Flow component types have one object-pattern parameter")
};
for (index, prop) in props.props.iter().enumerate() {
if index != 0 {
punct!(emitter, ",");
formatting_space!(emitter);
}

match prop {
ObjectPatProp::KeyValue(prop) => {
let is_shorthand = match prop.value.as_ref() {
Pat::Ident(binding) => match &prop.key {
PropName::Ident(key) => key.sym == binding.id.sym,
_ => false,
},
Pat::Assign(assign) => match assign.left.as_ref() {
Pat::Ident(binding) => match &prop.key {
PropName::Ident(key) => key.sym == binding.id.sym,
_ => false,
},
_ => false,
},
_ => false,
};

if is_shorthand {
emit!(prop.value);
} else {
emit!(prop.key);
formatting_space!(emitter);
keyword!(emitter, "as");
formatting_space!(emitter);
Comment on lines +379 to +381

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep hard spaces around component aliases

When minifying an aliased component prop such as component(foo as bar: string), both formatting_space! calls are suppressed, so codegen writes component(fooasbar:string). That reparses as a single fooasbar prop instead of foo aliased to bar, silently changing exported Flow component types; use hard space! around the as keyword.

Useful? React with 👍 / 👎.

emit!(prop.value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit optional aliases in Flow component order

For Flow component props that are both optional and aliased, e.g. component(foo? as bar: string), the parser records the optional marker on the alias binding. Emitting the whole value here therefore produces component(foo as bar?: string), but this parser's component grammar only accepts ? before as, so codegen can generate Flow that immediately fails to reparse. When the key/value are not shorthand, the optional marker needs to be printed on the prop key side instead of inside the alias pattern.

Useful? React with 👍 / 👎.

Comment on lines +377 to +382

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid leaking fallback names for string component props

For a component type with a string-literal prop written without an alias, e.g. component("data-testid": string), the parser stores the key as PropName::Str and uses the synthetic component_prop fallback binding. This branch treats every non-identifier/shorthand key as an alias and emits "data-testid" as component_prop: string, so codegen changes public Flow declarations by exposing an implementation-only name. Detect the synthetic fallback for string keys and print the original "key": Type form instead.

Useful? React with 👍 / 👎.

}
}
ObjectPatProp::Assign(prop) => emit!(prop),
ObjectPatProp::Rest(prop) => emit!(prop),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve spread component rest types in codegen

When a component type uses Flow's spread-prop rest syntax like component(...Props), the parser represents it as an ObjectPatProp::Rest with a synthetic component_rest binding and the real spread type in the rest type annotation. Emitting the RestPat here prints ...component_rest: Props, which is a named rest prop rather than the original spread-prop type, so codegen changes public Flow types for React Native declarations such as component(...AnimatedProps<Props>). Detect that synthetic spread-type form and emit ...<type> instead of the fallback binding.

Useful? React with 👍 / 👎.

#[cfg(swc_ast_unknown)]
_ => return Err(unknown_error()),
}
}
punct!(emitter, ")");

if !matches!(
self.type_ann.type_ann.as_ref(),
TsType::TsKeywordType(TsKeywordType {
kind: TsKeywordTypeKind::TsAnyKeyword,
..
})
) {
formatting_space!(emitter);
keyword!(emitter, "renders");
space!(emitter);
emit!(self.type_ann);
}

return Ok(());
}

emit!(self.type_params);

punct!(emitter, "(");
Expand All @@ -332,6 +397,23 @@ impl MacroNode for TsFnType {
}
}

/// Returns whether this function type is the parser's representation of a
/// Flow `component(...)` type.
///
/// Flow component parameters describe a single props object. The parser
/// preserves that syntax without extending the public AST by giving the
/// synthetic object pattern the same non-dummy span as the function type.
#[inline]
fn is_flow_component_type(fn_type: &TsFnType) -> bool {
matches!(
fn_type.params.as_slice(),
[TsFnParam::Object(props)]
if !fn_type.span.is_dummy()
&& props.span == fn_type.span
&& props.type_ann.is_none()
)
}

#[node_impl]
impl MacroNode for TsImportEqualsDecl {
fn emit(&mut self, emitter: &mut Macro) -> Result {
Expand Down
47 changes: 40 additions & 7 deletions crates/swc_ecma_codegen/tests/fixture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ use std::{
};

use serde::Deserialize;
use swc_common::comments::SingleThreadedComments;
use swc_common::{comments::SingleThreadedComments, FileName};
use swc_ecma_ast::EsVersion;
use swc_ecma_codegen::{
text_writer::{JsWriter, WriteJs},
Emitter,
};
use swc_ecma_parser::{parse_file_as_module, Syntax, TsSyntax};
use swc_ecma_parser::{parse_file_as_module, FlowSyntax, Syntax, TsSyntax};
use testing::{run_test2, NormalizedOutput};

const fn true_by_default() -> bool {
Expand All @@ -21,12 +21,18 @@ const fn true_by_default() -> bool {
struct TestConfig {
#[serde(default = "true_by_default")]
reduce_escaped_newline: bool,
#[serde(default)]
flow: bool,
#[serde(default)]
flow_components: bool,
}

impl Default for TestConfig {
fn default() -> Self {
TestConfig {
reduce_escaped_newline: true,
flow: false,
flow_components: false,
}
}
}
Expand Down Expand Up @@ -61,14 +67,23 @@ fn run(input: &Path, minify: bool) {
let fm = cm.load_file(input).unwrap();
let comments = SingleThreadedComments::default();

let m = parse_file_as_module(
&fm,
let syntax = if config.flow {
Syntax::Flow(FlowSyntax {
components: config.flow_components,
..Default::default()
})
} else {
Syntax::Typescript(TsSyntax {
decorators: true,
tsx: true,
dts,
..Default::default()
}),
})
};

let m = parse_file_as_module(
&fm,
syntax,
EsVersion::latest(),
Some(&comments),
&mut Vec::new(),
Expand All @@ -89,15 +104,33 @@ fn run(input: &Path, minify: bool) {
cfg: swc_ecma_codegen::Config::default()
.with_minify(minify)
.with_reduce_escaped_newline(config.reduce_escaped_newline),
cm,
cm: cm.clone(),
comments: Some(&comments),
wr,
};

emitter.emit_module(&m).unwrap();
}

NormalizedOutput::from(String::from_utf8(buf).unwrap())
let output_code = String::from_utf8(buf).unwrap();
if config.flow {
let output_fm = cm.new_source_file(FileName::Anon.into(), output_code.clone());
let mut errors = Vec::new();
parse_file_as_module(
&output_fm,
Syntax::Flow(FlowSyntax {
components: config.flow_components,
..Default::default()
}),
EsVersion::latest(),
None,
&mut errors,
)
.expect("generated Flow should parse");
assert!(errors.is_empty(), "generated Flow had parser errors");
}

NormalizedOutput::from(output_code)
.compare_to_file(&output)
.unwrap();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"flow": true,
"flow_components": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
type ComponentType<T> = component(ref?: T, ...props: mixed) renders React.Node;
type FunctionType<T> = (props: T) => React.Node;
type DestructuredFunctionType = ({value}: mixed) => mixed;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
type ComponentType<T> = component(ref?: T, ...props: mixed) renders React.Node;
type FunctionType<T> = (props: T) => React.Node;
type DestructuredFunctionType = ({ value }: mixed) => mixed;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
type ComponentType<T>=component(ref?:T,...props:mixed)renders React.Node;type FunctionType<T>=(props:T)=>React.Node;type DestructuredFunctionType=({value}: mixed)=>mixed;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
type ComponentType = component(ref?: mixed, ...props: mixed);
type FunctionType = (props: mixed) => mixed;
type HookType = hook (mixed) => mixed;

const MyComponent: component(ref?: mixed, ...props: mixed) = ({
ref,
...rest
}) => null;
export const ExportedComponent: component(value: mixed) = value => value;
const OrdinaryArrow: (value: mixed) => mixed = value => value;
const HookArrow: hook (mixed) => mixed = value => value;
const UntypedArrow = value => value;
const ExistingFunction: component() = function() {
return null;
};
Loading
Loading