react icon indicating copy to clipboard operation
react copied to clipboard

[Compiler Bug]: Coverage report shows missing branch coverage

Open ValentinGurkov opened this issue 8 months ago • 22 comments

What kind of issue is this?

  • [ ] React Compiler core (the JS output is incorrect, or your app works incorrectly after optimization)
  • [x] babel-plugin-react-compiler (build issue installing or using the Babel plugin)
  • [ ] eslint-plugin-react-compiler (build issue installing or using the eslint plugin)
  • [ ] react-compiler-healthcheck (build issue installing or using the healthcheck script)

Link to repro

https://github.com/ValentinGurkov/vitest-react-compiler-missing-coverage-repro

Repro steps

Describe the bug

Hello,

I've been testing out the new React compiler and have noticed that the code coverage report becomes incorrect with the compiler turned it. I believe it may be related to the way it changes the output react component code.

I've created a minimal reproduction repository to demonstrate the issue: 👉 https://github.com/ValentinGurkov/vitest-react-compiler-missing-coverage-repro, but I also want to share my findings here:

The button we are going to test is a simple one:

export const Button = () => {
    return <button>Click me</button>;
};

As well as its test:

import { page } from '@vitest/browser/context';
import { Button } from '@repo/components/ui/button.js';
import { describe, expect, it} from 'vitest';
import { render } from 'vitest-browser-react';

describe(Button, () => {
    it('renders with default variants', async () => {
        render(<Button />);

        const button = page.getByRole('button', { name: 'Click me' });

        await expect.element(button).toBeInTheDocument();
    });
});

The coverage report with the React compiler turned on looks like:

   ✓  browser (chromium)  test/components/ui/button.test.tsx (1 test) 9ms
   ✓ Button > renders with default variants 9ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  20:34:46
   Duration  632ms (transform 0ms, setup 133ms, collect 28ms, tests 9ms, environment 0ms, prepare 93ms)

 % Coverage report from v8
------------|---------|----------|---------|---------|-------------------
File        | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
------------|---------|----------|---------|---------|-------------------
All files   |     100 |       50 |     100 |     100 |                   
 button.tsx |     100 |       50 |     100 |     100 | 2                 
------------|---------|----------|---------|---------|-------------------

The component has no branching logic while the report shows are the are missing some. I believe I've managed to set up the vitest configuration to also log the transformed component and we have:

import { jsxDEV } from "react/jsx-dev-runtime";
import { c as _c } from "react/compiler-runtime";
export const Button = () => {
  const $ = _c(1);
  let t0;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t0 = /* @__PURE__ */ jsxDEV("button", { children: "Click me" }, void 0, false, {
      fileName: "/Users/<my-user>/Projects/vitest-react-compiler-coverage/src/components/ui/button.tsx",
      lineNumber: 6,
      columnNumber: 10
    }, this);
    $[0] = t0;
  } else {
    t0 = $[0];
  }
  return t0;
};

It looks like the React compiler introduces a conditional for memoization purposes. That may explain the coverage issue, though it raises the question: is it even possible to get 100% branch coverage for components compiled like this?

The test without the React compiler looks like:

- react({
- babel: {
- plugins: [
- ["babel-plugin-react-compiler", {}]
- ],
- },
- }),
+ react(),
   ✓  browser (chromium)  test/components/ui/button.test.tsx (1 test) 10ms
   ✓ Button > renders with default variants 10ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  20:36:42
   Duration  490ms (transform 0ms, setup 36ms, collect 6ms, tests 10ms, environment 0ms, prepare 92ms)

 % Coverage report from v8
------------|---------|----------|---------|---------|-------------------
File        | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
------------|---------|----------|---------|---------|-------------------
All files   |     100 |      100 |     100 |     100 |                   
 button.tsx |     100 |      100 |     100 |     100 |                   
------------|---------|----------|---------|---------|-------------------

The code coverage is 100% as expected. The output code also has no conditions:

import { jsxDEV } from "react/jsx-dev-runtime";
export const Button = () => {
  return /* @__PURE__ */ jsxDEV("button", { children: "Click me" }, void 0, false, {
    fileName: "/Users/<my-user>/Projects/vitest-react-compiler-coverage/src/components/ui/button.tsx",
    lineNumber: 2,
    columnNumber: 12
  }, this);
};

