stacks/very-happy-dom
publicClone
Push over the same URL. A password will not work: create a token under access tokens and use it in place of one.
- .config
- .github
- .vscode
- benchmark
- docs
- src
- test
- .editorconfig 147 B
- .gitattributes 12 B
- .gitignore 124 B
- benchmark.ts 27 B
- build.ts 332 B
- bun.lock 34.6 KB
- bunfig.toml 107 B
- CHANGELOG.md 28.5 KB
- CLAUDE.md 1.5 KB
- demo-compare.png 1.3 KB
- demo-compare.webp 24.8 KB
- deps.yaml 32 B
- LICENSE.md 1.1 KB
- package.json 2.5 KB
- pantry.lock 167 B
- README.md 17.2 KB
- tsconfig.json 716 B
- very-happy-dom.config.ts 0 B
- vhd 39 B

very-happy-dom
A blazingly fast, lightweight virtual DOM implementation powered by Bun. Drop-in replacement for happy-dom and jsdom in testing environments.
Features
- Comprehensive DOM - Full DOM manipulation, CSS selectors, XPath, events with bubbling/capturing
- Network APIs - Fetch, XMLHttpRequest, WebSocket, Server-Sent Events, BroadcastChannel, MessageChannel, request interception
- Browser APIs - Storage, Timers, Canvas 2D, Observers (Mutation/Intersection/Resize/Performance), Clipboard, History, Cookies, File API, IndexedDB, Web Storage
- Web Components - Custom Elements and Shadow DOM
- Framework Agnostic - Works with Bun, Vitest, or any testing framework
- jsdom-compatible - Real
JSDOMclass with.serialize(),.reconfigure(),.fromURL(),.fromFile(),.fragment(),VirtualConsole,CookieJar,ResourceLoader - happy-dom-compatible - Drop-in for
GlobalRegistrator,window.happyDOMAPI, virtual consoles - Screenshot - Pure-JS PNG/JPEG/WebP rendering + optional
Bun.WebViewreal-browser screenshots
Installation
bun add -d very-happy-domOr with npm/pnpm:
npm install --save-dev very-happy-dom
pnpm add -D very-happy-domQuick Start
import { Window } from 'very-happy-dom'
const window = new Window()
const document = window.document
document.body.innerHTML = '<h1>Hello World</h1>'
const heading = document.querySelector('h1')
console.log(heading?.textContent) // "Hello World"Testing with Bun
The simplest way — create a Window per test:
import { describe, expect, test } from 'bun:test'
import { Window } from 'very-happy-dom'
describe('MyComponent', () => {
test('renders correctly', () => {
const window = new Window()
const document = window.document
document.body.innerHTML = '<div class="container">Test</div>'
const element = document.querySelector('.container')
expect(element?.textContent).toBe('Test')
})
})Global DOM Environment
For Testing Library, React, and other frameworks that expect browser globals (document, window, etc.), either use the one-line preload subpath or call GlobalRegistrator manually.
Easiest — the /register subpath:
# bunfig.toml
[test]
preload = ["very-happy-dom/register"]You can override the URL with VERY_HAPPY_DOM_URL or HAPPY_DOM_URL env vars.
Manual — drop-in for @happy-dom/global-registrator:
// happy-dom.ts (preload script)
import { GlobalRegistrator } from 'very-happy-dom'
GlobalRegistrator.register()# bunfig.toml
[test]
preload = ["./happy-dom.ts"]That's it. All browser globals are now available in your tests:
import { test, expect } from 'bun:test'
import { screen, render } from '@testing-library/react'
import { MyComponent } from './MyComponent'
test('renders correctly', () => {
render(<MyComponent />)
expect(screen.getByTestId('my-component')).toBeInTheDocument()
})Migrating from happy-dom
One-line change — the GlobalRegistrator API is the same:
-import { GlobalRegistrator } from '@happy-dom/global-registrator'
+import { GlobalRegistrator } from 'very-happy-dom'
GlobalRegistrator.register()Advanced Usage
Browser Context
import { Browser } from 'very-happy-dom'
const browser = new Browser()
const context = browser.createContext()
const page = context.newPage()
page.goto('https://example.com')Request Interception
import { Window } from 'very-happy-dom'
const window = new Window()
window.interceptor.addInterceptor({
onRequest: (request) => {
if (request.url.includes('/api/')) {
return new Response(JSON.stringify({ mocked: true }))
}
return request
}
})Custom Window Configuration
import { Window } from 'very-happy-dom'
const window = new Window({
url: 'https://example.com',
width: 1920,
height: 1080,
settings: {
navigator: {
userAgent: 'MyCustomUserAgent/1.0'
},
device: {
prefersColorScheme: 'dark'
}
}
})Event Handling
const window = new Window()
const document = window.document
const button = document.createElement('button')
let clicked = false
button.addEventListener('click', () => {
clicked = true
})
button.click()
console.log(clicked) // trueStorage APIs
const window = new Window()
// localStorage
window.localStorage.setItem('key', 'value')
console.log(window.localStorage.getItem('key')) // "value"
// sessionStorage
window.sessionStorage.setItem('session', 'data')Observers
const window = new Window()
const document = window.document
// MutationObserver
const observer = new window.MutationObserver((mutations) => {
mutations.forEach((mutation) => {
console.log('DOM changed:', mutation.type)
})
})
observer.observe(document.body, {
childList: true,
attributes: true,
subtree: true
})
// IntersectionObserver
const io = new window.IntersectionObserver((entries) => {
entries.forEach((entry) => {
console.log('Visibility changed:', entry.isIntersecting)
})
})
// ResizeObserver
const ro = new window.ResizeObserver((entries) => {
entries.forEach((entry) => {
console.log('Size changed:', entry.contentRect)
})
})Canvas API
const window = new Window()
const document = window.document
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
ctx.fillStyle = 'red'
ctx.fillRect(0, 0, 100, 100)
ctx.strokeStyle = 'blue'
ctx.strokeRect(10, 10, 80, 80)
// Export canvas data
const dataUrl = canvas.toDataURL()
const blob = await canvas.toBlob()Performance
vs happy-dom vs jsdom
| Operation | very-happy-dom | happy-dom | jsdom | Faster by |
|---|---|---|---|---|
| Window Creation | 4.08 µs | 92.83 µs | 1.22 ms | 22.7x |
| createElement | 463.02 ns | 2.62 µs | 4.67 µs | 5.7x |
| createElement + setAttribute | 748.35 ns | 15.41 µs | 6.62 µs | 8.8x |
| innerHTML (medium) | 41.61 µs | 47.48 µs | 168.98 µs | 1.1x |
| innerHTML (large, 200 nodes) | 1.92 ms | 3.72 ms | 6.27 ms | 1.9x |
| querySelector by ID | 81.03 ns | n/a | 2.76 µs | 34.1x |
| querySelector by class | 242.20 ns | n/a | 3.52 µs | 14.5x |
| querySelectorAll (200 matches) | 66.44 µs | n/a | 66.55 µs | ~1x |
| querySelectorAll + iteration | 76.44 µs | n/a | 170.37 µs | 2.2x |
| appendChild (single) | 1.70 µs | 4.58 µs | 6.14 µs | 2.7x |
| appendChild (1000 children) | 852.90 µs | 1.54 ms | 4.45 ms | 1.8x |
| setAttribute | 124.66 ns | 2.64 µs | 1.43 µs | 11.5x |
| getAttribute | 2.18 ns | 28.85 ns | 194.98 ns | 13.2x |
| classList.add | 3.97 µs | 6.88 µs | 4.87 µs | 1.2x |
| addEventListener + dispatch | 2.67 µs | 5.43 µs | 3.65 µs | 1.4x |
| textContent set | 470.48 ns | 1.72 µs | 4.67 µs | 3.7x |
| cloneNode (deep) | 6.16 µs | 21.59 µs | 15.55 µs | 2.5x |
| style.setProperty | 490.62 ns | 4.20 µs | 4.64 µs | 8.6x |
| Build data table (50x5) | 519.30 µs | 754.42 µs | 2.89 ms | 1.5x |
| Update list items (100) | 454.98 µs | n/a | 2.41 ms | 5.3x |
Note: "Faster by" compares very-happy-dom to the next-fastest result. Benchmarks run on Apple M3 Pro with Bun 1.3.11. Run them yourself:
bun run benchMigration
From happy-dom
One-line change — the API is compatible:
// Before
import { Window } from 'happy-dom'
// After
import { Window } from 'very-happy-dom'From jsdom
very-happy-dom ships a jsdom-compatible JSDOM class — the exact idiom works unchanged:
// Before (jsdom)
import { JSDOM } from 'jsdom'
// After (very-happy-dom) — one-line change
import { JSDOM } from 'very-happy-dom'
// or: import { JSDOM } from 'very-happy-dom/jsdom'
const dom = new JSDOM('<!DOCTYPE html><html><body><h1>Hi</h1></body></html>', {
url: 'https://example.com/',
runScripts: 'dangerously',
})
const { window } = dom
const { document } = window
dom.serialize() // full HTML string
dom.reconfigure({ url: '...' }) // change URL mid-test
JSDOM.fragment('<p>x</p>') // DocumentFragment
await JSDOM.fromFile('./page.html') // parse a local file
await JSDOM.fromURL('https://x.test') // fetch + parseFull surface: JSDOM, VirtualConsole, CookieJar, ResourceLoader — each with the same method names and overloads as jsdom.
See the drop-in compatibility guide for the complete migration reference.
API Reference
Core Classes
- Window - Main window/global object with all browser APIs
- Document - DOM document with querySelector, createElement, etc.
- Element - DOM elements with full manipulation API
- Browser - Browser instance for advanced scenarios
- BrowserContext - Isolated browser contexts
- BrowserPage - Individual pages with navigation
Supported APIs
Click to expand full API list
DOM
- Document, Element, TextNode, CommentNode, DocumentFragment
- Attributes, ClassList (iterable,
toggle(x, force),replace), Style, dataset innerHTML,outerHTML(getter + setter),insertAdjacentHTMLdocument.readyStatelifecycle (loading → interactive → complete) +DOMContentLoaded+loaddocument.cookieread/write,document.titlelive getter/setter,document.parentWindowalias
Selectors
querySelector/querySelectorAll,matches,closestgetElementById/getElementsByClassName/getElementsByTagName/getElementsByTagNameNS- Full CSS selectors — combinators, attribute selectors (quoted + unquoted),
:not,:is,:where,:has,:nth-child, etc. - XPath (
document.evaluate,XPathEvaluator,XPathResult)
Events
addEventListener/removeEventListenerwith{ once, passive, capture, signal }- Bubbling, capturing,
stopPropagation,stopImmediatePropagation - Full event classes:
Event,CustomEvent,MouseEvent,KeyboardEvent,PointerEvent,TouchEvent,WheelEvent,InputEvent,FocusEvent,SubmitEvent,DragEvent,ClipboardEvent,AnimationEvent,TransitionEvent,CompositionEvent,ProgressEvent,MessageEvent,CloseEvent,StorageEvent,PopStateEvent,HashChangeEvent,ErrorEvent,MediaQueryListEvent - Focus model:
focus/blur+ bubblingfocusin/focusout,document.activeElementtracking
Network
fetch(),Request,Response,Headers,FormData(withnew FormData(form)populating from a form element)XMLHttpRequestwith full event handlingWebSocket(backed by Bun's native)EventSource(Server-Sent Events) — realfetch+ stream parsingBroadcastChannel,MessageChannel,MessagePortnavigator.sendBeacon()- Request Interception via
RequestInterceptor
Storage
localStorage,sessionStorage(isolated per instance)document.cookie→CookieContainerindexedDB— in-memoryIDBFactory/IDBDatabase/IDBObjectStore/IDBTransactionnavigator.storagewithestimate()/persist()/persisted()
Timers
setTimeout/clearTimeout,setInterval/clearIntervalrequestAnimationFrame/cancelAnimationFramerequestIdleCallback/cancelIdleCallbackqueueMicrotask
Observers
MutationObserver(childList, attributes, characterData, subtree, oldValue, filters)IntersectionObserver,ResizeObserverPerformanceObserverwithsupportedEntryTypes
Canvas + Screenshots
HTMLCanvasElement.getContext('2d'),toDataURL/toBlobCanvasRenderingContext2Dwith full drawing surface- Pure-JS rendering pipeline:
ScreenshotCapture,captureHtml,captureUrl,compareImages, WebP/PNG encoders - Optional
Bun.WebView-backed real-browser screenshots (useWebView: true)
Web Components + CSS
customElements.define/.get/.whenDefined, lifecycle callbacks (connected/disconnected/adopted/attributeChanged)- Shadow DOM (open + closed), event retargeting, slot support
CSSStyleSheetwithreplaceSync()parsing declarations intocssRulesdocument.adoptedStyleSheetsCSS.supports(),CSS.escape()getComputedStyle()with per-tagdisplaydefaults + common computed fallbacks
Forms
- Constraint validation:
checkValidity,reportValidity,setCustomValidity,validity,validationMessage,willValidate form.submit(),form.requestSubmit(submitter?),form.reset()new FormData(form)populates from disabled/checkbox/radio/select/file fields
Media
HTMLMediaElement.play()→ Promise,pause(),load(),canPlayType()currentTime,duration,paused,ended,volume,muted,playbackRate,readyState,networkState- Dispatches
play,playing,pause,timeupdate,volumechange,ratechange,loadstart,loadedmetadata HTMLImageElement.decode(),Element.animate()
jsdom-compatible surface
JSDOMclass with.window,.serialize(),.reconfigure(),.nodeLocation(), staticfragment/fromURL/fromFileVirtualConsolewithon/off/emit/sendTo,jsdomErrorfor uncaught exceptionsCookieJar(tough-cookie-style callback + promise API)ResourceLoader(subclassable fetch interceptor)runScripts: 'outside-only' | 'dangerously'— opt-in inline-script execution
happy-dom-compatible surface
Windowwithurl,width,height,console,settingsoptionswindow.happyDOMwithclose/abort/waitUntilComplete/setURL/setViewportGlobalRegistrator.register/unregister/registersubpath for one-line preload
Other
- Performance API +
PerformanceObserver navigator.permissions.query(),navigator.sendBeacon()- Clipboard API +
ClipboardItem - Geolocation API, Notification API
- History API (
pushState/replaceState/back/forward/go/state) - Location API (full
href/protocol/host/hostname/port/pathname/search/hash/origin+ assignment setters) - File API (
File,FileReader,FileList,Blob) URL,URLSearchParams,AbortController,AbortSignalTextEncoder,TextDecoder,ReadableStream,WritableStream,TransformStreamDOMParser,XMLSerializer,Range,Selection,NodeIterator,TreeWalker
Testing
bun test # Run all tests
bun test --coverage # Run with coverageContributing
We welcome contributions! Please see CONTRIBUTING for details.
Changelog
Please see our releases page for more information on what has changed recently.
Community
For help, discussion about best practices, or any other conversation that would benefit from being searchable:
For casual chit-chat with others using this package:
Join the Stacks Discord Server
Postcardware
Very Happy DOM is free and open-source, but we'd love to receive a postcard from you! Send one to:
Stacks.js, 12665 Village Ln #2306, Playa Vista, CA 90094, United States 🌎
We showcase postcards from around the world on our website!
Sponsors
We would like to extend our thanks to the following sponsors for funding Stacks development. If you are interested in becoming a sponsor, please reach out to us.
License
The MIT License (MIT). Please see LICENSE for more information.
Made with 💙
[codecov-href]: https://codecov.io/gh/stacksjs/very-happy-dom -->