Best Practices
Code structure
Keep a consistent order inside setup() so components read predictably:
- Variable definitions: atoms, constants, cached values
- Methods: named functions for reusable logic
- Event handlers:
ctx.on()calls - Effects:
ctx.effect()subscriptions - Return mixin: the optional object returned from setup
define("x-search")
.withProps((p) => ({ query: p.string() }))
.withRefs((r) => ({ input: r.one("input"), results: r.one("ul") }))
.setup((ctx) => {
// 1. Variables
const $filtered = computed(ctx.props.$query, (q) => filterItems(q));
// 2. Methods
function clearSearch() {
ctx.props.$query.set("");
}
// 3. Event handlers
ctx.on(ctx.refs.input, "input", (e) => {
ctx.props.$query.set(e.currentTarget.value);
});
// 4. Effects
ctx.effect($filtered, (items) => {
renderList(ctx.refs.results, tpl, {
data: items,
key: (item) => item.id,
update: (el, item) => {
el.textContent = item.name;
},
});
});
// 5. Return mixin
return { clearSearch };
});
Refs over manual selection
Prefer withRefs() for elements you always need. Use ctx.getElement() / ctx.getElements() only for dynamic queries (e.g. inside renderList() update callbacks):
// Good: static ref
.withRefs((r) => ({ trigger: r.one("button") }))
// Good: dynamic query inside renderList update
update: (el, item) => {
ctx.getElement(el, ".name").textContent = item.name;
}
Reactive state with atoms
When state changes over time, use Nano Stores atoms instead of local let variables. Atoms integrate with ctx.effect() and ctx.bind(), keeping updates declarative:
// Avoid: imperative variable + manual DOM update
let count = 0;
ctx.on(ctx.refs.btn, "click", () => {
count++;
ctx.refs.display.textContent = String(count);
});
// Prefer: atom + effect
const $count = atom(0);
ctx.on(ctx.refs.btn, "click", () => {
$count.set($count.get() + 1);
});
ctx.effect($count, (count) => {
ctx.refs.display.textContent = String(count);
});
The atom approach scales better: multiple effects can react to the same state, and the current value is always accessible via .get().
Effects over imperative handlers
When a DOM update depends on state, express it as an ctx.effect() rather than scattering updates across event handlers. Effects make the data flow explicit: state changes in one place, the DOM reacts in another:
// Avoid: updating DOM inside the handler
ctx.on(ctx.refs.toggle, "click", () => {
const next = !ctx.props.$open.get();
ctx.props.$open.set(next);
ctx.host.setAttribute("aria-expanded", String(next));
ctx.refs.body.hidden = !next;
});
// Prefer: handler changes state, effect updates DOM
ctx.on(ctx.refs.toggle, "click", () => {
ctx.props.$open.set(!ctx.props.$open.get());
});
ctx.effect(ctx.props.$open, (open) => {
ctx.host.setAttribute("aria-expanded", String(open));
ctx.refs.body.hidden = !open;
});
Components Communication
Parents pass data down through props. Children notify parents via custom events (ctx.emit() / ctx.on()). When a child needs ongoing access to parent state, use the context protocol (nanotags/context). Unrelated components share Nano Stores atoms directly.
Parent to child
The primary channel. A parent sets attributes or properties on its children, and each child reacts via its own prop stores:
// Parent sets attribute, child's $mode atom updates automatically
childEl.setAttribute("mode", "dark");
// Or via property
childEl.mode = "dark";
Child to parent
Standard DOM events. The child dispatches with ctx.emit(), the parent listens with ctx.on():
// Child
ctx.emit("tab:select", { index: 2 });
// Parent
ctx.on(ctx.refs.tabs, "tab:select", (e) => {
console.log(e.detail.index); // 2
});
Child needs parent state or API
Use the Context protocol. The parent exposes a value via provide(), descendants receive it via consume() or withContexts(). This avoids tight coupling and works regardless of DOM depth.
When components form a logical group (Tabs/Tab, Accordion/Panel), the parent provides a typed API and children declare required contexts:
import { createContext } from "nanotags/context";
type TabsAPI = { $active: WritableAtom<string> };
const tabsContext = createContext<TabsAPI>("tabs");
const XTabs = define("x-tabs").setup((ctx) => {
const $active = atom("");
tabsContext.provide(ctx, {
$active,
});
});
define("x-tab-panel")
.withProps((p) => ({ value: p.string() }))
.withContexts({ tabs: tabsContext })
.setup((ctx) => {
ctx.effect(ctx.contexts.tabs.$active, (active) => {
ctx.host.hidden = active !== ctx.props.$value.get();
});
});
withContexts() defers setup until all declared contexts resolve. For dynamic or conditional access, use consume() directly.
Siblings or unrelated components
Share a Nano Stores atom directly. Import the same store in both components and react via ctx.effect():
// shared store (plain module)
export const $theme = atom("light");
// component A
ctx.on(ctx.refs.toggle, "click", () => {
$theme.set($theme.get() === "light" ? "dark" : "light");
});
// component B
ctx.effect($theme, (theme) => {
ctx.host.dataset.theme = theme;
});
Combining patterns
You can provide a Nano Stores atom through the Context protocol so that siblings under the same parent share state without a global import:
const filterCtx = createContext<WritableAtom<string>>("filter");
define("x-filter-panel").setup((ctx) => {
const $filter = atom("");
filterCtx.provide(ctx, $filter);
});
// child A writes to the store
define("x-search-input")
.withRefs((r) => ({ input: r.one("input") }))
.withContexts({ filter: filterCtx })
.setup((ctx) => {
ctx.on(ctx.refs.input, "input", (e) => {
ctx.contexts.filter.set(e.currentTarget.value);
});
});
// sibling B reacts to changes
define("x-results-list")
.withContexts({ filter: filterCtx })
.setup((ctx) => {
ctx.effect(ctx.contexts.filter, (query) => {
// filter visible items
});
});
Context API
The Context API enables cross-component communication for parent-child relationships without tight coupling. It’s imported from the separate nanotags/context entry point (~0.4 KB).
When to use context
Use context when a child component needs ongoing access to parent state or API, not just a one-time value (use props) or a fire-and-forget notification (use events).
How it works
The protocol uses two DOM events following the Web Components Community Context Protocol:
Normal case (parent connects first):
provide()registers acontext-requestevent listener on the hostconsume()dispatches acontext-requestevent that bubbles up- The provider catches it, stops propagation, and calls the callback with the value
- The callback runs synchronously
Late provider (child upgrades before parent):
- The
consume()dispatch goes unhandled: no provider is listening yet - A lazy document-level handler stores the pending request
- When the parent’s
provide()runs, it dispatches acontext-providerevent - The document handler re-dispatches
context-requestfrom pending consumers, resolving them
This means context works regardless of element upgrade order.
provide vs consume vs withContexts
There are two ways to consume context. Prefer withContexts(); use consume() only when the context is optional.
withContexts() (declarative, preferred): declares required contexts on the builder. Setup is deferred until all contexts resolve:
define("x-tab")
.withContexts({ tabs: tabsCtx })
.setup((ctx) => {
// ctx.contexts.tabs is guaranteed to be available here
ctx.contexts.tabs.register(ctx.host);
});
Use when: the component cannot function without the context value. If a provider never appears, setup never runs and the element stays inert.
consume() (imperative): requests context inside setup. The callback runs when/if the context resolves:
define("x-widget").setup((ctx) => {
// Setup runs immediately, context is optional
tabsCtx.consume(ctx, (tabs) => {
tabs.register(ctx.host);
});
});
Use when: the context is optional; the component should still function without it, or you need to handle the “no provider” case yourself.
Context consumers registered via consume() are automatically cleaned up on disconnect: pending requests are removed from the document-level queue. Providers remove their context-request listener on disconnect.
TypeScript
Both patterns below use TypeScript global augmentation to extend built-in DOM interfaces.
Augmenting HTMLElementTagNameMap
Register your element so that refs (r.one()/r.many()), ctx.getElement(), ctx.getElements(), and standard DOM APIs (querySelector, createElement) return properly typed instances:
declare global {
interface HTMLElementTagNameMap {
"x-my-el": InstanceType<typeof MyEl>;
}
}
const MyEl = define("x-my-el").withProps(/* ... */).setup(/* ... */);
This also enables typed ref lookups in other components:
r.one("x-my-el"); // typed as InstanceType<typeof MyEl>, validated at runtime
Typed custom events
Use TypedEvent to define type-safe events, then augment HTMLElementEventMap so that ctx.on(), ctx.emit(), and addEventListener are fully typed:
import type { TypedEvent } from "nanotags";
type SelectionChangeEvent = TypedEvent<
InstanceType<typeof XListBox>,
{ selected: string[] }
>;
declare global {
interface HTMLElementEventMap {
"listbox:change": SelectionChangeEvent;
}
}
// Emit (inside x-listbox setup):
ctx.emit("listbox:change", { selected: ["a", "b"] });
// Listen (anywhere in the app):
ctx.on(listboxEl, "listbox:change", (e) => {
e.target; // XListBox instance
e.detail.selected; // string[]
});
Combining both augmentations
For a complete component definition, declare both the element and its events together:
import { define } from "nanotags";
import type { TypedEvent } from "nanotags";
type TabsChangedEvent = TypedEvent<
InstanceType<typeof XTabs>,
{ index: number }
>;
declare global {
interface HTMLElementTagNameMap {
"x-tabs": InstanceType<typeof XTabs>;
}
interface HTMLElementEventMap {
"tabs:changed": TabsChangedEvent;
}
}
const XTabs = define("x-tabs")
.withProps((p) => ({ active: p.string("") }))
.setup((ctx) => {
// ...
});
Attachments
Attachments are reusable functions that receive the setup context (ctx) and wire up behavior—effects, event listeners, cleanup—without creating a new component.
Unlike regular helper functions, attachments are lifecycle-aware: because they receive ctx, everything they register via ctx.on(), ctx.effect(), or ctx.onCleanup() is automatically cleaned up when the host component disconnects. A plain helper that calls addEventListener would leak listeners; an attachment never does.
Attachments also compose naturally with the context protocol. An attachment can call consume() to access ancestor state, or accept a context value as a parameter, letting you build reusable behaviors (keyboard navigation, drag handling, focus traps) that participate in the component tree without being components themselves.
Writing your own
An attachment is just a function, no special API needed. Follow these conventions:
- Accept
ctx: SetupContext<{}, {}>as the first parameter, so it composes with any component - Use
ctx.on(),ctx.effect(),ctx.onCleanup()for auto-cleanup - Accept configuration via additional parameters or an options object
- Optionally return state or methods for the calling component
export function attachClickOutside(ctx: SetupContext<{}, {}>, callback: () => void) {
ctx.on(document, "click", (e) => {
if (!ctx.host.contains(e.target as Node)) callback();
});
}
Example: roving focus
Arrow-key navigation through a group of focusable elements:
export function attachRovingFocus(
ctx: SetupContext<{}, {}>,
container: HTMLElement,
items: HTMLElement[],
options: { onFocus?: (el: HTMLElement) => void } = {},
) {
function setActive(index: number) {
items.forEach((item, i) => {
item.setAttribute("tabindex", i === index ? "0" : "-1");
});
}
setActive(0);
ctx.on(container, "keydown", (e) => {
const current = items.indexOf(document.activeElement as HTMLElement);
if (current === -1) return;
let next = -1;
if (e.key === "ArrowRight") next = (current + 1) % items.length;
if (e.key === "ArrowLeft")
next = (current - 1 + items.length) % items.length;
if (e.key === "Home") next = 0;
if (e.key === "End") next = items.length - 1;
if (next !== -1) {
e.preventDefault();
setActive(next);
items[next].focus();
options.onFocus?.(items[next]);
}
});
}
Usage:
define("x-tabs")
.withRefs((r) => ({ tablist: r.one("div"), tabs: r.many<HTMLElement>("[role=tab]") }))
.setup((ctx) => {
attachRovingFocus(ctx, ctx.refs.tablist, ctx.refs.tabs, {
onFocus: (el) => activate(el.dataset.value),
});
});
Testing
nanotags/testing helps you test components in jsdom or happy-dom, with any test runner. It builds the markup a page would have, connects the component, and gives you typed access to its refs. The Testing API lists every helper; this section shows how to use them.
What to test
You have two options, and you can pick per component:
- Test the logic on its own. If a component is mostly glue, move the real logic into a plain module next to it and test that module with ordinary unit tests. The component file stays a thin layer of DOM wiring that the type checker already covers.
- Mount the component and test its behaviour. If what matters is how the component reacts in the DOM (reading its initial data, updating when a prop changes, reusing list items, cleaning up on disconnect), mount it and assert what the DOM shows. That is what
nanotags/testingis for.
ui/Chart/
components.ts define() + refs + one effect
utils.ts deriveChartBars(...) <- plain logic, easy to test
utils.test.ts
Setup
The helpers need a DOM but do not ship one, so install jsdom or happy-dom and tell your runner to use it. Only test files import nanotags/testing, and no runtime entry does, so it stays out of your browser bundle.
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { environment: "jsdom" },
});
Your first test
import { afterEach, expect, it } from "vitest";
import { cleanup, h, mount, ref } from "nanotags/testing";
// Registers x-char-count and adds it to HTMLElementTagNameMap.
import "./components";
afterEach(cleanup);
it("counts characters as the user types", () => {
const markup = h(
"x-char-count",
null,
h("textarea", { "data-ref": "field", maxlength: 10 }),
h("span", { "data-ref": "count" }, "0"),
);
const host = mount(markup);
const field = ref(host, "field"); // HTMLTextAreaElement, from r.one("textarea")
field.value = "hello";
field.dispatchEvent(new Event("input", { bubbles: true }));
expect(ref(host, "count").textContent).toBe("5");
});
Step by step:
h("x-char-count", null, ...children)builds the HTML for the component. The nestedhcalls build what goes inside it: the same markup the server renders.mount(markup)puts that HTML on the page and returns the component element, called the host. The component sets up exactly as it would in the browser.ref(host, "field")finds a ref the same way the component does.afterEach(cleanup)removes everything the test added, so the next test starts clean.
Why not build the element by hand? The order is easy to get wrong. If you append the element first and set its innerHTML after, the component has already looked for its refs, found nothing, and thrown. mount builds everything first and connects it last, so the component sees the full markup, just like on a real page.
Typed tests
If the component adds itself to HTMLElementTagNameMap (see Augmenting HTMLElementTagNameMap), the helpers know its props and refs:
mountreturns the chart element forh("x-chart", ...)markup, sohost.bars = [...]needs no cast.h("x-chart", { ... })checks every prop name and value.ref(host, "list")only accepts the component’sr.onerefs and returns the element type the ref declares.
A typo becomes a compile error instead of a confusing test failure. Passing the component class instead of a tag name works the same way.
For markup the test adds itself, pass the element type: ref<HTMLElement>(host, "fixture"). A tag that is not in the map returns a plain HTMLElement.
Building markup with h
h(tag, props, ...children) works like React’s createElement, but it returns HTML. On a real page the server renders a component’s attributes, its JSON data and its inner markup. h gives you each of those:
const markup = h(
"x-calculator",
{ storageTb: 100, readsPct: undefined, presets: [{ id: "a", label: "A" }] },
CHILDREN,
);
const host = mount(markup);
| You pass | h writes |
On the page this is |
|---|---|---|
An attribute prop (storageTb) |
storage-tb="100" |
an attribute the server rendered |
A JSON prop (presets, from p.json()) |
<script type="application/json" data-prop="presets"> |
the JSON seed the server rendered |
| Children | the HTML inside the element | the component’s template |
A few rules keep test data simple:
undefinedis skipped.nullandfalseleave the attribute out. So you can pass optional values straight through, and a missing one tests the prop’s fallback.truewrites an empty attribute, likedisabled="". Numbers become strings.- Prop names are written in kebab-case, so name them after the prop:
storageTbbecomesstorage-tb. Other attributes (id,class,data-*,aria-*,viewBoxon an SVG) are written exactly as given. - Children are more
hcalls, or raw HTML strings for markup copied as is. Nestedhcalls are typed too, so a component inside the markup gets checked props of its own:
const markup = h("x-dashboard", null, h("x-chart", { bars }, CHART_CHILDREN));
mount(markup);
Property-only props ({ schema, attribute: false }) have no place in markup, and on a typed component h rejects them. Set them as properties before the component connects, as shown in Before the component connects.
The page around the component
Many components read DOM they do not own: the nav a scroll spy watches, the container a set of controls scrolls, the link that opens a modal. Mount that too, and cleanup removes it as well. mount also accepts a raw HTML string, handy for a larger piece of page copied as is.
const containerMarkup = h("div", {
"data-scroll-container": true,
"data-scroll-id": "cards",
});
const markup = h("x-scroll-controls", { scrollId: "cards" }, CHILDREN);
mount(containerMarkup);
const host = mount(markup);
expect(ref(host, "prev").disabled).toBe(true);
mount returns the first element of the markup. It inserts everything at once, so a component sees the siblings that come after it, just like on a parsed page. happy-dom is the exception: it sets up each element as it is inserted, so a component there only sees the siblings before it. If a component reads its surroundings in setup, mount the surroundings first.
To put a component inside another element, pass that element as the second argument. It must already be on the page:
const markup = h("x-cms-form", null, CHILDREN);
const dialog = mount("dialog");
const host = mount(markup, dialog);
Before the component connects
A component sets up as soon as it connects. Sometimes the test needs to do something first: replace a neighbouring element with a stub, fake layout sizes, or set a property the way a parent would. Build the element with create instead of mount. It is ready but not yet on the page. Then call connect:
const markup = h("x-share-button", null, CHILDREN);
const host = create(markup);
host.label = "Share"; // a property a parent would set before connect
connect(host); // setup runs now
Stubbing other elements
Components often talk to elements they do not own: a tooltip, a modal, another component. Do not import the real one just to make a ref resolve. Replace it with stubElement. The test stays about one component, and it does not depend on the order in which modules register their elements.
stubElement(el, shape) gives the element the members in shape:
- A value becomes a property that remembers every write. Read them with
writes(name), and the current value withget(name). - A function becomes a method that remembers every call. Read them with
calls(name). The function also runs, with the element asthis, so the stub can act like the real thing.
const modal = stubElement(ref(host, "detailsModal"), {
isOpen: false,
open() {
modal.el.isOpen = true;
},
});
ref(host, "detailsTrigger").click();
expect(modal.calls("open")).toEqual([[]]);
expect(modal.get("isOpen")).toBe(true);
modal.el is the same element, typed as if it had those members. The stubbed element can even be a registered component. Its props can be stubbed at any time, but its methods come from its setup, which would replace a stub. So stub its methods once it has connected: stubElement throws if its setup has not run yet. Reconnecting it runs setup again and brings the real methods back.
A stub records only what happens after you install it. On a mounted host, the component’s setup has already run, so its first writes are not recorded. To check them, stub before connecting:
const markup = h("x-share-button", null, CHILDREN);
const host = create(markup);
const tooltip = stubElement(ref(host, "tooltip"), { open: false });
connect(host);
expect(tooltip.writes("open")).toEqual([false]);
If the rest of the test only cares about later writes, drop these with tooltip.reset().
Events
Listen with your runner’s spy. A listener on the host goes away with it. A listener on document or window outlives the test, so remove it afterwards.
const onPicked = vi.fn<(event: CustomEvent<string>) => void>();
host.addEventListener("picked", onPicked);
ref(host, "option").click();
expect(onPicked.mock.calls.map(([event]) => event.detail)).toEqual(["a"]);
If the event has no payload and you need the state at the moment it fired, read the state inside the listener: host.addEventListener("change", () => values.push(host.value)). Reading host.value later only shows the last value.
Contexts
A component that uses withContexts waits until every context has a provider. Instead of mounting the real provider (with its own markup, refs and setup), use provideContext.
Take the tabs from Child needs parent state or API. To test x-tab-panel on its own, the test needs the same context key the component uses, so export it from the module that creates it:
// tabs/context.ts
import type { WritableAtom } from "nanostores";
import { createContext } from "nanotags/context";
export type TabsAPI = { $active: WritableAtom<string> };
export const tabsContext = createContext<TabsAPI>("tabs");
Then any element on the page can stand in for x-tabs:
import { atom } from "nanostores";
import { afterEach, expect, it } from "vitest";
import { cleanup, h, mount, provideContext } from "nanotags/testing";
import { tabsContext } from "./context";
// Registers x-tab-panel.
import "./components";
afterEach(cleanup);
it("shows the panel only while its tab is active", () => {
const markup = h("x-tab-panel", { value: "b" });
const $active = atom("a");
const tabs = mount("div");
provideContext(tabs, tabsContext, { $active });
const panel = mount(markup, tabs);
expect(panel.hidden).toBe(true);
$active.set("b");
expect(panel.hidden).toBe(false);
});
The value you provide is whatever the real provider would give: here a plain atom the test controls. The order does not matter. A component mounted before its provider waits and sets up once the provider appears, just like at runtime. cleanup removes the provider.
Without a provider the component never sets up, and it looks broken: no effects run and its methods are missing. ref and refs catch this. On a connected host that has not set up, they throw an error that names the missing provider.
Components defined in a test
Some tests need a small component of their own, such as a wrapper that runs an attachment, or a provider for a context. A tag name can only be defined once per page, and defining it again keeps the first version. So two tests that use the same name would share one component. uniqueTag returns a fresh name each time:
const tag = uniqueTag("roving");
define(tag)
.withRefs((r) => ({ tabs: r.many<HTMLElement>("[role=tab]") }))
.setup((ctx) => {
attachRovingFocus(ctx, ctx.host, ctx.refs.tabs);
});
const markup = h(tag, null, TABS_CHILDREN);
const host = mount(markup);
Disconnect and reconnect
disconnect(host) takes the host off the page and runs its cleanup. connect(host) puts it back in the same place, and setup runs again:
const markup = h("x-chart", null, CHILDREN);
const host = mount(markup);
disconnect(host);
host.bars = [{ label: "a", value: 1 }]; // nothing reacts while disconnected
expect(ref(host, "list").children).toHaveLength(0);
connect(host);
expect(ref(host, "list").children).toHaveLength(1);
Errors surface where they happen
jsdom does not throw errors from a component’s setup or cleanup. It reports them as a window error event instead, and the test carries on with a broken element and fails somewhere else much later. The helpers fix this, right at the call: mount and create rethrow constructor errors, mount, connect and provideContext rethrow setup errors, and disconnect and cleanup rethrow cleanup errors.
cleanup always removes everything before it throws, so one broken component cannot leak into the next test. If several fail, it throws them together as an AggregateError.
Upgrade order
In the browser, the order of imports decides which component sets up first. define("x-modal") sets up every <x-modal> already on the page, so if it is imported before x-modal-outlet, the outlet can call modal.close() in its setup. Swap the two imports and the outlet finds a plain element with no close method.
A parent whose setup calls into a child depends on import order, and that is a design smell, not a testing problem. If a test needs special steps to get the order right, the component will break the same way the day someone reorders the imports. Let state flow down instead: the parent provides it through a context, and the child reacts whenever it sets up. The context protocol works in either order, so a plain mount tests it.
// Coupled: the outlet's first effect calls into the modal, so x-modal must set up first.
ctx.effect($hash, (hash) => {
if (!hash) ctx.refs.modal.close();
});
// Decoupled: the outlet provides the state, and the modal follows it.
modalContext.provide(ctx, { $open: computed($hash, (hash) => hash !== "") });
define("x-modal")
.withContexts({ modal: modalContext })
.setup((ctx) => {
ctx.effect(ctx.contexts.modal.$open, (open) => {
// show or hide the dialog
});
});
mount sets up the outer component first and its children after, the same as a browser. (happy-dom sets up the children first, so a coupled test that passes there can still fail in a browser.) For a coupled component you cannot change yet, connect the child on its own first:
const modalMarkup = h("x-modal", { "data-ref": "modal" }, MODAL_CHILDREN);
const modal = mount(modalMarkup);
const host = create("x-modal-outlet");
host.append(modal);
connect(host);
Moving the modal disconnects and reconnects it, so its setup runs twice. If the child is not registered at all, stub its methods with stubElement instead.
DOM differences
jsdom and happy-dom are not browsers, and some gaps matter for component tests. Checked against jsdom 29 and happy-dom 20:
| jsdom 29 | happy-dom 20 | |
|---|---|---|
dialog.showModal() / show() / close() |
missing | works, close event fires |
ResizeObserver, IntersectionObserver |
missing | present |
matchMedia |
missing | present |
element.scrollTo |
missing | present |
showPopover(), element.animate |
missing | missing |
Layout (clientWidth, getBoundingClientRect) |
always 0 |
always 0 |
| Setup order of nested components | outer first, like a browser | inner first |
| A component whose attribute prop has no fallback | constructed with its attributes, like a browser | constructed before its attributes land, so the prop’s schema rejects the missing value |
Error thrown in attributeChangedCallback |
window error event |
thrown from setAttribute |
Error thrown in connectedCallback or disconnectedCallback |
window error event |
thrown from the insertion or removal |
The helpers handle the last row for you (see Errors surface where they happen). The rest you fill in yourself, in a beforeAll. Keep each shim as small as what the component actually uses, and keep all of them in one shared module so every test gets the same fake.
// test/stubs/dialogMethods.ts
// jsdom ships <dialog> without showModal/show, so any component driving a real dialog is
// untestable out of the box. This installs the minimum the spec guarantees and components
// rely on: `open` reflects visibility, and close() fires a non-bubbling "close" event once.
function open(this: HTMLDialogElement) {
this.open = true;
}
function close(this: HTMLDialogElement) {
if (!this.open) return;
this.open = false;
this.dispatchEvent(new Event("close"));
}
export function installDialogMethods() {
HTMLDialogElement.prototype.showModal = open;
HTMLDialogElement.prototype.show = open;
HTMLDialogElement.prototype.close = close;
}
An observer shim wants a handle back, so the test can decide what the callback sees:
// test/stubs/intersectionObserver.ts
import { vi } from "vitest";
export function installIntersectionObserver() {
const created: FakeIntersectionObserver[] = [];
class FakeIntersectionObserver {
targets: Element[] = [];
disconnected = false;
callback: IntersectionObserverCallback;
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
created.push(this);
}
observe(target: Element) {
this.targets.push(target);
}
unobserve(target: Element) {
this.targets = this.targets.filter((el) => el !== target);
}
disconnect() {
this.disconnected = true;
}
/** Drive the component: hand it the entries a real observer would. */
trigger(entries: Partial<IntersectionObserverEntry>[]) {
this.callback(entries as IntersectionObserverEntry[], this as never);
}
}
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
return { latest: () => created.at(-1) };
}
Layout has no shim worth writing. Define the geometry the component reads, on the element it reads it from:
Object.defineProperty(container, "scrollWidth", {
value: 900,
configurable: true,
});
Object.defineProperty(container, "clientWidth", {
value: 300,
configurable: true,
});
container.scrollTo = vi.fn();
If a component needs so much faked geometry that the test becomes a fiction, that behaviour belongs in an end-to-end test in a real browser instead.
Rules of thumb
- Copy the real markup, and note in a comment which template it comes from. If the test markup drifts from the template, the test passes while the component is broken.
- Assert what the DOM shows: text, attributes,
hidden, focus, and what the component wrote to a stub.nanotags/testingintentionally gives no access toctx. - Vitest moves
vi.mockabove every import, so its factory cannot use the file’s variables. Create the ones it needs withvi.hoisted. - Use
await vi.waitFor(...)for anything asynchronous. With fake timers, check the state just before and just after the timer fires. - Test against the platform, not your wrapper. A component that listens on
closest("dialog")can be tested inside a bare<dialog>, which is one reason to build on platform elements.