Skip to content

Preserve native destructuring for CommonJS exports to keep iterator semantics#3705

Open
Copilot wants to merge 8 commits into
mainfrom
copilot/fix-array-destructuring-transpilation
Open

Preserve native destructuring for CommonJS exports to keep iterator semantics#3705
Copilot wants to merge 8 commits into
mainfrom
copilot/fix-array-destructuring-transpilation

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented May 4, 2026

  • Visit v.Name() once and reuse the visited node for both the preserved declaration and the generated exports.X = X assignments, so any visitor rewrites stay consistent.
  • Add EFNoSourceMap to the synthetic propertyAccess so it actually matches the stated intent of suppressing comments and source-map ranges on the synthetic export reference.
  • Extend exportDestructuringIterator.ts with: an array pattern with omitted elements ([, e, , f]), a nested array-of-array/object pattern ([[g, h], { i, j: k }]), and a nested object-with-nested-pattern ({ m: [n, o], p: { q } }) to cover the recursion and omitted-element paths of appendExportAssignmentsForBindingPattern.
  • Address review feedback: rename name parameter to pattern in appendExportAssignmentsForBindingPattern
  • Address review feedback: fix misleading comment about alias exports
  • Add new .diff files to submoduleAccepted.txt with link to When using module=commonjs, Array destructuring is transpiled with wrong (non-iterator) behavior #3701
  • Simplify: use original variable declaration directly via pushVariable(variable) instead of reconstructing with NewVariableDeclaration
  • Build, full test suite, lint, format all clean.

Copilot AI changed the title [WIP] Fix array destructuring transpilation with module=commonjs Preserve native destructuring for CommonJS exports to keep iterator semantics May 4, 2026
Copilot AI requested a review from jakebailey May 4, 2026 22:04
@jakebailey
Copy link
Copy Markdown
Member

This does what it says on the tin, but, I'm not sure what affect this has on cjs-module-lexer's scanning.

@blickly Is this the change you were hoping to get?

@blickly
Copy link
Copy Markdown

blickly commented May 5, 2026

Honestly, I was expecting something more like:

const [A, V] = foo();
exports.A = A;
exports.V = V;

but this version is technically not wrong, and is definitely and improvement over the old behavior.

@jakebailey
Copy link
Copy Markdown
Member

Yeah. I think what you've suggested would be better just because it avoids the lexer problem.

@copilot please try

Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 5, 2026

Yeah. I think what you've suggested would be better just because it avoids the lexer problem.

@copilot please try

Reworked in 4a6c647 to emit the lexer-friendly form. For export const [A, V] = foo() the output is now:

const [A, V] = foo();
exports.A = A, exports.V = V;

The variable declaration is preserved as-is (so array destructuring keeps native iterator semantics), and exports.X = X is emitted for each declared identifier — so cjs-module-lexer can pick them up.

@jakebailey jakebailey marked this pull request as ready for review May 11, 2026 21:18
Copilot AI review requested due to automatic review settings May 11, 2026 21:18
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Updates CommonJS module emit to preserve native destructuring for exported binding patterns (especially arrays) so iterator semantics aren’t lost, while still emitting explicit exports.X = X assignments for tooling compatibility.

Changes:

  • Preserve exported binding-pattern variable declarations (instead of flattening to index/property access) and emit per-identifier exports.X = X assignments.
  • Add a new helper to walk binding patterns (nesting/rest/defaults/omissions) and generate the export assignments with suppressed synthetic comment/source-map metadata.
  • Add a regression test and update multiple compiler baselines to reflect the new emit shape.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
