Two things make a component test harder than a function test. Hyperscript batches renders: a state update queues one instead of rendering at once. And effects run after paint. @fiberfx/hyperscript/test-utils puts both under a test's control. Two more entry points help beside it: ./inspect, which reports what the renderer does, and ./debug, which adds development checks.
act
act(callback) runs callback and then flushes: every queued render and every pending effect, again and again until nothing is left. When act returns, the DOM shows the settled result.
import { describe, expect, it } from "vitest";
import { render } from "@fiberfx/hyperscript";
import { useEffect, useState } from "@fiberfx/hyperscript/hooks";
import { act } from "@fiberfx/hyperscript/test-utils";
function Greeting() {
const [name, setName] = useState("…");
useEffect(() => setName("Mercury"), []);
return <p>Hello, {name}</p>;
}
describe("Greeting", () => {
it("shows the name its effect sets", () => {
const container = document.createElement("div");
act(() => render(<Greeting />, container));
expect(container.textContent).toBe("Hello, Mercury");
});
});
- A synchronous callback is flushed before
actreturns. You don't have toawaitit, though it returns a promise. - An async callback is flushed when its promise settles. Then
await act(async () => { … }). - Nested calls flush once, when the outermost
actreturns.
act takes over the renderer's frame and render-queue slots while it runs, and puts the previous ones back when it is done.
Controlling the render queue
setupRerender() makes the render queue wait: a state update queues its render, and nothing renders until you call the function it returns. Use it to look at the DOM before and after a single update. teardown() drains what is left and restores the queue to normal; call it after each test that used setupRerender.
import { render } from "@fiberfx/hyperscript";
import { useState } from "@fiberfx/hyperscript/hooks";
import { setupRerender, teardown } from "@fiberfx/hyperscript/test-utils";
let increment = () => {};
function Counter() {
const [count, setCount] = useState(0);
increment = () => setCount(count + 1);
return <b>{count}</b>;
}
const container = document.createElement("div");
const rerender = setupRerender();
render(<Counter />, container);
increment();
// container.textContent is still "0": the render is queued
rerender();
// container.textContent is now "1"
teardown();
Hyperscript's own ./inspect suite drives a counter this way.
onCommit and onDiff
Two listeners report what the renderer did. Each returns a function that removes it, and calling that function twice is harmless.
onCommit(listener)callslistener(root)after each commit, once per root that was diffed, with the DOM up to date.onDiff(listener)callslistener()inside the renderer after each node it diffs. It is for tests only: a throw from the listener goes into the render and stops it where it is, so a test can put a ceiling on a loop of updates.
import { onDiff } from "@fiberfx/hyperscript/test-utils";
let diffs = 0;
const stop = onDiff(() => {
if (++diffs > 1000) throw new Error("more than 1000 diffs: an update loop?");
});
// … render and interact …
stop();
Mercury UI's test harness
Mercury UI's components are tested through fiberfx/ui/test/testing.ts, which builds the familiar render, rerender, unmount, cleanup and fireEvent on act. Its render(ui) mounts with Hyperscript's render inside act, and every fireEvent runs inside act too, so a test's next line always sees the settled DOM. The queries come from @fiberfx/testing.
Watching the renderer
@fiberfx/hyperscript/inspect reports what the renderer does as records. A tool such as a devtools panel or a profiler can use it, and so can a test.
import { inspect, inspectFailures } from "@fiberfx/hyperscript/inspect";
const app = document.getElementById("app");
if (app) {
const stop = inspect({
root: app,
phases: ["render", "error"],
fn: (record) => console.log(record.kind, record.type, record.name ?? "", record.id),
});
// … later
stop();
console.log(inspectFailures(), "subscriber calls threw");
}
- Six phases:
root,render,diffed,commit,unmountanderror.phasespicks some; leave it out for all six. An unknown phase throws aTypeErrorthat names the six. rootpicks one root: the container you passed torenderorhydrate. Leave it out to hear from every root.- A record is detached. It holds
kind,id,parentId,rootId,type(a tag,"#text","#fragment"or"#component"),name(a function component's name, elsenull) and, forerror, the thrownerror. It holds no node or component, so it survivesstructuredClone. - Ids are stable. A mounted component keeps its id across re-renders, and a remounted one gets a new id.
- An observer can't break the app. A throw from
fnis caught and counted, andinspectFailures()returns the count. The other subscribers still receive the record. - Importing it costs nothing. The first
inspectcall installs its handlers;inspectreturns the function that disposes the subscription, also reachable as.unsubscribe().
Development checks
@fiberfx/hyperscript/debug adds checks to the renderer. Importing the module turns them on: it has side effects, so import it once, in a development entry or a test setup. The package's README lists it as development warnings.
With it, a mistake that would otherwise fail obscurely gets a message:
- Thrown:
renderwith an undefined parent ("Undefined parent passed to render(), this is the second argument."), an undefined component passed tocreateElement, usually a missed export, and a hook called outside a component's render ("Hook can only be invoked from render methods."). - Logged with
console.error: two children with the samekey, and improper nesting of tables and paragraphs. Each message names the component and its owner stack.
It also exports helpers for tools: getDisplayName(vnode), getCurrentVNode(), getOwnerStack(vnode), captureOwnerStack(), setupComponentStack() and resetPropWarnings().
No file under fiberfx/ui/src, fiberfx/ui/test or fiberfx/core imports ./inspect or ./debug, or calls setupRerender: Mercury UI's own tests use act alone.