From 751dc1c9b3b062eb6917642d42d1a22338346987 Mon Sep 17 00:00:00 2001 From: Fred Rivett Date: Sat, 26 Sep 2026 11:43:17 +0100 Subject: [PATCH 01/15] Add debug trace store and instrumentation Bounded in-memory event timeline (no-op unless tracing) plus instrumenters for URL writes, React Query cache events, layout shifts, slow frames, masonry reflows, and component lifecycle. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/src/lib/debug/debug-flag.test.ts | 54 +++++++ app/src/lib/debug/debug-flag.ts | 65 ++++++++ app/src/lib/debug/format.test.ts | 54 +++++++ app/src/lib/debug/format.ts | 60 +++++++ app/src/lib/debug/grid-layout-diff.test.ts | 100 ++++++++++++ app/src/lib/debug/grid-layout-diff.ts | 116 ++++++++++++++ app/src/lib/debug/highlight.ts | 67 ++++++++ app/src/lib/debug/instrument-history.test.ts | 131 ++++++++++++++++ app/src/lib/debug/instrument-history.ts | 136 ++++++++++++++++ .../lib/debug/instrument-performance.test.ts | 84 ++++++++++ app/src/lib/debug/instrument-performance.ts | 131 ++++++++++++++++ .../lib/debug/instrument-query-cache.test.ts | 86 ++++++++++ app/src/lib/debug/instrument-query-cache.ts | 81 ++++++++++ app/src/lib/debug/test-utils.ts | 18 +++ app/src/lib/debug/trace.test.ts | 86 ++++++++++ app/src/lib/debug/trace.ts | 148 ++++++++++++++++++ app/src/lib/debug/use-debug-grid-observer.ts | 111 +++++++++++++ .../lib/debug/use-debug-lifecycle.test.tsx | 74 +++++++++ app/src/lib/debug/use-debug-lifecycle.ts | 82 ++++++++++ app/src/lib/debug/use-tracing.ts | 7 + 20 files changed, 1691 insertions(+) create mode 100644 app/src/lib/debug/debug-flag.test.ts create mode 100644 app/src/lib/debug/debug-flag.ts create mode 100644 app/src/lib/debug/format.test.ts create mode 100644 app/src/lib/debug/format.ts create mode 100644 app/src/lib/debug/grid-layout-diff.test.ts create mode 100644 app/src/lib/debug/grid-layout-diff.ts create mode 100644 app/src/lib/debug/highlight.ts create mode 100644 app/src/lib/debug/instrument-history.test.ts create mode 100644 app/src/lib/debug/instrument-history.ts create mode 100644 app/src/lib/debug/instrument-performance.test.ts create mode 100644 app/src/lib/debug/instrument-performance.ts create mode 100644 app/src/lib/debug/instrument-query-cache.test.ts create mode 100644 app/src/lib/debug/instrument-query-cache.ts create mode 100644 app/src/lib/debug/test-utils.ts create mode 100644 app/src/lib/debug/trace.test.ts create mode 100644 app/src/lib/debug/trace.ts create mode 100644 app/src/lib/debug/use-debug-grid-observer.ts create mode 100644 app/src/lib/debug/use-debug-lifecycle.test.tsx create mode 100644 app/src/lib/debug/use-debug-lifecycle.ts create mode 100644 app/src/lib/debug/use-tracing.ts diff --git a/app/src/lib/debug/debug-flag.test.ts b/app/src/lib/debug/debug-flag.test.ts new file mode 100644 index 00000000..da4c5090 --- /dev/null +++ b/app/src/lib/debug/debug-flag.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + applyDebugParam, + DEBUG_STORAGE_KEY, + isDebugFlagOn, + parseDebugParam, + setDebugFlag, +} from "./debug-flag"; + +describe("parseDebugParam", () => { + it.each([ + ["?debug=1", true], + ["?debug=true", true], + ["?debug=0", false], + ["?debug=false", false], + ["?debug=yes", null], + ["?item=abc", null], + ["", null], + ])("%s → %s", (search, expected) => { + expect(parseDebugParam(search)).toBe(expected); + }); +}); + +describe("debug flag", () => { + afterEach(() => { + window.localStorage.removeItem(DEBUG_STORAGE_KEY); + window.history.replaceState(null, "", "/"); + }); + + it("persists on/off in localStorage", () => { + expect(isDebugFlagOn()).toBe(false); + setDebugFlag(true); + expect(isDebugFlagOn()).toBe(true); + expect(window.localStorage.getItem(DEBUG_STORAGE_KEY)).toBe("1"); + setDebugFlag(false); + expect(isDebugFlagOn()).toBe(false); + }); + + it("applies ?debug=1 and ?debug=0 from the URL", () => { + window.history.replaceState(null, "", "/dashboard?debug=1"); + applyDebugParam(); + expect(isDebugFlagOn()).toBe(true); + window.history.replaceState(null, "", "/dashboard?debug=0"); + applyDebugParam(); + expect(isDebugFlagOn()).toBe(false); + }); + + it("leaves the flag alone when the URL has no debug param", () => { + setDebugFlag(true); + window.history.replaceState(null, "", "/dashboard?item=abc"); + applyDebugParam(); + expect(isDebugFlagOn()).toBe(true); + }); +}); diff --git a/app/src/lib/debug/debug-flag.ts b/app/src/lib/debug/debug-flag.ts new file mode 100644 index 00000000..d35479f6 --- /dev/null +++ b/app/src/lib/debug/debug-flag.ts @@ -0,0 +1,65 @@ +import { useSyncExternalStore } from "react"; + +/** + * Per-browser opt-in for the admin debug tools, persisted in localStorage so it + * survives reloads (jank is often only visible on a cold load). Toggle from the + * account menu, or via `?debug=1` / `?debug=0` on any page. + */ +export const DEBUG_STORAGE_KEY = "abode:debug"; +export const DEBUG_URL_PARAM = "debug"; + +const listeners = new Set<() => void>(); + +function readStored(): boolean { + try { + return window.localStorage.getItem(DEBUG_STORAGE_KEY) === "1"; + } catch { + return false; + } +} + +export function isDebugFlagOn(): boolean { + return typeof window !== "undefined" && readStored(); +} + +export function setDebugFlag(on: boolean): void { + try { + if (on) window.localStorage.setItem(DEBUG_STORAGE_KEY, "1"); + else window.localStorage.removeItem(DEBUG_STORAGE_KEY); + } catch { + // Storage unavailable (private mode quota etc.) — the toggle just won't persist + } + for (const listener of listeners) listener(); +} + +/** `?debug=1` → true, `?debug=0` → false, absent/other → null (leave as is). */ +export function parseDebugParam(search: string): boolean | null { + const value = new URLSearchParams(search).get(DEBUG_URL_PARAM); + if (value === "1" || value === "true") return true; + if (value === "0" || value === "false") return false; + return null; +} + +/** Apply a `?debug=` param from the current URL, if present. */ +export function applyDebugParam(): void { + const next = parseDebugParam(window.location.search); + if (next !== null && next !== readStored()) setDebugFlag(next); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + // Keep tabs in sync when toggled elsewhere + const onStorage = (event: StorageEvent) => { + if (event.key === DEBUG_STORAGE_KEY) listener(); + }; + window.addEventListener("storage", onStorage); + return () => { + listeners.delete(listener); + window.removeEventListener("storage", onStorage); + }; +} + +/** Reactive debug flag; always false during SSR/hydration. */ +export function useDebugFlag(): boolean { + return useSyncExternalStore(subscribe, readStored, () => false); +} diff --git a/app/src/lib/debug/format.test.ts b/app/src/lib/debug/format.test.ts new file mode 100644 index 00000000..a92c28b1 --- /dev/null +++ b/app/src/lib/debug/format.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + buildTraceExport, + formatTraceGap, + formatTraceSummary, + formatTraceTime, +} from "./format"; + +describe("formatTraceSummary", () => { + it("renders key=value pairs, omitting the stack", () => { + expect( + formatTraceSummary({ + to: "/dashboard", + removed: ["item"], + stack: ["at x"], + nested: { a: 1 }, + none: null, + }), + ).toBe('to=/dashboard removed=[item] nested={"a":1} none=null'); + }); + + it("truncates long summaries", () => { + const summary = formatTraceSummary({ long: "x".repeat(300) }, 20); + expect(summary).toHaveLength(20); + expect(summary.endsWith("…")).toBe(true); + }); + + it("is empty without data", () => { + expect(formatTraceSummary(undefined)).toBe(""); + }); +}); + +describe("formatTraceTime / formatTraceGap", () => { + it("formats times and gaps", () => { + expect(formatTraceTime(12345.6)).toBe("12.346s"); + expect(formatTraceGap(null)).toBe(""); + expect(formatTraceGap(12.4)).toBe("+12ms"); + expect(formatTraceGap(2500)).toBe("+2.5s"); + }); +}); + +describe("buildTraceExport", () => { + it("wraps events with page context", () => { + window.history.replaceState(null, "", "/dashboard?item=abc"); + const events = [{ id: 1, t: 1, channel: "mark" as const, event: "mark" }]; + const exported = buildTraceExport(events); + expect(exported).toMatchObject({ + url: "/dashboard?item=abc", + events, + viewport: { width: window.innerWidth, height: window.innerHeight }, + }); + expect(() => new Date(exported.capturedAt).toISOString()).not.toThrow(); + }); +}); diff --git a/app/src/lib/debug/format.ts b/app/src/lib/debug/format.ts new file mode 100644 index 00000000..c62603e1 --- /dev/null +++ b/app/src/lib/debug/format.ts @@ -0,0 +1,60 @@ +import type { TraceData, TraceEvent, TraceValue } from "./trace"; + +/** Events closer together than this are likely one causal chain */ +export const CHAIN_GAP_MS = 50; + +function formatValue(value: TraceValue): string { + if (Array.isArray(value)) return `[${value.map(formatValue).join(", ")}]`; + if (value !== null && typeof value === "object") return JSON.stringify(value); + return String(value); +} + +/** One-line `key=value` summary of an event's data for the timeline row. */ +export function formatTraceSummary( + data: TraceData | undefined, + max = 140, +): string { + if (!data) return ""; + const summary = Object.entries(data) + // Stacks are for the expanded view, not the one-liner + .filter(([key]) => key !== "stack") + .map(([key, value]) => `${key}=${formatValue(value)}`) + .join(" "); + return summary.length > max ? `${summary.slice(0, max - 1)}…` : summary; +} + +/** `12.345s` — event time since page load. */ +export function formatTraceTime(t: number): string { + return `${(t / 1000).toFixed(3)}s`; +} + +/** `+12ms` gap from the previous shown event (empty for the first). */ +export function formatTraceGap(gap: number | null): string { + if (gap === null) return ""; + return gap >= 1000 ? `+${(gap / 1000).toFixed(1)}s` : `+${Math.round(gap)}ms`; +} + +export type TraceExport = { + capturedAt: string; + url: string; + build: string | null; + userAgent: string; + viewport: { width: number; height: number; dpr: number }; + events: readonly TraceEvent[]; +}; + +/** Self-describing JSON blob to paste into an issue or an agent. */ +export function buildTraceExport(events: readonly TraceEvent[]): TraceExport { + return { + capturedAt: new Date().toISOString(), + url: `${window.location.pathname}${window.location.search}`, + build: process.env.NEXT_PUBLIC_BUILD_SHA ?? null, + userAgent: navigator.userAgent, + viewport: { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio, + }, + events, + }; +} diff --git a/app/src/lib/debug/grid-layout-diff.test.ts b/app/src/lib/debug/grid-layout-diff.test.ts new file mode 100644 index 00000000..5f499739 --- /dev/null +++ b/app/src/lib/debug/grid-layout-diff.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { + diffGridSnapshots, + diffItemIds, + type FrameBox, + type GridSnapshot, +} from "./grid-layout-diff"; + +const box = (y: number, height = 100, x = 0): FrameBox => ({ + x, + y, + width: 200, + height, +}); +const snap = (entries: [string, FrameBox][]): GridSnapshot => new Map(entries); +const viewport = { top: 0, bottom: 800 }; + +describe("diffGridSnapshots", () => { + it("reports moved frames and whether the user could see them", () => { + const diff = diffGridSnapshots({ + prev: snap([ + ["a", box(0)], + ["b", box(2000)], + ]), + next: snap([ + ["a", box(40)], + ["b", box(2100)], + ]), + viewport, + }); + expect(diff.moved).toEqual([ + { id: "a", dx: 0, dy: 40, dw: 0, dh: 0, visible: true }, + { id: "b", dx: 0, dy: 100, dw: 0, dh: 0, visible: false }, + ]); + }); + + it("counts a frame moving into view as visible", () => { + const diff = diffGridSnapshots({ + prev: snap([["a", box(1000)]]), + next: snap([["a", box(500)]]), + viewport, + }); + expect(diff.moved[0].visible).toBe(true); + }); + + it("ignores sub-pixel noise", () => { + const diff = diffGridSnapshots({ + prev: snap([["a", box(0)]]), + next: snap([["a", box(0.6, 100.4)]]), + viewport, + }); + expect(diff.moved).toEqual([]); + }); + + it("reports added and removed frames (e.g. skeletons swapped for items)", () => { + const diff = diffGridSnapshots({ + prev: snap([ + ["a", box(0)], + ["sk-1", box(200)], + ]), + next: snap([ + ["a", box(0)], + ["item-2", box(200)], + ]), + viewport, + }); + expect(diff).toEqual({ moved: [], added: ["item-2"], removed: ["sk-1"] }); + }); + + it("reports resizes as moves", () => { + const diff = diffGridSnapshots({ + prev: snap([["a", box(0, 100)]]), + next: snap([["a", box(0, 150)]]), + viewport, + }); + expect(diff.moved[0]).toMatchObject({ id: "a", dh: 50 }); + }); +}); + +describe("diffItemIds", () => { + it("detects an appended page", () => { + expect( + diffItemIds({ prev: ["a", "b"], next: ["a", "b", "c", "d"] }), + ).toEqual({ added: 2, removed: 0, reordered: false }); + }); + + it("detects dropped items without calling it a reorder", () => { + expect(diffItemIds({ prev: ["a", "b", "c"], next: ["a", "c"] })).toEqual({ + added: 0, + removed: 1, + reordered: false, + }); + }); + + it("detects surviving items changing order", () => { + expect( + diffItemIds({ prev: ["a", "b", "c"], next: ["b", "a", "c"] }), + ).toEqual({ added: 0, removed: 0, reordered: true }); + }); +}); diff --git a/app/src/lib/debug/grid-layout-diff.ts b/app/src/lib/debug/grid-layout-diff.ts new file mode 100644 index 00000000..34d242db --- /dev/null +++ b/app/src/lib/debug/grid-layout-diff.ts @@ -0,0 +1,116 @@ +/** Document-relative box of one grid frame, keyed by `data-grid-item`. */ +export type FrameBox = { x: number; y: number; width: number; height: number }; + +export type GridSnapshot = Map; + +export type FrameMove = { + id: string; + dx: number; + dy: number; + dw: number; + dh: number; + /** Was on screen before or after the move, i.e. the user could see it jump */ + visible: boolean; +}; + +export type GridLayoutDiff = { + moved: FrameMove[]; + added: string[]; + removed: string[]; +}; + +/** Sub-pixel noise from transforms/rounding isn't a visible move */ +const MOVE_THRESHOLD_PX = 1; + +function overlaps(box: FrameBox, viewport: { top: number; bottom: number }) { + return box.y < viewport.bottom && box.y + box.height > viewport.top; +} + +/** + * What changed between two settled grid layouts. Boxes are document-relative + * so scrolling alone produces no moves; `viewport` (document-relative too) + * decides which moves the user could actually see. + */ +export function diffGridSnapshots({ + prev, + next, + viewport, +}: { + prev: GridSnapshot; + next: GridSnapshot; + viewport: { top: number; bottom: number }; +}): GridLayoutDiff { + const moved: FrameMove[] = []; + const added: string[] = []; + const removed: string[] = []; + for (const [id, after] of next) { + const before = prev.get(id); + if (!before) { + added.push(id); + continue; + } + const dx = Math.round(after.x - before.x); + const dy = Math.round(after.y - before.y); + const dw = Math.round(after.width - before.width); + const dh = Math.round(after.height - before.height); + if ( + Math.abs(dx) > MOVE_THRESHOLD_PX || + Math.abs(dy) > MOVE_THRESHOLD_PX || + Math.abs(dw) > MOVE_THRESHOLD_PX || + Math.abs(dh) > MOVE_THRESHOLD_PX + ) { + moved.push({ + id, + dx, + dy, + dw, + dh, + visible: overlaps(before, viewport) || overlaps(after, viewport), + }); + } + } + for (const id of prev.keys()) { + if (!next.has(id)) removed.push(id); + } + return { moved, added, removed }; +} + +/** Measure every `[data-grid-item]` under root, document-relative. */ +export function snapshotGrid(root: Element): GridSnapshot { + const snapshot: GridSnapshot = new Map(); + const { scrollX, scrollY } = window; + for (const el of root.querySelectorAll("[data-grid-item]")) { + const id = el.getAttribute("data-grid-item"); + if (!id) continue; + const rect = el.getBoundingClientRect(); + snapshot.set(id, { + x: rect.x + scrollX, + y: rect.y + scrollY, + width: rect.width, + height: rect.height, + }); + } + return snapshot; +} + +/** + * How the grid's item list changed between renders: appended pages, items + * dropped (e.g. a refetch or search swap), and whether surviving items changed + * relative order (which reshuffles the masonry even with no adds/removes). + */ +export function diffItemIds({ + prev, + next, +}: { + prev: readonly string[]; + next: readonly string[]; +}): { added: number; removed: number; reordered: boolean } { + const prevSet = new Set(prev); + const nextSet = new Set(next); + const added = next.filter((id) => !prevSet.has(id)).length; + const removed = prev.filter((id) => !nextSet.has(id)).length; + const keptPrev = prev.filter((id) => nextSet.has(id)); + const keptNext = next.filter((id) => prevSet.has(id)); + const reordered = keptPrev.some((id, index) => keptNext[index] !== id); + return { added, removed, reordered }; +} diff --git a/app/src/lib/debug/highlight.ts b/app/src/lib/debug/highlight.ts new file mode 100644 index 00000000..96fbd6fb --- /dev/null +++ b/app/src/lib/debug/highlight.ts @@ -0,0 +1,67 @@ +/** + * Flash outlines over page regions (layout shifts, frames the grid moved) in a + * fixed overlay layer. Drawn outside the app tree on purpose — touching styles + * inside the masonry grid would itself trigger a reflow and pollute the trace. + */ + +const LAYER_ID = "abode-debug-highlights"; +const FLASH_MS = 900; + +export type HighlightRect = { + x: number; + y: number; + width: number; + height: number; +}; + +export type HighlightTone = "shift" | "move"; + +const TONE_COLORS: Record = { + shift: "rgba(239, 68, 68, 0.9)", + move: "rgba(59, 130, 246, 0.9)", +}; + +function getLayer(): HTMLElement { + const existing = document.getElementById(LAYER_ID); + if (existing) return existing; + const layer = document.createElement("div"); + layer.id = LAYER_ID; + layer.setAttribute("aria-hidden", "true"); + Object.assign(layer.style, { + position: "fixed", + inset: "0", + pointerEvents: "none", + zIndex: "2147483646", + }); + document.body.appendChild(layer); + return layer; +} + +/** Outline viewport-relative rects briefly. */ +export function flashRects(rects: HighlightRect[], tone: HighlightTone): void { + if (rects.length === 0) return; + const layer = getLayer(); + for (const rect of rects) { + const box = document.createElement("div"); + Object.assign(box.style, { + position: "absolute", + left: `${rect.x}px`, + top: `${rect.y}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + outline: `2px solid ${TONE_COLORS[tone]}`, + outlineOffset: "-1px", + borderRadius: "4px", + transition: `opacity ${FLASH_MS}ms ease-in`, + }); + layer.appendChild(box); + requestAnimationFrame(() => { + box.style.opacity = "0"; + }); + setTimeout(() => box.remove(), FLASH_MS + 50); + } +} + +export function removeHighlightLayer(): void { + document.getElementById(LAYER_ID)?.remove(); +} diff --git a/app/src/lib/debug/instrument-history.test.ts b/app/src/lib/debug/instrument-history.test.ts new file mode 100644 index 00000000..5c077960 --- /dev/null +++ b/app/src/lib/debug/instrument-history.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + callerStack, + diffSearchParams, + instrumentHistory, +} from "./instrument-history"; +import { resetTrace, traceNames } from "./test-utils"; +import { getTraceEvents } from "./trace"; + +describe("diffSearchParams", () => { + it("reports added, removed, and changed keys", () => { + expect( + diffSearchParams({ + from: "?item=a&q=cats&x=1", + to: "?q=dogs&x=1&sort=new", + }), + ).toEqual({ added: ["sort"], removed: ["item"], changed: ["q"] }); + }); + + it("is empty when nothing changed", () => { + expect(diffSearchParams({ from: "?a=1", to: "?a=1" })).toEqual({ + added: [], + removed: [], + changed: [], + }); + }); +}); + +describe("callerStack", () => { + it("drops the Error header and the wrapper frame", () => { + const stack = [ + "Error", + " at patched (instrument-history.ts:1:1)", + " at writeUrl (use-search.ts:66:5)", + " at onClick (item-card.tsx:1412:3)", + ].join("\n"); + expect(callerStack(stack)).toEqual([ + "at writeUrl (use-search.ts:66:5)", + "at onClick (item-card.tsx:1412:3)", + ]); + }); + + it("caps depth and tolerates a missing stack", () => { + const stack = [ + "Error", + ...Array.from({ length: 10 }, (_, i) => `at f${i}`), + ].join("\n"); + expect(callerStack(stack, 2)).toEqual(["at f1", "at f2"]); + expect(callerStack(undefined)).toEqual([]); + }); +}); + +describe("instrumentHistory", () => { + let uninstall: () => void = () => {}; + + beforeEach(() => { + window.history.replaceState(null, "", "/dashboard"); + resetTrace(); + uninstall = instrumentHistory(); + }); + + afterEach(() => { + uninstall(); + resetTrace({ enabled: false }); + }); + + it("records pushState with the param diff and a stack", () => { + window.history.pushState(null, "", "?item=abc"); + const [event] = getTraceEvents(); + expect(event.channel).toBe("url"); + expect(event.event).toBe("pushState"); + expect(event.data).toMatchObject({ + from: "/dashboard", + to: "/dashboard?item=abc", + added: ["item"], + }); + expect(Array.isArray(event.data?.stack)).toBe(true); + }); + + it("flags a replaceState that drops a param", () => { + window.history.replaceState(null, "", "/dashboard?item=abc&q=cats"); + window.history.replaceState(null, "", "/dashboard?q=cats"); + expect(getTraceEvents().at(-1)?.data).toMatchObject({ removed: ["item"] }); + }); + + it("ignores a replaceState that keeps the same URL", () => { + window.history.replaceState({ internal: true }, "", "/dashboard"); + window.history.replaceState({ internal: true }, ""); + expect(getTraceEvents()).toEqual([]); + }); + + it("records a traversal (popstate) against the previous URL", () => { + window.history.pushState(null, "", "?item=abc"); + // Simulate Back: the browser moves the URL without going through our wrapper + History.prototype.replaceState.call(window.history, null, "", "/dashboard"); + window.dispatchEvent(new PopStateEvent("popstate")); + expect(traceNames()).toEqual(["url:pushState", "url:popstate"]); + expect(getTraceEvents().at(-1)?.data).toMatchObject({ + from: "/dashboard?item=abc", + to: "/dashboard", + removed: ["item"], + }); + }); + + it("ignores a popstate that didn't change the URL", () => { + window.dispatchEvent(new PopStateEvent("popstate")); + expect(getTraceEvents()).toEqual([]); + }); + + it("reports a traversal once, even when a router re-writes the URL before popstate", () => { + window.history.pushState(null, "", "?item=abc"); + History.prototype.replaceState.call(window.history, null, "", "/dashboard"); + // e.g. Next syncing its state from the navigate event, before popstate + window.history.replaceState({ router: true }, "", window.location.href); + window.dispatchEvent(new PopStateEvent("popstate")); + expect(traceNames()).toEqual(["url:pushState", "url:popstate"]); + expect(getTraceEvents().at(-1)?.data).toMatchObject({ + from: "/dashboard?item=abc", + to: "/dashboard", + }); + }); + + it("restores the original history methods on uninstall", () => { + const patched = window.history.pushState; + uninstall(); + expect(window.history.pushState).not.toBe(patched); + window.history.pushState(null, "", "?item=after"); + expect(getTraceEvents()).toEqual([]); + uninstall = () => {}; + }); +}); diff --git a/app/src/lib/debug/instrument-history.ts b/app/src/lib/debug/instrument-history.ts new file mode 100644 index 00000000..af3e1503 --- /dev/null +++ b/app/src/lib/debug/instrument-history.ts @@ -0,0 +1,136 @@ +import { debugTrace, type TraceData } from "./trace"; + +/** + * Describe how the query string changed between two URLs — the bit that + * matters for URL-driven UI like the `?item=` dialog (e.g. a search write that + * silently drops `item`). + */ +export function diffSearchParams({ from, to }: { from: string; to: string }): { + added: string[]; + removed: string[]; + changed: string[]; +} { + const before = new URLSearchParams(from); + const after = new URLSearchParams(to); + const added: string[] = []; + const removed: string[] = []; + const changed: string[] = []; + for (const key of new Set(before.keys())) { + if (!after.has(key)) removed.push(key); + else if (before.getAll(key).join() !== after.getAll(key).join()) { + changed.push(key); + } + } + for (const key of new Set(after.keys())) { + if (!before.has(key)) added.push(key); + } + return { added, removed, changed }; +} + +/** + * Short call-site stack for a history write: drops the "Error" header and the + * wrapper's own frame so the first line is whoever called pushState/replaceState. + */ +export function callerStack(stack: string | undefined, depth = 6): string[] { + if (!stack) return []; + return stack + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && line !== "Error") + .slice(1, depth + 1); +} + +function describeUrl(url: string | URL | null | undefined): { + path: string; + search: string; +} { + const resolved = new URL(url ?? window.location.href, window.location.href); + return { path: resolved.pathname, search: resolved.search }; +} + +function traceUrlChange({ + event, + fromHref, + toUrl, + stack, +}: { + event: string; + fromHref: string; + toUrl: string | URL | null | undefined; + stack?: string; +}) { + const from = describeUrl(fromHref); + const to = describeUrl(toUrl); + const params = diffSearchParams({ from: from.search, to: to.search }); + const data: TraceData = { + from: `${from.path}${from.search}`, + to: `${to.path}${to.search}`, + }; + if (params.added.length) data.added = params.added; + if (params.removed.length) data.removed = params.removed; + if (params.changed.length) data.changed = params.changed; + if (stack !== undefined) data.stack = callerStack(stack); + debugTrace("url", event, data); +} + +/** + * Wrap history.pushState/replaceState and listen for popstate so every URL + * change is on the timeline with a call stack. Returns an uninstall function. + */ +export function instrumentHistory(): () => void { + const originalPush = window.history.pushState; + const originalReplace = window.history.replaceState; + // The URL as of the last change we saw + let lastHref = window.location.href; + + // A back/forward traversal moves the URL without going through our wrapper. + // Report it from whichever hook notices first — popstate, or a router + // re-writing the new URL (Next does this from the Navigation API's navigate + // event, before popstate fires) + const syncTraversal = () => { + if (window.location.href === lastHref) return; + traceUrlChange({ + event: "popstate", + fromHref: lastHref, + toUrl: window.location.href, + }); + lastHref = window.location.href; + }; + + const wrap = + ( + event: "pushState" | "replaceState", + original: History["pushState"], + ): History["pushState"] => + (data, unused, url) => { + syncTraversal(); + const fromHref = window.location.href; + original.call(window.history, data, unused, url); + lastHref = window.location.href; + // Next's router re-replaces the same URL to stash its own state; skip that noise + if (event === "replaceState" && lastHref === fromHref) return; + traceUrlChange({ + event, + fromHref, + toUrl: url, + stack: new Error().stack, + }); + }; + + const patchedPush = wrap("pushState", originalPush); + const patchedReplace = wrap("replaceState", originalReplace); + window.history.pushState = patchedPush; + window.history.replaceState = patchedReplace; + window.addEventListener("popstate", syncTraversal); + + return () => { + // Only restore if nobody wrapped on top of us since + if (window.history.pushState === patchedPush) { + window.history.pushState = originalPush; + } + if (window.history.replaceState === patchedReplace) { + window.history.replaceState = originalReplace; + } + window.removeEventListener("popstate", syncTraversal); + }; +} diff --git a/app/src/lib/debug/instrument-performance.test.ts b/app/src/lib/debug/instrument-performance.test.ts new file mode 100644 index 00000000..dcf7934d --- /dev/null +++ b/app/src/lib/debug/instrument-performance.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + describeLayoutShift, + describeLongFrame, + describeNode, + SLOW_FRAME_MS, +} from "./instrument-performance"; + +describe("describeNode", () => { + it("names nodes inside a grid item by item id", () => { + document.body.innerHTML = + '
'; + expect(describeNode(document.querySelector("img"))).toBe( + "grid-item:item-1", + ); + }); + + it("falls back to tag and classes, noting a dialog ancestor", () => { + document.body.innerHTML = + '

x

'; + expect(describeNode(document.querySelector("p"))).toBe("dialog > p.a.b.c"); + expect(describeNode(document.querySelector("span"))).toBe("span.z"); + expect(describeNode(null)).toBe("(detached)"); + }); +}); + +describe("describeLayoutShift", () => { + it("summarises score, source nodes, and rects", () => { + document.body.innerHTML = '
'; + const node = document.querySelector("div"); + const result = describeLayoutShift({ + value: 0.123456, + hadRecentInput: false, + sources: [{ node, currentRect: { x: 1, y: 2, width: 3, height: 4 } }], + }); + expect(result).toEqual({ + data: { score: 0.1235, nodes: ["grid-item:item-9"] }, + rects: [{ x: 1, y: 2, width: 3, height: 4 }], + }); + }); + + it("skips shifts made up only of the debug panel's own nodes", () => { + document.body.innerHTML = + '
'; + expect( + describeLayoutShift({ + value: 0.01, + sources: [{ node: document.querySelector("button") }], + }), + ).toBeNull(); + }); + + it("skips shifts caused by recent input and malformed entries", () => { + expect( + describeLayoutShift({ value: 0.2, hadRecentInput: true, sources: [] }), + ).toBeNull(); + expect(describeLayoutShift({})).toBeNull(); + }); +}); + +describe("describeLongFrame", () => { + it("ignores frames under the threshold", () => { + expect(describeLongFrame({ duration: SLOW_FRAME_MS - 1 })).toBeNull(); + }); + + it("reports duration, blocking time, and the heaviest scripts", () => { + expect( + describeLongFrame({ + duration: 180.4, + blockingDuration: 120.2, + scripts: [ + { invoker: "a", duration: 10 }, + { invoker: "b", duration: 90 }, + { invoker: "c", duration: 40 }, + { invoker: "d", duration: 5 }, + ], + }), + ).toEqual({ + duration: 180, + blocking: 120, + scripts: ["b (90ms)", "c (40ms)", "a (10ms)"], + }); + }); +}); diff --git a/app/src/lib/debug/instrument-performance.ts b/app/src/lib/debug/instrument-performance.ts new file mode 100644 index 00000000..6b823069 --- /dev/null +++ b/app/src/lib/debug/instrument-performance.ts @@ -0,0 +1,131 @@ +import { flashRects, type HighlightRect } from "./highlight"; +import { debugTrace, type TraceData } from "./trace"; + +/** Frames longer than this are worth a timeline entry (LoAF's own floor is 50ms). */ +export const SLOW_FRAME_MS = 100; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function toRect(value: unknown): HighlightRect | null { + if (!isRecord(value)) return null; + const { x, y, width, height } = value; + if ( + typeof x !== "number" || + typeof y !== "number" || + typeof width !== "number" || + typeof height !== "number" + ) { + return null; + } + return { x, y, width, height }; +} + +/** + * Human-readable handle for a DOM node: its grid item id if it's inside a + * card, else tag + first classes. + */ +export function describeNode(node: unknown): string { + if (!(node instanceof Element)) return "(detached)"; + const gridItem = node.closest("[data-grid-item]"); + if (gridItem) return `grid-item:${gridItem.getAttribute("data-grid-item")}`; + const dialog = node.closest("[role=dialog]"); + const classes = Array.from(node.classList).slice(0, 3).join("."); + const label = `${node.tagName.toLowerCase()}${classes ? `.${classes}` : ""}`; + return dialog ? `dialog > ${label}` : label; +} + +/** + * Layout-shift entry → timeline payload. Note CLS only counts shifts of the + * layout box: transform-driven motion (the masonry engine's translateY) is + * invisible here, which is why the grid has its own observer. + */ +export function describeLayoutShift(entry: unknown): { + data: TraceData; + rects: HighlightRect[]; +} | null { + if (!isRecord(entry) || typeof entry.value !== "number") return null; + if (entry.hadRecentInput === true) return null; + const sources = Array.isArray(entry.sources) ? entry.sources : []; + const rects: HighlightRect[] = []; + const nodes: string[] = []; + for (const source of sources) { + if (!isRecord(source)) continue; + // The debug panel's own layout isn't the app's jank + if ( + source.node instanceof Element && + source.node.closest("[data-debug-panel]") + ) { + continue; + } + nodes.push(describeNode(source.node)); + const rect = toRect(source.currentRect); + if (rect) rects.push(rect); + } + if (sources.length > 0 && nodes.length === 0) return null; + return { + data: { score: Math.round(entry.value * 10000) / 10000, nodes }, + rects, + }; +} + +/** Long-animation-frame entry → timeline payload (with its heaviest scripts). */ +export function describeLongFrame(entry: unknown): TraceData | null { + if (!isRecord(entry) || typeof entry.duration !== "number") return null; + if (entry.duration < SLOW_FRAME_MS) return null; + const scripts = Array.isArray(entry.scripts) ? entry.scripts : []; + const top = scripts + .filter(isRecord) + .sort( + (a, b) => + (typeof b.duration === "number" ? b.duration : 0) - + (typeof a.duration === "number" ? a.duration : 0), + ) + .slice(0, 3) + .map((script) => { + const invoker = typeof script.invoker === "string" ? script.invoker : "?"; + const duration = + typeof script.duration === "number" ? Math.round(script.duration) : 0; + return `${invoker} (${duration}ms)`; + }); + const data: TraceData = { duration: Math.round(entry.duration) }; + if (typeof entry.blockingDuration === "number") { + data.blocking = Math.round(entry.blockingDuration); + } + if (top.length) data.scripts = top; + return data; +} + +function observe(type: string, onEntry: (entry: PerformanceEntry) => void) { + const supported = + typeof PerformanceObserver !== "undefined" && + PerformanceObserver.supportedEntryTypes?.includes(type); + if (!supported) return () => {}; + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) onEntry(entry); + }); + observer.observe({ type, buffered: false }); + return () => observer.disconnect(); +} + +/** + * Layout shifts (flashed red on screen) and slow frames onto the timeline. + * Both are Chromium-only; other browsers skip silently. + */ +export function instrumentPerformance(): () => void { + const stopShifts = observe("layout-shift", (entry) => { + const described = describeLayoutShift(entry); + if (!described) return; + debugTrace("layout", "layout-shift", described.data); + flashRects(described.rects, "shift"); + }); + const stopFrames = observe("long-animation-frame", (entry) => { + const data = describeLongFrame(entry); + if (data) debugTrace("perf", "slow-frame", data); + }); + return () => { + stopShifts(); + stopFrames(); + }; +} diff --git a/app/src/lib/debug/instrument-query-cache.test.ts b/app/src/lib/debug/instrument-query-cache.test.ts new file mode 100644 index 00000000..d3918a43 --- /dev/null +++ b/app/src/lib/debug/instrument-query-cache.test.ts @@ -0,0 +1,86 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + instrumentQueryCache, + summarizeQueryData, +} from "./instrument-query-cache"; +import { resetTrace, traceNames } from "./test-utils"; +import { getTraceEvents } from "./trace"; + +describe("summarizeQueryData", () => { + it("counts pages and items for infinite queries", () => { + expect( + summarizeQueryData({ + pages: [{ items: [1, 2] }, { items: [3] }], + pageParams: [null, "c"], + }), + ).toEqual({ pages: 2, items: 3 }); + }); + + it("summarises lists, single records, and primitives", () => { + expect(summarizeQueryData([1, 2, 3])).toEqual({ length: 3 }); + expect(summarizeQueryData({ items: [1] })).toEqual({ items: 1 }); + expect(summarizeQueryData({ id: "abc", title: "x" })).toEqual({ + id: "abc", + }); + expect(summarizeQueryData(null)).toEqual({ type: "null" }); + expect(summarizeQueryData({ a: 1, b: 2 })).toEqual({ keys: ["a", "b"] }); + }); +}); + +describe("instrumentQueryCache", () => { + let queryClient: QueryClient; + let uninstall: () => void; + + beforeEach(() => { + resetTrace(); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + uninstall = instrumentQueryCache(queryClient); + }); + + afterEach(() => { + uninstall(); + queryClient.clear(); + resetTrace({ enabled: false }); + }); + + it("records fetch → success with a data summary", async () => { + await queryClient.fetchQuery({ + queryKey: ["items"], + queryFn: async () => ({ items: [1, 2, 3] }), + }); + expect(traceNames()).toEqual(["query:fetch", "query:success"]); + expect(getTraceEvents()[1].data).toEqual({ + queryKey: '["items"]', + items: 3, + }); + }); + + it("distinguishes setQueryData from a network success", () => { + queryClient.setQueryData(["items", "abc", "detail"], { id: "abc" }); + expect(traceNames()).toEqual(["query:setData"]); + }); + + it("records invalidations", async () => { + queryClient.setQueryData(["items"], { items: [] }); + await queryClient.invalidateQueries({ queryKey: ["items"] }); + expect(traceNames()).toContain("query:invalidate"); + }); + + it("records errors with the message", async () => { + await queryClient + .fetchQuery({ + queryKey: ["boom"], + queryFn: async () => { + throw new Error("nope"); + }, + }) + .catch(() => {}); + expect(getTraceEvents().at(-1)).toMatchObject({ + event: "error", + data: { message: "nope" }, + }); + }); +}); diff --git a/app/src/lib/debug/instrument-query-cache.ts b/app/src/lib/debug/instrument-query-cache.ts new file mode 100644 index 00000000..46bd9079 --- /dev/null +++ b/app/src/lib/debug/instrument-query-cache.ts @@ -0,0 +1,81 @@ +import type { QueryCacheNotifyEvent, QueryClient } from "@tanstack/react-query"; +import { debugTrace, type TraceData } from "./trace"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Compact shape of a query's data for the timeline — enough to see *that* the + * list changed (page/item counts), not the payload itself. + */ +export function summarizeQueryData(data: unknown): TraceData { + if (Array.isArray(data)) return { length: data.length }; + if (!isRecord(data)) return { type: data === null ? "null" : typeof data }; + const pages = data.pages; + if (Array.isArray(pages)) { + const items = pages.reduce( + (sum, page) => + sum + + (isRecord(page) && Array.isArray(page.items) ? page.items.length : 0), + 0, + ); + return { pages: pages.length, items }; + } + if (Array.isArray(data.items)) return { items: data.items.length }; + if (typeof data.id === "string") return { id: data.id }; + return { keys: Object.keys(data).slice(0, 8) }; +} + +/** Map a cache event to a timeline entry, or null for ones not worth showing. */ +export function describeQueryEvent( + event: QueryCacheNotifyEvent, +): { event: string; data: TraceData } | null { + const queryKey = JSON.stringify(event.query.queryKey); + if (event.type === "removed") { + return { event: "removed", data: { queryKey } }; + } + if (event.type !== "updated") return null; + const { action } = event; + switch (action.type) { + case "fetch": { + const direction = action.meta?.fetchMore?.direction; + return { + event: direction ? `fetch:${direction}` : "fetch", + data: { + queryKey, + observers: event.query.getObserversCount(), + }, + }; + } + case "success": + return { + // `manual` = setQueryData (optimistic/cache patch), not a network fetch + event: action.manual ? "setData" : "success", + data: { queryKey, ...summarizeQueryData(action.data) }, + }; + case "error": + return { + event: "error", + data: { + queryKey, + message: + action.error instanceof Error + ? action.error.message + : String(action.error), + }, + }; + case "invalidate": + return { event: "invalidate", data: { queryKey } }; + default: + return null; + } +} + +/** Put every React Query fetch/invalidation/data change on the timeline. */ +export function instrumentQueryCache(queryClient: QueryClient): () => void { + return queryClient.getQueryCache().subscribe((event) => { + const described = describeQueryEvent(event); + if (described) debugTrace("query", described.event, described.data); + }); +} diff --git a/app/src/lib/debug/test-utils.ts b/app/src/lib/debug/test-utils.ts new file mode 100644 index 00000000..89fd1d24 --- /dev/null +++ b/app/src/lib/debug/test-utils.ts @@ -0,0 +1,18 @@ +import { + clearTrace, + getTraceEvents, + setTracingEnabled, + setTracingPaused, +} from "./trace"; + +/** Fresh, recording trace store for a test. */ +export function resetTrace({ enabled = true } = {}): void { + setTracingPaused(false); + setTracingEnabled(enabled); + clearTrace(); +} + +/** Recorded events as `channel:event` strings, for compact assertions. */ +export function traceNames(): string[] { + return getTraceEvents().map((event) => `${event.channel}:${event.event}`); +} diff --git a/app/src/lib/debug/trace.test.ts b/app/src/lib/debug/trace.test.ts new file mode 100644 index 00000000..c30bf8b7 --- /dev/null +++ b/app/src/lib/debug/trace.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resetTrace, traceNames } from "./test-utils"; +import { + debugTrace, + getTraceEvents, + isTracing, + MAX_TRACE_EVENTS, + setTracingEnabled, + setTracingPaused, + subscribeTrace, +} from "./trace"; + +describe("debug trace store", () => { + beforeEach(() => resetTrace()); + afterEach(() => resetTrace({ enabled: false })); + + it("records nothing while tracing is off", () => { + setTracingEnabled(false); + debugTrace("grid", "reflow"); + expect(getTraceEvents()).toEqual([]); + expect(isTracing()).toBe(false); + }); + + it("records events with channel, name, and data while on", () => { + debugTrace("url", "pushState", { to: "/dashboard?item=1" }); + const [event] = getTraceEvents(); + expect(event).toMatchObject({ + channel: "url", + event: "pushState", + data: { to: "/dashboard?item=1" }, + }); + expect(typeof event.t).toBe("number"); + }); + + it("drops events while paused", () => { + setTracingPaused(true); + debugTrace("grid", "reflow"); + expect(isTracing()).toBe(false); + setTracingPaused(false); + debugTrace("grid", "items"); + expect(traceNames()).toEqual(["grid:items"]); + }); + + it("keeps only the most recent MAX_TRACE_EVENTS", () => { + for (let i = 0; i < MAX_TRACE_EVENTS + 10; i++) { + debugTrace("perf", `e${i}`); + } + const events = getTraceEvents(); + expect(events).toHaveLength(MAX_TRACE_EVENTS); + expect(events[0].event).toBe("e10"); + expect(events.at(-1)?.event).toBe(`e${MAX_TRACE_EVENTS + 9}`); + }); + + it("slots a backdated event in time order", () => { + const start = performance.now(); + debugTrace("query", "success"); + debugTrace("grid", "reflow", undefined, { at: start - 100 }); + expect(traceNames()).toEqual(["grid:reflow", "query:success"]); + }); + + it("puts a backdated event before one logged at the same instant", () => { + const now = performance.now(); + debugTrace("dialog", "mount", undefined, { at: now }); + debugTrace("dialog", "unmount", undefined, { at: now }); + expect(traceNames()).toEqual(["dialog:unmount", "dialog:mount"]); + }); + + it("returns a stable snapshot until something changes", () => { + debugTrace("mark", "mark"); + const snapshot = getTraceEvents(); + expect(getTraceEvents()).toBe(snapshot); + debugTrace("mark", "mark"); + expect(getTraceEvents()).not.toBe(snapshot); + }); + + it("batches listener notifications for a burst of events", async () => { + const listener = vi.fn(); + const unsubscribe = subscribeTrace(listener); + debugTrace("grid", "a"); + debugTrace("grid", "b"); + debugTrace("grid", "c"); + await vi.waitFor(() => expect(listener).toHaveBeenCalled()); + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + }); +}); diff --git a/app/src/lib/debug/trace.ts b/app/src/lib/debug/trace.ts new file mode 100644 index 00000000..6f9094bb --- /dev/null +++ b/app/src/lib/debug/trace.ts @@ -0,0 +1,148 @@ +import { isDebugFlagOn } from "./debug-flag"; + +/** + * In-memory event timeline for the admin debug tools. + * + * Instrumentation across the app calls {@link debugTrace}; while tracing is off + * (every normal session) that's a single boolean check, so it's safe to call + * from hot paths like grid renders. While on, events land in a bounded ring + * buffer the debug panel renders and can export as JSON — the point being to + * see causal chains (refetch → list changed → dialog unmounted) in one place. + */ + +export const TRACE_CHANNELS = [ + "url", + "query", + "grid", + "dialog", + "layout", + "perf", + "mark", +] as const; + +export type TraceChannel = (typeof TRACE_CHANNELS)[number]; + +/** JSON-safe payload so a trace can be copied out verbatim. */ +export type TraceValue = + | string + | number + | boolean + | null + | TraceValue[] + | { [key: string]: TraceValue }; + +export type TraceData = Record; + +export type TraceEvent = { + id: number; + /** ms since page load (performance.now()) */ + t: number; + channel: TraceChannel; + event: string; + data?: TraceData; +}; + +export const MAX_TRACE_EVENTS = 500; + +// Start recording at module load when the flag is set, so events from the very +// first render (a deep-linked dialog mounting, the grid's initial reflows) are +// captured before the debug tools have loaded and confirmed admin access +let enabled = isDebugFlagOn(); +let paused = false; +let nextId = 1; +let events: readonly TraceEvent[] = []; +const listeners = new Set<() => void>(); +const notify = { scheduled: false }; + +// Batch notifications to one per frame so a burst of events (a reflow, a +// refetch) re-renders the panel once, not per event +function scheduleNotify() { + if (notify.scheduled) return; + notify.scheduled = true; + const flush = () => { + notify.scheduled = false; + for (const listener of listeners) listener(); + }; + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(flush); + } else { + queueMicrotask(flush); + } +} + +/** True while tracing is on — guard any non-trivial payload building with it. */ +export function isTracing(): boolean { + return enabled && !paused; +} + +/** + * Record an event. A no-op unless tracing is on. Pass `at` (a performance.now() + * timestamp) for an event only known after the fact — e.g. a reflow measured + * once it settles — so it slots into the timeline where it actually began. + */ +export function debugTrace( + channel: TraceChannel, + event: string, + data?: TraceData, + options?: { at?: number }, +): void { + if (!enabled || paused) return; + const entry: TraceEvent = { + id: nextId++, + t: Math.round((options?.at ?? performance.now()) * 10) / 10, + channel, + event, + ...(data ? { data } : {}), + }; + const next = + events.length >= MAX_TRACE_EVENTS + ? events.slice(events.length - MAX_TRACE_EVENTS + 1) + : events.slice(); + // Insert in time order. A backdated event also goes before same-timestamp + // ones: it happened first, it was just logged later + const backdated = options?.at !== undefined; + let index = next.length; + while ( + index > 0 && + (next[index - 1].t > entry.t || + (backdated && next[index - 1].t === entry.t)) + ) { + index--; + } + next.splice(index, 0, entry); + events = next; + scheduleNotify(); +} + +export function setTracingEnabled(value: boolean): void { + if (enabled === value) return; + enabled = value; + scheduleNotify(); +} + +export function setTracingPaused(value: boolean): void { + if (paused === value) return; + paused = value; + scheduleNotify(); +} + +export function isTracingPaused(): boolean { + return paused; +} + +export function clearTrace(): void { + events = []; + scheduleNotify(); +} + +/** Stable snapshot (a new array only when events change) for useSyncExternalStore. */ +export function getTraceEvents(): readonly TraceEvent[] { + return events; +} + +export function subscribeTrace(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/app/src/lib/debug/use-debug-grid-observer.ts b/app/src/lib/debug/use-debug-grid-observer.ts new file mode 100644 index 00000000..e32e6152 --- /dev/null +++ b/app/src/lib/debug/use-debug-grid-observer.ts @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + diffGridSnapshots, + type GridSnapshot, + snapshotGrid, +} from "./grid-layout-diff"; +import { flashRects } from "./highlight"; +import { debugTrace } from "./trace"; +import { useIsTracing } from "./use-tracing"; + +/** Wait this long after the last style mutation — past the 300ms reflow transitions — before measuring */ +const SETTLE_MS = 400; +const SAMPLE_SIZE = 8; + +/** + * Trace masonry reflows: after each burst of grid DOM/style mutations settles, + * diff every frame's position against the last settled layout and log what + * moved, flashing visible movers blue. The masonry engine positions frames + * with transforms, which the browser's layout-shift API doesn't count — this is + * the only way to see "the grid jumped". + */ +export function useDebugGridObserver(): (node: HTMLElement | null) => void { + const tracing = useIsTracing(); + // State (not a ref) so the observer attaches when the grid mounts later — + // it renders only after hydration and only when there are items + const [root, setRoot] = useState(null); + + useEffect(() => { + if (!tracing || !root) return; + + let prev: GridSnapshot = snapshotGrid(root); + let burstStart: number | null = null; + let mutations = 0; + let timer: ReturnType | null = null; + + const settle = () => { + timer = null; + const next = snapshotGrid(root); + const viewport = { + top: window.scrollY, + bottom: window.scrollY + window.innerHeight, + }; + const diff = diffGridSnapshots({ prev, next, viewport }); + prev = next; + const visibleMoves = diff.moved.filter((move) => move.visible); + if (diff.moved.length || diff.added.length || diff.removed.length) { + const maxDy = diff.moved.reduce( + (max, move) => Math.max(max, Math.abs(move.dy)), + 0, + ); + const startedAt = burstStart ?? performance.now(); + debugTrace( + "grid", + "reflow", + { + settleMs: Math.round(performance.now() - startedAt), + mutations, + moved: diff.moved.length, + movedVisible: visibleMoves.length, + maxDy, + added: diff.added.length, + removed: diff.removed.length, + sample: visibleMoves + .slice(0, SAMPLE_SIZE) + .map((move) => `${move.id} Δ${move.dx},${move.dy}`), + }, + { at: startedAt }, + ); + flashRects( + visibleMoves.flatMap((move) => { + const box = next.get(move.id); + return box + ? [ + { + x: box.x - window.scrollX, + y: box.y - window.scrollY, + width: box.width, + height: box.height, + }, + ] + : []; + }), + "move", + ); + } + burstStart = null; + mutations = 0; + }; + + const observer = new MutationObserver((records) => { + if (burstStart === null) burstStart = performance.now(); + mutations += records.length; + if (timer) clearTimeout(timer); + timer = setTimeout(settle, SETTLE_MS); + }); + observer.observe(root, { + subtree: true, + childList: true, + attributeFilter: ["style"], + }); + + return () => { + observer.disconnect(); + if (timer) clearTimeout(timer); + }; + }, [root, tracing]); + + return setRoot; +} diff --git a/app/src/lib/debug/use-debug-lifecycle.test.tsx b/app/src/lib/debug/use-debug-lifecycle.test.tsx new file mode 100644 index 00000000..13fabb74 --- /dev/null +++ b/app/src/lib/debug/use-debug-lifecycle.test.tsx @@ -0,0 +1,74 @@ +import { render } from "@testing-library/react"; +import { StrictMode } from "react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resetTrace, traceNames } from "./test-utils"; +import { getTraceEvents } from "./trace"; +import { changedKeys, useDebugLifecycle } from "./use-debug-lifecycle"; + +function Probe({ value, flag }: { value: string | null; flag: boolean }) { + useDebugLifecycle({ + name: "Probe", + channel: "dialog", + watch: { value, flag }, + }); + return null; +} + +// Let the deferred (microtask) unmount log flush +const flush = () => new Promise((resolve) => queueMicrotask(resolve)); + +describe("changedKeys", () => { + it("lists keys whose value changed, including added/removed keys", () => { + expect( + changedKeys({ a: 1, b: "x", c: true }, { a: 1, b: "y", d: 2 }), + ).toEqual(["b", "c", "d"]); + }); +}); + +describe("useDebugLifecycle", () => { + beforeEach(() => resetTrace()); + afterEach(() => resetTrace({ enabled: false })); + + it("records mount with the watched values, then unmount", async () => { + const { unmount } = render(); + expect(getTraceEvents()[0]).toMatchObject({ + event: "Probe:mount", + data: { value: "a", flag: true }, + }); + unmount(); + await flush(); + expect(traceNames()).toEqual([ + "dialog:Probe:mount", + "dialog:Probe:unmount", + ]); + }); + + it("records which watched values changed", () => { + const { rerender } = render(); + rerender(); + rerender(); + expect(traceNames()).toEqual(["dialog:Probe:mount", "dialog:Probe:change"]); + expect(getTraceEvents()[1].data).toEqual({ value: "a → null" }); + }); + + it("ignores Strict Mode's simulated unmount/remount", async () => { + render( + + + , + ); + await flush(); + expect(traceNames()).toEqual(["dialog:Probe:mount"]); + }); + + it("still records a real remount (new instance)", async () => { + const { rerender } = render(); + rerender(); + await flush(); + expect(traceNames()).toEqual([ + "dialog:Probe:mount", + "dialog:Probe:unmount", + "dialog:Probe:mount", + ]); + }); +}); diff --git a/app/src/lib/debug/use-debug-lifecycle.ts b/app/src/lib/debug/use-debug-lifecycle.ts new file mode 100644 index 00000000..7d6a5bb1 --- /dev/null +++ b/app/src/lib/debug/use-debug-lifecycle.ts @@ -0,0 +1,82 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { debugTrace, type TraceChannel, type TraceData } from "./trace"; + +type WatchedValue = string | number | boolean | null | undefined; + +/** Keys whose value differs between two watched-value maps. */ +export function changedKeys( + prev: Record, + next: Record, +): string[] { + const keys = new Set([...Object.keys(prev), ...Object.keys(next)]); + return [...keys].filter((key) => !Object.is(prev[key], next[key])); +} + +function toTraceData(values: Record): TraceData { + const data: TraceData = {}; + for (const [key, value] of Object.entries(values)) { + data[key] = value === undefined ? null : value; + } + return data; +} + +/** + * Put a component's mount, unmount, and changes to `watch` on the debug + * timeline — for catching remounts (mount→unmount→mount of the same thing) and + * seeing which input flipped right before one. Watched values must be + * primitives; derive them (ids, booleans) rather than passing objects. + */ +export function useDebugLifecycle({ + name, + channel, + watch = {}, +}: { + name: string; + channel: TraceChannel; + watch?: Record; +}): void { + const prevRef = useRef | null>(null); + const watchRef = useRef(watch); + watchRef.current = watch; + const pendingUnmountRef = useRef(false); + + // Mount/unmount only (name/channel are constant per call site). Dev Strict + // Mode re-runs effects on the same instance within the same task, which + // would read as exactly the mount→unmount→mount we're hunting — so defer the + // unmount a microtask and drop the pair if the effect re-runs first. A real + // remount is a new instance (fresh refs) and still logs both. + useEffect(() => { + if (pendingUnmountRef.current) { + pendingUnmountRef.current = false; + return scheduleUnmount; + } + debugTrace(channel, `${name}:mount`, toTraceData(watchRef.current)); + return scheduleUnmount; + + function scheduleUnmount() { + pendingUnmountRef.current = true; + const at = performance.now(); + queueMicrotask(() => { + if (!pendingUnmountRef.current) return; + pendingUnmountRef.current = false; + // Backdated so it sorts before a replacement instance's mount + debugTrace(channel, `${name}:unmount`, undefined, { at }); + }); + } + }, [channel, name]); + + useEffect(() => { + const prev = prevRef.current; + prevRef.current = watch; + if (prev === null) return; + const changed = changedKeys(prev, watch); + if (changed.length === 0) return; + const data: TraceData = {}; + for (const key of changed) { + data[key] = `${String(prev[key])} → ${String(watch[key])}`; + } + debugTrace(channel, `${name}:change`, data); + }); +} diff --git a/app/src/lib/debug/use-tracing.ts b/app/src/lib/debug/use-tracing.ts new file mode 100644 index 00000000..6c462e18 --- /dev/null +++ b/app/src/lib/debug/use-tracing.ts @@ -0,0 +1,7 @@ +import { useSyncExternalStore } from "react"; +import { isTracing, subscribeTrace } from "./trace"; + +/** Reactive {@link isTracing}, for hooks that attach observers only while tracing. */ +export function useIsTracing(): boolean { + return useSyncExternalStore(subscribeTrace, isTracing, () => false); +} From ba6b1867553f89cbff5bcb07651936a705a4c726 Mon Sep 17 00:00:00 2001 From: Fred Rivett Date: Sat, 26 Sep 2026 11:43:18 +0100 Subject: [PATCH 02/15] Add admin debug trace panel Gated to admins (anyone in local dev) behind a per-browser flag, toggled via ?debug=1 or the admin account menu; the session is code-split. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/src/app/layout.tsx | 2 + .../components/debug/debug-panel.stories.tsx | 144 +++++++++ app/src/components/debug/debug-panel.test.tsx | 106 +++++++ app/src/components/debug/debug-panel.tsx | 288 ++++++++++++++++++ app/src/components/debug/debug-session.tsx | 77 +++++ app/src/components/debug/debug-tools.test.tsx | 68 +++++ app/src/components/debug/debug-tools.tsx | 54 ++++ .../layout/dashboard-header/client.tsx | 10 + 8 files changed, 749 insertions(+) create mode 100644 app/src/components/debug/debug-panel.stories.tsx create mode 100644 app/src/components/debug/debug-panel.test.tsx create mode 100644 app/src/components/debug/debug-panel.tsx create mode 100644 app/src/components/debug/debug-session.tsx create mode 100644 app/src/components/debug/debug-tools.test.tsx create mode 100644 app/src/components/debug/debug-tools.tsx diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index 42d7b2d1..71be4a73 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -4,6 +4,7 @@ import { Suspense } from "react"; import { Toaster } from "sonner"; import "./globals.css"; import { CommandPalette } from "@/components/command-palette"; +import { DebugTools } from "@/components/debug/debug-tools"; import { Footer } from "@/components/footer"; import { APP_NAME } from "@/lib/app"; import { branchTitlePrefix } from "@/lib/branch-title"; @@ -99,6 +100,7 @@ export default function RootLayout({