Summary
render() from vitest-browser-react emits an asynchronous unhandled promise rejection in webkit when a test file with several render() calls runs alongside other test files. All assertions pass — the rejection appears in vitest's Unhandled Errors summary and causes Errors: 1 / non-zero exit.
The rejection originates inside render() at the page.elementLocator(container) call (pure-C_qo4W4L.js:59:37), which ultimately calls selectorEngine.generateSelectorSimple(element) where some value being .includes-ed is undefined.
- Reproduces ~40–60% of CI runs locally
- Reproduces only in webkit
- Reproduces only when the suspect test file is run alongside ≥1 other test file
- Reproduces with a pure unit test as the second file (so it's not "parallel rendering" — just "any second file present")
- Coverage instrumentation (
--coverage) makes it noticeably more frequent
Versions
|
|
vitest-browser-react |
2.2.0 |
vitest |
4.1.2 |
@vitest/browser |
4.1.2 |
@vitest/browser-playwright |
4.1.2 |
playwright |
1.58.0 |
| React |
18.3.x |
| Node |
22.x |
| OS |
macOS (Darwin 25.4.0) |
| Browsers run |
chromium, firefox, webkit (only webkit triggers) |
Stack trace
TypeError: undefined is not an object (evaluating 'e.includes')
❯ render node_modules/.pnpm/vitest-browser-react@2.2.0_.../vitest-browser-react/dist/pure-C_qo4W4L.js:59:37
Line 59 in that bundle is:
const locator = page.elementLocator(container);
page.elementLocator in the playwright provider calls selectorEngine.generateSelectorSimple(element) — the e.includes lives somewhere inside that selector-generation path. The Safari/JSC error wording "undefined is not an object" is webkit-specific (V8 says "Cannot read properties of undefined").
vitest blames the test running at the time of the rejection, but the rejection is asynchronous and the cited test isn't the cause — it's just whichever was active when the framework's promise rejected.
Minimal reproducer
Create two test files in a project configured with vitest browser mode + the playwright provider + a webkit project:
a.test.tsx
import { describe, expect, test } from 'vitest'
import { render, page, userEvent } from 'vitest-browser-react'
// Anything that mounts a few times and has a hover interaction.
function Probe() {
return (
<div style={{ width: 400, height: 200 }}>
<div data-testid="hover-area" style={{ width: '100%', height: '100%' }}>hover me</div>
</div>
)
}
describe('probe', () => {
// ~10–15 tests doing render + hover. Single test isn't enough; the
// race is roughly proportional to the number of renders in the file.
for (let i = 0; i < 12; i++) {
test(`render and hover #${i}`, async () => {
render(<Probe />)
await userEvent.hover(page.getByTestId('hover-area'))
await expect.element(page.getByTestId('hover-area')).toBeInTheDocument()
})
}
})
b.test.ts — anything, even pure unit tests:
import { test, expect } from 'vitest'
test('placeholder', () => {
expect(1 + 1).toBe(2)
})
vitest.config.ts
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'webkit' }],
},
},
})
Run vitest --run --browser.headless repeatedly. Roughly half the runs report:
⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
TypeError: undefined is not an object (evaluating 'e.includes')
❯ render node_modules/.../vitest-browser-react/dist/pure-C_qo4W4L.js:59:37
Test Files N passed (N)
Tests M passed (M)
Errors 1 error
All assertions pass; only the Errors: 1 line is the symptom.
What I tried on our side
- Removed
setAttribute mutations outside React's commit phase (an aria-describedby effect that directly mutated an SVG element). Moving the attribute onto the JSX as a React prop reduced the rate from ~60% to ~40% but didn't eliminate it. So at least one contributor is on the framework side; bypassing React's reconciler appears to widen the race window.
- Test interaction with cursor state. Webkit doesn't reset cursor position between tests in browser mode, which surfaced a different unrelated bug for us. Fixing that didn't change the rejection rate.
- Switching
userEvent.hover to a raw dispatchEvent (didn't help).
- Splitting test files (untried — would reduce per-file render count, possible mitigation).
Workaround we're using
A narrow unhandledrejection listener in setupTests.ts that matches only rejections with this exact message AND a stack containing vitest-browser-react, then event.preventDefault()s them. Real test failures still surface. Comment explicitly references this issue and contains the deletion criteria once it's fixed upstream.
Expected behaviour
render() should not emit asynchronous unhandled rejections when called normally — the implicit cleanup between tests should fully settle before the next test's render begins. If the issue is in selectorEngine.generateSelectorSimple receiving a stale/null element, it should fail with a descriptive error from render()'s await rather than escaping as an unhandled rejection.
Guesses (low-confidence, just to help triage)
- The race feels timing-sensitive (coverage instrumentation makes it worse).
- The
e.includes location suggests selectorEngine.generateSelectorSimple is calling .includes on an element property — possibly element.tagName, element.className, element.id, or an attribute value — that's undefined because the element was detached/torn down by the previous test's cleanup before the new elementLocator call.
mountedContainers / mountedRootEntries accounting in pure-C_qo4W4L.js might be involved if cleanup awaits don't fully settle in webkit; multiple containers from prior tests may have inconsistent state when elementLocator is called on a new one.
Summary
render()fromvitest-browser-reactemits an asynchronous unhandled promise rejection in webkit when a test file with severalrender()calls runs alongside other test files. All assertions pass — the rejection appears in vitest'sUnhandled Errorssummary and causesErrors: 1/ non-zero exit.The rejection originates inside
render()at thepage.elementLocator(container)call (pure-C_qo4W4L.js:59:37), which ultimately callsselectorEngine.generateSelectorSimple(element)where some value being.includes-ed isundefined.--coverage) makes it noticeably more frequentVersions
vitest-browser-reactvitest@vitest/browser@vitest/browser-playwrightplaywrightStack trace
Line 59 in that bundle is:
page.elementLocatorin the playwright provider callsselectorEngine.generateSelectorSimple(element)— thee.includeslives somewhere inside that selector-generation path. The Safari/JSC error wording"undefined is not an object"is webkit-specific (V8 says"Cannot read properties of undefined").vitest blames the test running at the time of the rejection, but the rejection is asynchronous and the cited test isn't the cause — it's just whichever was active when the framework's promise rejected.
Minimal reproducer
Create two test files in a project configured with vitest browser mode + the playwright provider + a webkit project:
a.test.tsxb.test.ts— anything, even pure unit tests:vitest.config.tsRun
vitest --run --browser.headlessrepeatedly. Roughly half the runs report:All assertions pass; only the
Errors: 1line is the symptom.What I tried on our side
setAttributemutations outside React's commit phase (an aria-describedby effect that directly mutated an SVG element). Moving the attribute onto the JSX as a React prop reduced the rate from ~60% to ~40% but didn't eliminate it. So at least one contributor is on the framework side; bypassing React's reconciler appears to widen the race window.userEvent.hoverto a rawdispatchEvent(didn't help).Workaround we're using
A narrow
unhandledrejectionlistener insetupTests.tsthat matches only rejections with this exact message AND a stack containingvitest-browser-react, thenevent.preventDefault()s them. Real test failures still surface. Comment explicitly references this issue and contains the deletion criteria once it's fixed upstream.Expected behaviour
render()should not emit asynchronous unhandled rejections when called normally — the implicit cleanup between tests should fully settle before the next test's render begins. If the issue is inselectorEngine.generateSelectorSimplereceiving a stale/null element, it should fail with a descriptive error fromrender()'sawaitrather than escaping as an unhandled rejection.Guesses (low-confidence, just to help triage)
e.includeslocation suggestsselectorEngine.generateSelectorSimpleis calling.includeson an element property — possiblyelement.tagName,element.className,element.id, or an attribute value — that'sundefinedbecause the element was detached/torn down by the previous test's cleanup before the newelementLocatorcall.mountedContainers/mountedRootEntriesaccounting inpure-C_qo4W4L.jsmight be involved if cleanup awaits don't fully settle in webkit; multiple containers from prior tests may have inconsistent state whenelementLocatoris called on a new one.