Coming from https://github.com/vitest-dev/vitest/issues/7843#issuecomment-2812107855, it seems that babel's auxiliaryCommentBefore also does not have any effect on this.

How do we see test coverage working once the React Compiler becomes standard?

How often does this bug happen?

Every time

What version of React are you using?

19.1.0

What version of React Compiler are you using?

19.1.0-rc.1

ValentinGurkov avatar Apr 17 '25 09:04 ValentinGurkov

Understanding and Fixing Missing Coverage from React Compiler Memoization in Vitest


❗️Issue Type Classification

Category | This Issue Falls Under -- | -- ✅ React Compiler core | ✅ Yes — Transformed JS output includes an internal conditional for memo caching, which affects test coverage metrics. ❌ babel-plugin-react-compiler | Not a build-time install issue ❌ eslint-plugin-react-compiler | Not related to linting or formatting ❌ react-compiler-healthcheck | Not related to healthcheck scripts

🧠 React Infinite–Rooted Solution_ Understanding and Fixing Missing Coverage from React Compiler Memoization in Vitest.pdf

ObiwanKenobee avatar Apr 29 '25 07:04 ObiwanKenobee

https://github.com/Zakwan96/FB-HACK-PHISHING.git

POUSINRP avatar Apr 29 '25 07:04 POUSINRP

https://github.com/facebook/react/issues/32950

POUSINRP avatar Apr 29 '25 07:04 POUSINRP

Thanks for posting. React Compiler automatically memoizes components to make updates (re-rendering) faster. This includes memoizing JSX elements, such as the <button> in your example. Memoization uses if/else statements plus a cache, though the exact output may very well change in the future.

