public/js/stations/debug.js
// Station 8: debugging (§50, §83). Errors are captured by lib/errors.js from
// page load; this station triggers them on purpose and shows what you get.
import { h, station, panel, button, ms, bytes } from "../lib/dom.js";
import { missions, missionList } from "../lib/missions.js";
import { captured, onCaptured } from "../lib/errors.js";
const leaked = []; // module scope: survives leaving the station, like a real leak
function measureWork() {
performance.mark("parse:start");
const data = JSON.parse(JSON.stringify(Array.from({ length: 50000 }, (_, i) => ({ i, s: `item ${i}` }))));
performance.mark("parse:end");
performance.mark("sort:start");
data.sort((a, b) => b.s.localeCompare(a.s));
performance.mark("sort:end");
const m1 = performance.measure("parse 50k items", { start: "parse:start", end: "parse:end", detail: { devtools: { track: "Browser Lab", color: "primary" } } });
const m2 = performance.measure("sort 50k items", { start: "sort:start", end: "sort:end", detail: { devtools: { track: "Browser Lab", color: "secondary" } } });
return [m1, m2];
}
export function mount() {
const list = h("ol", { class: "log", "aria-label": "Captured errors" });
const render = () => {
list.replaceChildren(...captured.map((c) => h("li", { "data-kind": c.kind }, `[${c.kind}] ${c.message}${c.where ? ` @ ${c.where}` : ""}`)));
const kinds = new Set(captured.map((c) => c.kind));
if (kinds.has("error") && kinds.has("unhandledrejection")) missions.complete("debug", "errors");
if (kinds.has("csp")) missions.complete("debug", "csp");
};
const off = onCaptured(render);
render();
const memOut = h("dl", { class: "kv" });
const leakInfo = h("p", { "aria-live": "polite" }, `Leaked nodes held: ${leaked.length.toLocaleString()}`);
const measureMemory = async () => {
memOut.replaceChildren(h("dt", {}, "Measuring…"), h("dd", {}, "(can take a few seconds: it waits for a garbage collection)"));
if (crossOriginIsolated && performance.measureUserAgentSpecificMemory) {
const r = await performance.measureUserAgentSpecificMemory();
memOut.replaceChildren(h("dt", {}, "Total (all realms)"), h("dd", {}, bytes(r.bytes)),
...r.breakdown.filter((b) => b.bytes).slice(0, 5).flatMap((b) => [h("dt", {}, b.types.join(", ") || "other"), h("dd", {}, bytes(b.bytes))]));
} else if (performance.memory) {
memOut.replaceChildren(h("dt", {}, "JS heap used (Chrome-only, coarse)"), h("dd", {}, bytes(performance.memory.usedJSHeapSize)));
} else {
memOut.replaceChildren(h("dt", {}, "Not available"), h("dd", {}, "this browser exposes no memory API; use DevTools → Memory"));
}
if (leaked.length) missions.complete("debug", "leak");
};
const marksOut = h("ol", { class: "log", "aria-label": "Measures" });
const el = station(
"Debugging lab",
"Production bugs rarely announce themselves. Here you trigger the common ones on purpose (uncaught errors, unhandled rejections, CSP violations, leaks, slow code) and see what the browser gives you to catch them.",
missionList("debug"),
h("div", { class: "grid-2" },
panel("Trigger something",
h("div", { class: "row" },
button("Throw an uncaught error", () => setTimeout(() => { throw new Error("Boom from a timer callback"); })),
button("Reject a promise, never catch it", () => { Promise.reject(new Error("Nobody handled me")); }),
button("Violate the CSP (eval)", () => {
try {
new Function("return 1")(); // script-src has no 'unsafe-eval'
} catch (err) {
console.info("blocked as expected:", err.name);
}
}),
button("Pause in the debugger", () => {
debugger; // only stops when DevTools is open
})),
h("p", { class: "note" }, "These go to window 'error', 'unhandledrejection' and 'securitypolicyviolation' listeners installed at page load: exactly what an error tracker hooks into.")),
panel("Captured (newest first)", list)),
h("div", { class: "grid-2" },
panel("A memory leak you can find",
h("p", {}, "Each click creates 10,000 DOM nodes, never attaches them, and keeps them in a module-level array. They can never be collected: detached DOM nodes."),
h("div", { class: "row" },
button("Leak 10k nodes", () => {
for (let i = 0; i < 10000; i++) leaked.push(h("div", { class: "leak" }, `leaked ${i}`));
leakInfo.textContent = `Leaked nodes held: ${leaked.length.toLocaleString()}`;
}, { class: "danger" }),
button("Free them", () => {
leaked.length = 0;
leakInfo.textContent = "Leaked nodes held: 0 (collectable at the next GC)";
}),
button("Measure memory", measureMemory)),
leakInfo, memOut,
h("p", { class: "note" }, "Find it: DevTools → Memory → Heap snapshot → filter \"Detached\". Expand a HTMLDivElement → Retainers shows the array called leaked in debug.js.")),
panel("User Timing: your own marks in the Performance panel",
button("Run measured work", () => {
for (const m of measureWork()) marksOut.prepend(h("li", {}, `${m.name}: ${ms(m.duration)}`));
}, { class: "primary" }),
marksOut,
h("p", { class: "note" }, "Record in DevTools → Performance, click the button, stop. Your measures appear in the Timings track, and in a custom \"Browser Lab\" track in Chrome (the detail.devtools extension)."))),
panel("DevTools recipes worth knowing",
h("table", {},
h("thead", {}, h("tr", {}, h("th", {}, "Problem"), h("th", {}, "Tool"), h("th", {}, "How"))),
h("tbody", {}, [
["Which code changed this element?", "DOM breakpoints", "Elements → right-click node → Break on → subtree/attribute modifications"],
["Log without editing code", "Logpoints", "Sources → right-click line number → Add logpoint"],
["Stop only for one bad value", "Conditional breakpoint", "right-click line number → condition like id === 'A-1002'"],
["Who sent this request?", "Initiator + fetch breakpoints", "Network → Initiator column; Sources → XHR/fetch Breakpoints"],
["Where did this async call come from?", "Async stack traces", "Call Stack panel shows the await/then chain, on by default"],
["Why is this slow?", "Performance panel", "Record, then read the flame chart bottom-up; long tasks have red corners"],
["What repaints? What shifts?", "Rendering tab", "Paint flashing, Layout Shift Regions, Core Web Vitals overlay"],
["What would break offline / on 3G?", "Network conditions", "Throttling presets, request blocking, offline mode"],
["Fix prod CSS/JS without a deploy", "Local overrides", "Sources → Overrides → select a folder, then edit and save"],
["Unused code", "Coverage", "More tools → Coverage → reload: red = never executed"],
].map((r) => h("tr", {}, r.map((c) => h("td", {}, c))))))),
);
return { el, unmount: off };
}