Files
dbx/apps/desktop/src/lib/sidebar/sidebarLayoutMonitor.ts
T

930 lines
36 KiB
TypeScript

/**
* Sidebar layout monitor — diagnostic instrumentation for the "tree expands and
* the sidebar scroll viewport gets stuck" class of layout bugs. A typical
* report from the field: after expanding a Dameng connection the viewport
* height freezes near 50% of the window and scrolling cannot reach the
* connections listed below the Dameng subtree.
*
* The monitor does not fix anything. It:
* - continuously samples the scroll shell geometry (shell/scroller/rows),
* - records tree-change and expand/collapse events,
* - detects anomaly signatures with hysteresis (3 consecutive samples),
* - and produces serializable reports (sample ring + event ring + DOM
* ancestor chain) written to the console — which also lands in the
* user-facing debug logs whenever debug logging is enabled.
*
* Enabling:
* - `pnpm dev` builds sample by default.
* - Production builds: `window.__dbxSidebarLayoutMonitor.enable()` or set
* localStorage["dbx-sidebar-layout-monitor"] = "1" before the sidebar
* mounts. Enabling the in-app "debug logs" setting also arms the monitor.
*/
import { isDebugLoggingEnabled } from "@/lib/backend/debugLog";
export const SIDEBAR_LAYOUT_MONITOR_STORAGE_KEY = "dbx-sidebar-layout-monitor";
export const SIDEBAR_LAYOUT_MONITOR_LABEL = "[DBX][sidebar-layout-monitor]";
export const SIDEBAR_LAYOUT_MONITOR_MAX_SAMPLES = 240;
export const SIDEBAR_LAYOUT_MONITOR_MAX_EVENTS = 200;
export const SIDEBAR_LAYOUT_MONITOR_ANOMALY_STREAK = 3;
const SIDEBAR_LAYOUT_MONITOR_SAMPLE_INTERVAL_MS = 1000;
// A gap larger than this between consecutive samples invalidates anomaly
// streaks: the samples are no longer "consecutive" in time.
const SIDEBAR_LAYOUT_MONITOR_STREAK_GAP_MS = 3000;
// Reports embed trimmed history: the full rings stay available on the window
// handle (recentSamples/recentEvents), while snapshots stay copy-paste sized.
export const SIDEBAR_LAYOUT_MONITOR_REPORT_SAMPLES = 60;
export const SIDEBAR_LAYOUT_MONITOR_REPORT_EVENTS = 60;
export type SidebarLayoutAnomalyFlag =
| "half-height-shell" // shell stuck near ~50% of the window while content overflows
| "under-scroll" // virtual tree: scrollHeight far below expected itemSize x count -> bottom rows unreachable
| "rows-clipped" // plain tree: DOM content extends beyond scrollHeight
| "blank-viewport" // rendered pool ends far above the viewport bottom while more content exists below
| "bottom-unreachable"; // even at max scrollTop the last rows stay out of view
export interface SidebarExpandedConnectionInfo {
id: string;
label: string;
type: string;
descendantCount: number;
}
export interface SidebarLayoutMonitorContext {
flatNodeCount: number;
useVirtualTree: boolean;
virtualItemSize: number;
scrollerEl: HTMLElement | null;
shellEl: HTMLElement | null;
rootEl: HTMLElement | null;
virtualScroller: Record<string, unknown> | null;
expandedConnections: SidebarExpandedConnectionInfo[];
}
export interface SidebarLayoutSampleScroller {
clientHeight: number;
scrollHeight: number;
scrollTop: number;
computedHeight: string;
overflowY: string;
position: string;
}
export interface SidebarLayoutSampleRows {
renderedRows: number;
firstTop: number;
firstBottom: number;
lastTop: number;
lastBottom: number;
}
export interface SidebarLayoutSample {
t: number;
windowHeight: number;
rootHeight: number;
shellHeight: number;
shellRatioOfRoot: number | null;
shellRatioOfWindow: number | null;
flatNodeCount: number;
scroller: SidebarLayoutSampleScroller;
virtual: {
itemSize: number;
expectedScrollHeight: number;
renderedRows: number;
visiblePoolSize: number | null;
spacers: { start: number; end: number } | null;
/** Live viewport window reported by the scroller (getScroll()). */
scrollWindow: { start: number; end: number } | null;
/** Height of the .connection-tree-content wrapper element. */
listHeight: number | null;
} | null;
rows: SidebarLayoutSampleRows | null;
sticky: { active: boolean; top: number; height: number; transform: string } | null;
}
export interface SidebarLayoutTreeChangeEvent {
type: "tree-change";
t: number;
prevCount: number;
count: number;
expandedConnections: SidebarExpandedConnectionInfo[];
/** Diagnostics: types/labels of the nodes added since the previous sample (capped). */
added?: Array<{ type: string; label: string }>;
/** Diagnostics: schema nodes currently expanded. A schema expanding without a
* preceding expand-toggle event means the expansion was programmatic. */
expandedSchemas?: Array<{ id: string; label: string }>;
}
export interface SidebarLayoutExpandToggleEvent {
type: "expand-toggle";
t: number;
nodeId: string;
label: string;
nodeType: string;
expanded: boolean;
}
export type SidebarLayoutMonitorEvent =
| SidebarLayoutTreeChangeEvent
| SidebarLayoutExpandToggleEvent
| { type: "anomaly"; t: number; flags: SidebarLayoutAnomalyFlag[]; sample: SidebarLayoutSample; digest: string }
| { type: "settle"; t: number; flags: SidebarLayoutAnomalyFlag[] }
| { type: "scroll-bottom"; t: number; atBottom: boolean; scrollTop: number; maxScrollTop: number };
export type SidebarLayoutMonitorEventInput = Omit<SidebarLayoutTreeChangeEvent, "t"> | Omit<SidebarLayoutExpandToggleEvent, "t">;
export interface SidebarLayoutAncestorInfo {
tag: string;
className: string;
id: string;
height: number;
computed: {
height: string;
minHeight: string;
flexGrow: string;
flexShrink: string;
flexBasis: string;
overflowY: string;
position: string;
display: string;
paddingTop: string;
paddingBottom: string;
borderTop: string;
borderBottom: string;
};
}
export interface SidebarLayoutReport {
id: string;
t: number;
flags: SidebarLayoutAnomalyFlag[];
sample: SidebarLayoutSample;
transition: {
shellHeightBefore: number | null;
shellHeightAfter: number;
rootHeightBefore: number | null;
rootHeightAfter: number;
treeCountBefore: number | null;
treeCountAfter: number;
changedWithinSamples: number;
} | null;
ancestors: SidebarLayoutAncestorInfo[];
content: {
list: { height: number; scrollHeight: number; rectHeight: number; overflowY: string; position: string; display: string } | null;
sticky: { active: boolean; top: number; height: number; rectHeight: number; transform: string } | null;
} | null;
internals: {
visiblePoolSize: number | null;
startSpacerSize: number | null;
endSpacerSize: number | null;
scrollWindow: unknown;
} | null;
expandedConnections: SidebarExpandedConnectionInfo[];
sampleHistory: SidebarLayoutSample[];
events: SidebarLayoutMonitorEvent[];
digest: string;
}
export interface SidebarLayoutMonitorState {
enabled: boolean;
startedAt: number | null;
sampleCount: number;
activeFlags: SidebarLayoutAnomalyFlag[];
activations: Partial<Record<SidebarLayoutAnomalyFlag, number>>;
lastReport: SidebarLayoutReport | null;
lastSample: SidebarLayoutSample | null;
}
export interface SidebarLayoutMonitorOptions {
readContext: () => SidebarLayoutMonitorContext;
/** Logs every sample and event at console.debug level. */
verbose?: boolean;
intervalMs?: number;
}
export interface SidebarLayoutMonitor {
start(): void;
stop(): void;
dispose(): void;
setEnabled(enabled: boolean): void;
isEnabled(): boolean;
recordEvent(event: SidebarLayoutMonitorEventInput): void;
snapshotNow(): SidebarLayoutReport;
getState(): SidebarLayoutMonitorState;
}
export interface SidebarLayoutMonitorDebugHandle {
enable(): void;
disable(): void;
isEnabled(): boolean;
setVerbose(verbose: boolean): void;
snapshot(): SidebarLayoutReport;
state(): SidebarLayoutMonitorState;
recentSamples(count?: number): SidebarLayoutSample[];
recentEvents(count?: number): SidebarLayoutMonitorEvent[];
}
export function isSidebarLayoutMonitorRequested(): boolean {
if (import.meta.env.DEV) return true;
try {
return localStorage.getItem(SIDEBAR_LAYOUT_MONITOR_STORAGE_KEY) === "1" || isDebugLoggingEnabled();
} catch {
return false;
}
}
export function ringPush<T>(ring: readonly T[], entry: T, maxSize: number): T[] {
const next = ring.length >= maxSize ? ring.slice(ring.length - maxSize + 1) : [...ring];
next.push(entry);
return next;
}
function ratioOrNull(value: number, total: number): number | null {
return total > 0 ? value / total : null;
}
function rounded(value: number): number {
return Math.round(value);
}
function elementGeometry(element: HTMLElement | null): { height: number } | null {
if (!element) return null;
const rect = element.getBoundingClientRect();
return { height: rounded(rect.height) };
}
function scrollerMetrics(element: HTMLElement | null): SidebarLayoutSampleScroller {
if (!element) {
return { clientHeight: 0, scrollHeight: 0, scrollTop: 0, computedHeight: "n/a", overflowY: "n/a", position: "n/a" };
}
const style = window.getComputedStyle(element);
return {
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
scrollTop: element.scrollTop,
computedHeight: style.height,
overflowY: style.overflowY,
position: style.position,
};
}
function captureRows(scrollerEl: HTMLElement | null, virtual: boolean): SidebarLayoutSampleRows | null {
if (!scrollerEl) return null;
const selector = virtual ? ".vue-recycle-scroller__item-view" : ".connection-tree-content > *";
const elements = Array.from(scrollerEl.querySelectorAll<HTMLElement>(selector));
if (elements.length === 0) return null;
if (!virtual) {
// Plain tree: rows are in normal flow, so offsetTop is relative to the
// content wrapper and reflects the true scroll position.
const visible = elements.filter((element) => element.offsetHeight > 0);
if (visible.length === 0) return null;
const first = visible[0];
const last = visible[visible.length - 1];
return {
renderedRows: visible.length,
firstTop: first.offsetTop,
firstBottom: first.offsetTop + first.offsetHeight,
lastTop: last.offsetTop,
lastBottom: last.offsetTop + last.offsetHeight,
};
}
// Virtual (transform) tree: item-views are absolutely positioned with
// translateY, so offsetTop is always 0 and ignores the scroll position.
// Measure via getBoundingClientRect (transform-aware) relative to the
// scroller viewport, and skip the recycled views parked far above
// (hiddenPosition = -999999). Rows are viewport-relative here.
const scrollerRect = scrollerEl.getBoundingClientRect();
const scrollerTop = scrollerRect.top;
const scrollerHeight = scrollerRect.height;
let renderedRows = 0;
let firstTop = Number.POSITIVE_INFINITY;
let firstBottom = 0;
let lastTop = 0;
let lastBottom = 0;
for (const element of elements) {
const rect = element.getBoundingClientRect();
if (rect.height <= 0) continue;
const top = rect.top - scrollerTop;
if (top < -scrollerHeight) continue;
renderedRows += 1;
const bottom = top + rect.height;
if (top < firstTop) {
firstTop = top;
firstBottom = bottom;
}
if (bottom > lastBottom) {
lastTop = top;
lastBottom = bottom;
}
}
if (renderedRows === 0) return null;
return { renderedRows, firstTop: Math.round(firstTop), firstBottom: Math.round(firstBottom), lastTop: Math.round(lastTop), lastBottom: Math.round(lastBottom) };
}
function captureSticky(scrollerEl: HTMLElement | null): SidebarLayoutSample["sticky"] {
const header = scrollerEl?.querySelector<HTMLElement>(".sticky-database-header") ?? null;
if (!header || header.clientHeight === 0) return null;
const style = window.getComputedStyle(header);
return {
active: true,
top: rounded(header.getBoundingClientRect().top),
height: header.clientHeight,
transform: style.transform,
};
}
function asFiniteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : null;
}
function captureVirtualInternals(virtualScroller: Record<string, unknown> | null) {
if (!virtualScroller) return null;
const visiblePool = virtualScroller["visiblePool"];
let scrollWindow: unknown = null;
const getScroll = virtualScroller["getScroll"];
if (typeof getScroll === "function") {
try {
const result = (getScroll as () => unknown)();
if (typeof result === "object" && result !== null) scrollWindow = result;
} catch {
// Reading scroller internals must never break the app.
}
}
const scrollWindowNumbers = (() => {
if (typeof scrollWindow !== "object" || scrollWindow === null) return null;
const candidate = scrollWindow as Record<string, unknown>;
return asFiniteNumber(candidate["start"]) != null && asFiniteNumber(candidate["end"]) != null ? { start: asFiniteNumber(candidate["start"]) as number, end: asFiniteNumber(candidate["end"]) as number } : null;
})();
return {
visiblePoolSize: Array.isArray(visiblePool) ? visiblePool.length : null,
startSpacerSize: asFiniteNumber(virtualScroller["startSpacerSize"]),
endSpacerSize: asFiniteNumber(virtualScroller["endSpacerSize"]),
scrollWindow: scrollWindowNumbers,
};
}
/**
* Reads the DOM geometry. Every value is a plain number/string so samples can
* be stored, serialized and compared without retaining element references.
*/
export function captureSidebarLayoutSample(context: SidebarLayoutMonitorContext): SidebarLayoutSample {
const now = Date.now();
if (typeof document === "undefined") {
return {
t: now,
windowHeight: 0,
rootHeight: 0,
shellHeight: 0,
shellRatioOfRoot: null,
shellRatioOfWindow: null,
flatNodeCount: context.flatNodeCount,
scroller: { clientHeight: 0, scrollHeight: 0, scrollTop: 0, computedHeight: "n/a", overflowY: "n/a", position: "n/a" },
virtual: null,
rows: null,
sticky: null,
};
}
const root = elementGeometry(context.rootEl);
const shell = elementGeometry(context.shellEl);
const windowHeight = window.innerHeight;
const rowGeometry = captureRows(context.scrollerEl, context.useVirtualTree);
const internals = captureVirtualInternals(context.virtualScroller);
const listElement = context.scrollerEl?.querySelector<HTMLElement>(".connection-tree-content") ?? null;
return {
t: now,
windowHeight,
rootHeight: root?.height ?? 0,
shellHeight: shell?.height ?? 0,
shellRatioOfRoot: shell ? ratioOrNull(shell.height, root?.height ?? 0) : null,
shellRatioOfWindow: shell ? ratioOrNull(shell.height, windowHeight) : null,
flatNodeCount: context.flatNodeCount,
scroller: scrollerMetrics(context.scrollerEl),
virtual: context.useVirtualTree
? {
itemSize: context.virtualItemSize,
expectedScrollHeight: context.flatNodeCount * context.virtualItemSize,
renderedRows: rowGeometry?.renderedRows ?? 0,
visiblePoolSize: internals?.visiblePoolSize ?? null,
spacers: internals?.startSpacerSize != null || internals?.endSpacerSize != null ? { start: internals.startSpacerSize ?? 0, end: internals.endSpacerSize ?? 0 } : null,
scrollWindow: internals?.scrollWindow ?? null,
listHeight: listElement ? listElement.offsetHeight : null,
}
: null,
rows: rowGeometry,
sticky: captureSticky(context.scrollerEl),
};
}
export function unreachableRowCount(sample: SidebarLayoutSample): number {
const virtual = sample.virtual;
if (!virtual || virtual.itemSize <= 0 || sample.flatNodeCount <= 0) return 0;
const reachable = Math.floor(sample.scroller.scrollHeight / virtual.itemSize);
return Math.max(0, sample.flatNodeCount - reachable);
}
function maxOf(values: number[]): number {
return values.length > 0 ? Math.max(...values) : 0;
}
function minOf(values: number[]): number {
return values.length > 0 ? Math.min(...values) : 0;
}
/**
* Pure anomaly detection over a sample history. Only the last sample is
* classified; the earlier entries provide the stability context (hysteresis).
* Callers feed consecutive samples so streaks build up naturally.
*/
export function detectLayoutAnomalies(samples: readonly SidebarLayoutSample[]): SidebarLayoutAnomalyFlag[] {
const sample = samples[samples.length - 1];
if (!sample) return [];
const flags: SidebarLayoutAnomalyFlag[] = [];
const streakWindow = samples.slice(Math.max(0, samples.length - SIDEBAR_LAYOUT_MONITOR_ANOMALY_STREAK));
const halfInBand = (value: number | null) => value !== null && value >= 0.4 && value <= 0.6;
if (
sample.shellHeight >= 120 &&
sample.scroller.clientHeight > 0 &&
sample.scroller.scrollHeight > sample.shellHeight + 4 &&
streakWindow.length >= SIDEBAR_LAYOUT_MONITOR_ANOMALY_STREAK &&
streakWindow.every((entry) => entry.shellHeight >= 120 && halfInBand(entry.shellRatioOfWindow)) &&
maxOf(streakWindow.map((entry) => entry.shellHeight)) - minOf(streakWindow.map((entry) => entry.shellHeight)) <= 3
) {
flags.push("half-height-shell");
}
const virtual = sample.virtual;
if (virtual) {
if (virtual.itemSize > 0 && sample.flatNodeCount > 8 && virtual.expectedScrollHeight - sample.scroller.scrollHeight >= 2 * virtual.itemSize) {
flags.push("under-scroll");
}
if (sample.flatNodeCount > 0 && sample.rows) {
// Rows in virtual mode are measured relative to the scroller viewport
// (rect-based), so the gap is against clientHeight — not content
// coordinates. A blank band means the last materialized row ends well
// above the viewport bottom while more content exists below it.
const gap = sample.scroller.clientHeight - sample.rows.lastBottom;
const contentBelowViewport = sample.scroller.scrollHeight > sample.scroller.scrollTop + sample.scroller.clientHeight + 2 * virtual.itemSize;
if ((gap > 2 * virtual.itemSize && contentBelowViewport) || sample.rows.lastBottom < 0) {
flags.push("blank-viewport");
}
}
if (unreachableRowCount(sample) > 0 && sample.scroller.scrollTop >= sample.scroller.scrollHeight - sample.scroller.clientHeight - 2) {
flags.push("bottom-unreachable");
}
} else if (sample.rows && sample.rows.lastBottom > sample.scroller.scrollHeight + 2) {
flags.push("rows-clipped");
}
return flags;
}
function captureAncestors(shellEl: HTMLElement | null): SidebarLayoutAncestorInfo[] {
if (typeof window === "undefined" || !shellEl) return [];
const result: SidebarLayoutAncestorInfo[] = [];
let element: HTMLElement | null = shellEl;
while (element && result.length < 14) {
const style = window.getComputedStyle(element);
result.push({
tag: element.tagName.toLowerCase(),
className: typeof element.className === "string" ? element.className : "",
id: element.id,
height: rounded(element.getBoundingClientRect().height),
computed: {
height: style.height,
minHeight: style.minHeight,
flexGrow: style.flexGrow,
flexShrink: style.flexShrink,
flexBasis: style.flexBasis,
overflowY: style.overflowY,
position: style.position,
display: style.display,
paddingTop: style.paddingTop,
paddingBottom: style.paddingBottom,
borderTop: style.borderTopWidth,
borderBottom: style.borderBottomWidth,
},
});
element = element.parentElement;
}
return result;
}
function captureContentInfo(scrollerEl: HTMLElement | null): SidebarLayoutReport["content"] {
if (typeof window === "undefined" || !scrollerEl) return null;
const list = scrollerEl.querySelector<HTMLElement>(".connection-tree-content") ?? null;
const sticky = scrollerEl.querySelector<HTMLElement>(".sticky-database-header") ?? null;
return {
list: list
? {
height: list.offsetHeight,
scrollHeight: list.scrollHeight,
rectHeight: rounded(list.getBoundingClientRect().height),
overflowY: getComputedStyle(list).overflowY,
position: getComputedStyle(list).position,
display: getComputedStyle(list).display,
}
: null,
sticky: sticky
? {
active: true,
top: rounded(sticky.getBoundingClientRect().top),
height: sticky.clientHeight,
rectHeight: rounded(sticky.getBoundingClientRect().height),
transform: getComputedStyle(sticky).transform,
}
: null,
};
}
function findShellHeightTransition(samples: readonly SidebarLayoutSample[]): SidebarLayoutReport["transition"] {
if (samples.length < 2) return null;
const recent = samples.slice(-12);
let largestDelta = 0;
let index = -1;
for (let i = 1; i < recent.length; i += 1) {
const delta = Math.abs(recent[i].shellHeight - recent[i - 1].shellHeight);
if (delta > largestDelta) {
largestDelta = delta;
index = i;
}
}
if (index < 0) return null;
const before = recent[index - 1];
const after = recent[index];
return {
shellHeightBefore: before.shellHeight,
shellHeightAfter: after.shellHeight,
rootHeightBefore: before.rootHeight,
rootHeightAfter: after.rootHeight,
treeCountBefore: before.flatNodeCount,
treeCountAfter: after.flatNodeCount,
changedWithinSamples: recent.length - index,
};
}
function formatConnections(connections: SidebarExpandedConnectionInfo[]): string {
if (connections.length === 0) return "none";
return connections.map((entry) => `${entry.label} (id=${entry.id.slice(0, 8)}, type=${entry.type}, desc=${entry.descendantCount})`).join(", ");
}
export function buildAnomalyDigest(report: SidebarLayoutReport): string {
const sample = report.sample;
const virtual = sample.virtual;
const windowRatio = sample.shellRatioOfWindow == null ? "?" : `${Math.round(sample.shellRatioOfWindow * 100)}%`;
const rootRatio = sample.shellRatioOfRoot == null ? "?" : `${Math.round(sample.shellRatioOfRoot * 100)}%`;
const lines = [
`${SIDEBAR_LAYOUT_MONITOR_LABEL} anomaly detected (${report.id}): ${report.flags.join(", ") || "none"}`,
` geometry: window=${sample.windowHeight} root=${sample.rootHeight} shell=${sample.shellHeight} (window ${windowRatio}, root ${rootRatio}) | scroller client=${sample.scroller.clientHeight} scroll=${sample.scroller.scrollHeight} top=${sample.scroller.scrollTop} max-scroll=${sample.scroller.scrollHeight - sample.scroller.clientHeight} computed-height=${sample.scroller.computedHeight} overflow-y=${sample.scroller.overflowY}`,
];
if (virtual) {
const viewport = virtual.scrollWindow ? `${virtual.scrollWindow.start}..${virtual.scrollWindow.end}` : "?";
lines.push(
` virtual: ${sample.flatNodeCount} rows x ${virtual.itemSize}px -> expected ${virtual.expectedScrollHeight}px, actual ${sample.scroller.scrollHeight}px, unreachable ${unreachableRowCount(sample)} rows | pool=${virtual.visiblePoolSize ?? "?"} spacers=${virtual.spacers ? `${virtual.spacers.start}/${virtual.spacers.end}` : "?"} rendered=${virtual.renderedRows} viewport=${viewport} list=${virtual.listHeight ?? "?"}`,
);
}
if (sample.rows) {
lines.push(` rows: rendered=${sample.rows.renderedRows} first=${sample.rows.firstTop}..${sample.rows.firstBottom} last=${sample.rows.lastTop}..${sample.rows.lastBottom}`);
}
if (sample.sticky) {
lines.push(` sticky: active top=${sample.sticky.top} height=${sample.sticky.height} transform=${sample.sticky.transform}`);
}
if (report.transition) {
const transition = report.transition;
const shellBefore = transition.shellHeightBefore;
const delta = shellBefore == null ? "?" : transition.shellHeightAfter - shellBefore;
lines.push(` transition: shell ${shellBefore ?? "?"} -> ${transition.shellHeightAfter} px (delta ${delta}) within ${transition.changedWithinSamples} samples; tree count ${transition.treeCountBefore ?? "?"} -> ${transition.treeCountAfter}`);
}
const expansions = report.events.filter((event): event is SidebarLayoutExpandToggleEvent => event.type === "expand-toggle").slice(-3);
for (const event of expansions) {
lines.push(` expand-toggle: ${event.expanded ? "expanded" : "collapsed"} ${event.label} (${event.nodeType}, id=${event.nodeId.slice(0, 8)})`);
}
lines.push(` expanded connections: ${formatConnections(report.expandedConnections)}`);
lines.push(` full report: window.__dbxSidebarLayoutMonitor.snapshot()`);
return lines.join("\n");
}
export function buildSidebarLayoutReport(
sample: SidebarLayoutSample,
options: {
sampleHistory: readonly SidebarLayoutSample[];
events: readonly SidebarLayoutMonitorEvent[];
context: SidebarLayoutMonitorContext;
flags: SidebarLayoutAnomalyFlag[];
},
): SidebarLayoutReport {
const report: SidebarLayoutReport = {
id: `sidebar-layout-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
t: sample.t,
flags: options.flags,
sample,
transition: findShellHeightTransition(options.sampleHistory),
ancestors: captureAncestors(options.context.shellEl),
content: captureContentInfo(options.context.scrollerEl),
internals: captureVirtualInternals(options.context.virtualScroller),
expandedConnections: options.context.expandedConnections,
sampleHistory: [...options.sampleHistory].slice(-SIDEBAR_LAYOUT_MONITOR_REPORT_SAMPLES),
events: [...options.events].slice(-SIDEBAR_LAYOUT_MONITOR_REPORT_EVENTS),
digest: "",
};
report.digest = buildAnomalyDigest(report);
return report;
}
export function createSidebarLayoutMonitor(options: SidebarLayoutMonitorOptions): SidebarLayoutMonitor {
const readContext = options.readContext;
const intervalMs = options.intervalMs ?? SIDEBAR_LAYOUT_MONITOR_SAMPLE_INTERVAL_MS;
let verbose = options.verbose ?? false;
let enabled = isSidebarLayoutMonitorRequested();
let started = false;
let active = false;
let startedAt: number | null = null;
let timer: ReturnType<typeof setInterval> | null = null;
let burstTimer: ReturnType<typeof setTimeout> | null = null;
let resizeObserver: ResizeObserver | null = null;
let observedScroller: HTMLElement | null = null;
let observedShell: HTMLElement | null = null;
let sampleCount = 0;
let samples: SidebarLayoutSample[] = [];
let events: SidebarLayoutMonitorEvent[] = [];
let streaks = new Map<SidebarLayoutAnomalyFlag, number>();
let activeFlags: SidebarLayoutAnomalyFlag[] = [];
let activations: Partial<Record<SidebarLayoutAnomalyFlag, number>> = {};
let lastReport: SidebarLayoutReport | null = null;
let handle: SidebarLayoutMonitorDebugHandle | null = null;
let scrollListenerElement: HTMLElement | null = null;
let lastScrollCaptureAt = 0;
let lastScrollAtBottom: boolean | null = null;
let lastSampleT = 0;
// An explicit enable()/disable() from the debug handle wins over the
// auto-arming rules (DEV default, localStorage flag, debug-logs setting),
// so the user can actually turn the monitor off in a DEV session.
let userOverride: boolean | null = null;
function onTreeScroll() {
// Throttle scroll-driven captures so continuous scrolling cannot flood
// the sample ring with near-identical entries.
const now = Date.now();
if (now - lastScrollCaptureAt < 200) return;
lastScrollCaptureAt = now;
const context = readContext();
const scroller = context.scrollerEl;
if (scroller) {
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
const atBottom = scroller.scrollTop >= maxScrollTop - 4;
if (atBottom !== lastScrollAtBottom) {
lastScrollAtBottom = atBottom;
events = ringPush(events, { type: "scroll-bottom", t: now, atBottom, scrollTop: Math.round(scroller.scrollTop), maxScrollTop }, SIDEBAR_LAYOUT_MONITOR_MAX_EVENTS);
}
}
captureSample(context);
}
function attachScrollerListeners(context: SidebarLayoutMonitorContext) {
const scroller = context.scrollerEl;
if (scroller === scrollListenerElement) return;
if (scrollListenerElement) scrollListenerElement.removeEventListener("scroll", onTreeScroll);
scrollListenerElement = null;
if (scroller) {
scroller.addEventListener("scroll", onTreeScroll, { passive: true });
scrollListenerElement = scroller;
}
}
function detachScrollerListeners() {
if (scrollListenerElement) scrollListenerElement.removeEventListener("scroll", onTreeScroll);
scrollListenerElement = null;
}
function keepResizeObservation(context: SidebarLayoutMonitorContext) {
if (typeof ResizeObserver === "undefined") return;
if (context.scrollerEl === observedScroller && context.shellEl === observedShell) return;
resizeObserver?.disconnect();
resizeObserver = null;
observedScroller = null;
observedShell = null;
const targets = [context.scrollerEl, context.shellEl].filter((element): element is HTMLElement => !!element);
if (targets.length === 0) return;
resizeObserver = new ResizeObserver(() => captureSample());
for (const target of targets) resizeObserver.observe(target);
observedScroller = context.scrollerEl;
observedShell = context.shellEl;
}
function persistEnabled() {
try {
if (enabled) localStorage.setItem(SIDEBAR_LAYOUT_MONITOR_STORAGE_KEY, "1");
else localStorage.removeItem(SIDEBAR_LAYOUT_MONITOR_STORAGE_KEY);
} catch {
// Storage may be unavailable; the in-memory flag still applies.
}
}
function setEnabled(next: boolean, fromUser = true) {
if (enabled === next) return;
enabled = next;
if (fromUser) userOverride = next;
persistEnabled();
active = next;
if (next) {
console.info(SIDEBAR_LAYOUT_MONITOR_LABEL, "enabled", new Date().toISOString());
captureSample();
} else {
console.info(SIDEBAR_LAYOUT_MONITOR_LABEL, "disabled");
}
}
function captureSample(prefetchedContext?: SidebarLayoutMonitorContext) {
if (!started || !active) return;
const context = prefetchedContext ?? readContext();
if (!context.scrollerEl || !context.shellEl) return;
attachScrollerListeners(context);
keepResizeObservation(context);
const sample = captureSidebarLayoutSample(context);
samples = ringPush(samples, sample, SIDEBAR_LAYOUT_MONITOR_MAX_SAMPLES);
sampleCount += 1;
// Streaks are built from consecutive samples; a long gap (tab hidden,
// tree emptied, renderer switch) means the previous streak is stale.
if (lastSampleT > 0 && sample.t - lastSampleT > SIDEBAR_LAYOUT_MONITOR_STREAK_GAP_MS) {
streaks.clear();
}
lastSampleT = sample.t;
const detected = detectLayoutAnomalies(samples);
const flagSet = new Set(detected);
for (const flag of detected) streaks.set(flag, (streaks.get(flag) ?? 0) + 1);
const newlyActivated: SidebarLayoutAnomalyFlag[] = [];
for (const [flag, streak] of streaks) {
if (!flagSet.has(flag)) {
streaks.delete(flag);
} else if (streak >= SIDEBAR_LAYOUT_MONITOR_ANOMALY_STREAK && !activeFlags.includes(flag)) {
activeFlags = [...activeFlags, flag];
activations[flag] = (activations[flag] ?? 0) + 1;
newlyActivated.push(flag);
}
}
if (newlyActivated.length > 0) {
// Anomaly events carry the activating sample + digest so that
// recentEvents() alone tells the full geometry story.
const report = buildSidebarLayoutReport(sample, { sampleHistory: samples, events, context, flags: [...activeFlags] });
lastReport = report;
events = ringPush(events, { type: "anomaly", t: sample.t, flags: newlyActivated, sample: report.sample, digest: report.digest }, SIDEBAR_LAYOUT_MONITOR_MAX_EVENTS);
}
const settledFlags = activeFlags.filter((flag) => !flagSet.has(flag));
if (settledFlags.length > 0) {
activeFlags = activeFlags.filter((flag) => flagSet.has(flag));
events = ringPush(events, { type: "settle", t: sample.t, flags: settledFlags }, SIDEBAR_LAYOUT_MONITOR_MAX_EVENTS);
console.info(SIDEBAR_LAYOUT_MONITOR_LABEL, "anomaly settled", settledFlags.join(","));
}
if (activeFlags.length > 0) {
const report = buildSidebarLayoutReport(sample, { sampleHistory: samples, events, context, flags: [...activeFlags] });
lastReport = report;
if (newlyActivated.length > 0 || sampleCount % 4 === 0) {
console.warn(report.digest);
}
}
if (verbose) {
console.debug(SIDEBAR_LAYOUT_MONITOR_LABEL, "sample", JSON.stringify({ t: sample.t, shell: sample.shellHeight, ratio: sample.shellRatioOfWindow, scroller: sample.scroller, count: sample.flatNodeCount, rows: sample.rows?.renderedRows ?? 0 }));
}
}
function recordEvent(event: SidebarLayoutMonitorEventInput) {
const stamped = { ...event, t: Date.now() } as SidebarLayoutMonitorEvent;
events = ringPush(events, stamped, SIDEBAR_LAYOUT_MONITOR_MAX_EVENTS);
if (event.type === "tree-change") {
scheduleBurst();
}
if (verbose) {
console.debug(SIDEBAR_LAYOUT_MONITOR_LABEL, "event", JSON.stringify(stamped));
}
}
// Streaming tree changes (e.g. a Dameng subtree loading in batches) fire many
// tree-change events back to back; merge the post-change captures into a
// single pending sample instead of stacking one timer per event.
function scheduleBurst() {
if (burstTimer) return;
burstTimer = setTimeout(() => {
burstTimer = null;
captureSample();
}, 150);
}
function snapshotNow(): SidebarLayoutReport {
const context = readContext();
const sample = captureSidebarLayoutSample(context);
const history = [...samples, sample];
const flags = detectLayoutAnomalies(history);
const report = buildSidebarLayoutReport(sample, { sampleHistory: history, events, context, flags });
lastReport = report;
return report;
}
function installWindowHandle() {
if (typeof window === "undefined") return;
const nextHandle: SidebarLayoutMonitorDebugHandle = {
enable: () => setEnabled(true),
disable: () => setEnabled(false),
isEnabled: () => enabled,
setVerbose: (nextVerbose) => {
verbose = nextVerbose;
},
snapshot: () => snapshotNow(),
state: () => getState(),
recentSamples: (count = 40) => samples.slice(-count),
recentEvents: (count = 30) => events.slice(-count),
};
handle = nextHandle;
window.__dbxSidebarLayoutMonitor = nextHandle;
}
function uninstallWindowHandle() {
if (typeof window === "undefined") return;
if (window.__dbxSidebarLayoutMonitor === handle) {
delete window.__dbxSidebarLayoutMonitor;
}
handle = null;
}
function getState(): SidebarLayoutMonitorState {
return {
enabled,
startedAt,
sampleCount,
activeFlags: [...activeFlags],
activations: { ...activations },
lastReport,
lastSample: samples[samples.length - 1] ?? null,
};
}
function stop() {
if (!started) return;
started = false;
active = false;
if (timer) clearInterval(timer);
timer = null;
if (burstTimer) clearTimeout(burstTimer);
burstTimer = null;
resizeObserver?.disconnect();
resizeObserver = null;
observedScroller = null;
observedShell = null;
detachScrollerListeners();
}
function dispose() {
stop();
uninstallWindowHandle();
}
function start() {
if (started) return;
started = true;
startedAt = Date.now();
active = enabled;
installWindowHandle();
console.info(SIDEBAR_LAYOUT_MONITOR_LABEL, `started (${enabled ? "armed" : "idle — call window.__dbxSidebarLayoutMonitor.enable() or enable the debug-logs setting to arm"})`, "interval", intervalMs);
// The interval always runs so the monitor can arm itself when the
// debug-logs setting or the localStorage flag is turned on later.
timer = setInterval(tick, intervalMs);
if (enabled) captureSample();
}
function tick() {
if (userOverride !== null) {
if (enabled) captureSample();
return;
}
const requested = isSidebarLayoutMonitorRequested();
if (requested !== enabled) {
setEnabled(requested, false);
return;
}
if (enabled) captureSample();
}
return {
start,
stop,
dispose,
setEnabled,
isEnabled: () => enabled,
recordEvent,
snapshotNow,
getState,
};
}
declare global {
interface Window {
__dbxSidebarLayoutMonitor?: SidebarLayoutMonitorDebugHandle;
}
}