mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
fix(transformer/legacy-decorator): emit Array for ReadonlyArray<T> in decorator metadata (#22265)
## Summary
`ReadonlyArray<T>` is a TypeScript-only utility type with no runtime
value. The legacy decorator metadata emit currently wraps it in the
runtime guard `typeof (_ref = typeof X !== "undefined" && X) ===
"function" ? _ref : Object`, which always falls through to `Object`
because there is no runtime `ReadonlyArray` binding. tsc resolves this
through the checker and emits `Array`; OXC's existing handling of the
short form `readonly T[]` already emits `Array`, so the generic form is
the only divergent shape.
## The bug
```ts
class Entity {
@D() shortForm!: readonly string[]; // tsc: Array, OXC: Array (already correct)
@D() genericForm!: ReadonlyArray<string>; // tsc: Array, OXC: Object (bug)
@D() nestedReadonly!: ReadonlyArray<ReadonlyArray<number>>; // same
}
```
The two surface forms describe the same type but produce different
runtime metadata. Downstream consumers (TypeORM `@Column` array
inference, NestJS Swagger schema, AutoMapper field detection) rely on
the `Array` constructor; `Object` causes silent degradation.
## Fix
In `serialize_type_reference_node`, recognize `ReadonlyArray` by name
when the symbol is unresolved or type-only. Emit `global_array(ctx)`
directly, bypassing the wrapper. A user-shadowed `class ReadonlyArray
{}` would have a value symbol and falls through to the normal
class-handling path, so the shadow case is preserved.
```rust
if ident.name == "ReadonlyArray"
&& symbol_id.is_none_or(|sid| ctx.scoping().symbol_flags(sid).is_type())
{
return Self::global_array(ctx);
}
```
## Scope
Only `ReadonlyArray` is handled in this PR. `ReadonlyMap`,
`ReadonlySet`, `WeakMap`, `WeakSet`, and `Readonly<Array<T>>` peel are
left for a follow-up because tsc's spec emit for those is less
unambiguous and warrants case-by-case verification. The same
predicate-style approach extends naturally if reviewers want them folded
in.
## Test plan
- [x] 4 unit tests in `decorator_metadata_readonly_array.rs`: bare
ReadonlyArray, nested ReadonlyArray, regression on `readonly T[]` short
form, and a user-shadowed `class ReadonlyArray {}` regression
- [x] New conformance fixture `oxc/metadata/readonly-array/`
- [x] `cargo test -p oxc_transformer` passes (31 unit + 9 integration)
- [x] `cargo run -p oxc_transform_conformance` shows 229 OXC fixtures
still passing, +1 new fixture (`readonly-array`); 0 regressions
- [x] `cargo fmt -p oxc_transformer` and `cargo clippy -p
oxc_transformer --tests --no-deps` clean
AI assistance was used in writing this patch and tests; the contributor
has reviewed and tested locally.
---------
Co-authored-by: Dunqing <dengqing0821@gmail.com>
This commit is contained in:
1 parent
e421ef0f84
commit
69a6ba6353
8 files changed
+135
-2
No files matched your search
@@ -504,6 +504,12 @@ impl<'a> LegacyDecoratorMetadata<'a> {
|
||||
};
|
||||
}
|
||||
|
||||
// `ReadonlyArray<T>` is a TS-only utility type; tsc emits `Array`. Skip
|
||||
// when shadowed by any local declaration so the user's binding wins.
|
||||
if properties.is_empty() && root_ident.name == "ReadonlyArray" && symbol_id.is_none() {
|
||||
return Self::global_array(ctx);
|
||||
}
|
||||
|
||||
// Type-only references have no runtime binding either.
|
||||
if Self::is_type_symbol(symbol_id, ctx) {
|
||||
return Self::global_object(ctx);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
use std::path::Path;
|
||||
|
||||
use oxc_allocator::Allocator;
|
||||
use oxc_codegen::{Codegen, CodegenOptions};
|
||||
use oxc_parser::Parser;
|
||||
use oxc_semantic::SemanticBuilder;
|
||||
use oxc_span::SourceType;
|
||||
use oxc_transformer::{DecoratorOptions, TransformOptions, Transformer};
|
||||
|
||||
fn transform_ts(source_text: &str) -> String {
|
||||
let allocator = Allocator::default();
|
||||
let source_type = SourceType::ts();
|
||||
let ret = Parser::new(&allocator, source_text, source_type).parse();
|
||||
assert!(ret.errors.is_empty(), "parse errors: {:?}", ret.errors);
|
||||
let mut program = ret.program;
|
||||
let scoping = SemanticBuilder::new().build(&program).semantic.into_scoping();
|
||||
let options = TransformOptions {
|
||||
decorator: DecoratorOptions { legacy: true, emit_decorator_metadata: true },
|
||||
..TransformOptions::default()
|
||||
};
|
||||
let ret = Transformer::new(&allocator, Path::new(""), &options)
|
||||
.build_with_scoping(scoping, &mut program);
|
||||
assert!(ret.errors.is_empty(), "transform errors: {:?}", ret.errors);
|
||||
Codegen::new()
|
||||
.with_options(CodegenOptions { single_quote: true, ..CodegenOptions::default() })
|
||||
.build(&program)
|
||||
.code
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_array_emits_array() {
|
||||
let output = transform_ts(
|
||||
r"
|
||||
function D(): PropertyDecorator { return () => {}; }
|
||||
class Source { @D() value!: ReadonlyArray<string>; }
|
||||
",
|
||||
);
|
||||
assert!(output.contains("decorateMetadata('design:type', Array)"), "got:\n{output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_readonly_array_emits_array() {
|
||||
let output = transform_ts(
|
||||
r"
|
||||
function D(): PropertyDecorator { return () => {}; }
|
||||
class Source { @D() value!: ReadonlyArray<ReadonlyArray<number>>; }
|
||||
",
|
||||
);
|
||||
assert!(output.contains("decorateMetadata('design:type', Array)"), "got:\n{output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_short_form_still_emits_array() {
|
||||
let output = transform_ts(
|
||||
r"
|
||||
function D(): PropertyDecorator { return () => {}; }
|
||||
class Source { @D() value!: readonly string[]; }
|
||||
",
|
||||
);
|
||||
assert!(output.contains("decorateMetadata('design:type', Array)"), "got:\n{output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_shadowed_readonly_array_class_takes_precedence() {
|
||||
let output = transform_ts(
|
||||
r"
|
||||
class ReadonlyArray<T> { value!: T; }
|
||||
function D(): PropertyDecorator { return () => {}; }
|
||||
class Source { @D() value!: ReadonlyArray<string>; }
|
||||
",
|
||||
);
|
||||
assert!(
|
||||
!output.contains("decorateMetadata('design:type', Array)"),
|
||||
"user-shadowed ReadonlyArray must not be miscompiled to Array. got:\n{output}"
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod decorator_metadata;
|
||||
mod enum_eval;
|
||||
mod es_target;
|
||||
mod helper_call;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
commit: 3d34928e
|
||||
|
||||
Passed: 237/392
|
||||
Passed: 237/394
|
||||
|
||||
# All Passed:
|
||||
* babel-plugin-transform-class-static-block
|
||||
@@ -663,7 +663,7 @@ x Output mismatch
|
||||
x Output mismatch
|
||||
|
||||
|
||||
# legacy-decorators (9/102)
|
||||
# legacy-decorators (9/104)
|
||||
* oxc/accessor/input.ts
|
||||
x Output mismatch
|
||||
|
||||
@@ -1121,6 +1121,31 @@ Unresolved reference IDs mismatch for "babelHelpers":
|
||||
after transform: [ReferenceId(7), ReferenceId(8), ReferenceId(9), ReferenceId(11), ReferenceId(13), ReferenceId(17), ReferenceId(18), ReferenceId(19), ReferenceId(20), ReferenceId(22), ReferenceId(24)]
|
||||
rebuilt : [ReferenceId(0), ReferenceId(3), ReferenceId(5), ReferenceId(6), ReferenceId(8), ReferenceId(9), ReferenceId(12), ReferenceId(14), ReferenceId(16)]
|
||||
|
||||
* oxc/metadata/readonly-array/input.ts
|
||||
Bindings mismatch:
|
||||
after transform: ScopeId(0): ["Source", "dec"]
|
||||
rebuilt : ScopeId(0): ["Source"]
|
||||
Reference symbol mismatch for "dec":
|
||||
after transform: SymbolId(0) "dec"
|
||||
rebuilt : <None>
|
||||
Reference symbol mismatch for "dec":
|
||||
after transform: SymbolId(0) "dec"
|
||||
rebuilt : <None>
|
||||
Unresolved references mismatch:
|
||||
after transform: ["Array", "ReadonlyArray", "babelHelpers"]
|
||||
rebuilt : ["Array", "babelHelpers", "dec"]
|
||||
|
||||
* oxc/metadata/readonly-array-interface-shadow/input.ts
|
||||
Bindings mismatch:
|
||||
after transform: ScopeId(0): ["Source", "dec"]
|
||||
rebuilt : ScopeId(0): ["Source"]
|
||||
Reference symbol mismatch for "dec":
|
||||
after transform: SymbolId(2) "dec"
|
||||
rebuilt : <None>
|
||||
Unresolved references mismatch:
|
||||
after transform: ["Object", "babelHelpers"]
|
||||
rebuilt : ["Object", "babelHelpers", "dec"]
|
||||
|
||||
* oxc/metadata/static-anonymous-class-expression/input.ts
|
||||
Bindings mismatch:
|
||||
after transform: ScopeId(0): ["A", "Foo", "dec"]
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// Local interface shadows the global `ReadonlyArray` — expect `Object`, not `Array`.
|
||||
|
||||
interface ReadonlyArray<T> { custom: T }
|
||||
|
||||
declare function dec(target: any, key: string): void;
|
||||
|
||||
class Source {
|
||||
@dec value!: ReadonlyArray<string>;
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
class Source {
|
||||
value;
|
||||
}
|
||||
babelHelpers.decorate([dec, babelHelpers.decorateMetadata("design:type", Object)], Source.prototype, "value", void 0);
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
declare function dec(target: any, key: string): void;
|
||||
|
||||
class Source {
|
||||
@dec items!: ReadonlyArray<string>;
|
||||
@dec nested!: ReadonlyArray<ReadonlyArray<number>>;
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
class Source {
|
||||
items;
|
||||
nested;
|
||||
}
|
||||
babelHelpers.decorate([dec, babelHelpers.decorateMetadata("design:type", Array)], Source.prototype, "items", void 0);
|
||||
babelHelpers.decorate([dec, babelHelpers.decorateMetadata("design:type", Array)], Source.prototype, "nested", void 0);
|
||||
Reference in new issue
Block a user