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.
A very performant virtual DOM implementation.
- .config
- .github
- .vscode
- benchmark
- docs
- src
- test
- .editorconfig 147 B
- .gitattributes 12 B
- .gitignore 124 B
- benchmark.ts 27 B
- build.ts 1.4 KB
- bun.lock 18.7 KB
- bunfig.toml 107 B
- CHANGELOG.md 48.0 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.6 KB
- pantry.lock 167 B
- README.md 32.5 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
Web-First Assertions
Importing very-happy-dom/matchers registers Playwright-style matchers into
bun:test. They retry until the expectation holds or the timeout expires, which
is the difference that matters:
import { expect, test } from 'bun:test'
import { Browser } from 'very-happy-dom'
import 'very-happy-dom/matchers'
test('the alert reports the save', async () => {
const page = new Browser().newPage()
await expect(page.getByRole('alert')).toContainText('Saved')
})The one-shot rewrite — expect(await page.getByRole('alert').textContent()).toContain('Saved')
— samples once, so anything that appears after a fetch settles or a microtask
drains is a race. .not waits for the opposite to become true rather than
inverting a single sample.
Checked state is read through ARIA, so a design system's
<div role="checkbox" aria-checked> answers truthfully — isChecked(),
getByRole({ checked }), check()/uncheck() and toBeChecked() all go through
one predicate. locator.checkedState() returns 'mixed' for a tri-state control,
and toBeChecked({ indeterminate: true }) asserts it. toBeChecked() refuses an
element with no checked state rather than answering false, so
.not.toBeChecked() cannot pass against a plain <div>.
Locators: toBeAttached, toBeVisible, toBeHidden, toHaveCount,
toHaveText, toContainText, toHaveValue, toHaveValues, toHaveAttribute,
toHaveClass, toHaveId, toHaveCSS, toHaveJSProperty, toHaveRole,
toHaveAccessibleName, toHaveAccessibleDescription, toBeEnabled,
toBeDisabled, toBeChecked, toBeEditable, toBeFocused, toBeEmpty.
Pages: toHaveURL, toHaveTitle. Each takes an optional { timeout },
defaulting to page.setDefaultTimeout().
toHaveRole and toHaveAccessibleName assert what getByRole queries on, which
is how a failing role query becomes diagnosable. toHaveCSS compares values as
the engine reports them, so 'red' will not match a computed 'rgb(255, 0, 0)'
— use a RegExp. toHaveJSProperty takes a path, so validity.valueMissing works.
toBeInViewport takes an optional ratio, and can answer "no" — the layout pass
gives a box a real position, which is what it was waiting on.
An element is hidden by display: none, visibility: hidden, the hidden
attribute, or a declared zero size — height: 0, max-height: 0 and the
like, which is how a closed accordion or drawer is built when the author wants a
transition. Declared is the operative word: an empty box can also mean the layout
pass had nothing to give the element a size, and a text-driven height is an
estimate, so a zero box means unknown rather than collapsed and only a
declaration is trusted. That is narrower than Playwright's
non-empty-bounding-box rule, deliberately.
Locator actions auto-wait on the same terms — present, rendered and not
disabled — and locator.waitFor({ state }) covers attached, detached,
visible and hidden. Two conditions Playwright checks are not checked here:
stable (not mid-animation), which needs an animation clock, and receives-events
(not covered by another element), which the layout pass now has the geometry for
but which nothing asserts yet.
Rendered Text
innerText returns text as rendered: hidden subtrees left out, whitespace
collapsed, block elements separated by newlines, <br> honoured. Visibility is
resolved through the cascade, so a display: none from a stylesheet is excluded —
not only an inline one. white-space: pre, pre-wrap and break-spaces keep
their text verbatim, pre-line keeps newlines, and a <pre> element counts as
pre with no rule needed.
textContent is unchanged: raw, untrimmed, uncollapsed.
document.body.innerHTML = '<p> Save changes </p>'
document.querySelector('p').textContent // ' Save changes '
document.querySelector('p').innerText // 'Save changes'Dialogs
page.on('dialog', dialog => dialog.accept()) // confirm() returns true
page.on('dialog', dialog => dialog.accept('Ridge')) // prompt() returns 'Ridge'
page.on('dialog', dialog => dialog.dismiss()) // false / nullWithout a handler every dialog is dismissed, which is Playwright's default and
what the old hardcoded false/null did — so nothing changes unless you opt in.
dialog carries type, message and defaultValue, so a test can assert which
dialog appeared, including an alert().
The decision must be made synchronously: confirm() is a synchronous DOM API,
so a handler that awaits before deciding answers after the page has already acted
on the default. That throws rather than letting the wrong answer through.
dialog.accept() records the decision immediately, so async dialog =>
await dialog.accept() is fine — it is awaiting before deciding that is not.
Combining Locators
// Pick a row by what is inside it, then act within that row
const row = page.getByRole('row').filter({ has: page.getByText('Ridge Loop') })
await row.getByRole('button', { name: 'Delete' }).click()
// Two possible outcomes, without racing them
await expect(page.getByRole('alert').or(page.getByText('Saved'))).toBeVisible()
// Only the elements both match
await page.getByRole('button').and(page.locator('.primary')).click()filter({ has }) and { hasNot } query the inner locator from each candidate,
not from the page — so a match elsewhere does not qualify a row. hasText is not
a substitute: it compares the candidate's whole text, so a neighbouring column
containing the string matches too, and it cannot ask about a role.
filter() throws on an unrecognised key. Ignoring one is how filter({ has })
used to look like it worked while matching every candidate. or() returns
document order with duplicates removed.
Media Queries
await page.emulateMedia({ colorScheme: 'dark' })
await page.emulateMedia({ media: 'print' })
await page.emulateMedia({ reducedMotion: 'reduce', forcedColors: 'active' })
await page.emulateMedia({ colorScheme: null }) // back to the browser's setting@media blocks are parsed into real CSSMediaRules and applied when their
condition holds, so a dark-mode or responsive stylesheet takes effect in
getComputedStyle, in getBoundingClientRect and in the locator queries that
read visibility. matchMedia and the cascade evaluate the query with the same
code, so they cannot disagree about the same document.
Understood features: prefers-color-scheme, prefers-reduced-motion,
forced-colors, orientation, and the width/height family with px, em
and rem. Media types (screen, print, all), and, comma lists, not and
only all work. Any other feature — (hover: hover), for instance — makes its
query false, as an unrecognised feature does in a browser.
Geometry
const box = await page.locator('.card').boundingBox() // { x, y, width, height } | nullAll four are real. The rect resolves through the cascade, so a size from a
stylesheet is reported, and there is a layout pass, so x and y are where the
box actually sits — overlap, ordering, hit testing and is-this-above-the-fold can
all be asked. null is returned for an element that is not rendered.
Block-level boxes stack down their containing block and fill its width unless
they declare one; inline and inline-block boxes flow along a line, shrink to fit
and wrap. box-sizing, margins, padding, borders, min/max sizes, percentages,
position: relative, and absolute/fixed against the nearest positioned
ancestor are all modelled, as are lengths in px, %, em, rem,
vh/vw/vmin/vmax and the absolute units — plus calc(), min(), max()
and clamp(). font-size and line-height inherit, so an em measures
against the size an element actually has.
Flexbox works: both axes and both reverses, wrapping including
wrap-reverse, justify-content, align-items, align-content, align-self,
flex-grow/shrink/basis with the flex and flex-flow shorthands, gap,
and order. Growing and shrinking respect min-* and max-*, including the
automatic minimum that keeps an item from shrinking below its content.
Adjoining vertical margins collapse, including a first or last child's
escaping its parent and an empty box collapsing through entirely; a border,
padding, a definite height, overflow other than visible, a line box, a flex or
grid container, and being a flex or grid item each stop it.
Grid works too: track lists with fr, auto, minmax() and
repeat(auto-fill, …), named areas, implicit tracks, grid-auto-flow,
placement by line number or span including negative lines, dense packing,
gap, and the justify-*/align-* family for both the tracks and the items in
them. auto margins work in flex containers, so margin-left: auto pushes.
align-items: baseline works, and inline-level boxes sit on the text baseline
of their line rather than at the top of it.
Text is measured with real font metrics — per-glyph advance widths and
kerning pairs for serif, sans-serif and monospace at both weights, which
agree with Chrome to within a tenth of a percent. The same tables carry each
font's vertical metrics, so line-height: normal is the font's own spacing and a
line box puts its baseline where the font does, both matching Chrome exactly.
Screenshots read this same layout. captureHtml and createRenderer from
very-happy-dom/screenshot used to run a separate engine that knew nothing of
flexbox, grid or stylesheets; a screenshot and a getBoundingClientRect() of the
same markup now agree.
What is not: a font outside those three families is measured as the generic one
it falls back to, so a page in Inter is measured as Arial. Chrome quantises a
line box to a half pixel and this does not, so a line height can differ by 0.1px
at 16px. An item spanning several grid tracks does not contribute to their sizes,
and named grid lines are not resolved. There is no user-agent stylesheet, so
body starts at (0, 0) rather than a browser's 8px inset. Floats, transform,
z-index ordering and intrinsic image sizes are absent.
The compatibility guide has the full table.
scrollIntoViewIfNeeded() resolves and fires a scroll event; nothing scrolls on
its own and no overflow is clipped, so there is no position to scroll to.
Setting Page Content
await page.setContent('<button>Save</button>')
await expect(page.getByRole('button')).toBeVisible()A bare fragment becomes the body; a full <!DOCTYPE html><html><head>… string
lands intact. It replaces the whole document rather than filling the body, fires
domcontentloaded and load so an assertion straight afterwards needs no wait,
and leaves the URL alone — setting content is not navigating.
Form Controls
await page.locator('#area').selectOption('north') // by value
await page.locator('#area').selectOption({ label: 'North' }) // by option text
await page.locator('#photos').setInputFiles({ name: 'trail.gpx', buffer: gpx })
await page.locator('#photos').setInputFiles('./fixtures/trail.gpx') // read from disk
await page.locator('#search').press('Control+Enter')
await page.locator('#search').clear()
await page.locator('#row').dblclick()
await page.locator('#terms').setChecked(true)selectOption fires input then change, which is the point: assigning
select.value by hand fires neither, so a component listening for it never
updates and the test passes against a DOM the app never saw. setInputFiles
builds the FileList that assigning input.files requires. Passing several
values to a non-multiple control is refused rather than silently truncated.
press focuses the element first — page.keyboard only reaches whatever already
has focus.
Keyboard
await page.keyboard.press('Control+Enter') // key 'Enter', ctrlKey true
await page.keyboard.press('Shift+a') // types 'A'
await page.keyboard.down('Shift')
await page.keyboard.press('Tab') // shiftKey true
await page.keyboard.up('Shift')Control/Ctrl, Shift, Alt/Option and Meta/Cmd/Command are
recognised, several at a time; modifiers go down in order and come up in reverse.
event.code is populated (KeyA, Digit1, ArrowDown, ShiftLeft). A key held
with Control, Alt or Meta does not insert text — Control+A selects rather than
typing an "a" — while Shift produces the capital. Only a known modifier name is
consumed, so press('Enter') and even a nonsense press('a+b') are passed
through as literal keys.
Running Code Against a Locator
const ids = await page.locator('tbody tr').evaluateAll(els => els.map(el => el.dataset.id))
const label = await page.getByRole('button').evaluate(el => el.dataset.state)
await page.locator('li').allTextContents() // raw textContent, per match
await page.locator('li').allInnerTexts() // rendered text: hidden subtrees left outevaluate() is strict and waits for the element to be attached; evaluateAll()
is neither, because many matches is its expected case and none is a legitimate
[]. The element is the real node, so writing through it changes the document.
Callbacks are recompiled against the frame's window and cannot see their
closure, the same as in Playwright — pass anything they need as the second
argument. page.$eval / page.$$eval exist for ports, but take the first match
rather than refusing an ambiguous selector.
Waiting on Page Events
const [response] = await Promise.all([
page.waitForResponse('**/api/trails'),
page.getByRole('button', { name: 'Search' }).click(),
])
expect((await response.json()).trails).toHaveLength(3)Subscribe before the action, as above. An event that has already fired is not
replayed — the same as Playwright, and it matters more here because goto()
emits synchronously and returns, so there is no window at all.
page.waitForEvent(event, predicateOrOptions)— the primitive, for any of the six page events.page.waitForResponse(urlOrPredicate)/waitForRequest(...)— a glob or RegExp matches the URL through the same matcherroute()uses; a function receives the event itself, so it can readstatustoo. Responses are reported for navigations and forfetchcalls made by page code. Requests need interception on, whichpage.route()does as a side effect.page.waitForLoadState('load' | 'domcontentloaded')— returns at once if the state has already been reached.networkidlethrows rather than pretending: nothing here tracks in-flight requests.page.waitForURL(urlOrPredicate)— polled, so apushStatecounts.
Browser Context
import { Browser } from 'very-happy-dom'
const browser = new Browser()
const page = browser.newPage()
page.goto('https://example.com')
// Or an isolated context with its own storage and cookies
const context = browser.newIncognitoContext()
const isolatedPage = context.newPage()
await browser.close()Request Interception
Interception is attached per page. Each request is finished with exactly one of
continue(), respond() or abort().
import type { InterceptedRequest } from 'very-happy-dom'
import { Browser } from 'very-happy-dom'
const browser = new Browser()
const page = browser.newPage()
page.on('request', (request: InterceptedRequest) => {
if (request.url.includes('/api/')) {
request.respond({
status: 200,
body: JSON.stringify({ mocked: true }),
})
return
}
request.continue()
})
await page.setRequestInterception(true)
const response = await fetch('https://example.com/api/thing')
console.log(await response.text()) // {"mocked":true}
await browser.close()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()
// toBlob is callback-based, as in the browser
canvas.toBlob((blob) => {
console.log(blob?.type) // "image/png"
})
// or use the promise-returning convenience wrapper
const blob = await canvas.toBlobAsync()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 -->