Skip to content

Conversation

@stevez
Copy link

@stevez stevez commented Dec 31, 2025

Description

When the same file is covered by multiple projects with different transform modes (e.g., jsdom/SSR vs browser), Vite produces source maps with different end column positions for the same source location. Istanbul's native merge() treats these as different statements, causing inflated statement/function/branch counts.

Resolves #9366

Root cause: Vite's SSR transform uses MagicString with hires: "boundary" mode which produces different source map column mappings than the browser transform. This results in end.column: null (from Infinity) in SSR coverage vs specific values like end.column: 10 in browser coverage.

This fix implements a smart merge strategy that matches statements by their start position only, ignoring end column differences. This correctly merges execution counts without duplicating coverage entries.

Example: A file with 15 statements was incorrectly reported as having 29 statements when covered by both jsdom and browser projects. After this fix, it correctly reports 15 statements.

Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • It's really useful if your PR references an issue where it is discussed ahead of time. If the feature is substantial or introduces breaking changes without a discussion, PR might be closed.
  • Ideally, include a test that fails without this PR but passes with it.
  • Please, don't make changes to pnpm-lock.yaml unless you introduce a new test example.
  • Please check Allow edits by maintainers to make review process faster. Note that this option is not available for repositories that are owned by Github organizations.

Tests

  • Run the tests with pnpm test:ci.

Documentation

  • If you introduce new functionality, document it. You can run documentation with pnpm run docs command.

No documentation needed - this is an internal bug fix with no API changes.

Changesets

  • Changes in changelog are generated from PR name. Please, make sure that it explains your changes in an understandable manner. Please, prefix changeset messages with feat:, fix:, perf:, docs:, or chore:.

… multiple projects

When the same file is covered by multiple projects with different transform
modes (e.g., jsdom/SSR vs browser), Vite produces source maps with different
end column positions for the same source location. Istanbul's native merge()
treats these as different statements, causing inflated statement/function/branch
counts.

Root cause: Vite's SSR transform uses MagicString with `hires: "boundary"` mode
which produces different source map column mappings than the browser transform.
This results in `end.column: null` (from Infinity) in SSR coverage vs specific
values like `end.column: 10` in browser coverage.

This fix implements a smart merge strategy that matches statements by their
start position only, ignoring end column differences. This correctly merges
execution counts without duplicating coverage entries.
@netlify
Copy link

netlify bot commented Dec 31, 2025

Deploy Preview for vitest-dev ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 0a3f044
🔍 Latest deploy log https://app.netlify.com/projects/vitest-dev/deploys/695474cbcf12f8000863b286
😎 Deploy Preview https://deploy-preview-9365--vitest-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@stevez
Copy link
Author

stevez commented Dec 31, 2025

The CI failure is a snapshot mismatch in specs/runner.test.ts > timeout hooks - a flaky browser timeout test unrelated to the coverage-v8 changes in this PR.

The smart merge unit tests pass locally. Could a maintainer please re-run the failed jobs?

Copy link
Member

@AriPerkkio AriPerkkio left a comment

Choose a reason for hiding this comment

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

We'll need minimal reproduction for #9366 and then use that as test case here.

Once we got the minimal repro, we can actually see what's wrong.

@stevez
Copy link
Author

stevez commented Dec 31, 2025

We'll need minimal reproduction for #9366 and then use that as test case here.

Once we got the minimal repro, we can actually see what's wrong.

@AriPerkkio , I created a repo to reproduce the issue, also pasted the following in to #9366, please let me know if you have any more information.

Describe the bug

When using Vitest projects with different environments (e.g., jsdom for unit tests + browser for component tests), files covered by multiple projects have duplicate coverage entries, resulting in inflated statement/function/branch counts.

Reproduction

https://github.com/stevez/vitest-coverage-merge-bug

git clone https://github.com/stevez/vitest-coverage-merge-bug
cd vitest-coverage-merge-bug
npm install
npm run test:coverage
node check-coverage.cjs

Output shows duplicate statements for constants.ts:

File: constants.ts
  Statements: 6
    [0] line 1:22 -> 7:null      <- from jsdom
    [1] line 9:21 -> 9:null      <- from jsdom
    [2] line 11:22 -> 15:null    <- from jsdom
    [3] line 1:22 -> 7:10        <- from browser (duplicate!)
    [4] line 9:21 -> 9:58        <- from browser (duplicate!)
    [5] line 11:22 -> 15:10      <- from browser (duplicate!)

Expected: 3 statements
Actual: 6 statements

Root Cause

Vite's SSR transform (used for jsdom) produces source maps with end.column: null, while browser transform produces specific end column values. Istanbul's merge() treats these as different statements because the locations don't match exactly.

System Info

System:
  OS: Windows 11
  CPU: Intel(R) Core(TM) Ultra 9 285K
  Memory: 64 GB
  Node: v22.16.0
