Skip to content

Copy synthetic comments in SetOriginal and emit them once in TS trasforms - #64736

Open
Trevor Florence (trevorade) wants to merge 4 commits into
microsoft:mainfrom
trevorade:port-synthetic-comment-propagation
Open

Trevor Florence (trevorade) wants to merge 4 commits into
microsoft:mainfrom
trevorade:port-synthetic-comment-propagation

Conversation

@trevorade

@trevorade Trevor Florence (trevorade) commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #54742 for tsgo.

In the TypeScript 6 compiler, setOriginalNode merges the original node's emit node into the new node (mergeEmitNode), including its synthetic leading and trailing comments. In tsgo, emitNode.copyFrom copies comment and source map ranges, emit flags, and helpers, but not synthetic comments. So a synthetic comment attached to a declaration is dropped as soon as a transform replaces that declaration. For example, with a synthetic /*leading*/ comment on enum E { A }, the TS transforms emit var E; (function (E) { ... })(E || (E = {})); with no comment at all.

This PR:

  • copies synthetic comments in copyFrom, prepending them to any the destination already has, as mergeEmitNode does;
  • for an enum or namespace that gets a leading var, clears them on the VariableDeclaration (this was the // !!! synthetic comments TODO) and on the (function (E) { ... })(...) statement, as Strada does, so they are emitted once, on the var statement;
  • clears them on the class expression, its variable declaration, and the __decorate statement in the legacy decorators transform, so they are emitted once, on the let statement.

The last item goes beyond a port. Strada calls setOriginalNode on all of those nodes without clearing anything, and TypeScript 6.0.3 prints a class's synthetic comment four times under experimentalDecorators:

/*synthetic*/ let /*synthetic*/ C = /*synthetic*/ class C {
};
/*synthetic*/ C = __decorate([
    dec
], C);

That is #54742 (Bug, Help Wanted, Backlog). Its fix, #54750, was closed unmerged in the post-6.0 freeze. Copying synthetic comments without also clearing them here would carry that bug into tsgo.

Relationship to #64453 / #64488: tsgo's printer currently treats EFNoLeadingComments / EFNoTrailingComments as also suppressing synthetic comments, which Strada does not, and #64488 removes that. This PR does not depend on either order. Because it ports Strada's explicit clears rather than relying on those flags, nothing is emitted twice before or after #64488. The one visible effect of #64453 here is that a trailing synthetic comment on an enum or namespace is currently not emitted, because the var statement has EFNoTrailingComments. After #64488 it is emitted after var E;, as in TypeScript 6. The test comments on this.

The new test TestSyntheticCommentsAreEmittedOnce covers an enum, a namespace, and a decorated class, with a leading and a trailing synthetic comment on each. Without this change, none of the three emits any comment.

Verification: go test ./... passes except internal/astnav's TestGetTouchingPropertyName, which shells out to Node and needs node_modules/typescript (it fails the same way on main without npm ci). golangci-lint with the repo's custom plugin reports 0 issues on the changed packages, and gofumpt with the repo's dprint settings reports no changes. npx hereby test, lint, and check:format were not run directly.

AI assistance: I used Jetski, Google's internal AI coding agent, to investigate this and to write the patch, the test, and this description. I reviewed the change and will handle review feedback and revisions directly.

…forms

In the TypeScript 6 compiler, `setOriginalNode` merges the original node's emit node into the new node (`mergeEmitNode`), including its synthetic leading and trailing comments. In tsgo, `emitNode.copyFrom` copies comment and source map ranges, emit flags, and helpers, but not synthetic comments. So a synthetic comment attached to a declaration is dropped as soon as a transform replaces that declaration. For example, with a synthetic `/*leading*/` comment on `enum E { A }`, the TS transforms emit `var E; (function (E) { ... })(E || (E = {}));` with no comment at all.

This PR:

- copies synthetic comments in `copyFrom`, prepending them to any the destination already has, as `mergeEmitNode` does;
- clears them on the `VariableDeclaration` that `addVarForDeclaration` creates for an enum or namespace, as Strada does (this was the `// !!! synthetic comments` TODO), so they are emitted once, on the `var` statement;
- clears them on the class expression, its variable declaration, and the `__decorate` statement in the legacy decorators transform, so they are emitted once, on the `let` statement.

The last item goes beyond a port. Strada calls `setOriginalNode` on all of those nodes without clearing anything, and TypeScript 6.0.3 prints a class's synthetic comment four times under `experimentalDecorators`:

```js
/*synthetic*/ let /*synthetic*/ C = /*synthetic*/ class C {
};
/*synthetic*/ C = __decorate([
    dec
], C);
```

That is microsoft#54742 (labeled Bug and Help Wanted). Its fix, microsoft#54750, was closed unmerged in the post-6.0 freeze. Copying synthetic comments without also clearing them here would carry that bug into tsgo.

One difference from Strada remains: a trailing synthetic comment on an enum or namespace is emitted after the closing `})(E || (E = {}));`, not after `var E;`. tsgo's printer treats `EFNoTrailingComments` on the `var` statement as also suppressing synthetic trailing comments. The new position matches where source trailing comments for these declarations already go.

The new test `TestSyntheticCommentsAreEmittedOnce` covers an enum, a namespace, and a decorated class, with a leading and a trailing synthetic comment on each. Without this change, all three cases emit no comment.
…is added

Strada clears them on the enum and namespace statements when it emits a
leading `var` (ts.ts, `visitEnumDeclaration` and `visitModuleDeclaration`),
so the comments are emitted once, on the `var` statement. tsgo relied on
`EFNoLeadingComments` suppressing synthesized comments instead, which microsoft#64453
reports as a divergence from Strada and microsoft#64488 would remove.
Copilot AI balanced review requested due to automatic review settings October 11, 2026 20:47
@typescript-automation typescript-automation Bot added the For Backlog Bug PRs that fix a backlog bug label Oct 11, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The class-fields static-block transform can duplicate synthetic comments after #64488.

1 open finding
What changed in this PR

Brings synthetic-comment handling in tsgo transforms closer to the TypeScript compiler while preventing duplicate emission.

Changes:

  • Copies synthetic comments during SetOriginal.
  • Clears duplicate comments from enum, namespace, and legacy-decorator outputs.
  • Adds regression coverage for single emission.
File Description
syntheticcomments_test.go Tests enum, namespace, and decorated-class emission.
runtimesyntax.go Prevents duplicate comments in runtime syntax transforms.
legacydecorators.go Prevents decorated-class comment duplication.
emitcontext.go Copies and merges synthetic comments.

🧠 Review effort: Balanced


💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread tsc/internal/printer/emitcontext.go
When a static property initializer becomes `static { this.x = ...; }`,
the property's comments are emitted on the static block. Strada clears
them on the initializer statement (classFields.ts,
`transformPropertyOrClassStaticBlock` caller in `visitPropertyDeclaration`).
tsgo only set `EFNoComments`, which stops suppressing synthetic comments
once microsoft#64488 lands, so the comments would be printed twice.
@@ -0,0 +1,103 @@
package tstransforms_test

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I find this file a little weird. We typically don't do unit tests for these, and this duplicates a helper string, and tests multiple packages ad-hoc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair points. I looked at doing this as a compiler baseline test instead, but I don't think one can see it: synthetic comments only reach these transforms when an API user adds them (that's how we hit it, via a custom transformer). In a normal emit, the only synthetic comments in JS output come from the const enum inliner, which runs after these transforms, so a baseline passes with or without the change.

I trimmed the test down instead in 2c4d14a. It now just checks that each comment is emitted once rather than matching the whole output, so the duplicated __decorate helper is gone. It's also split by package: the SetOriginal copy is tested next to copyFrom in printer, the enum/namespace/decorator cases stay in tstransforms, and the static field case moved to estransforms.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I guess we have to do something annoying here because this is all Go API related. Just not mega satisfied with these ad hoc tests. Perhaps it's fine.

…ents

Split the synthetic comment tests by package instead of testing several
packages from tstransforms:

- printer: SetOriginal copying synthetic comments is tested next to the
  other emit context tests.
- tstransforms: the enum, namespace and legacy decorator cases.
- estransforms: the static property moved into a static block.

The transform tests now count how often each comment is emitted instead
of matching the whole output, which removes the duplicated __decorate
helper string. emittestutil gains an Emit helper that returns the
printed output so tests can make such assertions.

A compiler baseline cannot observe this change: in a normal emit, the
only synthetic comments in JS output come from the const enum inliner,
which runs after these transforms.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The compiler-wide metadata propagation change interacts with several transformation and printer paths and warrants final human review.

0 open findings

1 resolved since last review

🧠 Review effort: Balanced

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

For Backlog Bug PRs that fix a backlog bug

Projects

Status: Not started

Development

Successfully merging this pull request may close these issues.

Synthetic comment duplicated 3 times on class with experimental decorators

3 participants