From 22f166a5e2dabc8943c1caa4af412508d9479484 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Wed, 13 May 2026 17:09:16 +0100 Subject: [PATCH 1/2] fix(import_elision): handle tuple type elements without panicking `TSTupleElement::to_ts_type()` panicked when the element was `TSOptionalType` or `TSRestType` (discriminants outside the inherited `TSType` range). Switch to `as_ts_type()` for safety and add explicit branches for those two variants. Also adds `TSType::TSNamedTupleMember` handling in `collect_computed_keys_from_ts_type`: named tuple members are inherited `TSType` variants so they arrive there via `as_ts_type()`, not the match arm that was previously added for them (which was unreachable). Without this, computed property keys inside named tuple member element types were silently not traversed and their imports were incorrectly elided. Co-Authored-By: Claude Sonnet 4.6 --- .../src/component/import_elision.rs | 131 +++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/crates/oxc_angular_compiler/src/component/import_elision.rs b/crates/oxc_angular_compiler/src/component/import_elision.rs index a6342b7bc..298095dca 100644 --- a/crates/oxc_angular_compiler/src/component/import_elision.rs +++ b/crates/oxc_angular_compiler/src/component/import_elision.rs @@ -235,8 +235,51 @@ impl<'a> ImportElisionAnalyzer<'a> { Self::collect_computed_keys_from_ts_type(&array_type.element_type, result); } TSType::TSTupleType(tuple_type) => { + // `TSTupleElement` inherits the full set of `TSType` + // variants AND adds three of its own: `TSOptionalType` + // (`[number?]`), `TSRestType` (`[...string[]]`), and + // `TSNamedTupleMember` (`[name: string]`). The previous + // `element.to_ts_type()` call panicked with + // `Option::unwrap() on a None value` whenever a tuple + // contained any of those three variants — common in + // real code, e.g. function signatures expressed as + // tuple types in component decorator metadata. + // + // Use `as_ts_type()` (the safe `Option`-returning + // sibling of `to_ts_type`) for inherited variants, and + // unpack the wrapped type for each of the three named + // variants explicitly. for element in &tuple_type.element_types { - Self::collect_computed_keys_from_ts_type(element.to_ts_type(), result); + if let Some(ty) = element.as_ts_type() { + Self::collect_computed_keys_from_ts_type(ty, result); + continue; + } + match element { + oxc_ast::ast::TSTupleElement::TSOptionalType(opt) => { + Self::collect_computed_keys_from_ts_type( + &opt.type_annotation, + result, + ); + } + oxc_ast::ast::TSTupleElement::TSRestType(rest) => { + Self::collect_computed_keys_from_ts_type( + &rest.type_annotation, + result, + ); + } + oxc_ast::ast::TSTupleElement::TSNamedTupleMember(named) => { + // `named.element_type` is itself a + // `TSTupleElement`; recurse into its + // inherited TSType (named members + // can't nest per the TS spec, so we + // only need the inherited-variant + // branch here). + if let Some(inner) = named.element_type.as_ts_type() { + Self::collect_computed_keys_from_ts_type(inner, result); + } + } + _ => {} + } } } TSType::TSTypeReference(type_ref) => { @@ -246,6 +289,15 @@ impl<'a> ImportElisionAnalyzer<'a> { } } } + TSType::TSNamedTupleMember(named) => { + // Named tuple members (`[label: T]`) are inherited TSType variants, + // so they reach here via the `as_ts_type()` branch in the TSTupleType + // handler. The unreachable match arm added there is a no-op; traversal + // must happen here instead. + if let Some(inner) = named.element_type.as_ts_type() { + Self::collect_computed_keys_from_ts_type(inner, result); + } + } TSType::TSParenthesizedType(paren_type) => { Self::collect_computed_keys_from_ts_type(&paren_type.type_annotation, result); } @@ -1960,6 +2012,83 @@ class MyComponent { ); } + #[test] + fn test_computed_key_in_optional_tuple_element_preserved() { + // TSOptionalType (`[number?]`) in a tuple previously caused a panic via + // `to_ts_type()` (which called `unwrap()` on None). The fix uses + // `as_ts_type()` and handles the three named TSTupleElement variants. + let source = r#" +import { Component, Input } from '@angular/core'; +import { myKey } from './keys'; + +@Component({ selector: 'test' }) +class MyComponent { + @Input() pair: [string?, { [myKey]: number }?]; +} +"#; + let type_only = analyze_source(source); + assert!( + !type_only.contains("myKey"), + "myKey in optional tuple element type literal should be preserved" + ); + } + + #[test] + fn test_computed_key_in_rest_tuple_element_preserved() { + // TSRestType (`[...T[]]`) in a tuple previously caused a panic via + // `to_ts_type()`. The fix explicitly unwraps `TSRestType`. + let source = r#" +import { Component, Input } from '@angular/core'; +import { myKey } from './keys'; + +@Component({ selector: 'test' }) +class MyComponent { + @Input() pair: [string, ...{ [myKey]: number }[]]; +} +"#; + let type_only = analyze_source(source); + assert!( + !type_only.contains("myKey"), + "myKey inside rest tuple element type should be preserved" + ); + } + + #[test] + fn test_computed_key_in_named_tuple_member_preserved() { + // TSNamedTupleMember (`[name: T]`) in a tuple previously caused a panic + // via `to_ts_type()`. The fix explicitly unwraps `TSNamedTupleMember`. + let source = r#" +import { Component, Input } from '@angular/core'; +import { myKey } from './keys'; + +@Component({ selector: 'test' }) +class MyComponent { + @Input() pair: [label: string, item: { [myKey]: number }]; +} +"#; + let type_only = analyze_source(source); + assert!( + !type_only.contains("myKey"), + "myKey in named tuple member type literal should be preserved" + ); + } + + #[test] + fn test_tuple_with_mixed_element_kinds_no_panic() { + // Regression: a tuple with optional, rest, and named members together + // must not panic (TSOptionalType/TSRestType previously hit the unwrap in to_ts_type). + let source = r#" +import { Component, Input } from '@angular/core'; + +@Component({ selector: 'test' }) +class MyComponent { + @Input() data: [label: string, value?: number, ...rest: string[]]; +} +"#; + let type_only = analyze_source(source); + assert!(!type_only.contains("Component"), "Component should be preserved (decorator)"); + } + #[test] fn test_computed_key_in_parenthesized_type_preserved() { // Review claim: TSParenthesizedType is not handled From faa41c3b412eb54de99ad46265b10851f4de796b Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Wed, 13 May 2026 17:10:21 +0100 Subject: [PATCH 2/2] refactor(import_elision): remove unreachable TSNamedTupleMember match arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TSNamedTupleMember is an inherited TSType variant so as_ts_type() always returns Some for it — the match arm added in the TSTupleType loop was dead code. Traversal is handled by the TSNamedTupleMember arm in collect_computed_keys_from_ts_type. Clean up the stale comment too. Co-Authored-By: Claude Sonnet 4.6 --- .../src/component/import_elision.rs | 34 ++++--------------- 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/import_elision.rs b/crates/oxc_angular_compiler/src/component/import_elision.rs index 298095dca..cdce0c627 100644 --- a/crates/oxc_angular_compiler/src/component/import_elision.rs +++ b/crates/oxc_angular_compiler/src/component/import_elision.rs @@ -245,10 +245,11 @@ impl<'a> ImportElisionAnalyzer<'a> { // real code, e.g. function signatures expressed as // tuple types in component decorator metadata. // - // Use `as_ts_type()` (the safe `Option`-returning - // sibling of `to_ts_type`) for inherited variants, and - // unpack the wrapped type for each of the three named - // variants explicitly. + // `TSTupleElement` adds `TSOptionalType` and `TSRestType` on top of the + // inherited `TSType` variants. Use `as_ts_type()` for the common inherited + // path and unpack the two named variants explicitly. + // `TSNamedTupleMember` is an inherited `TSType` variant and is handled in + // the `TSType::TSNamedTupleMember` arm of `collect_computed_keys_from_ts_type`. for element in &tuple_type.element_types { if let Some(ty) = element.as_ts_type() { Self::collect_computed_keys_from_ts_type(ty, result); @@ -256,27 +257,10 @@ impl<'a> ImportElisionAnalyzer<'a> { } match element { oxc_ast::ast::TSTupleElement::TSOptionalType(opt) => { - Self::collect_computed_keys_from_ts_type( - &opt.type_annotation, - result, - ); + Self::collect_computed_keys_from_ts_type(&opt.type_annotation, result); } oxc_ast::ast::TSTupleElement::TSRestType(rest) => { - Self::collect_computed_keys_from_ts_type( - &rest.type_annotation, - result, - ); - } - oxc_ast::ast::TSTupleElement::TSNamedTupleMember(named) => { - // `named.element_type` is itself a - // `TSTupleElement`; recurse into its - // inherited TSType (named members - // can't nest per the TS spec, so we - // only need the inherited-variant - // branch here). - if let Some(inner) = named.element_type.as_ts_type() { - Self::collect_computed_keys_from_ts_type(inner, result); - } + Self::collect_computed_keys_from_ts_type(&rest.type_annotation, result); } _ => {} } @@ -290,10 +274,6 @@ impl<'a> ImportElisionAnalyzer<'a> { } } TSType::TSNamedTupleMember(named) => { - // Named tuple members (`[label: T]`) are inherited TSType variants, - // so they reach here via the `as_ts_type()` branch in the TSTupleType - // handler. The unreachable match arm added there is a no-op; traversal - // must happen here instead. if let Some(inner) = named.element_type.as_ts_type() { Self::collect_computed_keys_from_ts_type(inner, result); }