Binaries:
  npm: 10.9.2
npmPackages:
  vitest: ^4.0.16
  @vitest/coverage-v8: ^4.0.16
  @vitest/browser: ^4.0.16

Used Package Manager

npm

Validations

  • Check that you are using the latest version of Vitest
  • Read the contribution guide
  • Check existing issues for duplicates

@AriPerkkio
Copy link
Member

Reproduction

https://github.com/nicoder-dev/vitest-coverage-merge-bug

Perfect, thanks! I hope this makes debugging this bug easier. Could you make this project public?

Your root cause analysis sounds correct. I think we might want to instead apply the fix in Vite or istanbul-lib-coverage instead though. 🤔

@stevez
Copy link
Author

stevez commented Dec 31, 2025

Reproduction

https://github.com/nicoder-dev/vitest-coverage-merge-bug

Perfect, thanks! I hope this makes debugging this bug easier. Could you make this project public?

Your root cause analysis sounds correct. I think we might want to instead apply the fix in Vite or istanbul-lib-coverage instead though. 🤔

sorry, I put the wrong url, this is the one: https://github.com/stevez/vitest-coverage-merge-bug

@AriPerkkio
Copy link
Member

AriPerkkio commented Dec 31, 2025

@stevez could you make it even more minimal and remove @vitejs/plugin-react-swc? Just Vitest, so that we could add it as test case here.

Removing these lines seems to fix constants.ts coverage in your repro. 🤷

-import react from "@vitejs/plugin-react-swc";

export default defineConfig({
-   plugins: [react()],

@stevez
Copy link
Author

stevez commented Dec 31, 2025

I can't remove the react plugin, since the test will not run, instead I tried to replace the @vitejs/plugin-react-swc with @vitejs/plugin-react

I investigated further and found the root cause is in @vitejs/plugin-react-swc, not Vitest.

With SWC plugin:

  • Unit (jsdom): end.column: null
  • Browser: end.column: 10, 58, 10 (specific values)
  • Result: 6 statements (duplicated)

With Babel plugin (@vitejs/plugin-react):

  • Unit (jsdom): end.column: null
  • Browser: end.column: null
  • Result: 3 statements (correct)

The SWC plugin produces different source maps for SSR vs browser transforms. The browser transform includes end column info that the SSR transform doesn't preserve.

I've updated the reproduction repo to use @vitejs/plugin-react as a workaround. The underlying issue should probably be reported to https://github.com/vitejs/vite-plugin-react-swc.

That said, your coverage merge logic could still be more defensive - if two coverage entries have the same start location but different end columns (especially when one is null), they likely represent the same statement.

@stevez
Copy link
Author

stevez commented Dec 31, 2025

@stevez could you make it even more minimal and remove @vitejs/plugin-react-swc? Just Vitest, so that we could add it as test case here.

Removing these lines seems to fix constants.ts coverage in your repro. 🤷

-import react from "@vitejs/plugin-react-swc";

export default defineConfig({
-   plugins: [react()],

Interesting, you are just testing the constants, for me I would rather test the button.tsx which uses the constants, then I want to see the results, yes the constants itself will work but combine with browser test using @vitejs/plugin-react-swc, then issues happens

Copy link
Member

@AriPerkkio AriPerkkio left a comment

Choose a reason for hiding this comment

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

I don't think this is the correct way to fix the bug. Looking at this closer, it's visible that swc is not generating end mappings. When remapping the transpiled code back to sources we end up on wrong column. Istanbul assigns the column as Infinity in theses cases (to match the end of the line), which shows up as null in the report.

Correct way to fix this is to file bug report to SWC. Looking at their issue tracker, they've fixed similar coverage related source map bugs before too. Let's continue that on #9366.

Thanks for the PR, minimal reproduction and all the debugging! 🙌

@AriPerkkio AriPerkkio closed this Jan 1, 2026
@stevez
Copy link
Author

stevez commented Jan 1, 2026

It is more complicated, based on my experiment:

With SWC plugin:

  • Unit (jsdom): end.column: null
  • Browser: end.column: 10, 58, 10 (specific values)
  • Result: 6 statements (duplicated)

With Babel plugin (@vitejs/plugin-react):

  • Unit (jsdom): end.column: null
  • Browser: end.column: null
  • Result: 3 statements (correct)

the conclusion is @vitejs/plugin-react will generate end.column: null for both jsdom and browser, while @vitejs/plugin-react-swc generates end.column: null in jsdom, but generates valid value in browser mode, so the problem is not swc generates null, it is because its behavior is inconsistent between jsdom and browser; if you want to fix this issue, then you need to fix for both @vitejs/plugin-react and @vitejs/plugin-react-swc, since end.column: null is the root cause

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.

coverage-v8: Duplicate statements when merging coverage from multiple projects with different environments

2 participants