internal/transformers/moduletransforms/commonjsmodule.go Preserves exported destructuring declarations and adds helper to emit exports.X = X for each bound identifier.
testdata/tests/cases/compiler/exportDestructuringIterator.ts New test case covering exported destructuring patterns under module=commonjs.
testdata/baselines/reference/compiler/exportDestructuringIterator.js New baseline verifying native destructuring + explicit export assignments.
testdata/baselines/reference/compiler/exportDestructuringIterator.types New type baseline for the added test.
testdata/baselines/reference/compiler/exportDestructuringIterator.symbols New symbol baseline for the added test.
testdata/baselines/reference/compiler/exportDestructuring.js Baseline update reflecting the new emit for exported array destructuring.
testdata/baselines/reference/submodule/compiler/exportObjectRest(module=commonjs,target=esnext).js Baseline update to preserve native object rest destructuring + explicit export assignments.
testdata/baselines/reference/submodule/compiler/exportObjectRest(module=commonjs,target=esnext).js.diff Diff baseline capturing the change in output shape.
testdata/baselines/reference/submodule/compiler/exportEmptyObjectBindingPattern(module=commonjs,target=esnext).js Baseline update to emit native empty object destructuring.
testdata/baselines/reference/submodule/compiler/exportEmptyObjectBindingPattern(module=commonjs,target=esnext).js.diff Diff baseline capturing the change in output shape.
testdata/baselines/reference/submodule/compiler/exportEmptyArrayBindingPattern(module=commonjs,target=esnext).js Baseline update to emit native empty array destructuring.
testdata/baselines/reference/submodule/compiler/exportEmptyArrayBindingPattern(module=commonjs,target=esnext).js.diff Diff baseline capturing the change in output shape.
testdata/baselines/reference/submodule/compiler/downlevelLetConst13(target=es2015).js Baseline update for exported destructuring emit now producing locals + export assignments.
testdata/baselines/reference/submodule/compiler/downlevelLetConst13(target=es2015).js.diff Diff baseline capturing the change in output shape.
testdata/baselines/reference/submodule/compiler/destructuringInVariableDeclarations1.js Baseline update for exported destructuring emit now producing locals + export assignment.
testdata/baselines/reference/submodule/compiler/destructuringInVariableDeclarations1.js.diff Diff baseline capturing the change in output shape.
testdata/baselines/reference/submodule/compiler/declarationEmitRetainsJsdocyComments.js Baseline update reflecting preserved binding-pattern declaration with JSDoc-like comment placement.
testdata/baselines/reference/submodule/compiler/declarationEmitRetainsJsdocyComments.js.diff Diff baseline capturing the change in output shape.
testdata/baselines/reference/submodule/compiler/bindingPatternOmittedExpressionNesting.js Baseline update reflecting preserved native destructuring for a pattern consisting only of omissions/nesting.
testdata/baselines/reference/submodule/compiler/bindingPatternOmittedExpressionNesting.js.diff Diff baseline capturing the change in output shape.

Comment thread internal/transformers/moduletransforms/commonjsmodule.go Outdated
Comment thread internal/transformers/moduletransforms/commonjsmodule.go Outdated
Comment thread testdata/tests/cases/compiler/exportDestructuringIterator.ts
@jakebailey
Copy link
Copy Markdown
Member

@copilot please consider the review comments

…nd test coverage

Agent-Logs-Url: https://github.com/microsoft/typescript-go/sessions/d6e032e2-fc6a-4294-9dab-57025399232b

Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 11, 2026

@copilot please consider the review comments

Addressed all three review comments in de75861:

  • Visit v.Name() once and reuse the visited node for both the preserved declaration and the generated exports.X = X assignments.
  • Added EFNoSourceMap to the synthetic propertyAccess so it matches the comment's stated intent.
  • Expanded exportDestructuringIterator.ts with omitted-element ([, e, , f]), nested array-of-array/object ([[g, h], { i, j: k }]), and nested object-with-nested-pattern ({ m: [n, o], p: { q } }) cases.

@jakebailey
Copy link
Copy Markdown
Member

@copilot isn't it a bad idea to reuse the same node twice in the tree? are you sure you've done this correctly?

Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 11, 2026

@copilot isn't it a bad idea to reuse the same node twice in the tree? are you sure you've done this correctly?

Good catch to double-check — the binding pattern node itself is only placed at one spot in the emitted tree (as the Name of the new VariableDeclaration at commonjsmodule.go:1091-1096). appendExportAssignmentsForBindingPattern only walks the visited pattern to enumerate leaf identifiers and then clones each one via bindingName.Clone(tx.Factory()) before putting it into the synthesized exports.X = X assignment (commonjsmodule.go:1166 and 1175). So the cloned identifiers — not the original — are what end up in the export assignments. This mirrors the existing pattern in the same file for direct exports (commonjsmodule.go:1525-1534 and 1547-1557), which also name.Clone(...) before reusing as the property-access name.

The reason for sharing visitedName between the declaration and the walk is just so the helper enumerates the visited identifiers (in case the visitor renamed any), not so the visited node is inserted twice.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.

