nucleus-dom
Test and server-render Nucleus Stack apps in Node on a DOM that behaves like the browser's.
Features
- Tests / SSR A fresh browser-like
windowper test, or one that loads page after page for a prerender - Browser parity Tree-wide
querySelectorwith:scope/:has(~ …), Invoker Commands (command/commandfor),checkVisibility(), observers that survive garbage collection - Offline site Serve a directory to
fetch()and stylesheet loads, mock/api/* - Settled pages Wait until no request, timer or animation frame is pending
- Any test runner No Vitest / Jest dependency;
installShims(globalThis)upgrades a runner's happy-dom environment - Pinned happy-dom One exact version, the one every shim is verified against
Installation
This package is available in the
npm install @excom/nucleus-domimport { /* … */ } from "@excom/nucleus-dom";Peer dependencies (0)
Packages a consumer must install alongside this one. Workspace deps are bundled.
| Package | Version |
|---|---|
|
Usage
createDom({ url, html }) opens a happy-dom window with every shim installed; dispose() closes it, stopping its timers and aborting its fetches. url defaults to http://localhost/; html is a whole document or body markup.
import { createDom } from "@excom/nucleus-dom";
const { document, dispose } = createDom({
url: "https://shop.test/cart",
html: `<button command="--open" commandfor="cart">Cart</button><dialog id="cart"></dialog>`,
});
document.getElementById("cart").addEventListener("command", (event) => console.log(event.command)); // "--open"
document.querySelector("button").click();
await dispose();import { createDom } from "@excom/nucleus-dom";
const { document, dispose } = createDom({
url: "https://shop.test/cart",
html: `<button command="--open" commandfor="cart">Cart</button><dialog id="cart"></dialog>`,
});
document.getElementById("cart").addEventListener("command", (event) => console.log(event.command)); // "--open"
document.querySelector("button").click();
await dispose();The window never loads script files (<script src> still fires load), never evaluates page scripts and never navigates its main frame. More options:
viewportsizesinnerWidth/innerHeightandmatchMedia():{ width, height }, 1024 × 768 by defaultholdTimersAboveholds timers longer than this many ms: they never fire and never count as pending workintersectAllmakesIntersectionObserverreport every observed element in view, so lazy-loaded content renders; happy-dom's own never reportssettingstakes happy-dom settings, over the defaults above
In a test runner's happy-dom environment, install the shims once from a setup file. The environment loads your project's own happy-dom: keep it on the version this package pins, in one copy, since installShims throws on another.
// test/setup.ts — vitest.config.ts: test: { environment: "happy-dom", setupFiles: ["./test/setup.ts"] }
import { installShims } from "@excom/nucleus-dom";
installShims(globalThis);// test/setup.ts — vitest.config.ts: test: { environment: "happy-dom", setupFiles: ["./test/setup.ts"] }
import { installShims } from "@excom/nucleus-dom";
installShims(globalThis);Serve files / settle
serve() answers the window's requests from a directory, at the window's origin and offline. whenIdle() resolves once no request is in flight and no timer or animation frame is due.
import { createDom, serve, whenIdle } from "@excom/nucleus-dom";
import { expect, it } from "vitest";
it("shows the price it fetched", async () => {
const { window, document, dispose } = createDom({ url: "https://shop.test/" });
const { requests } = serve(window, "public"); // public/price.json: {"amount":7}
window.customElements.define(
"x-price",
class extends window.HTMLElement {
async connectedCallback() {
const { amount } = await (await window.fetch(this.getAttribute("src")!)).json();
this.textContent = `$${amount}`;
}
}
);
document.body.innerHTML = `<x-price src="/price.json"></x-price>`;
await whenIdle(window);
expect(document.body.textContent).toBe("$7");
expect(requests).toMatchObject([{ method: "GET", url: "https://shop.test/price.json", status: 200 }]);
await dispose();
});import { createDom, serve, whenIdle } from "@excom/nucleus-dom";
import { expect, it } from "vitest";
it("shows the price it fetched", async () => {
const { window, document, dispose } = createDom({ url: "https://shop.test/" });
const { requests } = serve(window, "public"); // public/price.json: {"amount":7}
window.customElements.define(
"x-price",
class extends window.HTMLElement {
async connectedCallback() {
const { amount } = await (await window.fetch(this.getAttribute("src")!)).json();
this.textContent = `$${amount}`;
}
}
);
document.body.innerHTML = `<x-price src="/price.json"></x-price>`;
await whenIdle(window);
expect(document.body.textContent).toBe("$7");
expect(requests).toMatchObject([{ method: "GET", url: "https://shop.test/price.json", status: 200 }]);
await dispose();
});serve(window, root, { fallback?, api?, readOnly? })also servesfallbackfor an extensionless path with no file of its own (SPA deep links), sends/api/*toapifirst (a mock backend;nullleaves the request to the files) and, withreadOnly, refuses every method but GET / HEAD with 405 beforeapisees it. Files never take writes, other origins get a network error, andrequestslogs every request with its status and the SHA-256digestof the body it got. A test runner's window works too:serve(globalThis, root)whenIdle(window, { quiet?, timeout? })needs acreateDom()window and resolves afterquiet(2) idle checks in a row. It rejects aftertimeoutms (2000) witherror.pendingnaming what is still due, and a runningsetIntervalnever goes idleresetDocument(window, { url, html?, beforeParse? })loads another page into the same window as a first visit: thecustomElementsregistry, module state andwindow/documentlisteners stay; storage, cookies and history are cleared; what the old page still has due after 50 ms is cancelled.beforeParseruns between the two pages: reset module-level state there. The new page parses whole, then each definition, in definition order, upgrades its elements in place, as a deferred script'sdefine()would: the same element objects, their attributes and children there.<template>content stays undefined until imported or inserted. A constructor or callback that throws while a page unloads or loads is reported as an uncaught error (anerrorevent on the window, and its console), and the load goes on. Unlike in a browser, every definition exists from the start: code that runs meanwhile sees later names defined and constructs the elements it creates at once, and an element moved before its turn still upgrades at its turn. Page scripts stay inert, anddocument.readyStatestays"complete"installGlobals(window)puts the window's globals onglobalThis, so browser modules imported in Node run against it, and returns the restore function. Install before importing modules that bind at import time, restore beforedispose(). While installed, a baresetTimeout/fetchin Node-side code is the window's too: take tooling timers fromnode:timersfindParseDifference(here, html)returns the first place where a browser would parsehtmlinto other elements thanhereholds, ornull.hereis the document thathtmlserializes (its doctype, thendocumentElement.outerHTML), where trees only scripts build show too (a<div>appended to a<p>, rows appended straight to a<table>), or a window that defines no element, whose parser then readshtmlas well.findParseDifference(window, "<p><x-card><section>Menu</section></x-card></p>")is{ path: "html > body > p > x-card", here: "<section>", browser: "nothing", line: 1, column: 12 }: a browser ends the<p>at<section>.unclosednames an element with no end tag that a browser reads the rest of the page into, such as<title>,<textarea>or<select>. Tags, nesting, order and attributes are compared, template content included; text, comments, namespaces and what a closed<noscript>/<select>holds are not. Without a doctype a browser parseshtmlin quirks modesameTree(html, other)istruewhen a browser parses both documents into the same tree: nodes, text and attribute values exactly, the order of attributes within a tag aside.parseTree(html)returns that tree as plain data:{ tag, attributes, children },{ text },{ comment },{ doctype }, a<template>'s content as its children
Shims
installShims(window) applies all nine, each also exported on its own. Repeat calls do nothing. Without them, happy-dom:
supportSelectorsanswers selectors unlike a browser: it matches only the first compound of a complex selector inside:not()/:is()/:where()/:has()(ul:not(ul ul)never matches), throws on:has(~ …), drops and misorders the matches of+/~in queries, matcheselement.querySelector(All)inside the element only (list.querySelector("main li")misses whenmainis an ancestor), keepsmatches()/closest()answers that a change to a sibling, a descendant or a farther ancestor made stale, and gets many pseudo-classes (:empty,:defined,:lang(), form states), attribute flags and escaped names wrong; shimmed,matches(),closest()and thequerySelector(All)()of elements, documents, fragments and shadow roots answer as Chrome does, afresh on every call, and an invalid selector, an unknown pseudo-class included, throws aSyntaxErrorDOMException. Dialogs too::modalholds fromshowModal()untilclose(), andshow()on a modal dialog, orshowModal()on one thatshow()opened or that is in no document, throwsInvalidStateErrorpinMutationObserverssilences aMutationObserverat the first garbage collection afterobserve()installCommandShimignores<button command commandfor>clicks and has nobutton.command/button.commandForElementinstallMissingApishas noelement.checkVisibility()(here alwaystrue: no layout), noServiceWorkerContainerand nocodeon aDOMExceptionupgradeClonesupgrades only the elements connected whendefine()runs, and calls the definition's callbacks on the rest; shimmed, an undefined element gets no callbacks,document.importNode()upgrades what it imports with the importing document's definitions, and an insertion upgrades what it connects, in tree order, so<template>content renders working elementskeepFormParentsparents the controls attached together with a<form>/<select>(a rendered template, a moved subtree) to the wrong object, soform.contains(input),input.parentNodeandinput.closest("form")fail; shimmed, the form / select stays their parentsupportTableTemplatesmoves a<template>written inside a table (table,tbody,tr, …) out of it and spills its rows in; shimmed, it stays where it is written, rows and cells inside. It patches happy-dom's parser, so every window in the process gets it, and throws when the window runs another copy of happy-domignoreStrayMarkuplets a stray end tag close elements out of its reach, past a<template>, or past a<div>for a</span>or a custom element's end tag, which ends a view's<div>early; it also splits text at a>between tags and repeats the text before a-->written outside a comment (a -->readsa a -->). Shimmed, such an end tag closes nothing, and the>/-->stays in its text node. It patches the parser assupportTableTemplatesdoes, with the same throwkeepEventPathsreads the publicparentNodegetter of every ancestor on each dispatch, so a spy on that getter counts events; shimmed,composedPath()returns the same path without reading it
Still happy-dom's own:
:hover,:active,:autofill,:user-valid,:popover-open,:fullscreen,:playingand:state()never match: it tracks none of these states&, a comment or a namespace prefix (svg|rect) in a selector throws itsSyntaxError, not aDOMExceptiondefine()for a tag already in the document replaces each such element with a new instance, withoutattributeChangedCallbackfor its attributes, andgetElementById()keeps returning the old one: define elements before the markup that uses them- A stray end tag still closes past an element a browser may already have closed (
<p>,<li>, a heading,<button>,<select>,<form>, table parts), as does a formatting end tag past a block (</b>past a<div>) and any end tag inside<math>,<title>or<textarea>; text after an ignored end tag is a Text node of its own - The parser does not reopen formatting elements (
<p><b>x</p>y), insert the element a stray</p>/</br>stands for, drop table parts outside a table, or build<foreignObject>content and MathML in a browser's namespaces
Attributes
Role —option is configurable, state
is managed by the element (read-only), or hybrid which is both.
Provision
Thisprovision property lives on the DOM node.
Read / watch it with Quark’s prop("provision"), or listen
for the neutron-provision event from app JS.
Events
Type
command event (HTML Command API): from a
<button command commandfor>, an <event-handler
command-name>, or a CommandEvent.
Recognized Elements
Child or descendant elements are recognized by and relevant to its functionality.Styles
@custom-selector synonyms so custom elements can share
semantic styles. Element aliases match the host tag / .tag-* class; state
aliases match attributes (nested under the element alias).
Release notes (1)
0.2.0
- Add server rendering and whole-page test support:
serve()(each request it answers carries adigestof the body),whenIdle(),resetDocument(),installGlobals(), and thecreateDom()optionsviewport,holdTimersAbove,intersectAllandsettings - Add
supportSelectors(part ofinstallShims()):matches(),closest()andquerySelector(All)()answer as Chrome does, including complex selectors inside:not(),:is(),:where()and:has(), sibling combinators, structural and form pseudo-classes,:empty,:defined, attribute flags and escapes - Remove
scopeQueriesToDocument:supportSelectorsdoes its work - Add
findParseDifference(),parseTree()andsameTree(): the tree a browser builds from markup, where happy-dom's tree departs from it, and whether two documents are the same tree - Add shims for cloned template content (
upgradeClones),<template>inside tables (supportTableTemplates), the parents of controls attached with a<form>or<select>(keepFormParents), event paths (keepEventPaths) and stray markup (ignoreStrayMarkup: a stray end tag no longer closes elements a browser leaves open, and text holding-->or>is no longer repeated or split);installMissingApisaddsDOMException#code
Release notes
e.preventDefault() is not
synchronously called on the event.
all: revert-layer.
Valence.css ships this as .unstyled and .unstyled-all for the entire tree.