fix(codegen): preserve property key annotations (#25766)

`@__KEY__` and `#__KEY__` are currently treated as normal comments. Codegen can drop them before a later property-mangling pass. The property may then be renamed while the annotated string keeps its old name.

This classifies the marker as a property-key annotation. Codegen keeps it next to string and no-substitution template literals when annotation comments are enabled, and the property mangler reads that classification instead of scanning source text. Final output still follows the normal comment options, so whitespace-minified or annotation-disabled output removes the marker.

## Example

Input:

```js
const object = { _field: 1 };
const key = /* #__KEY__ */ "_field";
object[key];
```

Before, an intermediate codegen pass could remove the marker:

```js
const object = { _field: 1 };
const key = "_field";
object[key];
```

After, the marker survives for the later property-mangling pass.

Verified with the affected crate suites, `just minsize`, workspace lint, docs, AST generation, and independent review. `just ready` is only blocked locally by existing Node-version and terminal-color snapshot differences.

Implemented with AI assistance (OpenAI Codex). I reviewed and understand the changes and remain responsible for this contribution under the repository AI usage policy.
This commit is contained in:
Dunqing committed 2026-08-17 09:23:04 +00:00
1 parent 28c0493682
commit 8ab883afaa
11 files changed
+185 -12

No files matched your search

+11
View File
@@ -104,6 +104,11 @@ pub enum CommentContent {
/// Classified separately because its meaning remains valid if the next AST
/// node is removed, unlike position-sensitive coverage annotations.
CoverageIgnoreFile = 11,
/// Marks the following string or no-substitution template as a property name.
/// `/* @__KEY__ */` or `/* #__KEY__ */`
/// <https://esbuild.github.io/api/#mangle-key>
PropertyKey = 12,
}
bitflags! {
@@ -277,6 +282,12 @@ impl Comment {
self.content == CommentContent::NoSideEffects
}
/// Is a leading `/* @__KEY__ */` or `/* #__KEY__ */` annotation.
#[inline]
pub fn is_property_key_annotation(self) -> bool {
self.content == CommentContent::PropertyKey && self.is_leading()
}
/// Is webpack magic comment.
#[inline]
pub fn is_webpack(self) -> bool {
+14
View File
@@ -101,6 +101,7 @@ impl Codegen<'_> {
}
}
if add {
self.has_property_key_annotations |= comment.is_property_key_annotation();
if preserve_when_orphaned(*comment)
&& let Err(idx) = self.orphan_comment_keys.binary_search(&comment.attached_to)
{
@@ -195,6 +196,19 @@ impl Codegen<'_> {
}
}
/// Print a property-key annotation attached directly to a string or template literal.
#[inline]
pub(crate) fn print_property_key_annotation(&mut self, start: u32) {
if !self.has_property_key_annotations {
return;
}
if self.comments.get(&start).is_some_and(|comments| {
comments.iter().any(|comment| comment.is_property_key_annotation())
}) {
self.print_leading_comments_anchored_to_self(start);
}
}
/// Print comments attached to an expression that survives codegen.
///
/// Probes the parenthesized layers too: `a || /* c */ (x)` anchors the
+3
View File
@@ -2342,6 +2342,9 @@ impl GenExpr for ImportExpression<'_> {
impl Gen for TemplateLiteral<'_> {
fn r#gen(&self, p: &mut Codegen, _ctx: Context) {
if self.is_no_substitution_template() {
p.print_property_key_annotation(self.span.start);
}
p.add_source_mapping(self.span);
p.print_ascii_byte(b'`');
debug_assert_eq!(self.quasis.len(), self.expressions.len() + 1);
+2
View File
@@ -127,6 +127,7 @@ pub struct Codegen<'a> {
// Builders
comments: CommentsMap,
has_property_key_annotations: bool,
/// Pure / no-side-effects annotation comments keyed by `attached_to`,
/// so the emission site can recover verbatim source text instead of a
@@ -195,6 +196,7 @@ impl<'a> Codegen<'a> {
indent: 0,
quote: Quote::Double,
comments: CommentsMap::default(),
has_property_key_annotations: false,
annotation_comments: FxHashMap::default(),
orphan_comment_keys: Vec::new(),
#[cfg(feature = "sourcemap")]
+1
View File
@@ -116,6 +116,7 @@ pub struct CommentOptions {
/// * pure: `/* #__PURE__ */` and `/* #__NO_SIDE_EFFECTS__ */`
/// * webpack: `/* webpackChunkName */`
/// * vite: `/* @vite-ignore */`
/// * property key: `/* @__KEY__ */` and `/* #__KEY__ */`
/// * coverage: `v8 ignore`, `c8 ignore`, `node:coverage`, `istanbul ignore`
///
/// Default is `true`.
+2
View File
@@ -29,6 +29,7 @@ impl Quote {
impl Codegen<'_> {
/// Print a [`StringLiteral`].
pub(crate) fn print_string_literal(&mut self, s: &StringLiteral<'_>, allow_backtick: bool) {
self.print_property_key_annotation(s.span.start);
self.add_source_mapping(s.span);
self.print_string_impl(s.value.as_str(), s.lone_surrogates, allow_backtick);
}
@@ -38,6 +39,7 @@ impl Codegen<'_> {
/// A template literal is never a directive, which a string literal in the first statement position would be.
/// See `print_directives_and_statements`.
pub(crate) fn print_string_literal_as_template(&mut self, s: &StringLiteral<'_>) {
self.print_property_key_annotation(s.span.start);
self.add_source_mapping(s.span);
Quote::Backtick.print(self);
self.print_string_body(s.value.as_str(), s.lone_surrogates, Some(Quote::Backtick), true);
@@ -71,6 +71,53 @@ fn test_comments_before_expression_operands_idempotency() {
test_idempotency("const value = { aFunction: /* istanbul ignore next */ () => {} };");
}
#[test]
fn test_property_key_annotations_before_literals() {
use oxc_codegen::{CodegenOptions, CommentOptions};
let annotation_only = CodegenOptions {
comments: CommentOptions { normal: false, ..CommentOptions::default() },
..CodegenOptions::default()
};
let source = r#"const key = /* @__KEY__ */ "_field";
const lineKey = // #__KEY__
"_field";
object[/* #__KEY__ */ `_field`];"#;
crate::tester::test_options(
source,
r#"const key = /* @__KEY__ */ "_field";
const lineKey = // #__KEY__
"_field";
object[/* #__KEY__ */ `_field`];
"#,
annotation_only.clone(),
);
crate::tester::test_options(
"use(/* @__KEY__ */ \"_field\");",
"use(\n\t/* @__KEY__ */\n\t\"_field\"\n);\n",
annotation_only.clone(),
);
let minify_with_annotations = CodegenOptions { minify: true, ..annotation_only.clone() };
crate::tester::test_options(
"(/* @__KEY__ */ \"_field\");",
"/* @__KEY__ */`_field`;",
minify_with_annotations.clone(),
);
test_idempotency_options(source, &annotation_only);
test_idempotency_options("(/* @__KEY__ */ \"_field\");", &minify_with_annotations);
crate::tester::test_options(
"const key = /* @__KEY__ */ \"_field\";",
"const key = \"_field\";\n",
CodegenOptions {
comments: CommentOptions { annotation: false, ..CommentOptions::default() },
..CodegenOptions::default()
},
);
}
// A mid-line comment group must not receive a full indent: `print_comments`
// used to inject `indent` tabs before any group whose first comment was not
// preceded by a newline, so indented emission sites (`key: /** c */ value`)
+35 -1
View File
@@ -8,6 +8,7 @@ pub const CONTENT_WEBPACK: u8 = 6;
pub const CONTENT_VITE: u8 = 7;
pub const CONTENT_COVERAGE_IGNORE: u8 = 8;
pub const CONTENT_COVERAGE_IGNORE_FILE: u8 = 9;
pub const CONTENT_PROPERTY_KEY: u8 = 10;
pub const META_MULTILINE: u8 = 0x10;
#[inline]
@@ -23,6 +24,7 @@ pub fn content_from_ordinal(o: u8) -> oxc_ast::ast::CommentContent {
CONTENT_VITE => CommentContent::Vite,
CONTENT_COVERAGE_IGNORE => CommentContent::CoverageIgnore,
CONTENT_COVERAGE_IGNORE_FILE => CommentContent::CoverageIgnoreFile,
CONTENT_PROPERTY_KEY => CommentContent::PropertyKey,
_ => CommentContent::None,
}
}
@@ -63,6 +65,11 @@ fn has_nl_cr(bytes: &[u8]) -> bool {
bytes.iter().any(|&b| b == b'\n' || b == b'\r')
}
fn is_property_key_annotation(bytes: &[u8]) -> bool {
std::str::from_utf8(bytes)
.is_ok_and(|source| matches!(source.trim().strip_prefix(['@', '#']), Some("__KEY__")))
}
#[inline]
fn classify(bytes: &[u8], is_block: bool, lic: bool) -> u8 {
if bytes.is_empty() {
@@ -87,6 +94,13 @@ fn classify(bytes: &[u8], is_block: bool, lic: bool) -> u8 {
return CONTENT_NONE;
}
let rest = &bytes[start..];
if (rest.starts_with(b"@__KEY__") || rest.starts_with(b"#__KEY__") || !rest[0].is_ascii())
&& is_property_key_annotation(bytes)
{
return CONTENT_PROPERTY_KEY;
}
match bytes[start] {
b'@' => {
start += 1;
@@ -176,7 +190,8 @@ pub fn meta_byte_exact(src: &[u8], start: u32, end: u32, is_block: bool) -> u8 {
#[cfg(test)]
mod tests {
use super::{
CONTENT_COVERAGE_IGNORE, CONTENT_COVERAGE_IGNORE_FILE, classify, content_from_ordinal,
CONTENT_COVERAGE_IGNORE, CONTENT_COVERAGE_IGNORE_FILE, CONTENT_NONE, CONTENT_PROPERTY_KEY,
classify, content_from_ordinal,
};
use oxc_ast::ast::CommentContent;
@@ -205,4 +220,23 @@ mod tests {
assert_eq!(classify(source, true, false), CONTENT_COVERAGE_IGNORE);
}
}
#[test]
fn property_key_annotation() {
for source in [
b"@__KEY__".as_slice(),
b" #__KEY__ ",
b"\t@__KEY__\n",
"\u{a0}@__KEY__\u{a0}".as_bytes(),
] {
assert_eq!(classify(source, true, false), CONTENT_PROPERTY_KEY);
assert_eq!(content_from_ordinal(CONTENT_PROPERTY_KEY), CommentContent::PropertyKey);
}
for source in
[b"__KEY__".as_slice(), b"@ __KEY__", b"@__key__", b"@__KEY___", b"@__KEY__ extra"]
{
assert_eq!(classify(source, true, false), CONTENT_NONE);
}
}
}
+1 -9
View File
@@ -161,19 +161,11 @@ fn is_canonical_numeric_string(name: &str) -> bool {
numeric_key_string(value) == name
}
fn is_key_annotation(comment: &Comment, source_text: &str) -> bool {
let text = comment.content_span().source_text(source_text).trim();
matches!(text.strip_prefix(['@', '#']), Some("__KEY__"))
}
fn key_annotated_spans(program: &Program<'_>) -> FxHashSet<u32> {
program
.comments
.iter()
.filter(|comment| {
comment.position == CommentPosition::Leading
&& is_key_annotation(comment, program.source_text)
})
.filter(|comment| comment.is_property_key_annotation())
.map(|comment| comment.attached_to)
.collect()
}
@@ -1,6 +1,6 @@
use oxc_allocator::Allocator;
use oxc_ast_visit::VisitMut;
use oxc_codegen::Codegen;
use oxc_codegen::{Codegen, CodegenOptions, CommentOptions};
use oxc_minifier::{
CompressOptions, MangleOptions, ManglePropertiesOptions, ManglePropertyCache, Minifier,
MinifierOptions, PropertyMangleCollection, PropertyMangler,
@@ -143,6 +143,31 @@ fn annotations_override_quoted_behavior() {
);
}
#[test]
fn property_key_annotations_survive_codegen_before_mangling() {
let source = "const object = { _field: 1 }; const key = /* #__KEY__ */ '_field'; object[key];";
let allocator = Allocator::default();
let parsed = Parser::new(&allocator, source, SourceType::mjs()).parse();
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
let rendered = Codegen::new()
.with_options(CodegenOptions {
comments: CommentOptions { normal: false, ..CommentOptions::default() },
..CodegenOptions::default()
})
.build(&parsed.program)
.code;
assert!(rendered.contains("/* #__KEY__ */"));
let (actual, _) = mangle_with(&rendered, SourceType::mjs(), options("^_"));
assert_eq!(
actual,
codegen(
"const object = { e: 1 }; const key = /* #__KEY__ */ 'e'; object[key];",
SourceType::mjs(),
)
);
}
#[test]
fn key_annotations_do_not_rewrite_directives() {
let source_type = SourceType::mjs();
@@ -154,7 +179,7 @@ fn key_annotations_do_not_rewrite_directives() {
}
#[test]
fn trailing_key_annotation_does_not_annotate_offset_zero() {
fn property_key_annotation_without_a_literal_does_not_annotate_offset_zero() {
let source = "'_x' in obj ? yes() : no();\nwork(); /* @__KEY__ */\ndone();";
test(source, source, options("^_"));
}
@@ -186,6 +186,7 @@ impl<'a> TriviaBuilder<'a> {
| CommentContent::Pure
| CommentContent::PureNotApplied
| CommentContent::NoSideEffects
| CommentContent::PropertyKey
)
}
@@ -276,6 +277,14 @@ impl<'a> TriviaBuilder<'a> {
return;
}
let rest = &bytes[start..];
if (rest.starts_with(b"@__KEY__") || rest.starts_with(b"#__KEY__") || !rest[0].is_ascii())
&& is_property_key_annotation(s)
{
comment.content = CommentContent::PropertyKey;
return;
}
// Fast path: check first non-whitespace byte
match bytes[start] {
b'@' => {
@@ -369,6 +378,11 @@ impl<'a> TriviaBuilder<'a> {
}
}
#[inline]
fn is_property_key_annotation(source: &str) -> bool {
matches!(source.trim().strip_prefix(['@', '#']), Some("__KEY__"))
}
#[inline(always)]
fn contains_license_or_preserve_comment(s: &str) -> bool {
const LICENSE_LEN: usize = b"@license".len();
@@ -634,6 +648,23 @@ function bar() {}";
assert!(comments[0].is_pure());
}
#[test]
fn property_key_comment_after_code_is_attached_to_next_literal() {
for (source_text, literal) in [
("work();/* #__KEY__ */\n\"_field\";", "\"_field\""),
("work();// @__KEY__\n`_field`;", "`_field`"),
] {
let comments = get_comments(source_text);
let literal_start = u32::try_from(source_text.find(literal).unwrap()).unwrap();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].position, CommentPosition::Leading);
assert_eq!(comments[0].attached_to, literal_start);
assert!(comments[0].is_property_key_annotation());
assert!(comments[0].is_annotation());
}
}
#[test]
fn leading_comments_after_eq() {
let source_text = "
@@ -816,6 +847,17 @@ function bar() {}";
("/* @__NO_SIDE_EFFECTS__ */", CommentContent::NoSideEffects),
("/* #__PURE__ */", CommentContent::Pure),
("/* #__NO_SIDE_EFFECTS__ */", CommentContent::NoSideEffects),
("/* @__KEY__ */", CommentContent::PropertyKey),
("/* #__KEY__ */", CommentContent::PropertyKey),
("/*\u{a0}@__KEY__\u{a0}*/", CommentContent::PropertyKey),
("//@__KEY__", CommentContent::PropertyKey),
("// #__KEY__", CommentContent::PropertyKey),
("/**\n * @__KEY__\n */", CommentContent::Jsdoc),
("/* __KEY__ */", CommentContent::None),
("/* @ __KEY__ */", CommentContent::None),
("/* @__key__ */", CommentContent::None),
("/* @__KEY___ */", CommentContent::None),
("/* @__KEY__ extra */", CommentContent::None),
("/* turbopackOptional: true */", CommentContent::Turbopack),
("/* v8 ignore next */", CommentContent::CoverageIgnore),
("/* v8 ignore filename */", CommentContent::CoverageIgnore),