A one-chunk magic-string shift in ssrTransform.ts — the emitted code is byte-identical, only the sourcemap moves.
When SSR transform rewrites a call to an imported binding, it wraps the callee as
(0,…) to avoid binding this. V8 anchors the stack frame of a
parenthesized callee to the argument-list (, not to the
callee. So the reported column depends on where that ( maps back to in the source.
The fix folds the ( into the same replacement chunk that starts at the
callee, so it inherits the callee’s column.
Example source (only line 2 matters):
1 import { fn } from 'vue'
2 fn(1)
( (col 2) — the bugupdate the callee · prependRight('(0,') · appendLeft(')'). The original (1) is left untouched, so the ( maps to its own column 2.((. Mismatch with Node.s.update(id.start, argsStart, "(0,binding)" + code.slice(id.end, argsStart)). The chunk now spans original fn( (cols 0–2), so everything in it — including the ( — maps to the chunk start, col 0.( now maps to col 0fn. Matches Node.
This isn’t a quirk the patch stumbled on; it’s an explicit, commented decision in V8’s
parser. When building a call node, V8 picks the stack-trace position based on the token
immediately before the argument-list (:
V8 · src/parsing/parser-base.h:4030 · ParseLeftHandSideContinuation → case Token::kLeftParen (pinned to v8/v8 @ 0c7a9d0)
The discriminator IsCallable(current_token()) is just a token-range check
(src/parsing/token.h:248): identifiers, property names, this, super,
reserved words. So the position depends purely on what character ends the callee:
| Call form | Token before ( | IsCallable? | Recorded position |
|---|---|---|---|
fn(…) | identifier fn | yes | position() → callee start |
obj.fn(…) | property name fn | yes | position() → property name |
(0,…fn)(…) (the transform) | ) | no | peek_position() → the ( |
fn?.() | ?. | no | peek_position() → the ( |
obj[x]() | ] | no | peek_position() → the ( |
Two takeaways: (1) the (0,…) wrap — added to unbind this — makes the callee
end in ), which flips every transformed call into the peek_position() branch, so V8
always reports the (. The sourcemap is then the only lever left, and the patch bends it so that
( resolves back to the callee. (2) The old mapping was literally faithful (generated (
→ source (); the fix is a deliberate, V8/Node-specific counter-adjustment. Other engines
may anchor differently, but for SSR the runtime is Node.
fn?.()
This falls out of the same V8 rule: the token before ( is ?., which isn’t
IsCallable, so even un-transformed Node already reports the ( column. Matching
Node therefore means not folding here — the fix excludes them with !parent.optional,
keeps the old wrap, and leaves the ( mapped to its own column 4.
f n ? . ( )
0 1 2 3 4 5 ← reports 2:5, matching Node
The (-anchoring above is V8-specific. JavaScriptCore (what Bun runs on) uses a different
call-site heuristic: it anchors the frame to the callee identifier / property name
in every case — parentheses and optional chaining included. Measured on identical raw source
(thrown-stack caller frame, column 1-indexed):
| Call form | V8 / Node | JSC / Bun |
|---|---|---|
fn() | 3:1 callee | 3:1 callee |
ns.fn() | 3:4 property | 3:4 property |
(0, ns.fn)() (the transform) | 3:13 the ( | 3:8 property fn |
ns.fn?.() | 3:10 the ( | 3:4 property fn |
Because JSC reads the generated property token .fn (not the args (), and that token sits in
the binding chunk that already maps to the callee start under both the old and
new mapping, Bun/JSC already reports the Node-aligned column before this patch and still does after. In
other words the fix is a V8-only correction; WebKit’s mapping heuristic differs but works as-is, and the
change is a harmless no-op for it (the args ( now also maps to col 0, but JSC never reads that
position). Measured with Node v24.18.0 (V8) and Bun 1.3.14 (JSC).
Why it's low-risk: in the new branch the emitted string is identical to before
— code.slice(id.end, argsStart) re-emits the exact original whitespace/comments/(
(or the whole () for empty arg lists via parent.end). Only the chunk boundary,
and thus the sourcemap, changes.
Anchor: packages/vite/src/node/ssr/ssrTransform.ts:371 · onIdentifier →
parent.type === 'CallExpression' branch (guard id === parent.callee && !parent.optional).