The 50% branch coverage you're seeing reflects the fact that the test doesn't exercise the update (re-render) case. It looks like you're using Vitest's default option for code coverage, backed by V8. It looks like there is another option, to use Istanbul, which may be able to take advantage of source maps to provide more precise coverage of the original, pre-transformed code (i'm not sure, you may need to experiment). https://vitest.dev/guide/coverage.html#coverage

josephsavona avatar Apr 29 '25 08:04 josephsavona

V8 coverage uses source maps too.

If a compiler adds code during transform, and includes it in source maps, coverage tools will show it in the report. Generated code should not be added in source maps.

This affects all JS coverage tools, not just Vitest. Either remove it from source maps, or add coverage exclusion hints during transform.

AriPerkkio avatar Apr 29 '25 08:04 AriPerkkio

@AriPerkkio Interesting, thanks for the tip. I'll experiment with trying to omit nodes from source maps, it has not been easy to find docs on this stuff (and how it interplays with Babel). We had naively assumed that if we skipped a loc property that the node would be emitted from source maps.

josephsavona avatar Apr 29 '25 09:04 josephsavona

Agree with AriPerkkio – the core issue feels like the generated memoization branch showing up in source maps. Ideally, coverage should reflect the original source, not the compiler's internal optimizations.

SGSANJAY044 avatar Apr 29 '25 11:04 SGSANJAY044

Yes we agree, and this is why we intentionally don’t add source location to nodes for generated memoization logic. The problem is that Babel appears to synthesize incorrect source map information for those nodes, rather than excluding them as you’d expect.

josephsavona avatar Apr 29 '25 12:04 josephsavona

I see that the related PR was closed, was the attempted fix unsuccessful?

ValentinGurkov avatar May 12 '25 09:05 ValentinGurkov

My original PR is still open

josephsavona avatar May 12 '25 14:05 josephsavona

https://github.com/facebook/react/pull/33051

josephsavona avatar May 12 '25 14:05 josephsavona

I see that the latest attempt to fix this was, unfortunately, unsuccessful. What do you think are the next steps? Should we try to reach out to someone from the Babel team, and see whether they are keen on providing some insight?

ValentinGurkov avatar Aug 04 '25 12:08 ValentinGurkov

Yeah, unfortunately I couldn't figure out how to get Babel to do the right thing here. Maybe it's possible somehow.

If you'd like to help, reaching out to the Babel team makes sense as the next step. All the information should be in the linked PR (#33051)

josephsavona avatar Aug 04 '25 17:08 josephsavona

I just ran into this today. As a workaround (until this can be fixed), I just excluded the compiler when running the unit tests:

vite.config.js

export default defineConfig(({ command, mode }) => {
  const baseConfig = {
    plugins: [tailwindcss()],
    test: {
      globals: false,
      environment: 'jsdom',
      coverage: {
        provider: 'v8',
        ...
      },
      ...
    },
    ...
  };

  let finalConfig = { ...baseConfig };

  // if we are in test mode, we do not want the react compiler included
  // it causes issues with coverage: https://github.com/facebook/react/issues/32950
  // otherwise, we want to include the compiler
  if (mode === 'test') {
    finalConfig.plugins.push(react());
  } else {
    finalConfig.plugins.push(react({ babel: { plugins: ['babel-plugin-react-compiler'] } }));
  }

  ... // other config setup

  return finalConfig;
});

Sample of coverage when the react compiler is included: Image

Sample of coverage when the react compiler is excluded: Image

obryckim avatar Sep 04 '25 12:09 obryckim

I just ran into this today. As a workaround (until this can be fixed), I just excluded the compiler when running the unit tests:

vite.config.js

export default defineConfig(({ command, mode }) => { const baseConfig = { plugins: [tailwindcss()], test: { globals: false, environment: 'jsdom', coverage: { provider: 'v8', ... }, ... }, ... };

let finalConfig = { ...baseConfig };

// if we are in test mode, we do not want the react compiler included // it causes issues with coverage: https://github.com/facebook/react/issues/32950 // otherwise, we want to include the compiler if (mode === 'test') { finalConfig.plugins.push(react()); } else { finalConfig.plugins.push(react({ babel: { plugins: ['babel-plugin-react-compiler'] } })); }

... // other config setup

return finalConfig; }); Sample of coverage when the react compiler is included: Image

Sample of coverage when the react compiler is excluded: Image

I am doing the same, but I worry that if there are places where we rely on the compiler optimisations, we might end up, for example, with constant re-renders in the tested component.

ValentinGurkov avatar Sep 04 '25 13:09 ValentinGurkov

@obryckim We don't recommend using different configurations for development/tests and production — as a general rule, that's a recipe for extremely hard to diagnose bugs. (bikeshed alert) I have not personally found code coverage metrics to be especially meaningful — you can have full coverage and still miss cases — but if you find the coverage metrics more important than performance gains then I'd recommend disabling the compiler everywhere. I just wouldn't want you to risk gnarly bugs in the off chance you see different behavior. This is a subjective opinion, just my cents. It's your app, your call.

josephsavona avatar Sep 04 '25 15:09 josephsavona

Hi! Is this issue still being investigated?

raz-ezra-lmnd avatar Oct 21 '25 08:10 raz-ezra-lmnd

We aren't actively investigating this. See #33051 for more context on what we tried and why it didn't work with Babel.

josephsavona avatar Oct 21 '25 17:10 josephsavona

Is there any mitigation for the moment? This is blocking me from adopting the compiler since it means we can't rely on branch coverage being correct. Just need to wait for a non-babel compiler integration?

mattm-malone avatar Nov 13 '25 18:11 mattm-malone

Coming from https://github.com/vitest-dev/vitest/issues/7843#issuecomment-2812107855, it seems that babel's auxiliaryCommentBefore also does not have any effect on this.

Documentation on auxiliaryCommentBefore is extremely sparse, if anyone has context on how this is supposed to work we could explore this route.

josephsavona avatar Nov 13 '25 21:11 josephsavona

Oh Man, there really needs to be a fix for this otherwise I can see adoption of the react compiler being lowered as people crash into this issue and then deciding NOT to use it. Like I am considering...

heath-freenome avatar Nov 25 '25 22:11 heath-freenome

Coming from vitest-dev/vitest#7843 (comment), it seems that babel's auxiliaryCommentBefore also does not have any effect on this.

Documentation on auxiliaryCommentBefore is extremely sparse, if anyone has context on how this is supposed to work we could explore this route.

Has anyone posted a question or created an issue requesting better documentation for auxiliaryCommentBefore with the Babel team?

heath-freenome avatar Nov 25 '25 22:11 heath-freenome