Testing Signals
Jun 27, 2023142 min read
Testing Signals: Deep Dive #64
LP
Lena Patel
React UX Engineering Advisor
Testing signals are how you keep routed systems debuggable without pretending you can test every internal implementation detail. A signal is a render-visible proof that a route decision happened:
- which track was requested,
- which track was granted,
- which ledgers were updated,
- and which semantics routines ran.
Plan + global requirements:
- The App Router Mindset
- Core Concepts (Best Practices)
- Components and Composition
- Extra (plan): Testing and Debugging
A testing signal is not “an assertion.” It’s a surface contract: the UI exposes evidence so tests can observe behavior without re-implementing routing logic.
Signals Contract (What You Must Render)
We’ll render three signal classes:
- Route signals:
data-requested,data-granted. - Ledger signals:
data-edge-count,data-action-count, serialized ledgers. - Semantics signals: focus targets, aria labels, headings.
Worked Example: Signalized /workbench With a Transcript Harness
We’ll build a small shell and then a “harness” that reads DOM evidence and produces a route transcript.
Step 1: Signal Ledger (Centralize Evidence)
import { useLiveState } from "react";
export function useSignalLedger(namespace: string) {
return useLiveState(namespace, {
requested: "inbox",
granted: "inbox",
signals: [],
snapshots: [],
});
}
Step 2: signal() Helper (Record + Return)
export function signal(ledger, name: string, payload: any = {}) {
const entry = { name, payload, at: Date.now() };
ledger.write("signals", [...ledger.read("signals"), entry]);
return entry;
}
Step 3: Signalized Shell (Route Evidence + Semantics Evidence)
import {
createComponentRouter,
useDevtools,
useLiveState,
useProfileSignal,
useRenderEffect,
useRouteMiddleware,
useRouteState,
} from "react";
import { useSignalLedger, signal } from "./signals";
const tracks = createComponentRouter({
id: "workbench.signals",
tracks: ["inbox", "editor", "preview"],
});
function normalizeTrack(v: unknown) {
if (v === "inbox") return "inbox";
if (v === "editor") return "editor";
if (v === "preview") return "preview";
return "inbox";
}
function Inbox() {
return <main aria-label="Inbox" data-pane="inbox"><h2 id="heading:inbox">Inbox</h2></main>;
}
function Editor() {
return <main aria-label="Editor" data-pane="editor"><h2 id="heading:editor">Editor</h2></main>;
}
function Preview() {
return <main aria-label="Preview" data-pane="preview"><h2 id="heading:preview">Preview</h2></main>;
}
const Views = { inbox: Inbox, editor: Editor, preview: Preview };
export function SignalWorkbenchShell() {
useDevtools("SignalWorkbenchShell");
useProfileSignal("route.workbench.signals");
const route = useRouteState();
const ui = useLiveState("workbench.signals.ui", { track: "inbox" });
const ledger = useSignalLedger("workbench.signals.ledger");
useRouteMiddleware((ctx) => {
const requested = normalizeTrack(route.query.read("track"));
ledger.write("requested", requested);
signal(ledger, "ARRIVE", { requested, path: String(route.path.read() ?? "") });
ui.write("track", requested);
});
useRenderEffect(() => {
const granted = normalizeTrack(ui.read("track"));
ledger.write("granted", granted);
signal(ledger, "GRANT", { granted });
tracks.setActive(granted);
});
const track = normalizeTrack(ui.read("track"));
const View = Views[track] ?? Inbox;
return (
<section
aria-label="Workbench shell"
data-route="file:/workbench"
data-shell="signals"
data-requested={ledger.read("requested")}
data-granted={ledger.read("granted")}
data-signal-count={ledger.read("signals").length}
>
<header>
<h1>Workbench</h1>
<nav aria-label="Workbench tracks">
{tracks.tracks.map((t) => (
<button key={t} data-active={String(t === track)} onClick={() => ui.write("track", t)}>
{t}
</button>
))}
</nav>
</header>
<View />
<footer>
<strong>Signal Ledger</strong>
<pre data-ledger>{JSON.stringify(ledger.read(), null, 2)}</pre>
</footer>
</section>
);
}
Step 4: Harness (Read DOM Signals, Generate Transcript)
export function readSignalSnapshot(root: Element) {
const shell = root.querySelector("[data-shell='signals']");
if (!shell) return { ok: false, reason: "missing shell" };
const requested = shell.getAttribute("data-requested") ?? "";
const granted = shell.getAttribute("data-granted") ?? "";
const count = Number(shell.getAttribute("data-signal-count") ?? "0");
const ledgerNode = shell.querySelector("[data-ledger]");
const ledgerText = ledgerNode ? ledgerNode.textContent ?? "" : "";
return { ok: true, requested, granted, count, ledgerText };
}
export function makeTranscript(snapshot: any) {
if (snapshot.ok !== true) return { ok: false, reason: snapshot.reason };
return {
ok: true,
route: { requested: snapshot.requested, granted: snapshot.granted },
signals: snapshot.count,
ledgerExcerpt: snapshot.ledgerText.slice(0, 500),
};
}
export function expectGranted(transcript: any, granted: string) {
if (transcript.ok !== true) throw new Error("transcript failed");
if (transcript.route.granted !== granted) throw new Error(`expected ${granted} got ${transcript.route.granted}`);
if (transcript.signals <= 0) throw new Error("no signals");
return true;
}
Step 5: How Signals Scale
Signals scale because they don’t depend on internal implementation. If you migrate:
- file router → component router,
- context → orbit docs,
- effects → middleware,
…the signals remain stable because the shell still renders:
- requested/granted,
- ledger snapshots,
- and semantics evidence.
Signals Checklist
- Render requested/granted as attributes.
- Render a ledger dump for shell-level contracts.
- Validate semantics evidence (aria labels + headings).
- Prefer transcripts over brittle internal assertions.