Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
14 changes: 14 additions & 0 deletions .changeset/preserve-flow-component-type-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
swc: patch
swc_core: major
swc_ecma_ast: major
swc_ecma_codegen: patch
swc_ecma_hooks: patch
swc_ecma_parser: major
swc_ecma_react_compiler: patch
swc_ecma_transforms_typescript: patch
swc_ecma_visit: patch

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 Mark swc_ecma_visit as a breaking release

This changeset records swc_ecma_visit as a patch, but the same commit adds NodeRef::TsComponentType to the public NodeRef enum in crates/swc_ecma_visit/src/generated.rs, which downstream users can exhaustively match. Publishing this as a patch can ship a semver-breaking API under the existing major version; bump swc_ecma_visit as major or avoid changing that public enum.

AGENTS.md reference: AGENTS.md:L41-L41

Useful? React with 👍 / 👎.

swc_estree_compat: patch
---

fix(es/typescript): Preserve Flow component type semantics
14 changes: 13 additions & 1 deletion bindings/binding_core_wasm/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2636,7 +2636,10 @@ export type TsType =
| TsTypePredicate
| TsImportType;

export type TsFnOrConstructorType = TsFunctionType | TsConstructorType;
export type TsFnOrConstructorType =
| TsFunctionType
| TsConstructorType
| TsComponentType;

