x-trace-history is BareDOM's dev-only debugger. It records every
CustomEvent dispatch, every observed attribute change, every
instance-field write, and every lifecycle callback, then surfaces them
as a navigable timeline with cause→effect navigation.
Activate by adding ?baredom-trace-history to the URL or setting
window.BAREDOM_TRACE_HISTORY = true before the app boots. When
inactive the recorder pays only a single nil-check per hook site and no
per-event overhead — there is no production cost when off.
x-trace-history ships in the same npm package as the rest of BareDOM
and as a self-hostable ES module under dist/. Pick whichever matches
how the rest of your app loads BareDOM — both paths use the same
activation signals and the same console API.
Via npm (most apps):
import "@vanelsas/baredom/x-trace-history";
Via self-hosted ES module:
<script type="module" src="/dist/x-trace-history.js"></script>
Then visit the page with ?baredom-trace-history appended to the URL.
A floating dock attaches to the right edge of the viewport, recording
every event the app produces.
See installation.md for the full set of import
paths supported by the rest of the library — the same options apply
here.
The recorder activates on any of three signals — checked in this order:
| Signal | When to use |
|---|---|
?baredom-trace-history URL parameter | Casual debugging. No code changes. |
window.BAREDOM_TRACE_HISTORY = true set before app boot | CI / E2E test harnesses; consumers that want the dock on every page. |
window.BAREDOM_TRACE_HISTORY = "raw" (or ?baredom-trace-history=raw) | Forensic mode — disables the sample-rate cap and shows state/* records by default. Use when investigating high-frequency animation components. |
The recorder pays zero per-event cost when no signal is set. The
nil-check call sites are present in the compiled code unconditionally,
but the hook atoms stay nil unless install! runs — and install!
only runs when one of the signals above is true. The dock element is
not mounted and window.BareDOM.traceHistory is not installed.
The ring buffer holds 5000 records by default. To raise the cap, set
window.BAREDOM_TRACE_HISTORY_CAPACITY to a positive integer before
app boot:
<script>
window.BAREDOM_TRACE_HISTORY = true;
window.BAREDOM_TRACE_HISTORY_CAPACITY = 20000;
</script>
When the buffer fills, the oldest record is dropped. Sessions and imports are unaffected — they have their own storage.
The dock is a floating panel mounted to <body> when the recorder
activates. Its anatomy, top to bottom:
| Region | Purpose |
|---|---|
| Toolbar | Pause / Resume · Record (start a session) · Clear · Export · Import · live record count |
| Session chips | One chip per session and one per import, plus the always-on Live view. Click to switch the timeline source. |
| Filters | Tag dropdown · view-mode toggle (Timeline / Causality) · category checkboxes (events, state, DOM, lifecycle) · axis-mode toggle (Order / Time) · full-text search |
| Timeline | One horizontal lane per component instance. Dots are coloured by category. Hover for tooltip; click to select. Lanes with overlapping events auto-render as a coloured heatmap band — see Heatmap density below. |
| Causality | (View-mode: Causality) Tree-shaped view of cause→effect rooted at the currently-selected record's highest ancestor. Click a node to scrub to it. The pane auto-scrolls so the selected node sits at the centre. |
| Splitter | Drag to resize the detail pane. |
| Detail pane | Pretty-printed JSON for the selected record, plus Caused by / Effects links. |
| Hint line | Record count, time-bounds, lane count. |
Keyboard:
In Time axis mode, lanes with overlapping events render as a coloured heatmap band instead of individual dots. This avoids the unreadable pile-up you'd otherwise get from a 60fps drag, a rapid-fire animation, or any component that emits many CustomEvents per second.
The filter bar carries a search input next to the tag dropdown. Typing into it narrows the timeline to records whose JSON-serialised form contains the typed string. Some examples of useful queries:
disabled — any record that mentions the disabled attribute or
property anywhere (events with detail.disabled, attribute writes,
lifecycle attribute-changed, etc.)."id":42 — a dispatch whose detail object carries id: 42. Quoted
fragments anchor against the JSON encoding.x-modal — every record from any x-modal instance (overlaps with
the tag dropdown; either path works).lifecycle/connected — every connected callback. Works for any
type value because the type string lives in the haystack too.The search combines with the tag dropdown and category checkboxes using AND-semantics: a record must satisfy every active filter to appear. Clearing the search input removes only the search constraint; the other filters remain.
Matching is case-insensitive — the query is lowercased on input and each record's haystack is lowercased on first access. Empty input disables the search filter entirely.
Indexed lazily. The haystack for each record is built on first
use of the search input and cached on the record itself, so the
recording hot path pays nothing for search. The first search after a
batch of new records pays one JSON.stringify + toLowerCase per
record; every subsequent keystroke reuses the cached strings.
Every method below is available at window.BareDOM.traceHistory.*
when the recorder activates. The TypeScript types are published
alongside the runtime; consumers can import type { BareDOMTraceHistory } from '@vanelsas/baredom/x-trace-history'.
| Method | Returns | Notes |
|---|---|---|
records() | TraceRecord[] | All records, oldest first. Reference-equal to the recorder's internal cache — treat as read-only. |
components() | { [id]: { tag, firstSeen } } | Stable componentId → tag map. Monotonic for the page lifetime. |
pause() | void | Stop accepting new records. |
resume() | void | Resume after pause. |
clear() | void | Drop live records and sessions. Imports survive. |
startSession() | number | Begin a bounded recording slice. Returns the new session id. |
stopSession() | void | Close the active session. |
sessions() | TraceSession[] | Metadata for every captured session. |
sessionRecords(id) | TraceRecord[] | Records inside the named session, chronological. |
export() | TraceEnvelope | Materialise the current state as a JSON-serialisable envelope. |
download() | void | Trigger a browser download of the current envelope as a .trace.json file. |
import(input, label?) | number \| null | Load a previously-exported envelope. Pass a parsed object or a JSON string. Returns the new import id, or null when malformed. |
imports() | TraceImport[] | Metadata for every loaded import. |
importRecords(id) | TraceRecord[] | Records inside the named import, chronological. |
removeImport(id) | void | Drop one import. |
?baredom-trace-history in the URL.window.BareDOM.traceHistory.download() from the console). A
.trace.json file downloads.window.BareDOM.traceHistory.import(text) with the file contents).
The imported trace appears as a chip alongside the live view.Trace files are pure JSON, validated against schemaVersion: 1. Older
or newer schema versions are rejected with a clear error.
For tiny traces (~6 KB of JSON), the viewer also accepts a base64- encoded envelope directly in the URL:
https://avanelsas.github.io/baredom/viewer.html?trace=<base64>
Encode with btoa(JSON.stringify(envelope)) and append. URL-safe
base64 (-_ in place of +/) is also accepted. Larger traces should
travel as files — most servers cap URLs around 8 KB, and base64
inflates the payload by 33%.
Privacy note: anything in the URL is visible to anyone with the link and to URL-logging proxies. Use the file-drop path for sensitive traces.
When an imported trace lands on a dock with an empty live buffer (the viewer page, or any otherwise-idle app), the dock auto-switches the view to the new import. Drag a file onto an active session with live records and the view stays put — the heuristic only kicks in when there is no active session to disturb.
The always-on recorder is convenient for ambient debugging, but high-traffic apps quickly outrun the 5000-record default buffer. Sessions are bounded slices the user explicitly captures:
const id = window.BareDOM.traceHistory.startSession();
// … reproduce the bug …
window.BareDOM.traceHistory.stopSession();
const records = window.BareDOM.traceHistory.sessionRecords(id);
Or click the Record button in the dock toolbar — it toggles between Start and Stop and the active session shows a live-dot in the session strip.
Sessions are metadata only. They name a half-open [startId, endId)
range over the ring buffer; records are filtered on demand. A session
that outlives the ring buffer's capacity will silently shed records
from its head, same as the live view.
download() and the toolbar's Export button both write a
.trace.json file with a filename like
baredom-trace-2026-05-11-143052.trace.json. The on-disk shape is
documented in x-trace-history-schema.md.
Importing accepts either a parsed object (no copy) or a JSON string (parsed before storing). Drag-drop onto the dock works too: the drop overlay appears when a file is dragged anywhere over the dock.
Imports are independent storage. They are NOT dropped by clear() —
remove them individually with removeImport(id) or the × button on
the import chip.
The dock is a self-contained custom element. There is no adapter-specific code — load the ESM module and activate via the URL flag. The four common entry points:
<script type="module" src="/node_modules/@vanelsas/baredom/dist/x-trace-history.js"></script>
import "@vanelsas/baredom/x-trace-history";
import type {
BareDOMTraceHistory,
TraceRecord,
} from "@vanelsas/baredom/x-trace-history";
// Narrow on the variant for exhaustive event-type handling.
function describe(r: TraceRecord): string {
switch (r.type) {
case "event/dispatch":
case "event/dispatch-cancelable":
case "event/dispatch-document":
return `${r.tag} ${r.eventName}`;
case "state/instance-field-set":
return `${r.tag} ${r.field} =`;
case "dom/attribute-set":
case "dom/attribute-removed":
return `${r.tag} [${r.attribute}]`;
case "lifecycle/connected":
case "lifecycle/disconnected":
return `${r.tag} ${r.type}`;
case "lifecycle/attribute-changed":
return `${r.tag} attr ${r.attribute}`;
}
}
// src/main.ts
import "zone.js";
// Side-effect import. Order matters: AFTER zone.js. Zone patches
// EventTarget.prototype.dispatchEvent during its own load; importing
// trace-history after zone means the recorder's wrapper layers on
// top of zone's patch (each layer calls through to the previous one
// via the JS prototype chain). The recorder's wrapper-stamp guard
// prevents double-installation if the module loads twice. Empirical
// end-to-end verification (does a zone-scheduled event reach the
// recorder?) is left to the smoke test-app at
// adapters/angular/test-app/ — run `ng serve` and check the live
// record count increments as you interact with the Angular-wrapped
// components.
import "@vanelsas/baredom/x-trace-history";
import { bootstrapApplication } from "@angular/platform-browser";
import { AppComponent } from "./app/app.component";
bootstrapApplication(AppComponent);
The standalone test-app at adapters/angular/test-app/ ships a
working smoke setup including a TraceHistoryPanelComponent that
shows live record count from window.BareDOM.traceHistory.records()
and a verifier button. Run ng serve from that directory and load
http://localhost:4200/?baredom-trace-history to confirm the dock
auto-mounts and records reach it from Angular-wrapped components.
// src/main.tsx
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "@vanelsas/baredom/x-trace-history"; // side effect
createRoot(document.getElementById("root")!).render(<App />);
StrictMode interaction. <StrictMode> double-mounts effects in
dev. The side-effect import at module scope runs once regardless of
StrictMode, since ES module loading is independent of React's
render lifecycle. Even if a consumer puts the import inside a
useEffect that runs twice, the dock's register! is idempotent
by design — a register-is-idempotent-test in the dock test suite
asserts triple register! produces exactly one <x-trace-history>
element, one dispatchEvent wrapper, and one custom-element
definition. The smoke test-app at adapters/react/test-app/ does
not currently enable StrictMode, so the interaction is verified at
the unit-test layer rather than the test-app layer.
The standalone test-app at adapters/react/test-app/ ships a working
smoke setup with a <TraceHistoryPanel> React component. Run
npm run dev from that directory and load
http://localhost:5173/?baredom-trace-history to confirm the dock
auto-mounts and records reach it from React-wrapped components.
For production builds, gate the import behind your bundler's dev flag so the ~22 KB gzipped module doesn't ship to users:
// Vite
if (import.meta.env.DEV) {
await import("@vanelsas/baredom/x-trace-history");
}
// Webpack / Next.js
if (process.env.NODE_ENV !== "production") {
await import("@vanelsas/baredom/x-trace-history");
}
In all cases the URL flag or window.BAREDOM_TRACE_HISTORY decides
whether the dock actually mounts. Shipping the module in production
without the flag is a no-op beyond the import cost.
Every record carries a causeId field. It points at the id of the
synchronously-enclosing dispatchEvent call's record, or is null when
the record was produced outside any active dispatch.
The detail pane shows:
causeId is null.event/dispatch*
records). Capped at 50 entries; the count shows N of TOTAL when
truncated.Clicking a link jumps the timeline selection to that record so you can walk a chain step by step.
For a graphical view of the same chain, flip the filter row's
view-mode select from Timeline to Causality. The pane
above the splitter is replaced with an SVG tree rooted at the highest
ancestor of the currently-selected record. Boxes are individual
records (tag · type), edges connect cause to effect, and the
currently-selected node is highlighted.
connectedCallback
— no enclosing dispatch frame, so causeId is null), the pane
shows a small banner above the lone node explaining that this isn't
a broken tree. Pick an event/dispatch* record (or one whose
detail pane shows a Caused by link) to see a real chain.Each record carries at most one causeId, so the causality structure
is a forest of trees rather than a general DAG — there's never a
cycle and never more than one parent per node. The view name ("DAG")
is general-correct but the algorithm is just tree layout.
causeId.el.dispatchEvent(new CustomEvent(…))
call — not just BareDOM's own helpers — establishes a cause frame.
This means third-party libraries dispatching CustomEvents on BareDOM
components are visible in the chain.click → change → input) keeps each step's
cause pointing at its immediate parent.The chain is synchronous-only by design. It breaks at any of:
setTimeout / setIntervalrequestAnimationFramePromise.then / await / microtask schedulingMutationObserver / IntersectionObserver / ResizeObserver callbacksMessageChannel.onmessage / postMessagepointerdown is not recorded, only its CustomEvent
consequences are)Records produced asynchronously have causeId: null. Async causality
requires Zone.js-style instrumentation, which is out of scope for the
current version. See docs/x-trace-history-roadmap.md (Phase 8 and
non-goals) for the deferred plan.
For a dispatch frame, the dot's timestamp is captured at frame entry (before handlers run) but the record itself is pushed at frame exit (after handlers complete). This means:
t;
the dock sorts records chronologically when filtering for display, so
scrubber stepping (Left/Right arrows) follows time order rather than
insertion order.This is purely a presentation choice — the underlying data preserves both the reserved id and the entry timestamp, so any consumer reading the JSON directly can reconstruct the chain unambiguously.
window.BareDOM.(componentId, eventName) within a 16ms window are dropped. This
keeps animation components (60fps pointermove, etc.) from saturating
the buffer. Activate with =raw to disable.The on-disk format for exported traces is documented in
x-trace-history-schema.md. It is
versioned at schemaVersion: 1; importers reject mismatched
versions.
The same schema is reflected as TypeScript declarations in
dist/x-trace-history.d.ts. TraceRecord is a discriminated union on
the type field — switching on it is exhaustive.
See x-trace-history-roadmap.md for
the phased plan. Phase 6 — consumer distribution (ESM module,
TypeScript declarations, these user docs) — is what makes the dev tool
usable outside this repo. Phase 7 ships a standalone viewer.html for
inspecting traces without the host app.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |