vitejs/vite · PR #22829 · fixes #19625

Aligning SSR call-site stack-trace columns with Node

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)

chunk maps to callee start (col 0) — the desired anchor
maps to the ( (col 2) — the bug
untouched passthrough
BEFORE
Three separate edits: update the callee · prependRight('(0,') · appendLeft(')'). The original (1) is left untouched, so the ( maps to its own column 2.
generated
(0,
__vite_ssr_import_0__.fn
)
(
1
)
V8 anchors frame here → the (
source
f
n
(
1
)
col 0
1
2
3
4
V8 frame → source col 2 → reports 2:3  —  points at (. Mismatch with Node.
AFTER
One edit: 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.
generated
(0,__vite_ssr_import_0__.fn)(
1
)
V8 anchors here → but the ( now maps to col 0
source
f
n
(
1
)
col 0
1
2
3
4
V8 frame → source col 0 → reports 2:1  —  points at fn. Matches Node.

Why V8 anchors here — the source of truth

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 (:

/* Call */ case Token::kLeftParen: { int pos; if (Token::IsCallable(scanner()->current_token())) { // For call of an identifier we want to report position of // the identifier as position of the call in the stack trace. pos = position(); // ← start of the callee } else { // For other kinds of calls we record position of the parenthesis // as position of the call. ... for expressions of the form // function(){...}() ... pos = peek_position(); // ← 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 formToken before (IsCallable?Recorded position
fn(…)identifier fnyesposition() → callee start
obj.fn(…)property name fnyesposition() → property name
(0,…fn)(…) (the transform))nopeek_position() → the (
fn?.()?.nopeek_position() → the (
obj[x]()]nopeek_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.

The one exception: optional calls 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

Other engines: WebKit / JSC (Bun) — different heuristic, already fine

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 formV8 / NodeJSC / Bun
fn()3:1 callee3:1 callee
ns.fn()3:4 property3: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).