Comment on lines +1147 to +1152
func (tx *CommonJSModuleTransformer) appendExportAssignmentsForBindingPattern(name *ast.Node, pushExpression func(*ast.Expression)) {
for _, element := range name.Elements() {
if ast.IsOmittedExpression(element) {
continue
}
bindingName := element.Name()
Comment on lines +1144 to +1146
// local binding (`y`). Identifiers that have additional export specifiers (e.g. `export { y as z }`
// elsewhere) are left to `appendExportsOfVariableStatement`, which emits the alias via the export
// specifier map.
Comment on lines +81 to +86
let {
/**
* comment5
*/
exports.someMethod = null.someMethod;
someMethod } = null;
exports.someMethod = someMethod;
@jakebailey
Copy link
Copy Markdown
Member

@copilot Address feedback. Also, any new .diff files this PR introduces should be put into submoduleAccepted, with a link back to #3701

…submoduleAccepted

Agent-Logs-Url: https://github.com/microsoft/typescript-go/sessions/da1644b8-c27c-4ddf-b063-125d398754f7

Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 11, 2026

@copilot Address feedback. Also, any new .diff files this PR introduces should be put into submoduleAccepted, with a link back to #3701

Addressed in 81c74ea:

(The trailing-whitespace baseline comment from the reviewer is a pre-existing printer behavior, not something introduced by this PR.)

// rewrites on binding names stay consistent between the two.
visitedName := tx.Visitor().VisitNode(v.Name())

pushVariable(tx.Factory().NewVariableDeclaration(
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'm like 80% sure this should be Update instead of New - actually, hold on, nothing changes from the transform here (quite literally we're using the variable declaration verbatim now), so all this peeling apart and putting back together is extraneous. It should just be pushVariable(v) followed by a tx.appendExportAssignmentsForBindingPattern(v.Name(), pushExpression), no?

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.

Yeah, it could (the PR evolved so this I think is leftover).

@copilot please address :)

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.

Simplified in 6134812 — now just pushVariable(variable) followed by tx.appendExportAssignmentsForBindingPattern(v.Name(), pushExpression). The reconstruction with NewVariableDeclaration + VisitNode is removed.

@weswigham
Copy link
Copy Markdown
Member

I mentioned this online, but I'll include it here for posterity - transforming export const [A, B] = whatever to

const [A, B] = whatever
exports.A = A, exports.B = B

does look nicer, but it breaks export visibility timing compared to the current (array-assuming) emit. So if B is fetched by a getter, that getter should be able to see exports.A already set - but that's not the case with this emit. Here's a whole test case:

import * as self from "./test.js"
const obj = { [Symbol.iterator]: function* () { yield "ok"; yield self.A; } }

export const [A, B] = obj

both A and B have value "ok" at runtime.

I don't know immediately how to transform it both to respect the iterator and the export timing without being very verbose with converting the array destructuring to an iterator you manually next.

@blickly
Copy link
Copy Markdown

blickly commented May 15, 2026

If the goal is to follow both the iterator protocol and export ordering, the original [exports.A, exports.B] = foo() that Copilot started with actually works fine.

That said, I question if we need to hold to that constraint here.

No argument from me that the suggested emit isn't fully ES spec-compliant, but we don't need to fix that here. Getting TSGo to parity with TS6 is the goal, and TS6 already has the exact same timing caveats when run with downlevelIteration. (Compare with this Playground link).

Also, I'm not sure I followed exactly what you meant by "compared to the current (array-assuming) emit." The array-assuming version seems even more broken, since it doesn't support iterators at all, much less iterators that depend on interleaving iteration with export visibility.

@jakebailey
Copy link
Copy Markdown
Member

I think another option is to do:

[exports.A, exports.B] = foo()
0 && exports.A = void 0;
0 && exports.B = void 0;

Or something, which is somewhat similar to what esbuild does to "declare" exports: https://jakebailey.dev/esbuild-playground/#v3=PTAEAEDMEsBsFMB2BDAtvAXKAxge0TAOYB0AVgM74BQA3laKAERIAuATgJ4AKu0iL5RlgDajYHwAm8AB7EBjALoAaek0i42qZCyFNsFRioaMADrG3rNuxolxTGVAL5UqICDAQp0WSTLnkqGRMNFlBIAFdEbBZofFBYXEIACgBKUDoGPERKBGIE5MYAd1xcRhSnKiA

@jakebailey
Copy link
Copy Markdown
Member

Wait, we don't need any of this, we already emit a big exports.A = exports.B = void 0 at the top of the module, so they are all declared.

I think we can just do a simple destructuring like [exports.A, exports.B] = ..., that is, the original change, oops!

…tterns

Assign directly to exports.X in destructuring patterns instead of flattening
to array indexing. This preserves iterator semantics and correct export
visibility timing. The void 0 prologue already satisfies cjs-module-lexer,
so no additional hints are needed.
…tructuring-transpilation

# Conflicts:
#	testdata/submoduleAccepted.txt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

When using module=commonjs, Array destructuring is transpiled with wrong (non-iterator) behavior

5 participants