export interface TsKeywordType extends Node, HasSpan {
type: "TsKeywordType";
Expand Down Expand Up @@ -2678,6 +2681,15 @@ export interface TsFunctionType extends Node, HasSpan {
typeAnnotation: TsTypeAnnotation;
}

export interface TsComponentType extends Node, HasSpan {
type: "TsComponentType";

params: TsFnParameter[];

typeParams?: TsTypeParameterDeclaration;
typeAnnotation: TsTypeAnnotation;
}

export interface TsConstructorType extends Node, HasSpan {
type: "TsConstructorType";

Expand Down
14 changes: 13 additions & 1 deletion bindings/binding_minifier_wasm/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2542,7 +2542,10 @@ export type TsType =
| TsTypePredicate
| TsImportType;

export type TsFnOrConstructorType = TsFunctionType | TsConstructorType;
export type TsFnOrConstructorType =
| TsFunctionType
| TsConstructorType
| TsComponentType;

export interface TsKeywordType extends Node, HasSpan {
type: "TsKeywordType";
Expand Down Expand Up @@ -2584,6 +2587,15 @@ export interface TsFunctionType extends Node, HasSpan {
typeAnnotation: TsTypeAnnotation;
}

export interface TsComponentType extends Node, HasSpan {
type: "TsComponentType";

params: TsFnParameter[];

typeParams?: TsTypeParameterDeclaration;
typeAnnotation: TsTypeAnnotation;
}

export interface TsConstructorType extends Node, HasSpan {
type: "TsConstructorType";

Expand Down
1 change: 1 addition & 0 deletions crates/swc/examples/node_counter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ macro_rules! node_ref_variants {
TsArrayType,
TsAsExpr,
TsCallSignatureDecl,
TsComponentType,
TsConditionalType,
TsConstAssertion,
TsConstructSignatureDecl,
Expand Down
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_ast/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ pub use self::{
SwitchCase, SwitchStmt, ThrowStmt, TryStmt, VarDeclOrExpr, WhileStmt, WithStmt,
},
typescript::{
Accessibility, TruePlusMinus, TsArrayType, TsAsExpr, TsCallSignatureDecl,
Accessibility, TruePlusMinus, TsArrayType, TsAsExpr, TsCallSignatureDecl, TsComponentType,
TsConditionalType, TsConstAssertion, TsConstructSignatureDecl, TsConstructorType,
TsEntityName, TsEnumDecl, TsEnumMember, TsEnumMemberId, TsExportAssignment,
TsExprWithTypeArgs, TsExternalModuleRef, TsFnOrConstructorType, TsFnParam, TsFnType,
Expand Down
35 changes: 35 additions & 0 deletions crates/swc_ecma_ast/src/typescript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ pub enum TsType {

#[tag("TsFunctionType")]
#[tag("TsConstructorType")]
#[tag("TsComponentType")]
TsFnOrConstructorType(TsFnOrConstructorType),

#[tag("TsTypeReference")]
Expand Down Expand Up @@ -414,6 +415,12 @@ pub enum TsFnOrConstructorType {
TsFnType(TsFnType),
#[tag("TsConstructorType")]
TsConstructorType(TsConstructorType),
/// A Flow `component(...)` type.
///
/// This is distinct from [`TsFnType`] because Flow stripping gives
/// component-typed arrow bindings function semantics.
#[tag("TsComponentType")]
TsComponentType(TsComponentType),
}

impl From<TsFnType> for TsType {
Expand All @@ -428,6 +435,12 @@ impl From<TsConstructorType> for TsType {
}
}

impl From<TsComponentType> for TsType {
fn from(t: TsComponentType) -> Self {
TsFnOrConstructorType::TsComponentType(t).into()
}
}

impl From<TsUnionType> for TsType {
fn from(t: TsUnionType) -> Self {
TsUnionOrIntersectionType::TsUnionType(t).into()
Expand Down Expand Up @@ -543,6 +556,28 @@ pub struct TsFnType {
pub type_ann: Box<TsTypeAnn>,
}

/// A Flow `component(...)` type annotation.
///
/// Flow component parameters describe a single props object, so the parser
/// represents them as one object-pattern entry in `params`.
#[ast_node("TsComponentType")]
#[derive(Eq, Hash, EqIgnoreSpan)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[cfg_attr(feature = "shrink-to-fit", derive(shrink_to_fit::ShrinkToFit))]
pub struct TsComponentType {
pub span: Span,
pub params: Vec<TsFnParam>,

#[cfg_attr(feature = "serde-impl", serde(default))]
#[cfg_attr(
feature = "encoding-impl",
encoding(with = "cbor4ii::core::types::Maybe")
)]
pub type_params: Option<Box<TsTypeParamDecl>>,
#[cfg_attr(feature = "serde-impl", serde(rename = "typeAnnotation"))]
pub type_ann: Box<TsTypeAnn>,
}

#[ast_node("TsConstructorType")]
#[derive(Eq, Hash, EqIgnoreSpan)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
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
75 changes: 75 additions & 0 deletions crates/swc_ecma_codegen/src/typescript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ impl MacroNode for TsFnOrConstructorType {
match self {
TsFnOrConstructorType::TsFnType(n) => emit!(n),
TsFnOrConstructorType::TsConstructorType(n) => emit!(n),
TsFnOrConstructorType::TsComponentType(n) => emit!(n),
#[cfg(swc_ast_unknown)]
_ => return Err(unknown_error()),
}
Expand Down Expand Up @@ -332,6 +333,80 @@ impl MacroNode for TsFnType {
}
}

#[node_impl]
impl MacroNode for TsComponentType {
fn emit(&mut self, emitter: &mut Macro) -> Result {
emitter.emit_leading_comments_of_span(self.span(), false)?;

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

punct!(emitter, "(");
// Flow component parameters are stored as a single props object
// pattern. Emit its properties without object-pattern braces to
// reconstruct `component(prop: Type, ...rest: Type)` syntax.
if let [TsFnParam::Object(props)] = self.params.as_slice() {
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()),
}
}
} else {
emitter.emit_list(self.span, Some(&self.params), ListFormat::Parameters)?;
}
punct!(emitter, ")");

if !matches!(
self.type_ann.type_ann.as_ref(),
TsType::TsKeywordType(TsKeywordType {
kind: TsKeywordTypeKind::TsAnyKeyword,
..

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 explicit renders any annotations

When the input explicitly says renders any, parse_flow_component_renders_ann stores the same TsAnyKeyword that parse_flow_component_type uses as the synthetic default for a missing renders clause, so this check suppresses both cases. A declaration such as type C = component() renders any; is printed as type C = component();, losing the user-authored Flow render bound in generated public declarations; keep the presence of the renders clause separately or only elide the parser-inserted default.

Useful? React with 👍 / 👎.

})
) {
formatting_space!(emitter);
keyword!(emitter, "renders");
space!(emitter);
emit!(self.type_ann);
}

Ok(())
}
}

#[node_impl]
impl MacroNode for TsImportEqualsDecl {
fn emit(&mut self, emitter: &mut Macro) -> Result {
Expand Down
Loading
Loading