fix(codegen): correct sourcemap end mappings for closing delimiters#22001
Conversation
Merging this PR will not alter performance
Comparing Footnotes
|
AriPerkkio
left a comment
There was a problem hiding this comment.
These two downstream fixes are likely related:
Remix building their own coverage pipeline will run into all the issues other test runners have fixed earlier.
52fd07c to
18ef494
Compare
Dunqing
left a comment
There was a problem hiding this comment.
Thank you! The fix looks good! However, I found a regression in the call chain; you can view it in Sourcemap Viewer.
As you can see, the red arrow points to the first call's right parenthesis, which points to the second call's left parenthesis.
I will send a fix in a follow-up PR.
Move `add_source_mapping_end(span)` to before the closing
`print_ascii_byte` in four places: `print_block_end`,
`print_curly_braces`, and the `]`/`}` of `ArrayExpression` /
`ObjectExpression`. The end mapping now lands at the generated position
OF the close char, matching the convention used by esbuild and Babel.
Same fix shape as the call-paren change — extends it to the remaining
close-delimiter sites. Previously the mapping landed one column past
the close char, so a closing `}` immediately followed by `;` (the
common arrow-function case `() => { ... };`) had its source `}` map to
the generated `;` instead of the generated `}`.
After this change, the source `}` of `const fn = () => { return 1 }`
maps to the generated `}` (line 2 col 0), not the `;` that follows it.
The `synthesized_block_closing_braces_are_mapped` test from #22001 is
updated to assert the new gen position (col 0 instead of col 1) — same
intent (the synthesized `}` resolves back to the wrapped if's last
char), now anchored at the `}` itself.
Move `add_source_mapping_end(span)` to before the closing
`print_ascii_byte` in four places: `print_block_end`,
`print_curly_braces`, and the `]`/`}` of `ArrayExpression` /
`ObjectExpression`. The end mapping now lands at the generated position
OF the close char, matching the convention used by esbuild and Babel.
Same fix shape as the call-paren change — extends it to the remaining
close-delimiter sites. Previously the mapping landed one column past
the close char, so a closing `}` immediately followed by `;` (the
common arrow-function case `() => { ... };`) had its source `}` map to
the generated `;` instead of the generated `}`.
After this change, the source `}` of `const fn = () => { return 1 }`
maps to the generated `}` (line 2 col 0), not the `;` that follows it.
The `synthesized_block_closing_braces_are_mapped` test from #22001 is
updated to assert the new gen position (col 0 instead of col 1) — same
intent (the synthesized `}` resolves back to the wrapped if's last
char), now anchored at the `}` itself.
Move `add_source_mapping_end(span)` to before the closing
`print_ascii_byte` in four places: `print_block_end`,
`print_curly_braces`, and the `]`/`}` of `ArrayExpression` /
`ObjectExpression`. The end mapping now lands at the generated position
OF the close char, matching the convention used by esbuild and Babel.
Same fix shape as the call-paren change — extends it to the remaining
close-delimiter sites. Previously the mapping landed one column past
the close char, so a closing `}` immediately followed by `;` (the
common arrow-function case `() => { ... };`) had its source `}` map to
the generated `;` instead of the generated `}`.
After this change, the source `}` of `const fn = () => { return 1 }`
maps to the generated `}` (line 2 col 0), not the `;` that follows it.
The `synthesized_block_closing_braces_are_mapped` test from #22001 is
updated to assert the new gen position (col 0 instead of col 1) — same
intent (the synthesized `}` resolves back to the wrapped if's last
char), now anchored at the `}` itself.
) Follow-up on #22001. Move `add_source_mapping_end(span)` to before `print_ascii_byte(b')')` in `print_arguments` so the call's end mapping lands at the generated position **of** `)` rather than one past it. Each generated paren now has at most one mapping at its exact position, matching the convention used by esbuild and Babel. A second follow-up (#22200) extends the same fix to block-end `}`, curly-braces `}`, and the `]`/`}` of `ArrayExpression` / `ObjectExpression`. ## Why After #22001, `add_source_mapping_end` correctly points the source side at `span.end - 1` (the closing source char), but the generated position was still placed AFTER `)` was printed. For chained calls like `factory()()`, this collided with the next AST node's start position: - inner call's end mapping at `gen 7:9 → src 7:8` (just past inner `)`) - the outer call's `(` lives at `gen 7:9` in the generated code Result: a V8 stack-trace lookup at `gen 7:9` (the outer `(`) returned `src 7:8` (the inner `)`) — one column off. ## How Emit the end mapping at the gen position **of** `)`, not after it. Now: - `gen 7:8 → src 7:8` (inner `)`) - `gen 7:9` has no direct mapping → falls back to inner end → `src 7:8` - `gen 7:10 → src 7:10` (outer `)`) The chained-call shadow at the outer `(` becomes a plain "no mapping here, fall back to inner `)`" — same accepted behavior as esbuild, Babel, and TypeScript. None of them special-case chained calls. ## Visual comparisons Generated against a fixture covering classes, arrow bodies, arrays, objects, and chained calls in the [source-map viewer](https://source-map-viewer.void.app): - **Pre-PR vs Full fix** (full picture): https://source-map-viewer.void.app/compare?a=j9GHxMTX&b=UcCYKDih - **PR-only vs Full fix** (just the changes #22199 + #22200 add): https://source-map-viewer.void.app/compare?a=ngs4XAsh&b=UcCYKDih - **esbuild vs Full fix** (alignment with esbuild): https://source-map-viewer.void.app/compare?a=26JVF9DI&b=UcCYKDih ## Reference esbuild (`internal/js_printer/js_printer.go`): ```go if e.CloseParenLoc.Start > expr.Loc.Start { p.addSourceMapping(e.CloseParenLoc) } p.print(")") ``` Babel (`packages/babel-generator/src/printer.ts::rightParens`): ```ts this.sourceWithOffset("end", node.loc, -1); this.token(")"); ``` ## Test plan - [x] New test `call_end_mapping_lands_at_close_paren` — pins both `)` mappings of `factory()()` to their close-paren positions - [x] `cargo test -p oxc_codegen` passes (99 tests) - [x] `stacktrace_is_correct` snapshot unchanged
) Follow-up on #22001. Move `add_source_mapping_end(span)` to before `print_ascii_byte(b')')` in `print_arguments` so the call's end mapping lands at the generated position **of** `)` rather than one past it. Each generated paren now has at most one mapping at its exact position, matching the convention used by esbuild and Babel. A second follow-up (#22200) extends the same fix to block-end `}`, curly-braces `}`, and the `]`/`}` of `ArrayExpression` / `ObjectExpression`. ## Why After #22001, `add_source_mapping_end` correctly points the source side at `span.end - 1` (the closing source char), but the generated position was still placed AFTER `)` was printed. For chained calls like `factory()()`, this collided with the next AST node's start position: - inner call's end mapping at `gen 7:9 → src 7:8` (just past inner `)`) - the outer call's `(` lives at `gen 7:9` in the generated code Result: a V8 stack-trace lookup at `gen 7:9` (the outer `(`) returned `src 7:8` (the inner `)`) — one column off. ## How Emit the end mapping at the gen position **of** `)`, not after it. Now: - `gen 7:8 → src 7:8` (inner `)`) - `gen 7:9` has no direct mapping → falls back to inner end → `src 7:8` - `gen 7:10 → src 7:10` (outer `)`) The chained-call shadow at the outer `(` becomes a plain "no mapping here, fall back to inner `)`" — same accepted behavior as esbuild, Babel, and TypeScript. None of them special-case chained calls. ## Visual comparisons Generated against a fixture covering classes, arrow bodies, arrays, objects, and chained calls in the [source-map viewer](https://source-map-viewer.void.app): - **Pre-PR vs Full fix** (full picture): https://source-map-viewer.void.app/compare?a=j9GHxMTX&b=UcCYKDih - **PR-only vs Full fix** (just the changes #22199 + #22200 add): https://source-map-viewer.void.app/compare?a=ngs4XAsh&b=UcCYKDih - **esbuild vs Full fix** (alignment with esbuild): https://source-map-viewer.void.app/compare?a=26JVF9DI&b=UcCYKDih ## Reference esbuild (`internal/js_printer/js_printer.go`): ```go if e.CloseParenLoc.Start > expr.Loc.Start { p.addSourceMapping(e.CloseParenLoc) } p.print(")") ``` Babel (`packages/babel-generator/src/printer.ts::rightParens`): ```ts this.sourceWithOffset("end", node.loc, -1); this.token(")"); ``` ## Test plan - [x] New test `call_end_mapping_lands_at_close_paren` — pins both `)` mappings of `factory()()` to their close-paren positions - [x] `cargo test -p oxc_codegen` passes (99 tests) - [x] `stacktrace_is_correct` snapshot unchanged
Move `add_source_mapping_end(span)` to before the closing
`print_ascii_byte` in four places: `print_block_end`,
`print_curly_braces`, and the `]`/`}` of `ArrayExpression` /
`ObjectExpression`. The end mapping now lands at the generated position
OF the close char, matching the convention used by esbuild and Babel.
Same fix shape as the call-paren change — extends it to the remaining
close-delimiter sites. Previously the mapping landed one column past
the close char, so a closing `}` immediately followed by `;` (the
common arrow-function case `() => { ... };`) had its source `}` map to
the generated `;` instead of the generated `}`.
After this change, the source `}` of `const fn = () => { return 1 }`
maps to the generated `}` (line 2 col 0), not the `;` that follows it.
The `synthesized_block_closing_braces_are_mapped` test from #22001 is
updated to assert the new gen position (col 0 instead of col 1) — same
intent (the synthesized `}` resolves back to the wrapped if's last
char), now anchored at the `}` itself.
Move `add_source_mapping_end(span)` to before the closing
`print_ascii_byte` in four places: `print_block_end`,
`print_curly_braces`, and the `]`/`}` of `ArrayExpression` /
`ObjectExpression`. The end mapping now lands at the generated position
OF the close char, matching the convention used by esbuild and Babel.
Same fix shape as the call-paren change — extends it to the remaining
close-delimiter sites. Previously the mapping landed one column past
the close char, so a closing `}` immediately followed by `;` (the
common arrow-function case `() => { ... };`) had its source `}` map to
the generated `;` instead of the generated `}`.
After this change, the source `}` of `const fn = () => { return 1 }`
maps to the generated `}` (line 2 col 0), not the `;` that follows it.
The `synthesized_block_closing_braces_are_mapped` test from #22001 is
updated to assert the new gen position (col 0 instead of col 1) — same
intent (the synthesized `}` resolves back to the wrapped if's last
char), now anchored at the `}` itself.
Move `add_source_mapping_end(span)` to before the closing
`print_ascii_byte` in four places: `print_block_end`,
`print_curly_braces`, and the `]`/`}` of `ArrayExpression` /
`ObjectExpression`. The end mapping now lands at the generated position
OF the close char, matching the convention used by esbuild and Babel.
Same fix shape as the call-paren change — extends it to the remaining
close-delimiter sites. Previously the mapping landed one column past
the close char, so a closing `}` immediately followed by `;` (the
common arrow-function case `() => { ... };`) had its source `}` map to
the generated `;` instead of the generated `}`.
After this change, the source `}` of `const fn = () => { return 1 }`
maps to the generated `}` (line 2 col 0), not the `;` that follows it.
The `synthesized_block_closing_braces_are_mapped` test from #22001 is
updated to assert the new gen position (col 0 instead of col 1) — same
intent (the synthesized `}` resolves back to the wrapped if's last
char), now anchored at the `}` itself.
Stacked on #22199. Extends the same fix shape from #22199 (move `add_source_mapping_end` before the closing `print_ascii_byte`) to the remaining close-delimiter sites: `print_block_end`, `print_curly_braces`, and the `]`/`}` of `ArrayExpression` / `ObjectExpression`. ## Why #22199 fixed the call's `)`. The same root cause exists for `}` of blocks/objects and `]` of arrays — the end mapping was placed AFTER the close char, putting it at the position of whatever follows. The most visible case: `() => { ... };` — the source `}` mapped to the generated `;` rather than the generated `}`, so source-map consumers viewing this diff saw the `}` "point at the semicolon." ## How Five sites total, four in this PR (one already in #22199): | Site | File | |---|---| | `print_block_end` (the `}` of `BlockStatement`) | `lib.rs` | | `print_curly_braces` (shared by class body, switch, TS enum/interface/typeliteral/module) | `lib.rs` | | `ArrayExpression` (`]`) | `gen.rs` | | `ObjectExpression` (`}`) | `gen.rs` | Each: swap `print_ascii_byte; add_source_mapping_end` → `add_source_mapping_end; print_ascii_byte`. The mapping now lands at the gen position OF the close char. ## Visual comparisons Fixture covering all 5 sites (classes, arrow bodies, arrays, objects, chained calls): - **Pre-PR vs Full fix** (full picture): https://source-map-viewer.void.app/compare?a=j9GHxMTX&b=UcCYKDih - **PR-only vs Full fix** (just the changes #22199 + #22200 add): https://source-map-viewer.void.app/compare?a=ngs4XAsh&b=UcCYKDih - **esbuild vs Full fix** (alignment with esbuild): https://source-map-viewer.void.app/compare?a=26JVF9DI&b=UcCYKDih ## Reference esbuild (`internal/js_printer/js_printer.go`): ```go if e.CloseBraceLoc.Start > expr.Loc.Start { p.addSourceMapping(e.CloseBraceLoc) } p.print("}") ``` (Same pattern as the call's `)` handling — see #22199.) ## Test plan Each of the 4 modified sites has direct test coverage: - [x] `block_end_mapping_lands_at_close_brace` — pins `}` of `() => { return 1 }` (exercises `print_block_end`) - [x] `class_body_close_brace_lands_at_close_brace` — pins `}` of `class C { a; }` (exercises `print_curly_braces` — shared by classes, switch, TS enum/interface/typeliteral/module) - [x] `array_close_bracket_lands_at_close_bracket` — pins `]` of `const a = [1, 2]` (exercises `ArrayExpression`) - [x] `object_close_brace_lands_at_close_brace` — pins `}` of `const o = { a: 1 }` (exercises `ObjectExpression`) - [x] `synthesized_block_closing_braces_are_mapped` (added by #22001) updated to assert `dst (2, 0)` instead of `(2, 1)` — same intent (the synthesized `}` resolves back to the wrapped if's last char), now anchored at the `}` itself - [x] `cargo test -p oxc_codegen` passes (11 sourcemap tests, 100 integration total) - [x] `stacktrace_is_correct` snapshot unchanged Each new test was verified to fail without the corresponding swap and pass with it.
### 🚀 Features - 66c9b01 transformer/typescript: Debug_assert that `enum_eval` ran in semantic (#22252) (Dunqing) - ffe6475 minifier: Fold `Array` constructor with safe spreads (#22215) (camc314) ### 🐛 Bug Fixes - d3d0b18 traverse: Handle `ChainElement::TSNonNullExpression` in `GatherNodeParts` (#22247) (leaysgur) - 4e880de transformer/object-rest-spread: Declare temp vars for computed keys (#22284) (camc314) - a7c3e22 semantic: Clear member write target for computed keys (#22302) (camc314) - 6a8852d codegen: Emit newline after legal-comment orphan flush (#22304) (Dunqing) - 5da9fda transformer/explicit-resource-management: Preserve class names (#22306) (Dunqing) - b5d970f transformer/explicit-resource-management: Preserve class names (#22290) (camc314) - bc54fd4 minifier: Keep function / class names if direct eval is present in the scope (#22241) (sapphi-red) - 7a810c0 minifier: Refresh direct eval flags after DCE (#21787) (Dunqing) - dd88726 transformer/legacy-decorator: Preserve accessor type annotation for emitDecoratorMetadata (#21966) (Dunqing) - 29a3cd7 codegen: Swap mapping/indent order for top-level decls (#22206) (Dunqing) - 73b4f40 minifier: Preserve catch binding with direct eval (#22221) (camc314) - 0e13d17 minifier: Preserve optional chain base side effects (#22219) (camc314) - 0c7c01c transformer/typescript: Inline optional-chain enum member access (#21834) (Dunqing) - a6aff7e codegen: Emit block/array/object end mapping at close char (#22200) (Dunqing) - a099b03 codegen: Emit call end mapping at `)` position, not past it (#22199) (Dunqing) - 5753774 minifier: Cap if-return ternary collapse for firefox (#21841) (Gurupungav Narayanan) - 2493bdd codegen: Correct sourcemap end mappings for closing delimiters (#22001) (Mark Dalgleish) - 3b385e2 minifier: Bail optimizing `Array` with unknown arg count (#22188) (camc314) - 9fa2122 parser: Parse array computed class keys (#22159) (camc314) ### 📚 Documentation - a4a6892 napi/parser: Correct code comment (#22278) (overlookmotel) - 9305373 oxc: Update README (#22178) (camc314) Co-authored-by: Cameron <[email protected]>
…#22789) ## Summary - `oxc_codegen` produced off-by-one V8 stack-trace and coverage columns for call-chain shapes. When a `(`/`.`/`[` is chained directly onto a call — or any expression ending in `)`/`]` — that punctuation was left unmapped, so the lookup fell back to the closing delimiter one column to the left. Surfaced as vitest failures in vite-ecosystem-ci: `test.each([1, 2])(…)` reporting column 17 instead of 18, and `await expect(…).resolves.toBe(4)` reporting 40 instead of 41. - Root cause: such operands end in `)` or `]`, so there is no trailing identifier token for a source-map consumer to anchor on. The fix maps that punctuation from `print_expr` — keyed on postfix precedence — back to the operand's exclusive end, exactly where V8 reports the frame. This is the trailing end-mapping that Babel and TSC apply to every node, scoped here to just the no-anchor sites. The closing `)`/`}`/`]` keep their existing at-delimiter mapping, which `v8-to-istanbul` relies on for coverage range boundaries (#22001). - Only these no-anchor chain sites are mapped — not the byte past every delimiter — so the added source-map tokens stay at **+0.6%** over baseline. (A blanket variant that mapped every delimiter regressed codegen ~5% on CodSpeed.) - Verified end-to-end through `node --enable-source-maps`: each chained frame resolves to the correct column (the outer `(` for curried calls, the `.` for a getter/member on a call result), and regresses by exactly one column when the mapping is disabled. Covered by the `stacktrace_is_correct` integration test and unit tests in `sourcemap.rs`. ## Example `factory()()` — V8 reports the outer call at the `(` after the inner `)`: ``` without this fix: at <anonymous> (input.js:7:9) ← the inner `)` with this fix: at <anonymous> (input.js:7:10) ← the outer `(` ✓ ``` The same one-column drift affected `expect(x).resolves` (the `.` getter access after the `)`). ## Compared with esbuild and tsc Same input through each tool (identical generated code), visualized with [source-map-viewer](https://source-map-viewer.void.app): ```js const outer = factory()(); // outer `(` of factory()() const member = expect(x).resolves; // `.` of expect(x).resolves const indexed = arr[i](); // `(` of arr[i]() ``` | Source token | oxc (this PR) | tsc | esbuild | | --- | :--: | :--: | :--: | | outer `(` of `factory()()` | ✓ | ✓ | ✗ | | `.` of `expect(x).resolves` | ✓ | ✓ | ✗ | | `(` of `arr[i]()` | ✓ | ✓ | ✗ | - **vs esbuild** ([compare](https://source-map-viewer.void.app/compare?a=QCnJWx7H&b=Y4FwbiMW)): oxc adds the three chain mappings esbuild lacks — esbuild maps the `]` one column left of the `(`, the exact off-by-one. This PR resolves what esbuild still gets wrong. - **vs tsc** ([compare](https://source-map-viewer.void.app/compare?a=UjsQoNTX&b=Y4FwbiMW)): all three positions match tsc exactly. tsc maps ~10 more positions because it gives every node a trailing end-mapping; this PR maps only the no-anchor sites (+0.6% vs tsc's full density). --- This PR was prepared with AI assistance; I reviewed, tested, and verified the changes.
This fixes incorrect sourcemap end mappings for closing delimiters emitted by
oxc_codegen.This came up while investigating Remix 3 test coverage reporting for code transformed with Oxc via the Remix
assetspackage, which we were using in ourtestpackage to run in-browser tests. V8 reported uncovered functions and branches correctly, butv8-to-istanbulcould not always resolve the end of those covered ranges back through Oxc's sourcemap, which caused coverage to be over-reported. This meant that we saw cases of 100% coverage being incorrectly reported becausev8-to-istanbuldropped the whole function block when it could not map the closing}line of an uncalled function.add_source_mapping_endusedSpan::enddirectly even though spans are end-exclusive, so the generated token could land one character past the real source span. Block printers also did not emit end mappings for closing}consistently. Consumers that resolve V8 coverage range ends through the sourcemap can then miss those block boundaries and over-report coverage.This PR maps end tokens to
span.end - 1and emits them for block closers, then extends the sourcemap tests with:elsebindingThe snapshot updates in
stacktrace_is_correctare expected for the same reason. Those column numbers come from Node consuming the generated sourcemap at runtime. For example, one frame changes from/project/input.js:10:12to/project/input.js:10:11for the source lineobj.fn({a})(). In 1-based columns, that line iso1 b2 j3 .4 f5 n6 (7 {8 a9 }10 )11 (12 )13. The error happens in the first call expression, so the end position should resolve to column 11, the first), which is the closing character ofobj.fn({a}). Before this change it resolved to column 12, the next(, because the end mapping pointed one character too far to the right. After switching end mappings to the final real source character and adding the missing block-closer mappings, that frame moves left by one column to the correct source position.Please note, I created this PR with AI assistance, but I worked closely with the agent to design, review, and iterate on the changes until it was at a level I was happy with.