import React from "react";
import DataTable from "react-data-table-component";
import type { TableColumn } from "react-data-table-component";
import type { AllocSites, ArraysBySize, BiggestCollectionRow, BiggestCollections, ClassRow, CollectionAttribution, CollectionContents, CollectionsAnalysis, Component, DominatorAnalysis, DuplicateClass, FieldsBySize, FillRatioBucket, FrameworkAnalysis, GcRootClassRow, GcRootRetainedRow, HeapComposition, HistRow, ImmDomPair, KindStat, LeakIndicators, LoaderRollup, MergedPathNode, ObjGraphEdge, ObjGraphFlat, ObjGraphFlatNode, ObjRow, PackageNode, QueryResult, QueryValue, ReferencesAnalysis, ReferenceStats, RefStatClassRow, Report, RootPathStep, SeriesClassRow, SeriesDiffResult, SeriesSuspectRow, Suspect, SystemOverview, ThreadInfo, ThreadLocalLeakRow, ThreadLocalObj, TopArrays, TopComponents, TypeEdge, TypeEdgeDiff, UnreachableClassRow } from "./types";
import { fmtCount, fmtExactBytes, fmtPct, formatBytes, formatBytesKB, formatEpochMs, formatDateNice, pctOf, shortLoader } from "./format";
import {
CompositionStackedBar,
ConcentrationChart,
ConcentrationStackedBar,
DepthHistogramChart,
GcRootsChart,
GcRootsRetainedChart,
HeapCompositionChart,
LeakShareChart,
QueryViz,
RetainedGrowthChart,
TopClassesChart,
ZoomableTreemap,
} from "./charts";
import { UnreachableDomTreeSection, DomSubtreeSvg } from "./domTree";
import { sankey, sankeyLinkHorizontal } from "d3-sankey";
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide } from "d3-force";
import { hierarchy, treemap, treemapSquarify } from "d3-hierarchy";
import cytoscape from "cytoscape";
import coseBilkent from "cytoscape-cose-bilkent";
cytoscape.use(coseBilkent as cytoscape.Ext);
// ── Theme Toggle ─────────────────────────────────────────────────────────────
// Cycles auto → light → dark → auto. Persists the choice in localStorage so it
// survives page reloads. Uses data-theme on <html> so CSS vars override the OS
// media query only when a manual choice is in effect.
type ThemeMode = "auto" | "light" | "dark";
const CYCLE: Record<ThemeMode, ThemeMode> = { auto: "light", light: "dark", dark: "auto" };
const GLYPHS: Record<ThemeMode, string> = { auto: "◐", light: "☀", dark: "☾" };
function applyMode(m: ThemeMode) {
if (m === "auto") {
document.documentElement.removeAttribute("data-theme");
try { localStorage.removeItem("hprof-theme"); } catch (_) { /* file:// storage may throw */ }
} else {
document.documentElement.dataset.theme = m;
try { localStorage.setItem("hprof-theme", m); } catch (_) { /* file:// storage may throw */ }
}
}
function ThemeToggle() {
const [mode, setMode] = React.useState<ThemeMode>("auto");
React.useEffect(() => {
try {
const saved = localStorage.getItem("hprof-theme");
if (saved === "light" || saved === "dark") {
setMode(saved);
applyMode(saved);
}
} catch (_) { /* file:// storage may throw */ }
// Sync theme across tabs / the browser shell.
const onStorage = (e: StorageEvent) => {
if (e.key !== "hprof-theme") return;
const v = e.newValue;
const m: ThemeMode = (v === "light" || v === "dark") ? v : "auto";
setMode(m);
applyMode(m);
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
const next = CYCLE[mode];
return (
<button
className="theme-toggle"
aria-label={"Theme: " + mode}
onClick={() => { applyMode(next); setMode(next); }}
>
{GLYPHS[mode]} Theme: {mode.charAt(0).toUpperCase() + mode.slice(1)}
</button>
);
}
// ── Report page header ────────────────────────────────────────────────────────
function ReportHeader({ report }: { report: Report }) {
const src = report.overview?.source_name ?? "(unknown)";
const dumpMs = report.overview?.dump_creation ?? 0;
return (
<div className="report-header">
<h1>
Heap Dump Analysis:{" "}
<span className="copy-cell">
<code>{src}</code>
<CopyBtn text={src} />
</span>
</h1>
<p className="report-meta">
{dumpMs > 0 && (
<>
<span title={formatEpochMs(dumpMs)}>Captured {formatDateNice(dumpMs)}</span>
<span className="report-meta-sep">·</span>
</>
)}
<span title="Fully self-contained — open offline, email, or archive.">📦 Self-contained</span>
<span className="report-meta-sep">·</span>
<span title="All byte sizes use binary prefixes: 1 KB = 1024 B, 1 MB = 1024 KB, 1 GB = 1024 MB">1 KB = 1024 B</span>
</p>
<p className="report-blurb">
Generated by{" "}
<a href="https://github.com/parttimenerd/hprof-analyzer" target="_blank" rel="noopener">hprof-analyzer</a>
{" — fast Rust-based Java heap dump analysis. "}
<a href="https://crates.io/crates/hprof-analyzer" target="_blank" rel="noopener">cargo install</a>
{" · "}
<a href="https://parttimenerd.github.io/hprof-analyzer/" target="_blank" rel="noopener">web version</a>
{" · "}
<a href="https://github.com/parttimenerd/hprof-analyzer" target="_blank" rel="noopener">GitHub</a>
</p>
</div>
);
}
// ── Table expansion context ───────────────────────────────────────────────────
// A global toggle that makes every capped table expand all its rows at once.
const TableExpansionCtx = React.createContext(false);
// ── Dominator data availability context ──────────────────────────────────────
// True when the report has dominator pairs — controls whether PivotBtns appear.
const HasDomDataCtx = React.createContext(false);
// ── Object-graph availability context ────────────────────────────────────────
// Carries the obj_graph_flat node map so ExploreBtns can check per-index.
const ObjGraphCtx = React.createContext<Record<string, import("./types").ObjGraphFlatNode> | null>(null);
// ── Navigation history ────────────────────────────────────────────────────────
// Tracks the last few section hash fragments so a back-button can appear
// after the user pivots away from a section.
interface NavEntry { hash: string; label: string }
const NavHistoryCtx = React.createContext<{
push: (entry: NavEntry) => void;
back: () => void;
canBack: boolean;
}>({ push: () => {}, back: () => {}, canBack: false });
// ── Per-table KB toggle ───────────────────────────────────────────────────────
// Returns [fmtB, toggleBtn, useKB] — the byte formatter, a button that switches
// between auto-scaled (1.2 MB) and always-KB display, and the current mode flag.
function useFmtBytes(): [(n: number) => string, React.ReactNode, boolean] {
const [useKB, setUseKB] = React.useState(false);
const fmtB = useKB ? formatBytesKB : formatBytes;
const btn = (
<button className="show-more-btn" onClick={() => setUseKB(v => !v)}
title="Toggle byte display: auto-scaled vs always-KB">
{useKB ? "Show as B, KB, …" : "Show as KB"}
</button>
);
return [fmtB, btn, useKB];
}
// Returns a DataTable `cell` renderer for a byte value column.
// In KB mode: shows plain number (no suffix) with exact bytes as title tooltip.
// In normal mode: shows auto-scaled value (e.g. "1.2 MB").
function byteCell<T>(selector: (row: T) => number, fmtB: (n: number) => string, _useKB: boolean): (row: T) => React.ReactNode {
return (row: T) => {
const raw = selector(row);
return <span title={fmtExactBytes(raw)}>{fmtB(raw)}</span>;
};
}
const TABLE_CAP = 20;
function useCapped<T>(items: T[], cap = TABLE_CAP): {
visible: T[];
hasMore: boolean;
extra: number;
showAll: boolean;
setShowAll: (v: boolean) => void;
} {
const expandAll = React.useContext(TableExpansionCtx);
const [showAll, setShowAll] = React.useState(false);
const open = expandAll || showAll;
return {
visible: open ? items : items.slice(0, cap),
hasMore: items.length > cap,
extra: items.length - cap,
showAll: open,
setShowAll,
};
}
function ShowMoreRow({ extra, cols, showAll, setShowAll }: { extra: number; cols: number; showAll: boolean; setShowAll: (v: boolean) => void }) {
if (extra <= 0) return null;
return (
<tr>
<td colSpan={cols} style={{ textAlign: "center", padding: "0.4rem 0" }}>
{showAll ? (
<button className="show-more-btn" onClick={() => setShowAll(false)}>Show fewer</button>
) : (
<button className="show-more-btn" onClick={() => setShowAll(true)}>Show {fmtCount(extra)} more</button>
)}
</td>
</tr>
);
}
// A capped <tbody> for tables whose <tfoot> totals must reflect the FULL row
// set. Renders only the first `cap` rows (unless expanded, per-table or via the
// global expand-all toggle) plus a "Show N more" row, while the caller keeps
// computing totals over the complete array. `cols` is the column count for the
// ShowMoreRow's colSpan.
function CappedTbody<T>({ rows, cols, renderRow, cap = TABLE_CAP }: {
rows: T[];
cols: number;
renderRow: (row: T, i: number) => React.ReactNode;
cap?: number;
}) {
const { visible, extra, showAll, setShowAll } = useCapped(rows, cap);
return (
<tbody>
{visible.map(renderRow)}
<ShowMoreRow extra={extra} cols={cols} showAll={showAll} setShowAll={setShowAll} />
</tbody>
);
}
// StdTable — standard table with filter toolbar + DataTable + show-more.
// searchKeys: row field names to match filter text against. Pass [] to hide search.
function StdTable<T extends object>({
columns, data, searchKeys = [], keyField,
defaultSortFieldId, defaultSortAsc = false,
fmtBtn, extraBtns, cap = TABLE_CAP,
onRowClicked, onRowContextMenu, rowClickTitle,
}: {
columns: TableColumn<T>[];
data: T[];
searchKeys?: (keyof T & string)[];
keyField?: string;
defaultSortFieldId?: string;
defaultSortAsc?: boolean;
fmtBtn?: React.ReactNode;
extraBtns?: React.ReactNode;
cap?: number;
onRowClicked?: (row: T) => void;
onRowContextMenu?: (row: T, e: React.MouseEvent) => void;
rowClickTitle?: string;
}) {
const [filter, setFilter] = React.useState("");
const filtered = React.useMemo(() => {
if (!searchKeys.length || !filter) return data;
const lc = filter.toLowerCase();
return data.filter(row => searchKeys.some(k => String((row as any)[k] ?? "").toLowerCase().includes(lc)));
}, [data, filter, searchKeys]);
const { visible, extra, showAll, setShowAll } = useCapped(filtered, cap);
const hasToolbar = searchKeys.length > 0 || fmtBtn || extraBtns;
// Track last-hovered row for context menu — updated via onRowClicked and mouse events.
const tableWrapRef = React.useRef<HTMLDivElement>(null);
const handleContextMenu = React.useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!onRowContextMenu) return;
// Find which row was right-clicked by walking up from the event target.
const rdtRow = (e.target as Element | null)?.closest(".rdt_TableRow") as HTMLElement | null;
if (!rdtRow || !tableWrapRef.current) return;
const rowIndex = Array.from(tableWrapRef.current.querySelectorAll(".rdt_TableRow")).indexOf(rdtRow);
if (rowIndex >= 0 && rowIndex < visible.length) {
onRowContextMenu(visible[rowIndex], e);
}
}, [onRowContextMenu, visible]);
return (
<>
{hasToolbar && (
<div className="tools">
{searchKeys.length > 0 && (
<>
<input type="text" className="filter" placeholder="Filter…" value={filter}
onChange={e => setFilter(e.target.value)} />
{filter && <span className="hint">{fmtCount(filtered.length)} shown</span>}
</>
)}
{extraBtns}
{fmtBtn}
</div>
)}
<div ref={tableWrapRef} title={rowClickTitle} onContextMenu={onRowContextMenu ? handleContextMenu : undefined}>
<DataTable columns={columns} data={visible} keyField={keyField}
defaultSortFieldId={defaultSortFieldId} defaultSortAsc={defaultSortAsc}
customStyles={histogramTableStyles} dense highlightOnHover
onRowClicked={onRowClicked}
pointerOnHover={!!onRowClicked}
/>
</div>
{extra > 0 && (
<button className="show-more-btn" onClick={() => setShowAll(!showAll)}>
{showAll ? "Show fewer" : `Show ${fmtCount(extra)} more`}
</button>
)}
</>
);
}
// ── Navigation ───────────────────────────────────────────────────────────────
// A sticky in-page table of contents so long reports (hundreds of threads,
// thousands of histogram rows) stay navigable — MAT's report has an equivalent
// left-hand section index.
function Nav({ report }: { report: Report }) {
// [id, label, group?, badge?] — group is set only on the first link of each group.
const items: [string, string, (string | undefined)?, (string | undefined)?][] = [];
// ── Overview group ──
items.push(
["memory-triage", "Memory Triage", "Overview"],
);
if (report.waste_summary && report.waste_summary.total_bytes > 0) {
items.push(["waste-summary", "Waste Summary"]);
}
items.push(
["system-overview", "System Overview"],
);
// ── Analysis group ──
const suspectCount = report.leaks.suspects.length;
const threadCount = report.threads?.threads?.length ?? 0;
items.push(["leak-suspects", "Leak Suspects", "Analysis", suspectCount > 0 ? String(suspectCount) : undefined]);
items.push(["top-consumers", "Top Consumers"]);
items.push(["dominator-analysis", "Dominator Analysis"]);
items.push(["threads", "Threads", undefined, threadCount > 0 ? String(threadCount) : undefined]);
if (report.top.size_distribution.count > 0) items.push(["size-distribution", "Size Distribution"]);
// ── Data group ──
let dataGroupSet = false;
const addData = (id: string, label: string, badge?: string) => {
if (!dataGroupSet) { items.push([id, label, "Data", badge]); dataGroupSet = true; }
else items.push([id, label, undefined, badge]);
};
addData("duplicate-strings", "Duplicate Strings");
addData("duplicate-prim-arrays", "Duplicate Primitive Arrays");
if (report.overview.boxed_numbers?.length) addData("boxed-numbers", "Boxed Numbers");
if (report.overview.header_overhead?.length) addData("object-header-overhead", "Header Overhead");
if (report.top_components?.components?.length) addData("top-components", "Top Components");
addData("arrays-by-size", "Arrays by Size");
addData("collections", "Collections");
const hasWasteBudget =
(report.overview.duplicate_strings?.approx_wasted_bytes ?? 0) > 0 ||
(report.overview.duplicate_prim_arrays?.total_wasted_bytes ?? 0) > 0 ||
(report.overview.boxed_numbers?.some(r => r.total_shallow > 0) ?? false) ||
(report.collection_attribution?.tiny_overhead?.some(r => r.overhead_bytes > 0) ?? false);
if (hasWasteBudget) addData("collection-waste-budget", "Waste Budget");
if (report.collection_attribution) addData("container-attribution", "Container Attribution");
if (report.fields_by_size) addData("fields-by-retained-size", "Fields by Size");
if (report.top_retainers?.length) addData("top-retainers", "Top Retainers");
if (report.biggest_collections) addData("biggest-collections", "Biggest Collections");
if (report.collection_contents) addData("collection-contents-by-type", "Collection Contents");
addData("references", "References");
addData("unreachable-objects", "Unreachable Objects");
if ((report.leak_indicators?.direct_byte_buffer_capacity_sum ?? 0) > 0) addData("off-heap-nio", "Off-Heap NIO");
if (report.alloc_sites?.traces_present && report.alloc_sites.sites.some(s => s.frames.length > 0)) addData("allocation-sites", "Allocation Sites");
if (report.queries?.length) addData("custom-queries", "Custom Queries", String(report.queries.length));
if (report.obj_graph_flat) addData("object-graph", "Object Graph");
if (report.type_ref_graph?.length) addData("type-ref-graph", "Type Graph");
// ── Distribution group ──
let distGroupSet = false;
const addDist = (id: string, label: string) => {
if (!distGroupSet) { items.push([id, label, "Distribution"]); distGroupSet = true; }
else items.push([id, label]);
};
const rc = report.overview.retention_concentration;
if (rc.top1_bp > 0 || rc.num_objects_ge_1pct > 0) addDist("retention-concentration", "Retention Concentration");
if (report.overview.dominator_depth_histogram.length > 0) addDist("dominator-depth-distribution", "Dominator-Depth Distribution");
addDist("hprof-record-census", "Dump Completeness");
const li = report.leak_indicators;
if (li && (li.anonymous_class_count > 0 || li.thread_local_null_key_count > 0 || li.direct_byte_buffer_capacity_sum > 0)) {
addDist("leak-indicators", "Leak Indicators");
}
addDist("glossary", "Glossary");
const [active, setActive] = React.useState<string>("");
const navRef = React.useRef<HTMLElement>(null);
React.useEffect(() => {
if (!navRef.current) return;
const nav = navRef.current;
const updateScrollPadding = () => {
const h = nav.getBoundingClientRect().height;
document.documentElement.style.scrollPaddingTop = `${h + 8}px`;
};
updateScrollPadding();
const ro = new ResizeObserver(updateScrollPadding);
ro.observe(nav);
return () => ro.disconnect();
}, []);
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
intersecting.set(e.target.id, e.isIntersecting);
});
const ids = items.map(([id]) => id);
let chosen = "";
let lowestAbove = -Infinity;
for (const id of ids) {
const el = document.getElementById(id);
if (!el) continue;
const top = el.getBoundingClientRect().top;
if (intersecting.get(id)) { chosen = id; break; }
if (top < 0 && top > lowestAbove) { lowestAbove = top; chosen = id; }
}
setActive(chosen);
},
{ rootMargin: "-40% 0px -55% 0px" },
);
const intersecting = new Map<string, boolean>();
items.forEach(([id]) => { const el = document.getElementById(id); if (el) observer.observe(el); });
return () => observer.disconnect();
}, []);
return (
<nav className="toc" ref={navRef}>
{items.map(([id, label, group, badge]) => (
<React.Fragment key={id}>
{group && <span className="toc-group">{group}</span>}
<a href={`#${id}`} className={id === active ? "active" : ""}>
{label}
{badge && <span className="toc-badge">{badge}</span>}
</a>
</React.Fragment>
))}
</nav>
);
}
// ── Back-to-top button ───────────────────────────────────────────────────────
function BackToTop() {
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
const onScroll = () => setVisible(window.scrollY > 600);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
if (!visible) return null;
return (
<button
className="back-to-top"
aria-label="Back to top"
title="Back to top"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
↑
</button>
);
}
// ── Navigation history breadcrumb ─────────────────────────────────────────────
// Tracks the last 8 meaningful hash-navigations so users can step back after
// pivoting from one section to another. Shows a thin bar with "← label" links.
// Only section-level hashes (no explore/domtree/<id>) are pushed.
const SECTION_LABELS: Record<string, string> = {
"leak-suspects": "Leak Suspects",
"top-consumers": "Top Consumers",
"dominator-analysis": "Dominator Analysis",
"object-graph": "Object Graph",
"references": "References",
"threads": "Threads",
"collections": "Collections",
"type-ref-graph": "Type Graph",
"custom-queries": "Custom Queries",
"waste-summary": "Waste Summary",
"system-overview": "System Overview",
"allocation-sites": "Allocation Sites",
"unreachable-objects": "Unreachable Objects",
"top-retainers": "Top Retainers",
"fields-by-retained-size": "Fields by Size",
"container-attribution": "Container Attribution",
"collection-waste-budget": "Waste Budget",
"biggest-collections": "Biggest Collections",
"duplicate-strings": "Duplicate Strings",
};
function NavBreadcrumb() {
// Store raw hashes only; resolve labels at render time so ObjGraphCtx
// loading order doesn't drop entries recorded before context was ready.
const [history, setHistory] = React.useState<string[]>([]);
const objNodes = React.useContext(ObjGraphCtx);
React.useEffect(() => {
const onHashChange = () => {
const raw = window.location.hash.slice(1);
const objMatch = raw.match(/^(explore|domtree)\/(\d+)$/);
if (!objMatch && !SECTION_LABELS[raw]) return;
setHistory(prev => {
const last = prev[prev.length - 1];
if (last === raw) return prev;
return [...prev.slice(-7), raw];
});
};
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
function labelFor(hash: string): string {
const m = hash.match(/^(explore|domtree)\/(\d+)$/);
if (m) {
const node = objNodes?.[m[2]];
const shortCls = node ? (node.display_class.split(".").pop() ?? node.display_class) : `obj`;
return `${m[1] === "domtree" ? "⌞ " : ""}${shortCls}#${m[2]}`;
}
return SECTION_LABELS[hash] ?? hash;
}
// Show the last 4 entries (excluding current) as breadcrumb trail.
// Don't render inside object-graph explorer — it has its own richer breadcrumb.
const currentHash = history[history.length - 1] ?? "";
const trail = history.slice(-5, -1);
if (trail.length === 0 || /^(explore|domtree)\/\d+$/.test(currentHash)) return null;
return (
<div className="nav-breadcrumb">
<span className="nav-bc-label">History:</span>
{trail.map((hash, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="nav-bc-sep">›</span>}
<a href={`#${hash}`} className="nav-bc-link"
title={`Go back to ${labelFor(hash)}`}>
{labelFor(hash)}
</a>
</React.Fragment>
))}
<a href="#" className="nav-bc-link nav-bc-back"
onClick={e => { e.preventDefault(); history.length > 1 && (window.location.hash = history[history.length - 2]); }}>
← Back
</a>
</div>
);
}
// Dumb formatter over report.triage (rules are evaluated once in Rust; see
// src/report/triage.rs). Mirrors render_markdown's render_oom_triage.
// Render inline markdown: `code` → <code>, _text_ → <em>.
function InlineCode({ text }: { text: string }) {
// Tokenize: backtick spans, underscore-italic spans, plain text.
const tokens: React.ReactNode[] = [];
const re = /`([^`]+)`|_([^_]+)_/g;
let last = 0;
let m: RegExpExecArray | null;
let key = 0;
while ((m = re.exec(text)) !== null) {
if (m.index > last) tokens.push(<React.Fragment key={key++}>{text.slice(last, m.index)}</React.Fragment>);
if (m[1] !== undefined) tokens.push(<code key={key++}>{m[1]}</code>);
else tokens.push(<em key={key++}>{m[2]}</em>);
last = m.index + m[0].length;
}
if (last < text.length) tokens.push(<React.Fragment key={key++}>{text.slice(last)}</React.Fragment>);
return <>{tokens}</>;
}
// ── Executive Summary Card (V1 + V25) ─────────────────────────────────────────
// A compact at-a-glance card placed above the full OomTriage list. Shows:
// 1. Dump info line (source + date)
// 2. Heap sizes row (reachable | unreachable | wasted)
// 3. Badge pills for active issues derived from triage signals + leak_indicators
// 4. Top suspect line
// 5. Longest dominator chain (V25, when ≥ 2)
function ExecSummaryCard({ report }: { report: Report }) {
const ov = report.overview;
const total = ov.total_shallow;
const unreachable = ov.unreachable_shallow ?? 0;
const wasted = report.waste_summary?.total_bytes ?? 0;
const triage = report.triage ?? [];
const li = report.leak_indicators;
const top = report.leaks.suspects[0] ?? null;
const chainDepth = report.dominator_analysis?.longest_chain_depth ?? null;
// Badge logic
const hasCritical = triage.some((s) => s.severity === "critical");
const topRetainsPct = top ? pctOf(top.retained, report.leaks.total_shallow) : 0;
const showLeakRisk = hasCritical || (top != null && topRetainsPct >= 50);
const showHighGC = triage.some(
(s) => (s.id.includes("gc") || s.id.includes("unreachable")) && (s.bytes ?? 0) > 50 * 1024 * 1024,
);
const showOffHeap = (li?.direct_byte_buffer_capacity_sum ?? 0) > 0;
const showStaleThreadLocals = (li?.thread_local_null_key_count ?? 0) > 0;
const badges: { label: string; color: string }[] = [];
if (showLeakRisk) badges.push({ label: "LEAK RISK", color: "var(--critical, #c0392b)" });
if (showHighGC) badges.push({ label: "HIGH GC PRESSURE", color: "var(--warning, #e67e22)" });
if (showOffHeap) badges.push({ label: "OFF-HEAP MEMORY", color: "var(--info, #2980b9)" });
if (showStaleThreadLocals) badges.push({ label: "STALE THREAD LOCALS", color: "var(--warning, #e67e22)" });
const dumpMs = ov.dump_creation ?? 0;
const src = ov.source_name ?? "";
const rowStyle: React.CSSProperties = {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem 1.5rem",
alignItems: "center",
margin: "0.3rem 0",
};
const labelStyle: React.CSSProperties = {
color: "var(--muted)",
fontSize: "0.8rem",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginRight: "0.25rem",
};
const badgeStyle = (color: string): React.CSSProperties => ({
display: "inline-block",
padding: "0.15rem 0.55rem",
borderRadius: "999px",
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.06em",
color: "#fff",
background: color,
});
return (
<section className="card" id="exec-summary" style={{ margin: "0.75rem 0", padding: "0.9rem 1.1rem" }}>
{/* Line 1: Dump info */}
<div style={{ marginBottom: "0.45rem" }}>
{src && <><span style={labelStyle}>Source</span><code style={{ fontSize: "0.88rem" }}>{src}</code></>}
{dumpMs > 0 && (
<span style={{ marginLeft: src ? "1rem" : 0 }}>
<span style={labelStyle}>Captured</span>
<span title={formatEpochMs(dumpMs)}>{formatDateNice(dumpMs)}</span>
</span>
)}
</div>
{/* Line 2: Heap sizes */}
<div style={rowStyle}>
<span>
<span style={labelStyle}>Reachable heap</span>
<strong title={fmtExactBytes(total)}>{formatBytes(total)}</strong>
</span>
{unreachable > 0 && (
<span>
<span style={labelStyle}>Unreachable</span>
<strong title={fmtExactBytes(unreachable)}>{formatBytes(unreachable)}</strong>
</span>
)}
{wasted > 0 && (
<span>
<span style={labelStyle}>Wasted</span>
<strong title={fmtExactBytes(wasted)}>{formatBytes(wasted)}</strong>
</span>
)}
</div>
{/* Line 3: Badge pills */}
{badges.length > 0 && (
<div style={{ ...rowStyle, margin: "0.4rem 0" }}>
{badges.map((b) => (
<span key={b.label} style={badgeStyle(b.color)}>{b.label}</span>
))}
</div>
)}
{/* Line 4: Top suspect */}
{top && (
<div style={{ margin: "0.3rem 0", fontSize: "0.9rem" }}>
<span style={labelStyle}>Top suspect</span>
<span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle" }}><code title={top.pretty_class}>{top.pretty_class}</code><CopyBtn text={top.pretty_class} /><PivotBtn cls={top.pretty_class} /><OqlBtn cls={top.pretty_class} /><ListObjectsBtn cls={top.pretty_class} /></span>
{" "}holds{" "}
<strong title={fmtExactBytes(top.retained)}>{formatBytes(top.retained)}</strong>
{" "}({fmtPct(topRetainsPct)})
{top.root_type_label && top.root_type_label !== "System Class" &&
!top.pretty_class.toLowerCase().includes(top.root_type_label.toLowerCase()) && (
<span style={{ color: "var(--muted)", marginLeft: "0.5rem", fontSize: "0.85em" }}>
via {top.root_type_label}
</span>
)}
</div>
)}
{/* Line 5: Notable indicators */}
{(showStaleThreadLocals || showOffHeap) && (
<div style={{ margin: "0.3rem 0", fontSize: "0.88rem", color: "var(--muted)", display: "flex", gap: "1.2rem", flexWrap: "wrap" }}>
{showStaleThreadLocals && (
<span>⚠ {fmtCount(li?.thread_local_null_key_count ?? 0)} stale ThreadLocal entr{(li?.thread_local_null_key_count ?? 0) === 1 ? "y" : "ies"}</span>
)}
{showOffHeap && (
<span>⚠ DirectByteBuffer off-heap: <span title={fmtExactBytes(li?.direct_byte_buffer_capacity_sum ?? 0)}>{formatBytes(li?.direct_byte_buffer_capacity_sum ?? 0)}</span></span>
)}
</div>
)}
{/* Line 6: Longest dominator chain (V25) */}
{chainDepth != null && chainDepth >= 2 && (
<div style={{ margin: "0.3rem 0", fontSize: "0.9rem" }}>
<span style={labelStyle}>Longest dominator chain</span>
<strong>{fmtCount(chainDepth)}</strong> hops
</div>
)}
</section>
);
}
// Browser-friendly overrides for signals whose CLI-oriented text doesn't fit the web UI.
const SIGNAL_DETAIL_OVERRIDES: Record<string, string> = {
"collections-not-analyzed": "Re-run with `--collections` to see wasted-capacity breakdown.",
};
function LeakScoreDashboard({ report }: { report: Report }) {
const bc = report.top?.biggest_classes ?? [];
const idoms = report.dominator_analysis?.immediate_dominators?.rows ?? [];
const pairs = report.dominator_analysis?.immediate_dominators?.pairs ?? [];
// Use actual reachable heap, not sum of retained (which double-counts nested dominators)
const totalHeap = report.overview.total_shallow > 0
? report.overview.total_shallow
: bc.reduce((s, c) => s + c.retained, 0);
if (bc.length === 0 || totalHeap === 0) return null;
// Hub score: classes that dominate many others (normalised dominated_count)
const maxDominated = Math.max(...idoms.map(r => r.dominated_count), 1);
const hubScore = new Map(idoms.map(r => [r.dominator_class, r.dominated_count / maxDominated]));
// Median bpi across biggest_classes
const bpis = bc.filter(c => c.instances > 0).map(c => c.retained / c.instances).sort((a, b) => a - b);
const medBpi = bpis[Math.floor(bpis.length / 2)] ?? 1;
// Depth: BFS over pairs from classes that aren't dominated
const hasDominated = new Set(pairs.map(p => p.dominated_class));
const depthMap = new Map<string, number>();
const queue: Array<{ cls: string; d: number }> = bc.map(c => c.pretty_class)
.filter(cls => !hasDominated.has(cls)).map(cls => ({ cls, d: 0 }));
while (queue.length) {
const { cls, d } = queue.shift()!;
if (depthMap.has(cls)) continue;
depthMap.set(cls, d);
for (const p of pairs) {
if (p.dominator_class === cls && !depthMap.has(p.dominated_class))
queue.push({ cls: p.dominated_class, d: d + 1 });
}
}
const maxDepth = Math.max(...depthMap.values(), 1);
type ScoreRow = { cls: string; score: number; pct: number; bpi: number; hub: number; depth: number; instances: number };
const rows: ScoreRow[] = bc.slice(0, 50).map(c => {
const pct = c.retained / totalHeap;
const bpi = c.instances > 0 ? c.retained / c.instances : 0;
const bpiSignal = bpi > medBpi * 5 ? 1 : bpi > medBpi * 2 ? 0.5 : 0;
const hub = hubScore.get(c.pretty_class) ?? 0;
const depth = depthMap.get(c.pretty_class) ?? maxDepth;
// Shallower = more likely root cause; scale over 8 hops so mid-depth gets partial credit
const depthSignal = 1 - Math.min(depth / 8, 1);
const score = Math.min(pct * 40 + bpiSignal * 30 + hub * 20 + depthSignal * 10, 99);
return { cls: c.pretty_class, score, pct, bpi, hub, depth, instances: c.instances };
}).filter(r => r.score > 3).sort((a, b) => b.score - a.score).slice(0, 12);
if (rows.length === 0) return null;
return (
<div style={{ marginTop: "1rem" }}>
<h3 style={{ marginBottom: "0.1rem" }}>Leak Score</h3>
<p className="subtitle" style={{ marginBottom: "0.5rem" }}>Composite leak likelihood score (0–99) based on heap share, bytes per instance, and dominator graph position. Higher score = more likely accumulation point. Click a card to inspect the class.</p>
<div className="leak-score-grid">
{rows.map(r => {
const conf = r.score >= 35 ? "high" : r.score >= 18 ? "mid" : "low";
const signals: string[] = [];
if (r.pct > 0.15) signals.push(`${(r.pct * 100).toFixed(0)}% heap`);
if (r.bpi > medBpi * 5) signals.push("↑ bytes/inst");
if (r.hub > 0.4) signals.push("↑ hub");
if (r.depth <= 1) signals.push("shallow");
return (
<div key={r.cls} className={`leak-score-card leak-score-${conf}`}
title={`Score: ${r.score.toFixed(0)} | depth: ${r.depth} | ${(r.pct*100).toFixed(1)}% heap | ${r.instances} ${r.instances === 1 ? "instance" : "instances"}`}>
<div className="leak-score-bar" style={{ width: `${r.score}%` }} />
<div className="leak-score-body">
<button className="trg-link-btn leak-score-cls" title={r.cls} onClick={() => fireInspect({ kind: "class", cls: r.cls })}>
<code>{r.cls.split(".").pop()}</code>
</button>
<span className="leak-score-num">{r.score.toFixed(0)}</span>
</div>
<div className="leak-score-tags">
{signals.map(s => <span key={s} className="leak-score-tag">{s}</span>)}
</div>
</div>
);
})}
</div>
</div>
);
}
function OomTriage({ report }: { report: Report }) {
const signals = report.triage ?? [];
const totalHeap = report.overview.total_shallow;
return (
<div className="oom" id="memory-triage" tabIndex={-1}>
<h2>Memory Triage</h2>
<p className="subtitle">
Automated signals pointing to where memory concentrates and what to investigate first.
{totalHeap > 0 && <> Total reachable heap: <strong title={fmtExactBytes(totalHeap)}>{formatBytes(totalHeap)}</strong>.</>}
</p>
<ul>
{signals.map((s, i) => {
const detail = SIGNAL_DETAIL_OVERRIDES[s.id] ?? s.detail;
const sevColor = s.severity === "critical" ? "var(--critical, #c0392b)" : s.severity === "warning" ? "var(--warn-border, #c84)" : "var(--muted, #888)";
const sevLabel = s.severity === "critical" ? "Critical" : s.severity === "warning" ? "Warning" : "Info";
return (
<li key={i}>
<span title={sevLabel} style={{ color: sevColor, marginRight: "0.3rem", fontSize: "0.85em" }}>●</span>
<strong>{s.title}:</strong> <InlineCode text={detail} />
{s.nav_class && <>{" "}<PivotBtn cls={s.nav_class} /><OqlBtn cls={s.nav_class} /><ListObjectsBtn cls={s.nav_class} /></>}
{s.anchor && s.anchor_label ? (
<>
{" "}
See <a href={`#${s.anchor}`}>{s.anchor_label}</a>.
</>
) : null}
</li>
);
})}
</ul>
<LeakScoreDashboard report={report} />
</div>
);
}
// ── Waste Summary ─────────────────────────────────────────────────────────
// One headline "reclaimable N" figure folding every quantifiable waste source,
// with a per-source breakdown that links into the section detailing each.
// Sources are approximate and may overlap slightly. Mirrors the Rust md/graphs
// "Waste Summary" section (same order, same values).
function WasteSummarySection({ report }: { report: Report }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const w = report.waste_summary;
if (!w || w.total_bytes <= 0) return null;
const max = w.sources.reduce((m, s) => Math.max(m, s.bytes), 0);
// Anchors the Rust side omits (subsections without a dedicated section id).
const wasteAnchorFallback: Record<string, string> = {
"Duplicate Primitive Arrays": "duplicate-prim-arrays",
};
type WasteSource = (typeof w.sources)[0];
const wasteCols: TableColumn<WasteSource>[] = [
{
id: "source", name: "Source", grow: 1,
cell: (s) => {
const anchor = s.anchor ?? wasteAnchorFallback[s.label];
return anchor ? <a href={`#${anchor}`}>{s.label}</a> : s.label;
},
},
{ id: "reclaimable", name: useKB ? "Reclaimable (KB)" : "Reclaimable", right: true, width: useKB ? "165px" : "130px", cell: byteCell(s => s.bytes, fmtB, useKB), selector: (s) => s.bytes, sortable: true },
{
id: "bar", name: "", width: "100px",
cell: (s) => (
<span className="bar-bg">
<span className="bar-fill" style={{ width: `${max > 0 ? (s.bytes / max) * 100 : 0}%` }} />
</span>
),
},
];
return (
<section className="section" id="waste-summary" tabIndex={-1}>
<h2>Waste Summary</h2>
<p className="subtitle">
<strong title={fmtExactBytes(w.total_bytes)}>{fmtB(w.total_bytes)}</strong> estimated reclaimable across the sources below — duplicate strings, duplicate primitive arrays, boxed primitives, and empty/singleton collection overhead. Fix the biggest category first for the highest impact. Figures are approximate; sources may overlap.
</p>
<div className="waste-summary-table">
<StdTable columns={wasteCols} data={w.sources} searchKeys={["label"]} fmtBtn={kbBtn} defaultSortFieldId="reclaimable" defaultSortAsc={false} />
</div>
</section>
);
}
// ── KPI card strip ──────────────────────────────────────────────────────────
function KpiStrip({ report }: { report: Report }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const suspects = report.leaks.suspects;
const top = suspects[0];
const topShare = top
? fmtPct(pctOf(top.retained, report.leaks.total_shallow))
: "—";
const dominantClass = top?.pretty_class ?? "—";
// Plain-language verdict mirroring the Markdown executive summary
// ("Likely problem:" line). CONCENTRATION_PCT = 50.
const pct = top ? pctOf(top.retained, report.leaks.total_shallow) : 0;
let verdict: React.ReactNode;
if (top && pct >= 50) {
verdict = (
<>
<strong>Top Suspect:</strong>{" "}
<span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle" }}><code title={top.pretty_class}>{top.pretty_class}</code><CopyBtn text={top.pretty_class} /><PivotBtn cls={top.pretty_class} /><OqlBtn cls={top.pretty_class} /><ListObjectsBtn cls={top.pretty_class} /></span>
— investigate this first.
</>
);
} else if (top) {
verdict = (
<>
<strong>Retention Pattern:</strong> spread — top class holds only {fmtPct(pct)}.
</>
);
} else {
verdict = (
<>
<strong>Retention Pattern:</strong> no dominant retainer — heap spans many roots.
</>
);
}
return (
<>
<div className="kpi-grid">
<a className="kpi kpi-link" href="#system-overview" title="Jump to System Overview">
<div className="kpi-value" title={fmtExactBytes(report.overview.total_shallow)}>{fmtB(report.overview.total_shallow)}</div>
<div className="kpi-label">Total Reachable Heap</div>
</a>
<a className="kpi kpi-link" href="#system-overview" title="Jump to System Overview">
<div className="kpi-value">{fmtCount(report.overview.total_objects)}</div>
<div className="kpi-label">Objects</div>
</a>
<a className="kpi kpi-link" href="#leak-suspects" title="Jump to Leak Suspects">
<div className="kpi-value">{fmtCount(suspects.length)}</div>
<div className="kpi-label">Leak Suspects</div>
</a>
<a className="kpi kpi-link" href="#leak-suspects" title="Jump to Leak Suspects">
<div className="kpi-value">{topShare}</div>
<div className="kpi-label">Top Suspect Share</div>
</a>
<a className="kpi kpi-link" href="#leak-suspects" title="Jump to Leak Suspects">
<div className="kpi-value">
<code title={dominantClass}>{dominantClass}</code>
</div>
<div className="kpi-label">Dominant Retainer</div>
</a>
<a className="kpi kpi-link" href="#system-overview" title="Jump to System Overview">
<div className="kpi-value">{fmtCount(report.overview.gc_roots)}</div>
<div className="kpi-label">GC Roots</div>
</a>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.5rem" }}>
<p className="subtitle" style={{ fontSize: "1rem", margin: 0 }}>{verdict}</p>
{kbBtn}
</div>
</>
);
}
// ── Column-resize hook ───────────────────────────────────────────────────────
// ── Reusable sort primitives ─────────────────────────────────────────────────
function useSortedRows<T>(rows: T[], initialKey: keyof T) {
const [sortKey, setSortKey] = React.useState<keyof T>(initialKey);
const sorted = React.useMemo(
() => [...rows].sort((a, b) => (b[sortKey] as number) - (a[sortKey] as number)),
[rows, sortKey],
);
return { sorted, sortKey, setSortKey };
}
function SortableTh<T>({ label, colKey, sortKey, setSortKey }: {
label: string; colKey: keyof T; sortKey: keyof T; setSortKey: (k: keyof T) => void;
}) {
const active = sortKey === colKey;
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSortKey(colKey); }
};
return (
<th
className={"num sortable" + (active ? " active" : "")}
onClick={() => setSortKey(colKey)}
onKeyDown={handleKey}
tabIndex={0}
role="button"
aria-sort={active ? "descending" : "none"}
title={`Sort by ${label} (descending)`}
>
{label} {active ? "▾" : ""}
</th>
);
}
// ── Sortable / filterable class histogram ────────────────────────────────────
const HIST_MIN_PCT = 0.1; // skip rows < 0.1% of heap
function ClassHistogramTable({ rows, totalShallow }: { rows: HistRow[]; totalShallow: number }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const [filter, setFilter] = React.useState("");
const [showAll, setShowAll] = React.useState(false);
const [showLoader, setShowLoader] = React.useState(false);
const [groupLambdas, setGroupLambdas] = React.useState(true);
const [highlightedClass, setHighlightedClass] = React.useState<string | null>(null);
React.useEffect(() => {
const handler = (e: CustomEvent) => {
setHighlightedClass(e.detail.cls);
setTimeout(() => setHighlightedClass(null), 3000);
};
window.addEventListener("highlight-class", handler as EventListener);
return () => window.removeEventListener("highlight-class", handler as EventListener);
}, []);
const hasIncomingRefCount = React.useMemo(
() => rows.some((r) => (r.incoming_ref_count ?? 0) > 0),
[rows],
);
// Only offer the loader toggle if at least one non-boot loader exists
const hasNonBootLoader = React.useMemo(
() => rows.some((r) => r.loader_label != null && r.loader_label !== "<boot>"),
[rows],
);
// Filter by class name text (substring or /regex/) + optionally skip tiny rows
const filtered = React.useMemo(() => {
let testFn: (s: string) => boolean = () => true;
if (filter) {
const reMatch = filter.match(/^\/(.+)\/([gi]*)$/);
if (reMatch) {
try {
const re = new RegExp(reMatch[1], reMatch[2] || "i");
testFn = (s) => re.test(s);
} catch {
const lc = filter.toLowerCase();
testFn = (s) => s.toLowerCase().includes(lc);
}
} else {
const lc = filter.toLowerCase();
testFn = (s) => s.toLowerCase().includes(lc);
}
}
return rows.filter((r) => {
if (filter && !testFn(r.pretty_class)) return false;
if (!showAll && pctOf(r.retained, totalShallow) < HIST_MIN_PCT) return false;
return true;
});
}, [rows, filter, showAll, totalShallow]);
const LAMBDA_RE = /\$\$Lambda\$\d/;
const ANON_RE = /\$\d+(?:\/.*)?$/;
function lambdaPrefix(name: string): string {
const m = name.match(/^(.+?)(?:\$\$Lambda\$|\$\d)/);
return m ? m[1] : name;
}
const displayRows = React.useMemo(() => {
if (!groupLambdas) return filtered;
const result: HistRow[] = [];
const groups = new Map<string, HistRow[]>();
const order: string[] = [];
for (const r of filtered) {
if (LAMBDA_RE.test(r.pretty_class) || ANON_RE.test(r.pretty_class)) {
const prefix = lambdaPrefix(r.pretty_class);
if (!groups.has(prefix)) {
groups.set(prefix, []);
order.push(prefix);
}
groups.get(prefix)!.push(r);
} else {
result.push(r);
}
}
for (const prefix of order) {
const grp = groups.get(prefix)!;
const grouped: HistRow = {
pretty_class: `${prefix} [λ ×${grp.length}]`,
instances: grp.reduce((s, r) => s + r.instances, 0),
shallow: grp.reduce((s, r) => s + r.shallow, 0),
retained: grp.reduce((s, r) => s + r.retained, 0),
max_instance_shallow: grp.reduce((mx, r) => Math.max(mx, r.max_instance_shallow), 0),
loader_id: grp[0].loader_id,
loader_label: grp[0].loader_label,
};
result.push(grouped);
}
return result;
}, [filtered, groupLambdas]);
const columns: TableColumn<HistRow>[] = React.useMemo(() => {
const fixedW = 644 + (showLoader ? 130 : 0) + (hasIncomingRefCount ? 116 : 0);
const classMaxW = `${Math.max(160, 1040 - fixedW)}px`;
const cols: TableColumn<HistRow>[] = [
{
id: "rank",
name: "#",
width: "36px",
grow: 0,
style: { color: "var(--muted)", fontSize: "0.8rem", justifyContent: "flex-end" },
cell: (_row, idx) => idx + 1,
sortable: false,
},
{
id: "pretty_class",
name: "Class",
minWidth: "100px",
maxWidth: classMaxW,
grow: 1,
selector: (r) => r.pretty_class,
cell: (r) => (
<span title={r.pretty_class} style={{ display: "flex", alignItems: "center", gap: 4, overflow: "hidden", width: "100%" }}>
<code style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1, minWidth: 0, background: "none", padding: 0 }}>{r.pretty_class}</code>
<CopyBtn text={r.pretty_class} />
<PivotBtn cls={r.pretty_class} />
<OqlBtn cls={r.pretty_class} />
<ListObjectsBtn cls={r.pretty_class} />
</span>
),
sortable: true,
},
...(showLoader ? [{
id: "loader_label",
name: "Loader",
width: "130px",
grow: 0,
selector: (r: HistRow) => r.loader_label ?? "",
cell: (r: HistRow) => (
<span title={r.loader_label ? fmtLoader(r.loader_label) : undefined} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "block", width: "100%" }}>
<LoaderCell label={r.loader_label} />
</span>
),
sortable: false,
}] : []),
{
id: "instances",
name: "Instances",
width: "104px",
grow: 0,
right: true,
selector: (r) => r.instances,
format: (r) => fmtCount(r.instances),
sortable: true,
},
...(hasIncomingRefCount ? [{
id: "incoming_ref_count",
name: "Inbound Refs",
width: "130px",
grow: 0,
right: true,
selector: (r: HistRow) => r.incoming_ref_count ?? 0,
format: (r: HistRow) => fmtCount(r.incoming_ref_count ?? 0),
sortable: true,
// Show a tooltip explaining the column
cell: (r: HistRow) => (
<span title={`${(r.incoming_ref_count ?? 0).toLocaleString()} total references to instances of this class`}
style={{ width: "100%", textAlign: "right", display: "block" }}>
{fmtCount(r.incoming_ref_count ?? 0)}
</span>
),
}] as TableColumn<HistRow>[] : []),
{
id: "shallow",
name: useKB ? "Shallow (KB)" : "Shallow",
width: useKB ? "122px" : "104px",
grow: 0,
right: true,
selector: (r) => r.shallow,
cell: byteCell(r => r.shallow, fmtB, useKB),
sortable: true,
},
{
id: "max_instance_shallow",
name: useKB ? "Largest (KB)" : "Largest",
width: useKB ? "122px" : "104px",
grow: 0,
right: true,
selector: (r) => r.max_instance_shallow,
cell: byteCell(r => r.max_instance_shallow, fmtB, useKB),
sortable: true,
},
{
id: "retained",
name: useKB ? "Retained (KB)" : "Retained",
width: useKB ? "142px" : "112px",
grow: 0,
right: true,
selector: (r) => r.retained,
cell: byteCell(r => r.retained, fmtB, useKB),
sortable: true,
},
{
id: "pct",
name: "% Heap",
width: "104px",
grow: 0,
right: true,
selector: (r) => r.retained,
format: (r) => fmtPct(pctOf(r.retained, totalShallow)),
sortable: true,
sortFunction: (a, b) => a.retained - b.retained,
},
{
id: "bar",
name: "",
width: "80px",
grow: 0,
sortable: false,
cell: (r) => {
const pct = totalShallow > 0 ? Math.min(100, (r.retained / totalShallow) * 100) : 0;
return (
<div title={`${fmtPct(pct)} of heap`} style={{ width: "100%", height: 8, background: "var(--border)", borderRadius: 4, overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", background: "var(--accent, #2563eb)", borderRadius: 4, minWidth: pct > 0 ? 2 : 0 }} />
</div>
);
},
},
];
return cols;
}, [showLoader, hasIncomingRefCount, totalShallow, fmtB, useKB]);
const hiddenSmall = !showAll && !filter ? rows.filter(r => pctOf(r.retained, totalShallow) < HIST_MIN_PCT).length : 0;
const histTsvRows = React.useMemo(() => {
const header = ["Class", "Instances", "Shallow (bytes)", "Retained (bytes)", "% Heap"];
const data = displayRows.map(r => [
r.pretty_class, String(r.instances), String(r.shallow),
String(r.retained), fmtPct(pctOf(r.retained, totalShallow)),
]);
return [header, ...data];
}, [displayRows, totalShallow]);
const ExpandedHistRow = React.memo(({ data }: { data: HistRow }) => {
if (!data.root_path?.length) return null;
return (
<div style={{ padding: "0.5rem 1rem 0.5rem 2rem", background: "var(--bg2, #f8f8f8)" }}>
<p style={{ fontSize: "0.78rem", color: "var(--muted)", margin: "0 0 0.25rem" }}>
GC Root Path for Highest-Retained Instance ({data.root_path.length} hops):
</p>
<RootPathChain steps={data.root_path} />
</div>
);
});
return (
<div>
<p className="subtitle" style={{ fontSize: "0.78rem", marginBottom: "0.4rem" }}>
Next to each class: <span title="Open in Inspector">⬡</span> Inspector · <span title="Copy OQL query">⌗</span> Copy OQL · <span title="List all instances">⬡≡</span> List Instances · <span title="Copy class name">⎘</span> Copy Name
</p>
<div className="tools">
<input
type="text"
className="filter"
placeholder="Filter by class name (or /regex/)…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
aria-label="Filter histogram by class name or regex"
/>
<span className="hint">{fmtCount(displayRows.length)} shown</span>
{hasNonBootLoader && (
<button className="show-more-btn" onClick={() => setShowLoader(v => !v)}>
{showLoader ? "Hide Loader" : "Show Loader"}
</button>
)}
<button className="show-more-btn" onClick={() => setGroupLambdas(v => !v)}>
{groupLambdas ? "Ungroup λ" : "Group λ"}
</button>
{hiddenSmall > 0 && (
<button className="show-more-btn" onClick={() => setShowAll(true)}>
Show {fmtCount(hiddenSmall)} more rows (< 0.1% each)
</button>
)}
{showAll && !filter && (
<button className="show-more-btn" onClick={() => setShowAll(false)}>
Show fewer
</button>
)}
{kbBtn}
<CopyTsvBtn rows={histTsvRows} label="Copy as TSV" />
</div>
<DataTable
columns={columns}
data={displayRows}
keyField="pretty_class"
defaultSortFieldId="retained"
defaultSortAsc={false}
dense
highlightOnHover
customStyles={histogramTableStyles}
conditionalRowStyles={[
{
when: (row: HistRow) => row.pretty_class === highlightedClass,
classNames: ["hist-row-highlighted"],
},
]}
expandableRows
expandableRowsHideExpander={false}
expandableRowDisabled={(row: HistRow) => !row.root_path?.length}
expandableRowsComponent={ExpandedHistRow as any}
/>
</div>
);
}
const histogramTableStyles = {
headRow: { style: { borderBottomWidth: "1px", borderBottomColor: "var(--border)", fontWeight: 600, fontSize: "0.82rem", color: "var(--muted)", background: "var(--card)" } },
headCells: { style: { paddingLeft: "5px", paddingRight: "5px", whiteSpace: "nowrap" as const } },
rows: { style: { fontSize: "0.86rem", borderBottomColor: "var(--border)", background: "transparent", minHeight: "unset" }, highlightOnHoverStyle: { background: "var(--hover-bg, var(--card))" } },
cells: { style: { paddingTop: "3px", paddingBottom: "3px", paddingLeft: "5px", paddingRight: "5px", whiteSpace: "nowrap" as const, overflow: "hidden", fontVariantNumeric: "tabular-nums" } },
table: { style: { background: "transparent", minWidth: "unset" } },
tableWrapper: { style: { overflow: "auto" } },
};
// Renders a class-loader label compactly: the loader's simple class name, with
// the full JVM-internal name as a tooltip. The boot loader is shown muted.
function LoaderCell({ label }: { label?: string | null }) {
const short = shortLoader(label);
if (short == null) return <span className="hint">—</span>;
if (short === "<boot>") return <span className="hint"><boot></span>;
return (
<code className="loader" title={label ? fmtLoader(label) : undefined}>
{short}
</code>
);
}
// ── Global class pivot helper ─────────────────────────────────────────────────
// Fires a CustomEvent that DominatorAnalysisSection listens for.
// Does NOT scroll — shows a nav-toast instead so the user stays in place.
function pivotClass(cls: string) {
window.dispatchEvent(new CustomEvent("pivot-class", { detail: cls }));
history.replaceState(null, "", "#dominator-analysis");
window.dispatchEvent(new CustomEvent("nav-toast", { detail: { label: "Updated in Dominator Analysis", sectionId: "dominator-analysis" } }));
}
// Small "⬡" button shown next to class names in any table that triggers the pivot.
function saveHtml(filename: string) {
const blob = new Blob(["<!DOCTYPE html>" + document.documentElement.outerHTML], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function PivotBtn({ cls }: { cls: string }) {
const hasDomData = React.useContext(HasDomDataCtx);
if (!hasDomData) return null;
return (
<button
className="copy-btn"
title="Open in Inspector"
aria-label="Open in Inspector"
onClick={(e) => {
e.stopPropagation();
fireInspect({ kind: "class", cls });
}}
style={{ opacity: 0.6 }}
>
⬡
</button>
);
}
function CopyBtn({ text }: { text: string }) {
const [copied, setCopied] = React.useState(false);
const copy = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard?.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1200);
});
};
return (
<button className="copy-btn" onClick={copy} title="Copy class name" aria-label="Copy class name">
{copied ? "✓" : "⎘"}
</button>
);
}
// Exports an array of string rows as tab-separated values to the clipboard.
// Each row is a string[]; the first row should be the header.
function CopyTsvBtn({ rows, label }: { rows: string[][]; label?: string }) {
const [copied, setCopied] = React.useState(false);
const copy = (e: React.MouseEvent) => {
e.stopPropagation();
const tsv = rows.map(r => r.join("\t")).join("\n");
navigator.clipboard?.writeText(tsv).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<button className="show-more-btn" onClick={copy}
title={label ?? "Copy as TSV (paste into spreadsheet)"}>
{copied ? "✓ Copied" : "⎘ Copy TSV"}
</button>
);
}
// Copies "SELECT * FROM ClassName LIMIT 20" to clipboard — quick OQL shortcut.
function OqlBtn({ cls }: { cls: string }) {
const [copied, setCopied] = React.useState(false);
const oql = `SELECT * FROM ${cls} LIMIT 20`;
const click = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard?.writeText(oql).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1400);
});
};
return (
<button className="copy-btn" onClick={click} title={`Copy OQL: ${oql}`} aria-label="Copy OQL query for this class">
{copied ? "✓" : "⌗"}
</button>
);
}
// Links to the HeapInspector instance view for a given dense index.
// Also keeps the old Object Graph Explorer navigation for contexts where it exists.
function ExploreBtn({ denseIdx, label }: { denseIdx: number; label?: string }) {
const nodes = React.useContext(ObjGraphCtx);
const wasm = (window as any).__wasmExploration;
if (!nodes && !wasm?.get_node_info) return null;
const cls = nodes?.[String(denseIdx)]?.display_class ?? "";
const click = (e: React.MouseEvent) => {
e.stopPropagation();
fireInspect({ kind: "instance", idx: denseIdx, cls });
};
return (
<button className="copy-btn" onClick={click}
title={label ? `Open "${label}" in Inspector` : "Open in Inspector"}
aria-label="Open in Inspector"
style={{ visibility: "visible", opacity: 0.7 }}>
⬡↗
</button>
);
}
// Opens the class's instances in the HeapInspector panel.
function ListObjectsBtn({ cls }: { cls: string }) {
return (
<button className="copy-btn" onClick={(e) => { e.stopPropagation(); fireInspect({ kind: "instances", cls, page: 0 }); }}
title={`Instances in Inspector: "${cls}"`}
aria-label="Instances in Inspector"
style={{ opacity: 0.7 }}>
⬡≡
</button>
);
}
// ── TextModal ─────────────────────────────────────────────────────────────────
// Full-screen overlay showing long text with a search/highlight bar.
// Uses the native <dialog> element for focus-trap and backdrop.
function TextModal({ title, text, onClose }: { title: string; text: string; onClose: () => void }) {
const dialogRef = React.useRef<HTMLDialogElement>(null);
const [query, setQuery] = React.useState("");
React.useEffect(() => {
const d = dialogRef.current;
if (!d) return;
d.showModal();
const close = () => onClose();
d.addEventListener("close", close);
return () => d.removeEventListener("close", close);
}, [onClose]);
const highlighted = React.useMemo(() => {
if (!query) return <code className="text-modal-body">{text}</code>;
const lc = query.toLowerCase();
const parts: React.ReactNode[] = [];
let i = 0;
let lcText = text.toLowerCase();
while (i < text.length) {
const idx = lcText.indexOf(lc, i);
if (idx === -1) { parts.push(text.slice(i)); break; }
if (idx > i) parts.push(text.slice(i, idx));
parts.push(<mark key={idx}>{text.slice(idx, idx + query.length)}</mark>);
i = idx + query.length;
}
return <code className="text-modal-body">{parts}</code>;
}, [text, query]);
return (
<dialog ref={dialogRef} className="text-modal" onClick={e => { if (e.target === dialogRef.current) dialogRef.current?.close(); }}>
<div className="text-modal-inner">
<div className="text-modal-header">
<span className="text-modal-title">{title}</span>
<input
type="text"
className="filter"
placeholder="Search…"
value={query}
onChange={e => setQuery(e.target.value)}
autoFocus
/>
<button className="copy-btn" style={{ fontSize: "1rem" }} onClick={() => navigator.clipboard?.writeText(text)} title="Copy">⎘</button>
<button className="copy-btn" style={{ fontSize: "1.1rem" }} onClick={() => dialogRef.current?.close()} title="Close">✕</button>
</div>
<div className="text-modal-content">
{highlighted}
</div>
{query && (
<div className="text-modal-footer">
{(() => {
const count = (text.toLowerCase().match(new RegExp(query.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || []).length;
return <span className="hint">{count} match{count !== 1 ? "es" : ""}</span>;
})()}
</div>
)}
</div>
</dialog>
);
}
// ExpandableText: shows text truncated in cell; double-click opens TextModal.
// Any cell text is expandable; long text also shows a small "⤢" hint button.
const EXPAND_THRESHOLD = 60;
function ExpandableText({ text, label }: { text: string; label?: string }) {
const [open, setOpen] = React.useState(false);
const isLong = text.length > EXPAND_THRESHOLD;
return (
<span
className="expandable-text"
onDoubleClick={e => { e.stopPropagation(); setOpen(true); }}
title={isLong ? "Double-click to expand" : text}
>
<code className={isLong ? "expandable-truncated" : ""}>{text}</code>
{isLong && (
<button
className="expand-btn"
onClick={e => { e.stopPropagation(); setOpen(true); }}
title="Show full value"
>⤢</button>
)}
{open && <TextModal title={label ?? "Full value"} text={text} onClose={() => setOpen(false)} />}
</span>
);
}
// ── ChartOrNote ──────────────────────────────────────────────────────────────
// Renders children when hasData is true; otherwise shows a muted note matching
// the "System properties not captured in this dump." pattern.
function ChartOrNote({ hasData, note, children }: { hasData: boolean; note: string; children: React.ReactNode }) {
if (!hasData) return <p className="subtitle" style={{ color: "var(--muted)" }}>{note}</p>;
return <>{children}</>;
}
// ── HPROF Record Census ───────────────────────────────────────────────────────
function gcRootTagLabel(tag: number): string {
switch (tag) {
case 0x00: return "System Class";
case 0x01: return "JNI Global";
case 0x02: return "JNI Local";
case 0x03: return "Java Frame";
case 0x04: return "Native Stack";
case 0x05: return "Sticky Class";
case 0x06: return "Thread Block";
case 0x07: return "Busy Monitor";
case 0x08: return "Thread";
default: return "Unknown";
}
}
function RecordCensusSection({ report }: { report: Report }) {
const c = report.overview.record_census;
const rows: { label: string; count: number }[] = [
{ label: "UTF-8 Strings", count: c.utf8_records },
{ label: "Load Class", count: c.load_class_records },
{ label: "Unload Class", count: c.unload_class_records },
{ label: "Stack Frames", count: c.stack_frame_records },
{ label: "Stack Traces", count: c.stack_trace_records },
{ label: "Heap Dump Segments", count: c.heap_dump_segments },
{ label: "Instance Dumps", count: c.instance_dumps },
{ label: "Object Array Dumps", count: c.obj_array_dumps },
{ label: "Primitive Array Dumps", count: c.prim_array_dumps },
{ label: "Class Dumps", count: c.class_dumps },
];
const censusCols: TableColumn<{ label: string; count: number }>[] = [
{ id: "label", name: "Record Type", grow: 1, selector: (r) => r.label, sortable: true },
{ id: "count", name: "Count", right: true, width: "120px", format: (r) => fmtCount(r.count), selector: (r) => r.count, sortable: true },
];
const gcRootTagLabel: Record<number, string> = {
0x00: "System Class", 0x01: "JNI Global", 0x02: "JNI Local",
0x03: "Java Frame", 0x04: "Native Stack", 0x05: "Sticky Class",
0x06: "Thread Block", 0x07: "Busy Monitor", 0x08: "Thread",
0x89: "Interned String", 0x8b: "Debugger", 0x8d: "VM Internal",
0x8e: "JNI Monitor",
};
const gcRootRows = (c.gc_root_tag_counts ?? []).map(([tag, count]) => ({
label: gcRootTagLabel[tag] ?? `0x${tag.toString(16)}`,
count,
}));
return (
<section id="hprof-record-census">
<h2>Dump Completeness</h2>
<p className="subtitle">
Record-type counts from the raw HPROF file — useful for diagnosing truncated or unusual dumps. Zero stack frames means no allocation-site data (requires <code>-agentlib:hprof=heap=dump,depth=8</code>, removed in JDK 9); a mismatch between load-class and class-dump counts can indicate a partial write.
</p>
<StdTable columns={censusCols} data={rows} searchKeys={["label"]} defaultSortFieldId="count" defaultSortAsc={false} />
{gcRootRows.length > 0 && (
<>
<h4>GC Root Records by Tag</h4>
<StdTable columns={censusCols} data={gcRootRows} searchKeys={["label"]} defaultSortFieldId="count" defaultSortAsc={false} />
</>
)}
</section>
);
}
// ── Top-Dominator Size Distribution ───────────────────────────────────────────
function SizeDistributionSection({ report }: { report: Report }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const d = report.top.size_distribution;
if (d.count <= 0) return null;
type SizeBucket = (typeof d.buckets)[0];
const sizeCols: TableColumn<SizeBucket>[] = [
{ id: "upper", name: useKB ? "Size ≤ (KB)" : "Size ≤", right: true, width: useKB ? "140px" : "120px", cell: byteCell(b => b.upper_bytes, fmtB, useKB), selector: (b) => b.upper_bytes, sortable: true },
{ id: "count", name: "Count", right: true, width: "100px", format: (b) => fmtCount(b.count), selector: (b) => b.count, sortable: true },
{ id: "pct", name: "% of Dominators", right: true, width: "155px", format: (b) => d.count > 0 ? fmtPct(b.count / d.count * 100) : "—", selector: (b) => b.count, sortable: true },
];
return (
<section id="size-distribution">
<h2>Retained Size Distribution</h2>
<p className="subtitle">
Retained heap distributed across {fmtCount(d.count)} top-level dominators. The shape reveals whether a handful of large objects dominate the heap or memory is scattered across many small ones.
Min / Max: <span title={fmtExactBytes(d.min)}>{fmtB(d.min)}</span> / <span title={fmtExactBytes(d.max)}>{fmtB(d.max)}</span> · Median: <span title={fmtExactBytes(d.median)}>{fmtB(d.median)}</span> · Total: <span title={fmtExactBytes(d.total)}>{fmtB(d.total)}</span>.
</p>
<StdTable columns={sizeCols} data={d.buckets} searchKeys={[]} fmtBtn={kbBtn} defaultSortFieldId="upper" />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ width: useKB ? "140px" : "120px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: "100px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(d.count)}</span>
<span style={{ width: "155px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>100%</span>
<span style={{ flex: 1 }} />
</div>
</section>
);
}
// ── Small capped sub-tables for Duplicate Strings section ────────────────────
function TopDuplicatedTable({ rows }: { rows: DupStringSample[] }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const cols: TableColumn<DupStringSample>[] = [
{ id: "rank", name: "#", right: true, width: "36px", cell: (_r, i) => (i ?? 0) + 1 },
{ id: "count", name: "Count", right: true, width: "100px", format: (s) => fmtCount(s.count), selector: (s) => s.count, sortable: true },
{ id: "wasted", name: useKB ? "Wasted (KB)" : "Wasted", right: true, width: useKB ? "132px" : "100px", cell: byteCell(s => s.wasted_bytes, fmtB, useKB), selector: (s) => s.wasted_bytes, sortable: true },
{ id: "value", name: "Value", grow: 1, minWidth: "100px", maxWidth: "600px", cell: (s) => <ExpandableText text={s.text} label="Duplicated String Value" /> },
];
return (
<>
<h3>Most-Duplicated Values</h3>
<StdTable columns={cols} data={rows} searchKeys={["text"]} fmtBtn={kbBtn} defaultSortFieldId="wasted" defaultSortAsc={false} />
</>
);
}
function TopByLengthTable({ rows }: { rows: DupStringSample[] }) {
const cols: TableColumn<DupStringSample>[] = [
{ id: "rank", name: "#", right: true, width: "36px", cell: (_r, i) => (i ?? 0) + 1 },
{ id: "len", name: "Length", right: true, width: "100px", format: (s) => fmtCount(s.len), selector: (s) => s.len, sortable: true },
{ id: "count", name: "Count", right: true, width: "100px", format: (s) => fmtCount(s.count), selector: (s) => s.count, sortable: true },
{ id: "value", name: "Value", grow: 1, minWidth: "100px", maxWidth: "600px", cell: (s) => <ExpandableText text={s.text} label="Longest String Value" /> },
];
return (
<>
<h3>Longest Values</h3>
<StdTable columns={cols} data={rows} searchKeys={["text"]} defaultSortFieldId="len" defaultSortAsc={false} />
</>
);
}
function StringHoldersTable({ rows }: { rows: StringHolder[] }) {
const cols: TableColumn<StringHolder>[] = [
{ id: "class", name: "Class", grow: 1, maxWidth: "600px", cell: (h) => <span className="copy-cell"><code title={h.class_name}>{h.class_name}</code><CopyBtn text={h.class_name} /><PivotBtn cls={h.class_name} /><OqlBtn cls={h.class_name} /><ListObjectsBtn cls={h.class_name} /></span>, selector: (h) => h.class_name, sortable: true },
{ id: "refs", name: "String Refs", right: true, width: "120px", format: (h) => fmtCount(h.string_refs), selector: (h) => h.string_refs, sortable: true },
];
return (
<>
<h3>Classes Holding the Most Strings</h3>
<p className="subtitle">
Which classes hold the most <code>java.lang.String</code> references — likely candidates to benefit from deduplication or interning.
</p>
<StdTable columns={cols} data={rows} searchKeys={["class_name"]} defaultSortFieldId="refs" defaultSortAsc={false} />
</>
);
}
function CharArrayWasteTopTable({ rows }: { rows: CharArrayWasteRow[] }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const cols: TableColumn<CharArrayWasteRow>[] = [
{ id: "array", name: "Array #", right: true, width: "100px", format: (r) => fmtCount(r.array_obj_1based), selector: (r) => r.array_obj_1based, sortable: true },
{ id: "length", name: "Length", right: true, width: "100px", format: (r) => fmtCount(r.length), selector: (r) => r.length, sortable: true },
{ id: "used", name: useKB ? "Used (KB)" : "Used", right: true, width: useKB ? "132px" : "100px", cell: byteCell(r => r.used, fmtB, useKB), selector: (r) => r.used, sortable: true },
{ id: "wasted", name: useKB ? "Wasted (KB)" : "Wasted", right: true, width: useKB ? "132px" : "100px", cell: byteCell(r => r.wasted_bytes, fmtB, useKB), selector: (r) => r.wasted_bytes, sortable: true },
];
return (
<StdTable columns={cols} data={rows} searchKeys={[]} fmtBtn={kbBtn} defaultSortFieldId="wasted" defaultSortAsc={false} />
);
}
// ── Small capped sub-tables for Duplicate Prim Arrays section ─────────────────
function DupPrimArrayRowsTable({ rows }: { rows: DupPrimArrayRow[] }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const cols: TableColumn<DupPrimArrayRow>[] = [
{ id: "rank", name: "#", right: true, width: "36px", cell: (_r, i) => (i ?? 0) + 1 },
{ id: "type", name: "Array Type", grow: 1, cell: (r) => <span className="copy-cell"><code title={r.array_class}>{r.array_class}</code><CopyBtn text={r.array_class} /><PivotBtn cls={r.array_class} /><OqlBtn cls={r.array_class} /><ListObjectsBtn cls={r.array_class} /></span>, selector: (r) => r.array_class, sortable: true },
{ id: "groups", name: "Dup Groups", right: true, width: "125px", format: (r) => fmtCount(r.duplicated_groups), selector: (r) => r.duplicated_groups, sortable: true },
{ id: "wasted", name: useKB ? "Wasted (KB)" : "Wasted", right: true, width: useKB ? "132px" : "100px", cell: byteCell(r => r.wasted_bytes, fmtB, useKB), selector: (r) => r.wasted_bytes, sortable: true },
];
return (
<>
<h3>Waste by Array Element Type</h3>
<p className="subtitle">Reclaimable bytes grouped by array element type — focus on the highest-waste type first.</p>
<StdTable columns={cols} data={rows} searchKeys={["array_class"]} fmtBtn={kbBtn} defaultSortFieldId="wasted" defaultSortAsc={false} />
</>
);
}
function DupArrayHoldersTable({ rows }: { rows: DupArrayHolder[] }) {
const cols: TableColumn<DupArrayHolder>[] = [
{ id: "rank", name: "#", right: true, width: "36px", cell: (_r, i) => (i ?? 0) + 1 },
{ id: "class", name: "Class", grow: 1, maxWidth: "600px", cell: (h) => <span className="copy-cell"><code title={h.class_name}>{h.class_name}</code><CopyBtn text={h.class_name} /><PivotBtn cls={h.class_name} /><OqlBtn cls={h.class_name} /><ListObjectsBtn cls={h.class_name} /></span>, selector: (h) => h.class_name, sortable: true },
{ id: "refs", name: "Array Refs", right: true, width: "120px", format: (h) => fmtCount(h.array_refs), selector: (h) => h.array_refs, sortable: true },
];
return (
<>
<h3>Classes Holding the Most Duplicate Arrays</h3>
<StdTable columns={cols} data={rows} searchKeys={["class_name"]} defaultSortFieldId="refs" defaultSortAsc={false} />
</>
);
}
// ── Duplicate Strings (approximate) ────────────────────────────────────────────
function DuplicateStringsSection({ report }: { report: Report }) {
const [fmtB] = useFmtBytes();
const d = report.overview.duplicate_strings;
if (!d) {
const wasRun = report.analysis_flags?.find_duplicates ?? false;
return (
<section id="duplicate-strings">
<h2>Duplicate Strings</h2>
<p className="subtitle">
{wasRun
? "No duplicate strings found."
: <>
Not run — use <strong>Full Analysis</strong> in the browser or pass <code>--find-duplicates</code> via CLI.
</>
}
</p>
</section>
);
}
const w = d.char_array_waste;
return (
<section id="duplicate-strings">
<h2>Duplicate Strings</h2>
<p className="subtitle">
String values seen more than once — reclaim by normalizing at parse time, using <code>-XX:+UseStringDeduplication</code> (G1 GC), or sharing a canonical instance per value. Deduplication is approximate (64-bit hash; rare collisions possible).{" "}
Approximate wasted: <strong title={fmtExactBytes(d.approx_wasted_bytes)}>{fmtB(d.approx_wasted_bytes)}</strong> across{" "}
{fmtCount(d.duplicated_values)} duplicated values ({fmtCount(d.total_string_instances)} total String instances, {fmtCount(d.distinct_values)} distinct).
</p>
{d.top_duplicated.length > 0 && (
<TopDuplicatedTable rows={d.top_duplicated} />
)}
{d.top_by_length.length > 0 && (
<TopByLengthTable rows={d.top_by_length} />
)}
{d.length_histogram.length > 0 && (() => {
type LenBucket = (typeof d.length_histogram)[0];
const lenCols: TableColumn<LenBucket>[] = [
{ id: "upper", name: "Length ≤", right: true, width: "120px", format: (b) => fmtCount(b.upper_len), selector: (b) => b.upper_len, sortable: true },
{ id: "values", name: "Values", right: true, width: "120px", format: (b) => fmtCount(b.count), selector: (b) => b.count, sortable: true },
];
return (
<>
<h3>String Length Distribution</h3>
<p className="subtitle">
Length distribution of distinct string values (in chars/bytes) — a peak at short lengths is normal; a peak at unexpectedly long lengths may signal log buffers or URL strings worth truncating. Min: {fmtCount(d.length_stats.min)} · Median: {fmtCount(d.length_stats.median)} · Max: {fmtCount(d.length_stats.max)} · Total: <span title={fmtExactBytes(d.length_stats.total)}>{fmtB(d.length_stats.total)}</span>.
</p>
<StdTable columns={lenCols} data={d.length_histogram} searchKeys={[]} defaultSortFieldId="upper" />
</>
);
})()}
{d.top_string_holders.length > 0 && (
<StringHoldersTable rows={d.top_string_holders} />
)}
{w && (
<>
<h3><code>char[]</code> Waste</h3>
<p className="subtitle">
Strings whose <code>char[]</code> or <code>byte[]</code> backing array is larger than the character data — typical of <code>StringBuilder.toString()</code> leaving slack capacity, or oversized pre-allocated buffers. {fmtCount(w.arrays_examined)} arrays examined, {fmtCount(w.wasteful_arrays)} wasteful,{" "}
<span title={fmtExactBytes(w.total_wasted_bytes)}>{fmtB(w.total_wasted_bytes)}</span> total wasted.
</p>
{w.top.length > 0 && (
<CharArrayWasteTopTable rows={w.top} />
)}
</>
)}
</section>
);
}
function DuplicatePrimArraysSection({ report }: { report: Report }) {
const [fmtB] = useFmtBytes();
const d = report.overview.duplicate_prim_arrays;
if (!d) {
const wasRun = report.analysis_flags?.find_duplicates ?? false;
return (
<section id="duplicate-prim-arrays">
<h2>Duplicate Primitive Arrays</h2>
<p className="subtitle">
{wasRun
? "No duplicate primitive arrays found."
: <>
Not run — use <strong>Full Analysis</strong> in the browser or pass <code>--find-duplicates</code> via CLI.
</>
}
</p>
</section>
);
}
return (
<section id="duplicate-prim-arrays">
<h2>Duplicate Primitive Arrays</h2>
<p className="subtitle">
Primitive arrays with identical content — each group wastes memory holding redundant copies. Replace with a shared <code>static final</code> constant, use a canonical-instance registry, or intern at creation time.
Approximate wasted: <strong title={fmtExactBytes(d.total_wasted_bytes)}>{fmtB(d.total_wasted_bytes)}</strong>. Deduplication is approximate (64-bit hash; rare collisions possible).
</p>
{d.rows.length > 0 && (
<DupPrimArrayRowsTable rows={d.rows} />
)}
{d.top_array_holders && d.top_array_holders.length > 0 && (
<DupArrayHoldersTable rows={d.top_array_holders} />
)}
</section>
);
}
function BoxedNumbersSection({ report }: { report: Report }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const rows = report.overview.boxed_numbers;
if (!rows?.length) return null;
const total = report.overview.total_shallow;
const holders = report.overview.boxed_number_holders ?? [];
const boxedCols: TableColumn<import("./types").BoxedNumberRow>[] = [
{ id: "rank", name: "#", right: true, width: "36px", cell: (_r, i) => (i ?? 0) + 1 },
{ id: "class", name: "Class", grow: 1, maxWidth: "450px", cell: (r) => <span className="copy-cell"><code title={r.pretty_class}>{r.pretty_class}</code><CopyBtn text={r.pretty_class} /><PivotBtn cls={r.pretty_class} /><OqlBtn cls={r.pretty_class} /><ListObjectsBtn cls={r.pretty_class} /></span>, selector: (r) => r.pretty_class, sortable: true },
{ id: "instances", name: "Instances", right: true, width: "120px", format: (r) => fmtCount(r.instances), selector: (r) => r.instances, sortable: true },
{ id: "shallow", name: useKB ? "Total Shallow (KB)" : "Total Shallow", right: true, width: useKB ? "172px" : "138px", cell: byteCell(r => r.total_shallow, fmtB, useKB), selector: (r) => r.total_shallow, sortable: true },
{ id: "pct", name: "% of Heap", right: true, width: "115px", format: (r) => total > 0 ? (r.pct_of_heap_bp === 0 && r.total_shallow > 0 ? "< 0.1%" : fmtPct(r.pct_of_heap_bp / 100)) : "—", selector: (r) => r.pct_of_heap_bp, sortable: true },
{ id: "avg", name: useKB ? "Avg Size (KB)" : "Avg Size", right: true, width: useKB ? "140px" : "105px", cell: byteCell(r => r.avg_shallow, fmtB, useKB), selector: (r) => r.avg_shallow, sortable: true },
];
const holderCols: TableColumn<import("./types").BoxedNumberHolder>[] = [
{ id: "rank", name: "#", right: true, width: "36px", cell: (_r, i) => (i ?? 0) + 1 },
{ id: "class", name: "Class", grow: 1, maxWidth: "600px", cell: (h) => <span className="copy-cell"><code title={h.class_name}>{h.class_name}</code><CopyBtn text={h.class_name} /><PivotBtn cls={h.class_name} /><OqlBtn cls={h.class_name} /><ListObjectsBtn cls={h.class_name} /></span>, selector: (h) => h.class_name, sortable: true },
{ id: "refs", name: "Boxed Refs", right: true, width: "130px", format: (h) => fmtCount(h.boxed_refs), selector: (h) => h.boxed_refs, sortable: true },
];
return (
<section id="boxed-numbers">
<h2>Boxed Numbers</h2>
<p className="subtitle">
Heap consumed by <code>Integer</code>, <code>Long</code>, <code>Double</code>, and other boxed wrapper types. Each boxed value costs 16–24 bytes (12-byte object header + primitive field, padded to 8-byte boundary) versus 4–8 bytes as an unboxed primitive. Replacing with primitive fields or <code>int[]</code>/<code>long[]</code> arrays eliminates the per-object header.
</p>
<StdTable columns={boxedCols} data={rows} searchKeys={["pretty_class"]} fmtBtn={kbBtn} defaultSortFieldId="shallow" defaultSortAsc={false} />
{holders.length > 0 && (
<>
<h3>Classes Holding the Most Boxed-Number References</h3>
<StdTable columns={holderCols} data={holders} searchKeys={["class_name"]} defaultSortFieldId="refs" defaultSortAsc={false} />
</>
)}
</section>
);
}
function HeaderOverheadSection({ report }: { report: Report }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const rows = report.overview.header_overhead;
if (!rows?.length) return null;
const cols: TableColumn<import("./types").HeaderOverheadRow>[] = [
{ id: "rank", name: "#", right: true, width: "36px", cell: (_r, i) => (i ?? 0) + 1 },
{ id: "class", name: "Class", grow: 1, maxWidth: "312px", cell: (r) => <span className="copy-cell"><code title={r.pretty_class}>{r.pretty_class}</code><CopyBtn text={r.pretty_class} /><PivotBtn cls={r.pretty_class} /><OqlBtn cls={r.pretty_class} /><ListObjectsBtn cls={r.pretty_class} /></span>, selector: (r) => r.pretty_class, sortable: true },
{ id: "instances", name: "Instances", right: true, width: "120px", format: (r) => fmtCount(r.instances), selector: (r) => r.instances, sortable: true },
{ id: "hdr", name: "Header / Obj", right: true, width: "130px", format: (r) => `${r.header_bytes} B`, selector: (r) => r.header_bytes, sortable: true },
{ id: "total_hdr", name: useKB ? "Total Headers (KB)" : "Total Headers", right: true, width: useKB ? "176px" : "140px", cell: byteCell(r => r.total_header_bytes, fmtB, useKB), selector: (r) => r.total_header_bytes, sortable: true },
{ id: "pct", name: "% of Shallow", right: true, width: "134px", format: (r) => fmtPct(r.header_pct_of_shallow_bp / 100), selector: (r) => r.header_pct_of_shallow_bp, sortable: true },
{ id: "avg", name: useKB ? "Avg Size (KB)" : "Avg Size", right: true, width: useKB ? "140px" : "105px", cell: byteCell(r => r.avg_shallow, fmtB, useKB), selector: (r) => r.avg_shallow, sortable: true },
];
return (
<section id="object-header-overhead">
<h2>Object Header Overhead</h2>
<p className="subtitle">
Classes where object headers (12 bytes with compressed OOPs, 16 without) consume a large share of shallow heap. The practical action is to reduce object <em>count</em>: merge small objects, use primitive arrays instead of boxed wrappers, or replace fine-grained instances with a flat array of fields. Value types (Project Valhalla) eliminate headers entirely.
</p>
<StdTable columns={cols} data={rows} searchKeys={["pretty_class"]} fmtBtn={kbBtn} defaultSortFieldId="total_hdr" defaultSortAsc={false} />
</section>
);
}
function GcRootHeatmap({ rows }: { rows: GcRootRetainedRow[] }) {
const fmtB = formatBytes;
// Derive top-8 classes across all rows by total retained
const classTotals = new Map<string, number>();
for (const row of rows) {
for (const cc of row.top_classes ?? []) {
classTotals.set(cc.class_name, (classTotals.get(cc.class_name) ?? 0) + cc.retained);
}
}
const topCols = [...classTotals.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([cls]) => cls);
if (topCols.length === 0) return null;
// Build matrix
const matrix: (number | null)[][] = rows.map(row =>
topCols.map(cls => {
const found = row.top_classes?.find(c => c.class_name === cls);
return found ? found.retained : null;
})
);
const maxCell = Math.max(...matrix.flatMap(r => r.map(v => v ?? 0)), 1);
return (
<div style={{ overflowX: "auto", marginTop: "0.75rem" }}>
<table className="gc-heatmap-table">
<thead>
<tr>
<th className="gc-heatmap-rowlabel" />
{topCols.map(cls => (
<th key={cls} className="gc-heatmap-colhead" title={cls}>
<div className="gc-heatmap-coltext">{cls.split(".").pop()}</div>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={row.root_type}>
<td className="gc-heatmap-rowlabel">{row.root_type.replace(/_/g, " ")}</td>
{topCols.map((cls, ci) => {
const val = matrix[ri][ci];
if (val == null) {
return <td key={cls} className="gc-heatmap-cell gc-heatmap-empty" />;
}
const t = val / maxCell;
const bg = heatColor(t);
const textColor = t > 0.5 ? "#fff" : "var(--fg)";
return (
<td key={cls} className="gc-heatmap-cell"
style={{ background: bg, color: textColor }}
title={`${row.root_type} → ${cls}: ${fmtB(val)} (${fmtExactBytes(val)})`}
onClick={() => fireInspect({ kind: "class", cls })}>
{fmtB(val)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
function SystemOverviewSection({ report }: { report: Report }) {
const fmtB = formatBytes;
const o = report.overview;
const threadCount = report.threads?.threads?.length ?? 0;
return (
<section id="system-overview">
<h2>System Overview</h2>
<p className="subtitle">JVM and dump metadata, heap totals, GC root breakdown, class loader sizes, and system properties.</p>
<div className="card">
<dl className="summary-grid">
<dt>Source File</dt>
<dd>
<code title={o.file_path}>{o.source_name}</code>
{o.file_path && o.file_path !== o.source_name && (
<span className="hint" style={{ display: "block" }}>
{o.file_path}
</span>
)}
</dd>
<dt>HPROF Format</dt>
<dd>{o.format}</dd>
{o.jvm_version && (
<>
<dt>JVM Version</dt>
<dd>
<code>{o.jvm_version}</code>
</dd>
</>
)}
<dt>File Size</dt>
<dd><span title={fmtExactBytes(o.file_size)}>{fmtB(o.file_size)}</span></dd>
<dt>Identifier Size</dt>
<dd>{o.identifier_size_bits}-bit</dd>
{o.compressed_oops !== null && (
<>
<dt>Compressed OOPs</dt>
<dd>{o.compressed_oops ? "Yes" : "No"}</dd>
</>
)}
{o.dump_creation !== null && (
<>
<dt>Dump Created</dt>
<dd><span title={formatEpochMs(o.dump_creation)}>{formatDateNice(o.dump_creation)}</span></dd>
</>
)}
<dt>Total Objects</dt>
<dd>{fmtCount(o.total_objects)}</dd>
<dt>Total Reachable Heap</dt>
<dd><span title={fmtExactBytes(o.total_shallow)}>{fmtB(o.total_shallow)}</span></dd>
<dt>GC Roots</dt>
<dd>{fmtCount(o.gc_roots)}</dd>
<dt>Classes Loaded</dt>
<dd>{fmtCount(o.classes_loaded)}</dd>
<dt>Class Loaders</dt>
<dd>{fmtCount(o.classloaders_loaded)}</dd>
{threadCount > 0 && (
<>
<dt>Threads (with Call Stacks)</dt>
<dd>
<a href="#threads">{fmtCount(threadCount)}</a>
</dd>
</>
)}
{o.unreachable_count > 0 && (
<>
<dt>Unreachable (Excluded)</dt>
<dd>
{fmtCount(o.unreachable_count)} (<span title={fmtExactBytes(o.unreachable_shallow)}>{fmtB(o.unreachable_shallow)}</span>)
</dd>
</>
)}
{(o.heap_fragmentation_ratio ?? 0) > 0 && (
<>
<dt title="unreachable ÷ (reachable + unreachable)">Dead Object Ratio (unreachable / total)</dt>
<dd>{fmtPct((o.heap_fragmentation_ratio ?? 0) * 100)}</dd>
</>
)}
{(o.top_class_concentration_bp ?? 0) > 0 && (
<>
<dt title="Retained heap share of the single largest class (top histogram row) — a high value means one class dominates retention">Top-Class Retained Concentration</dt>
<dd>{fmtPct((o.top_class_concentration_bp ?? 0) / 100)}</dd>
</>
)}
</dl>
</div>
{o.system_properties.length > 0 ? (
<details>
<summary>System Properties ({fmtCount(o.system_properties.length)})</summary>
<SysPropsTable rows={o.system_properties} />
</details>
) : (
<p className="subtitle">System properties not captured.</p>
)}
{o.heap_composition.by_kind.length > 0 && (() => {
const compCols: TableColumn<KindStat>[] = [
{ id: "kind", name: "Kind", grow: 1, cell: (k) => <span style={{ textTransform: "capitalize" }}>{k.kind}</span>, selector: (k) => k.kind, sortable: true },
{ id: "objects", name: "Objects", right: true, width: "120px", format: (k) => fmtCount(k.objects), selector: (k) => k.objects, sortable: true },
{ id: "shallow", name: "Shallow Heap", right: true, width: "138px", cell: (k) => <span title={fmtExactBytes(k.shallow_heap)}>{fmtB(k.shallow_heap)}</span>, selector: (k) => k.shallow_heap, sortable: true },
];
return (
<>
<h3>Heap Composition</h3>
<p className="subtitle">Shallow heap broken down by object kind: instances, object arrays, primitive arrays, and class objects.</p>
<ChartOrNote hasData={o.heap_composition.by_kind.length >= 2} note="Composition chart needs ≥2 kinds; table only.">
<HeapCompositionChart data={o.heap_composition.by_kind} />
<CompositionStackedBar data={o.heap_composition.by_kind} />
</ChartOrNote>
<StdTable columns={compCols} data={o.heap_composition.by_kind} searchKeys={["kind"]} defaultSortFieldId="shallow" defaultSortAsc={false} />
</>
);
})()}
{(o.gc_roots_retained_by_type?.length ?? o.gc_roots_by_type.length) > 0 && (() => {
const gcRows = o.gc_roots_retained_by_type?.length
? o.gc_roots_retained_by_type
: o.gc_roots_by_type.map((r) => ({ ...r, retained: 0, top_classes: [] as GcRootClassRow[] }));
const maxCount = Math.max(...gcRows.map((r) => r.count), 1);
const totalCount = gcRows.reduce((s, r) => s + r.count, 0);
const totalRetained = gcRows.reduce((s, r) => s + r.retained, 0);
type GcRow = (typeof gcRows)[0];
const gcCols: TableColumn<GcRow>[] = [
{
id: "bar", name: "", width: "90px", grow: 0,
cell: (r) => (
<span className="bar-bg" style={{ width: 80 }}>
<span className="bar-fill" style={{ width: `${(r.count / maxCount) * 100}%` }} />
</span>
),
},
{ id: "type", name: "Root Type", width: "210px", selector: (r) => r.root_type, sortable: true },
{ id: "count", name: "Count", right: true, width: "100px", format: (r) => fmtCount(r.count), selector: (r) => r.count, sortable: true },
{ id: "pct", name: "% of Roots", right: true, width: "104px", format: (r) => fmtPct(totalCount > 0 ? (r.count / totalCount) * 100 : 0), selector: (r) => r.count, sortable: true },
{ id: "retained", name: "Retained", right: true, width: "128px", cell: (r: GcRow) => <span title={fmtExactBytes(r.retained)}>{fmtB(r.retained)}</span>, selector: (r: GcRow) => r.retained, sortable: true },
{
id: "top_classes", name: "Top Retained Classes", grow: 1, maxWidth: "408px", wrap: true,
cell: (r: GcRow) => {
const top = (r as GcRootRetainedRow).top_classes;
if (!top || top.length === 0) return <span style={{ color: "var(--muted)" }}>—</span>;
return (
<div style={{ fontSize: "0.82em", lineHeight: 1.6, whiteSpace: "normal", padding: "2px 0" }}>
{top.map((cc, j) => (
<span key={cc.class_name} style={{ display: "inline-flex", alignItems: "center", gap: 2, whiteSpace: "nowrap", marginRight: j < top.length - 1 ? "0.4em" : 0 }}>
{j > 0 ? <span style={{ color: "var(--muted)", marginRight: 2 }}>·</span> : null}
<span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle" }}><code title={cc.class_name} style={{ overflow: "hidden", textOverflow: "ellipsis", maxWidth: "240px" }}>{cc.class_name}</code><CopyBtn text={cc.class_name} /><PivotBtn cls={cc.class_name} /><OqlBtn cls={cc.class_name} /><ListObjectsBtn cls={cc.class_name} /></span> ×{fmtCount(cc.count)} (<span title={fmtExactBytes(cc.retained)}>{fmtB(cc.retained)}</span>)
</span>
))}
</div>
);
},
},
];
return (
<>
<h3>GC Roots by Type</h3>
<p className="subtitle">GC roots are the entry points where the JVM starts reachability scanning — anything reachable from a root stays alive. Common root types: thread-stack locals, JNI global references, static fields of loaded classes, and synchronized lock objects.</p>
{totalRetained > 0
? <GcRootsRetainedChart data={gcRows} />
: <GcRootsChart data={gcRows} />}
<StdTable columns={gcCols} data={gcRows} searchKeys={["root_type"]} defaultSortFieldId="retained" defaultSortAsc={false} />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ width: "90px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5 }} />
<span style={{ width: "210px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: "100px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(totalCount)}</span>
<span style={{ width: "104px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>100%</span>
<span style={{ width: "128px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(totalRetained)}>{fmtB(totalRetained)}</span></span>
<span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }} />
</div>
{o.gc_roots_retained_by_type?.some(r => r.root_type.toLowerCase().includes('jni') && r.retained > 100 * 1024 * 1024) && (
<p className="subtitle" style={{ color: 'var(--warn-border)' }}>⚠ JNI roots hold significant retained heap — check for native code registering JNI globals without a matching <code>DeleteGlobalRef</code>.</p>
)}
{o.gc_roots_retained_by_type?.some(r => (r.top_classes?.length ?? 0) > 0) && (
<>
<h4 style={{ marginBottom: "0.25rem" }}>Root Type × Top Classes</h4>
<p className="subtitle" style={{ marginTop: 0 }}>Cells show retained bytes per root-type/class pair. Click a cell to inspect the class.</p>
<GcRootHeatmap rows={o.gc_roots_retained_by_type!} />
</>
)}
</>
);
})()}
<h3>Class Histogram (by Retained Heap)</h3>
<p className="subtitle">Every loaded class with its instance count, shallow heap (own bytes), and retained heap (bytes freed when all instances become unreachable).</p>
{o.histogram_truncated_to != null && (
<p className="subtitle">
Histogram capped to the largest {fmtCount(o.histogram_truncated_to)} classes.
</p>
)}
<ChartOrNote hasData={o.histogram.length > 0} note="No histogram classes to chart.">
<TopClassesChart data={o.histogram} totalRetained={o.histogram.reduce((s, r) => s + r.retained, 0)} />
</ChartOrNote>
<ClassHistogramTable rows={o.histogram} totalShallow={o.total_shallow} />
{o.loader_rollup.length > 0 && (
<>
<h3>Class Loaders</h3>
<p className="subtitle">
Retained heap attributed to each class loader — growing loaders (e.g. web-app or plugin loaders) are a common source of metaspace and heap leaks. Each tile is sized by retained heap. Click to inspect; use ← to go back.
</p>
{(() => {
type LoaderNode = LoaderRollup & { _children?: LoaderRollup[] };
const loaderRoot: LoaderNode = {
loader_label: "All Loaders",
loader_id: -1,
class_count: o.loader_rollup.reduce((s, r) => s + r.class_count, 0),
instances: o.loader_rollup.reduce((s, r) => s + r.instances, 0),
shallow: o.loader_rollup.reduce((s, r) => s + r.shallow, 0),
retained: o.loader_rollup.reduce((s, r) => s + r.retained, 0),
_children: o.loader_rollup,
};
return (
<ZoomableTreemap<LoaderNode>
root={loaderRoot}
getChildren={(n) => n._children ?? []}
getValue={(n) => n.retained}
getLabel={(n) => n.loader_label ? fmtLoader(n.loader_label) : `loader#${n.loader_id}`}
fmt={formatBytes}
fmtExact={fmtExactBytes}
height={280}
/>
);
})()}
<ClassLoadersTable rows={o.loader_rollup} />
</>
)}
{o.duplicate_classes.length > 0 && (
<>
<h3 id="duplicate-classes">Duplicate Classes</h3>
<p className="subtitle">
Class names loaded by more than one class loader. The same class loaded N times means N separate copies of its static state and N times the metaspace cost — a typical symptom of class-loader leaks (e.g. each web-app reload or plugin load creates a new loader that never gets GC'd). Check the per-loader breakdown: if one loader holds almost all the instances, the others are likely leaked copies.
</p>
<DuplicateClassesTable rows={o.duplicate_classes} />
</>
)}
</section>
);
}
// ── Leak Suspects ───────────────────────────────────────────────────────────
function ClassLoadersTable({ rows }: { rows: LoaderRollup[] }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const cols: TableColumn<LoaderRollup>[] = [
{ id: "loader", name: "Loader", grow: 1, cell: (r) => <code title={r.loader_label ? fmtLoader(r.loader_label) : undefined}>{r.loader_label ? fmtLoader(r.loader_label) : `loader@${r.loader_id}`}</code>, selector: (r) => r.loader_label ?? "", sortable: true },
{ id: "address", name: "Address", width: "130px", cell: (r) => <code style={{ fontSize: "0.78rem" }}>{r.loader_id === 0 ? "<boot>" : `0x${r.loader_id.toString(16)}`}</code>, selector: (r) => r.loader_id, sortable: true },
{ id: "classes", name: "Classes", right: true, width: "100px", format: (r) => fmtCount(r.class_count), selector: (r) => r.class_count, sortable: true },
{ id: "instances", name: "Instances", right: true, width: "120px", format: (r) => fmtCount(r.instances), selector: (r) => r.instances, sortable: true },
{ id: "shallow", name: useKB ? "Shallow (KB)" : "Shallow", right: true, width: useKB ? "135px" : "110px", cell: byteCell(r => r.shallow, fmtB, useKB), selector: (r) => r.shallow, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "110px", cell: byteCell(r => r.retained, fmtB, useKB), selector: (r) => r.retained, sortable: true },
];
return <StdTable columns={cols} data={rows} searchKeys={["loader_label"]} fmtBtn={kbBtn} defaultSortFieldId="retained" defaultSortAsc={false} />;
}
function DuplicateClassesTable({ rows }: { rows: DuplicateClass[] }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const loaderDetailCols: TableColumn<typeof rows[0]["per_loader"][0]>[] = [
{ id: "loader", name: "Loader", grow: 1,
cell: pl => <code title={pl.loader_label ? fmtLoader(pl.loader_label) : undefined}>{pl.loader_label ? fmtLoader(pl.loader_label) : "—"}</code>,
selector: pl => pl.loader_label ?? "", sortable: true },
{ id: "instances", name: "Instances", right: true, width: "110px",
format: pl => fmtCount(pl.instances), selector: pl => pl.instances, sortable: true },
{ id: "shallow", name: useKB ? "Shallow (KB)" : "Shallow", right: true, width: useKB ? "135px" : "110px",
cell: byteCell(pl => pl.shallow, fmtB, useKB), selector: pl => pl.shallow, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "110px",
cell: byteCell(pl => pl.retained, fmtB, useKB), selector: pl => pl.retained, sortable: true },
];
const cols: TableColumn<DuplicateClass>[] = [
{
id: "class", name: "Class", grow: 1, maxWidth: "600px",
cell: (d) => (
<span title={d.loaders.map(fmtLoader).join(", ")}>
{d.per_loader && d.per_loader.length > 0 ? (
<details>
<summary>
<span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle" }}><code title={d.pretty_class}>{d.pretty_class}</code><CopyBtn text={d.pretty_class} /><PivotBtn cls={d.pretty_class} /><OqlBtn cls={d.pretty_class} /><ListObjectsBtn cls={d.pretty_class} /></span>
</summary>
<DataTable columns={loaderDetailCols} data={d.per_loader} customStyles={histogramTableStyles} dense />
</details>
) : (
<span className="copy-cell"><code title={d.pretty_class}>{d.pretty_class}</code><CopyBtn text={d.pretty_class} /><PivotBtn cls={d.pretty_class} /><OqlBtn cls={d.pretty_class} /><ListObjectsBtn cls={d.pretty_class} /></span>
)}
</span>
),
},
{ id: "loaders", name: "# Loaders", right: true, width: "112px", format: (d) => fmtCount(d.loader_count), selector: (d) => d.loader_count, sortable: true },
{ id: "instances", name: "Instances", right: true, width: "120px", format: (d) => fmtCount(d.total_instances), selector: (d) => d.total_instances, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "110px", cell: byteCell(d => d.total_retained, fmtB, useKB), selector: (d) => d.total_retained, sortable: true },
];
return <StdTable columns={cols} data={rows} searchKeys={["pretty_class"]} fmtBtn={kbBtn} defaultSortFieldId="retained" defaultSortAsc={false} />;
}
// Renders the accumulation "shortest path" (MAT's signature view) plus the
// per-class breakdown of what piles up at the accumulation point.
function AccumulationPath({ s }: { s: Suspect }) {
const [fmtB] = useFmtBytes();
if (s.path.length === 0) return null;
return (
<details open>
<summary>Shortest Path to Accumulation Point ({s.path.length} steps)</summary>
<ol className="accum-path">
{s.path.map((p, i) => (
<li key={i}>
<span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle" }}>
<code style={{ cursor: "pointer" }} title="Click to view in Inspector" onClick={() => pivotClass(p.display_class)}>{p.display_class}</code>
<CopyBtn text={p.display_class} />
<PivotBtn cls={p.display_class} />
<OqlBtn cls={p.display_class} />
<ListObjectsBtn cls={p.display_class} />
<ExploreBtn denseIdx={p.obj_index_1based - 1} label={p.display_class} />
</span>{" "}
<span className="path-ret">retains <span title={fmtExactBytes(p.retained)}>{fmtB(p.retained)}</span></span>
</li>
))}
</ol>
</details>
);
}
function DominatedByClass({ rows, suspectRetained }: { rows: HistRow[]; suspectRetained: number }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
if (rows.length === 0) return null;
const cols: TableColumn<HistRow>[] = [
{ id: "class", name: "Class", grow: 1, maxWidth: "530px", cell: (r) => <span className="copy-cell"><code title={r.pretty_class}>{r.pretty_class}</code><CopyBtn text={r.pretty_class} /><PivotBtn cls={r.pretty_class} /><OqlBtn cls={r.pretty_class} /><ListObjectsBtn cls={r.pretty_class} /></span>, selector: (r) => r.pretty_class, sortable: true },
{ id: "instances", name: "Instances", right: true, width: "120px", format: (r) => fmtCount(r.instances), selector: (r) => r.instances, sortable: true },
{ id: "shallow", name: useKB ? "Shallow (KB)" : "Shallow", right: true, width: useKB ? "135px" : "110px", cell: byteCell(r => r.shallow, fmtB, useKB), selector: (r) => r.shallow, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "110px", cell: byteCell(r => r.retained, fmtB, useKB), selector: (r) => r.retained, sortable: true },
{ id: "pct", name: "% of Suspect", right: true, width: "125px", format: (r) => suspectRetained > 0 ? fmtPct(pctOf(r.retained, suspectRetained)) : "—", selector: (r) => r.retained, sortable: true },
];
return (
<details open>
<summary>Dominated Objects by Class ({rows.length})</summary>
<StdTable columns={cols} data={rows} searchKeys={["pretty_class"]} fmtBtn={kbBtn} defaultSortFieldId="retained" defaultSortAsc={false} />
</details>
);
}
function SysPropsTable({ rows }: { rows: { key: string; value: string }[] }) {
const sysCols: TableColumn<{ key: string; value: string }>[] = [
{ id: "key", name: "Key", width: "280px", grow: 0,
cell: r => <code>{r.key}</code>, selector: r => r.key, sortable: true },
{ id: "val", name: "Value", grow: 1, minWidth: "100px", maxWidth: "600px",
cell: r => <ExpandableText text={r.value} label={`${r.key}`} />,
selector: r => r.value, sortable: true },
];
return <StdTable columns={sysCols} data={rows} searchKeys={["key", "value"]} defaultSortFieldId="key" />;
}
// RootPathChain — SVG chain visualisation of the dominator path from suspect
// (step 0) down to its GC root (last step). Replaces the old numbered list.
function RootPathChain({ steps }: { steps: RootPathStep[] }) {
const [fmtB] = useFmtBytes();
const uid = React.useId();
const markerId = `rpc-arrow-${uid.replace(/:/g, "")}`;
const nodes = React.useContext(ObjGraphCtx);
if (steps.length === 0) return null;
// Layout constants
const VB_W = 620; // SVG viewBox width
const BOX_H = 44; // box height px
const BOX_X = 10; // left margin
const BOX_W = VB_W - 20; // box width
const CONN_H = 36; // vertical connector height between boxes
const R = 4; // corner radius
const ARROW_HEAD = 7; // arrowhead marker size
const PAD_X = 10; // text padding inside box
const last = steps.length - 1;
// Total SVG height: N boxes + (N-1) connectors
const svgH = steps.length * BOX_H + (steps.length - 1) * CONN_H;
return (
<details open className="root-path-chain">
<summary>Root Path to GC Root ({steps.length} step{steps.length === 1 ? "" : "s"})</summary>
<svg
viewBox={`0 0 ${VB_W} ${svgH}`}
width="100%"
role="img"
aria-label="Root path chain"
style={{ display: "block", maxWidth: "520px", margin: "0.5rem 0" }}
>
<defs>
<marker id={markerId} markerWidth={ARROW_HEAD} markerHeight={ARROW_HEAD}
refX={ARROW_HEAD / 2} refY={ARROW_HEAD / 2}
orient="auto" markerUnits="userSpaceOnUse">
<path
d={`M0,0 L${ARROW_HEAD},${ARROW_HEAD / 2} L0,${ARROW_HEAD} Z`}
fill="var(--muted)"
/>
</marker>
</defs>
{steps.map((step, i) => {
const boxY = i * (BOX_H + CONN_H);
const isFirst = i === 0;
const isLast = i === last;
const midX = BOX_X + BOX_W / 2;
const connTop = boxY + BOX_H;
const connBot = connTop + CONN_H - ARROW_HEAD;
const denseIdx = step.obj_index_1based - 1;
const inGraph = nodes != null && nodes[String(denseIdx)] != null;
// Truncate class name so it fits
const maxChars = 58;
const cls = step.display_class.length > maxChars
? step.display_class.slice(0, maxChars - 1) + "…"
: step.display_class;
return (
<g key={i}
style={inGraph ? { cursor: "pointer" } : undefined}
onClick={inGraph ? () => { (window as any).__explorerNavigate?.("explore", denseIdx) ?? (window.location.hash = `explore/${denseIdx}`); } : undefined}
role={inGraph ? "button" : undefined}
aria-label={inGraph ? `Explore ${step.display_class} in Object Graph` : undefined}
>
<title>{inGraph ? `Click to open ${step.display_class} in Object Graph Explorer` : step.display_class}</title>
{/* Box */}
{isLast ? (
// Double-border for GC root (two rects slightly inset)
<>
<rect x={BOX_X} y={boxY} width={BOX_W} height={BOX_H}
rx={R} ry={R}
fill="var(--bg)" stroke="var(--accent)" strokeWidth={1.5} />
<rect x={BOX_X + 3} y={boxY + 3} width={BOX_W - 6} height={BOX_H - 6}
rx={R} ry={R}
fill="none" stroke="var(--accent)" strokeWidth={1} />
</>
) : isFirst ? (
// Bold border for suspect (step 0)
<rect x={BOX_X} y={boxY} width={BOX_W} height={BOX_H}
rx={R} ry={R}
fill="var(--bg)" stroke="var(--accent)" strokeWidth={2} />
) : (
// Normal box
<rect x={BOX_X} y={boxY} width={BOX_W} height={BOX_H}
rx={R} ry={R}
fill="var(--bg)" stroke="var(--border)" strokeWidth={1} />
)}
{/* Class name */}
<text
x={BOX_X + PAD_X}
y={boxY + BOX_H / 2}
dy="0.35em"
fontSize={12}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
fill={isFirst || isLast ? "var(--accent)" : "var(--fg)"}
fontWeight={isFirst ? "700" : "400"}
>
{isFirst ? "SUSPECT " : ""}{cls}
</text>
{/* Retained bytes — right side */}
<text
x={BOX_X + BOX_W - PAD_X - (inGraph ? 16 : 0)}
y={boxY + BOX_H / 2}
dy="0.35em"
fontSize={11}
textAnchor="end"
fill="var(--muted)"
>
{!isLast && <title>{fmtExactBytes(step.retained)} retained</title>}
{isLast
? (step.root_type_label ? `GC Root: ${step.root_type_label}` : "GC Root")
: `retains ${fmtB(step.retained)}`}
</text>
{/* Explore badge for objects in graph */}
{inGraph && (
<text
x={BOX_X + BOX_W - PAD_X}
y={boxY + BOX_H / 2}
dy="0.35em"
fontSize={12}
textAnchor="end"
fill="var(--accent)"
opacity={0.7}
>
⬡
</text>
)}
{/* Connector arrow to next step */}
{!isLast && (
<>
<line
x1={midX} y1={connTop}
x2={midX} y2={connBot}
stroke="var(--muted)" strokeWidth={1.5}
markerEnd={`url(#${markerId})`}
/>
{steps[i + 1].field_edge && (
<text
x={midX + 6}
y={connTop + CONN_H / 2}
dy="0.35em"
fontSize={11}
fill="var(--muted)"
>
.{steps[i + 1].field_edge}
</text>
)}
</>
)}
</g>
);
})}
</svg>
</details>
);
}
// One node of the recursive "merged shortest paths to GC roots" prefix tree
// (class-group suspects). Mirrors DomSubtreeNode. Each node shows the class, how
// many member chains pass through it, and the aggregate retained heap; a
// terminal GC-root node carries its root-type label.
function MergedPathsNode({ node, depth }: { node: MergedPathNode; depth: number }) {
const [fmtB] = useFmtBytes();
const hasChildren = node.children.length > 0;
const label = (
<>
{node.field_edge && <span className="path-field">.{node.field_edge} → </span>}
<span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle" }}>
<code title={node.display_class}>{node.display_class}</code>
<CopyBtn text={node.display_class} />
<PivotBtn cls={node.display_class} />
<OqlBtn cls={node.display_class} />
<ListObjectsBtn cls={node.display_class} />
</span>{" "}
<span className="path-ret">
{fmtCount(node.object_count)} object{node.object_count === 1 ? "" : "s"} · retained <span title={fmtExactBytes(node.retained)}>{fmtB(node.retained)}</span>
</span>
{node.root_type_label && (
<> — <strong>GC root: {node.root_type_label}</strong></>
)}
</>
);
if (!hasChildren) {
return (
<li style={{ paddingLeft: `${depth * 1.1}rem` }}>
<span className="tree-leaf">•</span> {label}
</li>
);
}
return (
<li>
<details open={depth < 1}>
<summary style={{ paddingLeft: `${depth * 1.1}rem` }}>{label}</summary>
<ul className="dom-subtree">
{node.children.map((c, i) => (
<MergedPathsNode key={i} node={c} depth={depth + 1} />
))}
</ul>
</details>
</li>
);
}
function MergedPathsFallback({ node }: { node: MergedPathNode }) {
return (
<details open>
<summary>Merged Paths to GC Roots</summary>
<ul className="dom-subtree">
<MergedPathsNode node={node} depth={0} />
</ul>
</details>
);
}
// Flatten a MergedPathNode tree into sankey {nodes, links}.
function mergedPathToSankey(root: MergedPathNode) {
const nodeMap = new Map<string, number>(); // display_class → index
const nodes: { name: string; retained: number; count: number }[] = [];
const links: { source: number; target: number; value: number }[] = [];
function visit(node: MergedPathNode, parentIdx: number | null) {
let idx = nodeMap.get(node.display_class);
if (idx === undefined) {
idx = nodes.length;
nodeMap.set(node.display_class, idx);
nodes.push({ name: node.display_class, retained: node.retained, count: node.object_count });
} else {
nodes[idx].retained = Math.max(nodes[idx].retained, node.retained);
nodes[idx].count = Math.max(nodes[idx].count, node.object_count);
}
if (parentIdx !== null) {
links.push({ source: parentIdx, target: idx, value: Math.max(node.retained, 1) });
}
for (const child of node.children) visit(child, idx);
}
visit(root, null);
return { nodes, links };
}
interface SNode { name: string; retained: number; count: number; _idx: number }
interface SLink { source: number; target: number; value: number }
// MergedPathSankey — horizontal d3-sankey diagram of merged retention paths.
// Falls back to the old text tree when there are ≤1 nodes (degenerate case).
function MergedPathSankey({ node }: { node: MergedPathNode }) {
const [fmtB] = useFmtBytes();
const containerRef = React.useRef<HTMLDivElement>(null);
const [w, setW] = React.useState(600);
const [hoverPopover, setHoverPopover] = React.useState<{ x: number; y: number; name: string; count: number; retained: number } | null>(null);
React.useLayoutEffect(() => {
if (!containerRef.current) return;
const ro = new ResizeObserver((entries) => {
const bw = entries[0]?.contentRect.width;
if (bw && bw > 0) setW(Math.floor(bw));
});
ro.observe(containerRef.current);
return () => ro.disconnect();
}, []);
// Cap visible nodes to keep the diagram readable; show top N by retained.
const MAX_NODES = 40;
// Consolidate all data-derived computations into one memo keyed on `node`.
// This ensures visibleNodes/visibleLinks/height/nodePad are stable across
// renders and the graph memo only recomputes when underlying data changes.
const { rawNodes, rawLinks, visibleNodes, visibleLinks, nodePad, height, degenerate } = React.useMemo(() => {
const { nodes: rawNodes, links: rawLinks } = mergedPathToSankey(node);
// Filter out nodes that represent < 0.5% of total retained — they render as
// invisible hairlines in the sankey and add visual noise without information.
const MIN_SHARE = 0.005;
const totalRetained = node.retained > 0 ? node.retained : 1;
const significantNodes = rawNodes.filter((n) => n.retained / totalRetained >= MIN_SHARE);
// If fewer than 3 significant nodes survive the filter, the sankey would be
// uninformative (one dominant path + nothing else). Flag for fallback.
const degenerate = significantNodes.length < 3;
const candidateNodes = degenerate ? rawNodes : significantNodes;
const visibleNodes = candidateNodes.length > MAX_NODES
? candidateNodes.slice().sort((a, b) => b.retained - a.retained).slice(0, MAX_NODES)
: candidateNodes;
const visibleSet = new Set(visibleNodes.map((n) => n.name));
const visibleLinks = rawLinks.filter(
(l) => visibleSet.has(rawNodes[l.source]?.name) && visibleSet.has(rawNodes[l.target]?.name)
);
// Adaptive padding: reduce when many nodes so rects stay visible.
const nodePad = rawNodes.length > 30 ? 6 : rawNodes.length > 15 ? 10 : 12;
const height = Math.min(600, Math.max(200, visibleNodes.length * (14 + nodePad)));
return { rawNodes, rawLinks, visibleNodes, visibleLinks, nodePad, height, degenerate };
}, [node]);
// Right-side label column — 220px cleared for class name text.
const LABEL_COL = 220;
const graph = React.useMemo(() => {
if (w < 60 || visibleNodes.length <= 1) return null;
// Build fresh arrays; d3-sankey mutates them so we must clone each render.
const nodes: SNode[] = visibleNodes.map((n, i) => ({ ...n, _idx: i }));
// Remap link indices to positions within visibleNodes.
const nameToIdx = new Map(nodes.map((n, i) => [n.name, i]));
const links: SLink[] = [];
for (const l of visibleLinks) {
const sName = rawNodes[l.source]?.name;
const tName = rawNodes[l.target]?.name;
const si = sName !== undefined ? nameToIdx.get(sName) : undefined;
const ti = tName !== undefined ? nameToIdx.get(tName) : undefined;
if (si !== undefined && ti !== undefined) {
links.push({ source: si, target: ti, value: Math.max(l.value, 1) });
}
}
if (links.length === 0) return null;
try {
const svgW = Math.max(1, w - LABEL_COL);
const layout = sankey<SNode, SLink>()
.nodeId((n) => n._idx)
.nodeWidth(16)
.nodePadding(nodePad)
.extent([[1, 1], [svgW - 1, height - 1]]);
return layout({ nodes, links });
} catch {
return null;
}
}, [w, visibleNodes, visibleLinks, height, nodePad, rawNodes]);
// All hooks above — safe to early-return now.
if (rawNodes.length <= 1 || degenerate) return <MergedPathsFallback node={node} />;
if (graph === null) return <MergedPathsFallback node={node} />;
// Count root chains (leaves of the tree) and total retained.
const totalRetained = node.retained;
let chainCount = 0;
function countLeaves(n: MergedPathNode) {
if (n.children.length === 0) { chainCount++; return; }
for (const c of n.children) countLeaves(c);
}
countLeaves(node);
const maxDepth = Math.max(...graph.nodes.map((x) => (x as unknown as { depth?: number }).depth ?? 0));
const nodeColor = (depth: number) => {
if (depth === 0) return "var(--accent)";
if (depth >= maxDepth) return "#9ca3af";
return "#6b7280";
};
const svgW = Math.max(1, w - LABEL_COL);
return (
<>
<details open className="merged-path-sankey">
<summary>
Merged Retention Paths ({chainCount} chain{chainCount === 1 ? "" : "s"} · <span title={fmtExactBytes(totalRetained)}>{fmtB(totalRetained)}</span>)
{rawNodes.length > MAX_NODES && (
<span style={{ color: "var(--muted)", fontSize: "0.78rem", marginLeft: "0.4rem" }}>
(top {MAX_NODES} of {rawNodes.length} nodes)
</span>
)}
</summary>
<div ref={containerRef} style={{ width: "100%" }}>
<svg
width={w}
height={height}
role="img"
aria-label="Merged retention paths sankey"
style={{ display: "block", overflow: "visible" }}
>
{/* Links — drawn inside the sankey column (svgW wide) */}
{graph.links.map((link, i) => (
<path
key={`sl-${i}`}
className="sankey-link"
d={sankeyLinkHorizontal()(link) ?? undefined}
stroke="var(--muted)"
strokeWidth={Math.max(1, link.width ?? 1)}
strokeOpacity={0.35}
/>
))}
{/* Nodes + labels; labels extend into the right label column */}
{graph.nodes.map((n, i) => {
type SN = SNode & { x0?: number; x1?: number; y0?: number; y1?: number; depth?: number };
const sn = n as unknown as SN;
const x0 = sn.x0 ?? 0;
const x1 = sn.x1 ?? 0;
const y0 = sn.y0 ?? 0;
const y1 = sn.y1 ?? 0;
const nodeH = Math.max(2, y1 - y0);
const midY = y0 + nodeH / 2;
const depth = sn.depth ?? 0;
const shortName = sn.name.length > 32 ? sn.name.slice(0, 31) + "…" : sn.name;
return (
<g key={`sn-${i}`} className="sankey-node">
<rect x={x0} y={y0} width={Math.max(2, x1 - x0)} height={nodeH} fill={nodeColor(depth)}
style={{ cursor: "pointer" }}
onClick={() => pivotClass(sn.name)}
onMouseEnter={(e) => setHoverPopover({ x: e.clientX, y: e.clientY, name: sn.name, count: sn.count, retained: sn.retained })}
onMouseLeave={() => setHoverPopover(null)}
/>
{nodeH >= 8 && (
<text x={svgW + 4} y={midY} dy="0.35em" fontSize={10} fill="var(--fg)" textAnchor="start">
{shortName}
</text>
)}
</g>
);
})}
</svg>
</div>
</details>
{hoverPopover && (
<div style={{
position: "fixed",
left: Math.min(hoverPopover.x + 10, window.innerWidth - 260),
top: Math.min(hoverPopover.y + 10, window.innerHeight - 120),
zIndex: 99999,
background: "var(--card-bg, var(--bg))",
border: "1px solid var(--border)",
borderRadius: 8,
boxShadow: "0 4px 16px rgba(0,0,0,0.2)",
padding: "0.5rem 0.75rem",
minWidth: 200,
maxWidth: 300,
fontSize: "0.8rem",
pointerEvents: "none",
}}>
<div style={{ fontFamily: "var(--mono, monospace)", fontSize: "0.75rem", fontWeight: 600, wordBreak: "break-all", marginBottom: "0.3rem" }}>{hoverPopover.name}</div>
<div style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "0.1rem 0.5rem", color: "var(--fg)" }}>
<span style={{ color: "var(--muted)" }}>Objects:</span><span>{fmtCount(hoverPopover.count)}</span>
<span style={{ color: "var(--muted)" }}>Retained:</span><span title={fmtExactBytes(hoverPopover.retained)}>{fmtB(hoverPopover.retained)}</span>
</div>
<div style={{ marginTop: "0.3rem", fontSize: "0.72rem", color: "var(--muted)" }}>Click to view in navigator →</div>
</div>
)}
</>
);
}
function LeakChainGraph({ steps }: { steps: RootPathStep[] }) {
const uid = React.useId().replace(/:/g, "");
const markerId = `lc-arr-${uid}`;
if (!steps || steps.length < 2) return null;
const W = 320, H = 70, pad = 20;
const step = (W - 2 * pad) / Math.max(1, steps.length - 1);
const nodes = steps.map((s, i) => ({
x: pad + i * step,
y: H / 2,
label: s.display_class.split(".").pop()?.slice(0, 9) ?? "?",
fieldEdge: s.field_edge ?? "",
}));
return (
<svg width={W} height={H} className="leak-chain-svg" style={{ overflow: "visible" }}>
<defs>
<marker id={markerId} markerWidth="5" markerHeight="5" refX="4" refY="2.5" orient="auto">
<path d="M0,0 L0,5 L5,2.5 z" fill="var(--muted)" />
</marker>
</defs>
{nodes.slice(0, -1).map((n, i) => (
<line key={i} x1={n.x + 7} y1={n.y} x2={nodes[i + 1].x - 7} y2={nodes[i + 1].y}
stroke="var(--muted)" strokeWidth={1.5} markerEnd={`url(#${markerId})`} />
))}
{nodes.map((n, i) => (
<g key={i}>
<circle cx={n.x} cy={n.y} r={i === 0 ? 9 : 6}
fill={i === 0 ? "var(--accent)" : "var(--muted)"} opacity={0.75} />
<text x={n.x} y={n.y + 18} textAnchor="middle" fontSize={9} fill="var(--fg)">{n.label}</text>
{n.fieldEdge && i < nodes.length - 1 && (
<text x={n.x + step / 2} y={n.y - 8} textAnchor="middle" fontSize={8} fill="var(--muted)">.{n.fieldEdge}</text>
)}
</g>
))}
</svg>
);
}
function SuspectCard({ s, total, rank }: { s: Suspect; total: number; rank: number }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const share = pctOf(s.retained, total);
return (
<div className="suspect" id={`suspect-${rank}`}>
<h3 style={{ margin: "0 0 0.25rem" }}>
<span className="rank">Suspect #{rank}</span>{" "}
<span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle" }}>
<code style={{ cursor: "pointer" }} title="Click to view in Inspector" onClick={() => pivotClass(s.pretty_class)}>{s.pretty_class}</code>
<CopyBtn text={s.pretty_class} />
<PivotBtn cls={s.pretty_class} />
<OqlBtn cls={s.pretty_class} />
<ListObjectsBtn cls={s.pretty_class} />
</span>{" "}
<span className="pill">{s.is_single ? "Single Object" : `Class Group ×${fmtCount(s.instance_count)}`}</span>
</h3>
<p style={{ margin: "0.25rem 0" }}>
Retains <strong title={fmtExactBytes(s.retained)}>{fmtB(s.retained)}</strong>{" "}
<span className="mat-exact">
{fmtExactBytes(s.retained)} ({fmtPct(share)})
</span>
{s.shallow > 0 && <> · shallow <span title={fmtExactBytes(s.shallow)}>{fmtB(s.shallow)}</span></>}.
</p>
{s.root_path && s.root_path.length >= 2 && (
<LeakChainGraph steps={s.root_path} />
)}
<p style={{ margin: "0.25rem 0" }}>
<span className="label">Held by:</span>{" "}
{s.root_type_label ? (
<>
a <strong>{s.root_type_label}</strong> GC root
</>
) : (
<span style={{ color: "var(--muted)" }}>multiple roots — no single holder identified</span>
)}
</p>
{s.keywords.length > 0 && (
<p style={{ margin: "0.25rem 0" }}>
<span className="label">Keywords:</span>{" "}
{s.keywords.map((k, i) => (
<span key={i} className="pill keyword" title="Click to view in Inspector"
style={{ cursor: "pointer" }}
onClick={() => pivotClass(k)}>
{k}
</span>
))}
</p>
)}
{s.accumulation_class && (
<p style={{ margin: "0.25rem 0", color: "var(--muted)", fontSize: "0.86rem" }}>
Accumulation point:{" "}
<span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle" }}>
<code title={s.accumulation_class}>{s.accumulation_class}</code>
<CopyBtn text={s.accumulation_class} />
<PivotBtn cls={s.accumulation_class} />
<OqlBtn cls={s.accumulation_class} />
<ListObjectsBtn cls={s.accumulation_class} />
{s.accumulation_obj_1based != null &&
<ExploreBtn denseIdx={s.accumulation_obj_1based - 1} label={s.accumulation_class} />}
</span>
{s.accumulation_retained != null && <> retaining <span title={fmtExactBytes(s.accumulation_retained)}>{fmtB(s.accumulation_retained)}</span></>}.
</p>
)}
<DominatedByClass rows={s.dominated_by_class} suspectRetained={s.retained} />
<AccumulationPath s={s} />
{s.dominated.length > 0 && (() => {
const domCols: TableColumn<import("./types").DominatedRow>[] = [
{ id: "class", name: "Class", grow: 1, maxWidth: "600px", cell: (d) => <span className="copy-cell"><code title={d.display_class}>{d.display_class}</code><CopyBtn text={d.display_class} /><PivotBtn cls={d.display_class} /><OqlBtn cls={d.display_class} /><ListObjectsBtn cls={d.display_class} /><ExploreBtn denseIdx={d.obj_index_1based - 1} label={d.display_class} /></span>, selector: (d) => d.display_class, sortable: true },
{ id: "shallow", name: useKB ? "Shallow (KB)" : "Shallow", right: true, width: useKB ? "135px" : "110px", cell: byteCell(d => d.shallow, fmtB, useKB), selector: (d) => d.shallow, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "110px", cell: byteCell(d => d.retained, fmtB, useKB), selector: (d) => d.retained, sortable: true },
];
return (
<details>
<summary>
Directly Dominated Objects{" "}
{s.dominated_total_count > s.dominated_shown
? `(${fmtCount(s.dominated_total_count)} total, showing top ${fmtCount(s.dominated_shown)})`
: `(${fmtCount(s.dominated_total_count)} total)`}
</summary>
<StdTable columns={domCols} data={s.dominated} searchKeys={["display_class"]} fmtBtn={kbBtn} defaultSortFieldId="retained" defaultSortAsc={false} />
</details>
);
})()}
{s.root_path && s.root_path.length > 0 && <RootPathChain steps={s.root_path} />}
{s.dominator_tree && <DomSubtreeSvg node={s.dominator_tree} onNavigate={(idx) => {
(window as any).__explorerNavigate?.("explore", idx) ?? (window.location.hash = `explore/${idx}`);
}} />}
{!s.is_single && s.merged_paths && <MergedPathSankey node={s.merged_paths} />}
<div style={{ marginTop: "0.75rem", padding: "0.5rem 0.75rem", background: "var(--code-bg, #f6f7f8)", borderRadius: 4, fontSize: "0.84rem", lineHeight: "1.5" }}>
<strong>Next Steps</strong>
<ul style={{ margin: "0.25rem 0 0", paddingLeft: "1.2rem", listStyle: "disc" }}>
<li>Click <span title="Open in Inspector">⬡</span> (Inspector) next to the class name above to browse field values, inbound references, and the path to the GC root.</li>
{s.accumulation_class && (
<li>The accumulation point is <code title={s.accumulation_class}>{s.accumulation_class}</code> — inspect it to find which field retains these objects.</li>
)}
{!s.is_single && s.instance_count > 10 && (
<li>{fmtCount(s.instance_count)} instance{s.instance_count === 1 ? "" : "s"} {s.instance_count === 1 ? "suggests" : "suggest"} a pool, registry, or cache that accumulates without bound — check for a static field never cleared or a listener list missing deregistration.</li>
)}
{s.root_type_label === "Java Frame" && (
<li>Held via a <strong>thread stack frame</strong> — check the Threads section for a blocked or long-running thread; it retains these objects until the frame returns.</li>
)}
{s.root_type_label === "JNI Global" && (
<li>Held by a <strong>JNI global reference</strong> — native code pins these objects; check JNI code for missing <code>DeleteGlobalRef</code> calls.</li>
)}
{!s.root_type_label && (
<li>No single GC root holds all instances — retention spans multiple roots. Filter the Dominator Graph (<em>Dominator Analysis → Graph</em>) to this class to trace which root retains each instance.</li>
)}
</ul>
</div>
</div>
);
}
function LeakSuspectsSection({ report }: { report: Report }) {
const l = report.leaks;
return (
<section id="leak-suspects">
<h2>Leak Suspects</h2>
<p className="subtitle">Objects and class groups retaining the most heap, ranked by retained size — the most likely accumulation points for excessive memory usage. To fix: follow the dominator chain to the nearest object you control and drop or null out the reference that keeps it alive. GC root paths are shown for each suspect. Class-name icons: <span title="Copy class name">⎘</span> copy name · <span title="Open in Inspector">⬡</span> Inspector · <span title="Copy OQL query">⌗</span> OQL query · <span title="List all instances in Object Graph Explorer">⬡≡</span> list instances</p>
{l.suspects.length === 0 ? (
<p className="subtitle">No single class dominates heap retention — heap spans many roots. Explore the largest classes in <a href="#top-consumers" onClick={(e) => { e.preventDefault(); document.getElementById("top-consumers")?.scrollIntoView({ behavior: "smooth" }); }}>Top Consumers</a> or trace retention chains in <a href="#dominator-analysis" onClick={(e) => { e.preventDefault(); document.getElementById("dominator-analysis")?.scrollIntoView({ behavior: "smooth" }); }}>Dominator Analysis</a>.</p>
) : (
<>
<h3>Retained-Heap Share</h3>
<p className="subtitle">
Retention concentration: each slice is one suspect's retained heap; the remainder is everything else.
</p>
<ChartOrNote hasData={l.suspects.length > 0 && l.total_shallow > 0} note="No leak suspects to chart.">
<LeakShareChart suspects={l.suspects} total={l.total_shallow} onSlice={(i) => {
if (i < l.suspects.length)
document.getElementById(`suspect-${i + 1}`)?.scrollIntoView({ behavior: "smooth", block: "center" });
}} />
</ChartOrNote>
{l.suspects.map((s, i) => (
<SuspectCard key={i} s={s} total={l.total_shallow} rank={i + 1} />
))}
</>
)}
</section>
);
}
// ── Top Consumers ───────────────────────────────────────────────────────────
// A recursive, expandable package tree (MAT PackageTreeResult drill-down). Each
// node shows cumulative # objects / shallow / retained over its subtree.
function PackageTreeRow({ node, depth, maxRetained, rowId, fmtB }: { node: PackageNode; depth: number; maxRetained: number; rowId?: string; fmtB: (n: number) => string }) {
const [open, setOpen] = React.useState(depth < 1);
const hasChildren = node.children.length > 0;
const label = node.name || "(default package)";
const pct = maxRetained > 0 ? (node.retained_heap / maxRetained) * 100 : 0;
return (
<>
<tr id={rowId}>
<td>
<span style={{ paddingLeft: `${depth * 1.1}rem` }}>
{hasChildren ? (
<button className="tree-toggle" onClick={() => setOpen(!open)} aria-expanded={open}>
{open ? "▾" : "▸"}
</button>
) : (
<span className="tree-leaf">•</span>
)}
<code>{label}</code>
</span>
</td>
<td className="num">{fmtCount(node.top_dominator_count)}</td>
<td className="num"><span title={fmtExactBytes(node.shallow_heap)}>{fmtB(node.shallow_heap)}</span></td>
<td className="num bar-cell">
<span className="bar-bg">
<span className="bar-fill" style={{ width: `${pct}%` }} />
</span>
<span title={fmtExactBytes(node.retained_heap)}>{fmtB(node.retained_heap)}</span>
</td>
</tr>
{open &&
node.children.map((c, i) => (
<PackageTreeRow key={i} node={c} depth={depth + 1} maxRetained={maxRetained} fmtB={fmtB} />
))}
</>
);
}
function PkgTreeTable({ nodes, maxRetained }: { nodes: PackageNode[]; maxRetained: number }) {
const [fmtB, kbBtn] = useFmtBytes();
const [filter, setFilter] = React.useState("");
const lc = filter.toLowerCase();
const filtered = lc ? nodes.filter((n) => (n.name || "").toLowerCase().includes(lc)) : nodes;
const { visible, extra, showAll, setShowAll } = useCapped(filtered);
return (
<div style={{ marginTop: "0.75rem" }}>
<div className="table-toolbar">
<input
className="filter-input"
placeholder="Filter packages…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{kbBtn}
</div>
<table className="tree-table">
<thead>
<tr>
<th>Package</th>
<th className="num"># Objects</th>
<th className="num">Shallow</th>
<th className="num">Retained</th>
</tr>
</thead>
<tbody>
{visible.map((p, i) => (
<PackageTreeRow key={p.name || i} node={p} depth={0} maxRetained={maxRetained} rowId={`pkg-${i}`} fmtB={fmtB} />
))}
</tbody>
{extra > 0 && (
<tfoot>
<ShowMoreRow extra={extra} cols={4} showAll={showAll} setShowAll={setShowAll} />
</tfoot>
)}
</table>
</div>
);
}
function TopConsumersTreemap({ rows }: { rows: Array<{ pretty_class: string; retained: number }> }) {
const ref = React.useRef<HTMLDivElement>(null);
const [w, setW] = React.useState(680);
React.useLayoutEffect(() => {
if (!ref.current) return;
const ro = new ResizeObserver(entries => {
const bw = entries[0]?.contentRect.width;
if (bw && bw > 0) setW(Math.floor(bw));
});
ro.observe(ref.current);
return () => ro.disconnect();
}, []);
const H = 400;
const data = { name: "root", children: rows.slice(0, 60).map(r => ({ name: r.pretty_class, value: r.retained })) };
const root = hierarchy(data).sum((d: any) => d.value ?? 0).sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
treemap<any>().size([w, H]).tile(treemapSquarify).padding(2)(root);
return (
<div ref={ref} style={{ width: "100%" }}>
<svg width={w} height={H} style={{ display: "block", border: "1px solid var(--border)", borderRadius: 4 }}>
{root.leaves().map((leaf: any, i: number) => {
const lw = leaf.x1 - leaf.x0, lh = leaf.y1 - leaf.y0;
return (
<g key={i} style={{ cursor: "pointer" }}
onClick={() => fireInspect({ kind: "class", cls: leaf.data.name })}>
<title>{leaf.data.name}{"\n"}{fmtExactBytes(leaf.data.value)} retained</title>
<rect x={leaf.x0} y={leaf.y0} width={lw} height={lh}
fill={tpfgColor(leaf.data.name)} opacity={0.82} rx={2} />
{lw > 36 && lh > 18 && (
<text x={leaf.x0 + lw / 2} y={leaf.y0 + lh / 2}
textAnchor="middle" dominantBaseline="middle"
fontSize={Math.min(12, lw / 7)} fill="var(--bg, #fff)"
style={{ pointerEvents: "none" }}>
{leaf.data.name.split(".").pop()?.slice(0, Math.floor(lw / 7))}
</text>
)}
</g>
);
})}
</svg>
<p style={{ fontSize: "0.8rem", color: "var(--muted)", margin: "4px 0 0" }}>
Top {Math.min(60, rows.length)} classes by retained heap. Click to inspect.
</p>
</div>
);
}
function TopConsumersSection({ report }: { report: Report }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const [fmtBcls, kbBtnCls, useKBcls] = useFmtBytes();
const t = report.top;
const total = report.leaks.total_shallow;
const pkgRoot = t.biggest_packages;
const maxPkgRetained = pkgRoot.children.reduce((m, c) => Math.max(m, c.retained_heap), 0);
const objHasOwner = t.biggest_objects.some((o) => !!o.owner || !!o.held_via);
const hasObjGraph = !!report.obj_graph_flat;
const objTableCols: TableColumn<ObjRow>[] = [
{ id: "rank", name: "#", right: true, width: "36px", cell: (_r, i) => (i ?? 0) + 1 },
{ id: "class", name: "Class", grow: 1, maxWidth: objHasOwner ? "317px" : "600px", cell: (o) => {
const denseIdx = o.obj_index_1based - 1;
const inGraph = hasObjGraph && report.obj_graph_flat!.nodes[String(denseIdx)] != null;
return (
<span className="copy-cell">
<code title={o.display_class}>{o.display_class}</code>
<span style={{ color: "var(--muted)", fontSize: "0.75rem", flexShrink: 0 }}>#{denseIdx}</span>
<CopyBtn text={o.display_class} />
<PivotBtn cls={o.display_class} />
<OqlBtn cls={o.display_class} />
<ListObjectsBtn cls={o.display_class} />
{inGraph && (
<button className="copy-btn" title="Explore outbound references in Object Graph"
onClick={() => { (window as any).__explorerNavigate?.("explore", denseIdx) ?? (window.location.hash = `explore/${denseIdx}`); }}>
→
</button>
)}
{inGraph && (
<button className="copy-btn" title="Open dominator tree in Object Graph"
onClick={() => { (window as any).__explorerNavigate?.("domtree", denseIdx) ?? (window.location.hash = `domtree/${denseIdx}`); }}>
⌞
</button>
)}
</span>
);
}},
{ id: "shallow", name: useKB ? "Shallow (KB)" : "Shallow", right: true, width: useKB ? "135px" : "110px", cell: byteCell(o => o.shallow, fmtB, useKB), selector: (o) => o.shallow, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "110px", cell: (o) => <span title={fmtExactBytes(o.retained)}>{fmtB(o.retained)}</span>, selector: (o) => o.retained, sortable: true },
{ id: "pct", name: "% Heap", right: true, width: "100px", format: (o) => fmtPct(pctOf(o.retained, total)), selector: (o) => o.pct_bp, sortable: true },
...(objHasOwner ? [{ id: "held_via", name: "Held via (Class#field)", grow: 1, minWidth: "160px", maxWidth: "317px", cell: (o: ObjRow) => {
const text = o.owner ?? o.held_via ?? null;
const cls = text ? text.split("#")[0] : null;
return text ? (
<span className="copy-cell">
<ExpandableText text={text} label="Held Via" />
<CopyBtn text={text} />
{cls && <PivotBtn cls={cls} />}
{cls && <OqlBtn cls={cls} />}
{cls && <ListObjectsBtn cls={cls} />}
{o.held_via && !o.owner && <span className="muted"> (stack)</span>}
</span>
) : <span>—</span>;
} } as TableColumn<ObjRow>] : []),
];
const maxClsRetained = React.useMemo(
() => t.biggest_classes.reduce((m, c) => Math.max(m, c.retained), 0),
[t.biggest_classes],
);
// Median retained-per-instance — used to flag outlier classes with ⚠
const medianBpi = React.useMemo(() => {
const bpis = t.biggest_classes
.filter(c => c.instances > 0)
.map(c => c.retained / c.instances)
.sort((a, b) => a - b);
return bpis[Math.floor(bpis.length / 2)] ?? 1;
}, [t.biggest_classes]);
const clsTableCols: TableColumn<ClassRow>[] = [
{ id: "class", name: "Class", grow: 1, maxWidth: "500px", cell: (c) => <span className="copy-cell"><code title={c.pretty_class}>{c.pretty_class}</code><CopyBtn text={c.pretty_class} /><PivotBtn cls={c.pretty_class} /><OqlBtn cls={c.pretty_class} /><ListObjectsBtn cls={c.pretty_class} /></span>, selector: (c) => c.pretty_class, sortable: true },
{ id: "instances", name: "Instances", right: true, width: "120px", format: (c) => fmtCount(c.instances), selector: (c) => c.instances, sortable: true },
{ id: "bar", name: "", width: "80px", cell: (c) => <span className="bar-bg"><span className="bar-fill" style={{ width: `${maxClsRetained > 0 ? (c.retained / maxClsRetained) * 100 : 0}%` }} /></span> },
{ id: "retained", name: useKBcls ? "Retained (KB)" : "Retained", right: true, width: useKBcls ? "135px" : "110px", cell: (c) => <span title={fmtExactBytes(c.retained)}>{fmtBcls(c.retained)}</span>, selector: (c) => c.retained, sortable: true },
{ id: "pct", name: "% Heap", right: true, width: "100px", format: (c) => fmtPct(pctOf(c.retained, total)), selector: (c) => c.retained, sortable: true },
{ id: "bpi", name: "B / Inst", right: true, width: "90px", sortable: true, selector: c => c.instances > 0 ? c.retained / c.instances : 0,
cell: c => {
if (c.instances === 0) return <span style={{ color: "var(--muted)" }}>—</span>;
const bpi = c.retained / c.instances;
const isHigh = bpi > medianBpi * 10;
return (
<span title={`${fmtBcls(Math.round(bpi))} retained per instance (median: ${fmtBcls(Math.round(medianBpi))})`}
style={isHigh ? { color: "#c87533", fontWeight: 600 } : {}}>
{fmtBcls(Math.round(bpi))}{isHigh ? " ⚠" : ""}
</span>
);
}
},
];
const [topView, setTopView] = React.useState<"tables" | "treemap">("tables");
return (
<section id="top-consumers">
<h2>Top Consumers</h2>
<p className="subtitle">Biggest objects, classes, and packages by retained heap. Unlike Leak Suspects, these tables are unfiltered — use them when a suspect didn't cross the leak threshold, or to see the full retention picture.</p>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
{(["tables", "treemap"] as const).map(v => (
<button key={v} onClick={() => setTopView(v)} style={{
padding: "0.25rem 0.85rem", fontSize: "0.88rem",
border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer",
background: topView === v ? "var(--accent)" : "transparent",
color: topView === v ? "#fff" : "var(--fg)",
}}>{v === "tables" ? "⊞ Tables" : "▦ Treemap"}</button>
))}
</div>
{topView === "treemap" && (
<TopConsumersTreemap rows={t.biggest_classes} />
)}
{topView === "tables" && (<>
<h3>Biggest Objects (Top-Level Dominators)</h3>
<p className="subtitle">All top-level dominators ranked by retained heap — every object directly held by a GC root, sorted largest first. Click a row to jump to it in the Object Graph Explorer.{objHasOwner && <> <strong>Held via</strong> — the <code>Class#field</code> reference most directly retaining each object; objects can have multiple referrers.</>}</p>
<StdTable columns={objTableCols} data={t.biggest_objects} searchKeys={["display_class"]} fmtBtn={kbBtn} defaultSortFieldId="retained" />
<h3>Biggest Classes by Retained Heap</h3>
<p className="subtitle">Classes ranked by total retained heap. High retained with low shallow means the class is keeping many other objects alive — investigate it in Dominator Analysis.</p>
<StdTable columns={clsTableCols} data={t.biggest_classes} searchKeys={["pretty_class"]} fmtBtn={kbBtnCls} defaultSortFieldId="retained"
extraBtns={<CopyTsvBtn rows={[["Class","Instances","Retained (bytes)","% Heap"],...t.biggest_classes.map(c=>[ c.pretty_class, String(c.instances), String(c.retained), fmtPct(pctOf(c.retained,total)) ])]} label="Copy as TSV" />}
/>
{pkgRoot.children.length > 0 && (
<>
<h3>Biggest Packages by Retained Heap</h3>
<p className="subtitle">
Expand a package to see its sub-packages. Totals roll up through the subtree. Only classes retaining ≥{fmtPct(t.threshold_bp / 100)} of the heap are shown.
</p>
<ZoomableTreemap
root={pkgRoot}
getChildren={(n) => n.children}
getValue={(n) => n.retained_heap}
getLabel={(n) => n.name || "(default)"}
fmt={formatBytes}
fmtExact={fmtExactBytes}
height={320}
extraLeaves={(_node, pathLabels) => {
const pkgPrefix = pathLabels.join(".");
const dotPkg = pkgPrefix ? pkgPrefix + "." : "";
return report.overview.histogram
.filter((r) => {
if (!dotPkg) return false; // skip at root — too many classes
if (!r.pretty_class.startsWith(dotPkg)) return false;
return !r.pretty_class.slice(dotPkg.length).includes(".");
})
.sort((a, b) => b.retained - a.retained)
.slice(0, 40) // cap to avoid overwhelming the layout
.map((r) => ({ label: r.pretty_class.slice(dotPkg.length), value: r.retained }));
}}
renderLeaf={(_node, pathLabels) => {
const pkgPrefix = pathLabels.join(".");
const dotPkg = pkgPrefix ? pkgPrefix + "." : "";
const classes = report.overview.histogram.filter((r) => {
if (!dotPkg) return true;
if (!r.pretty_class.startsWith(dotPkg)) return false;
return !r.pretty_class.slice(dotPkg.length).includes(".");
}).sort((a, b) => b.retained - a.retained);
if (classes.length === 0) return <p className="subtitle" style={{ marginTop: "0.5rem" }}>No classes found in package <code>{pkgPrefix}</code>.</p>;
type LeafRow = { short: string; pretty_class: string; instances: number; retained: number };
const leafRows: LeafRow[] = classes.map(r => ({ ...r, short: r.pretty_class.slice(dotPkg.length) }));
const leafCols: TableColumn<LeafRow>[] = [
{ id: "class", name: "Class", grow: 1, maxWidth: "600px", cell: r => <span className="copy-cell"><code title={r.pretty_class}>{r.short}</code><CopyBtn text={r.pretty_class} /><PivotBtn cls={r.pretty_class} /><OqlBtn cls={r.pretty_class} /><ListObjectsBtn cls={r.pretty_class} /></span>, selector: r => r.short, sortable: true },
{ id: "instances", name: "Instances", right: true, width: "110px", format: r => fmtCount(r.instances), selector: r => r.instances, sortable: true },
{ id: "retained", name: "Retained", right: true, width: "110px", cell: r => <span title={fmtExactBytes(r.retained)}>{formatBytes(r.retained)}</span>, selector: r => r.retained, sortable: true },
];
return (
<div style={{ marginTop: "0.75rem" }}>
<h3 style={{ margin: "0 0 0.4rem" }}>Classes in {pkgPrefix ? <code>{pkgPrefix}</code> : "(default package)"}</h3>
<StdTable columns={leafCols} data={leafRows} searchKeys={["short"]} defaultSortFieldId="retained" />
</div>
);
}}
/>
<PkgTreeTable nodes={pkgRoot.children} maxRetained={maxPkgRetained} />
</>
)}
</>)}
</section>
);
}
// ── Threads ─────────────────────────────────────────────────────────────────
// One collapsible block per thread; frames rendered verbatim in a monospace
// <pre>. A filter box keeps large thread sets (hundreds) navigable. Preserves
// the upstream (thread_serial-sorted) order for determinism.
// a small table of a thread's GC-thread-local root
// objects. Renders nothing for an empty list. Mirrors report.rs::render_thread_locals.
function ThreadLocalsTable({ objs, totalCount }: { objs: ThreadLocalObj[]; totalCount: number }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
if (objs.length === 0) return null;
const cols: TableColumn<ThreadLocalObj>[] = [
{ id: "obj", name: "Object", grow: 1, cell: (o) => <span className="copy-cell"><code title={o.display_class}>{o.display_class}</code><CopyBtn text={o.display_class} /><PivotBtn cls={o.display_class} /><OqlBtn cls={o.display_class} /><ListObjectsBtn cls={o.display_class} /><ExploreBtn denseIdx={o.obj_index_1based - 1} label={o.display_class} /></span>, selector: (o) => o.display_class, sortable: true },
{ id: "shallow", name: useKB ? "Shallow (KB)" : "Shallow", right: true, width: useKB ? "135px" : "110px", cell: byteCell(o => o.shallow, fmtB, useKB), selector: (o) => o.shallow, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "110px", cell: byteCell(o => o.retained, fmtB, useKB), selector: (o) => o.retained, sortable: true },
];
return (
<div className="thread-locals-inline">
<p className="thread-locals-label">Local Root Objects ({objs.length < totalCount
? `showing top ${fmtCount(objs.length)} of ${fmtCount(totalCount)}; retained sizes overlap, so totals may exceed thread retained`
: fmtCount(objs.length)})</p>
<StdTable columns={cols} data={objs} searchKeys={["display_class"]} fmtBtn={kbBtn} defaultSortFieldId="retained" defaultSortAsc={false} />
</div>
);
}
function threadStateLabel(raw: string): string {
// "alive, waiting, waiting indefinitely, parked" → "waiting"
const parts = raw.replace(/[\[\]]/g, "").split(",").map((s) => s.trim()).filter(Boolean);
const nonAlive = parts.filter((p) => p !== "alive" && p !== "runnable");
const label = nonAlive[nonAlive.length - 1] ?? parts[0] ?? raw;
return label.replace(/\b\w/g, (c) => c.toUpperCase());
}
function fmtLoader(raw: string): string {
// "org/foo/Bar @ 0xdeadbeef" → "org.foo.Bar"
return raw.replace(/\s*@\s*0x[0-9a-fA-F]+$/, "").replaceAll("/", ".");
}
function ThreadCard({ t, open }: { t: ThreadInfo; open?: boolean }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const name = t.name?.trim();
const cls = (t.class_name ?? "<unresolved>").replaceAll("/", ".");
const sig = t.significant_frames ?? [];
const stateLabel = t.thread_state ? threadStateLabel(t.thread_state) : null;
return (
<details className="thread" open={open} id={`thread-${t.thread_serial}`}>
<summary>
<span className="thread-name">{name ? `"${name}"` : `Thread ${t.thread_serial}`}</span>
{name && <span className="thread-serial"> · Thread {t.thread_serial}</span>}
{" "}<span className="thread-meta-inline">
<span title={fmtExactBytes(t.retained)}>{fmtB(t.retained)}</span> retained
{stateLabel && <span className="thread-state-badge">{stateLabel}</span>}
{t.is_daemon && <span className="thread-daemon-badge">Daemon</span>}
</span>
</summary>
<div className="thread-body">
<div className="thread-meta-row">
<span className="thread-meta-item"><span className="thread-meta-label">class</span><code title={cls}>{cls}</code><CopyBtn text={cls} /><PivotBtn cls={cls} /><OqlBtn cls={cls} /><ListObjectsBtn cls={cls} /></span>
<span className="thread-meta-item"><span className="thread-meta-label">shallow</span><span title={fmtExactBytes(t.shallow)}>{fmtB(t.shallow)}</span></span>
<span className="thread-meta-item"><span className="thread-meta-label">retained</span><span title={fmtExactBytes(t.retained)}>{fmtB(t.retained)}</span></span>
<span className="thread-meta-item"><span className="thread-meta-label">max local retained</span><span title={fmtExactBytes(t.max_local_retained)}>{fmtB(t.max_local_retained)}</span></span>
<span className="thread-meta-item"><span className="thread-meta-label">priority</span>{t.priority}</span>
{t.context_class_loader && (
<span className="thread-meta-item"><span className="thread-meta-label">loader</span><code title={fmtLoader(t.context_class_loader)}>{fmtLoader(t.context_class_loader)}</code></span>
)}
{t.thread_state && (
<span className="thread-meta-item"><span className="thread-meta-label">state</span>{t.thread_state.replace(/[\[\]]/g, "").split(",").map(s => s.trim()).filter(Boolean).map(s => s.replace(/\b\w/g, c => c.toUpperCase())).join(", ")}</span>
)}
</div>
{t.local_objects && <ThreadLocalsTable objs={t.local_objects} totalCount={t.local_root_count} />}
{sig.length > 0 ? (
<>
<p className="subtitle"><em>%: each frame's share of this thread's <span title={fmtExactBytes(t.retained)}>{fmtB(t.retained)}</span> retained heap.</em></p>
<ul className="sig-frames">
{sig.map((sf, i) => {
const frameCls = frameToClass(sf.frame);
return (
<li key={i}>
<span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle" }}><code title={sf.frame}>{sf.frame}</code><CopyBtn text={sf.frame} />{frameCls && <><PivotBtn cls={frameCls} /><OqlBtn cls={frameCls} /><ListObjectsBtn cls={frameCls} /></>}</span>
{sf.locals.length > 0 && (
<ul>
{sf.locals.map((loc, j) => (
<li key={j}>
<span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle" }}><code title={loc.display_class}>{loc.display_class}</code><CopyBtn text={loc.display_class} /><PivotBtn cls={loc.display_class} /><OqlBtn cls={loc.display_class} /><ListObjectsBtn cls={loc.display_class} /></span>{" "}
<span className="path-ret">retains <span title={fmtExactBytes(loc.retained)}>{fmtB(loc.retained)}</span> ({fmtPct(loc.pct)} of thread retained)</span>
</li>
))}
</ul>
)}
</li>
); })}
</ul>
</>
) : (
<pre className="stack">{t.frames.join("\n")}</pre>
)}
</div>
</details>
);
}
// ── Threads by Retained Heap table ────────────────────────────────────────────
function ThreadsByRetainedTable({ threads }: { threads: ThreadInfo[] }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
if (threads.length === 0) return null;
const sorted = React.useMemo(
() => [...threads].sort((a, b) => b.retained - a.retained),
[threads],
);
const cols: TableColumn<ThreadInfo>[] = [
{
id: "name",
name: "Thread Name",
grow: 1,
minWidth: "120px",
maxWidth: "360px",
cell: (t) => <a href={`#thread-${t.thread_serial}`}><code>{t.name?.trim() || "(unnamed)"}</code></a>,
selector: (t) => t.name ?? "",
sortable: true,
},
{
id: "state",
name: "State",
width: "145px",
selector: (t) => t.thread_state ?? "",
cell: (t) => <span title={t.thread_state?.replace(/[\[\]]/g, "") || undefined} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "block" }}>{t.thread_state ? threadStateLabel(t.thread_state) : "—"}</span>,
sortable: true,
},
{
id: "retained",
name: useKB ? "Retained (KB)" : "Retained",
right: true,
width: useKB ? "142px" : "118px",
sortable: true,
cell: byteCell((t) => t.retained, fmtB, useKB),
selector: (t) => t.retained,
},
{
id: "max_local_retained",
name: useKB ? "Max Local Retained (KB)" : "Max Local Retained",
right: true,
width: useKB ? "210px" : "175px",
sortable: true,
cell: byteCell((t) => t.max_local_retained, fmtB, useKB),
selector: (t) => t.max_local_retained,
},
{
id: "stack_depth",
name: "Stack Depth",
right: true,
width: "128px",
selector: (t) => t.frames?.length ?? 0,
format: (t) => String(t.frames?.length ?? 0),
sortable: true,
},
];
return (
<>
<h3>Threads by Retained Heap</h3>
<p className="subtitle">Sorted by retained heap — threads high on this list keep significant memory alive through local variables on their call stack. Click a name to jump to its stack trace.</p>
<StdTable columns={cols} data={sorted} searchKeys={["name"]} fmtBtn={kbBtn} defaultSortFieldId="retained" />
</>
);
}
// ── Thread Overview table (always-on properties, mirrors MAT columns) ──────────
function ThreadOverviewTable({ threads }: { threads: ThreadInfo[] }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
if (threads.length === 0) return null;
const cols: TableColumn<ThreadInfo>[] = [
{ id: "name", name: "Name", grow: 1, maxWidth: "127px", cell: (t) => <a href={`#thread-${t.thread_serial}`}>{t.name?.trim() || `<thread ${t.thread_serial}>`}</a>, selector: (t) => t.name ?? "", sortable: true },
{ id: "shallow", name: useKB ? "Shallow (KB)" : "Shallow", right: true, width: useKB ? "135px" : "110px", cell: byteCell(t => t.shallow, fmtB, useKB), selector: (t) => t.shallow, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "118px", cell: byteCell(t => t.retained, fmtB, useKB), selector: (t) => t.retained, sortable: true },
{ id: "max_local", name: useKB ? "Max Local Retained (KB)" : "Max Local Retained", right: true, width: useKB ? "225px" : "190px", cell: byteCell(t => t.max_local_retained, fmtB, useKB), selector: (t) => t.max_local_retained, sortable: true },
{ id: "loader", name: "Context Class Loader", grow: 1, maxWidth: "155px", cell: (t) => t.context_class_loader ? <code title={fmtLoader(t.context_class_loader)}>{fmtLoader(t.context_class_loader)}</code> : <span>—</span>, selector: (t) => t.context_class_loader ?? "", sortable: true },
{ id: "daemon", name: "Daemon", width: "100px", selector: (t) => t.is_daemon ? 1 : 0, format: (t) => t.is_daemon ? "Yes" : "No", sortable: true },
{ id: "priority", name: "Priority", right: true, width: "95px", format: (t) => String(t.priority), selector: (t) => t.priority, sortable: true },
{ id: "state", name: "State", width: "145px", selector: (t) => t.thread_state ?? "", cell: (t) => <span title={t.thread_state?.replace(/[\[\]]/g, "") || undefined} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "block" }}>{t.thread_state ? threadStateLabel(t.thread_state) : "—"}</span>, sortable: true },
];
return (
<details className="thread-overview-detail">
<summary>Thread Overview ({fmtCount(threads.length)})</summary>
<StdTable columns={cols} data={threads} searchKeys={["name"]} fmtBtn={kbBtn} defaultSortFieldId="retained" defaultSortAsc={false} />
</details>
);
}
// ── ThreadLocal Leak Analyzer ─────────────────────────────────────────────────
function ThreadLocalAnalysisTable({ rows }: { rows: ThreadLocalLeakRow[] }) {
if (!rows.length) return null;
const [fmtB, kbBtn, kb] = useFmtBytes();
return (
<div style={{ marginTop: "1rem" }}>
<h3>ThreadLocal Variables</h3>
<p className="subtitle">Values stored in thread-local slots, grouped by value class. Stale entries have a null key — the <code>ThreadLocal</code> object was GC'd but the value remains. In pooled threads (Tomcat, Netty) the thread rarely terminates, so stale values accumulate; call <code>ThreadLocal.remove()</code> to clean up.</p>
<StdTable
columns={[
{ id: "vc", name: "Value Class", grow: 1, maxWidth: "600px", cell: (r) => <span className="copy-cell"><code title={r.value_class}>{r.value_class}</code><CopyBtn text={r.value_class} /><PivotBtn cls={r.value_class} /><OqlBtn cls={r.value_class} /><ListObjectsBtn cls={r.value_class} /></span>, selector: (r) => r.value_class, sortable: true },
{ id: "cnt", name: "Entries", right: true, width: "90px", format: (r) => fmtCount(r.entry_count), selector: (r) => r.entry_count, sortable: true },
{ id: "stl", name: "Stale", right: true, width: "100px",
cell: (r) => r.stale_count > 0 ? <span style={{color:"var(--warning,#e67e22)"}}>{"⚠"} {fmtCount(r.stale_count)}</span> : <span>0</span>,
selector: (r) => r.stale_count, sortable: true },
{ id: "ret", name: "Retained", right: true, width: "120px", cell: byteCell((r) => r.retained, fmtB, kb), selector: (r) => r.retained, sortable: true },
]}
data={rows}
searchKeys={["value_class"]}
fmtBtn={kbBtn}
defaultSortFieldId="ret"
defaultSortAsc={false}
/>
</div>
);
}
// ── Framework Auto-Analysis ───────────────────────────────────────────────────
function FrameworkAnalysisSection({ items }: { items?: FrameworkAnalysis[] }) {
if (!items || items.length === 0) return null;
const detected = items.map(i => i.framework).join(' · ');
return (
<section className="section" id="framework-analysis">
<h2>Framework Analysis</h2>
<p className="subtitle">Frameworks detected: {detected}. Framework-specific objects and their heap footprint — useful for spotting oversized caches or leaked request contexts.</p>
<div className="framework-cards">
{items.map(item => (
<div key={item.framework} className="framework-card">
<div className="framework-card-name">{item.framework}</div>
<div className="framework-card-stats">
<span>{item.instance_count.toLocaleString()} instances</span>
<span className="muted"> · </span>
<span><span title={fmtExactBytes(item.total_retained)}>{formatBytes(item.total_retained)}</span> retained</span>
</div>
</div>
))}
</div>
</section>
);
}
function ThreadsSection({ report }: { report: Report }) {
const CAP = 100;
const threads = report.threads?.threads ?? [];
const [filter, setFilter] = React.useState("");
const [showAll, setShowAll] = React.useState(false);
const [openAll, setOpenAll] = React.useState<boolean | undefined>(undefined);
const [genKey, setGenKey] = React.useState(0);
const view = React.useMemo(() => {
const needle = filter.trim().toLowerCase();
if (!needle) return threads;
return threads.filter(
(t) =>
(t.name ?? "").toLowerCase().includes(needle) ||
(t.class_name ?? "").toLowerCase().includes(needle) ||
String(t.thread_serial).includes(needle) ||
t.frames.some((f) => f.toLowerCase().includes(needle)),
);
}, [threads, filter]);
const isFiltering = filter.trim().length > 0;
const visible = React.useMemo(() => {
if (isFiltering || showAll) return view;
const base = view.slice(0, CAP);
// Always include threads linked from the "by retained" table (retained > 0 threads
// may fall beyond CAP in the default stack-depth order).
const baseSerials = new Set(base.map(t => t.thread_serial));
const extra = view.filter(t => t.retained > 0 && !baseSerials.has(t.thread_serial));
return extra.length > 0 ? [...base, ...extra] : base;
}, [view, isFiltering, showAll]);
return (
<section id="threads">
<h2>Threads</h2>
<p className="subtitle">Per-thread call stacks and retained heap. A thread keeps everything on its stack alive — blocked or long-running threads can hold significant memory through local variables.</p>
{threads.length === 0 ? (
<p className="subtitle">No thread call stacks were recorded in this dump.</p>
) : (
<>
<ThreadsByRetainedTable threads={threads} />
<ThreadOverviewTable threads={threads} />
<div className="tools">
<input
type="text"
className="filter"
placeholder="Filter threads (name, class, serial, or stack frame)…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
aria-label="Filter threads"
/>
<span className="hint">
{fmtCount(view.length)} of {fmtCount(threads.length)} thread{threads.length === 1 ? "" : "s"}
</span>
<button
className="theme-toggle"
onClick={() => { setOpenAll(true); setGenKey((k) => k + 1); }}
>
Expand All
</button>
<button
className="theme-toggle"
onClick={() => { setOpenAll(false); setGenKey((k) => k + 1); }}
>
Collapse All
</button>
</div>
{visible.map((t, i) => (
<ThreadCard key={`${genKey}-${i}`} t={t} open={openAll} />
))}
{!isFiltering && !showAll && visible.length < view.length && (
<button
className="theme-toggle"
style={{ marginTop: "0.5rem" }}
onClick={() => setShowAll(true)}
>
Show {fmtCount(view.length - visible.length)} more threads
</button>
)}
</>
)}
{(report.thread_local_analysis?.length ?? 0) > 0 && (
<ThreadLocalAnalysisTable rows={report.thread_local_analysis!} />
)}
</section>
);
}
// ── Top Components ─────────────────────────────────────────────────────────────
// Retained heap grouped by class loader (component), mirroring Eclipse MAT's
// Top Components view. Mirrors render_md.rs::render_top_components.
type ComponentKey = "retained" | "pct";
const COMPONENT_COLS: { key: ComponentKey; label: string }[] = [
{ key: "retained", label: "Retained" },
{ key: "pct", label: "% Heap" },
];
function TopComponentsSection({ data }: { data: TopComponents }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const components = data?.components ?? [];
if (components.length === 0) return null;
const cols: TableColumn<Component>[] = [
{ id: "component", name: "Component", grow: 1, minWidth: "200px", maxWidth: "280px", cell: (c) => <code title={c.loader_label ? fmtLoader(c.loader_label) : undefined} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "block" }}>{fmtLoader(c.loader_label ?? "")}</code>, selector: (c) => c.loader_label ?? "", sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: "140px", sortable: true, cell: byteCell(c => c.retained, fmtB, useKB), selector: (c) => c.retained },
{ id: "pct", name: "% Heap", right: true, width: "100px", sortable: true, format: (c) => fmtPct(c.pct), selector: (c) => c.pct },
{
id: "top_classes", name: "Top Classes", grow: 2, maxWidth: "520px",
wrap: true,
cell: (c) => (
<div style={{ display: "flex", flexWrap: "wrap", gap: "2px 6px", padding: "2px 0", whiteSpace: "normal" }}>
{c.top_classes.map((cc, j) => (
<span key={j} style={{ display: "inline-flex", alignItems: "center", gap: 2, whiteSpace: "nowrap", minWidth: 0 }}>
{j > 0 ? <span style={{ color: "var(--muted)", marginRight: 2 }}>·</span> : null}
<span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle", minWidth: 0 }}><code title={cc.pretty_class} style={{ overflow: "hidden", textOverflow: "ellipsis", maxWidth: "240px" }}>{cc.pretty_class}</code><CopyBtn text={cc.pretty_class} /><PivotBtn cls={cc.pretty_class} /><OqlBtn cls={cc.pretty_class} /><ListObjectsBtn cls={cc.pretty_class} /></span> <span title={fmtExactBytes(cc.retained)} style={{ color: "var(--muted)", fontSize: "0.85em" }}>({fmtB(cc.retained)})</span>
</span>
))}
</div>
),
},
];
return (
<section id="top-components">
<h2>Top Components</h2>
<p className="subtitle">
Retained heap grouped by class loader (component). <strong>% Heap</strong> is the share of total reachable heap. Totals can exceed 100% because retained sets overlap — an object held by multiple components is counted in each.
</p>
<details open>
<summary>Components by Retained Heap ({fmtCount(components.length)} rows)</summary>
<StdTable columns={cols} data={components} searchKeys={["loader_label"]} fmtBtn={kbBtn} defaultSortFieldId="retained" />
</details>
</section>
);
}
// ── Arrays by Size ─────────────────────────────────────────────────────────
// Power-of-two array-length histogram (object vs primitive arrays). Always-on;
// mirrors render_md.rs::render_arrays_by_size.
function ArraysBySizeSection({ data, totalShallow }: { data?: ArraysBySize; totalShallow: number }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const obj = data?.obj_array_buckets ?? [];
const prim = data?.prim_array_buckets ?? [];
const zero = data?.zero_length_count ?? 0;
const empty = obj.length === 0 && prim.length === 0 && zero === 0;
const bucketTable = (title: string, buckets: ArraysBySize["obj_array_buckets"]) => {
const totalObjects = buckets.reduce((s, b) => s + b.objects, 0);
const totalBytes = buckets.reduce((s, b) => s + b.shallow, 0);
type Bucket = (typeof buckets)[0];
const cols: TableColumn<Bucket>[] = [
{ id: "len", name: "Max Length", right: true, width: "120px", format: (b) => `≤ ${fmtCount(b.upper_len)}`, selector: (b) => b.upper_len, sortable: true },
{ id: "objects", name: "Objects", right: true, width: "110px", format: (b) => fmtCount(b.objects), selector: (b) => b.objects, sortable: true },
{ id: "shallow", name: useKB ? "Shallow (KB)" : "Shallow", right: true, width: useKB ? "135px" : "110px", cell: byteCell(b => b.shallow, fmtB, useKB), selector: (b) => b.shallow, sortable: true },
{ id: "pct", name: "% Heap", right: true, width: "100px", format: (b) => totalShallow > 0 ? fmtPct(b.shallow / totalShallow * 100) : "—", selector: (b) => b.shallow, sortable: true },
];
return (
<>
<h3>{title}</h3>
{buckets.length === 0 ? (
<p className="subtitle">None found in this dump.</p>
) : (
<>
<StdTable columns={cols} data={buckets} searchKeys={[]} fmtBtn={kbBtn} defaultSortFieldId="shallow" defaultSortAsc={false} />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ width: "120px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(totalObjects)}</span>
<span style={{ width: useKB ? "135px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(totalBytes)}>{fmtB(totalBytes)}</span></span>
<span style={{ width: "100px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{totalShallow > 0 ? fmtPct(totalBytes / totalShallow * 100) : "—"}</span>
<span style={{ flex: 1 }} />
</div>
</>
)}
</>
);
};
return (
<section id="arrays-by-size">
<h2>Arrays by Size</h2>
<p className="subtitle">
Array length distribution bucketed by powers of two — <strong>Max Length</strong> is the inclusive upper bound of each bucket. Spot unexpectedly large arrays, many tiny zero-length allocations, or a skewed distribution that explains outsized array heap.
</p>
{empty ? (
<p className="subtitle">No arrays found.</p>
) : (
<>
{bucketTable("Object Arrays", obj)}
{bucketTable("Primitive Arrays", prim)}
<p className="subtitle">Zero-length arrays: {fmtCount(zero)}</p>
</>
)}
</section>
);
}
// ── Collections ─────────────────────────────────────────────────────────────
// Collection/array occupancy: fill ratios, size distribution, map collision
// (load) ratio, and constant primitive arrays. Always-on; mirrors
// render_md.rs::render_collections.
function CollectionsSection({ data }: { data?: CollectionsAnalysis }) {
const [fmtB, kbBtn, useKB] = useFmtBytes(); // Collections by Kind
const [fmtBcfr, kbBtnCfr, useKBcfr] = useFmtBytes(); // Collection Fill Ratio
const [fmtBcbs, kbBtnCbs, useKBcbs] = useFmtBytes(); // Collections by Size
const [fmtBafr, kbBtnAfr, useKBafr] = useFmtBytes(); // Array Fill Ratio
const [fmtBmcr, kbBtnMcr, useKBmcr] = useFmtBytes(); // Map Collision Ratio
const [fmtBcpa, kbBtnCpa, useKBcpa] = useFmtBytes(); // Constant Primitive Arrays
const [fmtBoarr, kbBtnOarr, useKBoarr] = useFmtBytes(); // Top Object Arrays
const [fmtBparr, kbBtnParr, useKBparr] = useFmtBytes(); // Top Primitive Arrays
const cfr = data?.collection_fill_ratio;
const cbs = data?.collections_by_size;
const afr = data?.array_fill_ratio;
const mcr = data?.map_collision_ratio;
const cpa = data?.constant_primitive_arrays;
const topPrim = data?.top_prim_arrays;
const topObj = data?.top_obj_arrays;
// The two Top Arrays tables (largest individual arrays + largest array
// classes by aggregate shallow) for one category. Mirrors
// render_md.rs::render_top_arrays.
const topArraysBlock = (t: TopArrays | undefined, kind: string, fmtBArr: (n: number) => string, kbBtnArr: React.ReactNode, useKBArr: boolean) => {
const individual = t?.top_individual ?? [];
const byClass = t?.top_by_class ?? [];
const hasFill = individual.some((r) => r.non_null != null);
const hasOwner = individual.some((r) => r.owner != null);
const totalIndivShallow = individual.reduce((s, r) => s + r.shallow, 0);
const indivCols: TableColumn<import("./types").TopArrayRow>[] = [
{ id: "class", name: "Array Class", grow: 1, maxWidth: hasOwner ? "337px" : "575px", cell: (r) => <span className="copy-cell"><code title={r.array_class}>{r.array_class}</code><CopyBtn text={r.array_class} /><PivotBtn cls={r.array_class} /><OqlBtn cls={r.array_class} /><ListObjectsBtn cls={r.array_class} /><ExploreBtn denseIdx={r.obj_index_1based - 1} label={r.array_class} /></span>, selector: (r) => r.array_class, sortable: true },
{ id: "length", name: "Length", right: true, width: "100px", format: (r) => fmtCount(r.length), selector: (r) => r.length, sortable: true },
...(hasFill ? [{ id: "fill", name: "Used / Length", right: true, width: "165px", selector: (r: import("./types").TopArrayRow) => r.non_null ?? 0, format: (r: import("./types").TopArrayRow) => r.non_null != null ? `${fmtCount(r.non_null)}/${fmtCount(r.length)}` : "—", sortable: true } as TableColumn<import("./types").TopArrayRow>] : []),
{ id: "shallow", name: useKBArr ? "Shallow (KB)" : "Shallow", right: true, width: useKBArr ? "135px" : "110px", cell: byteCell(r => r.shallow, fmtBArr, useKBArr), selector: (r) => r.shallow, sortable: true },
...(hasOwner ? [{ id: "owner", name: "Owner (Class#field)", grow: 1, maxWidth: "337px", cell: (r: import("./types").TopArrayRow) => r.owner ? <span className="copy-cell"><code title={r.owner} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "block" }}>{r.owner}</code><CopyBtn text={r.owner} /><PivotBtn cls={r.owner.split("#")[0]} /><OqlBtn cls={r.owner.split("#")[0]} /><ListObjectsBtn cls={r.owner.split("#")[0]} /></span> : <span>—</span> } as TableColumn<import("./types").TopArrayRow>] : []),
];
const byClassCols: TableColumn<import("./types").TopArrayClassRow>[] = [
{ id: "class", name: "Array Class", grow: 1, maxWidth: "600px", cell: (r) => <span className="copy-cell"><code title={r.array_class}>{r.array_class}</code><CopyBtn text={r.array_class} /><PivotBtn cls={r.array_class} /><OqlBtn cls={r.array_class} /><ListObjectsBtn cls={r.array_class} /></span>, selector: (r) => r.array_class, sortable: true },
{ id: "instances", name: "Instances", right: true, width: "120px", format: (r) => fmtCount(r.objects), selector: (r) => r.objects, sortable: true },
{ id: "shallow", name: useKBArr ? "Shallow (KB)" : "Shallow", right: true, width: useKBArr ? "135px" : "110px", cell: byteCell(r => r.shallow, fmtBArr, useKBArr), selector: (r) => r.shallow, sortable: true },
];
return (
<>
<h3>Top Arrays ({kind.charAt(0).toUpperCase() + kind.slice(1)})</h3>
<p className="subtitle">
Largest {kind} arrays by shallow size — individual instances and class totals.
</p>
{individual.length === 0 ? (
<p className="subtitle">None found in this dump.</p>
) : (
<>
<StdTable columns={indivCols} data={individual} searchKeys={["array_class"]} fmtBtn={kbBtnArr} defaultSortFieldId="shallow" defaultSortAsc={false} />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: "100px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}></span>
{hasFill && <span style={{ width: "165px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}></span>}
<span style={{ width: useKBArr ? "135px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(totalIndivShallow)}>{fmtBArr(totalIndivShallow)}</span></span>
{hasOwner && <span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}></span>}
</div>
</>
)}
<h4>Top Array Classes ({kind.charAt(0).toUpperCase() + kind.slice(1)})</h4>
{byClass.length === 0 ? (
<p className="subtitle">None found in this dump.</p>
) : (
<>
<StdTable columns={byClassCols} data={byClass} searchKeys={["array_class"]} fmtBtn={kbBtnArr} defaultSortFieldId="shallow" defaultSortAsc={false} />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: "120px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(byClass.reduce((s, r) => s + r.objects, 0))}</span>
<span style={{ width: useKBArr ? "135px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(byClass.reduce((s, r) => s + r.shallow, 0))}>{fmtBArr(byClass.reduce((s, r) => s + r.shallow, 0))}</span></span>
</div>
</>
)}
</>
);
};
// Format a basis-point fill/load range as a percent label (e.g. "0–10%").
const ratioLabel = (b: FillRatioBucket) =>
b.lower_ratio_bp === b.upper_ratio_bp
? `${b.lower_ratio_bp / 100}% (full)`
: `${b.lower_ratio_bp / 100}–${b.upper_ratio_bp / 100}%`;
// A fill/wasted table (Collection Fill Ratio, Array Fill Ratio) sharing 4 cols.
const fillTable = (label: string, itemsHeader: string, buckets: FillRatioBucket[], fmtBFill: (n: number) => string, kbBtnFill: React.ReactNode, useKBFill: boolean) => {
const totalItems = buckets.reduce((s, b) => s + b.objects, 0);
const totalShallowFill = buckets.reduce((s, b) => s + b.shallow, 0);
const totalWasted = buckets.reduce((s, b) => s + b.wasted, 0);
const fillCols: TableColumn<FillRatioBucket>[] = [
{ id: "ratio", name: label, right: true, width: "130px", format: (b) => ratioLabel(b), selector: (b) => b.lower_ratio_bp, sortable: true },
{ id: "items", name: itemsHeader, right: true, width: "124px", format: (b) => fmtCount(b.objects), selector: (b) => b.objects, sortable: true },
{ id: "shallow", name: useKBFill ? "Shallow (KB)" : "Shallow", right: true, width: useKBFill ? "135px" : "110px", cell: byteCell(b => b.shallow, fmtBFill, useKBFill), selector: (b) => b.shallow, sortable: true },
{ id: "wasted", name: useKBFill ? "Wasted (KB)" : "Wasted", right: true, width: useKBFill ? "135px" : "110px", cell: byteCell(b => b.wasted, fmtBFill, useKBFill), selector: (b) => b.wasted, sortable: true },
];
return (
<>
<StdTable columns={fillCols} data={buckets} searchKeys={[]} fmtBtn={kbBtnFill} defaultSortFieldId="wasted" defaultSortAsc={false} />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ width: "130px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: "124px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(totalItems)}</span>
<span style={{ width: useKBFill ? "135px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(totalShallowFill)}>{fmtBFill(totalShallowFill)}</span></span>
<span style={{ width: useKBFill ? "135px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(totalWasted)}>{fmtBFill(totalWasted)}</span></span>
<span style={{ flex: 1 }} />
</div>
</>
);
};
const cfrBuckets = cfr?.buckets ?? [];
const cbsBuckets = cbs?.buckets ?? [];
const afrBuckets = afr?.buckets ?? [];
const mcrBuckets = mcr?.buckets ?? [];
const cpaRows = cpa?.rows ?? [];
const cpaHasOwner = cpaRows.some((r) => r.owner != null);
const kindRows = data?.kind_summary?.kinds ?? [];
return (
<section id="collections">
<h2>Collections</h2>
<p className="subtitle">
Collection fill ratios, map load factors, and constant-value primitive array groups. Low fill ratios waste backing-array memory; high load factors increase hash-bucket collisions and degrade lookup performance.
</p>
<h3>Collections by Kind</h3>
{kindRows.length === 0 ? (
<p className="subtitle">No collection kinds found in this heap.</p>
) : (() => {
const kindCols: TableColumn<import("./types").CollectionKindStat>[] = [
{ id: "kind", name: "Kind", grow: 1, cell: (s) => <span style={{ textTransform: "capitalize" }}>{s.kind}</span>, selector: (s) => s.kind, sortable: true },
{ id: "count", name: "Count", right: true, width: "100px", format: (s) => fmtCount(s.count), selector: (s) => s.count, sortable: true },
{ id: "total_el", name: "Total Elements", right: true, width: "148px", format: (s) => fmtCount(s.total_elements), selector: (s) => s.total_elements, sortable: true },
{ id: "max_el", name: "Max Elements", right: true, width: "140px", format: (s) => fmtCount(s.max_elements), selector: (s) => s.max_elements, sortable: true },
{ id: "shallow", name: useKB ? "Total Shallow (KB)" : "Total Shallow", right: true, width: useKB ? "172px" : "136px", cell: byteCell(s => s.total_shallow, fmtB, useKB), selector: (s) => s.total_shallow, sortable: true },
];
return (
<>
<StdTable columns={kindCols} data={kindRows} searchKeys={["kind"]} fmtBtn={kbBtn} defaultSortFieldId="shallow" defaultSortAsc={false} />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: "100px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(kindRows.reduce((s, r) => s + r.count, 0))}</span>
<span style={{ width: "148px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(kindRows.reduce((s, r) => s + r.total_elements, 0))}</span>
<span style={{ width: "140px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}></span>
<span style={{ width: useKB ? "172px" : "136px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{(() => { const t = kindRows.reduce((s, r) => s + r.total_shallow, 0); return <span title={fmtExactBytes(t)}>{fmtB(t)}</span>; })()}</span>
</div>
</>
);
})()}
<h3>Collection Fill Ratio</h3>
<p className="subtitle">
Fraction of each collection's capacity in use — low fill wastes backing-array memory.
{cfr && <>{" "}{fmtCount(cfr.total)} collections analyzed ({fmtCount(cfr.tracked)} non-empty tracked).</>}
</p>
{cfrBuckets.length === 0 ? (
<p className="subtitle">None found in this dump.</p>
) : (
fillTable("Fill %", "Collections", cfrBuckets, fmtBcfr, kbBtnCfr, useKBcfr)
)}
<h3>Collections by Size</h3>
<p className="subtitle">
Element-count distribution of collections, bucketed by size.
{cbs && <>{" "}{fmtCount(cbs.tracked)} tracked; {fmtCount(cbs.empty_count)} empty.</>}
</p>
{cbsBuckets.length === 0 ? (
<p className="subtitle">None found in this dump.</p>
) : (() => {
const cbsCols: TableColumn<import("./types").SizeHistogramBucket>[] = [
{ id: "size", name: "Size ≤", right: true, width: "120px", format: (b) => `≤ ${fmtCount(b.upper_len)}`, selector: (b) => b.upper_len, sortable: true },
{ id: "collections", name: "Collections", right: true, width: "142px", format: (b) => fmtCount(b.objects), selector: (b) => b.objects, sortable: true },
{ id: "shallow", name: useKBcbs ? "Shallow (KB)" : "Shallow", right: true, width: useKBcbs ? "140px" : "120px", cell: byteCell(b => b.shallow, fmtBcbs, useKBcbs), selector: (b) => b.shallow, sortable: true },
];
return (
<>
<StdTable columns={cbsCols} data={cbsBuckets} searchKeys={[]} fmtBtn={kbBtnCbs} defaultSortFieldId="size" defaultSortAsc={true} />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ width: "120px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: "142px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(cbsBuckets.reduce((s, b) => s + b.objects, 0))}</span>
<span style={{ width: useKBcbs ? "140px" : "120px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(cbsBuckets.reduce((s, b) => s + b.shallow, 0))}>{fmtBcbs(cbsBuckets.reduce((s, b) => s + b.shallow, 0))}</span></span>
<span style={{ flex: 1 }} />
</div>
</>
);
})()}
<h3>Array Fill Ratio</h3>
<p className="subtitle">
Non-null element fraction of object arrays — low fill leaves most slots empty.
{afr && <>{" "}{fmtCount(afr.tracked)} tracked.</>}
</p>
{afrBuckets.length === 0 ? (
<p className="subtitle">None found in this dump.</p>
) : (
fillTable("Fill %", "Arrays", afrBuckets, fmtBafr, kbBtnAfr, useKBafr)
)}
<h3>Map Load Factor</h3>
<p className="subtitle">
Load factor (occupied slots ÷ capacity) for {fmtCount(mcr?.tracked ?? 0)} of {fmtCount(mcr?.total ?? 0)} maps; high values (≥ 90%) signal dense packing and longer bucket chains per lookup.
</p>
{mcrBuckets.length === 0 ? (
<p className="subtitle">None found in this dump.</p>
) : (() => {
const mcrCols: TableColumn<FillRatioBucket>[] = [
{ id: "load", name: "Load %", right: true, width: "130px", format: (b) => ratioLabel(b), selector: (b) => b.lower_ratio_bp, sortable: true },
{ id: "maps", name: "Maps", right: true, width: "110px", format: (b) => fmtCount(b.objects), selector: (b) => b.objects, sortable: true },
{ id: "shallow", name: useKBmcr ? "Shallow (KB)" : "Shallow", right: true, width: useKBmcr ? "135px" : "110px", cell: byteCell(b => b.shallow, fmtBmcr, useKBmcr), selector: (b) => b.shallow, sortable: true },
];
return (
<>
<StdTable columns={mcrCols} data={mcrBuckets} searchKeys={[]} fmtBtn={kbBtnMcr} defaultSortFieldId="shallow" defaultSortAsc={false} />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ width: "130px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(mcrBuckets.reduce((s, b) => s + b.objects, 0))}</span>
<span style={{ width: useKBmcr ? "135px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(mcrBuckets.reduce((s, b) => s + b.shallow, 0))}>{fmtBmcr(mcrBuckets.reduce((s, b) => s + b.shallow, 0))}</span></span>
<span style={{ flex: 1 }} />
</div>
</>
);
})()}
<h3>Constant Primitive Arrays</h3>
<p className="subtitle">
Primitive arrays whose every element is identical — possible candidates for deduplication or replacement with a shared constant. Short arrays (length < 8 with few instances) are filtered as noise.
</p>
{cpa?.truncated && (
<p className="subtitle">List truncated — remaining groups folded into one row.</p>
)}
{cpaRows.length === 0 ? (
<p className="subtitle">None found in this dump.</p>
) : (() => {
const cpaCols: TableColumn<import("./types").ConstantArrayRow>[] = [
{ id: "class", name: "Array Class", grow: 1, maxWidth: cpaHasOwner ? "308px" : "575px", cell: (r) => <span className="copy-cell"><code title={r.array_class}>{r.array_class}</code><CopyBtn text={r.array_class} /><PivotBtn cls={r.array_class} /><OqlBtn cls={r.array_class} /><ListObjectsBtn cls={r.array_class} /></span>, selector: (r) => r.array_class, sortable: true },
{ id: "length", name: "Length", right: true, width: "100px", format: (r) => fmtCount(r.length), selector: (r) => r.length, sortable: true },
{ id: "value", name: "Value", right: true, width: "90px", format: (r) => String(r.value), selector: (r) => r.value, sortable: true },
{ id: "objects", name: "Objects", right: true, width: "100px", format: (r) => fmtCount(r.objects), selector: (r) => r.objects, sortable: true },
{ id: "shallow", name: useKBcpa ? "Shallow (KB)" : "Shallow", right: true, width: useKBcpa ? "135px" : "110px", cell: byteCell(r => r.shallow, fmtBcpa, useKBcpa), selector: (r) => r.shallow, sortable: true },
...(cpaHasOwner ? [{ id: "owner", name: "Owner (Class#field)", grow: 1, maxWidth: "307px", cell: (r: import("./types").ConstantArrayRow) => r.owner ? <code title={r.owner} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "block" }}>{r.owner}</code> : <span>—</span> } as TableColumn<import("./types").ConstantArrayRow>] : []),
];
return <StdTable columns={cpaCols} data={cpaRows} searchKeys={["array_class"]} fmtBtn={kbBtnCpa} defaultSortFieldId="shallow" defaultSortAsc={false} />;
})()}
{topArraysBlock(topPrim, "primitive", fmtBparr, kbBtnParr, useKBparr)}
{topArraysBlock(topObj, "object", fmtBoarr, kbBtnOarr, useKBoarr)}
</section>
);
}
// ── Collection Waste Budget ───────────────────────────────────────────────────
// Aggregates all heap-waste sources (duplicate strings, dup prim arrays, boxed
// numbers, tiny collection overhead) into one table sorted by wasted bytes.
function CollectionWasteBudgetSection({ report }: { report: Report }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const ds = report.overview.duplicate_strings;
const dp = report.overview.duplicate_prim_arrays;
const bn = report.overview.boxed_numbers;
const ca = report.collection_attribution;
// Determine visibility: at least one source must have data.
const hasDs = (ds?.approx_wasted_bytes ?? 0) > 0;
const hasDp = (dp?.total_wasted_bytes ?? 0) > 0;
const hasBn = (bn?.reduce((s, r) => s + r.total_shallow, 0) ?? 0) > 0;
const hasTiny = (ca?.tiny_overhead?.some(r => r.overhead_bytes > 0)) ?? false;
if (!hasDs && !hasDp && !hasBn && !hasTiny) return null;
interface WasteRow {
type: string;
wasted: number;
objects: number;
fix: string;
}
const rows: WasteRow[] = [];
if (hasDs && ds) {
rows.push({
type: "Duplicate Strings",
wasted: ds.approx_wasted_bytes,
objects: ds.total_string_instances - ds.distinct_values,
fix: "Intern at parse time (e.g. map.computeIfAbsent(s, k -> k)) or use Guava Interner",
});
}
if (hasDp && dp) {
const objCount = dp.rows.reduce((s, r) => s + r.duplicated_groups, 0);
rows.push({
type: "Duplicate Primitive Arrays",
wasted: dp.total_wasted_bytes,
objects: objCount,
fix: "Deduplicate or replace with shared static final constants",
});
}
if (hasBn && bn) {
// total_shallow = full footprint, not reclaimable delta; labeled "(footprint)" in the table
const totalWasted = bn.reduce((s, r) => s + r.total_shallow, 0);
const totalObjs = bn.reduce((s, r) => s + r.instances, 0);
rows.push({
type: "Boxed Primitives (footprint)*",
wasted: totalWasted,
objects: totalObjs,
fix: "Use primitive arrays, or Eclipse Collections / Koloboke for primitive-typed collections",
});
}
if (ca?.tiny_overhead) {
for (const row of ca.tiny_overhead) {
if (row.overhead_bytes > 0) {
rows.push({
type: `Empty/Singleton ${row.container_kind.charAt(0).toUpperCase() + row.container_kind.slice(1)} (${row.holder_class}#${row.field})`,
wasted: row.overhead_bytes,
objects: row.empty_count + row.singleton_count,
fix: "Use null or Collections.emptyList() sentinels until the collection is first written",
});
}
}
}
// Sort by wasted descending.
rows.sort((a, b) => b.wasted - a.wasted);
const totalWasted = rows.reduce((s, r) => s + r.wasted, 0);
const totalObjects = rows.reduce((s, r) => s + r.objects, 0);
const showCollectionsNote = !ca && (hasDs || hasDp);
return (
<section id="collection-waste-budget">
<h2>Collection Waste Budget</h2>
<p className="subtitle">
Memory tied up in avoidable objects — duplicate strings, duplicate primitive arrays, boxed primitives, and empty/singleton collection overhead. Fix the biggest category first for the highest impact. Figures are approximate.
</p>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.875rem", marginBottom: "0.25rem" }}>
<thead>
<tr style={{ borderBottom: "2px solid var(--border)", textAlign: "left" }}>
<th style={{ padding: "4px 8px", fontWeight: 600 }}>Waste Type</th>
<th style={{ padding: "4px 8px", fontWeight: 600, textAlign: "right", whiteSpace: "nowrap" }}>↓ {useKB ? "Wasted (KB)" : "Wasted"}</th>
<th style={{ padding: "4px 8px", fontWeight: 600, textAlign: "right" }}>Objects</th>
<th style={{ padding: "4px 8px", fontWeight: 600 }}>Fix Suggestion</th>
</tr>
</thead>
<tbody>
{rows.map(r => (
<tr key={r.type} style={{ borderBottom: "1px solid var(--border)" }}>
<td style={{ padding: "5px 8px", whiteSpace: "nowrap" }}>{r.type}</td>
<td style={{ padding: "5px 8px", textAlign: "right", whiteSpace: "nowrap" }}><span title={fmtExactBytes(r.wasted)}>{fmtB(r.wasted)}</span></td>
<td style={{ padding: "5px 8px", textAlign: "right", whiteSpace: "nowrap" }}>{fmtCount(r.objects)}</td>
<td style={{ padding: "5px 8px", color: "var(--muted)", fontSize: "0.82rem" }}>{r.fix}</td>
</tr>
))}
</tbody>
</table>
<p className="subtitle" style={{ textAlign: "right", marginTop: "4px" }}>
<strong>Total: <span title={fmtExactBytes(totalWasted)}>{fmtB(totalWasted)}</span></strong> wasted across{" "}
<strong>{fmtCount(totalObjects)}</strong> objects
</p>
{showCollectionsNote && (
<p className="subtitle">
Re-run with <code>--collections</code> to include collection waste categories.
</p>
)}
{hasBn && (
<p className="subtitle">* Boxed primitive footprint shown; reclaimable savings depend on usage.</p>
)}
{kbBtn}
</section>
);
}
// ── Container Attribution (Class#field) ──────────────────────────────────────
// Which holder Class#field points at the most container memory. Absent when
// --collections was off (data undefined → section not rendered). Mirrors
// render_md.rs::render_collection_attribution (HTML has no bar columns).
function TinyCollectionTable({ rows }: { rows: import("./types").TinyCollectionRow[] }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const cols: TableColumn<import("./types").TinyCollectionRow>[] = [
{ id: "field", name: "Class#field", grow: 1, maxWidth: "380px", cell: (r) => <span className="copy-cell"><code title={`${r.holder_class}#${r.field}`}>{r.holder_class}#{r.field}</code><CopyBtn text={r.holder_class} /><PivotBtn cls={r.holder_class} /><OqlBtn cls={r.holder_class} /><ListObjectsBtn cls={r.holder_class} /></span>, selector: (r) => `${r.holder_class}#${r.field}`, sortable: true },
{ id: "kind", name: "Kind", width: "130px", cell: (r) => <span style={{ textTransform: "capitalize" }}>{r.container_kind}</span>, selector: (r) => r.container_kind, sortable: true },
{ id: "empty", name: "Empty", right: true, width: "90px", format: (r) => fmtCount(r.empty_count), selector: (r) => r.empty_count, sortable: true },
{ id: "singleton", name: "Singleton", right: true, width: "100px", format: (r) => fmtCount(r.singleton_count), selector: (r) => r.singleton_count, sortable: true },
{ id: "overhead", name: useKB ? "Overhead (KB)" : "Overhead", right: true, width: useKB ? "150px" : "110px", cell: byteCell(r => r.overhead_bytes, fmtB, useKB), selector: (r) => r.overhead_bytes, sortable: true },
];
return <StdTable columns={cols} data={rows} searchKeys={["holder_class"]} fmtBtn={kbBtn} defaultSortFieldId="overhead" defaultSortAsc={false} />;
}
function CollectionAttributionSection({ data }: { data?: CollectionAttribution }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
if (!data) return null;
const mostOverall = data.most_overall ?? [];
const biggestSingle = data.biggest_single ?? [];
return (
<section id="container-attribution">
<h2>Container Attribution</h2>
<p className="subtitle">
Which <code>Class#field</code> holds the most collection memory — two rankings: total across all containers reached through a field, and the single largest container per field. To reduce waste: shrink the collection's initial capacity, evict unused entries, or null out the field when the holder is done.
</p>
<h3>Top by Total Memory</h3>
{mostOverall.length === 0 ? (
<p className="subtitle">No collections exceeded the size threshold.</p>
) : (() => {
const overallCols: TableColumn<import("./types").FieldAttributionRow>[] = [
{ id: "field", name: "Class#field", grow: 1, maxWidth: "320px", cell: (r) => <span className="copy-cell"><code title={`${r.holder_class}#${r.field}`}>{r.holder_class}#{r.field}</code><CopyBtn text={r.holder_class} /><PivotBtn cls={r.holder_class} /><OqlBtn cls={r.holder_class} /><ListObjectsBtn cls={r.holder_class} /></span>, selector: (r) => `${r.holder_class}#${r.field}`, sortable: true },
{ id: "kind", name: "Kind", width: "130px", cell: (r) => <span style={{ textTransform: "capitalize" }}>{r.container_kind}</span>, selector: (r) => r.container_kind, sortable: true },
{ id: "containers", name: "Containers", right: true, width: "110px", format: (r) => fmtCount(r.container_count), selector: (r) => r.container_count, sortable: true },
{ id: "holders", name: "Holders", right: true, width: "90px", format: (r) => fmtCount(r.holder_instances), selector: (r) => r.holder_instances, sortable: true },
{ id: "elements", name: "Elements", right: true, width: "96px", format: (r) => fmtCount(r.total_elements), selector: (r) => r.total_elements, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "110px", cell: byteCell(r => r.total_retained, fmtB, useKB), selector: (r) => r.total_retained, sortable: true },
{ id: "wasted", name: useKB ? "Wasted (KB)" : "Wasted", right: true, width: useKB ? "120px" : "96px", cell: (r) => r.total_wasted_bytes != null ? <span title={fmtExactBytes(r.total_wasted_bytes)}>{fmtB(r.total_wasted_bytes)}</span> : "—", selector: (r) => r.total_wasted_bytes ?? 0, sortable: true },
];
return <StdTable columns={overallCols} data={mostOverall} searchKeys={["holder_class"]} fmtBtn={kbBtn} defaultSortFieldId="retained" defaultSortAsc={false} />;
})()}
<h3>Largest Single Container</h3>
{biggestSingle.length === 0 ? (
<p className="subtitle">No collections exceeded the size threshold.</p>
) : (() => {
const singleCols: TableColumn<import("./types").FieldAttributionBiggestRow>[] = [
{ id: "field", name: "Class#field", grow: 1, maxWidth: "300px", cell: (r) => <span className="copy-cell"><code title={`${r.holder_class}#${r.field}`}>{r.holder_class}#{r.field}</code><CopyBtn text={r.holder_class} /><PivotBtn cls={r.holder_class} /><OqlBtn cls={r.holder_class} /><ListObjectsBtn cls={r.holder_class} /></span>, selector: (r) => `${r.holder_class}#${r.field}`, sortable: true },
{ id: "container", name: "Container Class", grow: 1, maxWidth: "280px", cell: (r) => <span className="copy-cell"><code title={r.container_class}>{r.container_class}</code><CopyBtn text={r.container_class} /><PivotBtn cls={r.container_class} /><OqlBtn cls={r.container_class} /><ListObjectsBtn cls={r.container_class} /></span>, selector: (r) => r.container_class, sortable: true },
{ id: "kind", name: "Kind", width: "130px", cell: (r) => <span style={{ textTransform: "capitalize" }}>{r.container_kind}</span>, selector: (r) => r.container_kind, sortable: true },
{ id: "elements", name: "Elements", right: true, width: "96px", format: (r) => fmtCount(r.elements), selector: (r) => r.elements, sortable: true },
{ id: "capacity", name: "Capacity", right: true, width: "96px", format: (r) => fmtCount(r.capacity), selector: (r) => r.capacity, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "110px", cell: byteCell(r => r.retained, fmtB, useKB), selector: (r) => r.retained, sortable: true },
];
return <StdTable columns={singleCols} data={biggestSingle} searchKeys={["holder_class"]} fmtBtn={kbBtn} defaultSortFieldId="retained" defaultSortAsc={false} />;
})()}
{data.tiny_overhead && data.tiny_overhead.length > 0 && (
<>
<h3>Tiny Collection Overhead</h3>
<p className="subtitle">
Empty (size-0) and singleton (size-1) collections whose wrapper objects are unnecessary — replace with <code>null</code> or <code>Collections.emptyList()</code> until the collection is first written. Wrapper overhead per collection is one object header plus the backing-array pointer.
</p>
<TinyCollectionTable rows={data.tiny_overhead} />
</>
)}
{data.truncated && (
<p className="subtitle">
Attribution data was truncated — some holder or container records were capped; totals may undercount the full heap.
</p>
)}
</section>
);
}
function BiggestCollectionsTable({ rows, title }: { rows: BiggestCollectionRow[]; title: string }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
if (rows.length === 0) return null;
const hasRetained = rows.some((r) => r.retained != null);
const hasOwner = rows.some((r) => r.owner != null);
const hasBreakdown = rows.some((r) => (r.value_type_breakdown?.length ?? 0) > 0);
// Drop standalone Value Type column when breakdown is present (it duplicates the lead entry).
const hasValue = !hasBreakdown && rows.some((r) => r.dominant_value_type != null);
const totalElements = rows.reduce((s, r) => s + r.elements, 0);
const totalRetained = rows.reduce((s, r) => s + (r.retained ?? 0), 0);
// Coalesce consecutive identical rows.
type Coalesced = { row: BiggestCollectionRow; count: number };
type CoalescedRow = Coalesced;
const coalesced: Coalesced[] = [];
for (const r of rows) {
const last = coalesced[coalesced.length - 1];
// obj_index_1based intentionally excluded: coalesced rows suppress ExploreBtn via count > 1
if (
last &&
last.row.kind === r.kind &&
last.row.container_class === r.container_class &&
last.row.elements === r.elements &&
last.row.owner === r.owner &&
last.row.retained === r.retained
) {
last.count++;
} else {
coalesced.push({ row: r, count: 1 });
}
}
const cols: TableColumn<CoalescedRow>[] = [
{ id: "kind", name: "Kind", width: "130px", cell: ({ row: r }) => <span style={{ textTransform: "capitalize" }}>{r.kind}</span>, selector: ({ row: r }) => r.kind, sortable: true },
{
id: "class", name: "Container Class", grow: 1, maxWidth: "240px",
cell: ({ row: r, count }) => (
<span className="copy-cell">
<code title={r.container_class}>{r.container_class}</code>
<CopyBtn text={r.container_class} />
<PivotBtn cls={r.container_class} />
<OqlBtn cls={r.container_class} />
<ListObjectsBtn cls={r.container_class} />
{count === 1 && r.obj_index_1based != null && (
<ExploreBtn denseIdx={r.obj_index_1based - 1} label={r.container_class} />
)}
</span>
),
selector: ({ row: r }) => r.container_class,
sortable: true,
},
{ id: "elements", name: "Elements", right: true, width: "100px", format: ({ row: r, count }) => count > 1 ? `${fmtCount(r.elements)} each` : fmtCount(r.elements), selector: ({ row: r }) => r.elements, sortable: true },
...(hasValue ? [{ id: "value", name: "Value Type", grow: 1, cell: ({ row: r }: CoalescedRow) => r.dominant_value_type ? <span className="copy-cell"><code title={r.dominant_value_type}>{r.dominant_value_type}</code><CopyBtn text={r.dominant_value_type} /><PivotBtn cls={r.dominant_value_type} /><OqlBtn cls={r.dominant_value_type} /><ListObjectsBtn cls={r.dominant_value_type} /></span> : <span>—</span> } as TableColumn<CoalescedRow>] : []),
...(hasBreakdown ? [{
id: "breakdown", name: "Value Types (top)", grow: 2, maxWidth: "280px",
cell: ({ row: r }: CoalescedRow) => !r.value_type_breakdown || r.value_type_breakdown.length === 0
? <span>—</span>
: <div style={{ display: "flex", flexWrap: "wrap", gap: "2px 6px", minWidth: 0, overflow: "hidden", whiteSpace: "normal", padding: "2px 0" }}>{r.value_type_breakdown.map((s, j) => <span key={j} style={{ display: "inline-flex", alignItems: "center", whiteSpace: "nowrap", minWidth: 0 }}><span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle", minWidth: 0 }}><code style={{ overflow: "hidden", textOverflow: "ellipsis", maxWidth: "160px" }} title={s.type_name}>{s.type_name}</code><CopyBtn text={s.type_name} /><PivotBtn cls={s.type_name} /><OqlBtn cls={s.type_name} /><ListObjectsBtn cls={s.type_name} /></span><span style={{ flexShrink: 0, color: "var(--muted)", fontSize: "0.85em" }}> ×{fmtCount(s.count)}</span></span>)}</div>,
} as TableColumn<CoalescedRow>] : []),
...(hasOwner ? [{ id: "owner", name: "Owner (Class#field)", grow: 1, maxWidth: "240px", cell: ({ row: r }: CoalescedRow) => r.owner ? <span className="copy-cell"><code title={r.owner} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "block" }}>{r.owner}</code><CopyBtn text={r.owner} /><PivotBtn cls={r.owner.split("#")[0]} /><OqlBtn cls={r.owner.split("#")[0]} /><ListObjectsBtn cls={r.owner.split("#")[0]} /></span> : <span>—</span> } as TableColumn<CoalescedRow>] : []),
...(hasRetained ? [{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "130px" : "110px", cell: ({ row: r }: CoalescedRow) => r.retained != null ? <span title={fmtExactBytes(r.retained)}>{fmtB(r.retained)}</span> : "—", selector: ({ row: r }: CoalescedRow) => r.retained ?? 0, sortable: true } as TableColumn<CoalescedRow>] : []),
];
return (
<>
<h3>{title}</h3>
<StdTable columns={cols} data={coalesced} searchKeys={[]} fmtBtn={kbBtn} defaultSortFieldId="elements" defaultSortAsc={false} />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ width: "130px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5 }}></span>
<span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: "100px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(totalElements)}</span>
{hasValue && <span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}></span>}
{hasBreakdown && <span style={{ flex: 2, paddingLeft: 5, paddingRight: 5 }}></span>}
{hasOwner && <span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}></span>}
{hasRetained && <span style={{ width: useKB ? "130px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(totalRetained)}>{fmtB(totalRetained)}</span></span>}
</div>
</>
);
}
function BiggestCollectionsSection({ data }: { data?: BiggestCollections }) {
if (!data) return null;
return (
<section id="biggest-collections">
<h2>Biggest Collections</h2>
<p className="subtitle">
The largest individual collection instances. <strong>Owner</strong> is the primary incoming <code>Class#field</code>; <strong>Value Type</strong> is the dominant runtime element type of the backing array (the direct element, not the logical key/value — for a <code>Map<K,V></code> this is often <code>Entry</code> or <code>Object</code>, not <code>V</code>). Oversized collections often signal unbounded growth, missing eviction, or data that should be paginated or right-sized. Owner/retained/value columns require <code>--collections</code>.
</p>
<BiggestCollectionsTable rows={data.combined} title="Combined" />
{data.by_kind.map((k) => <BiggestCollectionsTable key={k.kind} rows={k.rows} title={`By Kind — ${k.kind.charAt(0).toUpperCase() + k.kind.slice(1)}`} />)}
{data.truncated && (
<p className="subtitle">Collection value tally truncated — some value groups dropped; ranking is a bounded sample.</p>
)}
</section>
);
}
function CollectionContentsSection({ data }: { data?: CollectionContents }) {
if (!data) return null;
const rows = data.rows ?? [];
const cols: TableColumn<import("./types").CollectionContentsRow>[] = [
{ id: "class", name: "Collection Class", grow: 1, maxWidth: "600px", cell: (r) => <span className="copy-cell"><code title={r.collection_class}>{r.collection_class}</code><CopyBtn text={r.collection_class} /><PivotBtn cls={r.collection_class} /><OqlBtn cls={r.collection_class} /><ListObjectsBtn cls={r.collection_class} /></span>, selector: (r) => r.collection_class, sortable: true },
{ id: "instances", name: "Instances", right: true, width: "120px", format: (r) => fmtCount(r.instances), selector: (r) => r.instances, sortable: true },
{ id: "values", name: "Total Values", right: true, width: "120px", format: (r) => fmtCount(r.total_values), selector: (r) => r.total_values, sortable: true },
{
id: "types", name: "Top Value Types", grow: 2, maxWidth: "400px",
cell: (r) => r.top_value_types.length === 0
? <span>—</span>
: <div style={{ display: "flex", flexWrap: "wrap", gap: "2px 8px", padding: "2px 0", whiteSpace: "normal" }}>{r.top_value_types.map((s, j) => <span key={j} style={{ display: "inline-flex", alignItems: "center", gap: 2, whiteSpace: "nowrap" }}><span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle" }}><code style={{ overflow: "hidden", textOverflow: "ellipsis", maxWidth: "200px" }} title={s.type_name}>{s.type_name}</code><CopyBtn text={s.type_name} /><PivotBtn cls={s.type_name} /><OqlBtn cls={s.type_name} /><ListObjectsBtn cls={s.type_name} /></span> <span style={{ color: "var(--muted)", fontSize: "0.85em" }}>×{fmtCount(s.count)}</span></span>)}</div>,
},
];
return (
<section id="collection-contents-by-type">
<h2>Collection Contents by Type</h2>
<p className="subtitle">
Element types stored in each collection class, summed across all instances. Spot unexpected or boxed value types that could be replaced with primitive arrays or more specific collections. Requires <code>--collections</code>.
</p>
{rows.length === 0 ? (
<p className="subtitle">None found in this dump.</p>
) : (
<StdTable columns={cols} data={rows} searchKeys={["collection_class"]} defaultSortFieldId="values" defaultSortAsc={false} />
)}
{data.truncated && (
<p className="subtitle">Results truncated — some collection classes dropped.</p>
)}
</section>
);
}
// ── Fields by Retained Size (Class#field) ────────────────────────────────────
// Which holder Class#field retains the most memory summed over its pointees.
// Absent when --collections was off. Mirrors render_md.rs::render_fields_by_size
// (HTML has no bar column).
function FieldsBySizeSection({ data }: { data?: FieldsBySize }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
if (!data) return null;
const rows = data.rows ?? [];
const totalRetained = rows.reduce((s, r) => s + r.total_retained, 0);
const totalPointees = rows.reduce((s, r) => s + r.pointees, 0);
const hasElements = rows.some((r) => (r.elements ?? 0) > 0);
type FBSRow = import("./types").FieldBySizeRow;
const cols: TableColumn<FBSRow>[] = [
{ id: "field", name: "Class#field", grow: 2, minWidth: "160px", maxWidth: "280px", cell: (r) => <span className="copy-cell"><code title={`${r.holder_class}#${r.field}`}>{r.holder_class}#{r.field}</code><CopyBtn text={r.holder_class} /><PivotBtn cls={r.holder_class} /><OqlBtn cls={r.holder_class} /><ListObjectsBtn cls={r.holder_class} /></span>, selector: (r) => `${r.holder_class}#${r.field}`, sortable: true },
{ id: "pointee", name: "Pointee Type", grow: 2, minWidth: "140px", maxWidth: "260px", cell: (r) => <span className="copy-cell"><code title={r.pointee_type}>{r.pointee_type}</code><CopyBtn text={r.pointee_type} /><PivotBtn cls={r.pointee_type} /><OqlBtn cls={r.pointee_type} /><ListObjectsBtn cls={r.pointee_type} /></span>, selector: (r) => r.pointee_type, sortable: true },
{ id: "category", name: "Kind", width: "82px", cell: (r) => <span style={{ textTransform: "capitalize" }}>{r.category ?? "—"}</span>, selector: (r) => r.category ?? "", sortable: true },
{ id: "pointees", name: "Pointees", right: true, width: "96px", format: (r) => fmtCount(r.pointees), selector: (r) => r.pointees, sortable: true },
...(hasElements ? [{ id: "elements", name: "Elements", right: true, width: "90px", format: (r: FBSRow) => r.elements != null ? fmtCount(r.elements) : "—", selector: (r: FBSRow) => r.elements ?? 0, sortable: true } as TableColumn<FBSRow>] : []),
{ id: "holders", name: "Holders", right: true, width: "96px", format: (r) => fmtCount(r.holder_instances), selector: (r) => r.holder_instances, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "130px" : "110px", cell: byteCell(r => r.total_retained, fmtB, useKB), selector: (r) => r.total_retained, sortable: true },
];
return (
<section id="fields-by-retained-size">
<h2>Fields by Retained Size</h2>
<p className="subtitle">
Which <code>Class#field</code> retains the most memory, summed over every object the field points at.
Runtime pointee type is the dominant concrete class reached through the field (<code>varies</code> when no single type dominates). A field retaining unexpectedly large memory is a good candidate to null out after use or wrap in a lazy-initialized reference.
</p>
{data.truncated && (
<p className="subtitle">
Field grouping was truncated (group or pointee cap hit) — ranking is a bounded sample.
</p>
)}
{rows.length === 0 ? (
<p className="subtitle">No field-size data — pass <code>--collections</code> to enable field attribution.</p>
) : (
<>
<StdTable columns={cols} data={rows} searchKeys={["holder_class"]} fmtBtn={kbBtn} defaultSortFieldId="retained" defaultSortAsc={false} />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}></span>
<span style={{ width: "82px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5 }}></span>
<span style={{ width: "96px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(totalPointees)}</span>
{hasElements && <span style={{ width: "80px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(rows.reduce((s, r) => s + (r.elements ?? 0), 0))}</span>}
<span style={{ width: "96px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5 }}></span>
<span style={{ width: useKB ? "130px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(totalRetained)}>{fmtB(totalRetained)}</span></span>
</div>
</>
)}
</section>
);
}
// ── References ──────────────────────────────────────────────────────────────
// Soft/weak/phantom reference referents (what they point at). Always-on;
// mirrors render_md.rs::render_references.
function RefClassTable({ rows }: { rows: RefStatClassRow[] }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const cols: TableColumn<RefStatClassRow>[] = [
{ id: "class", name: "Class", grow: 1, maxWidth: "650px", cell: (r) => <span className="copy-cell"><code title={r.pretty_class}>{r.pretty_class}</code><CopyBtn text={r.pretty_class} /><PivotBtn cls={r.pretty_class} /><OqlBtn cls={r.pretty_class} /><ListObjectsBtn cls={r.pretty_class} /></span>, selector: (r) => r.pretty_class, sortable: true },
{ id: "objects", name: "Objects", right: true, width: "100px", format: (r) => fmtCount(r.objects), selector: (r) => r.objects, sortable: true },
{ id: "shallow", name: useKB ? "Shallow (KB)" : "Shallow", right: true, width: useKB ? "135px" : "110px", cell: byteCell(r => r.shallow, fmtB, useKB), selector: (r) => r.shallow, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "110px", cell: byteCell(r => r.retained ?? 0, fmtB, useKB), selector: (r) => r.retained ?? 0, sortable: true },
];
return (
<>
<StdTable columns={cols} data={rows} searchKeys={["pretty_class"]} fmtBtn={kbBtn} defaultSortFieldId="retained" defaultSortAsc={false} />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: "100px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(rows.reduce((s, r) => s + r.objects, 0))}</span>
<span style={{ width: useKB ? "135px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{(() => { const t = rows.reduce((s, r) => s + r.shallow, 0); return <span title={fmtExactBytes(t)}>{fmtB(t)}</span>; })()}</span>
<span style={{ width: useKB ? "135px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{(() => { const t = rows.reduce((s, r) => s + (r.retained ?? 0), 0); return <span title={fmtExactBytes(t)}>{fmtB(t)}</span>; })()}</span>
</div>
</>
);
}
function ReferencesSection({ data }: { data?: ReferencesAnalysis }) {
const kinds: ReferenceStats[] = [data?.soft, data?.weak, data?.phantom].filter(
(s): s is ReferenceStats => s != null,
);
const kindCaption = (kind: string) => {
switch (kind) {
case "Soft": return "Soft references keep objects alive until the JVM needs memory — cleared under GC pressure. A large soft-referenced heap signals an oversized cache; cap it with a max-entries limit or switch to an explicit bounded cache (e.g. Caffeine).";
case "Weak": return "Weak references let GC claim referents — reachable only via weak chains, reclaimed at any collection. Large counts are usually benign, but a growing count can indicate ThreadLocal leaks or listener registries not deregistering.";
case "Phantom": return "Phantom references track objects in cleanup pipelines for native resource release. A large backlog signals a stalled or overloaded ReferenceQueue processor, or indicates native resources (file handles, off-heap buffers) not being released promptly.";
default: return "";
}
};
return (
<section id="references">
<h2>References</h2>
<p className="subtitle">Soft, weak, and phantom references — referents, retention status, and null-referent counts.</p>
{kinds.length === 0 ? (
<p className="subtitle">No soft, weak, or phantom references found.</p>
) : (
kinds.map((stats) => (
<React.Fragment key={stats.kind}>
<h3>{stats.kind} References</h3>
<p className="subtitle">{kindCaption(stats.kind)}</p>
<p className="subtitle">
{fmtCount(stats.reference_instances)} reference instances.
{stats.null_referent_count != null && stats.null_referent_count > 0 && (
<> {fmtCount(stats.null_referent_count)} {stats.null_referent_count === 1 ? "instance has" : "instances have"} a null referent — referent collected, not yet processed.
{stats.null_referent_count / stats.reference_instances > 0.5 && (
<strong style={{color: 'var(--warn-border)'}}> ⚠ Over 50% null — reference queue processor is likely stalled.</strong>
)}
</>
)}
</p>
<h4>Referent Classes</h4>
<RefClassTable rows={stats.referent_histogram ?? []} />
<h4>{stats.kind === "Soft" ? "Only Softly Retained" : stats.kind === "Weak" ? "Only Weakly Retained" : "Only Phantom-Retained"}</h4>
<p className="subtitle">{
stats.kind === "Soft"
? "Referents reachable only through soft references — no strong path. GC clears these under memory pressure."
: stats.kind === "Weak"
? "Referents reachable only through weak references — no strong or soft path. GC can reclaim them at any collection."
: "Referents reachable only through phantom references — finalized and enqueued for post-mortem cleanup via a ReferenceQueue."
}</p>
{(stats.only_weakly_retained ?? []).length > 0
? <RefClassTable rows={stats.only_weakly_retained} />
: <p className="subtitle">None found — no objects are exclusively reachable via this reference kind.</p>
}
</React.Fragment>
))
)}
</section>
);
}
// ── Dominator Analysis ──────────────────────────────────────────────────────
// Two dominator-tree sub-views: Big Drops (dominators where retained heap
// concentrates) and Immediate Dominators (dominated-object rollup by dominator
// class). Always-on; mirrors render_md.rs::render_dominator_analysis.
// ── Who Holds This Class? (V5) ──────────────────────────────────────────────
// Two-sided Sankey navigator built from immediate_dominators.pairs. Left column
// shows classes that dominate the target; right column shows classes the target
// dominates. Click any side node to pivot the focused class.
interface WhoHoldsSankeyProps {
pairs: ImmDomPair[];
initialTarget: string;
/** When set, the component is controlled from outside: target = externalTarget. */
externalTarget?: string;
/** Called when the user wants to pivot to a new class (internal or external). */
onPivot?: (cls: string) => void;
}
// Display-friendly class name: drop package prefix (keep after last '.').
function shortClass(name: string): string {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot + 1) : name;
}
const KNOWN_COLLECTION_FRAGMENTS = [
"HashMap", "HashSet",
"ArrayList", "LinkedList", "ArrayDeque", "PriorityQueue",
"TreeMap", "TreeSet",
"java.util.Vector", "java.util.Stack",
"scala.collection", "kotlin.collections",
] as const;
const isCollectionClass = (cls: string) =>
KNOWN_COLLECTION_FRAGMENTS.some(f => cls.includes(f));
type SankeyColCount = 3 | 5 | 7;
// side encodes column position: L2/L1 = left hops, C = center, R1/R2 = right hops
type SankeyColSide = "L2" | "L1" | "C" | "R1" | "R2" | "L3" | "R3";
interface WhoHoldsNode {
id: string;
side: SankeyColSide;
cls: string;
// aggregated pair info for popover
totalRetained: number;
totalShallow: number;
pairCount: number;
}
interface WhoHoldsLink {
source: string;
target: string;
value: number;
}
// Aggregate pair stats for a class appearing in the graph
function aggregateStats(pairs: ImmDomPair[], cls: string): { totalRetained: number; totalShallow: number; pairCount: number } {
let totalRetained = 0, totalShallow = 0, pairCount = 0;
for (const p of pairs) {
if (p.dominator_class === cls || p.dominated_class === cls) {
totalRetained += p.dominated_retained;
totalShallow += p.dominated_shallow ?? 0;
pairCount += p.pair_count ?? 1;
}
}
return { totalRetained, totalShallow, pairCount };
}
// Build multi-hop nodes for N-column layout (cols = 3|5|7 → hops = 1|2|3 each side)
function buildSankeyGraph(
pairs: ImmDomPair[],
target: string,
cols: SankeyColCount,
w: number,
height: number,
padding: { top: number; right: number; bottom: number; left: number },
) {
if (w < 20) return null;
const hops = (cols - 1) / 2; // 1 for 3-col, 2 for 5-col, 3 for 7-col
const maxPerHop = hops >= 3 ? 4 : hops >= 2 ? 6 : 8;
const nodeMap = new Map<string, WhoHoldsNode>();
const links: WhoHoldsLink[] = [];
const makeNode = (id: string, side: SankeyColSide, cls: string): WhoHoldsNode => {
const stats = aggregateStats(pairs, cls);
const n: WhoHoldsNode = { id, side, cls, ...stats };
nodeMap.set(id, n);
return n;
};
const centerId = "C:" + target;
makeNode(centerId, "C", target);
// Expand one hop: classes that hold `cls` (left) or are held by `cls` (right)
const expandLeft = (cls: string, prevId: string, side: SankeyColSide) => {
const holders = pairs
.filter((p) => p.dominated_class === cls && p.dominator_class !== cls)
.sort((a, b) => b.dominated_retained - a.dominated_retained)
.slice(0, maxPerHop);
for (const h of holders) {
const id = side + ":" + h.dominator_class;
if (!nodeMap.has(id)) makeNode(id, side, h.dominator_class);
// Avoid duplicate links
if (!links.find(l => l.source === id && l.target === prevId)) {
links.push({ source: id, target: prevId, value: Math.max(1, h.dominated_retained) });
}
}
return holders;
};
const expandRight = (cls: string, prevId: string, side: SankeyColSide) => {
const dominated = pairs
.filter((p) => p.dominator_class === cls && p.dominated_class !== cls)
.sort((a, b) => b.dominated_retained - a.dominated_retained)
.slice(0, maxPerHop);
for (const d of dominated) {
const id = side + ":" + d.dominated_class;
if (!nodeMap.has(id)) makeNode(id, side, d.dominated_class);
if (!links.find(l => l.source === prevId && l.target === id)) {
links.push({ source: prevId, target: id, value: Math.max(1, d.dominated_retained) });
}
}
return dominated;
};
const hop1L = expandLeft(target, centerId, "L1");
const hop1R = expandRight(target, centerId, "R1");
if (hops >= 2) {
for (const h of hop1L) {
const prevId = "L1:" + h.dominator_class;
expandLeft(h.dominator_class, prevId, "L2");
}
for (const d of hop1R) {
const prevId = "R1:" + d.dominated_class;
expandRight(d.dominated_class, prevId, "R2");
}
}
if (hops >= 3) {
const l2nodes = Array.from(nodeMap.values()).filter(n => n.side === "L2");
for (const n of l2nodes) {
expandLeft(n.cls, n.id, "L3");
}
const r2nodes = Array.from(nodeMap.values()).filter(n => n.side === "R2");
for (const n of r2nodes) {
expandRight(n.cls, n.id, "R3");
}
}
if (links.length === 0) return null;
const nodes = Array.from(nodeMap.values()).map(n => ({ ...n }));
try {
const layout = sankey<WhoHoldsNode, WhoHoldsLink>()
.nodeId((n) => n.id)
.nodeWidth(12)
.nodePadding(hops >= 3 ? 4 : hops >= 2 ? 6 : 8)
.extent([[padding.left, padding.top], [w - padding.right, height - padding.bottom]]);
return layout({ nodes, links });
} catch {
return null;
}
}
// Popover shown on node hover
interface NodePopover {
nodeId: string;
x: number;
y: number;
}
function WhoHoldsSankey({ pairs, initialTarget, externalTarget, onPivot }: WhoHoldsSankeyProps) {
const [target, setTarget] = React.useState(externalTarget ?? initialTarget);
const [history, setHistory] = React.useState<string[]>([]);
const [search, setSearch] = React.useState("");
const ref = React.useRef<HTMLDivElement>(null);
const [w, setW] = React.useState(600);
const [svgHeight, setSvgHeight] = React.useState(220);
const [cols, setCols] = React.useState<SankeyColCount>(3);
const [fullscreen, setFullscreen] = React.useState(false);
const [popover, setPopover] = React.useState<NodePopover | null>(null);
const [showChain, setShowChain] = React.useState(true);
// When a table row drives an external pivot, reset history and jump to it.
const prevExternal = React.useRef(externalTarget);
React.useEffect(() => {
if (externalTarget && externalTarget !== prevExternal.current) {
setHistory([]);
setTarget(externalTarget);
}
prevExternal.current = externalTarget;
}, [externalTarget]);
React.useLayoutEffect(() => {
if (!ref.current) return;
const ro = new ResizeObserver((entries) => {
const bw = entries[0]?.contentRect.width;
if (bw && bw > 0) setW(Math.floor(bw));
});
ro.observe(ref.current);
return () => ro.disconnect();
}, []);
// Esc closes fullscreen
React.useEffect(() => {
if (!fullscreen) return;
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") setFullscreen(false); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [fullscreen]);
const classOptions = React.useMemo(() => {
const seen = new Set<string>();
for (const p of pairs) { seen.add(p.dominator_class); seen.add(p.dominated_class); }
return Array.from(seen).sort();
}, [pairs]);
const filtered = search.length >= 2
? classOptions.filter((c) => c.toLowerCase().includes(search.toLowerCase())).slice(0, 10)
: [];
const pivot = React.useCallback((cls: string) => {
if (cls === target) return;
setHistory((h) => [...h, target]);
setTarget(cls);
setPopover(null);
// Don't call onPivot — internal SVG navigation stays internal.
}, [target]);
const selectClass = React.useCallback((cls: string) => {
setHistory([]);
setTarget(cls);
onPivot?.(cls);
setSearch("");
}, [onPivot]);
const goToHistory = React.useCallback((i: number) => {
setTarget(history[i]);
onPivot?.(history[i]);
setHistory((h) => h.slice(0, i));
}, [history, onPivot]);
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
const graph = React.useMemo(
() => buildSankeyGraph(pairs, target, cols, w, svgHeight, padding),
[pairs, target, cols, w, svgHeight],
);
const nodeColor = (side: SankeyColSide) => {
if (side === "C") return "var(--warn-border)";
if (side === "L1" || side === "L2" || side === "L3") return "var(--accent)";
return "var(--ok, #27ae60)";
};
// Drag-to-resize handle
const dragStartY = React.useRef<number | null>(null);
const dragStartH = React.useRef(svgHeight);
const onDragStart = React.useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragStartY.current = e.clientY;
dragStartH.current = svgHeight;
const onMove = (mv: MouseEvent) => {
if (dragStartY.current === null) return;
const delta = mv.clientY - dragStartY.current;
setSvgHeight(Math.max(120, Math.min(800, dragStartH.current + delta)));
};
const onUp = () => {
dragStartY.current = null;
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}, [svgHeight]);
// Find node by id for popover lookup
const nodeById = React.useMemo(() => {
if (!graph) return new Map<string, WhoHoldsNode & { x0?: number; x1?: number; y0?: number; y1?: number }>();
const m = new Map<string, WhoHoldsNode & { x0?: number; x1?: number; y0?: number; y1?: number }>();
for (const n of graph.nodes) m.set(n.id, n);
return m;
}, [graph]);
const popoverNode = popover ? nodeById.get(popover.nodeId) : null;
const chainToRoot = React.useMemo(() => {
const chain: string[] = [target];
const seen = new Set<string>([target]);
let cur = target;
for (let i = 0; i < 50; i++) {
const p = pairs.find(p => p.dominated_class === cur);
if (!p || seen.has(p.dominator_class)) break;
chain.push(p.dominator_class);
seen.add(p.dominator_class);
cur = p.dominator_class;
}
return chain; // [target, ..., root]
}, [target, pairs]);
const toolbar = (
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap", marginBottom: "0.5rem" }}>
{/* Column selector */}
<span style={{ fontSize: "0.82rem", color: "var(--muted)" }} title="Number of columns — 3 = 1 hop each side, 5 = 2 hops each side, 7 = 3 hops. More hops reveal deeper dominance chains but add clutter.">Columns:</span>
{([3, 5, 7] as SankeyColCount[]).map(c => (
<button
key={c}
onClick={() => setCols(c)}
style={{
padding: "0.15rem 0.55rem", fontSize: "0.82rem", border: "1px solid var(--border)",
borderRadius: 4, cursor: "pointer",
background: cols === c ? "var(--accent)" : "transparent",
color: cols === c ? "#fff" : "var(--fg)",
}}
>{c}</button>
))}
<span style={{ flex: 1 }} />
{/* Chain-to-root toggle */}
{!showChain && chainToRoot.length > 1 && (
<button className="show-more-btn"
onClick={() => setShowChain(true)}
title="Show dominator chain to root">Chain ▸</button>
)}
{/* Fullscreen button */}
<button
onClick={() => setFullscreen(f => !f)}
title={fullscreen ? "Exit fullscreen (Esc)" : "Expand fullscreen"}
style={{ padding: "0.2rem 0.5rem", fontSize: "0.85rem", border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer", background: "transparent", color: "var(--fg)" }}
>{fullscreen ? "⛶ Exit" : "⛶ Fullscreen"}</button>
</div>
);
const searchBox = (
<div style={{ position: "relative", maxWidth: 420, marginBottom: "0.75rem" }}>
<input
type="text"
value={search}
placeholder="Search class name to navigate Sankey…"
onChange={(e) => setSearch(e.target.value)}
style={{ width: "100%", boxSizing: "border-box", padding: "0.4rem 0.6rem", fontSize: "0.9rem", border: "1px solid var(--border)", borderRadius: 6, background: "var(--bg)", color: "var(--fg)" }}
/>
{filtered.length > 0 && (
<ul style={{ position: "absolute", zIndex: 10, listStyle: "none", margin: "2px 0 0", padding: 0, width: "100%", maxHeight: 240, overflowY: "auto", border: "1px solid var(--border)", borderRadius: 6, background: "var(--card-bg, var(--bg))", boxShadow: "0 4px 12px rgba(0,0,0,0.15)" }}>
{filtered.map((c) => (
<li key={c}>
<button
onClick={() => selectClass(c)}
style={{ display: "block", width: "100%", textAlign: "left", padding: "0.35rem 0.6rem", border: "none", background: "transparent", color: "var(--fg)", cursor: "pointer", fontSize: "0.85rem", fontFamily: "var(--mono, monospace)" }}
>{c}</button>
</li>
))}
</ul>
)}
</div>
);
const breadcrumb = (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "0.25rem", marginBottom: "0.5rem", fontSize: "0.85rem" }}>
{history.map((h, i) => (
<React.Fragment key={`${h}-${i}`}>
<button
onClick={() => goToHistory(i)}
title={h}
style={{ border: "none", background: "transparent", color: "var(--accent)", cursor: "pointer", padding: 0, fontSize: "0.85rem", textDecoration: "underline" }}
>{shortClass(h)}</button>
<span style={{ color: "var(--muted)" }}>›</span>
</React.Fragment>
))}
<span className="copy-cell">
<span title={target} style={{ fontWeight: 600, fontFamily: "var(--mono, monospace)" }}>{shortClass(target)}</span>
<CopyBtn text={target} />
<OqlBtn cls={target} />
<ListObjectsBtn cls={target} />
</span>
<button className="show-more-btn" style={{ fontSize: "0.82rem", padding: "1px 6px", flexShrink: 0 }}
onClick={() => fireInspect({ kind: "class", cls: target })}>
In Inspector →
</button>
</div>
);
const svgEl = (
<div ref={ref} style={{ width: "100%", position: "relative" }}>
{graph === null ? (
<p className="subtitle">No dominator pair data for <code>{shortClass(target)}</code>. This class may not dominate or be dominated by any other — search for a class with entries in the Immediate Dominators table above.</p>
) : (
<>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem", color: "var(--muted)", marginBottom: "2px", paddingLeft: 2, paddingRight: 2 }}>
<span title="Classes that keep the selected class alive — drop one to free it">← Dominators (hold it)</span>
<span style={{ fontWeight: 600, color: "var(--fg)" }}>{shortClass(target)}</span>
<span title="Classes kept alive by the selected class — bytes freed when it becomes unreachable">Dominated (held) →</span>
</div>
<svg
width={w} height={svgHeight}
role="img" aria-label={`Who holds ${target}`}
style={{ overflow: "hidden" }}
>
{/* Links */}
{graph.links.map((link, i) => (
<path
key={`link-${i}`}
d={sankeyLinkHorizontal()(link) ?? undefined}
fill="none"
stroke="var(--muted)"
strokeWidth={Math.max(1, link.width ?? 1)}
opacity={0.4}
/>
))}
{/* Nodes */}
{graph.nodes.map((n) => {
const x0 = n.x0 ?? 0;
const x1 = n.x1 ?? 0;
const y0 = n.y0 ?? 0;
const y1 = n.y1 ?? 0;
const midY = (y0 + y1) / 2;
const clickable = n.side !== "C";
const isPopoverOpen = popover?.nodeId === n.id;
let labelEl: React.ReactNode = null;
const isLeft = n.side === "L1" || n.side === "L2" || n.side === "L3";
const isRight = n.side === "R1" || n.side === "R2" || n.side === "R3";
if (isLeft) {
labelEl = <text x={x1 + 4} y={midY} dy="0.35em" textAnchor="start" fontSize={11} fill="var(--fg)">{shortClass(n.cls)}</text>;
} else if (isRight) {
labelEl = <text x={x0 - 4} y={midY} dy="0.35em" textAnchor="end" fontSize={11} fill="var(--fg)">{shortClass(n.cls)}</text>;
}
// no label for the center node — the header row already shows it
return (
<g key={n.id}>
<rect
x={x0} y={y0}
width={Math.max(1, x1 - x0)}
height={Math.max(1, y1 - y0)}
fill={nodeColor(n.side)}
stroke={isPopoverOpen ? "var(--fg)" : "none"}
strokeWidth={1}
style={{ cursor: clickable ? "pointer" : "default" }}
onClick={(e) => {
e.stopPropagation();
if (clickable) pivot(n.cls);
}}
onMouseEnter={(e) => {
setPopover({ nodeId: n.id, x: e.clientX, y: e.clientY });
}}
onMouseLeave={() => setPopover(null)}
/>
{labelEl}
</g>
);
})}
</svg>
{/* Resize handle */}
<div
onMouseDown={onDragStart}
style={{
height: 6, cursor: "ns-resize", background: "var(--border)", borderRadius: 3,
marginTop: 2, opacity: 0.6,
display: "flex", alignItems: "center", justifyContent: "center",
}}
title="Drag to resize"
>
<span style={{ fontSize: 8, color: "var(--muted)", letterSpacing: 2, userSelect: "none" }}>⠿</span>
</div>
</>
)}
</div>
);
// Popover rendering (fixed position so it works in both normal and fullscreen)
const popoverEl = popoverNode ? (
<div
style={{
position: "fixed",
left: Math.min(popover!.x + 8, window.innerWidth - 280),
top: Math.min(popover!.y + 8, window.innerHeight - 160),
zIndex: 99999,
background: "var(--card-bg, var(--bg))",
border: "1px solid var(--border)",
borderRadius: 8,
boxShadow: "0 4px 16px rgba(0,0,0,0.2)",
padding: "0.6rem 0.8rem",
minWidth: 240,
maxWidth: 320,
fontSize: "0.82rem",
pointerEvents: "none",
}}
>
<div style={{ fontFamily: "var(--mono, monospace)", fontSize: "0.78rem", color: "var(--fg)", wordBreak: "break-all", marginBottom: "0.4rem", fontWeight: 600 }}>
{popoverNode.cls}
</div>
<div style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "0.15rem 0.6rem", color: "var(--fg)" }}>
<span style={{ color: "var(--muted)" }}>Pairs:</span><span>{fmtCount(popoverNode.pairCount)}</span>
<span style={{ color: "var(--muted)" }}>Total retained:</span><span>{fmtExactBytes(popoverNode.totalRetained)}</span>
{popoverNode.totalShallow > 0 && (
<><span style={{ color: "var(--muted)" }}>Total shallow:</span><span>{fmtExactBytes(popoverNode.totalShallow)}</span></>
)}
<span style={{ color: "var(--muted)" }}>Role:</span><span>
{popoverNode.side === "C" ? "Focus Class" : (popoverNode.side.startsWith("L") ? "Dominates (Holds)" : "Dominated (Held)")}
</span>
</div>
{popoverNode.side !== "C" && (
<div style={{ marginTop: "0.4rem", fontSize: "0.75rem", color: "var(--muted)" }}>Click to focus →</div>
)}
</div>
) : null;
const chainPanel = showChain && chainToRoot.length > 1 ? (
<div className="sankey-chain-panel">
<div className="sankey-chain-header">
Chain to root
<button className="show-more-btn" style={{ float: "right", fontSize: "0.75rem", padding: "0 4px" }}
onClick={() => setShowChain(false)}>✕</button>
</div>
{chainToRoot.map((cls, i) => (
<div key={cls}
className={"sankey-chain-step" + (i === 0 ? " sankey-chain-current" : "")}
title={cls}
onClick={i > 0 ? () => { setHistory([]); setTarget(cls); onPivot?.(cls); } : undefined}
style={{ cursor: i > 0 ? "pointer" : "default" }}>
{i === 0 ? "●" : i === chainToRoot.length - 1 ? "⌖" : "┃"} {shortClass(cls)}
</div>
))}
</div>
) : null;
const inner = (
<>
{toolbar}
{searchBox}
{breadcrumb}
<div style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}>
<div style={{ flex: 1, minWidth: 0 }}>{svgEl}</div>
{chainPanel}
</div>
</>
);
if (fullscreen) {
return (
<>
<div style={{ position: "fixed", inset: 0, zIndex: 9998, background: "var(--bg)", display: "flex", flexDirection: "column", padding: "1rem", overflow: "auto" }}>
{inner}
</div>
{popoverEl}
</>
);
}
return (
<>
<div>{inner}</div>
{popoverEl}
</>
);
}
function DomGraphView({ pairs, idoms }: {
pairs: ImmDomPair[];
idoms: import("./types").ImmediateDominatorRow[];
}) {
const [fmtB] = useFmtBytes();
const totalHeap = React.useMemo(() => pairs.reduce((s, p) => s + p.dominated_retained, 0), [pairs]);
const [layoutKey, setLayoutKey] = React.useState(0);
const [selected, setSelected] = React.useState<string | null>(null);
const [focusMode, setFocusMode] = React.useState<"all" | "up" | "down">("all");
const [layoutMode, setLayoutMode] = React.useState<"force" | "tree">("force");
const [showPct, setShowPct] = React.useState(false);
const [showEdgeLabels, setShowEdgeLabels] = React.useState(false);
const [colorMode, setColorMode] = React.useState<"package" | "pct" | "bpi" | "depth">("package");
const [search, setSearch] = React.useState("");
const [pathFrom, setPathFrom] = React.useState("");
const [pathTo, setPathTo] = React.useState("");
const [pathResult, setPathResult] = React.useState<string[] | null>(null);
const [pathOpen, setPathOpen] = React.useState(false);
// Extra nodes injected by "Expand context" for isolated nodes
const [extraClasses, setExtraClasses] = React.useState<Set<string>>(new Set());
const cyContainerRef = React.useRef<HTMLDivElement>(null);
const cyRef = React.useRef<cytoscape.Core | null>(null)
// Build retained map per class (sum dominated_retained for each dominator_class)
const retMap = React.useMemo(() => {
const m = new Map<string, number>();
for (const p of pairs) {
m.set(p.dominator_class, (m.get(p.dominator_class) ?? 0) + p.dominated_retained);
if (!m.has(p.dominated_class)) m.set(p.dominated_class, 0);
}
for (const r of idoms) {
if (!m.has(r.dominator_class)) m.set(r.dominator_class, 0);
}
return m;
}, [pairs, idoms]);
// Retained bytes per dominator→dominated edge
const topN = 50;
const { fdNodes, fdEdges } = React.useMemo(() => {
const sorted = [...retMap.entries()].sort((a, b) => b[1] - a[1]);
// Always include top-N by retained, plus any extra classes from "Expand context"
const topSet = new Set(sorted.slice(0, topN).map(([cls]) => cls));
for (const cls of extraClasses) topSet.add(cls);
const included = sorted.filter(([cls]) => topSet.has(cls));
const maxRet = Math.max(...included.map(([, r]) => r), 1);
const fdNodes = included.map(([cls, ret]): { id: string; r: number; ret: number } => ({
id: cls,
r: Math.max(8, Math.min(28, 8 + 20 * Math.sqrt(ret / maxRet))),
ret,
}));
const nodeIds = new Set(fdNodes.map(n => n.id));
const fdEdges = pairs
.filter(p => nodeIds.has(p.dominator_class) && nodeIds.has(p.dominated_class) && p.dominator_class !== p.dominated_class)
.map(p => ({ src: p.dominator_class, dst: p.dominated_class, retained: p.dominated_retained }));
return { fdNodes, fdEdges };
}, [retMap, pairs, extraClasses]); // eslint-disable-line react-hooks/exhaustive-deps
// Build adjacency for ancestor/subtree traversal (class-level graph)
const { parentsOf, childrenOf } = React.useMemo(() => {
const parentsOf = new Map<string, string[]>();
const childrenOf = new Map<string, string[]>();
for (const e of fdEdges) {
if (!parentsOf.has(e.dst)) parentsOf.set(e.dst, []);
parentsOf.get(e.dst)!.push(e.src);
if (!childrenOf.has(e.src)) childrenOf.set(e.src, []);
childrenOf.get(e.src)!.push(e.dst);
}
return { parentsOf, childrenOf };
}, [fdEdges]);
// Instance count per class (from idoms rows, for B/instance overlay)
const instanceCountMap = React.useMemo(
() => new Map(idoms.map(r => [r.dominator_class, r.dominator_count])),
[idoms]
);
// BFS depth from graph roots (nodes with no incoming edge) — for depth overlay
const depthMap = React.useMemo(() => {
const hasDst = new Set(fdEdges.map(e => e.dst));
const roots = fdNodes.map(n => n.id).filter(id => !hasDst.has(id));
const dm = new Map<string, number>();
const queue: Array<{ id: string; d: number }> = roots.map(id => ({ id, d: 0 }));
for (const { id, d } of queue) {
if (dm.has(id)) continue;
dm.set(id, d);
for (const e of fdEdges) {
if (e.src === id && !dm.has(e.dst)) queue.push({ id: e.dst, d: d + 1 });
}
}
return dm;
}, [fdEdges, fdNodes]);
// Blame path: greedy max-retained-child walk from each root
const blamePath = React.useMemo((): Set<string> => {
const hasDst = new Set(fdEdges.map(e => e.dst));
const roots = fdNodes.map(n => n.id).filter(id => !hasDst.has(id));
const visited = new Set<string>();
for (const root of roots) {
visited.add(root);
let cur = root;
for (let i = 0; i < 30; i++) {
const children = fdEdges.filter(e => e.src === cur);
if (!children.length) break;
const best = children.reduce((a, b) => b.retained > a.retained ? b : a);
if (visited.has(best.dst)) break;
visited.add(best.dst);
cur = best.dst;
}
}
return visited;
}, [fdEdges, fdNodes]);
// Compute node color based on colorMode
const computeNodeColor = React.useCallback((id: string, ret: number): string => {
if (colorMode === "pct") return heatColor(totalHeap > 0 ? (ret / totalHeap) / 0.25 : 0);
if (colorMode === "bpi") {
const count = instanceCountMap.get(id) ?? 1;
const bpi = count > 0 ? ret / count : 0;
const bpis = fdNodes.map(n => {
const c = instanceCountMap.get(n.id) ?? 1;
return c > 0 ? n.ret / c : 0;
}).sort((a, b) => a - b);
const p95 = bpis[Math.floor(bpis.length * 0.95)] ?? 1;
return heatColor(p95 > 0 ? bpi / p95 : 0);
}
if (colorMode === "depth") {
const maxDepth = Math.max(...[...depthMap.values()], 1);
return heatColor((depthMap.get(id) ?? 0) / maxDepth);
}
return tpfgColor(id);
}, [colorMode, totalHeap, instanceCountMap, depthMap, fdNodes]); // eslint-disable-line react-hooks/exhaustive-deps
// Walk ancestors (BFS upward) from a node
const getAncestors = React.useCallback((id: string): Set<string> => {
const visited = new Set<string>();
const queue = [id];
while (queue.length) {
const cur = queue.shift()!;
for (const p of (parentsOf.get(cur) ?? [])) {
if (!visited.has(p)) { visited.add(p); queue.push(p); }
}
}
return visited;
}, [parentsOf]);
// Walk subtree (BFS downward) from a node
const getSubtree = React.useCallback((id: string): Set<string> => {
const visited = new Set<string>();
const queue = [id];
while (queue.length) {
const cur = queue.shift()!;
for (const c of (childrenOf.get(cur) ?? [])) {
if (!visited.has(c)) { visited.add(c); queue.push(c); }
}
}
return visited;
}, [childrenOf]);
// Ancestry breadcrumb: shortest path from any root (no incoming edges) to selected
const ancestorChain = React.useMemo((): string[] => {
if (!selected) return [];
// BFS from selected upward, build path
const prev = new Map<string, string>();
const queue = [selected];
const visited = new Set([selected]);
let root: string | null = null;
while (queue.length) {
const cur = queue.shift()!;
const parents = parentsOf.get(cur) ?? [];
if (parents.length === 0) { root = cur; break; }
for (const p of parents) {
if (!visited.has(p)) { visited.add(p); prev.set(p, cur); queue.push(p); }
}
}
if (!root || root === selected) return [selected];
const chain: string[] = [];
let cur: string | null = root;
while (cur) { chain.push(cur); cur = prev.get(cur) ?? null; }
if (chain[chain.length - 1] !== selected) chain.push(selected);
return chain;
}, [selected, parentsOf]);
React.useEffect(() => {
if (!cyContainerRef.current) return;
void layoutKey; void layoutMode;
const maxEdgeRet = Math.max(...fdEdges.map(e => e.retained), 1);
const elements: cytoscape.ElementDefinition[] = [
...fdNodes.map(n => ({
data: {
id: n.id,
label: n.id.split(".").pop() ?? n.id,
pctLabel: totalHeap > 0 ? fmtPct(n.ret / totalHeap * 100) : "",
size: n.r * 2,
color: computeNodeColor(n.id, n.ret),
retained: n.ret,
},
})),
...fdEdges.map((e, i) => ({
data: {
id: `e${i}`, source: e.src, target: e.dst,
weight: Math.max(1, 1 + 3 * Math.sqrt(e.retained / maxEdgeRet)),
retained: e.retained,
retLabel: fmtB(e.retained),
},
})),
];
const layout = layoutMode === "tree"
? { name: "breadthfirst", directed: true, spacingFactor: 1.4, padding: 24, fit: true } as any
: { name: "cose-bilkent", animate: false, nodeDimensionsIncludeLabels: true, idealEdgeLength: 100, nodeRepulsion: 8000, padding: 24 } as any;
const cy = cytoscape({
container: cyContainerRef.current,
elements,
style: buildDomGraphStyle(),
layout,
wheelSensitivity: 0.3,
minZoom: 0.05,
maxZoom: 5,
});
cy.on("tap", "node", evt => {
const id = evt.target.data("id") as string;
setSelected(prev => {
const next = prev === id ? null : id;
applyCyHighlight(cy, next);
return next;
});
});
cy.on("tap", evt => {
if (evt.target === cy) { setSelected(null); applyCyHighlight(cy, null); }
});
cyRef.current?.destroy();
cyRef.current = cy;
const detachCtrlZoom = attachCtrlZoom(cy, cyContainerRef.current!);
return () => { detachCtrlZoom(); cy.destroy(); cyRef.current = null; };
}, [fdNodes, fdEdges, layoutKey, layoutMode, computeNodeColor]); // eslint-disable-line react-hooks/exhaustive-deps
// Live recolor nodes when colorMode changes (without remounting graph)
React.useEffect(() => {
const cy = cyRef.current;
if (!cy) return;
cy.nodes().forEach(n => {
n.style("background-color", computeNodeColor(n.data("id") as string, n.data("retained") as number));
});
}, [colorMode, computeNodeColor]);
// Toggle % labels on nodes
React.useEffect(() => {
const cy = cyRef.current;
if (!cy) return;
cy.nodes().forEach(n => {
const base = n.data("label") as string;
const pct = n.data("pctLabel") as string;
n.style("label", showPct && pct ? `${base}\n${pct}` : base);
});
}, [showPct]);
// Toggle edge retained labels
React.useEffect(() => {
const cy = cyRef.current;
if (!cy) return;
cy.edges().forEach(e => {
e.style("label", showEdgeLabels ? (e.data("retLabel") as string ?? "") : "");
});
}, [showEdgeLabels]);
// Apply focus mode: dim nodes outside ancestors/subtree of selected, or outside blame path
React.useEffect(() => {
const cy = cyRef.current;
if (!cy) return;
cy.elements().removeStyle("opacity");
if (focusMode === "blame") {
cy.nodes().forEach(n => {
if (!blamePath.has(n.data("id") as string)) n.style("opacity", 0.08);
});
cy.edges().forEach(e => {
const src = e.source().data("id") as string;
const dst = e.target().data("id") as string;
if (!blamePath.has(src) || !blamePath.has(dst)) e.style("opacity", 0.04);
});
return;
}
if (!selected || focusMode === "all") return;
const related = focusMode === "up" ? getAncestors(selected) : getSubtree(selected);
related.add(selected);
cy.nodes().forEach(n => {
if (!related.has(n.data("id") as string)) n.style("opacity", 0.08);
});
cy.edges().forEach(e => {
const src = e.source().data("id") as string;
const dst = e.target().data("id") as string;
if (!related.has(src) || !related.has(dst)) e.style("opacity", 0.04);
});
}, [selected, focusMode, blamePath, getAncestors, getSubtree]);
if (pairs.length === 0) {
return <p className="subtitle">No dominator pair data.</p>;
}
const btnStyle = (active: boolean) => ({
padding: "0.15rem 0.55rem", fontSize: "0.82rem",
border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer",
background: active ? "var(--accent)" : "transparent",
color: active ? "#fff" : "var(--fg)",
});
const divider = <span style={{ width: 1, height: 16, background: "var(--border)", display: "inline-block", margin: "0 2px" }} />;
// Jump-to search: highlight matching nodes, center on first match
const handleSearch = React.useCallback((q: string) => {
setSearch(q);
const cy = cyRef.current;
if (!cy) return;
if (!q) { cy.elements().removeStyle("opacity"); return; }
const lc = q.toLowerCase();
const matches = cy.nodes().filter(n => (n.data("id") as string).toLowerCase().includes(lc));
if (matches.length === 0) return;
cy.elements().style("opacity", 0.1);
matches.style("opacity", 1);
cy.center(matches[0]);
}, []);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<p className="subtitle" style={{ margin: "0 0 0.25rem" }}>
Class-level dominator graph — each node is a class, each edge means the source dominates the target. Node size reflects retained heap. Use <strong>▲ Retained by</strong> / <strong>▼ Retains</strong> to focus on a selected class's ancestors or subtree.
</p>
{/* Toolbar row 1: layout + focus + label controls */}
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", flexWrap: "wrap" }}>
<button onClick={() => { setLayoutKey(k => k + 1); setSelected(null); }}
style={btnStyle(false)} title="Re-run layout">↺</button>
<button onClick={() => cyRef.current?.fit(undefined, 24)}
style={btnStyle(false)} title="Fit to view">⊡</button>
{divider}
<button onClick={() => setLayoutMode("force")} style={btnStyle(layoutMode === "force")} title="Force layout">Force</button>
<button onClick={() => setLayoutMode("tree")} style={btnStyle(layoutMode === "tree")} title="Tree layout (dominators at top)">Tree</button>
{divider}
<button onClick={() => setFocusMode("all")} style={btnStyle(focusMode === "all")} title="Show all nodes">All</button>
<button onClick={() => setFocusMode("up")} style={btnStyle(focusMode === "up")} title="Show only ancestors of selected node (what retains it)">▲ Retained by</button>
<button onClick={() => setFocusMode("down")} style={btnStyle(focusMode === "down")} title="Show only subtree of selected node (what it retains)">▼ Retains</button>
<button onClick={() => setFocusMode(m => m === "blame" ? "all" : "blame")} style={btnStyle(focusMode === "blame")} title="Show only the critical max-retained path from each GC root">📌 Blame</button>
{divider}
<button onClick={() => setShowPct(v => !v)} style={btnStyle(showPct)} title="Overlay % of total heap on each node">% Labels</button>
<button onClick={() => setShowEdgeLabels(v => !v)} style={btnStyle(showEdgeLabels)} title="Show retained bytes on each edge">Edge Labels</button>
{divider}
<label style={{ fontSize: "0.82rem", color: "var(--muted)", display: "flex", alignItems: "center", gap: "0.3rem" }}>
Color:
<select value={colorMode} onChange={e => setColorMode(e.target.value as typeof colorMode)}
style={{ fontSize: "0.82rem", padding: "0.1rem 0.3rem", border: "1px solid var(--border)", borderRadius: 4, background: "var(--bg)", color: "var(--fg)", cursor: "pointer" }}>
<option value="package">Package</option>
<option value="pct">% Heap</option>
<option value="bpi">B/instance</option>
<option value="depth">Depth</option>
</select>
</label>
<span style={{ fontSize: "0.82rem", color: "var(--muted)", marginLeft: "auto" }}>
{fdNodes.length} classes · {fdEdges.length} edges
</span>
</div>
{/* Toolbar row 2: search */}
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<input
type="search"
placeholder="Jump to class…"
value={search}
onChange={e => handleSearch(e.target.value)}
style={{ flex: "0 0 220px", fontSize: "0.82rem", padding: "0.15rem 0.4rem", border: "1px solid var(--border)", borderRadius: 4, background: "var(--bg)", color: "var(--fg)" }}
/>
{search && (
<button onClick={() => handleSearch("")} style={btnStyle(false)} title="Clear search">✕</button>
)}
<button
style={{ ...btnStyle(pathOpen), marginLeft: "auto", fontSize: "0.78rem" }}
onClick={() => setPathOpen(v => !v)}
title="Find shortest retention path between two classes">
🔍 Find Path
</button>
</div>
{/* Find Path panel */}
{pathOpen && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem", alignItems: "center", padding: "0.5rem", background: "var(--code-bg, rgba(0,0,0,0.04))", borderRadius: 6, fontSize: "0.82rem" }}>
<input
list="path-from-list"
placeholder="From class…"
value={pathFrom}
onChange={e => setPathFrom(e.target.value)}
style={{ flex: "1 1 160px", fontSize: "0.82rem", padding: "0.15rem 0.4rem", border: "1px solid var(--border)", borderRadius: 4, background: "var(--bg)", color: "var(--fg)" }}
/>
<datalist id="path-from-list">
{fdNodes.slice(0, 50).map(n => <option key={n.id} value={n.id} />)}
</datalist>
<span style={{ color: "var(--muted)" }}>→</span>
<input
list="path-to-list"
placeholder="To class…"
value={pathTo}
onChange={e => setPathTo(e.target.value)}
style={{ flex: "1 1 160px", fontSize: "0.82rem", padding: "0.15rem 0.4rem", border: "1px solid var(--border)", borderRadius: 4, background: "var(--bg)", color: "var(--fg)" }}
/>
<datalist id="path-to-list">
{fdNodes.slice(0, 50).map(n => <option key={n.id} value={n.id} />)}
</datalist>
<button style={btnStyle(false)} onClick={() => {
// BFS over fdEdges from pathFrom to pathTo
if (!pathFrom || !pathTo) return;
const prev = new Map<string, string | null>();
prev.set(pathFrom, null);
const queue = [pathFrom];
let found = false;
outer: while (queue.length) {
const cur = queue.shift()!;
for (const e of fdEdges) {
if (e.src === cur && !prev.has(e.dst)) {
prev.set(e.dst, cur);
if (e.dst === pathTo) { found = true; break outer; }
queue.push(e.dst);
}
}
}
if (!found) { setPathResult([]); return; }
const path: string[] = [];
let cur: string | null = pathTo;
while (cur != null) { path.unshift(cur); cur = prev.get(cur) ?? null; }
setPathResult(path);
// Highlight path in graph
const cy = cyRef.current;
if (cy) {
cy.elements().removeClass("path-highlight");
for (let i = 0; i < path.length; i++) {
cy.getElementById(path[i]).addClass("path-highlight");
if (i > 0) {
cy.edges(`[source="${path[i-1]}"][target="${path[i]}"]`).addClass("path-highlight");
}
}
}
}}>Find</button>
{pathResult !== null && pathResult.length === 0 && (
<span style={{ color: "var(--muted)" }}>No path found — both classes must be in the displayed graph. Try expanding context or increasing the node cap above.</span>
)}
{pathResult && pathResult.length > 0 && (
<div style={{ width: "100%", display: "flex", flexWrap: "wrap", gap: "2px 4px", alignItems: "center", paddingTop: "0.25rem" }}>
<span style={{ color: "var(--muted)", fontSize: "0.75rem" }}>{pathResult.length - 1} hops:</span>
{pathResult.map((cls, i) => (
<React.Fragment key={cls}>
{i > 0 && <span style={{ color: "var(--muted)" }}>→</span>}
<button className="trg-link-btn" style={{ fontSize: "0.78rem" }}
title={cls}
onClick={() => { setSelected(cls); const cy = cyRef.current; if (cy) applyCyHighlight(cy, cls); }}>
<code>{cls.split(".").pop()}</code>
</button>
{retMap.has(cls) && <span title={fmtExactBytes(retMap.get(cls)!)} style={{ color: "var(--muted)", fontSize: "0.70rem" }}>{fmtB(retMap.get(cls)!)}</span>}
</React.Fragment>
))}
</div>
)}
</div>
)}
{/* Graph canvas */}
{fdEdges.length === 0 && (
<p className="subtitle" style={{ color: "var(--muted)" }}>No edges to display — the filtered set of classes has no direct dominator relationships. Try increasing the node cap or clearing any active filter.</p>
)}
<div className="cy-graph-container" ref={cyContainerRef} />
{/* Heat legend */}
{colorMode !== "package" && (
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "0.78rem", color: "var(--muted)" }}>
<span style={{ width: 12, height: 12, borderRadius: 2, background: heatColor(0), display: "inline-block" }} /> low
<span style={{ width: 12, height: 12, borderRadius: 2, background: heatColor(0.5), display: "inline-block" }} /> mid
<span style={{ width: 12, height: 12, borderRadius: 2, background: heatColor(1), display: "inline-block" }} /> high
<span style={{ color: "var(--muted)", marginLeft: 4 }}>
{colorMode === "pct" && "— % of total heap"}
{colorMode === "bpi" && "— retained ÷ instance count"}
{colorMode === "depth" && "— hops from GC root"}
</span>
</div>
)}
{/* Blame mode annotation */}
{focusMode === "blame" && blamePath.size > 0 && (
<div style={{ fontSize: "0.78rem", color: "var(--muted)", padding: "0.2rem 0" }}>
📌 Critical retention path · {blamePath.size} classes ·{" "}
{totalHeap > 0
? `${fmtPct([...blamePath].reduce((s, id) => s + (retMap.get(id) ?? 0), 0) / totalHeap * 100)} of heap`
: ""}
</div>
)}
{/* Ancestry breadcrumb */}
{selected && ancestorChain.length > 1 && (
<div style={{ display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap", fontSize: "0.78rem", padding: "0.3rem 0.5rem", background: "var(--card)", border: "1px solid var(--border)", borderRadius: 6 }}>
<span style={{ color: "var(--muted)", flexShrink: 0 }}>▲ via:</span>
{ancestorChain.map((cls, i) => (
<React.Fragment key={cls}>
{i > 0 && <span style={{ color: "var(--muted)" }}>→</span>}
<button
className="trg-link-btn"
style={cls === selected ? { fontWeight: 700 } : undefined}
title={cls}
onClick={() => { setSelected(cls); const cy = cyRef.current; if (cy) { applyCyHighlight(cy, cls); cy.getElementById(cls).select(); } }}
>
{cls.split(".").pop()}
</button>
{retMap.has(cls) && (
<span title={fmtExactBytes(retMap.get(cls)!)} style={{ color: "var(--muted)", fontSize: "0.72rem" }}>{fmtB(retMap.get(cls)!)}</span>
)}
</React.Fragment>
))}
</div>
)}
{/* Selected node info */}
{selected && (
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "0.82rem", flexWrap: "wrap", borderTop: "1px solid var(--border)", paddingTop: "0.4rem" }}>
<span style={{ width: 10, height: 10, borderRadius: "50%", background: tpfgColor(selected), display: "inline-block", flexShrink: 0 }} />
<code style={{ wordBreak: "break-all", flex: "1 1 auto" }} title={selected}>{selected}</code>
{retMap.has(selected) && totalHeap > 0 && (
<span style={{ color: "var(--muted)", fontSize: "0.78rem", flexShrink: 0 }}>
retains <span title={fmtExactBytes(retMap.get(selected)!)}>{fmtB(retMap.get(selected)!)}</span> ({fmtPct(retMap.get(selected)! / totalHeap * 100)})
</span>
)}
{retMap.has(selected) && totalHeap === 0 && (
<span style={{ color: "var(--muted)", fontSize: "0.78rem", flexShrink: 0 }}>
retains <span title={fmtExactBytes(retMap.get(selected)!)}>{fmtB(retMap.get(selected)!)}</span>
</span>
)}
{/* Expand context: add dominators/dominated of this node from full dataset */}
{(() => {
const cy = cyRef.current;
const isIsolated = cy ? cy.getElementById(selected).connectedEdges().length === 0 : false;
const hasDomRelations = pairs.some(p => p.dominator_class === selected || p.dominated_class === selected);
if (!isIsolated && !hasDomRelations) return null;
const canExpand = pairs.some(p =>
(p.dominator_class === selected || p.dominated_class === selected) &&
!extraClasses.has(p.dominator_class === selected ? p.dominated_class : p.dominator_class)
);
if (!canExpand) return null;
return (
<button className="show-more-btn" style={{ flexShrink: 0 }} title="Add this node's dominator chain to the graph"
onClick={() => {
const related = new Set<string>();
for (const p of pairs) {
if (p.dominator_class === selected) related.add(p.dominated_class);
if (p.dominated_class === selected) related.add(p.dominator_class);
}
setExtraClasses(prev => new Set([...prev, ...related]));
}}>
Expand Context
</button>
);
})()}
<button className="show-more-btn" style={{ flexShrink: 0 }}
onClick={() => fireInspect({ kind: "class", cls: selected })}>Inspect →</button>
<button className="show-more-btn" style={{ flexShrink: 0 }}
onClick={() => fireInspect({ kind: "instances", cls: selected, page: 0 })}>Instances →</button>
<button className="show-more-btn" style={{ flexShrink: 0 }}
onClick={() => { window.dispatchEvent(new CustomEvent("trg-focus-class", { detail: selected })); history.replaceState(null, "", "#type-ref-graph"); window.dispatchEvent(new CustomEvent("nav-toast", { detail: { label: "Updated in Type Graph", sectionId: "type-ref-graph" } })); }}>Type Graph →</button>
<button className="show-more-btn" style={{ flexShrink: 0 }}
onClick={() => pivotClass(selected)}>WhoHolds →</button>
</div>
)}
</div>
);
}
function RetentionHeatmapView({ pairs }: { pairs: import("./types").ImmDomPair[] }) {
const fmtB = formatBytes;
if (pairs.length === 0) return <p className="trg-no-data">No dominator pair data for this heatmap.</p>;
// Top-N dominator rows (by total dominated_retained)
const domRetained = new Map<string, number>();
const deeRetained = new Map<string, number>();
for (const p of pairs) {
domRetained.set(p.dominator_class, (domRetained.get(p.dominator_class) ?? 0) + p.dominated_retained);
deeRetained.set(p.dominated_class, (deeRetained.get(p.dominated_class) ?? 0) + p.dominated_retained);
}
const topRows = [...domRetained.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map(([cls]) => cls);
const topCols = [...deeRetained.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([cls]) => cls);
// Build matrix
const cellMap = new Map<string, number>();
for (const p of pairs) cellMap.set(`${p.dominator_class}|${p.dominated_class}`, p.dominated_retained);
const maxCell = Math.max(...pairs.map(p => p.dominated_retained), 1);
return (
<div>
<p className="subtitle" style={{ marginTop: 0 }}>
Rows: dominator class · Columns: dominated class · Cell: retained heap gated by that dominator→dominated pair.
Click a cell to inspect the dominated class.
</p>
<div style={{ overflowX: "auto" }}>
<table className="gc-heatmap-table ret-heatmap-table">
<thead>
<tr>
<th className="gc-heatmap-rowlabel" style={{ minWidth: 140 }}>Dominator ↓</th>
{topCols.map(cls => (
<th key={cls} className="gc-heatmap-colhead" title={cls}>
<div className="gc-heatmap-coltext">{cls.split(".").pop()}</div>
</th>
))}
<th className="gc-heatmap-colhead" style={{ color: "var(--muted)", fontStyle: "italic" }}>Total</th>
</tr>
</thead>
<tbody>
{topRows.map(rowCls => {
const rowTotal = domRetained.get(rowCls) ?? 0;
return (
<tr key={rowCls}>
<td className="gc-heatmap-rowlabel" title={rowCls}>
<button className="trg-link-btn" style={{ fontSize: "0.75rem", textAlign: "left" }}
onClick={() => fireInspect({ kind: "class", cls: rowCls })}>
{rowCls.split(".").pop()}
</button>
</td>
{topCols.map(colCls => {
const val = cellMap.get(`${rowCls}|${colCls}`) ?? null;
if (!val) return <td key={colCls} className="gc-heatmap-cell gc-heatmap-empty" title={`${rowCls} → ${colCls}: no direct domination`} />;
const t = val / maxCell;
const bg = heatColor(t);
const textColor = t > 0.45 ? "#fff" : "var(--fg)";
return (
<td key={colCls} className="gc-heatmap-cell"
style={{ background: bg, color: textColor }}
title={`${rowCls} → ${colCls}: ${fmtB(val)} (${fmtExactBytes(val)})`}
onClick={() => fireInspect({ kind: "class", cls: colCls })}>
{fmtB(val)}
</td>
);
})}
<td className="gc-heatmap-cell" style={{ color: "var(--muted)", fontSize: "0.7rem", background: "var(--code-bg, rgba(0,0,0,0.03))" }}
title={fmtExactBytes(rowTotal)}>
{fmtB(rowTotal)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
function DominatorAnalysisSection({ data }: { data?: DominatorAnalysis }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const drops = data?.big_drops?.rows ?? [];
const threshold = data?.big_drops?.threshold ?? 0;
const thresholdMb = (threshold / (1024 * 1024)).toFixed(1);
const idoms = data?.immediate_dominators?.rows ?? [];
const pairs = data?.immediate_dominators?.pairs ?? [];
const [domView, setDomView] = React.useState<"tables" | "graph" | "heatmap">("tables");
// Navigator state lifted so tables can drive it.
const [navTarget, setNavTarget] = React.useState<string | null>(null);
const navigatorRef = React.useRef<HTMLDivElement>(null);
const pivotToClass = React.useCallback((cls: string) => {
setNavTarget(cls);
// Only scroll the navigator into view if the dominator section is already visible.
setTimeout(() => {
const section = document.getElementById("dominator-analysis");
if (!section) return;
const rect = section.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
navigatorRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, 80);
}, []);
// Listen for cross-section pivot events dispatched by PivotBtn.
React.useEffect(() => {
const handler = (e: Event) => {
const cls = (e as CustomEvent<string>).detail;
if (cls) pivotToClass(cls);
};
window.addEventListener("pivot-class", handler);
return () => window.removeEventListener("pivot-class", handler);
}, [pivotToClass]);
// Context menu state.
const [ctxMenu, setCtxMenu] = React.useState<{ x: number; y: number; cls: string } | null>(null);
React.useEffect(() => {
if (!ctxMenu) return;
const close = () => setCtxMenu(null);
window.addEventListener("click", close);
window.addEventListener("keydown", close);
return () => { window.removeEventListener("click", close); window.removeEventListener("keydown", close); };
}, [!!ctxMenu]);
const hasPairs = pairs.length > 0 && idoms.length > 0;
const effectiveNavTarget = navTarget ?? (idoms[0]?.dominator_class ?? "");
return (
<section id="dominator-analysis">
<h2>Dominator Analysis</h2>
<p className="subtitle">An object <em>dominates</em> another if every path from a GC root passes through it — making it unreachable reclaims the entire dominated subtree. <strong>Big Drops</strong> shows objects holding memory directly or across many small children. <strong>Immediate Dominators</strong> ranks classes by how much dominated shallow heap they gate. <strong>Graph</strong> shows a class-level dominator graph (top 50 classes by retained); <strong>Heatmap</strong> maps dominator → dominated by class.</p>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
{(["tables", "graph", "heatmap"] as const).map(v => (
<button key={v} onClick={() => setDomView(v)} style={{
padding: "0.25rem 0.85rem", fontSize: "0.88rem",
border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer",
background: domView === v ? "var(--accent)" : "transparent",
color: domView === v ? "#fff" : "var(--fg)",
}}>{v === "tables" ? "⊞ Tables" : v === "graph" ? "⬡ Graph" : "▦ Heatmap"}</button>
))}
</div>
{/* Context menu */}
{ctxMenu && (
<div style={{ position: "fixed", left: ctxMenu.x, top: ctxMenu.y, zIndex: 9999, background: "var(--card-bg, var(--bg))", border: "1px solid var(--border)", borderRadius: 6, boxShadow: "0 4px 14px rgba(0,0,0,0.18)", padding: "0.25rem 0", minWidth: 190 }}>
<div style={{ padding: "0.3rem 0.8rem 0.15rem", fontSize: "0.78rem", color: "var(--muted)", fontFamily: "var(--mono, monospace)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 220 }}>{ctxMenu.cls}</div>
<button
onClick={() => { pivotToClass(ctxMenu.cls); setCtxMenu(null); }}
style={{ display: "block", width: "100%", textAlign: "left", padding: "0.4rem 0.8rem", border: "none", background: "transparent", color: "var(--fg)", cursor: "pointer", fontSize: "0.9rem" }}
>
View in Who Holds Sankey
</button>
</div>
)}
{domView === "tables" && (<>
<h3>Big Drops</h3>
<p className="subtitle">
Objects retaining far more than their largest single child — memory held directly in the object or spread across many small dominated children. <strong>Drop</strong> = object retained − largest child retained; the memory freed by dropping just this object, not counting what its largest dominated child already retains. Threshold:{" "}
{thresholdMb} MB (1% of reachable heap). Multiple rows with the same class are distinct objects.
</p>
{drops.length === 0 ? (
<p className="subtitle">No objects meet the threshold.</p>
) : (() => {
const dropCols: TableColumn<import("./types").BigDropRow>[] = [
{ id: "object", name: "Object", grow: 1, maxWidth: "310px", cell: (r) => <span className="copy-cell"><code title={r.display_class}>{r.display_class}</code><CopyBtn text={r.display_class} /><PivotBtn cls={r.display_class} /><OqlBtn cls={r.display_class} /><ListObjectsBtn cls={r.display_class} /><ExploreBtn denseIdx={r.obj_index_1based - 1} label={r.display_class} /></span>, selector: (r) => r.display_class, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "110px", cell: byteCell(r => r.retained, fmtB, useKB), selector: (r) => r.retained, sortable: true },
{ id: "largest_child", name: "Largest Child", grow: 1, maxWidth: "310px", cell: (r) => r.largest_child_class ? <span className="copy-cell"><code title={r.largest_child_class}>{r.largest_child_class}</code><CopyBtn text={r.largest_child_class} /><PivotBtn cls={r.largest_child_class} /><OqlBtn cls={r.largest_child_class} /><ListObjectsBtn cls={r.largest_child_class} /></span> : <span>—</span>, selector: (r) => r.largest_child_class ?? "", sortable: true },
{ id: "child_ret", name: useKB ? "Child Retained (KB)" : "Child Retained", right: true, width: useKB ? "160px" : "130px", cell: byteCell(r => r.largest_child_retained, fmtB, useKB), selector: (r) => r.largest_child_retained, sortable: true },
{ id: "drop", name: useKB ? "Drop (KB)" : "Drop", right: true, width: useKB ? "120px" : "110px", cell: byteCell(r => r.drop_bytes, fmtB, useKB), selector: (r) => r.drop_bytes, sortable: true },
];
const totalDropRetained = drops.reduce((s, r) => s + r.retained, 0);
const totalChildRetained = drops.reduce((s, r) => s + r.largest_child_retained, 0);
const totalDropBytes = drops.reduce((s, r) => s + r.drop_bytes, 0);
return (
<>
<StdTable
columns={dropCols} data={drops} searchKeys={["display_class"]} fmtBtn={kbBtn}
defaultSortFieldId="drop" defaultSortAsc={false}
onRowClicked={hasPairs ? (r) => pivotToClass(r.display_class) : undefined}
onRowContextMenu={hasPairs ? (r, e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, cls: r.display_class }); } : undefined}
rowClickTitle={hasPairs ? "Click to view in Who Holds sankey" : undefined} />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: useKB ? "135px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(totalDropRetained)}>{fmtB(totalDropRetained)}</span></span>
<span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}></span>
<span style={{ width: useKB ? "150px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(totalChildRetained)}>{fmtB(totalChildRetained)}</span></span>
<span style={{ width: useKB ? "120px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(totalDropBytes)}>{fmtB(totalDropBytes)}</span></span>
</div>
</>
);
})()}
<h3>Immediate Dominators</h3>
<p className="subtitle">
Each row shows one dominator class: how many other objects it immediately dominates and the total shallow heap of those dominated objects. A large dominated-shallow figure means instances of that class are collectively gating large portions of the live heap — making them unreachable reclaims that memory.
{hasPairs && <span style={{ color: "var(--muted)", fontSize: "0.9em" }}> Click or right-click a row to open it in the "Who Holds This Class?" sankey below.</span>}
</p>
{idoms.length === 0 ? (
<p className="subtitle">No immediate dominators.</p>
) : (() => {
const idomCols: TableColumn<import("./types").ImmediateDominatorRow>[] = [
{ id: "dominator_class", name: "Dominator Class", grow: 1, maxWidth: "360px", cell: (r) => <span className="copy-cell"><code title={r.dominator_class}>{r.dominator_class}</code><CopyBtn text={r.dominator_class} /><PivotBtn cls={r.dominator_class} /><OqlBtn cls={r.dominator_class} /><ListObjectsBtn cls={r.dominator_class} /></span>, selector: (r) => r.dominator_class, sortable: true },
{ id: "dominator_count", name: "# Dominators", right: true, width: "132px", format: (r) => fmtCount(r.dominator_count), selector: (r) => r.dominator_count, sortable: true },
{ id: "dominated_count", name: "# Dominated", right: true, width: "128px", format: (r) => fmtCount(r.dominated_count), selector: (r) => r.dominated_count, sortable: true },
{ id: "dominator_shallow", name: useKB ? "Dominator Shallow (KB)" : "Dominator Shallow", right: true, width: useKB ? "205px" : "195px", cell: byteCell(r => r.dominator_shallow, fmtB, useKB), selector: (r) => r.dominator_shallow, sortable: true },
{ id: "dominated_shallow", name: useKB ? "Dominated Shallow (KB)" : "Dominated Shallow", right: true, width: useKB ? "210px" : "195px", cell: byteCell(r => r.dominated_shallow, fmtB, useKB), selector: (r) => r.dominated_shallow, sortable: true },
];
const totalDomCount = idoms.reduce((s, r) => s + r.dominator_count, 0);
const totalDominatedCount = idoms.reduce((s, r) => s + r.dominated_count, 0);
const totalDomShallow = idoms.reduce((s, r) => s + r.dominator_shallow, 0);
const totalDominatedShallow = idoms.reduce((s, r) => s + r.dominated_shallow, 0);
return (
<>
<StdTable
columns={idomCols} data={idoms} searchKeys={["dominator_class"]} fmtBtn={kbBtn}
defaultSortFieldId="dominated_shallow" defaultSortAsc={false}
onRowClicked={hasPairs ? (r) => pivotToClass(r.dominator_class) : undefined}
onRowContextMenu={hasPairs ? (r, e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, cls: r.dominator_class }); } : undefined}
rowClickTitle={hasPairs ? "Click to view in Who Holds sankey" : undefined} extraBtns={<CopyTsvBtn rows={[["Dominator Class","# Dominators","# Dominated","Dominator Shallow (bytes)","Dominated Shallow (bytes)"],...idoms.map(r=>[r.dominator_class,String(r.dominator_count),String(r.dominated_count),String(r.dominator_shallow),String(r.dominated_shallow)])]} label="Copy as TSV" />}
/>
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: "132px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(totalDomCount)}</span>
<span style={{ width: "128px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(totalDominatedCount)}</span>
<span style={{ width: useKB ? "205px" : "195px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(totalDomShallow)}>{fmtB(totalDomShallow)}</span></span>
<span style={{ width: useKB ? "210px" : "195px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(totalDominatedShallow)}>{fmtB(totalDominatedShallow)}</span></span>
</div>
</>
);
})()}
{hasPairs && (
<div ref={navigatorRef}>
<h3>Who Holds This Class?</h3>
<p className="subtitle">
Select a class — <strong>left</strong> shows what dominates it (the objects keeping it alive); <strong>right</strong> shows what it dominates (everything it keeps alive — making it unreachable reclaims that memory). A wide right side means this class retains a large portion of the heap. Click any node or row to refocus.
</p>
<WhoHoldsSankey
pairs={pairs}
initialTarget={effectiveNavTarget}
externalTarget={navTarget ?? undefined}
onPivot={setNavTarget}
/>
</div>
)}
</>)}
{domView === "graph" && (
<DomGraphView pairs={pairs} idoms={idoms} />
)}
{domView === "heatmap" && (
<RetentionHeatmapView pairs={pairs} />
)}
</section>
);
}
// ── Unreachable Objects ─────────────────────────────────────────────────────
// Per-class histogram of objects not dominated by the virtual root
// (idom == u32::MAX). Always-on; mirrors render_md.rs::render_unreachable_histogram.
type UnreachableKey = "objects" | "shallow" | "retained";
const UNREACHABLE_COLS: { key: UnreachableKey; label: string }[] = [
{ key: "objects", label: "Objects" },
{ key: "shallow", label: "Shallow" },
{ key: "retained", label: "Retained" },
];
function UnreachableCompositionTable({ comp }: { comp: HeapComposition }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
if (comp.by_kind.length === 0) return null;
// When prim_array_by_type is available, expand "Primitive arrays" into
// individual types so the chart shows byte[], int[], char[], etc.
const chartKinds: KindStat[] = React.useMemo(() => {
if (!comp.prim_array_by_type?.length) return comp.by_kind;
return comp.by_kind.flatMap((k) =>
k.kind === "Primitive Arrays" ? comp.prim_array_by_type! : [k]
);
}, [comp]);
return (
<>
<h3>Unreachable Heap Composition</h3>
<ChartOrNote hasData={chartKinds.length >= 2} note="Composition chart needs ≥2 kinds; table only.">
<CompositionStackedBar data={chartKinds} />
</ChartOrNote>
{(() => {
type CompRow = { kind: string; objects: number; shallow_heap: number; indent?: boolean };
const flatRows: CompRow[] = comp.by_kind.flatMap((k) => {
const main: CompRow = { kind: k.kind, objects: k.objects, shallow_heap: k.shallow_heap };
if (k.kind === "Primitive Arrays" && comp.prim_array_by_type?.length) {
return [main, ...comp.prim_array_by_type.map((p) => ({ kind: p.kind, objects: p.objects, shallow_heap: p.shallow_heap, indent: true }))];
}
return [main];
});
const compCols: TableColumn<CompRow>[] = [
{ id: "kind", name: "Kind", grow: 1, cell: (r) => <span style={{ textTransform: "capitalize", ...(r.indent ? { paddingLeft: "1.5rem", fontSize: "0.88em", color: "var(--muted)" } : {}) }}>{r.kind}</span>, selector: (r) => r.kind, sortable: true },
{ id: "objects", name: "Objects", right: true, minWidth: "110px", cell: (r) => <span style={r.indent ? { fontSize: "0.88em", color: "var(--muted)" } : undefined}>{fmtCount(r.objects)}</span>, selector: (r) => r.objects, sortable: true },
{ id: "shallow", name: useKB ? "Shallow (KB)" : "Shallow", right: true, minWidth: useKB ? "130px" : "110px", cell: (r) => <span title={fmtExactBytes(r.shallow_heap)} style={r.indent ? { fontSize: "0.88em", color: "var(--muted)" } : undefined}>{fmtB(r.shallow_heap)}</span>, selector: (r) => r.shallow_heap, sortable: true },
];
return <StdTable columns={compCols} data={flatRows} searchKeys={["kind"]} fmtBtn={kbBtn} defaultSortFieldId="shallow" defaultSortAsc={false} />;
})()}
</>
);
}
function UnreachableObjectsSection({ data }: { data?: SystemOverview }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const rows: UnreachableClassRow[] = data?.unreachable_histogram ?? [];
const unreachablePct = React.useMemo(() => {
const total = (data?.total_shallow ?? 0) + (data?.unreachable_shallow ?? 0);
return total > 0 ? (data?.unreachable_shallow ?? 0) / total * 100 : 0;
}, [data]);
return (
<section id="unreachable-objects">
<h2>Unreachable Objects</h2>
{rows.length === 0 ? (
<p className="subtitle">No unreachable objects. All heap objects are reachable from a GC root — normal when a full GC ran before the dump was taken.</p>
) : (
<>
<p className="subtitle">
Objects that are no longer reachable from any GC root but have not yet been collected. A small unreachable fraction (< 5%) is normal between GC cycles; a large one suggests the dump was taken mid-collection.
</p>
<p className="subtitle">
{fmtCount(data?.unreachable_count ?? 0)} unreachable objects,{" "}
<span title={fmtExactBytes(data?.unreachable_shallow ?? 0)}>{fmtB(data?.unreachable_shallow ?? 0)}</span> shallow heap.
Showing top {fmtCount(rows.length)} classes by shallow size.
</p>
{unreachablePct >= 5 ? (
<p className="subtitle">
Unreachable objects are eligible for collection but have not yet been reclaimed. At {fmtPct(unreachablePct)} of heap total, this is elevated — the dump was likely taken before a full GC cycle completed. GC reclaims this memory automatically; it is <em>not</em> a leak. Confirm: trigger a full GC (<code>jcmd <pid> GC.run</code>) then re-dump; if the count drops sharply, it was pre-GC garbage.
</p>
) : (
<p className="subtitle">
Unreachable objects are eligible for collection but have not yet been reclaimed. A small unreachable heap (< 5% of heap total) is normal between GC cycles.
</p>
)}
{data?.unreachable_composition && (
<UnreachableCompositionTable comp={data.unreachable_composition} />
)}
{data?.unreachable_garbage_roots && data.unreachable_garbage_roots.length > 0 && (
<>
<ZoomableTreemap
root={{ pretty_class: "(unreachable)", retained: data.unreachable_retained ?? 0, objects: data.unreachable_count ?? 0, children: data.unreachable_garbage_roots }}
getChildren={(n) => n.children}
getValue={(n) => n.retained}
getLabel={(n) => n.pretty_class}
fmt={formatBytes}
fmtExact={fmtExactBytes}
height={240}
/>
<UnreachableDomTreeSection roots={data.unreachable_garbage_roots} />
</>
)}
<details open>
<summary>Unreachable Objects by Class ({fmtCount(rows.length)} rows)</summary>
<p className="subtitle" style={{ fontSize: "0.82rem" }}>Shallow heap is additive; retained sets overlap — nested subtrees are counted once per ancestor, so summing retained across classes overstates the total reclaimable memory.</p>
{(() => {
const unreachCols: TableColumn<UnreachableClassRow>[] = [
{ id: "class", name: "Class", grow: 1, maxWidth: "600px", cell: (r) => <span className="copy-cell"><code title={r.pretty_class}>{r.pretty_class}</code><CopyBtn text={r.pretty_class} /><PivotBtn cls={r.pretty_class} /><OqlBtn cls={r.pretty_class} /><ListObjectsBtn cls={r.pretty_class} /></span>, selector: (r) => r.pretty_class, sortable: true },
{ id: "objects", name: "Objects", right: true, width: "110px", format: (r) => fmtCount(r.objects), selector: (r) => r.objects, sortable: true },
{ id: "shallow", name: useKB ? "Shallow (KB)" : "Shallow", right: true, width: useKB ? "135px" : "110px", cell: byteCell(r => r.shallow, fmtB, useKB), selector: (r) => r.shallow, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "110px", cell: byteCell(r => r.retained, fmtB, useKB), selector: (r) => r.retained, sortable: true },
];
return (
<>
<StdTable columns={unreachCols} data={rows} searchKeys={["pretty_class"]} fmtBtn={kbBtn} defaultSortFieldId="shallow" defaultSortAsc={false} />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(data?.unreachable_count ?? 0)}</span>
<span style={{ width: useKB ? "135px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(data?.unreachable_shallow ?? 0)}>{fmtB(data?.unreachable_shallow ?? 0)}</span></span>
<span style={{ width: useKB ? "135px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(data?.unreachable_retained ?? 0)}>{fmtB(data?.unreachable_retained ?? 0)}</span></span>
</div>
</>
);
})()}
</details>
</>
)}
</section>
);
}
// ── Off-Heap NIO Memory (V6) ───────────────────────────────────────────────────
// DirectByteBuffer capacity card: shows native (OS) memory allocation for NIO buffers
// which is not counted in JVM heap totals.
function DirectByteBufferCard({ indicators }: { indicators?: LeakIndicators }) {
if (!indicators || (indicators.direct_byte_buffer_capacity_sum ?? 0) <= 0) return null;
const capacity = indicators.direct_byte_buffer_capacity_sum;
const bufferCount = indicators.direct_byte_buffer_count;
const isLarge = capacity > 256 * 1024 * 1024;
return (
<section id="off-heap-nio" tabIndex={-1}>
<h2>Off-Heap NIO Memory</h2>
<p className="subtitle">Native (OS) memory allocated by <code>DirectByteBuffer</code> — not counted in JVM heap totals and invisible to the GC. Can trigger OS-level OOM if unbounded.</p>
<div className="card">
<p>
<strong title={fmtExactBytes(capacity)}>{formatBytes(capacity)}</strong>
{bufferCount && bufferCount > 0 && ` across ${fmtCount(bufferCount)} buffers`}
</p>
{isLarge && (
<p className="subtitle">
⚠ Over 256 MB of off-heap NIO memory detected — invisible to GC and pressures OS memory.
</p>
)}
</div>
</section>
);
}
// ── Allocation Sites ──────────────────────────────────────────────────────────
// aggregated allocation sites. Honest note when the
// dump carried no allocation stack-trace info. Mirrors report.rs::render_alloc_sites.
// Frame-trie types and helpers (module-level, used by AllocSitesSection).
interface FrameTrieNode {
label: string;
retained: number;
children: Map<string, FrameTrieNode>;
}
interface FrameTreeNode {
label: string;
retained: number;
children: FrameTreeNode[];
}
function trieToTree(node: FrameTrieNode): FrameTreeNode {
return {
label: node.label,
retained: node.retained,
children: Array.from(node.children.values())
.map(trieToTree)
.sort((a, b) => b.retained - a.retained),
};
}
function buildFrameTrie(sites: import("./types").AllocSite[]): FrameTrieNode {
const root: FrameTrieNode = { label: "(root)", retained: 0, children: new Map() };
for (const site of sites) {
if (site.frames.length === 0 || site.retained_total <= 0) continue;
const reversed = [...site.frames].reverse();
let cur = root;
cur.retained += site.retained_total;
for (const frame of reversed) {
let child = cur.children.get(frame);
if (!child) {
child = { label: frame, retained: 0, children: new Map() };
cur.children.set(frame, child);
}
child.retained += site.retained_total;
cur = child;
}
}
return root;
}
function frameToClass(frame: string): string | null {
const paren = frame.indexOf("(");
if (paren < 0) return null;
const sig = frame.slice(0, paren);
const dot = sig.lastIndexOf(".");
if (dot <= 0) return null;
const cls = sig.slice(0, dot);
return cls.includes(".") ? cls : null;
}
function AllocSitesSection({ data, biggestClasses }: { data: AllocSites; biggestClasses: import("./types").ClassRow[] }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
// Build a map from class name to retained bytes from biggest_classes
const retainedByClass = React.useMemo(() => {
const m = new Map<string, number>();
for (const c of biggestClasses) m.set(c.pretty_class, c.retained);
return m;
}, [biggestClasses]);
const medRetained = React.useMemo(() => {
const vals = biggestClasses.filter(c => c.retained > 0).map(c => c.retained).sort((a,b) => a-b);
return vals[Math.floor(vals.length / 2)] ?? 1;
}, [biggestClasses]);
const frameTree: FrameTreeNode = React.useMemo(() => {
return trieToTree(buildFrameTrie(data.sites));
}, [data.sites]);
return (
<section id="allocation-sites">
<h2>Allocation Sites</h2>
<p className="subtitle">Objects grouped by the stack trace that allocated them — shows where heap was created, not necessarily what is keeping it alive. Only available when the dump was captured with the HPROF agent (JDK 8 and earlier). Each site is a candidate to allocate less by pooling, caching, or deferring construction.</p>
{!data.traces_present ? (
<p className="subtitle">
Allocation tracking not captured. This requires the HPROF agent (<code>-agentlib:hprof=heap=dump,depth=8</code>), which was removed in JDK 9. Standard <code>jmap</code>/<code>jcmd</code> dumps do not include per-site allocation stacks.
</p>
) : !data.sites.some(s => s.frames.length > 0) ? (
<p className="subtitle">
Allocation-site records are present but contain no per-frame data. The HPROF agent must be invoked with <code>depth=8</code> or higher to record method-level allocation stacks: <code>-agentlib:hprof=heap=dump,depth=8</code>.
</p>
) : (() => {
const allocCols: TableColumn<import("./types").AllocSite>[] = [
{ id: "stack", name: "Stack", grow: 1, cell: (s) => {
if (s.frames.length === 0) {
return <span className="hint">serial {s.stack_serial} <span className="hint">(no frames recorded)</span></span>;
}
const cls = frameToClass(s.frames[0]);
const frameLabel = (
<span className="copy-cell">
<code title={s.frames[0]}>{s.frames[0]}</code>
<CopyBtn text={s.frames[0]} />
{cls && <PivotBtn cls={cls} />}
{cls && <OqlBtn cls={cls} />}
{cls && <ListObjectsBtn cls={cls} />}
</span>
);
if (s.frames.length === 1) {
return frameLabel;
}
return (
<details className="stack-detail">
<summary>{frameLabel}</summary>
<ol className="stack-frames">
{s.frames.map((f, fi) => {
const fc = frameToClass(f);
return (
<li key={fi}><span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle" }}><code title={f}>{f}</code><CopyBtn text={f} />{fc && <><PivotBtn cls={fc} /><OqlBtn cls={fc} /><ListObjectsBtn cls={fc} /></>}</span></li>
);
})}
</ol>
</details>
);
}},
{ id: "objects", name: "Objects", right: true, width: "110px", format: (s) => fmtCount(s.object_count), selector: (s) => s.object_count, sortable: true },
{ id: "shallow", name: useKB ? "Shallow (KB)" : "Shallow", right: true, width: useKB ? "135px" : "110px", cell: byteCell(s => s.shallow_total, fmtB, useKB), selector: (s) => s.shallow_total, sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "110px", sortable: true,
selector: (s) => {
const cls = s.frames.length > 0 ? frameToClass(s.frames[0]) : null;
return cls ? (retainedByClass.get(cls) ?? 0) : 0;
},
cell: (s) => {
const cls = s.frames.length > 0 ? frameToClass(s.frames[0]) : null;
const retained = cls ? (retainedByClass.get(cls) ?? null) : null;
if (retained == null) return <span style={{ color: "var(--muted)" }}>—</span>;
const isHigh = retained > medRetained * 5;
return (
<span title={`${cls} retains ${fmtExactBytes(retained)} currently in heap`}
style={isHigh ? { color: "#c87533", fontWeight: 600 } : {}}>
{fmtB(retained)}{isHigh ? " ⚠" : ""}
</span>
);
},
},
];
const totalObjects = data.sites.reduce((s, r) => s + r.object_count, 0);
const totalShallow = data.sites.reduce((s, r) => s + r.shallow_total, 0);
return (
<>
{frameTree.retained > 0 && (
<>
<h3>Retained Heap by Call Path</h3>
<p className="subtitle">Retained heap grouped by call path. Click a frame to drill in.</p>
<ZoomableTreemap<FrameTreeNode>
root={frameTree}
getChildren={(n) => n.children}
getValue={(n) => n.retained}
getLabel={(n) => n.label}
fmt={fmtB}
fmtExact={fmtExactBytes}
height={260}
/>
</>
)}
<StdTable columns={allocCols} data={data.sites} searchKeys={[]} fmtBtn={kbBtn} defaultSortFieldId="shallow" defaultSortAsc={false} />
<div style={{ display: "flex", fontSize: "0.86rem", fontWeight: 600, borderTop: "2px solid var(--border)", paddingTop: "0.3rem", marginBottom: "1rem", fontVariantNumeric: "tabular-nums" }}>
<span style={{ flex: 1, paddingLeft: 5, paddingRight: 5 }}>Total</span>
<span style={{ width: "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}>{fmtCount(totalObjects)}</span>
<span style={{ width: useKB ? "135px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5, textAlign: "right" }}><span title={fmtExactBytes(totalShallow)}>{fmtB(totalShallow)}</span></span>
<span style={{ width: useKB ? "135px" : "110px", flexShrink: 0, flexGrow: 0, paddingLeft: 5, paddingRight: 5 }}></span>
</div>
</>
);
})()}
</section>
);
}
// ── Retention Concentration ─────────────────────────────────────────────────
function RetentionConcentrationSection({ report }: { report: Report }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const rc = report.overview.retention_concentration;
if (!rc || (rc.top1_bp === 0 && rc.top10_bp === 0 && rc.top100_bp === 0 && rc.num_objects_ge_1pct === 0)) {
return null;
}
return (
<section id="retention-concentration">
<h2>Retention Concentration</h2>
<p className="subtitle">
Share of the reachable heap retained by the few largest top-level dominators (a dominator's retained size is everything it keeps alive). Read it as a concentration curve: if{" "}
<strong>Top 1</strong> is already high, one object is the accumulation point — freeing it would reclaim most of the heap; if the share only climbs as you widen to <strong>Top 10</strong> / <strong>Top 100</strong>,
retention is spread across many peers (e.g. a big cache or collection of similar objects) and no single fix helps much.
</p>
<ConcentrationChart rc={rc} />
<ConcentrationStackedBar rc={rc} />
{(() => {
type RcRow = { scope: string; bp: number; retained: number };
const rcRows: RcRow[] = [
{ scope: "Top 1 object", bp: rc.top1_bp, retained: rc.top1_retained ?? 0 },
{ scope: "Top 10 objects", bp: rc.top10_bp, retained: rc.top10_retained ?? 0 },
{ scope: "Top 100 objects", bp: rc.top100_bp, retained: rc.top100_retained ?? 0 },
];
const rcCols: TableColumn<RcRow>[] = [
{ id: "scope", name: "Scope", grow: 1, selector: (r) => r.scope, sortable: true },
{ id: "share", name: "Retained Share", right: true, width: "150px", selector: (r) => r.bp, format: (r) => fmtPct(r.bp / 100), sortable: true },
{ id: "retained", name: useKB ? "Retained (KB)" : "Retained", right: true, width: useKB ? "135px" : "110px", cell: byteCell(r => r.retained, fmtB, useKB), selector: (r) => r.retained, sortable: true },
];
return <StdTable columns={rcCols} data={rcRows} searchKeys={[]} defaultSortFieldId="share" defaultSortAsc={true} fmtBtn={kbBtn} />;
})()}
{rc.num_objects_ge_1pct > 0 && (
<p className="subtitle"><em>{fmtCount(rc.num_objects_ge_1pct)} {rc.num_objects_ge_1pct === 1 ? "object" : "objects"} each hold ≥1% of the reachable heap.</em></p>
)}
</section>
);
}
// ── Dominator-Depth Distribution ─────────────────────────────────────────────
// Objects per idom-hop below a GC root. Mirrors render_md.rs::render_dominator_depth.
function DominatorDepthSection({ report }: { report: Report }) {
const hist = report.overview.dominator_depth_histogram;
const totalObjs = (hist ?? []).reduce((s, b) => s + b.objects, 0);
const maxDepth = (hist ?? []).reduce((m, b) => Math.max(m, b.depth), 0);
// Compute cumulative percentage for each bucket.
type DepthRow = { depth: number; objects: number; pct: number; cum: number };
const rows: DepthRow[] = React.useMemo(() => {
if (!hist) return [];
let cumSum = 0;
return hist.map((b) => {
cumSum += b.objects;
return {
depth: b.depth,
objects: b.objects,
pct: totalObjs > 0 ? (b.objects / totalObjs) * 100 : 0,
cum: totalObjs > 0 ? (cumSum / totalObjs) * 100 : 0,
};
});
}, [hist, totalObjs]);
if (!hist || hist.length === 0) return null;
const depthCols: TableColumn<DepthRow>[] = [
{ id: "depth", name: "Depth", right: true, width: "90px", selector: (r) => r.depth, sortable: true },
{ id: "objects", name: "Objects", right: true, width: "110px", format: (r) => fmtCount(r.objects), selector: (r) => r.objects, sortable: true },
{ id: "pct", name: "% Objects", right: true, width: "118px", format: (r) => fmtPct(r.pct), selector: (r) => r.pct, sortable: true },
{ id: "cum", name: "Cumulative %", right: true, width: "140px", format: (r) => fmtPct(r.cum), selector: (r) => r.cum, sortable: true },
];
return (
<section id="dominator-depth-distribution">
<h2>Dominator-Depth Distribution</h2>
<p className="subtitle">
How many dominator hops each object sits below a GC root. A spike at depth 1–3 is normal; a long tail at depth 10+ points to deeply nested containers or linked structures. Maximum depth in this dump: {maxDepth}.
</p>
<DepthHistogramChart data={hist} />
<details>
<summary>Full Depth Table ({fmtCount(hist.length)} buckets)</summary>
<StdTable columns={depthCols} data={rows} searchKeys={[]} defaultSortFieldId="objects" defaultSortAsc={false} />
</details>
</section>
);
}
// ── Leak Indicators ─────────────────────────────────────────────────────────
// Scalar signals for common Java leak patterns. Only rendered when at least
// one indicator is non-zero. Mirrors render_md.rs::render_leak_indicators.
function LeakIndicatorsSection({ data, totalHeap = 0 }: { data?: LeakIndicators; totalHeap?: number }) {
const [fmtB, kbBtn] = useFmtBytes();
if (!data) return null;
const { anonymous_class_count, thread_local_null_key_count, direct_byte_buffer_capacity_sum } = data;
if (anonymous_class_count === 0 && thread_local_null_key_count === 0 && direct_byte_buffer_capacity_sum === 0) {
return null;
}
type LeakRow = { indicator: React.ReactNode; value: React.ReactNode; hint: React.ReactNode };
const leakRows: LeakRow[] = [
...(anonymous_class_count > 0 ? [{
indicator: "Anonymous/generated classes",
value: fmtCount(anonymous_class_count),
hint: <>High counts signal class-loader leaks (e.g. dynamic proxies accumulating per request). In Top Consumers, filter by <code>$</code> to find the biggest offenders.</>,
}] : []),
...(thread_local_null_key_count > 0 ? [{
indicator: <><code>ThreadLocal</code> null-key entries (cleared referent)</>,
value: fmtCount(thread_local_null_key_count),
hint: <>A null key means the <code>ThreadLocal</code> object was GC'd while the thread still holds the value — classic leak in thread pools. Call <code>ThreadLocal.remove()</code> when done, or use try-finally to guarantee cleanup.</>,
}] : []),
...(direct_byte_buffer_capacity_sum > 0 ? [{
indicator: <><code>DirectByteBuffer</code> off-heap capacity</>,
value: <span title={fmtExactBytes(direct_byte_buffer_capacity_sum)}>{fmtB(direct_byte_buffer_capacity_sum)}</span>,
hint: totalHeap > 0 && direct_byte_buffer_capacity_sum > totalHeap
? <strong style={{ color: "var(--warn, #c84)" }}>⚠ Off-Heap NIO (<span title={fmtExactBytes(direct_byte_buffer_capacity_sum)}>{fmtB(direct_byte_buffer_capacity_sum)}</span>) exceeds the entire JVM heap (<span title={fmtExactBytes(totalHeap)}>{fmtB(totalHeap)}</span>). Invisible to GC — can trigger OS-level OOM. See <a href="#off-heap-nio">Off-Heap NIO</a>.</strong>
: <>Native memory, excluded from JVM heap totals. Check for NIO buffer pools that leak on close, or Netty/gRPC allocators missing a buffer cap.</>,
}] : []),
];
const leakCols: TableColumn<LeakRow>[] = [
{ id: "indicator", name: "Indicator", grow: 1, maxWidth: "300px", cell: (r) => <span>{r.indicator}</span> },
{ id: "value", name: "Value", right: true, width: "120px", cell: (r) => <span style={{ fontVariantNumeric: "tabular-nums" }}>{r.value}</span> },
{ id: "hint", name: "What to Check", grow: 2, maxWidth: "620px", wrap: true, cell: (r) => <span style={{ fontSize: "0.82rem", color: "var(--muted)", whiteSpace: "normal" }}>{r.hint}</span> },
];
return (
<section id="leak-indicators">
<h2>Leak Indicators</h2>
<p className="subtitle">
Point-in-time counts for known Java leak patterns. Non-zero values are not always bugs — see the <strong>What to Check</strong> column for how to triage each one.
</p>
<StdTable columns={leakCols} data={leakRows} searchKeys={[]} fmtBtn={kbBtn} />
</section>
);
}
// ── Top Retainers (§813) ───────────────────────────────────────────────────────
// Merged Class#field + stack-frame retainers, sorted by retained desc.
function TopRetainersSection({ rows }: { rows?: import("./types").RetainerRow[] }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
if (!rows || rows.length === 0) return null;
return (
<section id="top-retainers">
<h2>Top Retainers</h2>
<p className="subtitle">
Combined ranking of <code>Class#field</code> references and stack-frame locals by retained heap.
Retained totals can exceed heap size for linked structures (e.g. <code>List#next</code>) where each node retains its entire tail — treat as relative, not additive.
</p>
{(() => {
const retainerCols: TableColumn<import("./types").RetainerRow>[] = [
{ id: "name", name: "Name", grow: 1, maxWidth: "800px", cell: (r) => {
const cls = r.name.split("#")[0];
const isThreadLocal = r.name.includes("ThreadLocalMap$Entry");
return (
<span className="copy-cell">
<code>{r.name}</code>
<CopyBtn text={r.name} /><PivotBtn cls={cls} /><OqlBtn cls={cls} /><ListObjectsBtn cls={cls} />
{isThreadLocal && (
<span title="ThreadLocal entries live as long as the thread. In thread-pooled servers (Netty, Tomcat) values persist unless explicitly removed — classic slow leak."
style={{ marginLeft: 4, color: "var(--warn, #c84)", fontSize: "0.8em", cursor: "help" }}>⚠ TL</span>
)}
</span>
);
}, selector: (r) => r.name, sortable: true },
{ id: "kind", name: "Kind", width: "115px", format: (r) => r.kind === "stack-frame" ? "Stack Frame" : r.kind === "field" ? "Field" : r.kind, selector: (r) => r.kind, sortable: true },
{ id: "retained", name: "Retained", right: true, width: "120px",
cell: byteCell(r => r.retained, fmtB, useKB),
selector: (r) => r.retained, sortable: true },
];
return <StdTable columns={retainerCols} data={rows} searchKeys={["name"]} fmtBtn={kbBtn} defaultSortFieldId="retained" defaultSortAsc={false} />;
})()}
</section>
);
}
// ── Glossary (end section, mirrors the Markdown glossary) ─────────────────────
// ── Custom Queries ───────────────────────────────────────────────────────────
// Renders report.queries (user-supplied OQL results). Query results are already
// LIMIT-capped server-side, so every row is rendered (no ShowMore). React
// escapes each {cell} text child automatically — no manual HTML escaping.
// Format a single QueryValue cell for display. Mirrors fmt_query_value in
// src/report/render_md.rs (ObjRef renders as `class@index`).
function fmtCell(v: QueryValue): string {
switch (v.kind) {
case "null":
return "null";
case "bool":
case "int":
case "float":
return String(v.v);
case "str":
return v.v;
case "obj_ref":
return `${v.v.class}@${v.v.index}`;
}
}
// Rich cell renderer for a single QueryValue — adds copy/pivot/navigate for obj_ref.
function QueryCell({ val, colName }: { val: QueryValue; colName: string }) {
if (val.kind === "str") return <ExpandableText text={val.v} label={colName} />;
if (val.kind === "obj_ref") {
const { class: cls, index: idx } = val.v;
return (
<span className="copy-cell">
<code title={cls}>{cls}</code><span className="muted" style={{ fontSize: "0.78rem" }}>@{idx}</span>
<CopyBtn text={`${cls}@${idx}`} />
<PivotBtn cls={cls} />
<OqlBtn cls={cls} />
<ListObjectsBtn cls={cls} />
<ExploreBtn denseIdx={idx} label={cls} />
</span>
);
}
return <span>{fmtCell(val)}</span>;
}
function downloadQueryCsv(q: QueryResult) {
const escape = (s: string) => /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
const header = q.columns.map(c => escape(c.name)).join(",");
const body = q.rows.map(row => row.map(v => escape(fmtCell(v))).join(",")).join("\n");
const blob = new Blob([header + "\n" + body], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = (q.name || "query").replace(/[^a-z0-9_-]/gi, "_") + ".csv";
a.click();
URL.revokeObjectURL(url);
}
function CustomQueriesSection({ report }: { report: Report }) {
const queries = report.queries;
if (!queries?.length) return null;
return (
<section id="custom-queries">
<h2>Custom Queries</h2>
<p className="subtitle">OQL queries embedded in this report at generation time.</p>
{queries.map((q: QueryResult, qi) => (
<div key={qi}>
<h3 style={{ display: "flex", alignItems: "baseline", gap: "0.5rem" }}>
{q.name}
{!q.error && q.rows.length > 0 && (
<button className="theme-toggle" style={{ fontSize: "0.75rem", padding: "1px 8px" }}
title="Download results as CSV"
onClick={() => downloadQueryCsv(q)}>⬇ CSV</button>
)}
</h3>
<pre>{q.oql}</pre>
{q.error ? (
<p className="subtitle">
<strong>Error:</strong> {q.error}
</p>
) : (
<>
{(() => {
const queryCols: TableColumn<QueryValue[]>[] = q.columns.map((c, ci) => ({
id: `col_${ci}`,
name: c.name,
grow: 1,
minWidth: "80px",
maxWidth: "500px",
cell: (row) => <QueryCell val={row[ci]} colName={c.name} />,
selector: (row) => fmtCell(row[ci]),
sortable: true,
}));
return <StdTable columns={queryCols} data={q.rows} searchKeys={[]} cap={q.rows.length} />;
})()}
<p className="subtitle">
{fmtCount(q.row_count)} {q.row_count === 1 ? "row" : "rows"}{q.truncated ? " (truncated)" : ""}
</p>
{q.note && <p className="subtitle">{q.note}</p>}
<QueryViz query={q} />
</>
)}
</div>
))}
</section>
);
}
// ── Shared Cytoscape helpers ──────────────────────────────────────────────────
// Blue→orange→red gradient for 0–1 heat value.
function heatColor(t: number): string {
const c = Math.max(0, Math.min(1, t));
const r = Math.round(30 + 225 * c);
const g = Math.round(144 - 100 * c);
const b = Math.round(255 - 230 * c);
return `rgb(${r},${g},${b})`;
}
function buildDomGraphStyle(): cytoscape.Stylesheet[] {
return [
{ selector: "node", style: {
label: "data(label)", "font-size": 11, "background-color": "data(color)",
width: "data(size)", height: "data(size)", color: "#222",
"text-valign": "bottom", "text-halign": "center", "text-margin-y": 3,
"text-outline-width": 2, "text-outline-color": "#fff", "min-zoomed-font-size": 8,
} as any },
{ selector: "node:selected", style: { "border-width": 3, "border-color": "#0066cc", "border-opacity": 1 } as any },
{ selector: "edge", style: {
width: "data(weight)", "line-color": "#888", "target-arrow-color": "#888",
"target-arrow-shape": "triangle", "curve-style": "bezier", opacity: 0.5,
} as any },
{ selector: "node.path-highlight", style: { "border-width": 3, "border-color": "#f90", "border-opacity": 1, opacity: 1 } as any },
{ selector: "edge.path-highlight", style: { "line-color": "#f90", "target-arrow-color": "#f90", opacity: 1, width: 3 } as any },
];
}
function buildCyBaseStyle(): cytoscape.Stylesheet[] {
return [
{
selector: "node",
style: {
label: "data(label)",
"font-size": 11,
"background-color": "data(color)",
width: "data(size)",
height: "data(size)",
color: "#222",
"text-valign": "bottom",
"text-halign": "center",
"text-margin-y": 3,
"text-outline-width": 2,
"text-outline-color": "#fff",
"min-zoomed-font-size": 8,
} as any,
},
{
selector: "node:selected",
style: { "border-width": 3, "border-color": "#0066cc", "border-opacity": 1 } as any,
},
{
selector: "edge",
style: {
width: "data(weight)",
"line-color": "#999",
"target-arrow-color": "#999",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
opacity: 0.6,
} as any,
},
];
}
function buildCyStyleWithFields(showFields: boolean): cytoscape.Stylesheet[] {
const base = buildCyBaseStyle();
if (showFields) {
base.push({
selector: "edge[fields]",
style: {
label: "data(fields)",
"font-size": 8,
color: "#555",
"text-rotation": "autorotate",
"text-outline-width": 1,
"text-outline-color": "#fff",
} as any,
});
}
return base;
}
function makeCyInstance(
container: HTMLDivElement,
elements: cytoscape.ElementDefinition[],
style: cytoscape.Stylesheet[],
): cytoscape.Core {
const cy = cytoscape({
container,
elements,
style,
layout: {
name: "cose-bilkent",
animate: false,
nodeDimensionsIncludeLabels: true,
idealEdgeLength: 100,
nodeRepulsion: 8000,
padding: 24,
} as any,
wheelSensitivity: 0.3,
minZoom: 0.05,
maxZoom: 5,
});
return cy;
}
/** Dim all nodes/edges that are not directly connected to the tapped node. Pass null to clear. */
function applyCyHighlight(cy: cytoscape.Core, nodeId: string | null): void {
if (!nodeId) {
cy.elements().removeStyle("opacity");
return;
}
const node = cy.getElementById(nodeId);
const neighborhood = node.neighborhood().add(node);
cy.elements().difference(neighborhood).style("opacity", 0.1);
neighborhood.removeStyle("opacity");
}
/**
* Make Ctrl+wheel zoom and plain wheel scroll the page.
*
* Cytoscape registers its wheel handler on the container with capture=true, so it
* runs before any bubble-phase handlers. We counter this by attaching our handler
* to the *parent* element in capture phase — which fires before any handler on the
* container itself. When Ctrl is NOT held we call stopPropagation() so the event
* never reaches Cytoscape's handler, and we do NOT call preventDefault() so the
* browser can still scroll the page.
*
* A brief "Ctrl+scroll to zoom" toast appears to teach the gesture.
*/
function attachCtrlZoom(cy: cytoscape.Core, container: HTMLElement): () => void {
const parent = container.parentElement ?? document.body;
let toastEl: HTMLDivElement | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null;
function showToast() {
if (!toastEl) {
toastEl = document.createElement("div");
toastEl.style.cssText = [
"position:absolute", "bottom:8px", "left:50%", "transform:translateX(-50%)",
"background:rgba(0,0,0,0.65)", "color:#fff", "font-size:0.78rem",
"padding:3px 10px", "border-radius:4px", "pointer-events:none",
"white-space:nowrap", "z-index:10", "opacity:0",
"transition:opacity 0.15s ease",
].join(";");
toastEl.textContent = "Ctrl + scroll to zoom";
const pos = getComputedStyle(container).position;
if (pos === "static") container.style.position = "relative";
container.appendChild(toastEl);
}
void toastEl.offsetWidth;
toastEl.style.opacity = "1";
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
if (toastEl) toastEl.style.opacity = "0";
}, 1200);
}
function onWheel(e: WheelEvent) {
// Only intercept events targeting the cy container or its children
if (!container.contains(e.target as Node)) return;
if (e.ctrlKey || e.metaKey) return; // Ctrl held → let Cytoscape zoom
// No Ctrl → stop the event before it reaches Cytoscape's capture handler
e.stopPropagation();
showToast();
}
parent.addEventListener("wheel", onWheel, { capture: true });
return () => {
parent.removeEventListener("wheel", onWheel, { capture: true } as EventListenerOptions);
if (toastEl) toastEl.remove();
if (toastTimer) clearTimeout(toastTimer);
};
}
// ── Type Reference Graph (TPFG, V13) ─────────────────────────────────────────
// Rich interactive force-directed class-to-class reference topology.
// Graph tab: SVG force graph with stable spring layout.
// Table tab: sortable/filterable edge table.
// Clicking a node opens a popover with class stats and navigation to instances.
// 8-color palette for package-prefix hashing (same scheme as domTree.tsx).
const TPFG_PALETTE = ["#6366f1","#10b981","#f59e0b","#3b82f6","#ef4444","#8b5cf6","#06b6d4","#ec4899"];
function tpfgColor(cls: string): string {
const pkg = cls.includes(".") ? cls.slice(0, cls.lastIndexOf(".")) : cls;
let h = 0;
for (let i = 0; i < pkg.length; i++) h = (Math.imul(31, h) + pkg.charCodeAt(i)) | 0;
return TPFG_PALETTE[Math.abs(h) % TPFG_PALETTE.length];
}
function tpfgShortName(cls: string): string {
const parts = cls.split(".");
return parts[parts.length - 1];
}
// Force-directed layout: 250 Verlet iterations (static — no animation).
interface FDNode { id: string; x: number; y: number; vx: number; vy: number; r: number; }
function runForceLayoutD3(
nodes: FDNode[],
edges: { src: number; dst: number }[],
w: number,
h: number,
): FDNode[] {
interface SimNode extends FDNode { fx?: number | null; fy?: number | null; }
const simNodes: SimNode[] = nodes.map(n => ({ ...n }));
const simLinks = edges
.filter(e => e.src >= 0 && e.src < simNodes.length && e.dst >= 0 && e.dst < simNodes.length)
.map(e => ({ source: e.src, target: e.dst }));
const sim = forceSimulation<SimNode>(simNodes)
.force("link", forceLink<SimNode, { source: number; target: number }>(simLinks).distance(90).strength(0.3))
.force("charge", forceManyBody<SimNode>().strength(-500))
.force("center", forceCenter<SimNode>(w / 2, h / 2))
.force("collide", forceCollide<SimNode>().radius((d) => d.r + 6).iterations(3))
.stop();
for (let i = 0; i < 300; i++) sim.tick();
return simNodes.map((sn, i) => ({
...nodes[i],
x: Math.max(nodes[i].r + 5, Math.min(w - nodes[i].r - 5, sn.x ?? w / 2)),
y: Math.max(nodes[i].r + 5, Math.min(h - nodes[i].r - 5, sn.y ?? h / 2)),
}));
}
function runForceLayout(
nodes: FDNode[], edges: { src: number; dst: number }[],
w: number, h: number,
): FDNode[] {
const ns = nodes.map(n => ({ ...n }));
const KR = 8000, KA = 0.06, REST = 70, DAMP = 0.85, ITER = 400;
for (let t = 0; t < ITER; t++) {
// Repulsion
for (let i = 0; i < ns.length; i++) {
for (let j = i + 1; j < ns.length; j++) {
const dx = ns[j].x - ns[i].x;
const dy = ns[j].y - ns[i].y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const f = KR / (dist * dist);
const fx = (dx / dist) * f;
const fy = (dy / dist) * f;
ns[i].vx -= fx; ns[i].vy -= fy;
ns[j].vx += fx; ns[j].vy += fy;
}
}
// Attraction
for (const e of edges) {
const a = ns[e.src], b = ns[e.dst];
if (!a || !b) continue;
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const f = KA * (dist - REST);
const fx = (dx / dist) * f;
const fy = (dy / dist) * f;
a.vx += fx; a.vy += fy;
b.vx -= fx; b.vy -= fy;
}
// Integrate + damp + clamp
const pad = 40;
for (const n of ns) {
n.vx *= DAMP; n.vy *= DAMP;
n.x = Math.max(pad + n.r, Math.min(w - pad - n.r, n.x + n.vx));
n.y = Math.max(pad + n.r, Math.min(h - pad - n.r, n.y + n.vy));
}
}
return ns;
}
// ── Heap Inspector types ──────────────────────────────────────────────────────
type InspectPage =
| { kind: "class"; cls: string }
| { kind: "instances"; cls: string; page: number }
| { kind: "instance"; idx: number; cls: string }
| { kind: "fields"; idx: number; cls: string }
| { kind: "gcroot"; idx: number; cls: string }
| { kind: "field-scan"; cls: string; fieldName: string };
function fireInspect(page: InspectPage) {
window.dispatchEvent(new CustomEvent("inspect", { detail: page }));
}
function TypeRefGraph({ edges, histogram, objGraph }: { edges: TypeEdge[]; histogram: HistRow[]; objGraph?: ObjGraphFlat | null }) {
const [fmtB] = useFmtBytes();
const [view, setView] = React.useState<"graph" | "table">("graph");
const [filter, setFilter] = React.useState("");
const [topN, setTopN] = React.useState(100);
const [sizeBy, setSizeBy] = React.useState<"retained" | "edges">("retained");
const [selected, setSelected] = React.useState<string | null>(null);
const [fullscreen, setFullscreen] = React.useState(false);
const [layoutKey, setLayoutKey] = React.useState(0);
const [showEdgeFields, setShowEdgeFields] = React.useState(false);
const [showAllOut, setShowAllOut] = React.useState(false);
const [showAllIn, setShowAllIn] = React.useState(false);
const [showAllSiblings, setShowAllSiblings] = React.useState(false);
const cyContainerRef = React.useRef<HTMLDivElement>(null);
const cyRef = React.useRef<cytoscape.Core | null>(null);
// Build histogram lookup map once
const histMap = React.useMemo(() => {
const m = new Map<string, HistRow>();
for (const r of histogram) m.set(r.pretty_class, r);
return m;
}, [histogram]);
// Esc closes fullscreen / popover
React.useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") { setFullscreen(false); setSelected(null); }
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, []);
// Build node set, filtered to topN by combined retained/edge weight
const { nodeInfos, graphEdges } = React.useMemo(() => {
const retByNode = new Map<string, number>();
const edgeCntByNode = new Map<string, number>();
for (const e of edges) {
retByNode.set(e.src_class, (retByNode.get(e.src_class) ?? 0) + e.retained_weight);
retByNode.set(e.dst_class, (retByNode.get(e.dst_class) ?? 0) + e.retained_weight);
edgeCntByNode.set(e.src_class, (edgeCntByNode.get(e.src_class) ?? 0) + e.edge_count);
edgeCntByNode.set(e.dst_class, (edgeCntByNode.get(e.dst_class) ?? 0) + e.edge_count);
}
const all = Array.from(retByNode.keys());
all.sort((a, b) => (retByNode.get(b) ?? 0) - (retByNode.get(a) ?? 0));
const kept = new Set(all.slice(0, topN));
const graphEdges = edges.filter(e => kept.has(e.src_class) && kept.has(e.dst_class));
const nodeInfos = Array.from(kept).map(cls => ({
cls,
retFlow: retByNode.get(cls) ?? 0,
edgeCount: edgeCntByNode.get(cls) ?? 0,
hist: histMap.get(cls) ?? null,
}));
return { nodeInfos, graphEdges };
}, [edges, histMap, topN]);
// Compute radius per node
const maxWeight = React.useMemo(
() => nodeInfos.reduce((m, n) => Math.max(m, sizeBy === "retained" ? n.retFlow : n.edgeCount), 1),
[nodeInfos, sizeBy],
);
const maxEdgeRet = React.useMemo(
() => graphEdges.reduce((m, e) => Math.max(m, e.retained_weight), 1),
[graphEdges],
);
const ogCtxForHint = React.useContext(ObjGraphCtx);
const hasDomData = React.useContext(HasDomDataCtx);
const [biggestWasmIdx, setBiggestWasmIdx] = React.useState<number | null>(null);
React.useEffect(() => {
setBiggestWasmIdx(null);
const wasmEx = (window as any).__wasmExploration;
if (!selected || !wasmEx?.find_instances) return;
try {
const r = JSON.parse(wasmEx.find_instances(selected, 1));
if (r.ok && r.matches?.length > 0) setBiggestWasmIdx(r.matches[0].dense_idx);
} catch { /* ignore */ }
}, [selected]);
const biggestStaticIdx = React.useMemo(() => {
if (!ogCtxForHint || !selected) return null;
let best: { idx: number; retained: number } | null = null;
for (const [k, n] of Object.entries(ogCtxForHint)) {
if (n.display_class === selected) {
const idx = parseInt(k, 10);
if (!best || n.retained > best.retained) best = { idx, retained: n.retained };
}
}
return best?.idx ?? null;
}, [ogCtxForHint, selected]);
const biggestIdx = biggestWasmIdx ?? biggestStaticIdx;
// Build Cytoscape elements and mount
React.useEffect(() => {
if (!cyContainerRef.current || view !== "graph") return;
void layoutKey;
if (nodeInfos.length === 0) return;
const elements: cytoscape.ElementDefinition[] = [
...nodeInfos.map(n => {
const w2 = sizeBy === "retained" ? n.retFlow : n.edgeCount;
const r = Math.max(6, Math.min(28, 6 + 22 * Math.sqrt(w2 / maxWeight)));
return {
data: {
id: n.cls,
label: tpfgShortName(n.cls),
size: r * 2,
color: tpfgColor(n.cls),
},
};
}),
...graphEdges.map((e, i) => {
const sw = Math.max(0.5, Math.min(4, 0.5 + 3.5 * Math.sqrt(e.retained_weight / maxEdgeRet)));
return {
data: {
id: `e${i}`,
source: e.src_class,
target: e.dst_class,
weight: sw,
fields: e.top_field_names && e.top_field_names.length > 0
? e.top_field_names.slice(0, 2).join(", ")
: undefined,
},
};
}),
];
const style = buildCyStyleWithFields(showEdgeFields);
const cy = makeCyInstance(cyContainerRef.current, elements, style);
cy.on("tap", "node", evt => {
const id = evt.target.data("id") as string;
setSelected(prev => {
const next = prev === id ? null : id;
applyCyHighlight(cy, next);
return next;
});
});
cy.on("tap", evt => {
if (evt.target === cy) { setSelected(null); applyCyHighlight(cy, null); }
});
cyRef.current?.destroy();
cyRef.current = cy;
const detachCtrlZoom = attachCtrlZoom(cy, cyContainerRef.current!);
return () => { detachCtrlZoom(); cy.destroy(); cyRef.current = null; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeInfos, graphEdges, layoutKey, sizeBy, maxWeight, maxEdgeRet, view]);
// Update edge field labels style when toggle changes without re-running layout
React.useEffect(() => {
if (!cyRef.current) return;
cyRef.current.style(buildCyStyleWithFields(showEdgeFields) as any).update();
}, [showEdgeFields]);
// Listen for external "select this class" events (e.g. from Object Explorer's "Open in Type Graph →")
React.useEffect(() => {
const handler = (e: Event) => {
const cls = (e as CustomEvent<string>).detail;
if (!cls) return;
setSelected(cls);
setView("graph");
// If the class is visible in the graph, pan to it; otherwise just select it (sidebar will appear)
setTimeout(() => {
const cy = cyRef.current;
if (!cy) return;
const node = cy.getElementById(cls);
if (node.length) { cy.animate({ fit: { eles: node, padding: 60 } }, { duration: 300 }); applyCyHighlight(cy, cls); }
}, 100);
};
window.addEventListener("trg-focus-class", handler);
return () => window.removeEventListener("trg-focus-class", handler);
}, []);
// Apply filter highlight via Cytoscape style
const filterLc = filter.toLowerCase().trim();
React.useEffect(() => {
const cy = cyRef.current;
if (!cy) return;
cy.nodes().removeStyle("opacity");
cy.edges().removeStyle("opacity");
if (!filterLc) return;
const matched = cy.nodes().filter(n => (n.data("id") as string).toLowerCase().includes(filterLc));
const unmatched = cy.nodes().difference(matched);
unmatched.style("opacity", 0.1);
cy.edges().forEach(e => {
const srcMatch = (e.source().data("id") as string).toLowerCase().includes(filterLc);
const dstMatch = (e.target().data("id") as string).toLowerCase().includes(filterLc);
if (!srcMatch || !dstMatch) e.style("opacity", 0.05);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterLc]);
// Sync node highlight when selected changes via sidebar link clicks
React.useEffect(() => {
if (!cyRef.current || filterLc) return;
applyCyHighlight(cyRef.current, selected);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected]);
// Reset show-all state when selection changes
React.useEffect(() => { setShowAllOut(false); setShowAllIn(false); setShowAllSiblings(false); }, [selected]);
const tableCols: TableColumn<TypeEdge>[] = [
{ id: "src_class", name: "Source Class", selector: r => r.src_class, sortable: true, wrap: true, grow: 2, maxWidth: "400px", cell: r => <span className="copy-cell"><code title={r.src_class}>{r.src_class}</code><CopyBtn text={r.src_class} /><PivotBtn cls={r.src_class} /><OqlBtn cls={r.src_class} /><ListObjectsBtn cls={r.src_class} /></span> },
{ id: "dst_class", name: "Dest Class", selector: r => r.dst_class, sortable: true, wrap: true, grow: 2, maxWidth: "400px", cell: r => <span className="copy-cell"><code title={r.dst_class}>{r.dst_class}</code><CopyBtn text={r.dst_class} /><PivotBtn cls={r.dst_class} /><OqlBtn cls={r.dst_class} /><ListObjectsBtn cls={r.dst_class} /></span> },
{ id: "edge_count", name: "Edge Count", selector: r => r.edge_count, sortable: true, right: true, width: "110px", format: r => fmtCount(r.edge_count) },
{ id: "retained_weight", name: "Retained Flow", selector: r => r.retained_weight, sortable: true, right: true, width: "130px", cell: r => <span title={fmtExactBytes(r.retained_weight)}>{fmtB(r.retained_weight)}</span> },
];
// Popover data for selected node
const selInfo = selected ? nodeInfos.find(n => n.cls === selected) : null;
// Use full edges (all 500 TypeEdge entries) so we don't miss edges to nodes outside the visible graph
const EDGE_SHOW = 10;
const selAllOutEdges = React.useMemo(
() => selected ? edges.filter(e => e.src_class === selected).sort((a, b) => b.retained_weight - a.retained_weight) : [],
[selected, edges],
);
const selAllInEdges = React.useMemo(
() => selected ? edges.filter(e => e.dst_class === selected).sort((a, b) => b.retained_weight - a.retained_weight) : [],
[selected, edges],
);
const selOutEdges = selAllOutEdges.slice(0, EDGE_SHOW);
const selInEdges = selAllInEdges.slice(0, EDGE_SHOW);
// Phase 2: build class → most-common idom class mapping from obj_graph_flat
// We walk the flat node list once and, for each class, tally its idom classes.
const classIdomMap = React.useMemo(() => {
if (!objGraph) return null;
// Map: class_name → Map<idom_class, count>
const tally = new Map<string, Map<string, number>>();
for (const [, node] of Object.entries(objGraph.nodes)) {
if (node.idom == null) continue;
const idomNode = objGraph.nodes[String(node.idom)];
if (!idomNode) continue;
const cls = node.display_class;
const idomCls = idomNode.display_class;
if (!tally.has(cls)) tally.set(cls, new Map());
const m = tally.get(cls)!;
m.set(idomCls, (m.get(idomCls) ?? 0) + 1);
}
// Reduce to best idom per class
const best = new Map<string, string>();
for (const [cls, m] of tally) {
let bestCls = "";
let bestCount = 0;
for (const [iCls, cnt] of m) {
if (cnt > bestCount) { bestCount = cnt; bestCls = iCls; }
}
if (bestCls) best.set(cls, bestCls);
}
return best;
}, [objGraph]);
// Sibling edges: edges from parent dominator class
const parentClass = selected && classIdomMap ? (classIdomMap.get(selected) ?? null) : null;
const siblingEdges = React.useMemo(() => {
if (!parentClass) return [];
return edges.filter(e => e.src_class === parentClass).sort((a, b) => b.retained_weight - a.retained_weight);
}, [parentClass, edges]);
const wrapStyle: React.CSSProperties = fullscreen
? { position: "fixed", inset: 0, background: "var(--bg)", zIndex: 9999, overflow: "auto", padding: "1rem" }
: {};
return (
<div style={wrapStyle}>
{/* Tab bar */}
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", flexWrap: "wrap", marginBottom: "0.75rem" }}>
{(["graph", "table"] as const).map(v => (
<button key={v} onClick={() => setView(v)} style={{
padding: "0.25rem 0.85rem", fontSize: "0.88rem",
border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer",
background: view === v ? "var(--accent)" : "transparent",
color: view === v ? "#fff" : "var(--fg)",
}}>{v === "graph" ? "⬡ Graph" : "⊞ Table"}</button>
))}
<span style={{ flex: 1 }} />
{view === "graph" && (
<>
<input type="text" className="filter" value={filter} onChange={e => setFilter(e.target.value)}
placeholder="Highlight class…" style={{ maxWidth: 200, fontSize: "0.82rem" }} />
<span style={{ fontSize: "0.82rem", color: "var(--muted)" }}>Top:</span>
{([50, 100, 150] as const).map(n => (
<button key={n} onClick={() => setTopN(n)} style={{
padding: "0.15rem 0.5rem", fontSize: "0.82rem",
border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer",
background: topN === n ? "var(--accent)" : "transparent",
color: topN === n ? "#fff" : "var(--fg)",
}}>{n}</button>
))}
<button onClick={() => setSizeBy(v => v === "retained" ? "edges" : "retained")}
style={{ padding: "0.15rem 0.55rem", fontSize: "0.82rem", border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer", background: "transparent", color: "var(--fg)" }}
title="Retained: size nodes by total retained heap bytes (best for finding memory bottlenecks). Edges: size nodes by reference count (best for finding heavily connected classes).">
Size: {sizeBy === "retained" ? "Retained" : "Edges"}
</button>
<button onClick={() => setShowEdgeFields(v => !v)}
style={{ padding: "0.15rem 0.55rem", fontSize: "0.82rem", border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer", background: showEdgeFields ? "var(--accent)" : "transparent", color: showEdgeFields ? "#fff" : "var(--fg)" }}
title="Show field names on edges">Fields</button>
<button onClick={() => { setLayoutKey(k => k + 1); setSelected(null); }}
style={{ padding: "0.15rem 0.55rem", fontSize: "0.82rem", border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer", background: "transparent", color: "var(--fg)" }}
title="Re-run force layout from scratch">↺ Layout</button>
<button onClick={() => cyRef.current?.fit(undefined, 24)}
style={{ padding: "0.15rem 0.55rem", fontSize: "0.82rem", border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer", background: "transparent", color: "var(--fg)" }}
title="Reset zoom and pan to default">⊡ View</button>
<button onClick={() => setFullscreen(f => !f)}
title={fullscreen ? "Exit fullscreen (Esc)" : "Expand to full window for easier exploration"}
style={{ padding: "0.15rem 0.55rem", fontSize: "0.82rem", border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer", background: "transparent", color: "var(--fg)" }}>
{fullscreen ? "⛶ Exit" : "⛶ Fullscreen"}
</button>
</>
)}
</div>
{/* Graph view */}
{view === "graph" && (
<div className="trg-graph-layout">
<div style={{ position: "relative", flex: 1, minWidth: 0 }}>
{nodeInfos.length === 0 && (
<p className="subtitle">No type reference data — re-run with <code>--obj-graph</code>.</p>
)}
{nodeInfos.length > 0 && (
<>
<div className="cy-graph-container" ref={cyContainerRef} />
<p style={{ fontSize: "0.74rem", color: "var(--muted)", margin: "0.25rem 0 0" }}>
Showing top {nodeInfos.length} classes by retained flow.
Ctrl+scroll to zoom · Drag background to pan · Drag nodes to reposition · Click node to inspect.
{(window as any).__wasmExploration
? <> · <span className="trg-hint-wasm">Heap loaded — live instance browsing available</span></>
: !ogCtxForHint
? <> · Tip: re-run with <code>--obj-graph</code> to enable instance browsing</>
: null}
</p>
</>
)}
</div>
{/* Sidebar — class preview panel */}
<div className="trg-sidebar">
{!selected ? (
<div className="trg-sidebar-empty">
<p>Click a class node to preview its stats and connections here.</p>
<p className="trg-sidebar-hint">Use "Full Details →", "Instances →", "Explorer →", or "In Dominator →" to navigate.</p>
</div>
) : (
<div className="trg-sidebar-content">
<div className="trg-sidebar-header">
<div className="trg-sidebar-dot" style={{ background: tpfgColor(selected) }} />
<code className="trg-sidebar-cls" title={selected}>{selected}</code>
<button className="trg-close-btn" onClick={() => setSelected(null)} title="Close">✕</button>
</div>
{selInfo?.hist && (
<table className="trg-stat-table trg-sidebar-stats">
<tbody>
<tr><th>Instances</th><td>{fmtCount(selInfo.hist.instances)}</td></tr>
<tr><th>Shallow</th><td><span title={fmtExactBytes(selInfo.hist.shallow)}>{fmtB(selInfo.hist.shallow)}</span></td></tr>
<tr><th>Retained</th><td><strong title={fmtExactBytes(selInfo.hist.retained)}>{fmtB(selInfo.hist.retained)}</strong></td></tr>
{parentClass && histMap.has(parentClass) && histMap.get(parentClass)!.retained > 0 && (
<>
<tr><th>Parent Dom.</th><td><button className="trg-link-btn" onClick={() => setSelected(parentClass)} title={parentClass}>{tpfgShortName(parentClass)}</button></td></tr>
<tr><th>% of Parent</th><td>{Math.round(selInfo.hist.retained / histMap.get(parentClass)!.retained * 100)}%</td></tr>
</>
)}
<tr><th>Max Instance</th><td><span title={fmtExactBytes(selInfo.hist.max_instance_shallow)}>{fmtB(selInfo.hist.max_instance_shallow)}</span></td></tr>
{selInfo.hist.instances > 0 && (
<tr><th>Avg Instance</th><td><span title={fmtExactBytes(Math.round(selInfo.hist.shallow / selInfo.hist.instances))}>{fmtB(Math.round(selInfo.hist.shallow / selInfo.hist.instances))}</span></td></tr>
)}
{selInfo.hist.incoming_ref_count != null && (
<tr><th>Incoming Refs</th><td>{fmtCount(selInfo.hist.incoming_ref_count)}</td></tr>
)}
</tbody>
</table>
)}
{(() => {
// Build top-field rows from all outbound edges' top_field_names
const fieldMap = new Map<string, { dst: string; count: number; weight: number }>();
for (const e of selAllOutEdges) {
if (!e.top_field_names || e.top_field_names.length === 0) continue;
for (const f of e.top_field_names) {
const existing = fieldMap.get(f);
if (existing) {
existing.count += e.edge_count;
existing.weight += e.retained_weight;
} else {
fieldMap.set(f, { dst: e.dst_class, count: e.edge_count, weight: e.retained_weight });
}
}
}
const fieldRows = Array.from(fieldMap.entries())
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 5);
if (fieldRows.length === 0) return null;
return (
<>
<p className="trg-sidebar-section-label">⊟ Top Fields</p>
<ul className="trg-edge-list trg-field-dist">
{fieldRows.map(([field, info], i) => (
<li key={i}>
<span className="trg-field-name-tag">{field}</span>
<span className="trg-field-dst">→ <button className="trg-link-btn" title={info.dst} onClick={() => setSelected(info.dst)}>{tpfgShortName(info.dst)}</button></span>
<span className="trg-edge-stat">×{fmtCount(info.count)} · <span title={fmtExactBytes(info.weight)}>{fmtB(info.weight)}</span></span>
</li>
))}
</ul>
</>
);
})()}
{selInfo?.hist?.root_path && selInfo.hist.root_path.length > 0 && (
<div className="trg-gcpath-section">
<p className="trg-sidebar-section-label">⊘ Shortest GC Root Path ({selInfo.hist.root_path.length} steps)</p>
<ol className="trg-gcpath-list">
{selInfo.hist.root_path.map((step, i) => (
<li key={i} style={{ fontSize: "0.76rem", color: i === 0 ? "var(--accent)" : i === selInfo!.hist!.root_path!.length - 1 ? "var(--ok, #065f46)" : undefined }}>
{step.display_class.split(".").pop()}
{step.root_type_label && <span style={{ color: "var(--muted)", marginLeft: "0.3em" }}>({step.root_type_label})</span>}
</li>
))}
</ol>
</div>
)}
{selAllOutEdges.length > 0 && (
<>
<p className="trg-sidebar-section-label">→ Outbound References ({fmtCount(selAllOutEdges.length)} total)</p>
<ul className="trg-edge-list">
{(showAllOut ? selAllOutEdges : selOutEdges).map((e, i) => (
<li key={i}>
<button className="trg-link-btn" title={e.dst_class} onClick={() => setSelected(e.dst_class)}>
{tpfgShortName(e.dst_class)}
</button>
<span className="trg-edge-stat">×{fmtCount(e.edge_count)} · <span title={fmtExactBytes(e.retained_weight)}>{fmtB(e.retained_weight)}</span></span>
{e.top_field_names && e.top_field_names.length > 0 && (
<span className="trg-field-names">via {e.top_field_names.join(", ")}</span>
)}
</li>
))}
</ul>
{selAllOutEdges.length > EDGE_SHOW && (
<button className="show-more-btn" style={{ fontSize: "0.78rem", marginTop: "0.25rem" }}
onClick={() => setShowAllOut(v => !v)}>
{showAllOut ? "Show fewer" : `Show ${fmtCount(selAllOutEdges.length - EDGE_SHOW)} more`}
</button>
)}
</>
)}
{selAllInEdges.length > 0 && (
<>
<p className="trg-sidebar-section-label">← Inbound References ({fmtCount(selAllInEdges.length)} total)</p>
<ul className="trg-edge-list">
{(showAllIn ? selAllInEdges : selInEdges).map((e, i) => (
<li key={i}>
<button className="trg-link-btn" title={e.src_class} onClick={() => setSelected(e.src_class)}>
{tpfgShortName(e.src_class)}
</button>
<span className="trg-edge-stat">×{fmtCount(e.edge_count)} · <span title={fmtExactBytes(e.retained_weight)}>{fmtB(e.retained_weight)}</span></span>
</li>
))}
</ul>
{selAllInEdges.length > EDGE_SHOW && (
<button className="show-more-btn" style={{ fontSize: "0.78rem", marginTop: "0.25rem" }}
onClick={() => setShowAllIn(v => !v)}>
{showAllIn ? "Show fewer" : `Show ${fmtCount(selAllInEdges.length - EDGE_SHOW)} more`}
</button>
)}
</>
)}
{parentClass && siblingEdges.length > 0 && (
<>
<p className="trg-sidebar-section-label" title={`Edges from parent dominator: ${parentClass}`}>
Siblings (from <button className="trg-link-btn" onClick={() => setSelected(parentClass)} title={parentClass}>{tpfgShortName(parentClass)}</button>)
</p>
<ul className="trg-edge-list">
{(showAllSiblings ? siblingEdges : siblingEdges.slice(0, 6)).map((e, i) => (
<li key={i} style={e.dst_class === selected ? { fontWeight: 600 } : undefined}>
<button className="trg-link-btn" onClick={() => setSelected(e.dst_class)} style={e.dst_class === selected ? { fontWeight: 600 } : undefined}>
{tpfgShortName(e.dst_class)}{e.dst_class === selected ? " → this" : ""}
</button>
<span className="trg-edge-stat">×{fmtCount(e.edge_count)} · <span title={fmtExactBytes(e.retained_weight)}>{fmtB(e.retained_weight)}</span></span>
</li>
))}
</ul>
{siblingEdges.length > 6 && (
<button className="show-more-btn" style={{ fontSize: "0.78rem", marginTop: "0.25rem" }}
onClick={() => setShowAllSiblings(v => !v)}>
{showAllSiblings ? "Show fewer" : `Show ${fmtCount(siblingEdges.length - 6)} more`}
</button>
)}
</>
)}
<div className="trg-sidebar-actions">
<button className="show-more-btn" title="Open class details, field stats, and reference chains in the floating Inspector panel"
onClick={() => fireInspect({ kind: "class", cls: selected })}>
Full Details →
</button>
<button className="show-more-btn" title="List all instances of this class in the floating Inspector panel" onClick={() => fireInspect({ kind: "instances", cls: selected, page: 0 })}>
Instances →
</button>
<button className="show-more-btn" title="Open class in Object Graph Explorer" onClick={() => {
window.dispatchEvent(new CustomEvent("explore-class", { detail: selected }));
history.replaceState(null, "", "#object-graph");
window.dispatchEvent(new CustomEvent("nav-toast", { detail: { label: "Updated in Object Graph Explorer", sectionId: "object-graph" } }));
}}>
Explorer →
</button>
{hasDomData && (
<button className="show-more-btn" title="Open in WhoHolds Dominator Sankey"
onClick={() => pivotClass(selected!)}>
In Dominator →
</button>
)}
{biggestIdx != null && (
<button className="show-more-btn" title="Open the largest instance of this class by retained heap in the floating Inspector panel"
onClick={() => fireInspect({ kind: "instance", idx: biggestIdx, cls: selected! })}>
Biggest Instance →
</button>
)}
</div>
<div className="trg-sidebar-btns">
<CopyBtn text={selected} />
<PivotBtn cls={selected} />
<OqlBtn cls={selected} />
<ListObjectsBtn cls={selected} />
</div>
</div>
)}
</div>
</div>
)}
{/* Table view */}
{view === "table" && (
<StdTable
columns={tableCols}
data={edges}
searchKeys={["src_class", "dst_class"]}
defaultSortFieldId="retained_weight"
defaultSortAsc={false}
extraBtns={<CopyTsvBtn rows={[["Source Class","Dest Class","Edge Count","Retained Flow (bytes)"],...edges.map(e=>[e.src_class,e.dst_class,String(e.edge_count),String(e.retained_weight)])]} label="Copy as TSV" />}
/>
)}
</div>
);
}
// ── Object Graph Explorer (V3 + V4) ──────────────────────────────────────────
function WasmQueryPanel({
nodeId,
session,
data,
}: {
nodeId: number;
session: any;
data: ObjGraphFlat;
}) {
const node = data.nodes[String(nodeId)];
const defaultQuery = node
? `SELECT * FROM ${node.display_class} s WHERE s.@objectId = ${nodeId}`
: `SELECT * FROM java.lang.Object s WHERE s.@objectId = ${nodeId}`;
const [queryText, setQueryText] = React.useState(defaultQuery);
const [result, setResult] = React.useState<QueryResult | null>(null);
const [queryError, setQueryError] = React.useState<string | null>(null);
const [running, setRunning] = React.useState(false);
const [history, setHistory] = React.useState<string[]>([]);
React.useEffect(() => {
const n = data.nodes[String(nodeId)];
const q = n
? `SELECT * FROM ${n.display_class} s WHERE s.@objectId = ${nodeId}`
: `SELECT * FROM java.lang.Object s WHERE s.@objectId = ${nodeId}`;
setQueryText(q);
setResult(null);
setQueryError(null);
}, [nodeId, data]);
const runQuery = () => {
setRunning(true);
setHistory(prev => {
const deduped = prev.filter(q => q !== queryText);
return [queryText, ...deduped].slice(0, 10);
});
setResult(null);
setQueryError(null);
try {
const raw = JSON.parse(session.query(queryText));
if (raw.ok) {
setResult(raw.result as QueryResult);
} else {
const err = raw.error;
const loc = err.location ? ` (line ${err.location.line}, col ${err.location.col})` : "";
setQueryError(err.message + loc);
}
} catch (e: any) {
setQueryError(String(e));
}
setRunning(false);
};
return (
<div style={{ marginTop: "0.75rem", borderTop: "1px solid var(--border-faint, #f0f0f0)", paddingTop: "0.5rem" }}>
<p style={{ fontSize: "0.75rem", color: "var(--muted)", margin: "0 0 0.3rem 0", fontWeight: 600 }}>
OQL Query
</p>
{history.length > 0 && (
<div style={{ marginBottom: "0.3rem", display: "flex", flexWrap: "wrap", gap: "0.25rem" }}>
{history.map((q, i) => (
<button key={q} className="btn-link"
style={{ fontSize: "0.72rem", background: "var(--accent-muted, #dbeafe)", color: "var(--accent)", borderRadius: 4, padding: "1px 5px", maxWidth: "24em", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
title={q}
onClick={() => setQueryText(q)}>
{q.length > 40 ? q.slice(0, 38) + "…" : q}
</button>
))}
</div>
)}
<div style={{ display: "flex", gap: "0.3rem", alignItems: "flex-start" }}>
<textarea
value={queryText}
onChange={(e) => setQueryText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); runQuery(); } }}
rows={3}
style={{
flex: 1, fontFamily: "monospace", fontSize: "0.78rem", padding: "4px 6px",
border: "1px solid var(--border)", borderRadius: 4, resize: "vertical",
background: "var(--input-bg, var(--bg))", color: "inherit", boxSizing: "border-box",
}}
/>
<button
onClick={runQuery}
disabled={running || !queryText.trim()}
style={{
padding: "4px 10px", fontSize: "0.82rem", border: "1px solid var(--border)",
borderRadius: 4, cursor: "pointer", background: "var(--accent, #3b82f6)", color: "#fff",
flexShrink: 0, whiteSpace: "nowrap",
}}
>
{running ? "…" : "Run ▶"}
</button>
</div>
<p style={{ fontSize: "0.7rem", color: "var(--muted)", margin: "2px 0 0.3rem" }}>
Ctrl+Enter to run
</p>
{queryError && (
<p style={{ fontSize: "0.8rem", color: "var(--error, #ef4444)", margin: "0.3rem 0 0" }}>
{queryError}
</p>
)}
{result && (() => {
const rows = result.rows.slice(0, 200);
const cols = result.columns;
return (
<>
<table className="std-table" style={{ marginTop: "0.4rem", fontSize: "0.78rem" }}>
<thead>
<tr>
{cols.map((c: { name: string }, i: number) => (
<th key={i}>{c.name}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row: QueryValue[], ri: number) => (
<tr key={ri}>
{row.map((val, ci) => (
<td key={ci}>
<QueryCell val={val} colName={cols[ci]?.name ?? ""} />
</td>
))}
</tr>
))}
</tbody>
</table>
<p style={{ fontSize: "0.75rem", color: "var(--muted)", margin: "0.2rem 0 0" }}>
{result.row_count} row(s){result.row_count > 200 ? " (showing first 200)" : ""}
{result.truncated ? ", truncated" : ""}
</p>
</>
);
})()}
</div>
);
}
function WasmGcPathPanel({ nodeId, session, data, fmtB, navigate }: {
nodeId: number;
session: any;
data: ObjGraphFlat;
fmtB: (b: number) => string;
navigate: (id: number) => void;
}) {
const [pathData, setPathData] = React.useState<any>(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
setLoading(true);
try {
const result = JSON.parse(session.gc_root_path(nodeId));
setPathData(result.ok ? result : null);
} catch { setPathData(null); }
setLoading(false);
}, [nodeId, session]);
if (loading) return <p className="subtitle">Computing shortest path…</p>;
if (!pathData) return <p className="subtitle">No path to GC root found.</p>;
const chainNodes: ChainNode[] = (pathData.path as any[]).map((step: any, i: number, arr: any[]) => ({
denseIdx: step.dense_idx,
displayClass: step.display_class || data.nodes[String(step.dense_idx)]?.display_class || `obj#${step.dense_idx}`,
retained: step.retained ?? data.nodes[String(step.dense_idx)]?.retained ?? 0,
fieldName: step.field_name || undefined,
isFirst: i === 0,
isCurrent: i === arr.length - 1,
}));
return (
<RetentionChain
nodes={chainNodes}
rootBadge={pathData.root_type}
data={data}
session={session}
fmtB={fmtB}
navigate={navigate}
/>
);
}
type ChainNode = {
denseIdx: number;
displayClass: string;
retained: number;
fieldName?: string;
isFirst?: boolean;
isCurrent?: boolean;
};
function RetentionChain({
nodes,
rootBadge,
data,
session,
fmtB,
navigate,
}: {
nodes: ChainNode[];
rootBadge?: string;
data: ObjGraphFlat;
session?: any;
fmtB: (b: number) => string;
navigate: (denseIdx: number) => void;
}) {
const [expanded, setExpanded] = React.useState<Set<number>>(new Set());
const [refs, setRefs] = React.useState<Map<number, { field: string; denseIdx: number; displayClass: string; retained: number }[]>>(new Map());
const [showAllRefs, setShowAllRefs] = React.useState<Set<number>>(new Set());
const fetchRefs = (denseIdx: number) => {
if (refs.has(denseIdx)) return;
const staticEdges = data.edges[String(denseIdx)] ?? [];
if (staticEdges.length > 0) {
setRefs(m => {
const next = new Map(m);
next.set(denseIdx, staticEdges.map(e => ({
field: e.field_name || "",
denseIdx: e.child_idx,
displayClass: data.nodes[String(e.child_idx)]?.display_class ?? `#${e.child_idx}`,
retained: data.nodes[String(e.child_idx)]?.retained ?? 0,
})));
return next;
});
return;
}
if (!session?.outbound_refs) return;
try {
const r = JSON.parse(session.outbound_refs(denseIdx, 50));
if (r.ok) {
setRefs(m => {
const next = new Map(m);
next.set(denseIdx, (r.refs as any[]).map(ref => ({
field: ref.field_name || "",
denseIdx: ref.dst_idx,
displayClass: ref.display_class ?? `#${ref.dst_idx}`,
retained: ref.retained ?? 0,
})));
return next;
});
}
} catch {}
};
const toggleExpand = (denseIdx: number) => {
setExpanded(s => {
const next = new Set(s);
if (next.has(denseIdx)) { next.delete(denseIdx); }
else { next.add(denseIdx); }
return next;
});
if (!expanded.has(denseIdx)) fetchRefs(denseIdx);
};
return (
<div style={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
{rootBadge && (
<div style={{
display: "inline-block", border: "2px solid var(--accent, #3b82f6)",
borderRadius: 4, padding: "2px 8px", background: "var(--accent-muted, #dbeafe)",
color: "var(--accent)", fontWeight: 600, fontSize: "0.76rem", marginBottom: "2px",
}}>
[{rootBadge}]
</div>
)}
{nodes.map((node, i) => {
const isExp = expanded.has(node.denseIdx);
const nodeRefs = refs.get(node.denseIdx) ?? [];
const showAll = showAllRefs.has(node.denseIdx);
const visibleRefs = showAll ? nodeRefs : nodeRefs.slice(0, 8);
const canExpand = !node.isCurrent;
return (
<React.Fragment key={`${node.denseIdx}-${i}`}>
{(!node.isFirst || rootBadge) && (
<div style={{ color: "var(--muted)", fontSize: "0.74rem", paddingLeft: "0.5rem" }}>
│{node.fieldName ? ` .${node.fieldName}` : ""}
</div>
)}
{(!node.isFirst || rootBadge) && (
<div style={{ color: "var(--muted)", paddingLeft: "0.5rem", fontSize: "0.78rem" }}>▼</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
{canExpand ? (
<button
className="btn-link"
style={{ fontSize: "0.7rem", width: "1.2em", flexShrink: 0, color: "var(--muted)" }}
title={isExp ? "Collapse outbound references" : "Expand outbound references"}
onClick={() => toggleExpand(node.denseIdx)}
>
{isExp ? "▼" : "▶"}
</button>
) : (
<span style={{ fontSize: "0.7rem", width: "1.2em", flexShrink: 0, color: "var(--accent)" }}>●</span>
)}
{node.isCurrent ? (
<span style={{ fontFamily: "monospace", fontSize: "0.8rem", fontWeight: 600 }}>
{node.displayClass}
</span>
) : (
<button className="btn-link" style={{ fontFamily: "monospace", fontSize: "0.8rem" }}
onClick={() => navigate(node.denseIdx)}>
{node.displayClass}
</button>
)}
<span style={{ color: "var(--muted)", fontSize: "0.74rem", whiteSpace: "nowrap" }}>
<span title={fmtExactBytes(node.retained)}>{fmtB(node.retained)}</span>
</span>
{node.isCurrent && (
<span style={{ fontSize: "0.7rem", color: "var(--muted)", fontStyle: "italic" }}>← here</span>
)}
</div>
{isExp && (
<div style={{ paddingLeft: "2rem", marginTop: "1px", marginBottom: "2px" }}>
{visibleRefs.length === 0 ? (
<span style={{ fontSize: "0.74rem", color: "var(--muted)" }}>No outbound references captured.</span>
) : visibleRefs.map((ref, ri) => (
<div key={ri} style={{ display: "flex", alignItems: "center", gap: "0.3rem", fontSize: "0.76rem", padding: "1px 0" }}>
<span style={{ color: "var(--muted)", flexShrink: 0 }}>
{ri === visibleRefs.length - 1 && !(!showAll && nodeRefs.length > 8) ? "└─" : "├─"}
</span>
{ref.field && <code style={{ fontSize: "0.72rem", color: "var(--muted)", flexShrink: 0 }}>.{ref.field}</code>}
<button className="btn-link" style={{ fontSize: "0.76rem", fontFamily: "monospace" }}
onClick={() => navigate(ref.denseIdx)}>
{ref.displayClass.split(".").pop()}
</button>
<span style={{ color: "var(--muted)", fontSize: "0.72rem", whiteSpace: "nowrap" }} title={fmtExactBytes(ref.retained)}>{fmtB(ref.retained)}</span>
<button className="btn-link" style={{ fontSize: "0.72rem", opacity: 0.6 }}
title="Navigate to this object"
onClick={() => navigate(ref.denseIdx)}>→</button>
</div>
))}
{!showAll && nodeRefs.length > 8 && (
<div style={{ fontSize: "0.74rem", color: "var(--muted)", paddingLeft: "1.2em" }}>
<button className="btn-link" style={{ fontSize: "0.74rem" }}
onClick={() => setShowAllRefs(s => { const n = new Set(s); n.add(node.denseIdx); return n; })}>
… {nodeRefs.length - 8} more
</button>
</div>
)}
</div>
)}
</React.Fragment>
);
})}
</div>
);
}
function WasmInboundPanel({ nodeId, session, fmtB, onNavigate, onNavigateDomtree }: {
nodeId: number; session: any; fmtB: (b: number) => string;
onNavigate: (id: number) => void; onNavigateDomtree: (id: number) => void;
}) {
const [refs, setRefs] = React.useState<any[]>([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
setLoading(true);
try {
const r = JSON.parse(session.inbound_refs(nodeId, 200));
if (r.ok) { setRefs(r.refs); setTotal(r.total); }
} catch {}
setLoading(false);
}, [nodeId, session]);
if (loading) return <p className="subtitle">Loading…</p>;
if (!refs.length) return <p className="subtitle">No inbound references found.</p>;
return (
<>
<table className="std-table">
<thead><tr><th>Field</th><th>Class</th><th style={{ textAlign: "right" }}>Shallow</th><th style={{ textAlign: "right" }}>Retained</th></tr></thead>
<tbody>
{refs.map((r: any, i: number) => (
<tr key={i}>
<td style={{ color: "var(--muted)" }}><code>{r.field_name || "—"}</code></td>
<td>
<span className="copy-cell">
<button className="btn-link" onClick={() => onNavigate(r.src_idx)}><code title={r.display_class}>{r.display_class}</code></button>
<button className="btn-link" title="Open in dominator tree" style={{ opacity: 0.6, flexShrink: 0 }} onClick={() => onNavigateDomtree(r.src_idx)}>⌞</button>
<PivotBtn cls={r.display_class} />
<OqlBtn cls={r.display_class} />
<ListObjectsBtn cls={r.display_class} />
</span>
</td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(r.shallow)}>{fmtB(r.shallow)}</span></td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(r.retained)}>{fmtB(r.retained)}</span></td>
</tr>
))}
</tbody>
</table>
{total > refs.length && <p className="subtitle" style={{ fontSize: "0.78rem" }}>Showing {fmtCount(refs.length)} of {fmtCount(total)} inbound references.</p>}
</>
);
}
// ── OGE Graph View ──────────────────────────────────────────────────────────────
function OGEGraphView({ data, onNavigate }: {
data: ObjGraphFlat;
onNavigate: (nodeId: number, label: string) => void;
}) {
const [topN, setTopN] = React.useState(80);
const [layoutKey, setLayoutKey] = React.useState(0);
const [graphSelected, setGraphSelected] = React.useState<number | null>(null);
const [fmtB] = useFmtBytes();
const cyContainerRef = React.useRef<HTMLDivElement>(null);
const cyRef = React.useRef<cytoscape.Core | null>(null);
// Select top-N nodes by retained
const topNodes = React.useMemo(() => {
return Object.entries(data.nodes)
.map(([k, n]) => ({ id: k, nodeId: parseInt(k, 10), ...n }))
.sort((a, b) => b.retained - a.retained)
.slice(0, topN);
}, [data.nodes, topN]);
const topNodeSet = React.useMemo(() => new Set(topNodes.map(n => n.id)), [topNodes]);
const cyElements = React.useMemo(() => {
const maxRet = topNodes.reduce((m, n) => Math.max(m, n.retained), 1);
const nodeEls: cytoscape.ElementDefinition[] = topNodes.map(n => ({
data: {
id: n.id,
label: (n.display_class.split(".").pop() ?? n.display_class),
size: Math.max(12, Math.min(56, Math.sqrt(n.retained / maxRet) * 64)),
color: tpfgColor(n.display_class),
},
}));
const edgeEls: cytoscape.ElementDefinition[] = [];
const seen = new Set<string>();
for (const n of topNodes) {
const srcEdges = data.edges[n.id] ?? [];
for (const e of srcEdges) {
const dstKey = String(e.child_idx);
if (!topNodeSet.has(dstKey) || n.id === dstKey) continue;
const key = `${n.id}→${dstKey}`;
if (seen.has(key)) continue;
seen.add(key);
edgeEls.push({
data: {
id: key,
source: n.id,
target: dstKey,
weight: 1,
fields: e.field_name ? `.${e.field_name}` : undefined,
},
});
}
}
return [...nodeEls, ...edgeEls];
}, [topNodes, topNodeSet, data.edges]);
React.useEffect(() => {
if (!cyContainerRef.current) return;
void layoutKey;
if (cyElements.length === 0) return;
const style = buildCyStyleWithFields(true);
const cy = makeCyInstance(cyContainerRef.current, cyElements, style);
cy.on("tap", "node", evt => {
const id = parseInt(evt.target.data("id") as string, 10);
applyCyHighlight(cy, String(id));
setGraphSelected(id);
const nodeInfo = data.nodes[String(id)];
if (nodeInfo) fireInspect({ kind: "instance", idx: id, cls: nodeInfo.display_class });
});
cy.on("tap", evt => {
if (evt.target === cy) { setGraphSelected(null); applyCyHighlight(cy, null); }
});
cyRef.current?.destroy();
cyRef.current = cy;
const detachCtrlZoom = attachCtrlZoom(cy, cyContainerRef.current!);
return () => { detachCtrlZoom(); cy.destroy(); cyRef.current = null; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cyElements, layoutKey]);
// Selected node sidebar data
const selectedNodeData = graphSelected !== null ? data.nodes[String(graphSelected)] : null;
const selectedStaticEdges = graphSelected !== null ? (data.edges[String(graphSelected)] ?? []) : [];
const [wasmFields, setWasmFields] = React.useState<any[] | null>(null);
const [wasmOutRefs, setWasmOutRefs] = React.useState<any[] | null>(null);
React.useEffect(() => {
setWasmFields(null);
setWasmOutRefs(null);
if (graphSelected === null) return;
const session = (window as any).__wasmSession;
if (!session) return;
try {
if (session.get_field_values) {
const r = JSON.parse(session.get_field_values(graphSelected));
if (r.ok) setWasmFields(r.fields ?? []);
}
} catch { /* ignore */ }
try {
if (session.outbound_refs) {
const r = JSON.parse(session.outbound_refs(graphSelected, 20));
if (r.ok) setWasmOutRefs(r.refs ?? []);
}
} catch { /* ignore */ }
}, [graphSelected]);
const shortLabel = (cls: string) => cls.split(".").pop() ?? cls;
return (
<div style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start", flexWrap: "wrap" }}>
{/* Controls */}
<div style={{ width: "100%" }}>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginBottom: "0.4rem", flexWrap: "wrap" }}>
<span style={{ fontSize: "0.8rem", color: "var(--muted)" }}>Top N:</span>
{([40, 80, 120] as const).map(n => (
<button key={n}
className={topN === n ? "btn-active" : "btn-link"}
style={{ fontSize: "0.8rem", padding: "1px 7px" }}
onClick={() => { setTopN(n); setGraphSelected(null); }}>
{n}
</button>
))}
<button className="btn-link" style={{ fontSize: "0.8rem", marginLeft: "0.5rem" }}
onClick={() => { setLayoutKey(k => k + 1); }}>
↺ Re-layout
</button>
<button className="btn-link" style={{ fontSize: "0.8rem" }}
onClick={() => cyRef.current?.fit(undefined, 24)}>
⊡ View
</button>
<span style={{ fontSize: "0.75rem", color: "var(--muted)", marginLeft: "auto" }}>
{topNodes.length} nodes
</span>
</div>
</div>
{/* Cytoscape graph */}
<div className="cy-graph-container" ref={cyContainerRef} style={{ flex: "1 1 480px" }} />
{/* Sidebar */}
<div style={{ flex: "0 0 220px", minWidth: 180, fontSize: "0.82rem", maxHeight: 440, overflowY: "auto" }}>
{!graphSelected || !selectedNodeData ? (
<div style={{ color: "var(--muted)", padding: "0.5rem 0" }}>
<p style={{ margin: "0 0 0.3rem" }}>Click a node for details.</p>
<p style={{ margin: 0, fontSize: "0.76rem" }}>Nodes sized by retained heap, colored by class.</p>
</div>
) : (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "0.3rem", marginBottom: "0.4rem" }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: "0.3rem" }}>
<span style={{ width: 10, height: 10, borderRadius: "50%", background: tpfgColor(selectedNodeData.display_class), display: "inline-block", flexShrink: 0 }} />
<code style={{ fontSize: "0.78rem", wordBreak: "break-all" }} title={selectedNodeData.display_class}>
{shortLabel(selectedNodeData.display_class)}
</code>
</div>
<div style={{ color: "var(--muted)", fontSize: "0.75rem", marginTop: "0.15rem", paddingLeft: "1.2rem" }}>
#{graphSelected}
</div>
</div>
<button className="copy-btn" onClick={() => setGraphSelected(null)} title="Close">✕</button>
</div>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.8rem", marginBottom: "0.4rem" }}>
<tbody>
<tr>
<th style={{ textAlign: "left", color: "var(--muted)", fontWeight: 400, paddingRight: "0.5rem" }}>Shallow</th>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(selectedNodeData.shallow)}>{fmtB(selectedNodeData.shallow)}</span></td>
</tr>
<tr>
<th style={{ textAlign: "left", color: "var(--muted)", fontWeight: 400, paddingRight: "0.5rem" }}>Retained</th>
<td style={{ textAlign: "right" }}><strong title={fmtExactBytes(selectedNodeData.retained)}>{fmtB(selectedNodeData.retained)}</strong></td>
</tr>
</tbody>
</table>
<button className="btn-link"
style={{ fontSize: "0.8rem", marginBottom: "0.5rem", display: "block" }}
onClick={() => onNavigate(graphSelected, selectedNodeData.display_class)}>
Open in Explorer →
</button>
{/* WASM fields */}
{wasmFields && wasmFields.length > 0 && (
<>
<p style={{ margin: "0 0 0.2rem", color: "var(--muted)", fontSize: "0.75rem" }}>Fields</p>
<ul style={{ margin: 0, padding: 0, listStyle: "none", fontSize: "0.77rem" }}>
{[...wasmFields].sort((a: any, b: any) => {
if (a.kind === "ref" && b.kind !== "ref") return -1;
if (a.kind !== "ref" && b.kind === "ref") return 1;
return 0;
}).slice(0, 5).map((f: any, i: number) => (
<li key={i} style={{ marginBottom: "0.1rem" }}>
<span style={{ color: "var(--muted)" }}>{f.name}: </span>
<span>{String(f.value ?? f.display_class ?? "")}</span>
</li>
))}
</ul>
<button className="show-more-btn" style={{ marginTop: "0.3rem" }}
onClick={() => fireInspect({ kind: "fields", idx: graphSelected!, cls: selectedNodeData!.display_class })}>
All Fields →
</button>
</>
)}
{/* WASM outbound refs */}
{wasmOutRefs && wasmOutRefs.length > 0 && (
<>
<p style={{ margin: "0.4rem 0 0.2rem", color: "var(--muted)", fontSize: "0.75rem" }}>Refs →</p>
<ul style={{ margin: 0, padding: 0, listStyle: "none", fontSize: "0.77rem" }}>
{wasmOutRefs.slice(0, 8).map((r: any, i: number) => (
<li key={i} style={{ marginBottom: "0.1rem" }}>
<button className="btn-link" style={{ fontSize: "0.77rem" }}
onClick={() => onNavigate(r.dst_idx, r.display_class)}>
{shortLabel(r.display_class)}#{r.dst_idx}
</button>
</li>
))}
</ul>
</>
)}
{/* Static edges (when no WASM) */}
{!wasmOutRefs && selectedStaticEdges.length > 0 && (
<>
<p style={{ margin: "0.4rem 0 0.2rem", color: "var(--muted)", fontSize: "0.75rem" }}>Edges →</p>
<ul style={{ margin: 0, padding: 0, listStyle: "none", fontSize: "0.77rem" }}>
{selectedStaticEdges.slice(0, 8).map((e, i) => (
<li key={i} style={{ marginBottom: "0.1rem" }}>
<button className="btn-link" style={{ fontSize: "0.77rem" }}
onClick={() => onNavigate(e.child_idx, e.child_class)}>
{shortLabel(e.child_class)}#{e.child_idx}
</button>
</li>
))}
</ul>
</>
)}
</div>
)}
</div>
</div>
);
}
function ObjectGraphExplorer({ data, totalHeapOverride }: { data: ObjGraphFlat; totalHeapOverride?: number }) {
const [tab, setTab] = React.useState<"explore" | "domtree" | "graph">("explore");
const [nodeId, setNodeId] = React.useState<number | null>(null);
const [breadcrumb, setBreadcrumb] = React.useState<{ nodeId: number; label: string; edge?: string; sourceTab?: "explore" | "domtree" }[]>([]);
const [forwardStack, setForwardStack] = React.useState<{ nodeId: number; label: string; edge?: string; sourceTab?: "explore" | "domtree" }[]>([]);
const [page, setPage] = React.useState(0);
const [showSvg, setShowSvg] = React.useState(false);
const [jumpInput, setJumpInput] = React.useState("");
const [pendingLabel, setPendingLabel] = React.useState<string | null>(null);
const [expandedGroups, setExpandedGroups] = React.useState<Set<string>>(new Set());
// Per-node pending expand groups: nodeId → groupKey. Survives state-reset races during navigation.
const pendingExpandByNode = React.useRef<Map<number, string>>(new Map());
const [domFilter, setDomFilter] = React.useState("");
const [domViewMode, setDomViewMode] = React.useState<"flat" | "grouped" | "expanded">("flat");
const [expandedDomList, setExpandedDomList] = React.useState<
{ id: number; depth: number; display_class: string; shallow: number; retained: number }[] | null
>(null);
const [expandFilter, setExpandFilter] = React.useState("");
const [refFilter, setRefFilter] = React.useState("");
const [rootFilter, setRootFilter] = React.useState("");
const [rootViewMode, setRootViewMode] = React.useState<"instances" | "classes">("instances");
const [rootSort, setRootSort] = React.useState<{ col: "class" | "idx" | "shallow" | "retained"; asc: boolean }>({ col: "retained", asc: false });
const [showAllInbound, setShowAllInbound] = React.useState(false);
const [pathDepth, setPathDepth] = React.useState(8);
const [activeRefTab, setActiveRefTab] = React.useState<"outbound" | "inbound">("outbound");
const [bannerDismissed, setBannerDismissed] = React.useState(
sessionStorage.getItem("wasm-banner-dismissed") === "1"
);
const [showGcPath, setShowGcPath] = React.useState(false);
const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0);
const [liveSearchQuery, setLiveSearchQuery] = React.useState("");
const [liveSearchResults, setLiveSearchResults] = React.useState<any[] | null>(null);
const [liveSearchTotal, setLiveSearchTotal] = React.useState(0);
const [liveSearchTruncated, setLiveSearchTruncated] = React.useState(false);
const [fmtB] = useFmtBytes();
const containerRef = React.useRef<HTMLDivElement>(null);
// Remember the last active rootFilter so "⌂ Roots" returns to the filtered list
const savedRootFilter = React.useRef("");
// Track which node was last navigated to via navigate() so external hash changes can clear the breadcrumb
const lastInternalNavRef = React.useRef<number | null>(null);
// Live outbound refs from WASM (populated when static snapshot has no edges for this node)
const [wasmOutboundEdges, setWasmOutboundEdges] = React.useState<ObjGraphEdge[] | null>(null);
const [wasmOutboundTotal, setWasmOutboundTotal] = React.useState(0);
const [wasmOutboundTruncated, setWasmOutboundTruncated] = React.useState(false);
// WASM data for below-threshold nodes (not in static graph)
const [wasmBelowInfo, setWasmBelowInfo] = React.useState<{display_class: string; shallow: number; retained: number} | null>(null);
const [wasmBelowOutbound, setWasmBelowOutbound] = React.useState<{dst_idx: number; field_name: string; display_class: string; shallow: number; retained: number}[] | null>(null);
const [wasmBelowInbound, setWasmBelowInbound] = React.useState<{src_idx: number; field_name: string; display_class: string; shallow: number; retained: number}[] | null>(null);
const [showBelowGcPath, setShowBelowGcPath] = React.useState(false);
const [wasmPeerInstances, setWasmPeerInstances] = React.useState<{dense_idx: number; display_class: string; retained: number}[] | null>(null);
const [wasmPeerTotal, setWasmPeerTotal] = React.useState(0);
const [wasmFieldValues, setWasmFieldValues] = React.useState<{name: string; kind: string; value?: any; display_class?: string; dense_idx?: number}[] | null>(null);
const [wasmCollEntries, setWasmCollEntries] = React.useState<{type: string; entries: any[]; truncated: boolean} | null>(null);
const [wasmAllPaths, setWasmAllPaths] = React.useState<{paths: {path: {dense_idx: number; display_class: string; shallow: number; retained: number}[]; root_type: string}[]; total_found: number} | null>(null);
const [showAllPaths, setShowAllPaths] = React.useState(false);
const [pinnedNodes, setPinnedNodes] = React.useState<{nodeId: number; label: string}[]>([]);
const togglePin = (id: number, label: string) => {
setPinnedNodes(prev => {
if (prev.some(p => p.nodeId === id)) return prev.filter(p => p.nodeId !== id);
return [...prev.slice(-4), { nodeId: id, label }];
});
};
const [pathSource, setPathSource] = React.useState<{nodeId: number; label: string} | null>(null);
const [pathBetweenResult, setPathBetweenResult] = React.useState<any[] | null>(null);
const [pathBetweenError, setPathBetweenError] = React.useState<string | null>(null);
const hasDomData = React.useContext(HasDomDataCtx);
React.useEffect(() => {
setWasmOutboundEdges(null);
setWasmOutboundTotal(0);
setWasmOutboundTruncated(false);
setPathBetweenResult(null);
setPathBetweenError(null);
const session = (window as any).__wasmSession;
if (!session?.outbound_refs || nodeId === null) return;
// Fetch live outbound refs via WASM when static snapshot has no edges or node is unknown
const hasStaticEdges = data.edges[String(nodeId)] != null;
const inCapture = data.nodes[String(nodeId)] != null;
if (hasStaticEdges && !data.nodes[String(nodeId)]?.edges_truncated) return;
try {
const r = JSON.parse(session.outbound_refs(nodeId, 200));
if (r.ok && r.refs.length > 0) {
const edges: ObjGraphEdge[] = r.refs.map((ref: any) => ({
field_name: ref.field_name ?? "",
child_idx: ref.dst_idx,
child_class: ref.display_class,
child_retained: ref.retained,
}));
setWasmOutboundEdges(edges);
setWasmOutboundTotal(r.total);
setWasmOutboundTruncated(r.truncated);
}
} catch {}
void inCapture; // suppress lint
}, [nodeId, data.edges, data.nodes]);
// Peer instances panel: find top instances of same class via WASM find_instances
React.useEffect(() => {
setWasmPeerInstances(null);
setWasmPeerTotal(0);
const wasm = (window as any).__wasmExploration;
const node = nodeId !== null ? data.nodes[String(nodeId)] : null;
if (!wasm?.find_instances || nodeId === null || !node) return;
try {
const r = JSON.parse(wasm.find_instances(node.display_class, 11));
if (r.ok && r.matches) {
const peers = (r.matches as any[]).filter((m: any) => m.dense_idx !== nodeId).slice(0, 10);
setWasmPeerInstances(peers);
setWasmPeerTotal(r.total);
}
} catch {}
}, [nodeId, data.nodes]);
// Field values: fetch primitive + ref fields for the current node via WASM
React.useEffect(() => {
setWasmFieldValues(null);
const wasm = (window as any).__wasmExploration;
if (!wasm?.get_field_values || nodeId === null) return;
try {
const r = JSON.parse(wasm.get_field_values(nodeId));
if (r.ok && r.fields?.length > 0) setWasmFieldValues(r.fields);
} catch {}
}, [nodeId, data.nodes]);
// Collection entries: fetch for known Java/Scala/Kotlin collections
React.useEffect(() => {
setWasmCollEntries(null);
const wasm = (window as any).__wasmExploration;
if (!wasm?.get_collection_entries || nodeId === null) return;
try {
const r = JSON.parse(wasm.get_collection_entries(nodeId, 50));
if (r.ok && r.type !== "unknown" && r.entries?.length > 0) setWasmCollEntries(r);
} catch {}
}, [nodeId, data.nodes]);
// All GC root paths: multi-path BFS to GC roots
React.useEffect(() => {
setWasmAllPaths(null);
setShowAllPaths(false);
const wasm = (window as any).__wasmExploration;
if (!wasm?.all_gc_root_paths || nodeId === null) return;
try {
const r = JSON.parse(wasm.all_gc_root_paths(nodeId, 10));
if (r.ok && r.paths?.length > 1) setWasmAllPaths(r); // only show if >1 path (single path already shown)
} catch {}
}, [nodeId, data.nodes]);
// Below-threshold node: fetch WASM info when the node isn't in the static graph but WASM exploration is live
React.useEffect(() => {
setWasmBelowInfo(null);
setWasmBelowOutbound(null);
setWasmBelowInbound(null);
setShowBelowGcPath(false);
const wasm = (window as any).__wasmExploration;
if (!wasm?.get_node_info || nodeId === null) return;
const inCapture = data.nodes[String(nodeId)] != null;
if (inCapture) return; // static graph has it — no need
try {
const info = JSON.parse(wasm.get_node_info(nodeId));
if (info.ok) setWasmBelowInfo({ display_class: info.display_class, shallow: info.shallow, retained: info.retained });
} catch {}
try {
const out = JSON.parse(wasm.outbound_refs(nodeId, 100));
if (out.ok) setWasmBelowOutbound(out.refs);
} catch {}
try {
const inp = JSON.parse(wasm.inbound_refs(nodeId, 100));
if (inp.ok) setWasmBelowInbound(inp.refs);
} catch {}
}, [nodeId, data.nodes]);
React.useEffect(() => {
const onHash = (fromEvent: boolean) => {
const h = window.location.hash;
const m = h.match(/^#(explore|domtree)\/(\d+)$/);
if (m) {
const newId = parseInt(m[2], 10);
setTab(m[1] as "explore" | "domtree");
setNodeId(newId);
setPage(0);
setShowSvg(false);
setShowAllInbound(false);
setPathDepth(8);
// External navigation (from elsewhere in the report or direct URL): clear breadcrumb
if (lastInternalNavRef.current !== newId) {
setBreadcrumb([]);
}
if (fromEvent) {
setTimeout(() => document.getElementById("object-graph")?.scrollIntoView({ behavior: "smooth", block: "start" }), 50);
}
} else if (h === "#object-graph") {
setNodeId(null);
setBreadcrumb([]);
}
};
onHash(false);
const listener = () => onHash(true);
window.addEventListener("hashchange", listener);
return () => window.removeEventListener("hashchange", listener);
}, []);
// Apply any pending group expansion after nodeId settles (survives state-reset races)
React.useEffect(() => {
if (nodeId !== null && pendingExpandByNode.current.has(nodeId)) {
const key = pendingExpandByNode.current.get(nodeId)!;
pendingExpandByNode.current.delete(nodeId);
setExpandedGroups(new Set([key]));
} else {
setExpandedGroups(new Set());
}
// Reset dom-tree view state on every node change (covers hashchange-driven navigation)
setDomFilter("");
setDomViewMode("flat");
setExpandedDomList(null);
setExpandFilter("");
}, [nodeId]);
// Keyboard: Escape / Alt+Left = go back; Alt+Right = go forward in explorer
React.useEffect(() => {
if (nodeId === null) return;
const onKey = (e: KeyboardEvent) => {
if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) return;
const isEsc = e.key === "Escape";
const isAltLeft = e.key === "ArrowLeft" && e.altKey;
const isAltRight = e.key === "ArrowRight" && e.altKey;
if (!isEsc && !isAltLeft && !isAltRight) return;
e.preventDefault();
const curLabel = nodeId !== null ? (data.nodes[String(nodeId)]?.display_class ?? String(nodeId)) : String(nodeId);
if (isAltRight) {
setForwardStack(fs => {
if (fs.length === 0) return fs;
const fwd = fs[0];
setBreadcrumb(prev => [...prev.slice(-9), { nodeId: nodeId!, label: curLabel, sourceTab: (tab === "graph" ? "explore" : tab) as "explore" | "domtree" }]);
lastInternalNavRef.current = fwd.nodeId;
window.location.hash = `${fwd.sourceTab ?? (tab === "graph" ? "explore" : tab)}/${fwd.nodeId}`;
return fs.slice(1);
});
return;
}
setBreadcrumb(prev => {
if (prev.length > 0) {
const last = prev[prev.length - 1];
setForwardStack(fs => [{ nodeId: nodeId!, label: curLabel, sourceTab: (tab === "graph" ? "explore" : tab) as "explore" | "domtree" }, ...fs.slice(0, 19)]);
lastInternalNavRef.current = last.nodeId;
window.location.hash = `${last.sourceTab ?? (tab === "graph" ? "explore" : tab)}/${last.nodeId}`;
return prev.slice(0, -1);
}
window.location.hash = "object-graph";
return [];
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [nodeId, tab, data.nodes]);
const navigate = (newTab: "explore" | "domtree", id: number, childClass: string, edgeLabel?: string, expandGroup?: string) => {
// Save rootFilter so going back to root list restores the filter context
if (nodeId === null) savedRootFilter.current = rootFilter;
// Store which group should be auto-expanded when returning to the current node
if (expandGroup && nodeId !== null) pendingExpandByNode.current.set(nodeId, expandGroup);
setBreadcrumb(prev => {
if (nodeId === null) return [];
const stab: "explore" | "domtree" = tab === "graph" ? "explore" : tab;
return [...prev.slice(-9), { nodeId, label: currentNode?.display_class ?? String(nodeId), edge: edgeLabel ?? childClass, sourceTab: stab }];
});
setForwardStack([]);
lastInternalNavRef.current = id;
setPendingLabel(childClass);
setPage(0);
setDomFilter("");
setDomViewMode("flat");
setExpandedDomList(null);
setExpandFilter("");
setRefFilter("");
setRootFilter("");
setShowAllInbound(false);
setPathDepth(8);
window.location.hash = `${newTab}/${id}`;
setTimeout(() => document.getElementById("object-graph")?.scrollIntoView({ behavior: "smooth", block: "start" }), 50);
};
const goToRoot = (filter?: string) => {
setBreadcrumb([]);
setForwardStack([]);
const newFilter = filter ?? savedRootFilter.current;
savedRootFilter.current = newFilter;
setRootFilter(newFilter);
window.location.hash = "object-graph";
};
// Expose an external entry-point so buttons outside the explorer can navigate here
// without leaving stale breadcrumb state. Sets hash and clears breadcrumb atomically.
React.useEffect(() => {
(window as any).__explorerNavigate = (tab: string, id: number) => {
lastInternalNavRef.current = null; // mark as external
setBreadcrumb([]);
setForwardStack([]);
const hash = `${tab}/${id}`;
if (window.location.hash === `#${hash}`) {
// Hash won't change → manually trigger the same logic as onHash
setTab(tab as "explore" | "domtree");
setNodeId(id);
setPage(0);
setShowSvg(false);
setShowAllInbound(false);
setPathDepth(8);
setTimeout(() => document.getElementById("object-graph")?.scrollIntoView({ behavior: "smooth", block: "start" }), 50);
} else {
window.location.hash = hash;
setTimeout(() => document.getElementById("object-graph")?.scrollIntoView({ behavior: "smooth", block: "start" }), 50);
}
};
return () => { delete (window as any).__explorerNavigate; };
}, []);
// Listen for cross-section "explore-class" events (from ListObjectsBtn).
React.useEffect(() => {
const handler = (e: Event) => {
const cls = (e as CustomEvent<string>).detail;
if (!cls) return;
lastInternalNavRef.current = null;
setBreadcrumb([]);
setForwardStack([]);
setNodeId(null);
setRootFilter(cls);
setRootViewMode("instances");
};
window.addEventListener("explore-class", handler);
return () => window.removeEventListener("explore-class", handler);
}, []);
const currentNode: ObjGraphFlatNode | null =
nodeId !== null ? (data.nodes[String(nodeId)] ?? null) : null;
const staticEdges: ObjGraphEdge[] =
nodeId !== null ? (data.edges[String(nodeId)] ?? []) : [];
// Use WASM outbound refs when they cover more edges (truncated static or no static capture).
const currentEdges: ObjGraphEdge[] = wasmOutboundEdges ?? staticEdges;
const currentDomChildren: number[] =
nodeId !== null ? (data.dom_children[String(nodeId)] ?? []) : [];
// Inbound refs: scan all edges in captured graph for edges pointing to current node
const inboundRefs: { srcIdx: number; field_name: string }[] = React.useMemo(() => {
if (nodeId === null) return [];
const result: { srcIdx: number; field_name: string }[] = [];
for (const [srcKey, edges] of Object.entries(data.edges)) {
const srcIdx = parseInt(srcKey, 10);
for (const e of edges) {
if (e.child_idx === nodeId) {
result.push({ srcIdx, field_name: e.field_name });
}
}
}
return result;
}, [nodeId, data.edges]);
const dominatorChain: number[] = React.useMemo(() => {
if (!currentNode || nodeId === null) return [];
const chain: number[] = [nodeId];
let cursor = currentNode.idom;
const seen = new Set<number>([nodeId]);
while (cursor != null && !seen.has(cursor)) {
chain.push(cursor);
seen.add(cursor);
cursor = data.nodes[String(cursor)]?.idom ?? null;
}
return chain; // target → root order; reversed for display
}, [currentNode, nodeId, data.nodes]);
// Set of node IDs in the breadcrumb trail — used to detect cycles in ref navigation
const breadcrumbIdSet = React.useMemo(
() => new Set(breadcrumb.map(b => b.nodeId)),
[breadcrumb]
);
// Sorted same-class siblings for prev/next navigation (computed before early returns to satisfy hook rules)
const currentDisplayClass = nodeId !== null ? (data.nodes[String(nodeId)]?.display_class ?? null) : null;
const classSiblings: { id: number; retained: number }[] = React.useMemo(() => {
if (!currentDisplayClass) return [];
return Object.entries(data.nodes)
.filter(([, n]) => n.display_class === currentDisplayClass)
.map(([k, n]) => ({ id: parseInt(k, 10), retained: n.retained }))
.sort((a, b) => b.retained - a.retained);
}, [currentDisplayClass, data.nodes]);
const siblingIdx = nodeId !== null ? classSiblings.findIndex(s => s.id === nodeId) : -1;
// Keyboard: [ / ] = prev/next same-class sibling (lateral, no breadcrumb push)
React.useEffect(() => {
if (nodeId === null || classSiblings.length <= 1) return;
const onKey = (e: KeyboardEvent) => {
if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) return;
if (e.key !== "[" && e.key !== "]") return;
e.preventDefault();
const idx = classSiblings.findIndex(s => s.id === nodeId);
if (e.key === "[" && idx > 0) {
setPage(0); setExpandedGroups(new Set()); setDomFilter(""); setDomViewMode("flat"); setExpandedDomList(null); setExpandFilter(""); setRefFilter(""); setShowAllInbound(false); setPathDepth(8);
window.location.hash = `${tab === "graph" ? "explore" : tab}/${classSiblings[idx - 1].id}`;
} else if (e.key === "]" && idx < classSiblings.length - 1) {
setPage(0); setExpandedGroups(new Set()); setDomFilter(""); setDomViewMode("flat"); setExpandedDomList(null); setExpandFilter(""); setRefFilter(""); setShowAllInbound(false); setPathDepth(8);
window.location.hash = `${tab === "graph" ? "explore" : tab}/${classSiblings[idx + 1].id}`;
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [nodeId, classSiblings, tab]);
const totalHeap = totalHeapOverride ?? Object.values(data.nodes).reduce(
(s, n) => (n.idom == null ? s + n.retained : s), 0
);
const expandDomSubtree = React.useCallback(() => {
if (nodeId === null) return;
const MAX = 1000;
const result: { id: number; depth: number; display_class: string; shallow: number; retained: number }[] = [];
const queue: { id: number; depth: number }[] = [{ id: nodeId, depth: 0 }];
const seen = new Set<number>();
while (queue.length > 0 && result.length < MAX) {
const { id, depth } = queue.shift()!;
if (seen.has(id)) continue;
seen.add(id);
const n = data.nodes[String(id)];
if (!n) continue;
result.push({ id, depth, display_class: n.display_class, shallow: n.shallow, retained: n.retained });
const children = data.dom_children[String(id)] ?? [];
for (const child of children) {
if (!seen.has(child)) queue.push({ id: child, depth: depth + 1 });
}
}
setExpandedDomList(result);
setDomViewMode("expanded");
}, [nodeId, data.nodes, data.dom_children]);
const PAGE_SIZE = 50;
// Group edges by (field_name, child_class) to collapse arrays of identical refs.
interface EdgeGroup {
field_name: string;
child_class: string;
child_idx: number; // representative (highest retained)
count: number;
total_retained: number;
any_shared: boolean;
members: ObjGraphEdge[]; // all raw edges in this group
groupKey: string;
}
const groupedEdges: EdgeGroup[] = React.useMemo(() => {
const map = new Map<string, EdgeGroup>();
for (const edge of currentEdges) {
const key = `${edge.field_name}\0${edge.child_class}`;
const existing = map.get(key);
const childNode = data.nodes[String(edge.child_idx)];
const isShared = !!(childNode && childNode.idom !== nodeId);
if (!existing) {
map.set(key, { field_name: edge.field_name, child_class: edge.child_class, child_idx: edge.child_idx, count: 1, total_retained: edge.child_retained, any_shared: isShared, members: [edge], groupKey: key });
} else {
existing.count++;
existing.total_retained += edge.child_retained;
if (edge.child_retained > (data.nodes[String(existing.child_idx)]?.retained ?? 0)) {
existing.child_idx = edge.child_idx;
}
if (isShared) existing.any_shared = true;
existing.members.push(edge);
}
}
// Sort by total_retained descending so largest refs show first
return Array.from(map.values()).sort((a, b) => b.total_retained - a.total_retained);
}, [currentEdges, nodeId, data.nodes]);
const filteredEdges = refFilter
? groupedEdges.filter(e =>
e.child_class.toLowerCase().includes(refFilter.toLowerCase()) ||
e.field_name.toLowerCase().includes(refFilter.toLowerCase()))
: groupedEdges;
const pagedEdges = filteredEdges.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
// Index of the grouped edge representative that is the highest-retained dom child
const domBadgeChildIdx: number | null = React.useMemo(() => {
let best: { idx: number; retained: number } | null = null;
const domSet = new Set(currentDomChildren);
for (const edge of groupedEdges) {
if (domSet.has(edge.child_idx) && !edge.any_shared) {
const r = edge.total_retained;
if (!best || r > best.retained) best = { idx: edge.child_idx, retained: r };
}
}
return best?.idx ?? null;
}, [groupedEdges, currentDomChildren]);
// Find pre-built SVG tree for current node
const prebuiltTree = nodeId !== null
? data.root_dom_trees?.find(([idx]) => idx === nodeId)?.[1]
: undefined;
const pinStrip = pinnedNodes.length > 0 ? (
<div style={{ display: "flex", gap: "0.3rem", alignItems: "center", flexWrap: "wrap", marginBottom: "0.4rem", fontSize: "0.78rem" }}>
<span style={{ color: "var(--muted)", flexShrink: 0 }}>📌</span>
{pinnedNodes.map(p => (
<button key={p.nodeId} className="btn-link"
style={{ background: "var(--accent-muted, #dbeafe)", color: "var(--accent)", borderRadius: 4, padding: "1px 6px", fontSize: "0.76rem" }}
title={`Jump to pinned: ${p.label}#${p.nodeId}`}
onClick={() => navigate("explore", p.nodeId, p.label)}>
{p.label.split(".").pop()}#{p.nodeId}
<span style={{ marginLeft: "0.2rem", opacity: 0.5, cursor: "pointer" }}
onClick={e => { e.stopPropagation(); togglePin(p.nodeId, p.label); }}>×</span>
</button>
))}
</div>
) : null;
// ── Root list ──────────────────────────────────────────────────────────────
if (nodeId === null) {
const allCaptured = Object.entries(data.nodes)
.sort((a, b) => b[1].retained - a[1].retained);
const filtered = rootFilter
? allCaptured.filter(([, n]) => n.display_class.toLowerCase().includes(rootFilter.toLowerCase()))
: data.roots.map(id => [String(id), data.nodes[String(id)]] as [string, typeof data.nodes[string]]).filter(([, n]) => n != null);
const showAll = !!rootFilter;
const sorted = [...filtered].sort((a, b) => {
const [ak, an] = a; const [bk, bn] = b;
let cmp = 0;
if (rootSort.col === "class") cmp = an.display_class.localeCompare(bn.display_class);
else if (rootSort.col === "idx") cmp = parseInt(ak) - parseInt(bk);
else if (rootSort.col === "shallow") cmp = an.shallow - bn.shallow;
else cmp = an.retained - bn.retained;
return rootSort.asc ? cmp : -cmp;
});
const displayRows = sorted.slice(0, showAll ? 200 : 50);
return (
<div ref={containerRef}>
{pinStrip}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.5rem" }}>
<p className="subtitle" style={{ margin: 0 }}>
{rootFilter
? <>Searching all {Object.keys(data.nodes).length.toLocaleString()} captured objects (retained ≥ <span title={fmtExactBytes(data.sig_floor_bytes)}>{fmtB(data.sig_floor_bytes)}</span>).</>
: <>Top dominator roots (retained ≥ <span title={fmtExactBytes(data.sig_floor_bytes)}>{fmtB(data.sig_floor_bytes)}</span>).{" "}
<strong>→</strong> click class name to explore outbound references;{" "}
<strong>⌞</strong> opens the dominator subtree.</>
}
</p>
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexShrink: 0, flexWrap: "wrap" }}>
<span style={{ display: "inline-flex", borderRadius: 4, overflow: "hidden", border: "1px solid var(--border, #e2e8f0)", fontSize: "0.78rem" }}>
<button
className={rootViewMode === "instances" ? "btn-active" : "btn-link"}
style={{ fontSize: "0.78rem", padding: "1px 7px", borderRadius: 0 }}
onClick={() => setRootViewMode("instances")}>Instances</button>
<button
className={rootViewMode === "classes" ? "btn-active" : "btn-link"}
style={{ fontSize: "0.78rem", padding: "1px 7px", borderRadius: 0, borderLeft: "1px solid var(--border, #e2e8f0)" }}
onClick={() => setRootViewMode("classes")}>By Class</button>
</span>
<span style={{ position: "relative", display: "inline-flex", alignItems: "center" }}>
<input
type="text"
value={rootFilter}
onChange={e => { setRootFilter(e.target.value); setRootViewMode("instances"); }}
placeholder="Filter by class…"
style={{ width: "14em", fontSize: "0.82rem", padding: "1px 5px", paddingRight: rootFilter ? "1.4em" : "5px", border: "1px solid var(--border, #e2e8f0)", borderRadius: 4, background: "var(--input-bg, var(--bg))", color: "inherit" }}
title="Filter captured objects by class name"
/>
{rootFilter && (
<button onClick={() => { setRootFilter(""); setRootViewMode("instances"); }}
style={{ position: "absolute", right: "4px", background: "none", border: "none", cursor: "pointer", color: "var(--muted)", fontSize: "0.85rem", padding: 0, lineHeight: 1 }}
title="Clear filter">×</button>
)}
</span>
<form style={{ display: "flex", gap: "0.25rem", alignItems: "center" }}
onSubmit={e => {
e.preventDefault();
const raw = jumpInput.trim();
const isHex = /^0x[0-9a-fA-F]+$/i.test(raw);
if (isHex) {
const addr = parseInt(raw.slice(2), 16);
const wasm = (window as any).__wasmExploration;
if (wasm?.find_dense_by_address) {
try {
const r = JSON.parse(wasm.find_dense_by_address(addr));
if (r.ok) {
setJumpInput("");
navigate("explore", r.dense_idx, data.nodes[String(r.dense_idx)]?.display_class ?? `#${r.dense_idx}`);
}
} catch {}
}
} else {
const n = parseInt(raw, 10);
if (!isNaN(n) && n >= 0) {
setJumpInput("");
navigate("explore", n, data.nodes[String(n)]?.display_class ?? `#${n}`);
}
}
}}>
<input
type="text"
value={jumpInput}
onChange={e => setJumpInput(e.target.value)}
placeholder="Go to object # or 0x…"
style={{ width: "9em", fontSize: "0.82rem", padding: "1px 5px", border: "1px solid var(--border, #e2e8f0)", borderRadius: 4, background: "var(--input-bg, var(--bg))", color: "inherit" }}
title="Jump to an object by its dense index"
/>
<button type="submit" className="btn-link" style={{ fontSize: "0.82rem" }}>Go</button>
</form>
</div>
</div>
{rootFilter && (
<p className="subtitle" style={{ marginBottom: "0.3rem", fontSize: "0.8rem" }}>
{filtered.length} matches{filtered.length > 200 ? " (showing first 200)" : ""}{" "}
{filtered.length > 0 && (
<>— total retained: <strong title={fmtExactBytes(filtered.reduce((s, [, n]) => s + n.retained, 0))}>{fmtB(filtered.reduce((s, [, n]) => s + n.retained, 0))}</strong>
{totalHeap > 0 && (
<span style={{ color: "var(--muted)" }}>{" "}({fmtPct(filtered.reduce((s, [, n]) => s + n.retained, 0) / totalHeap * 100)} of heap)</span>
)}
</>
)}
</p>
)}
{!rootFilter && totalHeap > 0 && (() => {
const topN = data.roots.slice(0, 3);
const topNretained = topN.map(id => data.nodes[String(id)]?.retained ?? 0);
const topNtotal = topNretained.reduce((s, r) => s + r, 0);
const pct = topNtotal / totalHeap * 100;
if (pct < 50) return null;
return (
<div style={{ margin: "0 0 0.5rem", padding: "0.4rem 0.75rem", background: "var(--warn-bg, #fef3c7)", border: "1px solid var(--warn-border, #fde68a)", borderRadius: 5, fontSize: "0.82rem", color: "var(--warn, #92400e)" }}>
⚠ Top {topN.length} {topN.length === 1 ? "object holds" : "objects hold"} <strong>{fmtPct(pct)}</strong> of heap (<span title={fmtExactBytes(topNtotal)}>{fmtB(topNtotal)}</span>) — a few large retainers dominate. Investigate these first.
</div>
);
})()}
{rootViewMode === "classes" && (() => {
const byClass = new Map<string, { count: number; totalShallow: number; totalRetained: number; topIdx: number }>();
for (const [idStr, n] of filtered) {
const entry = byClass.get(n.display_class) ?? { count: 0, totalShallow: 0, totalRetained: 0, topIdx: parseInt(idStr, 10) };
entry.count++;
entry.totalShallow += n.shallow;
entry.totalRetained += n.retained;
byClass.set(n.display_class, entry);
}
const rows = [...byClass.entries()]
.map(([cls, v]) => ({ cls, ...v }))
.sort((a, b) => b.totalRetained - a.totalRetained);
return (
<table className="std-table">
<thead>
<tr>
<th>Class</th>
<th style={{ textAlign: "right" }}>Instances</th>
<th style={{ textAlign: "right" }}>Total Shallow</th>
<th style={{ textAlign: "right" }}>Total Retained</th>
<th style={{ textAlign: "right" }}>% Heap</th>
</tr>
</thead>
<tbody>
{rows.slice(0, 200).map(r => (
<tr key={r.cls}>
<td>
<span className="copy-cell">
<button className="btn-link" title="Navigate to top instance"
onClick={() => navigate("explore", r.topIdx, r.cls)}>
<code title={r.cls}>{r.cls}</code>
</button>
<PivotBtn cls={r.cls} />
<OqlBtn cls={r.cls} />
<ListObjectsBtn cls={r.cls} />
</span>
</td>
<td style={{ textAlign: "right" }}>{fmtCount(r.count)}</td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(r.totalShallow)}>{fmtB(r.totalShallow)}</span></td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(r.totalRetained)}>{fmtB(r.totalRetained)}</span></td>
<td style={{ textAlign: "right" }}>{totalHeap > 0 ? fmtPct(r.totalRetained / totalHeap * 100) : "—"}</td>
</tr>
))}
</tbody>
</table>
);
})()}
{rootViewMode === "instances" && <table className="std-table">
<thead>
<tr>
<th style={{ cursor: "pointer", userSelect: "none" }} onClick={() => setRootSort(s => s.col === "class" ? { col: "class", asc: !s.asc } : { col: "class", asc: true })}>Class {rootSort.col === "class" ? (rootSort.asc ? "▲" : "▼") : ""}</th>
<th style={{ whiteSpace: "nowrap", cursor: "pointer", userSelect: "none" }} title="Dense index — use in 'Go to object #' to navigate directly" onClick={() => setRootSort(s => s.col === "idx" ? { col: "idx", asc: !s.asc } : { col: "idx", asc: true })}># {rootSort.col === "idx" ? (rootSort.asc ? "▲" : "▼") : ""}</th>
<th style={{ cursor: "pointer", userSelect: "none" }} onClick={() => setRootSort(s => s.col === "shallow" ? { col: "shallow", asc: !s.asc } : { col: "shallow", asc: false })}>Shallow {rootSort.col === "shallow" ? (rootSort.asc ? "▲" : "▼") : ""}</th>
<th style={{ textAlign: "right", cursor: "pointer", userSelect: "none" }} onClick={() => setRootSort(s => s.col === "retained" ? { col: "retained", asc: !s.asc } : { col: "retained", asc: false })}>Retained {rootSort.col === "retained" ? (rootSort.asc ? "▲" : "▼") : ""}</th>
<th style={{ textAlign: "right" }}>% Heap</th>
</tr>
</thead>
<tbody>
{displayRows.map(([idStr, node]) => {
const id = parseInt(idStr, 10);
return (
<tr key={id}>
<td style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<button className="btn-link" title="Explore outbound references"
onClick={() => navigate("explore", id, node.display_class)}>
<code title={node.display_class}>{node.display_class}</code>
</button>
{" "}
<button className="btn-link" title="Open dominator tree"
onClick={() => navigate("domtree", id, node.display_class)}
style={{ opacity: 0.6, flexShrink: 0 }}>
⌞
</button>
<PivotBtn cls={node.display_class} />
<OqlBtn cls={node.display_class} />
<ListObjectsBtn cls={node.display_class} />
</td>
<td style={{ color: "var(--muted)", fontSize: "0.8rem", whiteSpace: "nowrap" }}>{id}</td>
<td><span title={fmtExactBytes(node.shallow)}>{fmtB(node.shallow)}</span></td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<span style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: "0.35rem" }}>
{totalHeap > 0 && node.retained / totalHeap > 0.01 && (
<span style={{ display: "inline-block", width: `${Math.max(3, Math.round(node.retained / totalHeap * 48))}px`, height: "6px", borderRadius: 2, background: "var(--accent, #3b82f6)", opacity: 0.55, flexShrink: 0 }} />
)}
<span title={fmtExactBytes(node.retained)}>{fmtB(node.retained)}</span>
</span>
</td>
<td style={{ textAlign: "right" }}>{totalHeap > 0 ? fmtPct(node.retained / totalHeap * 100) : "—"}</td>
</tr>
);})}
</tbody>
</table>}
{!rootFilter && data.roots.length > 50 && (
<p className="subtitle" style={{ marginTop: "0.4rem", fontSize: "0.8rem" }}>
Showing top 50 of {data.roots.length} roots. Use the class filter above to search all {Object.keys(data.nodes).length.toLocaleString()} captured objects.
</p>
)}
{!!(window as any).__wasmExploration && (
<div style={{ marginTop: "1rem", borderTop: "1px solid var(--border-faint, #f0f0f0)", paddingTop: "0.6rem" }}>
<div style={{ fontWeight: 600, fontSize: "0.85rem", marginBottom: "0.3rem" }}>Live Instance Search</div>
<p className="subtitle" style={{ fontSize: "0.78rem", margin: "0 0 0.4rem 0" }}>
Search all {(((window as any).__wasmExploration as any)?.n ?? Object.keys(data.nodes).length).toLocaleString()} captured objects by class name (not limited to captured graph).
</p>
<form style={{ display: "flex", gap: "0.3rem", alignItems: "center" }}
onSubmit={e => {
e.preventDefault();
const wasm = (window as any).__wasmExploration;
if (!wasm?.find_instances || !liveSearchQuery.trim()) return;
try {
const r = JSON.parse(wasm.find_instances(liveSearchQuery.trim(), 50));
if (r.ok) { setLiveSearchResults(r.matches); setLiveSearchTotal(r.total); setLiveSearchTruncated(r.truncated); }
} catch {}
}}>
<input
type="text"
value={liveSearchQuery}
onChange={e => setLiveSearchQuery(e.target.value)}
placeholder="Class name substring…"
style={{ flex: 1, fontSize: "0.82rem", padding: "1px 5px", border: "1px solid var(--border, #e2e8f0)", borderRadius: 4, background: "var(--input-bg, var(--bg))", color: "inherit" }}
/>
<button type="submit" className="btn-link" style={{ fontSize: "0.82rem" }}>Search</button>
{liveSearchResults !== null && (
<button type="button" className="btn-link" style={{ fontSize: "0.82rem", opacity: 0.6 }}
onClick={() => { setLiveSearchResults(null); setLiveSearchQuery(""); }}>Clear</button>
)}
</form>
{liveSearchResults !== null && (
<>
<p className="subtitle" style={{ fontSize: "0.78rem", margin: "0.3rem 0" }}>
{fmtCount(liveSearchTotal)} match{liveSearchTotal === 1 ? "" : "es"}{liveSearchTruncated ? ` (showing top 50 by retained)` : ""}.
</p>
{liveSearchResults.length > 0 && (
<table className="std-table">
<thead>
<tr>
<th>Class</th>
<th style={{ whiteSpace: "nowrap" }}>#</th>
<th style={{ textAlign: "right" }}>Shallow</th>
<th style={{ textAlign: "right" }}>Retained</th>
</tr>
</thead>
<tbody>
{liveSearchResults.map((m: any) => (
<tr key={m.dense_idx}>
<td>
<span className="copy-cell">
<button className="btn-link" onClick={() => navigate("explore", m.dense_idx, m.display_class)}><code title={m.display_class}>{m.display_class}</code></button>
<button className="btn-link" title="Open dominator tree" style={{ opacity: 0.6, flexShrink: 0 }} onClick={() => navigate("domtree", m.dense_idx, m.display_class)}>⌞</button>
<PivotBtn cls={m.display_class} />
<OqlBtn cls={m.display_class} />
<ListObjectsBtn cls={m.display_class} />
</span>
</td>
<td style={{ color: "var(--muted)", fontSize: "0.8rem" }}>{m.dense_idx}</td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(m.shallow)}>{fmtB(m.shallow)}</span></td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(m.retained)}>{fmtB(m.retained)}</span></td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</div>
)}
</div>
);
}
// ── Node detail view ───────────────────────────────────────────────────────
if (!currentNode) {
const effectiveCls = wasmBelowInfo?.display_class ?? pendingLabel;
const shortCls = effectiveCls ? (effectiveCls.split(".").pop() ?? effectiveCls) : null;
const sameClassNodes = effectiveCls
? Object.entries(data.nodes)
.filter(([, n]) => n.display_class === effectiveCls)
.sort((a, b) => b[1].retained - a[1].retained)
.slice(0, 10)
: [];
return (
<div ref={containerRef}>
{breadcrumb.length > 0 && (
<div className="breadcrumb">
<span className="breadcrumb-item" onClick={() => goToRoot()}>Roots</span>
{breadcrumb.map((b, i) => (
<React.Fragment key={i}>
<span className="breadcrumb-sep">/</span>
<span
className="breadcrumb-item"
onClick={() => {
setBreadcrumb(prev => prev.slice(0, i));
window.location.hash = `${b.sourceTab ?? (tab === "graph" ? "explore" : tab)}/${b.nodeId}`;
}}
title={b.sourceTab === "domtree" ? "Navigated via dominator tree" : b.sourceTab === "explore" ? "Navigated via outbound references" : undefined}
>
{b.sourceTab === "domtree" && <span style={{ color: "var(--muted)", fontSize: "0.8em", marginRight: "2px" }}>⌞</span>}
{(b.label.split(".").pop() ?? b.label)}#{b.nodeId}
{(() => {
const n = data.nodes[String(b.nodeId)];
if (!n) return null;
return <span title={fmtExactBytes(n.retained)} style={{ color: "var(--muted)", fontSize: "0.72em", marginLeft: "0.2em" }}>{fmtB(n.retained)}</span>;
})()}
{b.sourceTab !== "domtree" && b.edge && b.edge !== b.label && !b.edge.includes(".") && /^[a-zA-Z_$]/.test(b.edge) && (
<span style={{ color: "var(--muted)", fontWeight: 400, fontSize: "0.8em" }}>
{" "}.{b.edge}
</span>
)}
</span>
</React.Fragment>
))}
<span className="breadcrumb-sep">/</span>
<span style={{ fontWeight: 600 }}>
{shortCls ? `${shortCls}#${nodeId}` : `obj#${nodeId}`}
{wasmBelowInfo && <span title={fmtExactBytes(wasmBelowInfo.retained)} style={{ color: "var(--muted)", fontSize: "0.72em", marginLeft: "0.2em" }}>{fmtB(wasmBelowInfo.retained)}</span>}
</span>
</div>
)}
<div style={{ padding: "0.75rem 1rem", background: "var(--card-bg, var(--bg))", border: "1px solid var(--border)", borderRadius: 6 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "0.5rem", marginBottom: "0.4rem" }}>
<p style={{ margin: 0, fontWeight: 600 }}>
{effectiveCls ? <><code>{effectiveCls}</code> #{nodeId}</> : <>Object #{nodeId}</>}{" "}— not in captured graph
</p>
<button className="btn-link" style={{ flexShrink: 0, fontSize: "0.82rem" }}
onClick={() => {
if (breadcrumb.length > 0) {
const prev = breadcrumb[breadcrumb.length - 1];
setBreadcrumb(b => b.slice(0, -1));
window.location.hash = `${prev.sourceTab ?? (tab === "graph" ? "explore" : tab)}/${prev.nodeId}`;
} else {
goToRoot();
}
}}>
{breadcrumb.length > 0 ? `← Back` : "⌂ Roots"}
</button>
</div>
{!wasmBelowInfo ? (
<p className="subtitle" style={{ margin: "0 0 0.4rem" }}>
This object was referenced{breadcrumb.length > 0 ? (() => {
const parent = breadcrumb[breadcrumb.length - 1];
const parentShort = parent.label.split(".").pop();
const field = parent.edge && parent.edge !== parent.label && !parent.edge.includes(".") && /^[a-zA-Z_$]/.test(parent.edge)
? <> via field <code>.{parent.edge}</code></> : null;
return <> from <strong>{parentShort}#{parent.nodeId}</strong>{field} but is</>;
})() : " above but is"
} below the significance threshold (≥<span title={fmtExactBytes(data.sig_floor_bytes)}>{fmtB(data.sig_floor_bytes)}</span> retained).
Re-run with <code>--top-n</code> to include it.
</p>
) : (
<p className="subtitle" style={{ margin: "0 0 0.4rem", fontSize: "0.8rem" }}>
<code>{wasmBelowInfo.display_class}</code> · shallow <span title={fmtExactBytes(wasmBelowInfo.shallow)}>{fmtB(wasmBelowInfo.shallow)}</span> · retained <span title={fmtExactBytes(wasmBelowInfo.retained)}>{fmtB(wasmBelowInfo.retained)}</span> · below the significance threshold — data loaded from the live heap.
</p>
)}
{effectiveCls && (
<span className="copy-cell">
<OqlBtn cls={effectiveCls} />
<ListObjectsBtn cls={effectiveCls} />
<span style={{ fontSize: "0.8rem", color: "var(--muted)" }}>Copy OQL for all {shortCls} instances</span>
</span>
)}
{sameClassNodes.length > 0 && (
<div style={{ marginTop: "0.6rem" }}>
<div style={{ fontSize: "0.78rem", color: "var(--muted)", fontWeight: 600, marginBottom: "3px" }}>
{shortCls} instances in captured graph ({sameClassNodes.length}{sameClassNodes.length === 10 ? "+" : ""} shown):
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.3rem" }}>
{sameClassNodes.map(([idxStr, n]) => (
<button key={idxStr} className="btn-link"
style={{ fontSize: "0.78rem", border: "1px solid var(--border)", borderRadius: 3, padding: "1px 5px", background: "var(--hover-bg, rgba(0,0,0,0.04))" }}
onClick={() => navigate(tab === "graph" ? "explore" : tab, parseInt(idxStr, 10), n.display_class)}>
#{idxStr} <span title={fmtExactBytes(n.retained)} style={{ color: "var(--muted)" }}>{fmtB(n.retained)}</span>
</button>
))}
</div>
</div>
)}
</div>
{/* WASM live refs for below-threshold node */}
{wasmBelowInfo && (wasmBelowOutbound !== null || wasmBelowInbound !== null) && (() => {
const mkRow = (idx: number, field: string, cls: string, ret: number, i: number) => (
<tr key={i}>
<td style={{ color:"var(--muted)" }}><code>{field||"—"}</code></td>
<td><span className="copy-cell">
<button className="btn-link" onClick={() => navigate("explore",idx,cls,field||undefined)}><code title={cls}>{cls}</code></button>
<button className="btn-link" title="Open in dominator tree" style={{ opacity:0.6,flexShrink:0 }} onClick={() => navigate("domtree",idx,cls,field||undefined)}>⌞</button>
<PivotBtn cls={cls}/><OqlBtn cls={cls}/><ListObjectsBtn cls={cls} />
</span></td>
<td style={{ textAlign:"right" }}><span title={fmtExactBytes(ret)}>{fmtB(ret)}</span></td>
</tr>
);
return (
<div style={{ display:"grid", gridTemplateColumns:"1fr 1fr", gap:"0.75rem", marginTop:"0.75rem" }}>
{([["Outbound References", (wasmBelowOutbound??[]).map((r,i)=>mkRow(r.dst_idx,r.field_name,r.display_class,r.retained,i)), "No outbound references."],
["Inbound References", (wasmBelowInbound??[]).map((r,i)=>mkRow(r.src_idx,r.field_name,r.display_class,r.retained,i)), "No inbound references."]] as const).map(([title,rows,empty]) => (
<div key={String(title)} style={{ background:"var(--card-bg,var(--bg))", border:"1px solid var(--border)", borderRadius:6, padding:"0.6rem 0.75rem" }}>
<div style={{ fontWeight:600, fontSize:"0.85rem", marginBottom:"0.35rem" }}>{title}</div>
{rows.length===0 ? <p className="subtitle">{empty}</p> : (
<table className="std-table"><thead><tr><th>Field</th><th>Class</th><th style={{ textAlign:"right" }}>Retained</th></tr></thead>
<tbody>{rows.slice(0,50)}</tbody>
</table>
)}
</div>
))}
</div>
);
})()}
{wasmBelowInfo && nodeId !== null && (() => {
const wasm = (window as any).__wasmExploration;
if (!wasm?.gc_root_path) return null;
return (
<div style={{ background:"var(--card-bg,var(--bg))", border:"1px solid var(--border)", borderRadius:6, padding:"0.6rem 0.75rem", marginTop:"0.75rem" }}>
<button className="btn-link" style={{ fontSize:"0.85rem", fontWeight:600, display:"flex", alignItems:"center", gap:"0.3rem" }}
onClick={() => setShowBelowGcPath(v => !v)}>
{showBelowGcPath ? "▼" : "▶"} Path to GC Root
</button>
{showBelowGcPath && (
<WasmGcPathPanel nodeId={nodeId} session={wasm} data={data} fmtB={fmtB}
navigate={(id) => navigate("explore", id, data.nodes[String(id)]?.display_class ?? `#${id}`)} />
)}
</div>
);
})()}
</div>
);
}
const idomNode = currentNode.idom != null ? data.nodes[String(currentNode.idom)] : null;
return (
<div ref={containerRef}>
{pinStrip}
{/* Breadcrumb */}
{breadcrumb.length > 0 && (
<div className="breadcrumb">
<span className="breadcrumb-item" onClick={() => goToRoot()}>Roots</span>
{breadcrumb.map((b, i) => (
<React.Fragment key={i}>
<span className="breadcrumb-sep">/</span>
<span
className="breadcrumb-item"
onClick={() => {
setBreadcrumb(prev => prev.slice(0, i));
lastInternalNavRef.current = b.nodeId;
window.location.hash = `${b.sourceTab ?? (tab === "graph" ? "explore" : tab)}/${b.nodeId}`;
}}
title={b.sourceTab === "domtree" ? "Navigated via dominator tree" : b.sourceTab === "explore" ? "Navigated via outbound references" : undefined}
>
{b.sourceTab === "domtree" && <span style={{ color: "var(--muted)", fontSize: "0.8em", marginRight: "2px" }}>⌞</span>}
{(b.label.split(".").pop() ?? b.label)}#{b.nodeId}
{(() => {
const n = data.nodes[String(b.nodeId)];
if (!n) return null;
return <span title={fmtExactBytes(n.retained)} style={{ color: "var(--muted)", fontSize: "0.72em", marginLeft: "0.2em" }}>{fmtB(n.retained)}</span>;
})()}
{b.sourceTab !== "domtree" && b.edge && b.edge !== b.label && !b.edge.includes(".") && /^[a-zA-Z_$]/.test(b.edge) && (
<span style={{ color: "var(--muted)", fontWeight: 400, fontSize: "0.8em" }}>
{" "}.{b.edge}
</span>
)}
</span>
</React.Fragment>
))}
<span className="breadcrumb-sep">/</span>
<span style={{ fontWeight: 600 }}>
{(currentNode.display_class.split(".").pop() ?? currentNode.display_class)}#{nodeId}
<span title={fmtExactBytes(currentNode.retained)} style={{ color: "var(--muted)", fontSize: "0.72em", marginLeft: "0.2em" }}>{fmtB(currentNode.retained)}</span>
</span>
</div>
)}
{/* Tab bar */}
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem", alignItems: "center", flexWrap: "wrap" }}>
<button
className={tab === "explore" ? "btn-active" : "btn-link"}
onClick={() => { setTab("explore"); window.location.hash = `explore/${nodeId}`; }}
>
Outbound Refs
</button>
<button
className={tab === "domtree" ? "btn-active" : "btn-link"}
onClick={() => { setTab("domtree"); window.location.hash = `domtree/${nodeId}`; }}
>
Dominator Tree
</button>
<button
className={tab === "graph" ? "btn-active" : "btn-link"}
title="Interactive force-directed graph — drag nodes, Ctrl+scroll to zoom. Best for visualising small clusters of 20–200 objects."
onClick={() => setTab("graph")}
>
Force Graph
</button>
<button className="btn-link" style={{ marginLeft: "auto" }}
title="Go back (Esc or Alt+←)"
onClick={() => {
if (breadcrumb.length > 0) {
const prev = breadcrumb[breadcrumb.length - 1];
setForwardStack(fs => [{ nodeId: nodeId!, label: currentNode?.display_class ?? String(nodeId), sourceTab: (tab === "graph" ? "explore" : tab) as "explore" | "domtree" }, ...fs.slice(0, 19)]);
setBreadcrumb(b => b.slice(0, -1));
lastInternalNavRef.current = prev.nodeId;
window.location.hash = `${prev.sourceTab ?? (tab === "graph" ? "explore" : tab)}/${prev.nodeId}`;
} else {
goToRoot();
}
}}>
{breadcrumb.length > 0 ? `← ${breadcrumb[breadcrumb.length - 1].sourceTab === "domtree" ? "⌞ " : ""}${(breadcrumb[breadcrumb.length - 1].label.split(".").pop() ?? breadcrumb[breadcrumb.length - 1].label)}#${breadcrumb[breadcrumb.length - 1].nodeId}` : "⌂ Roots"}
</button>
{forwardStack.length > 0 && (
<button className="btn-link"
title="Go forward (Alt+→)"
onClick={() => {
const fwd = forwardStack[0];
setForwardStack(fs => fs.slice(1));
setBreadcrumb(prev => [...prev.slice(-9), { nodeId: nodeId!, label: currentNode?.display_class ?? String(nodeId), sourceTab: (tab === "graph" ? "explore" : tab) as "explore" | "domtree" }]);
lastInternalNavRef.current = fwd.nodeId;
window.location.hash = `${fwd.sourceTab ?? (tab === "graph" ? "explore" : tab)}/${fwd.nodeId}`;
}}>
→ {(forwardStack[0].label.split(".").pop() ?? forwardStack[0].label)}#{forwardStack[0].nodeId}
</button>
)}
{classSiblings.length > 1 && (
<span style={{ display: "flex", gap: "0.1rem", alignItems: "center", fontSize: "0.78rem", color: "var(--muted)" }} title="Navigate same-class instances by retained size (lateral — no breadcrumb push). Keyboard: [ / ]">
<button className="btn-link" style={{ fontSize: "0.78rem", padding: "0 3px", opacity: siblingIdx > 0 ? 1 : 0.3 }}
disabled={siblingIdx <= 0}
title="Previous same-class instance ([ key)"
onClick={() => {
if (siblingIdx > 0) {
const s = classSiblings[siblingIdx - 1];
setPage(0); setExpandedGroups(new Set()); setDomFilter(""); setDomViewMode("flat"); setExpandedDomList(null); setExpandFilter(""); setRefFilter(""); setShowAllInbound(false); setPathDepth(8);
window.location.hash = `${tab === "graph" ? "explore" : tab}/${s.id}`;
}
}}>
‹
</button>
<span style={{ fontSize: "0.72rem" }}>{siblingIdx + 1}/{classSiblings.length}</span>
<button className="btn-link" style={{ fontSize: "0.78rem", padding: "0 3px", opacity: siblingIdx < classSiblings.length - 1 ? 1 : 0.3 }}
disabled={siblingIdx >= classSiblings.length - 1}
title="Next same-class instance (] key)"
onClick={() => {
if (siblingIdx < classSiblings.length - 1) {
const s = classSiblings[siblingIdx + 1];
setPage(0); setExpandedGroups(new Set()); setDomFilter(""); setDomViewMode("flat"); setExpandedDomList(null); setExpandFilter(""); setRefFilter(""); setShowAllInbound(false); setPathDepth(8);
window.location.hash = `${tab === "graph" ? "explore" : tab}/${s.id}`;
}
}}>
›
</button>
</span>
)}
<form style={{ display: "flex", gap: "0.25rem", alignItems: "center" }}
onSubmit={e => {
e.preventDefault();
const raw = jumpInput.trim();
const isHex = /^0x[0-9a-fA-F]+$/i.test(raw);
if (isHex) {
const addr = parseInt(raw.slice(2), 16);
const wasm = (window as any).__wasmExploration;
if (wasm?.find_dense_by_address) {
try {
const r = JSON.parse(wasm.find_dense_by_address(addr));
if (r.ok) {
setJumpInput("");
navigate(tab === "graph" ? "explore" : tab, r.dense_idx, data.nodes[String(r.dense_idx)]?.display_class ?? `#${r.dense_idx}`);
}
} catch {}
}
} else {
const n = parseInt(raw, 10);
if (!isNaN(n) && n >= 0) {
setJumpInput("");
navigate(tab === "graph" ? "explore" : tab, n, data.nodes[String(n)]?.display_class ?? `#${n}`);
}
}
}}>
<input
type="text"
value={jumpInput}
onChange={e => setJumpInput(e.target.value)}
placeholder="Go to object # or 0x…"
style={{ width: "9em", fontSize: "0.82rem", padding: "1px 5px", border: "1px solid var(--border, #e2e8f0)", borderRadius: 4, background: "var(--input-bg, var(--bg))", color: "inherit" }}
title="Jump to an object by its dense index"
/>
<button type="submit" className="btn-link" style={{ fontSize: "0.82rem" }}>Go</button>
</form>
</div>
{data.capture_params && (() => {
const cp = data.capture_params;
return (
<p style={{ fontSize: "0.75rem", color: "var(--muted)", margin: "0 0 0.4rem 0" }}>
Capture: {cp.edge_cap} edges/obj ({cp.size_tier}).{cp.size_tier !== "large" && <> Re-run with <code>--obj-graph={cp.size_tier === "small" ? "medium" : "large"}</code> for more.</>}
</p>
);
})()}
{!bannerDismissed && !(window as any).__wasmSession && (
<div style={{ background: "var(--accent-bg, #eff6ff)", border: "1px solid var(--accent-border, #bfdbfe)", borderRadius: 6, padding: "0.4rem 0.6rem", marginBottom: "0.5rem", display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "0.8rem" }}>
<span style={{ flex: 1 }}>Load the .hprof in the browser for the full inbound graph and shortest GC root paths.</span>
<button className="copy-btn" onClick={() => { sessionStorage.setItem("wasm-banner-dismissed", "1"); setBannerDismissed(true); }} style={{ flexShrink: 0, opacity: 0.6 }} title="Dismiss">✕</button>
</div>
)}
{!bannerDismissed && !!(window as any).__wasmSession && !(window as any).__wasmExploration && (
<div style={{ background: "var(--accent-bg, #eff6ff)", border: "1px solid var(--accent-border, #bfdbfe)", borderRadius: 6, padding: "0.4rem 0.6rem", marginBottom: "0.5rem", display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "0.8rem" }}>
<span style={{ flex: 1 }}>
Heap loaded in browser. Enable full exploration for inbound references and GC root paths.
</span>
<button className="copy-btn" style={{ flexShrink: 0 }}
onClick={async () => {
const session = (window as any).__wasmSession;
if (session?.enable_exploration) {
await session.enable_exploration();
(window as any).__wasmExploration = session;
forceUpdate();
}
}}>Enable →</button>
<button className="copy-btn" onClick={() => { sessionStorage.setItem("wasm-banner-dismissed", "1"); setBannerDismissed(true); }} style={{ flexShrink: 0, opacity: 0.6 }} title="Dismiss">✕</button>
</div>
)}
{tab === "graph" && (
<OGEGraphView
data={data}
onNavigate={(nid, label) => { navigate("explore", nid, label); }}
/>
)}
<div className="obj-explorer" style={{ display: tab === "graph" ? "none" : undefined }}>
{/* Left panel */}
<div className="obj-explorer-left">
{tab === "explore" ? (
<>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginBottom: "0.4rem" }}>
{(["outbound","inbound"] as const).map(t => (
<React.Fragment key={t}>
{t === "inbound" && <span style={{ color: "var(--muted)" }}>|</span>}
<button className="btn-link"
title={t === "outbound" ? "Objects referenced by this object (what it holds)" : "Objects that reference this object (what holds it alive)"}
style={{ fontWeight: activeRefTab === t ? 700 : 400, fontSize: "0.9rem", padding: "0 2px" }} onClick={() => setActiveRefTab(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
</React.Fragment>
))}
<span style={{ color: "var(--muted)", fontSize: "0.8rem", marginLeft: "auto" }}>References</span>
</div>
{activeRefTab === "outbound" && (
<>
{currentNode.edges_unknown && !wasmOutboundEdges && (
<p className="subtitle" style={{ color: "var(--warn-border)" }}>
⚠ Outbound references not captured — this object fell below the top-10,000 shallow-heap threshold when the report was generated.{" "}
Load the .hprof in the browser for live references, or use{" "}
<button className="btn-link" style={{ fontSize: "inherit" }}
onClick={() => { setTab("domtree"); window.location.hash = `domtree/${nodeId}`; }}>
Dominator Tree →
</button>
{" "}as an alternative view.
</p>
)}
{wasmOutboundEdges && (
<p className="subtitle" style={{ fontSize: "0.78rem", color: "var(--muted)" }}>
Live outbound references from loaded heap ({fmtCount(wasmOutboundTotal)} total{wasmOutboundTruncated ? ", showing first 200" : ""}).
</p>
)}
{currentNode.edges_truncated && !wasmOutboundEdges && (
<p className="subtitle">Showing first 100 outbound references.{" "}
{(window as any).__wasmSession?.outbound_refs && "Load the .hprof in the browser for all references."}
</p>
)}
{currentEdges.length > 0 && currentEdges.every(e => !e.field_name) && !data.capture_params?.ref_paths && (
<p className="subtitle" style={{ fontSize: "0.78rem", color: "var(--muted)" }}>
All fields unnamed — re-run with <code>--ref-paths</code> for field names.
</p>
)}
{pagedEdges.length === 0 && !currentNode.edges_unknown && (
<p className="subtitle">{refFilter ? `No references matching "${refFilter}".` : "No outbound references — leaf object or all fields are primitives."}</p>
)}
{groupedEdges.length > 1 && (
<input
type="text"
value={refFilter}
onChange={e => { setRefFilter(e.target.value); setPage(0); }}
placeholder="Filter by field or class…"
style={{ width: "100%", marginBottom: "0.4rem", fontSize: "0.82rem", padding: "2px 6px", border: "1px solid var(--border, #e2e8f0)", borderRadius: 4, background: "var(--input-bg, var(--bg))", color: "inherit", boxSizing: "border-box" }}
/>
)}
{groupedEdges.length > 0 && currentEdges.length > groupedEdges.length && (
<p className="subtitle" style={{ fontSize: "0.8rem" }}>
{currentEdges.length} edges collapsed into {groupedEdges.length} field/class groups.
{refFilter && filteredEdges.length < groupedEdges.length && (
<> Showing {filteredEdges.length} matching.</>
)}
</p>
)}
{pagedEdges.length > 0 && (
<table className="std-table">
<thead>
<tr>
<th>Field</th>
<th>Child Class</th>
<th style={{ textAlign: "right" }}>Retained</th>
<th></th>
</tr>
</thead>
<tbody>
{pagedEdges.flatMap((edge, i) => {
const isExpanded = expandedGroups.has(edge.groupKey);
const pct = currentNode.retained > 0 && !edge.any_shared ? edge.total_retained / currentNode.retained : 0;
const rows: React.ReactNode[] = [];
rows.push(
<tr key={i}>
<td>
<code>{edge.field_name || <em style={{ color: "var(--muted)" }}>(unnamed)</em>}</code>
{edge.count > 1 && (
<button
className="btn-link"
style={{ marginLeft: "0.35rem", fontSize: "0.75rem", background: "var(--accent-muted, #dbeafe)", color: "var(--accent)", borderRadius: 4, padding: "0 4px" }}
title={isExpanded ? "Collapse" : `Expand — show all ${edge.count} instances`}
onClick={() => setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(edge.groupKey)) next.delete(edge.groupKey); else next.add(edge.groupKey);
return next;
})}
>
{isExpanded ? "▾" : "▸"}×{edge.count}
</button>
)}
</td>
<td>
<span className="copy-cell">
<button className="btn-link"
title={edge.count > 1 ? `Navigate to biggest instance (×${edge.count} total)` : "Navigate to outbound references"}
onClick={() => {
navigate("explore", edge.child_idx, edge.child_class, edge.field_name || undefined, edge.count > 1 ? edge.groupKey : undefined);
}}>
<code title={edge.child_class}>{edge.child_class}</code>
</button>
<button className="btn-link" title="Open in dominator tree"
style={{ opacity: 0.6, flexShrink: 0 }}
onClick={() => navigate("domtree", edge.child_idx, edge.child_class, edge.field_name || undefined)}>
⌞
</button>
<PivotBtn cls={edge.child_class} />
<OqlBtn cls={edge.child_class} />
<ListObjectsBtn cls={edge.child_class} />
{edge.count >= 2 && !edge.any_shared && isCollectionClass(edge.child_class) && (
<span
style={{
fontSize: "0.72rem",
color: "var(--muted)",
marginLeft: "0.25rem",
whiteSpace: "nowrap",
}}
title={`${edge.count} outbound references of this type — proxy for collection size`}
>
×{edge.count} entries
</span>
)}
</span>
</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<span style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: "0.35rem" }}>
{pct > 0.001 && (
<span style={{ display: "inline-block", width: `${Math.max(2, Math.round(pct * 48))}px`, height: "6px", borderRadius: 2, background: "var(--accent, #3b82f6)", opacity: 0.55, flexShrink: 0 }} title={`${pct < 0.01 ? (pct * 100).toFixed(1) : (pct * 100).toFixed(0)}% of parent retained`} />
)}
<span title={fmtExactBytes(edge.total_retained)}>{fmtB(edge.total_retained)}</span>
{pct >= 0.0005 && (
<span style={{ color: "var(--muted)", fontSize: "0.78rem" }}>
{pct < 0.01 ? `${(pct * 100).toFixed(1)}%` : `${(pct * 100).toFixed(0)}%`}
</span>
)}
</span>
</td>
<td>
{domBadgeChildIdx === edge.child_idx && (
<span
className="shared-badge"
title="Dominant retention path — immediate dominator child with highest retained size"
style={{ background: "var(--ok-bg, #d1fae5)", color: "var(--ok, #065f46)", borderColor: "var(--ok-border, #a7f3d0)" }}
>
Dom
</span>
)}
{edge.any_shared && (
<span className="shared-badge" title="Shared: retained heap owned by another subtree — gross sum">↻ Shared</span>
)}
{breadcrumbIdSet.has(edge.child_idx) && (
<span className="shared-badge" title="Already in navigation path — back-reference or cycle" style={{ background: "var(--warn-bg, #fef3c7)", color: "var(--warn, #92400e)", borderColor: "var(--warn-border, #fde68a)" }}>↩ Visited</span>
)}
</td>
</tr>
);
if (isExpanded) {
const sorted = [...edge.members].sort((a, b) => b.child_retained - a.child_retained);
for (const m of sorted) {
const mShared = !!(data.nodes[String(m.child_idx)] && data.nodes[String(m.child_idx)]!.idom !== nodeId);
rows.push(
<tr key={`${i}-${m.child_idx}`} style={{ background: "var(--hover-bg, rgba(0,0,0,0.03))" }}>
<td style={{ paddingLeft: "1.5rem", color: "var(--muted)", fontSize: "0.8rem" }}>
#{m.child_idx}
</td>
<td>
<span className="copy-cell" style={{ paddingLeft: "0.5rem" }}>
<button className="btn-link" title="Navigate to outbound references" style={{ fontSize: "0.8rem" }}
onClick={() => navigate("explore", m.child_idx, m.child_class, edge.field_name || undefined)}>
<code title={m.child_class}>{m.child_class}</code>
</button>
<button className="btn-link" title="Open dominator tree"
style={{ opacity: 0.6, flexShrink: 0, fontSize: "0.8rem" }}
onClick={() => navigate("domtree", m.child_idx, m.child_class, edge.field_name || undefined)}>
⌞
</button>
<PivotBtn cls={m.child_class} />
<OqlBtn cls={m.child_class} />
<ListObjectsBtn cls={m.child_class} />
</span>
</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap", fontSize: "0.8rem" }}><span title={fmtExactBytes(m.child_retained)}>{fmtB(m.child_retained)}</span></td>
<td>
{mShared && <span className="shared-badge" style={{ fontSize: "0.75rem" }}>↻</span>}
{breadcrumbIdSet.has(m.child_idx) && (
<span className="shared-badge" title="Already in navigation path — back-reference or cycle" style={{ background: "var(--warn-bg, #fef3c7)", color: "var(--warn, #92400e)", borderColor: "var(--warn-border, #fde68a)", fontSize: "0.75rem" }}>↩ Visited</span>
)}
</td>
</tr>
);
}
}
return rows;
})}
</tbody>
</table>
)}
{filteredEdges.length > PAGE_SIZE && (
<div style={{ marginTop: "0.5rem", display: "flex", gap: "0.5rem" }}>
<button
className="btn-link"
disabled={page === 0}
onClick={() => setPage(p => p - 1)}
>
« Prev
</button>
<span>
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, filteredEdges.length)} of {filteredEdges.length}
</span>
<button
className="btn-link"
disabled={(page + 1) * PAGE_SIZE >= filteredEdges.length}
onClick={() => setPage(p => p + 1)}
>
Next »
</button>
</div>
)}
{groupedEdges.length > 0 && currentNode && (() => {
const childTotal = groupedEdges.reduce((s, e) => s + (e.any_shared ? 0 : e.total_retained), 0);
const selfOnly = Math.max(0, currentNode.retained - childTotal);
const pct = currentNode.retained > 0 ? Math.round(childTotal / currentNode.retained * 100) : 0;
return (
<p className="subtitle" style={{ fontSize: "0.78rem", marginTop: "0.4rem" }}>
{currentEdges.length} reference{currentEdges.length === 1 ? "" : "s"} · children retain <span title={fmtExactBytes(childTotal)}>{fmtB(childTotal)}</span> ({pct}%) · self shallow <span title={fmtExactBytes(selfOnly)}>{fmtB(selfOnly)}</span>
</p>
);
})()}
</>
)}
{activeRefTab === "inbound" && nodeId !== null && (() => {
const iEdges = data.inbound_edges?.[String(nodeId)] ?? [];
const iTrunc = (data.inbound_truncated ?? []).includes(nodeId);
const wasm = (window as any).__wasmExploration;
if (wasm) {
return <WasmInboundPanel nodeId={nodeId} session={wasm} fmtB={fmtB} onNavigate={(id) => navigate("explore", id, data.nodes[String(id)]?.display_class ?? `#${id}`)} onNavigateDomtree={(id) => navigate("domtree", id, data.nodes[String(id)]?.display_class ?? `#${id}`)} />;
}
if (iEdges.length > 0 || iTrunc) {
return (
<>
<table className="std-table">
<thead><tr>
<th>Field</th><th>Class</th>
<th style={{ textAlign: "right" }}>Shallow</th>
<th style={{ textAlign: "right" }}>Retained</th>
</tr></thead>
<tbody>
{iEdges.map((e, i) => (
<tr key={i}>
<td style={{ color: "var(--muted)" }}><code>{e.field_name || "—"}</code></td>
<td>
<span className="copy-cell">
<button className="btn-link" onClick={() => navigate("explore", e.src_idx, e.src_class)}>
<code title={e.src_class}>{e.src_class}</code>
</button>
<button className="btn-link" title="Open in dominator tree" style={{ opacity: 0.6, flexShrink: 0 }} onClick={() => navigate("domtree", e.src_idx, e.src_class)}>⌞</button>
<PivotBtn cls={e.src_class} />
<OqlBtn cls={e.src_class} />
<ListObjectsBtn cls={e.src_class} />
</span>
</td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(e.src_shallow)}>{fmtB(e.src_shallow)}</span></td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(e.src_retained)}>{fmtB(e.src_retained)}</span></td>
</tr>
))}
</tbody>
</table>
{iTrunc && (
<p className="subtitle" style={{ fontSize: "0.78rem" }}>
Showing first {data.capture_params?.edge_cap ?? 100} inbound references.{" "}
Re-run with <code>--obj-graph=medium</code> for more.
</p>
)}
</>
);
}
if (currentNode?.edges_unknown) {
return (
<p className="subtitle">
Inbound references not captured. Load the .hprof in the browser for the full inbound graph.
</p>
);
}
return <p className="subtitle">No inbound references captured for this object.</p>;
})()}
{/* Path to GC Root */}
{currentNode && dominatorChain.length > 0 && (
<div style={{ marginTop: "0.75rem", borderTop: "1px solid var(--border-faint, #f0f0f0)", paddingTop: "0.5rem" }}>
<button
className="btn-link"
style={{ fontSize: "0.85rem", fontWeight: 600, display: "flex", alignItems: "center", gap: "0.3rem" }}
onClick={() => setShowGcPath(v => !v)}
>
{showGcPath ? "▼" : "▶"} Path to GC Root
</button>
{showGcPath && (() => {
const wasmExploration = (window as any).__wasmExploration;
const chainReversed = [...dominatorChain].reverse(); // root → target
const domSection = (
<div style={{ marginTop: "0.4rem" }}>
<p style={{ fontSize: "0.75rem", color: "var(--muted)", margin: "0 0 0.3rem 0" }}>
Dominator path{" "}
<span title="Dominating objects — not the actual reference path." style={{ cursor: "help", borderBottom: "1px dotted var(--muted)" }}>(?)</span>
</p>
{chainReversed.map((idx, i) => {
const node = data.nodes[String(idx)];
return (
<div key={idx} style={{ display: "flex", alignItems: "center", gap: "0.4rem", fontSize: "0.82rem", padding: "1px 0" }}>
{i > 0 && <span style={{ color: "var(--muted)", marginLeft: "0.5rem" }}>↓</span>}
<button className="btn-link" style={{ fontFamily: "monospace", flex: 1, textAlign: "left" }}
onClick={() => navigate("explore", idx, node?.display_class ?? `obj#${idx}`)}>
{node?.display_class ?? `obj#${idx}`}
</button>
<span style={{ color: "var(--muted)", fontSize: "0.75rem", whiteSpace: "nowrap" }} title={fmtExactBytes(node?.retained ?? 0)}>{fmtB(node?.retained ?? 0)}</span>
</div>
);
})}
</div>
);
if (wasmExploration) {
return (
<>
<div style={{ marginTop: "0.4rem" }}>
<p style={{ fontSize: "0.75rem", color: "var(--muted)", margin: "0 0 0.3rem 0", fontWeight: 600 }}>
Shortest Path to GC Root
</p>
<WasmGcPathPanel
nodeId={nodeId!}
session={wasmExploration}
data={data}
fmtB={fmtB}
navigate={(id) => navigate("explore", id, data.nodes[String(id)]?.display_class ?? `#${id}`)}
/>
</div>
{domSection}
</>
);
}
return domSection;
})()}
</div>
)}
{/* OQL Query Panel — only when WASM session is available */}
{nodeId !== null && (() => {
const wasm = (window as any).__wasmExploration;
if (!wasm) return null;
return (
<WasmQueryPanel
nodeId={nodeId}
session={wasm}
data={data}
/>
);
})()}
{/* Collection Entries (J): entries for known Java/Scala/Kotlin collections */}
{wasmCollEntries && (
<div style={{ marginTop: "0.75rem", borderTop: "1px solid var(--border-faint, #f0f0f0)", paddingTop: "0.5rem" }}>
<div style={{ fontSize: "0.85rem", fontWeight: 600, marginBottom: "0.3rem" }}>
Collection entries{wasmCollEntries.truncated ? " (first 50)" : ` (${wasmCollEntries.entries.length})`}
</div>
<table className="std-table" style={{ fontSize: "0.8rem" }}>
<thead>
<tr>
{wasmCollEntries.type === "map" ? (
<th>Entry Object</th>
) : (
<th>Element</th>
)}
</tr>
</thead>
<tbody>
{wasmCollEntries.entries.map((e: any, i: number) => (
<tr key={i}>
<td>
{e.elem_idx != null ? (
<button className="btn-link" style={{ fontFamily: "monospace", fontSize: "0.8rem" }}
onClick={() => navigate("explore", e.elem_idx, e.elem_class ?? `#${e.elem_idx}`)}>
{e.elem_class ?? `obj#${e.elem_idx}`}#{e.elem_idx}
</button>
) : (
<span style={{ color: "var(--muted)", fontStyle: "italic" }}>null</span>
)}
</td>
</tr>
))}
</tbody>
</table>
{wasmCollEntries.truncated && (
<p style={{ fontSize: "0.75rem", color: "var(--muted)", margin: "0.2rem 0 0" }}>Showing first 50 entries.</p>
)}
</div>
)}
{/* Additional GC root paths (H): multi-path BFS, shown when >1 distinct path exists */}
{wasmAllPaths && wasmAllPaths.paths.length > 1 && (
<div style={{ marginTop: "0.75rem", borderTop: "1px solid var(--border-faint, #f0f0f0)", paddingTop: "0.5rem" }}>
<button
className="btn-link"
style={{ fontSize: "0.85rem", fontWeight: 600, display: "flex", alignItems: "center", gap: "0.3rem" }}
onClick={() => setShowAllPaths(v => !v)}
>
{showAllPaths ? "▼" : "▶"} Additional Retention Paths ({wasmAllPaths.paths.length})
</button>
{showAllPaths && (
<div style={{ marginTop: "0.4rem" }}>
<p style={{ fontSize: "0.75rem", color: "var(--muted)", margin: "0 0 0.4rem" }}>
Multiple paths from GC roots to this object. Each row is a separate retention chain (root → object).
</p>
{wasmAllPaths.paths.map((p, pi) => (
<details key={pi} style={{ marginBottom: "0.4rem" }}>
<summary style={{ fontSize: "0.82rem", cursor: "pointer", userSelect: "none" }}>
Path {pi + 1} via <strong>{p.root_type}</strong> ({p.path.length} hop{p.path.length !== 1 ? "s" : ""})
</summary>
<div style={{ paddingLeft: "0.5rem", marginTop: "0.2rem" }}>
<RetentionChain
nodes={(p.path as any[]).map((step: any, si: number, arr: any[]) => ({
denseIdx: step.dense_idx,
displayClass: step.display_class ?? `obj#${step.dense_idx}`,
retained: step.retained ?? 0,
fieldName: undefined,
isFirst: si === 0,
isCurrent: si === arr.length - 1,
}))}
rootBadge={p.root_type}
data={data}
session={(window as any).__wasmExploration}
fmtB={fmtB}
navigate={(id) => navigate("explore", id, data.nodes[String(id)]?.display_class ?? `#${id}`)}
/>
</div>
</details>
))}
</div>
)}
</div>
)}
</>
) : (
<>
{currentNode.idom == null && data.roots.length > 0 && (
<div style={{ marginBottom: "0.75rem" }}>
<h4 style={{ margin: "0 0 0.4rem" }}>Top Retained Roots</h4>
<p className="subtitle" style={{ fontSize: "0.82rem", margin: "0 0 0.4rem" }}>
GC roots sorted by retained heap. Click to descend into dominated subtree.
</p>
<table className="std-table">
<thead>
<tr>
<th>Class</th>
<th style={{ textAlign: "right" }}>Shallow</th>
<th style={{ textAlign: "right" }}>Retained</th>
<th style={{ textAlign: "right" }}>% Heap</th>
<th style={{ textAlign: "right" }}>Objects in Subtree</th>
</tr>
</thead>
<tbody>
{[...data.roots]
.sort((a, b) => (data.nodes[String(b)]?.retained ?? 0) - (data.nodes[String(a)]?.retained ?? 0))
.slice(0, 50)
.map(rootId => {
const rn = data.nodes[String(rootId)];
if (!rn) return null;
const pct = totalHeap > 0 ? fmtPct(rn.retained / totalHeap * 100) : "—";
return (
<tr key={rootId}>
<td>
<span className="copy-cell">
<button className="btn-link"
onClick={() => navigate("domtree", rootId, rn.display_class)}>
<code title={rn.display_class}>{rn.display_class}</code>
</button>
<PivotBtn cls={rn.display_class} />
<OqlBtn cls={rn.display_class} />
<ListObjectsBtn cls={rn.display_class} />
</span>
</td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(rn.shallow)}>{fmtB(rn.shallow)}</span></td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(rn.retained)}>{fmtB(rn.retained)}</span></td>
<td style={{ textAlign: "right" }}>{pct}</td>
<td style={{ textAlign: "right" }}>
{rn.dom_subtree_count != null && rn.dom_subtree_count > 0
? fmtCount(rn.dom_subtree_count)
: "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{currentDomChildren.length > 0 && (() => {
const childRetainedTotal = currentDomChildren.reduce((s, id) => {
const cn = data.nodes[String(id)];
return s + (cn?.retained ?? 0);
}, 0);
const shallowSelf = currentNode.retained - childRetainedTotal;
const shallowPct = currentNode.retained > 0 ? (shallowSelf / currentNode.retained * 100).toFixed(0) : "0";
const childPct = currentNode.retained > 0 ? (childRetainedTotal / currentNode.retained * 100).toFixed(0) : "0";
// Compute how many steps we can skip (single-child chain where child ≥ 95% of parent)
let chainEnd: number | null = null;
let chainLen = 0;
if (currentDomChildren.length === 1) {
let cur = currentDomChildren[0];
while (true) {
const curNode = data.nodes[String(cur)];
if (!curNode) break;
const curChildren = data.dom_children[String(cur)] ?? [];
const frac = currentNode.retained > 0 ? curNode.retained / currentNode.retained : 0;
if (curChildren.length !== 1 || frac < 0.95) { chainEnd = cur; chainLen++; break; }
chainLen++;
cur = curChildren[0];
}
}
return (
<>
<p className="subtitle" style={{ fontSize: "0.82rem", margin: "0 0 0.4rem" }}>
{currentDomChildren.length} {currentDomChildren.length === 1 ? "child" : "children"} retaining <span title={fmtExactBytes(childRetainedTotal)}>{fmtB(childRetainedTotal)}</span> ({childPct}%){" "}
+ <span title={fmtExactBytes(shallowSelf)}>{fmtB(shallowSelf)}</span> ({shallowPct}%) in this object itself.
Click a child to descend.
{currentNode.dom_subtree_count != null && currentNode.dom_subtree_count > 1 && (
<> · <strong>{fmtCount(currentNode.dom_subtree_count)}</strong> objects total in subtree.</>
)}
</p>
{chainEnd !== null && chainLen >= 2 && (
<p className="subtitle" style={{ fontSize: "0.8rem", margin: "0 0 0.4rem" }}>
<button className="btn-link" style={{ fontSize: "0.8rem" }}
title={`Skip ${chainLen} single-child steps to reach the first node with multiple dominatees`}
onClick={() => navigate("domtree", chainEnd!, data.nodes[String(chainEnd!)]?.display_class ?? `#${chainEnd}`)}>
Skip {chainLen}-step chain →
</button>
{" "}(single-child chain — each step retains ≥95% of this object)
</p>
)}
</>
);
})()}
{/* Parent / sibling / depth nav — shown for all domtree nodes, not just those with children */}
{(() => {
const parentId = dominatorChain.length > 1 ? dominatorChain[1] : null;
const parentNode = parentId != null ? data.nodes[String(parentId)] : null;
const depth = dominatorChain.length - 1;
const prevSib = siblingIdx > 0 ? classSiblings[siblingIdx - 1] : null;
const nextSib = siblingIdx >= 0 && siblingIdx < classSiblings.length - 1 ? classSiblings[siblingIdx + 1] : null;
return (parentNode || depth > 0 || prevSib || nextSib) ? (
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap", alignItems: "center", marginBottom: "0.4rem" }}>
{parentNode && (
<button className="show-more-btn"
title={`Up to parent: ${parentNode.display_class}`}
onClick={() => navigate("domtree", parentId!, parentNode.display_class)}>
↑ Parent
</button>
)}
{prevSib != null && (
<button className="show-more-btn"
title="Previous same-class instance ([ key)"
onClick={() => navigate("domtree", prevSib.id, data.nodes[String(prevSib.id)]?.display_class ?? `#${prevSib.id}`)}>
← Prev
</button>
)}
{nextSib != null && (
<button className="show-more-btn"
title="Next same-class instance (] key)"
onClick={() => navigate("domtree", nextSib.id, data.nodes[String(nextSib.id)]?.display_class ?? `#${nextSib.id}`)}>
Next →
</button>
)}
{depth > 0 && (
<span style={{ fontSize: "0.78rem", color: "var(--muted)" }}>
depth {depth}
</span>
)}
</div>
) : null;
})()}
{currentDomChildren.length > 0 && (
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.4rem", fontSize: "0.82rem", alignItems: "center", flexWrap: "wrap" }}>
<span style={{ color: "var(--muted)" }}>View:</span>
{(["flat", "grouped", "expanded"] as const).map(mode => (
<button key={mode} className={domViewMode === mode ? "btn-active" : "btn-link"}
style={{ fontSize: "0.82rem", padding: "1px 6px" }}
onClick={() => {
if (mode === "expanded") {
expandDomSubtree();
} else {
setDomViewMode(mode);
}
}}>
{mode === "flat" ? "Immediate" : mode === "grouped" ? "By Class" : "All Objects (up to 1000)"}
</button>
))}
{hasDomData && (
<button className="btn-link" style={{ fontSize: "0.82rem", marginLeft: "auto" }}
title={`Open ${currentNode.display_class} in the Dominator Analysis Sankey chart`}
onClick={() => pivotClass(currentNode.display_class)}>
Dominator Analysis →
</button>
)}
</div>
)}
{currentDomChildren.length > 5 && (domViewMode === "flat" || domViewMode === "grouped") && (
<input
type="text"
value={domFilter}
onChange={e => setDomFilter(e.target.value)}
placeholder="Filter by class…"
style={{ width: "100%", marginBottom: "0.4rem", fontSize: "0.82rem", padding: "2px 6px", border: "1px solid var(--border, #e2e8f0)", borderRadius: 4, background: "var(--input-bg, var(--bg))", color: "inherit", boxSizing: "border-box" }}
/>
)}
{currentDomChildren.length === 0 ? (
<p className="subtitle">No dominated children to show.</p>
) : (
<>
{domViewMode === "grouped" && (() => {
const byClass = new Map<string, { count: number; total_retained: number; max_retained: number }>();
for (const childId of currentDomChildren) {
const cn = data.nodes[String(childId)];
if (!cn) continue;
const entry = byClass.get(cn.display_class) ?? { count: 0, total_retained: 0, max_retained: 0 };
entry.count++;
entry.total_retained += cn.retained;
entry.max_retained = Math.max(entry.max_retained, cn.retained);
byClass.set(cn.display_class, entry);
}
const rows = [...byClass.entries()]
.map(([cls, v]) => ({ cls, ...v }))
.filter(r => !domFilter || r.cls.toLowerCase().includes(domFilter.toLowerCase()))
.sort((a, b) => b.total_retained - a.total_retained);
return (
<table className="std-table">
<thead>
<tr>
<th>Class</th>
<th style={{ textAlign: "right" }}>Instances</th>
<th style={{ textAlign: "right" }}>Total Retained</th>
<th style={{ textAlign: "right" }}>Max Single Retained</th>
</tr>
</thead>
<tbody>
{rows.map(r => (
<tr key={r.cls}>
<td><span className="copy-cell"><code title={r.cls}>{r.cls}</code><CopyBtn text={r.cls} /><PivotBtn cls={r.cls} /><OqlBtn cls={r.cls} /><ListObjectsBtn cls={r.cls} /></span></td>
<td style={{ textAlign: "right" }}>{fmtCount(r.count)}</td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(r.total_retained)}>{fmtB(r.total_retained)}</span></td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(r.max_retained)}>{fmtB(r.max_retained)}</span></td>
</tr>
))}
</tbody>
</table>
);
})()}
{domViewMode === "expanded" && expandedDomList !== null && (() => {
const filtered = expandFilter
? expandedDomList.filter(r => r.display_class.toLowerCase().includes(expandFilter.toLowerCase()))
: expandedDomList;
return (
<>
<input type="text" value={expandFilter} onChange={e => setExpandFilter(e.target.value)}
placeholder="Filter by class…"
style={{ width: "100%", marginBottom: "0.4rem", fontSize: "0.82rem", padding: "2px 6px",
border: "1px solid var(--border, #e2e8f0)", borderRadius: 4,
background: "var(--input-bg, var(--bg))", color: "inherit", boxSizing: "border-box" as const }} />
<p className="subtitle" style={{ fontSize: "0.8rem", margin: "0 0 0.3rem" }}>
{expandedDomList.length >= 1000
? "Showing first 1,000 objects (subtree may be larger)."
: `${expandedDomList.length} objects in captured subtree.`}
</p>
<table className="std-table">
<thead>
<tr>
<th>Class</th>
<th style={{ textAlign: "right" }}>Depth</th>
<th style={{ textAlign: "right" }}>Shallow</th>
<th style={{ textAlign: "right" }}>Retained</th>
</tr>
</thead>
<tbody>
{filtered.slice(0, 500).map(r => (
<tr key={r.id}>
<td>
<span className="copy-cell">
<button className="btn-link" style={{ fontSize: "0.85rem" }}
onClick={() => navigate("domtree", r.id, r.display_class)}>
<code title={r.display_class}>{r.display_class}</code>
</button>
<PivotBtn cls={r.display_class} />
<OqlBtn cls={r.display_class} />
<ListObjectsBtn cls={r.display_class} />
</span>
</td>
<td style={{ textAlign: "right", color: "var(--muted)", fontSize: "0.8rem" }}>{r.depth}</td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(r.shallow)}>{fmtB(r.shallow)}</span></td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(r.retained)}>{fmtB(r.retained)}</span></td>
</tr>
))}
</tbody>
</table>
</>
);
})()}
{domViewMode === "flat" && (
<table className="std-table">
<thead>
<tr>
<th>Class</th>
<th>Shallow</th>
<th style={{ textAlign: "right" }}>Retained</th>
<th style={{ textAlign: "right" }}>% Heap</th>
</tr>
</thead>
<tbody>
{currentDomChildren.filter(childId => {
if (!domFilter) return true;
const cn = data.nodes[String(childId)];
return cn?.display_class.toLowerCase().includes(domFilter.toLowerCase());
}).map(childId => {
const cn = data.nodes[String(childId)];
if (!cn) return null;
const pct = currentNode.retained > 0 ? cn.retained / currentNode.retained : 0;
return (
<tr key={childId}>
<td>
<span style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
<button className="btn-link" title="Explore outbound references"
onClick={() => navigate("explore", childId, cn.display_class)}
>
<code title={cn.display_class}>{cn.display_class}</code>
</button>
<button className="btn-link" title="Open in dominator tree"
style={{ opacity: 0.6, flexShrink: 0 }}
onClick={() => navigate("domtree", childId, cn.display_class)}>
⌞
</button>
<PivotBtn cls={cn.display_class} />
<OqlBtn cls={cn.display_class} />
<ListObjectsBtn cls={cn.display_class} />
</span>
</td>
<td><span title={fmtExactBytes(cn.shallow)}>{fmtB(cn.shallow)}</span></td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<span style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: "0.35rem" }}>
{pct > 0.01 && (
<span style={{ display: "inline-block", width: `${Math.max(3, Math.round(pct * 48))}px`, height: "6px", borderRadius: 2, background: "var(--accent, #3b82f6)", opacity: 0.55, flexShrink: 0 }} title={`${(pct * 100).toFixed(1)}% of parent`} />
)}
<span title={fmtExactBytes(cn.retained)}>{fmtB(cn.retained)}</span>
{pct >= 0.005 && (
<span style={{ color: "var(--muted)", fontSize: "0.78rem" }}>
{(pct * 100).toFixed(0)}%
</span>
)}
</span>
</td>
<td style={{ textAlign: "right" }}>
{totalHeap > 0
? fmtPct(cn.retained / totalHeap * 100)
: "—"}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</>
)}
{prebuiltTree && (
<div style={{ marginTop: "0.75rem" }}>
<button className="btn-link" onClick={() => setShowSvg(v => !v)}>
{showSvg ? "Hide SVG Tree" : "Show SVG Tree"}
</button>
{showSvg && <DomSubtreeSvg node={prebuiltTree} onNavigate={(idx) => {
(window as any).__explorerNavigate?.("explore", idx) ?? (window.location.hash = `explore/${idx}`);
}} />}
</div>
)}
</>
)}
</div>
{/* Right panel: node details */}
<div className="obj-explorer-right">
<h4 style={{ margin: "0 0 0.4rem" }}>Object Details</h4>
<table className="std-table">
<tbody>
<tr>
<th>Class</th>
<td>
<span className="copy-cell">
<code title={currentNode.display_class}>{currentNode.display_class}</code>
<CopyBtn text={currentNode.display_class} />
<PivotBtn cls={currentNode.display_class} />
<OqlBtn cls={currentNode.display_class} />
<ListObjectsBtn cls={currentNode.display_class} />
</span>
{classSiblings.length > 1 && (
<button className="btn-link" style={{ fontSize: "0.76rem", color: "var(--muted)", marginLeft: "0.3rem" }}
title={`Show all ${classSiblings.length} ${currentNode.display_class.split(".").pop()} instances in captured graph (${fmtB(classSiblings.reduce((s, x) => s + x.retained, 0))} total retained)`}
onClick={() => goToRoot(currentNode.display_class)}>
{classSiblings.length} instances ↗
</button>
)}
</td>
</tr>
<tr>
<th>Object #</th>
<td>
<span className="copy-cell">
<span title="0-based dense index; use in 'Go to object #' to navigate here">{nodeId}</span>
<CopyBtn text={String(nodeId)} />
</span>
</td>
</tr>
<tr>
<th>Pin</th>
<td>
<button className="btn-link" style={{ fontSize: "0.82rem" }}
title={pinnedNodes.some(p => p.nodeId === nodeId) ? "Unpin this object" : "Pin for quick return"}
onClick={() => togglePin(nodeId!, currentNode.display_class)}>
{pinnedNodes.some(p => p.nodeId === nodeId) ? "📌 Pinned" : "📌 Pin"}
</button>
</td>
</tr>
<tr>
<th>Path</th>
<td>
{pathSource?.nodeId === nodeId ? (
<span style={{ fontSize: "0.82rem", color: "var(--muted)" }}>
← Path Source
<button className="btn-link" style={{ marginLeft: "0.4rem", fontSize: "0.78rem" }}
onClick={() => setPathSource(null)}>Clear</button>
</span>
) : pathSource ? (
<button className="btn-link" style={{ fontSize: "0.82rem" }}
title={`Find reference path from ${pathSource.label}#${pathSource.nodeId} to here`}
onClick={() => {
const wasm = (window as any).__wasmExploration;
if (!wasm?.find_path_between) { setPathBetweenResult(null); setPathBetweenError("requires .hprof loaded in browser"); return; }
try {
const r = JSON.parse(wasm.find_path_between(pathSource.nodeId, nodeId!));
if (r.ok) { setPathBetweenResult(r.path); setPathBetweenError(null); }
else { setPathBetweenResult(null); setPathBetweenError(r.error ?? "not_found"); }
} catch (e: any) { setPathBetweenError(String(e)); }
}}>
Find path from {pathSource.label.split(".").pop()}#{pathSource.nodeId} →
</button>
) : (
<button className="btn-link" style={{ fontSize: "0.82rem" }}
title="Set this object as the source for a path-between search"
onClick={() => { setPathSource({ nodeId: nodeId!, label: currentNode.display_class }); setPathBetweenResult(null); setPathBetweenError(null); }}>
Set as Path Source
</button>
)}
</td>
</tr>
<tr>
<th>Query</th>
<td>
<button className="btn-link" style={{ fontSize: "0.82rem" }}
title={`Copy OQL: SELECT * FROM ${currentNode.display_class} s WHERE s.@objectId = ${nodeId}`}
onClick={() => {
const q = `SELECT * FROM ${currentNode.display_class} s WHERE s.@objectId = ${nodeId}`;
navigator.clipboard?.writeText(q);
}}>
Copy OQL ⎘
</button>
</td>
</tr>
<tr>
<th>Type Graph</th>
<td>
<button className="btn-link" style={{ fontSize: "0.82rem" }}
title={`Open ${currentNode.display_class} in Type Reference Graph`}
onClick={() => {
window.dispatchEvent(new CustomEvent("trg-focus-class", { detail: currentNode.display_class }));
history.replaceState(null, "", "#type-ref-graph");
window.dispatchEvent(new CustomEvent("nav-toast", { detail: { label: "Updated in Type Graph", sectionId: "type-ref-graph" } }));
}}>
Open in Type Graph →
</button>
</td>
</tr>
{hasDomData && (
<tr>
<th>Dominator</th>
<td>
<button className="btn-link" style={{ fontSize: "0.82rem" }}
title={`Show ${currentNode.display_class} in WhoHolds Dominator Sankey`}
onClick={() => pivotClass(currentNode.display_class)}>
Open in Dominator →
</button>
</td>
</tr>
)}
<tr>
<th>Shallow</th>
<td><span title={fmtExactBytes(currentNode.shallow)}>{fmtB(currentNode.shallow)}</span></td>
</tr>
<tr>
<th>Retained</th>
<td><span title={fmtExactBytes(currentNode.retained)}>{fmtB(currentNode.retained)}</span></td>
</tr>
<tr>
<th>% Heap</th>
<td>
{totalHeap > 0
? fmtPct(currentNode.retained / totalHeap * 100)
: "—"}
</td>
</tr>
{(() => {
if (dominatorChain.length <= 1) {
return (
<tr>
<th>Why Alive?</th>
<td style={{ color: "var(--muted)", fontSize: "0.82rem" }}>
Held directly by GC roots — no single dominating object
</td>
</tr>
);
}
const rootIdx = dominatorChain[dominatorChain.length - 1];
const rootNode = data.nodes[String(rootIdx)];
const rootLabel = rootNode
? `${shortClass(rootNode.display_class)}#${rootIdx}`
: `obj#${rootIdx}`;
const hops = dominatorChain.length - 1;
const hopPhrase = hops === 1 ? "directly" : `via ${hops} hops`;
return (
<tr>
<th>Why Alive?</th>
<td style={{ fontSize: "0.82rem" }}>
<button
className="btn-link"
style={{ fontSize: "inherit" }}
title="Navigate to the GC root holding this object"
onClick={() => navigate("explore", rootIdx, rootNode?.display_class ?? `obj#${rootIdx}`)}
>
{rootLabel}
</button>
{" (GC root) "}<span title="Dominator-tree hops; actual reference path may differ">{hopPhrase}</span>
</td>
</tr>
);
})()}
{nodeId !== null && (() => {
const wasm = (window as any).__wasmExploration;
if (!wasm?.get_object_address) return null;
try {
const r = JSON.parse(wasm.get_object_address(nodeId));
if (!r.ok) return null;
return (
<tr>
<th>Address</th>
<td>
<span className="copy-cell">
<code style={{ fontSize: "0.8rem" }}>{r.address}</code>
<CopyBtn text={r.address} />
</span>
</td>
</tr>
);
} catch { return null; }
})()}
{/* Field Values (G): show primitive + ref fields from WASM */}
{wasmFieldValues && wasmFieldValues.map((f, fi) => (
<tr key={`fv-${fi}`}>
<th style={{ fontWeight: "normal", color: "var(--muted)", fontStyle: "italic" }}>
.{f.name}
</th>
<td>
{f.kind === "ref" && f.dense_idx != null ? (
<span className="copy-cell">
<button className="btn-link" style={{ fontFamily: "monospace", fontSize: "0.82rem" }}
onClick={() => navigate("explore", f.dense_idx!, f.display_class ?? `#${f.dense_idx}`)}>
{f.display_class ?? `obj#${f.dense_idx}`}
</button>
</span>
) : f.kind === "null" ? (
<span style={{ color: "var(--muted)", fontStyle: "italic" }}>null</span>
) : (
<code style={{ fontSize: "0.82rem" }}>{String(f.value)}</code>
)}
</td>
</tr>
))}
<tr>
<th>Immediate Dominator</th>
<td>
{currentNode.idom != null ? (
<span className="copy-cell">
<button
className="btn-link"
onClick={() => navigate("explore", currentNode.idom!, idomNode?.display_class ?? `#${currentNode.idom}`, "idom")}
>
{idomNode ? `${idomNode.display_class.split(".").pop()}#${currentNode.idom}` : `obj#${currentNode.idom}`}
</button>
<button
className="btn-link"
title="Open in dominator tree"
style={{ opacity: 0.6, flexShrink: 0 }}
onClick={() => navigate("domtree", currentNode.idom!, idomNode?.display_class ?? `#${currentNode.idom}`, "idom")}
>
⌞
</button>
{idomNode && <PivotBtn cls={idomNode.display_class} />}
{idomNode && <OqlBtn cls={idomNode.display_class} />}
<ListObjectsBtn cls={idomNode.display_class} />
</span>
) : (
<em>GC root</em>
)}
</td>
</tr>
</tbody>
</table>
{pathBetweenError && (
<p style={{ fontSize: "0.8rem", color: "var(--error, #ef4444)", margin: "0.4rem 0 0" }}>
{pathBetweenError === "requires .hprof loaded in browser"
? "Path search requires the .hprof loaded in the browser."
: `No reference path found (${pathBetweenError}). No outbound path connects them.`}
</p>
)}
{pathBetweenResult && pathBetweenResult.length > 0 && (
<div style={{ marginTop: "0.5rem" }}>
<div style={{ fontSize: "0.78rem", color: "var(--muted)", fontWeight: 600, marginBottom: "2px" }}>
Reference path ({pathBetweenResult.length} steps)
</div>
<div style={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
{pathBetweenResult.map((step: any, i: number) => (
<React.Fragment key={i}>
{i > 0 && <div style={{ color: "var(--muted)", paddingLeft: "0.5rem", fontSize: "0.76rem" }}>▼</div>}
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<button className="btn-link" style={{ fontFamily: "monospace", fontSize: "0.8rem", fontWeight: i === 0 || i === pathBetweenResult.length - 1 ? 600 : 400 }}
onClick={() => navigate("explore", step.dense_idx, step.display_class)}>
{step.display_class.split(".").pop()}#{step.dense_idx}
</button>
<span title={fmtExactBytes(step.retained)} style={{ color: "var(--muted)", fontSize: "0.74rem" }}>{fmtB(step.retained)}</span>
{i === 0 && <span style={{ fontSize: "0.7rem", color: "var(--muted)", fontStyle: "italic" }}>← source</span>}
{i === pathBetweenResult.length - 1 && <span style={{ fontSize: "0.7rem", color: "var(--muted)", fontStyle: "italic" }}>← target</span>}
</div>
</React.Fragment>
))}
</div>
</div>
)}
{/* Retaining path: walk idom links up to GC root */}
{(() => {
const chainNodes: ChainNode[] = [];
let cur = currentNode.idom;
let childId: number = nodeId!;
const seen = new Set<number>();
while (cur != null && !seen.has(cur) && chainNodes.length < pathDepth) {
seen.add(cur);
const n = data.nodes[String(cur)];
if (!n) break;
const edgeToChild = (data.edges[String(cur)] ?? []).find(e => e.child_idx === childId);
chainNodes.push({
denseIdx: cur,
displayClass: n.display_class,
retained: n.retained,
fieldName: edgeToChild?.field_name || undefined,
isFirst: false,
isCurrent: false,
});
childId = cur;
cur = n.idom;
}
if (chainNodes.length === 0) return null;
chainNodes.reverse();
chainNodes.push({
denseIdx: nodeId!,
displayClass: currentNode.display_class,
retained: currentNode.retained,
fieldName: undefined,
isFirst: false,
isCurrent: true,
});
chainNodes[0].isFirst = true;
const hasMore = cur != null && chainNodes.length >= pathDepth + 1;
const wasmSession = (window as any).__wasmExploration;
return (
<div style={{ marginTop: "0.5rem" }}>
<div style={{ fontSize: "0.78rem", color: "var(--muted)", fontWeight: 600, marginBottom: "2px" }}>
Retaining path (dominator chain)
<span title="Dominating objects — not the actual reference path." style={{ cursor: "help", borderBottom: "1px dotted var(--muted)", marginLeft: "0.3rem", fontSize: "0.74rem" }}>(?)</span>
</div>
<RetentionChain
nodes={chainNodes}
data={data}
session={wasmSession}
fmtB={fmtB}
navigate={(id) => navigate("explore", id, data.nodes[String(id)]?.display_class ?? `#${id}`)}
/>
{hasMore && (
<button className="btn-link" style={{ fontSize: "0.74rem", marginTop: "2px" }}
onClick={() => setPathDepth(d => d + 20)}>
↑ … Show more
</button>
)}
</div>
);
})()}
{inboundRefs.length > 0 && (
<div style={{ marginTop: "0.5rem" }}>
<div style={{ fontSize: "0.78rem", color: "var(--muted)", fontWeight: 600, marginBottom: "2px" }}>
Inbound references from captured graph ({fmtCount(inboundRefs.length)})
</div>
{(showAllInbound ? inboundRefs : inboundRefs.slice(0, 8)).map(({ srcIdx, field_name }, i) => {
const sn = data.nodes[String(srcIdx)];
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: "0.2rem", fontSize: "0.78rem" }}>
{field_name && <code style={{ fontSize: "0.72rem", color: "var(--muted)" }}>.{field_name}</code>}
<button className="btn-link" style={{ fontSize: "0.78rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
title={sn?.display_class ?? `obj#${srcIdx}`}
onClick={() => navigate("explore", srcIdx, sn?.display_class ?? `#${srcIdx}`, field_name || undefined)}>
<code style={{ fontSize: "0.76rem" }}>{(sn?.display_class.split(".").pop() ?? sn?.display_class ?? `obj`)}#{srcIdx}</code>
</button>
<button className="btn-link" style={{ fontSize: "0.76rem", opacity: 0.6, flexShrink: 0 }}
title="Open in dominator tree"
onClick={() => navigate("domtree", srcIdx, sn?.display_class ?? `#${srcIdx}`, field_name || undefined)}>
⌞
</button>
{sn && <span style={{ color: "var(--muted)", flexShrink: 0, fontSize: "0.74rem" }} title={fmtExactBytes(sn.retained)}>{fmtB(sn.retained)}</span>}
</div>
);
})}
{inboundRefs.length > 8 && (
<button className="btn-link" style={{ fontSize: "0.74rem", marginTop: "2px" }}
onClick={() => setShowAllInbound(v => !v)}>
{showAllInbound ? "Show fewer" : `Show ${fmtCount(inboundRefs.length - 8)} more`}
</button>
)}
</div>
)}
{wasmPeerInstances && wasmPeerInstances.length > 0 && currentNode && (
<div style={{ marginTop: "0.5rem" }}>
<div style={{ fontSize: "0.78rem", color: "var(--muted)", fontWeight: 600, marginBottom: "2px" }}>
Other {currentNode.display_class.split(".").pop()} instances ({fmtCount(wasmPeerTotal)} total)
</div>
{wasmPeerInstances.map((m) => (
<div key={m.dense_idx} style={{ display: "flex", alignItems: "center", gap: "0.3rem", fontSize: "0.78rem" }}>
<button className="btn-link" style={{ fontSize: "0.78rem" }}
onClick={() => navigate("explore", m.dense_idx, m.display_class)}>
#{m.dense_idx}
</button>
<span title={fmtExactBytes(m.retained)} style={{ color: "var(--muted)", fontSize: "0.74rem" }}>{fmtB(m.retained)}</span>
</div>
))}
</div>
)}
{currentDomChildren.length > 0 && tab === "explore" && (
<div style={{ marginTop: "0.75rem" }}>
<h4 style={{ margin: "0 0 0.4rem" }}>Dominator children ({currentDomChildren.length})</h4>
<div style={{ display: "grid", gridTemplateColumns: "1fr 80px 44px", fontSize: "0.84rem", gap: "0 0" }}>
<div style={{ fontWeight: 600, padding: "2px 4px", borderBottom: "1px solid var(--border, #e2e8f0)" }}>Class</div>
<div style={{ fontWeight: 600, padding: "2px 4px", textAlign: "right", borderBottom: "1px solid var(--border, #e2e8f0)" }}>Retained</div>
<div style={{ fontWeight: 600, padding: "2px 4px", textAlign: "right", borderBottom: "1px solid var(--border, #e2e8f0)", color: "var(--muted)" }}>%</div>
{currentDomChildren.slice(0, 10).map(childId => {
const cn = data.nodes[String(childId)];
if (!cn) return null;
return (
<React.Fragment key={childId}>
<div style={{ overflow: "hidden", padding: "1px 2px" }}>
<span style={{ display: "flex", alignItems: "center", gap: "0.25rem", minWidth: 0 }}>
<button className="btn-link" title="Explore outbound references"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 }}
onClick={() => navigate("explore", childId, cn.display_class)}>
<code style={{ fontSize: "0.8rem" }}>{cn.display_class}</code>
</button>
<button className="btn-link" title="Open in dominator tree"
style={{ flexShrink: 0, opacity: 0.6 }}
onClick={() => navigate("domtree", childId, cn.display_class)}>
⌞
</button>
<PivotBtn cls={cn.display_class} />
<OqlBtn cls={cn.display_class} />
<ListObjectsBtn cls={cn.display_class} />
</span>
</div>
<div style={{ textAlign: "right", whiteSpace: "nowrap", padding: "1px 4px" }} title={fmtExactBytes(cn.retained)}>{fmtB(cn.retained)}</div>
<div style={{ textAlign: "right", whiteSpace: "nowrap", padding: "1px 4px", color: "var(--muted)", fontSize: "0.8rem" }}>
{currentNode.retained > 0 ? (() => {
const p = cn.retained / currentNode.retained;
return p >= 0.005 ? `${(p * 100).toFixed(0)}%` : p > 0 ? `${(p * 100).toFixed(1)}%` : "—";
})() : "—"}
</div>
</React.Fragment>
);
})}
{currentDomChildren.length > 10 && (
<div style={{ gridColumn: "1/-1", fontSize: "0.8rem", padding: "2px 2px" }}>
<button className="btn-link" style={{ fontSize: "0.8rem" }}
onClick={() => { setTab("domtree"); window.location.hash = `domtree/${nodeId}`; }}>
+{currentDomChildren.length - 10} more — view all in Dominator Tree tab →
</button>
</div>
)}
</div>
</div>
)}
{currentNode.subtree_classes && currentNode.subtree_classes.length > 0 && (
<div style={{ marginTop: "0.75rem" }}>
<h4 style={{ margin: "0 0 0.25rem", fontSize: "0.85rem" }}>
Retained heap by class
<span style={{ fontWeight: 400, color: "var(--muted)", marginLeft: "0.35rem", fontSize: "0.78rem" }}>
(top {currentNode.subtree_classes.length} by shallow, full subtree)
</span>
</h4>
<div style={{ display: "grid", gridTemplateColumns: "1fr 60px 60px 40px", fontSize: "0.82rem", gap: "0 0" }}>
<div style={{ fontWeight: 600, padding: "2px 4px", borderBottom: "1px solid var(--border, #e2e8f0)" }}>Class</div>
<div style={{ fontWeight: 600, padding: "2px 4px", textAlign: "right", borderBottom: "1px solid var(--border, #e2e8f0)" }}>Instances</div>
<div style={{ fontWeight: 600, padding: "2px 4px", textAlign: "right", borderBottom: "1px solid var(--border, #e2e8f0)" }}>Shallow</div>
<div style={{ fontWeight: 600, padding: "2px 4px", textAlign: "right", borderBottom: "1px solid var(--border, #e2e8f0)", color: "var(--muted)" }} title="% of captured subtree shallow heap">%</div>
{(() => {
const subtreeShallowTotal = currentNode.subtree_classes!.reduce((s, r) => s + r.total_shallow, 0);
return currentNode.subtree_classes!.map((row, i) => {
const pct = subtreeShallowTotal > 0 ? row.total_shallow / subtreeShallowTotal : 0;
return (
<React.Fragment key={i}>
<div style={{ padding: "1px 2px", overflow: "hidden" }}>
<span className="copy-cell" style={{ display: "inline-flex", verticalAlign: "middle", maxWidth: "100%" }}>
<code style={{ fontSize: "0.78rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={row.class}>{row.class}</code>
<PivotBtn cls={row.class} />
<OqlBtn cls={row.class} />
<ListObjectsBtn cls={row.class} />
</span>
</div>
<div style={{ textAlign: "right", padding: "1px 4px", whiteSpace: "nowrap" }}>{fmtCount(row.instance_count)}</div>
<div style={{ textAlign: "right", padding: "1px 4px", whiteSpace: "nowrap" }} title={fmtExactBytes(row.total_shallow)}>{fmtB(row.total_shallow)}</div>
<div style={{ textAlign: "right", padding: "1px 4px", color: "var(--muted)", fontSize: "0.78rem" }}>
{pct >= 0.001 ? `${(pct * 100).toFixed(pct >= 0.01 ? 0 : 1)}%` : "< 0.1%"}
</div>
</React.Fragment>
);
});
})()}
</div>
</div>
)}
</div>
</div>
</div>
);
}
function GlossarySection() {
const entries: [string, React.ReactNode][] = [
["Shallow Size", <>an object's header plus its fields (and, for an array, its elements). Does <em>not</em> include referenced objects.</>],
["Retained Heap (Retained Size)", <>the total memory freed when this object becomes unreachable: its shallow size plus everything reachable <em>only</em> through it. This is the number that answers "how much memory does freeing this object release?" and it is the basis for all percentages. See <a href="https://en.wikipedia.org/wiki/Dominator_(graph_theory)" target="_blank" rel="noreferrer">dominator (graph theory)</a>.</>],
["Reachable Heap", <>all objects the <a href="https://en.wikipedia.org/wiki/Garbage_collection_(computer_science)" target="_blank" rel="noreferrer">garbage collector</a> can reach from a GC root. Anything unreachable is excluded from all totals.</>],
["GC Root", <>an object the JVM keeps alive unconditionally: live thread stacks (local variables), static fields of loaded classes, <a href="https://en.wikipedia.org/wiki/Java_Native_Interface" target="_blank" rel="noreferrer">JNI</a> references, and similar. Every retained-size chain ends at a GC root.</>],
["Dominator", <>object <em>A</em> dominates object <em>B</em> if every path from a GC root to <em>B</em> passes through <em>A</em> — in other words, if <em>A</em> becomes unreachable, so does <em>B</em>. An object's retained heap is exactly the set of objects it dominates. See <a href="https://en.wikipedia.org/wiki/Dominator_(graph_theory)" target="_blank" rel="noreferrer">dominator (graph theory)</a>.</>],
["Dominator Tree", <>a tree linking each object to its immediate dominator. Retained heap equals the shallow-size sum of each subtree.</>],
["Top-Level Dominator", <>an object directly held by a GC root — top of the dominator tree. Ranked in Top Consumers and Retention Concentration.</>],
["Dominator Depth", <>dominator-tree hop count from an object to its GC root. Low depth means objects are held close to a root; high depth means retention flows through long chains (nested collections, linked lists).</>],
["Accumulation Point", <>a single object (often a collection, cache, or map) that dominates many instances of the <em>same</em> class — where excess memory accumulates.</>],
["Class Loader", <>the JVM component that defined a class. The same class name loaded by two different <a href="https://en.wikipedia.org/wiki/Java_Classloader" target="_blank" rel="noreferrer">class loaders</a> produces two distinct heap classes — counts are per (class, loader) pair.</>],
["Referent", <>the object a reference field points <em>to</em>. A <a href="https://en.wikipedia.org/wiki/Weak_reference" target="_blank" rel="noreferrer"><code>WeakReference</code></a>, for example, has a referent it does not keep alive.</>],
["Only-Weakly Retained", <>an object that has no incoming strong reference — reachable only through <code>WeakReference</code>, <code>SoftReference</code>, or <code>PhantomReference</code> chains. Weak-only referents are collected at the next GC cycle; soft-only referents are collected under memory pressure; phantom-only referents have been finalized and their references enqueued for post-mortem cleanup via a ReferenceQueue.</>],
["Instance vs. Class", <>an <em>instance</em> is one object; a <em>class</em> row aggregates every instance of that type. "Largest" in the histogram is the shallow size of the single biggest instance of a class.</>],
["Collection Fill Ratio", <>fraction of a collection's backing-array capacity occupied by elements — <code>elements ÷ capacity</code>. Near 0 means mostly empty (wasted memory); near 1 means the collection is full.</>],
["Map Load Factor", <>for hash maps, the fraction of backing-array slots occupied — <code>occupied_slots ÷ capacity</code>. Low load factor = many empty buckets (wasted memory); high load factor (≥ 90%) increases hash-collision chains and lookup cost.</>],
["Compressed OOPs", <>a JVM optimization storing object references as 32-bit integers instead of 64-bit pointers, halving reference-field overhead on heaps ≤ ~32 GB. Shown in Heap Summary as "Compressed OOPs: yes".</>],
["Class#field Notation", <>used throughout this report to identify a specific field: <code>HolderClass#fieldName</code> (e.g. <code>java.util.HashMap#table</code>). Indicates the dominant incoming reference path, not a guaranteed allocation site — it is a hint, not a precise origin.</>],
];
return (
<section id="glossary">
<h2>Glossary</h2>
<p className="subtitle">Definitions for the heap analysis terms used throughout this report.</p>
<dl className="summary-grid">
{entries.map(([term, def]) => (
<React.Fragment key={term}>
<dt>{term}</dt>
<dd>{def}</dd>
</React.Fragment>
))}
</dl>
<h3 style={{ marginTop: "1.5rem" }}>Keyboard Shortcuts</h3>
<p className="subtitle">Global shortcuts work when no text input is focused.</p>
<table className="std-table" style={{ maxWidth: 480 }}>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><kbd>/</kbd></td><td>Focus the nearest filter input</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Blur focused input</td></tr>
<tr><td><kbd>g</kbd> <kbd>h</kbd></td><td>Jump to System Overview</td></tr>
<tr><td><kbd>g</kbd> <kbd>l</kbd></td><td>Jump to Leak Suspects</td></tr>
<tr><td><kbd>g</kbd> <kbd>t</kbd></td><td>Jump to Top Consumers</td></tr>
<tr><td><kbd>g</kbd> <kbd>d</kbd></td><td>Jump to Dominator Analysis</td></tr>
<tr><td><kbd>g</kbd> <kbd>r</kbd></td><td>Jump to Type Reference Graph</td></tr>
<tr><td><kbd>g</kbd> <kbd>o</kbd></td><td>Jump to Object Graph Explorer</td></tr>
<tr><td><kbd>Alt</kbd>+<kbd>←</kbd></td><td>Back in Object Explorer</td></tr>
<tr><td><kbd>Alt</kbd>+<kbd>→</kbd></td><td>Forward in Object Explorer</td></tr>
<tr><td><kbd>[</kbd> / <kbd>]</kbd></td><td>Prev/next peer instance in Object Explorer</td></tr>
</tbody>
</table>
</section>
);
}
// ── Cross-dump time-series diff view ─────────────────────────────────────────
// Renders a SeriesDiffResult: a legend (r1..rN → labels), headline totals, and
// one sortable N-column table per section. The HTML diff view embeds a tagged
// {"kind":"series-diff","diff":…} envelope in #report-data; index.tsx dispatches
// to this component when it sees that discriminator.
const MINUS = "−"; // typographic minus, matching the Markdown renderer.
// Signed byte delta, e.g. "+1.2 MB" / "−340 KB" / "0 B".
function fmtDeltaBytes(n: number, fmtB: (n: number) => string): string {
if (n === 0) return "0 B";
const sign = n > 0 ? "+" : MINUS;
return sign + fmtB(Math.abs(n));
}
// Signed count delta with thousands separators, e.g. "+1,024" / "−17" / "0".
function fmtDeltaCount(n: number): string {
if (n === 0) return "0";
const sign = n > 0 ? "+" : MINUS;
return sign + Math.abs(n).toLocaleString("en-US");
}
// A sortable, N-column class/suspect table. Columns: name | r1 … rN | Δ.
// Sorting is descending by the chosen numeric key: any per-report column
// (its retained value) or the Δ column. Copies before sorting so the model
// is never mutated.
function SeriesTable({
nameLabel,
labels,
rows,
showNew,
}: {
nameLabel: string;
labels: string[];
rows: (SeriesClassRow | SeriesSuspectRow)[];
showNew?: boolean;
}) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const n = labels.length;
type SRow = SeriesClassRow | SeriesSuspectRow;
const seriesCols: TableColumn<SRow>[] = [
{ id: "name", name: nameLabel, grow: 1, cell: (r) => <span className="copy-cell"><code title={r.pretty_class}>{r.pretty_class}</code><CopyBtn text={r.pretty_class} /><PivotBtn cls={r.pretty_class} /><OqlBtn cls={r.pretty_class} /><ListObjectsBtn cls={r.pretty_class} /></span>, selector: (r) => r.pretty_class, sortable: true },
...labels.map((lbl, i): TableColumn<SRow> => ({
id: `r${i}`,
name: useKB ? `Retained r${i + 1} (KB)` : `Retained r${i + 1}`,
right: true,
width: useKB ? "140px" : "110px",
cell: (r) => byteCell((row: SRow) => row.retained[i] ?? 0, fmtB, useKB)(r),
selector: (r) => r.retained[i] ?? 0,
sortable: true,
})),
{ id: "delta", name: "Δ(r1→rN)", right: true, width: "110px", cell: (r) => fmtDeltaBytes(r.delta_retained, fmtB), selector: (r) => r.delta_retained, sortable: true },
{
id: "deltaPct",
name: "Δ %",
right: true,
width: "80px",
cell: (r: SRow) => {
const base = r.retained[0] ?? 0;
if (base === 0) return <span style={{ color: "var(--muted)" }}>new</span>;
const pct = (r.delta_retained / base) * 100;
if (Math.abs(pct) < 0.5) return <span style={{ color: "var(--muted)" }}>≈0%</span>;
const sign = pct > 0 ? "+" : "";
return <span style={{ color: pct > 0 ? "var(--ok, #22c55e)" : "var(--muted)" }}>{sign}{fmtPct(Math.abs(pct))}</span>;
},
selector: (r: SRow) => {
const base = r.retained[0] ?? 0;
if (base === 0) return 0;
return (r.delta_retained / base) * 100;
},
sortable: true,
},
...(showNew ? [{
id: "new",
name: "New?",
width: "60px",
cell: (r: SRow) => ("is_new" in r && r.is_new ? "Yes" : ""),
selector: (r: SRow) => ("is_new" in r && r.is_new ? 1 : 0),
sortable: true,
} as TableColumn<SRow>] : []),
];
return <StdTable columns={seriesCols} data={rows} searchKeys={["pretty_class"]} fmtBtn={kbBtn} defaultSortFieldId="delta" />;
}
// A sortable table for the TPFG diff: growing directed type-level reference edges.
function TpfgDiffTable({ rows, fmtB }: { rows: TypeEdgeDiff[]; fmtB: (n: number) => string }) {
const ok = "var(--ok, #22c55e)", mu = "var(--muted)";
const dc = (n: number) => <span style={{ color: n > 0 ? ok : n < 0 ? mu : undefined }}>{n > 0 ? "+" : ""}{n.toLocaleString()}</span>;
const dw = (n: number) => <span style={{ color: n > 0 ? ok : n < 0 ? mu : undefined }}>{n > 0 ? "+" : n < 0 ? "−" : ""}{fmtB(Math.abs(n))}{n < 0 ? " ▼" : n > 0 ? " ▲" : ""}</span>;
const cols: TableColumn<TypeEdgeDiff>[] = [
{ id: "src", name: "Source Class", selector: r => r.src_class, sortable: true, cell: r => <span className="copy-cell"><code title={r.src_class}>{r.src_class}</code><CopyBtn text={r.src_class} /><PivotBtn cls={r.src_class} /><OqlBtn cls={r.src_class} /><ListObjectsBtn cls={r.src_class} /></span>, grow: 2, maxWidth: "320px" },
{ id: "dst", name: "Target Class", selector: r => r.dst_class, sortable: true, cell: r => <span className="copy-cell"><code title={r.dst_class}>{r.dst_class}</code><CopyBtn text={r.dst_class} /><PivotBtn cls={r.dst_class} /><OqlBtn cls={r.dst_class} /><ListObjectsBtn cls={r.dst_class} /></span>, grow: 2, maxWidth: "320px" },
{ id: "cf", name: "Edges (first)", selector: r => r.count_first, sortable: true, right: true, cell: r => r.count_first.toLocaleString() },
{ id: "cl", name: "Edges (last)", selector: r => r.count_last, sortable: true, right: true, cell: r => r.count_last.toLocaleString() },
{ id: "dc", name: "Δ Edges", selector: r => r.delta_count, sortable: true, right: true, cell: r => dc(r.delta_count) },
{ id: "dw", name: "Δ Retained Weight", selector: r => r.delta_weight, sortable: true, right: true, cell: r => dw(r.delta_weight) },
];
return <StdTable columns={cols} data={rows} defaultSortFieldId="dw" defaultSortAsc={false} searchKeys={["src_class", "dst_class"]} />;
}
// One diff section: a heading, and either the sortable table or an empty note.
function DiffSection({
title,
nameLabel,
labels,
rows,
emptyNote,
showNew,
}: {
title: string;
nameLabel: string;
labels: string[];
rows: (SeriesClassRow | SeriesSuspectRow)[];
emptyNote: string;
showNew?: boolean;
}) {
return (
<section className="diff-section">
<h2>{title}</h2>
{rows.length === 0 ? (
<p className="subtitle">{emptyNote}</p>
) : (
<SeriesTable nameLabel={nameLabel} labels={labels} rows={rows} showNew={showNew} />
)}
</section>
);
}
// The verdict line: mirrors the Markdown verdict (the sole percentage).
function diffVerdict(diff: SeriesDiffResult, fmtB: (n: number) => string): string {
const firstShallow = diff.total_shallow[0] ?? 0;
const newSuspects = diff.grown_suspects.filter((s) => s.is_new).length;
let line: string;
if (firstShallow === 0) {
// Undefined percentage against an empty baseline (§37.3).
if (diff.delta_total_shallow > 0) {
const lead = diff.growth_leaders[0];
const driver = lead
? `; largest driver ${lead.pretty_class} (${fmtDeltaBytes(lead.delta_retained, fmtB)} retained)`
: "";
line = `Heap grew by ${fmtDeltaBytes(diff.delta_total_shallow, fmtB)} shallow (baseline was empty)${driver}.`;
} else {
line = "Heap size is unchanged (baseline was empty).";
}
} else {
const pct = (diff.delta_total_shallow / firstShallow) * 100;
if (diff.delta_total_shallow > 0) {
const lead = diff.growth_leaders[0];
const driver = lead
? `; largest driver ${lead.pretty_class} (${fmtDeltaBytes(lead.delta_retained, fmtB)} retained)`
: "";
line = `Heap grew ${fmtPct(pct)} (${fmtDeltaBytes(diff.delta_total_shallow, fmtB)} shallow)${driver}.`;
} else if (diff.delta_total_shallow < 0) {
line = `Heap shrank ${fmtPct(Math.abs(pct))} (${fmtDeltaBytes(diff.delta_total_shallow, fmtB)} shallow); no net growth.`;
} else {
line = "Heap size is unchanged.";
}
}
// Gross churn when a net-flat/shrinking series still churned a lot (§37.2).
if (
diff.gross_growth_retained > 0 &&
diff.gross_growth_retained > Math.max(diff.net_delta_retained, 0) * 2
) {
line += ` Gross retained churn: +${fmtB(diff.gross_growth_retained)} grown / ${MINUS}${fmtB(diff.gross_shrink_retained)} reclaimed across steps.`;
}
if (newSuspects > 0) {
line += ` ${newSuspects} new suspect${newSuspects === 1 ? "" : "s"}.`;
}
return line;
}
// A dedicated table for Transient Spikes (§37.1): name | r1…rN | Peak | Peak−r1.
function SpikeTable({ labels, rows }: { labels: string[]; rows: SeriesClassRow[] }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const spikeNameMaxW = `${Math.max(160, 1040 - 322 - labels.length * 140)}px`;
const spikeCols: TableColumn<SeriesClassRow>[] = [
{ id: "name", name: "Class", grow: 1, maxWidth: spikeNameMaxW, cell: (r) => <span className="copy-cell"><code title={r.pretty_class}>{r.pretty_class}</code><CopyBtn text={r.pretty_class} /><PivotBtn cls={r.pretty_class} /><OqlBtn cls={r.pretty_class} /><ListObjectsBtn cls={r.pretty_class} /></span>, selector: (r) => r.pretty_class, sortable: true },
...labels.map((lbl, i): TableColumn<SeriesClassRow> => ({
id: `r${i}`,
name: useKB ? `Retained r${i + 1} (KB)` : `Retained r${i + 1}`,
right: true,
width: useKB ? "140px" : "110px",
cell: (r) => byteCell((row: SeriesClassRow) => row.retained[i] ?? 0, fmtB, useKB)(r),
selector: (r) => r.retained[i] ?? 0,
sortable: true,
})),
{ id: "peak", name: useKB ? "Peak (KB)" : "Peak", right: true, width: useKB ? "132px" : "100px", cell: byteCell(r => r.peak_retained, fmtB, useKB), selector: (r) => r.peak_retained, sortable: true },
{ id: "peakOverBaseline", name: "Peak−r1", right: true, width: "110px", cell: (r) => fmtDeltaBytes(r.peak_over_baseline, fmtB), selector: (r) => r.peak_over_baseline, sortable: true },
{
id: "deltaPct",
name: "Δ %",
right: true,
width: "80px",
cell: (r: SeriesClassRow) => {
const base = r.retained[0] ?? 0;
if (base === 0) return <span style={{ color: "var(--muted)" }}>new</span>;
const pct = (r.delta_retained / base) * 100;
if (Math.abs(pct) < 0.5) return <span style={{ color: "var(--muted)" }}>≈0%</span>;
const sign = pct > 0 ? "+" : "";
return <span style={{ color: pct > 0 ? "var(--ok, #22c55e)" : "var(--muted)" }}>{sign}{fmtPct(Math.abs(pct))}</span>;
},
sortable: true,
},
];
return <StdTable columns={spikeCols} data={rows} searchKeys={["pretty_class"]} fmtBtn={kbBtn} defaultSortFieldId="peak" />;
}
export function DiffApp({ diff }: { diff: SeriesDiffResult }) {
const [fmtB, kbBtn, useKB] = useFmtBytes();
const { labels } = diff;
return (
<div className="app">
<h1>Heap Dump Comparison ({labels.length} reports)</h1>
<p className="subtitle">
Retained-heap changes across a dump series — first dump is baseline.
</p>
<div className="theme-toggle-wrap">
<button className="theme-toggle" title="Save this self-contained report as an HTML file" onClick={() => saveHtml("heap-comparison.html")}>⬇ Save HTML</button>
<ThemeToggle />
</div>
<section className="diff-section">
<h2>Reports</h2>
{kbBtn && <div className="tools">{kbBtn}</div>}
<ol className="diff-legend">
{labels.map((lbl, i) => (
<li key={i}>
<code>r{i + 1}</code> = {lbl}
</li>
))}
</ol>
</section>
<section className="diff-section">
<h2>Headline Totals</h2>
<p><strong>Verdict:</strong> {diffVerdict(diff, fmtB)}</p>
<ul>
<li><strong>Δ Objects (r1→rN):</strong> {fmtDeltaCount(diff.delta_total_objects)}</li>
<li><strong>Δ Shallow Heap (r1→rN):</strong> {fmtDeltaBytes(diff.delta_total_shallow, fmtB)}</li>
<li><strong>Net Δ Retained (all classes, r1→rN):</strong> {fmtDeltaBytes(diff.net_delta_retained, fmtB)}</li>
<li><strong>Gross Retained Churn (all classes, per-step):</strong> +{fmtB(diff.gross_growth_retained)} grown / {MINUS}{fmtB(diff.gross_shrink_retained)} reclaimed</li>
</ul>
</section>
{diff.growth_leaders.length > 0 && (
<section className="diff-section">
<RetainedGrowthChart rows={diff.growth_leaders} />
</section>
)}
<DiffSection
title="Growth Leaders (by Δ Retained)"
nameLabel="Class"
labels={labels}
rows={diff.growth_leaders}
emptyNote="No classes grew."
/>
{diff.spike_leaders.length > 0 ? (
<section className="diff-section">
<h2>Transient Spikes (Peak Above Baseline)</h2>
<p className="subtitle">
Classes whose retained heap peaked at an intermediate dump then recovered by the final dump — invisible to a first-to-last comparison. Ranked by peak-minus-baseline retained.
</p>
<SpikeTable labels={labels} rows={diff.spike_leaders} />
</section>
) : null}
<DiffSection
title="New Classes"
nameLabel="Class"
labels={labels}
rows={diff.new_classes}
emptyNote="No new classes."
/>
<DiffSection
title="Removed Classes"
nameLabel="Class"
labels={labels}
rows={diff.removed_classes}
emptyNote="No removed classes."
/>
<DiffSection
title="New / Grown Leak Suspects"
nameLabel="Suspect"
labels={labels}
rows={diff.grown_suspects}
emptyNote="No new or growing suspects."
showNew
/>
<DiffSection
title="Shrunk Leak Suspects"
nameLabel="Suspect"
labels={labels}
rows={diff.shrunk_suspects}
emptyNote="No shrinking suspects."
/>
<section className="diff-section">
<h2>Disappeared Leak Suspects (Resolved)</h2>
<p className="subtitle">
Suspects present in an earlier dump but absent now — resolved or transient.
</p>
{diff.gone_suspects.length === 0 ? (
<p className="subtitle">No resolved suspects.</p>
) : (
<SeriesTable nameLabel="Suspect" labels={labels} rows={diff.gone_suspects} />
)}
</section>
{diff.tpfg_diff && diff.tpfg_diff.length > 0 && (
<section className="diff-section">
<h2>Type Reference Graph Diff</h2>
<p className="subtitle">
Directed edges between class types that grew between the first and last dump.
A large Δ means more references from that source class to the target.
Sorted by absolute change in retained heap.
</p>
<TpfgDiffTable rows={diff.tpfg_diff} fmtB={fmtB} />
</section>
)}
<BackToTop />
</div>
);
}
// ── Heap Inspector page components ───────────────────────────────────────────
function InspectorClassPage({ cls, histogram, report, onNavigate }: {
cls: string;
histogram: any[];
report: any;
onNavigate: (p: InspectPage) => void;
}) {
const hist = histogram.find((h: any) => h.pretty_class === cls);
const hasDomData = React.useContext(HasDomDataCtx);
const edges: any[] = report.type_ref_graph ?? [];
const outEdges = edges.filter((e: any) => e.src_class === cls).sort((a: any, b: any) => b.retained_weight - a.retained_weight);
const inEdges = edges.filter((e: any) => e.dst_class === cls).sort((a: any, b: any) => b.retained_weight - a.retained_weight);
const [showAllOutInsp, setShowAllOutInsp] = React.useState(false);
const [showAllInInsp, setShowAllInInsp] = React.useState(false);
React.useEffect(() => { setShowAllOutInsp(false); setShowAllInInsp(false); }, [cls]);
const wasm = (window as any).__wasmExploration;
const wasmSession = (window as any).__wasmSession;
// WASM loaded but full analysis not yet run (no retained sizes)
const needsFullAnalysis = !!wasmSession && (!wasm?.gc_root_path || wasmSession?.has_retained?.() === false);
const ogCtx = React.useContext(ObjGraphCtx);
// Most common idom class across all instances of this class (dominator relationship, not inheritance)
const parentDomClass = React.useMemo(() => {
if (!ogCtx) return null;
const tally = new Map<string, number>();
for (const node of Object.values(ogCtx)) {
if (node.display_class !== cls || node.idom == null) continue;
const idomNode = ogCtx[String(node.idom)];
if (!idomNode || idomNode.display_class === cls) continue;
tally.set(idomNode.display_class, (tally.get(idomNode.display_class) ?? 0) + 1);
}
if (tally.size === 0) return null;
return [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0];
}, [ogCtx, cls]);
// Biggest instance index from WASM instance list (loaded lazily on demand)
const [biggestIdx, setBiggestIdx] = React.useState<number | null>(null);
React.useEffect(() => {
if (!wasm?.find_instances) return;
try {
const r = JSON.parse(wasm.find_instances(cls, 1));
if (r.ok && r.matches?.length) setBiggestIdx(r.matches[0].dense_idx);
} catch {}
}, [cls]);
return (
<div className="inspector-page">
<h3 className="inspector-page-title"><code>{cls}</code></h3>
{needsFullAnalysis && (
<div className="inspector-nudge">
For GC root paths and per-instance retained sizes, run <strong>Full Analysis</strong> in the{" "}
<button className="trg-link-btn" onClick={() => {
document.getElementById("object-graph")?.scrollIntoView({ behavior: "smooth" });
}}>Object Explorer</button>.
</div>
)}
{hist && (
<table className="trg-stat-table">
<tbody>
<tr><th>Instances</th><td>{fmtCount(hist.instances)}</td></tr>
<tr><th>Shallow Heap</th><td><span title={fmtExactBytes(hist.shallow)}>{formatBytes(hist.shallow)}</span></td></tr>
<tr><th>Retained Heap</th><td><span title={fmtExactBytes(hist.retained)}>{formatBytes(hist.retained)}</span></td></tr>
{parentDomClass && (
<tr>
<th title="Most common immediate dominator class across instances of this class">Held by (most common)</th>
<td>
<button className="btn-link" style={{ fontFamily: "var(--mono, monospace)", fontSize: "0.85rem" }}
title={parentDomClass}
onClick={() => onNavigate({ kind: "class", cls: parentDomClass })}>
{shortClass(parentDomClass)}
</button>
</td>
</tr>
)}
</tbody>
</table>
)}
<div className="trg-edge-cols">
<div>
<h4>Outbound References ({fmtCount(outEdges.length)})</h4>
<ul className="trg-edge-list">
{(showAllOutInsp ? outEdges : outEdges.slice(0, 8)).map((e: any) => (
<li key={e.dst_class}>
<button className="trg-link-btn" title={e.dst_class} onClick={() => onNavigate({ kind: "class", cls: e.dst_class })}>
{e.dst_class.split(".").pop()}
</button>
{e.top_field_names && e.top_field_names.length > 0 && (
<span className="inspector-field-tags">
{e.top_field_names.slice(0, 3).map((f: string) => (
<span key={f} className="inspector-field-tag">.{f}</span>
))}
</span>
)}
<span className="trg-edge-stat">{fmtCount(e.edge_count)} references · <span title={fmtExactBytes(e.retained_weight)}>{formatBytes(e.retained_weight)}</span></span>
</li>
))}
</ul>
{outEdges.length > 8 && (
<button className="show-more-btn" style={{ fontSize: "0.78rem", marginTop: "0.25rem" }}
onClick={() => setShowAllOutInsp(v => !v)}>
{showAllOutInsp ? "Show fewer" : `Show ${fmtCount(outEdges.length - 8)} more`}
</button>
)}
</div>
<div>
<h4>Inbound References ({fmtCount(inEdges.length)})</h4>
<ul className="trg-edge-list">
{(showAllInInsp ? inEdges : inEdges.slice(0, 8)).map((e: any) => (
<li key={e.src_class}>
<button className="trg-link-btn" title={e.src_class} onClick={() => onNavigate({ kind: "class", cls: e.src_class })}>
{e.src_class.split(".").pop()}
</button>
{e.top_field_names && e.top_field_names.length > 0 && (
<span className="inspector-field-tags">
{e.top_field_names.slice(0, 3).map((f: string) => (
<span key={f} className="inspector-field-tag">.{f}</span>
))}
</span>
)}
<span className="trg-edge-stat">{fmtCount(e.edge_count)} references · <span title={fmtExactBytes(e.retained_weight)}>{formatBytes(e.retained_weight)}</span></span>
</li>
))}
</ul>
{inEdges.length > 8 && (
<button className="show-more-btn" style={{ fontSize: "0.78rem", marginTop: "0.25rem" }}
onClick={() => setShowAllInInsp(v => !v)}>
{showAllInInsp ? "Show fewer" : `Show ${fmtCount(inEdges.length - 8)} more`}
</button>
)}
</div>
</div>
<div className="trg-page-actions">
<button className="show-more-btn" title="List all instances of this class" onClick={() => onNavigate({ kind: "instances", cls, page: 0 })}>
Instances →
</button>
{biggestIdx != null && (
<button className="show-more-btn" title="Open the largest instance of this class by retained heap" onClick={() => onNavigate({ kind: "instance", idx: biggestIdx, cls })}>
Biggest Instance →
</button>
)}
<button className="show-more-btn"
onClick={() => {
window.dispatchEvent(new CustomEvent("trg-focus-class", { detail: cls }));
history.replaceState(null, "", "#type-ref-graph");
window.dispatchEvent(new CustomEvent("nav-toast", { detail: { label: "Updated in Type Graph", sectionId: "type-ref-graph" } }));
}}>
Type Graph →
</button>
<button className="show-more-btn"
title="Scroll to System Overview and highlight this class in the heap histogram"
onClick={() => {
document.getElementById("system-overview")?.scrollIntoView({ behavior: "smooth" });
window.dispatchEvent(new CustomEvent("highlight-class", { detail: { cls } }));
}}>
In Histogram →
</button>
{hasDomData && (
<button className="show-more-btn" onClick={() => pivotClass(cls)}>
In Dominator →
</button>
)}
<OqlBtn cls={cls} />
</div>
{report?.field_stats && (() => {
const cfs = report.field_stats!.classes.find((c: any) => c.class_name === cls);
if (!cfs || cfs.ref_fields.length === 0) return null;
return (
<details style={{ marginTop: "0.5rem" }}>
<summary style={{ fontSize: "0.82rem", cursor: "pointer" }}>
Field Statistics ({cfs.ref_fields.length} reference field{cfs.ref_fields.length !== 1 ? "s" : ""})
</summary>
<table style={{ fontSize: "0.78rem", width: "100%", borderCollapse: "collapse", marginTop: "0.3rem" }}>
<thead>
<tr>
<th style={{ textAlign: "left" }}>Field</th>
<th style={{ textAlign: "right" }}>Non-Null Refs</th>
<th style={{ textAlign: "right" }}>Total Retained</th>
</tr>
</thead>
<tbody>
{cfs.ref_fields.map((f: any, i: number) => (
<tr key={i} style={{ borderTop: "1px solid var(--border)" }}>
<td><code>{f.field_name || "(all references)"}</code></td>
<td style={{ textAlign: "right" }}>{f.non_null_count.toLocaleString()}</td>
<td style={{ textAlign: "right" }}><span title={fmtExactBytes(f.total_retained)}>{formatBytes(f.total_retained)}</span></td>
</tr>
))}
</tbody>
</table>
</details>
);
})()}
{hist?.root_path && hist.root_path.length > 0 && (
<div className="inspector-gcpath">
<p style={{ fontSize: "0.78rem", color: "var(--muted)", margin: "0 0 0.25rem" }}>
GC Root Path for Highest-Retained Instance:
</p>
<RootPathChain steps={hist.root_path} />
</div>
)}
</div>
);
}
const TRG_PAGE_SIZE = 50;
interface TRGInstRow { idx: number; cls: string; shallow: number; retained: number; }
interface TRGRefEntry { idx: number; fieldName: string; cls: string; shallow: number; retained: number; }
function InspectorInstanceListPage({ cls, page, onNavigate }: {
cls: string;
page: number;
onNavigate: (p: InspectPage) => void;
}) {
const ogCtx = React.useContext(ObjGraphCtx);
const wasm = (window as any).__wasmExploration;
const [wasmInstances, setWasmInstances] = React.useState<TRGInstRow[] | null>(null);
const [wasmTotal, setWasmTotal] = React.useState(0);
const [wasmLoading, setWasmLoading] = React.useState(false);
React.useEffect(() => {
if (!wasm?.find_instances) return;
setWasmLoading(true);
try {
const limit = (page + 2) * TRG_PAGE_SIZE;
const r = JSON.parse(wasm.find_instances(cls, limit));
if (r.ok) {
setWasmInstances(r.matches.map((m: any) => ({
idx: m.dense_idx, cls: m.display_class, shallow: m.shallow, retained: m.retained,
})));
setWasmTotal(r.total);
}
} catch (e) { /* ignore */ } finally {
setWasmLoading(false);
}
}, [cls, page]);
const staticInstances = React.useMemo<TRGInstRow[]>(() => {
if (!ogCtx) return [];
return Object.entries(ogCtx)
.map(([idxStr, n]) => ({ idx: Number(idxStr), cls: n.display_class, shallow: n.shallow, retained: n.retained }))
.filter(n => n.cls === cls)
.sort((a, b) => b.retained - a.retained);
}, [ogCtx, cls]);
const useWasm = !!wasm?.find_instances;
const instances = useWasm ? (wasmInstances ?? []) : staticInstances;
const totalCount = useWasm ? wasmTotal : staticInstances.length;
const totalPages = Math.max(1, Math.ceil(totalCount / TRG_PAGE_SIZE));
const slice = instances.slice(page * TRG_PAGE_SIZE, (page + 1) * TRG_PAGE_SIZE);
if (!useWasm && !ogCtx) {
return (
<div className="inspector-page">
<h3 className="inspector-page-title">Instances of <code>{cls}</code></h3>
<p className="trg-no-data">
Load the .hprof in the browser, or re-run with <code>--obj-graph</code>, to browse instances.
</p>
</div>
);
}
return (
<div className="inspector-page">
<h3 className="inspector-page-title">
Instances of <code>{cls.split(".").pop()}</code>
<span className="trg-page-count"> — {fmtCount(totalCount)} total{useWasm ? "" : " (captured)"}</span>
</h3>
{wasmLoading && <p className="trg-no-data">Loading…</p>}
{slice.length > 0 && (
<div style={{ marginBottom: "0.4rem" }}>
<button className="show-more-btn"
onClick={() => onNavigate({ kind: "instance", idx: slice[0].idx, cls: slice[0].cls })}>
Biggest Instance (<span title={fmtExactBytes(slice[0].retained)}>{formatBytes(slice[0].retained)}</span> retained) →
</button>
</div>
)}
<table className="trg-inst-table">
<thead>
<tr><th>#</th><th>Index</th><th>Shallow</th><th>Retained</th></tr>
</thead>
<tbody>
{slice.map((n, i) => (
<tr key={n.idx} className="trg-inst-row"
title="Click to open this instance in the Inspector"
onClick={() => onNavigate({ kind: "instance", idx: n.idx, cls: n.cls })}>
<td>{page * TRG_PAGE_SIZE + i + 1}</td>
<td><code>{n.idx}</code></td>
<td><span title={fmtExactBytes(n.shallow)}>{formatBytes(n.shallow)}</span></td>
<td><span title={fmtExactBytes(n.retained)}>{formatBytes(n.retained)}</span></td>
</tr>
))}
</tbody>
</table>
{totalPages > 1 && (
<div className="trg-pagination">
<button className="show-more-btn" disabled={page === 0}
onClick={() => onNavigate({ kind: "instances", cls, page: page - 1 })}>← Prev</button>
<span>Page {page + 1} / {totalPages}</span>
<button className="show-more-btn" disabled={page >= totalPages - 1}
onClick={() => onNavigate({ kind: "instances", cls, page: page + 1 })}>Next →</button>
</div>
)}
</div>
);
}
function buildIdomChain(
nodes: Record<string, ObjGraphFlatNode>,
startId: number,
maxHops = 5
): Array<{ id: number; display_class: string; retained: number }> {
const chain: Array<{ id: number; display_class: string; retained: number }> = [];
const seen = new Set<number>();
let cur: number | undefined = nodes[String(startId)]?.idom;
while (cur != null && !seen.has(cur) && chain.length < maxHops) {
const n = nodes[String(cur)];
if (!n) break;
seen.add(cur);
chain.unshift({ id: cur, display_class: n.display_class, retained: n.retained });
cur = n.idom;
}
return chain;
}
function InspectorInstancePage({ idx, cls, onNavigate }: {
idx: number;
cls: string;
onNavigate: (p: InspectPage) => void;
}) {
const ogCtx = React.useContext(ObjGraphCtx);
const wasm = (window as any).__wasmExploration;
const useWasm = !!wasm?.get_node_info;
const hasDomData = React.useContext(HasDomDataCtx);
const staticNode: ObjGraphFlatNode | null = ogCtx ? (ogCtx[String(idx)] ?? null) : null;
const [wasmInfo, setWasmInfo] = React.useState<{ shallow: number; retained: number } | null>(null);
const [outRefs, setOutRefs] = React.useState<TRGRefEntry[]>([]);
const [outTotal, setOutTotal] = React.useState(0);
const [inRefs, setInRefs] = React.useState<TRGRefEntry[]>([]);
const [inTotal, setInTotal] = React.useState(0);
const [loading, setLoading] = React.useState(false);
const [gcPath, setGcPath] = React.useState<any[] | null>(null);
const [gcPathOpen, setGcPathOpen] = React.useState(true);
// Top ref fields by retained heap, for inline summary
const [topFields, setTopFields] = React.useState<{ name: string; cls: string; idx: number; retained: number }[]>([]);
React.useEffect(() => {
if (!useWasm) return;
setLoading(true);
setTopFields([]);
try {
const info = JSON.parse(wasm.get_node_info(idx));
if (info.ok) setWasmInfo({ shallow: info.shallow, retained: info.retained });
const out = JSON.parse(wasm.outbound_refs(idx, 50));
if (out.ok) {
setOutRefs(out.refs.map((r: any) => ({
idx: r.dst_idx, fieldName: r.field_name ?? "", cls: r.display_class, shallow: r.shallow, retained: r.retained,
})));
setOutTotal(out.total);
}
const inp = JSON.parse(wasm.inbound_refs(idx, 50));
if (inp.ok) {
setInRefs(inp.refs.map((r: any) => ({
idx: r.src_idx, fieldName: r.field_name ?? "", cls: r.display_class, shallow: r.shallow, retained: r.retained,
})));
setInTotal(inp.total);
}
if (wasm?.gc_root_path) {
try {
const p = JSON.parse(wasm.gc_root_path(idx));
setGcPath(Array.isArray(p) ? p : (p?.path ?? null));
} catch { /* ignore */ }
}
// Load top ref fields by retained size
if (wasm?.get_field_values) {
try {
const fv = JSON.parse(wasm.get_field_values(idx));
if (fv.ok) {
const refFields = fv.fields.filter((f: any) => f.kind === "ref" && f.dense_idx != null);
const withRetained: { name: string; cls: string; idx: number; retained: number }[] = [];
for (const f of refFields) {
try {
const ni = JSON.parse(wasm.get_node_info(f.dense_idx));
if (ni.ok) withRetained.push({ name: f.name || "", cls: f.display_class ?? "?", idx: f.dense_idx, retained: ni.retained });
} catch {}
}
withRetained.sort((a, b) => b.retained - a.retained);
setTopFields(withRetained.slice(0, 5));
}
} catch {}
}
} catch (e) { /* ignore */ } finally {
setLoading(false);
}
}, [idx]);
const node = useWasm ? wasmInfo : staticNode ? { shallow: staticNode.shallow, retained: staticNode.retained } : null;
const idomIdx: number | undefined = staticNode?.idom;
const idomNode: ObjGraphFlatNode | null = idomIdx != null && ogCtx
? (ogCtx[String(idomIdx)] ?? null)
: null;
const idomChain = React.useMemo(() => {
if (!ogCtx) return [];
return buildIdomChain(ogCtx, idx);
}, [ogCtx, idx]);
if (!node && !loading) {
return <p className="trg-no-data">Object #{idx} not available — load the .hprof in the browser or re-run with <code>--obj-graph</code>.</p>;
}
const retained = node?.retained ?? 0;
return (
<div className="inspector-page">
{idomChain.length > 0 && (
<div className="inspector-idom-chain">
<span className="inspector-idom-label">Retained via:</span>
{idomChain.map((item, i) => (
<React.Fragment key={item.id}>
<button className="trg-link-btn inspector-idom-item"
title={`${item.display_class} — retains ${fmtExactBytes(item.retained)}`}
onClick={() => onNavigate({ kind: "instance", idx: item.id, cls: item.display_class })}>
<span className="inspector-idom-cls">{item.display_class.split(".").pop()}</span>
<span className="inspector-idom-size" title={fmtExactBytes(item.retained)}>{formatBytes(item.retained)}</span>
</button>
<span className="inspector-idom-arrow">→</span>
</React.Fragment>
))}
<span className="inspector-idom-this">▶ this</span>
</div>
)}
<h3 className="inspector-page-title">
<code>{cls.split(".").pop()}</code>
<span className="trg-page-subtitle"> #{idx}</span>
</h3>
{loading && <p className="trg-no-data">Loading…</p>}
{node && (
<table className="trg-stat-table">
<tbody>
<tr><th>Shallow</th><td><span title={fmtExactBytes(node.shallow)}>{formatBytes(node.shallow)}</span></td></tr>
<tr><th>Retained</th><td><span title={fmtExactBytes(node.retained)}>{formatBytes(node.retained)}</span></td></tr>
{idomNode && (
<tr>
<th>Dominated By</th>
<td>
<button className="trg-link-btn"
onClick={() => onNavigate({ kind: "instance", idx: idomIdx!, cls: idomNode.display_class })}>
#{idomIdx} {idomNode.display_class?.split(".").pop()}
</button>
</td>
</tr>
)}
</tbody>
</table>
)}
{topFields.length > 0 && (
<div className="inspector-top-fields">
<h4>Top Fields by Retained <button className="show-more-btn" style={{ float: "right", fontSize: "0.74rem" }}
onClick={() => onNavigate({ kind: "fields", idx, cls })}>All Fields →</button></h4>
<ul className="inspector-field-bars">
{topFields.map((f, i) => (
<li key={i}>
<div className="inspector-field-bar-row">
<code className="trg-field-name">{f.name || <span style={{color:"var(--muted)"}}>(unnamed reference)</span>}</code>
<button className="trg-link-btn" onClick={() => onNavigate({ kind: "instance", idx: f.idx, cls: f.cls })}>
{f.cls.split(".").pop()}
</button>
<span className="trg-edge-stat" title={fmtExactBytes(f.retained)}>{formatBytes(f.retained)}</span>
</div>
{retained > 0 && (
<div className="inspector-field-bar-track">
<div className="inspector-field-bar-fill"
style={{ width: `${Math.round(f.retained / retained * 100)}%` }} />
</div>
)}
</li>
))}
</ul>
</div>
)}
{outRefs.length > 0 && (
<>
<h4>Outbound References ({outTotal}{outTotal > outRefs.length ? `, showing ${outRefs.length}` : ""})</h4>
<ul className="trg-edge-list">
{outRefs.map((e, i) => (
<li key={i}>
<code className="trg-field-name">{e.fieldName}</code>
<button className="trg-link-btn"
onClick={() => onNavigate({ kind: "instance", idx: e.idx, cls: e.cls })}>
#{e.idx} {e.cls.split(".").pop()}
</button>
<span className="trg-edge-stat" title={fmtExactBytes(e.retained)}>{formatBytes(e.retained)} retained</span>
</li>
))}
</ul>
</>
)}
{inRefs.length > 0 && (
<>
<h4>Inbound References ({inTotal}{inTotal > inRefs.length ? `, showing ${inRefs.length}` : ""})</h4>
<ul className="trg-edge-list">
{inRefs.map((e, i) => (
<li key={i}>
<button className="trg-link-btn"
onClick={() => onNavigate({ kind: "instance", idx: e.idx, cls: e.cls })}>
#{e.idx} {e.cls.split(".").pop()}
</button>
{e.fieldName && <code className="trg-field-name"> .{e.fieldName}</code>}
</li>
))}
</ul>
</>
)}
{(gcPath || wasm?.gc_root_path) && (
<div className="inspector-gcpath">
<button className="inspector-gcpath-toggle" onClick={() => setGcPathOpen(v => !v)}>
{gcPathOpen ? "▼" : "▶"} Path to GC Root
</button>
{gcPathOpen && !gcPath && loading && <p className="trg-no-data">Loading…</p>}
{gcPathOpen && !gcPath && !loading && <p className="trg-no-data" style={{ color: "var(--muted)" }}>No GC root path available for this object.</p>}
{gcPathOpen && gcPath && (
<ol className="inspector-dom-path">
{gcPath.map((step: any, i: number) => (
<li key={i}>
<button className="trg-link-btn"
onClick={() => onNavigate({ kind: "instance", idx: step.dense_idx, cls: step.display_class })}>
{step.display_class.split(".").pop()}
</button>
{step.field_name && <span className="trg-edge-stat"> .{step.field_name}</span>}
</li>
))}
</ol>
)}
</div>
)}
<div className="trg-page-actions">
<button className="show-more-btn" title="Back to the class overview" onClick={() => onNavigate({ kind: "class", cls })}>
← Class View
</button>
<button className="show-more-btn" title="List all instances of this class" onClick={() => onNavigate({ kind: "instances", cls, page: 0 })}>
All Instances →
</button>
<button className="show-more-btn" title="Show field values of this object" onClick={() => onNavigate({ kind: "fields", idx, cls })}>
Fields →
</button>
<button className="show-more-btn" title="Open in Object Graph Explorer to follow reference chains" onClick={() => {
(window as any).__explorerNavigate?.("explore", idx);
history.replaceState(null, "", `#explore/${idx}`);
window.dispatchEvent(new CustomEvent("nav-toast", { detail: { label: "Loaded in Object Graph Explorer", sectionId: "object-graph" } }));
}}>
Object Explorer →
</button>
<button className="show-more-btn" title="Open in the dominator tree view of Object Graph Explorer" onClick={() => {
(window as any).__explorerNavigate?.("domtree", idx);
history.replaceState(null, "", `#domtree/${idx}`);
window.dispatchEvent(new CustomEvent("nav-toast", { detail: { label: "Loaded in Object Graph Explorer", sectionId: "object-graph" } }));
}}>
Dominator Tree →
</button>
{hasDomData && (
<button className="show-more-btn" title="Open this class in the WhoHolds Dominator Sankey" onClick={() => pivotClass(cls)}>
In Dominator →
</button>
)}
</div>
</div>
);
}
function InspectorGCRootPage({ idx, cls, onNavigate }: {
idx: number; cls: string; onNavigate: (p: InspectPage) => void;
}) {
const wasm = (window as any).__wasmExploration;
const [path, setPath] = React.useState<any[] | null>(null);
React.useEffect(() => {
if (!wasm?.gc_root_path) return;
try { setPath(JSON.parse(wasm.gc_root_path(idx))); } catch {}
}, [idx]);
return (
<div className="inspector-page">
<h3 className="inspector-page-title">GC Root Path — <code>{cls.split(".").pop()}</code></h3>
{!wasm?.gc_root_path && <p className="trg-no-data">Load the .hprof in the browser for the GC root path.</p>}
{wasm?.gc_root_path && !path && <p className="trg-no-data">Loading…</p>}
{path && (
<ol className="inspector-dom-path">
{path.map((step: any, i: number) => (
<li key={i}>
<button className="trg-link-btn"
onClick={() => onNavigate({ kind: "instance", idx: step.dense_idx, cls: step.display_class })}>
{step.display_class.split(".").pop()}
</button>
{step.field_name && <span className="trg-edge-stat"> .{step.field_name}</span>}
</li>
))}
</ol>
)}
<div className="trg-page-actions">
<button className="show-more-btn" onClick={() => onNavigate({ kind: "instance", idx, cls })}>
← Instance View
</button>
</div>
</div>
);
}
function InspectorFieldsPage({ idx, cls, onNavigate }: {
idx: number; cls: string; onNavigate: (p: InspectPage) => void;
}) {
const wasm = (window as any).__wasmExploration;
const [fields, setFields] = React.useState<any[] | null>(null);
const [refSizes, setRefSizes] = React.useState<Map<number, number>>(new Map());
const [instanceRetained, setInstanceRetained] = React.useState<number>(0);
React.useEffect(() => {
if (!wasm?.get_field_values) return;
try {
const fv = JSON.parse(wasm.get_field_values(idx));
if (!fv.ok) return;
setFields(fv.fields);
const refs = fv.fields.filter((f: any) => f.kind === "ref" && f.dense_idx != null);
const sizes = new Map<number, number>();
for (const f of refs) {
try {
const info = JSON.parse(wasm.get_node_info(f.dense_idx));
if (info.ok) sizes.set(f.dense_idx, info.retained);
} catch {}
}
setRefSizes(new Map(sizes));
try {
const info = JSON.parse(wasm.get_node_info(idx));
if (info && info.ok) setInstanceRetained(info.retained ?? 0);
} catch {}
} catch {}
}, [idx]);
if (!wasm?.get_field_values) {
return (
<div className="inspector-page">
<h3 className="inspector-page-title">Fields — <code>{cls.split(".").pop()}</code></h3>
<p className="trg-no-data">Load the .hprof in the browser to browse field values.</p>
</div>
);
}
const sorted = (fields ?? []).slice().sort((a: any, b: any) => {
const ra = a.kind === "ref" ? (refSizes.get(a.dense_idx) ?? 0) : -1;
const rb = b.kind === "ref" ? (refSizes.get(b.dense_idx) ?? 0) : -1;
return rb - ra;
});
return (
<div className="inspector-page">
<h3 className="inspector-page-title">Fields — <code>{cls.split(".").pop()}</code> #{idx}</h3>
<p style={{ fontSize: "0.8rem", color: "var(--muted)", margin: 0 }}>Reference fields ranked by retained heap.</p>
{!fields && <p className="trg-no-data">Loading…</p>}
<ul className="trg-edge-list">
{sorted.map((f: any, i: number) => (
<li key={i}>
<code className="trg-field-name">
{f.name || <span style={{color:"var(--muted)"}}>(unnamed reference)</span>}
</code>
{f.kind === "ref" ? (
<>
<button className="trg-link-btn"
onClick={() => onNavigate({ kind: "instance", idx: f.dense_idx, cls: f.display_class ?? "?" })}>
{(f.display_class ?? "?").split(".").pop()}
</button>
<CopyBtn text={f.display_class ?? ""} />
{refSizes.has(f.dense_idx) && (
<span className="trg-edge-stat">
<span title={fmtExactBytes(refSizes.get(f.dense_idx)!)}>{formatBytes(refSizes.get(f.dense_idx)!)}</span> retained
{instanceRetained > 0 && (
<span style={{color:"var(--muted)",fontSize:"0.75em"}}>
{" "}({fmtPct(refSizes.get(f.dense_idx)! / instanceRetained * 100)})
</span>
)}
</span>
)}
<button className="trg-link-btn" style={{ fontSize: "0.75rem" }}
onClick={() => onNavigate({ kind: "fields", idx: f.dense_idx, cls: f.display_class ?? "?" })}
title="Drill into fields of this object">↳ Fields</button>
</>
) : (
<span className="trg-edge-stat">
<span style={{color:"var(--muted)",fontSize:"0.75em",marginRight:"0.25em"}}>{f.kind}</span>
{f.kind === "str" ? `"${f.value}"` : String(f.value ?? "null")}
{(f.kind === "int" || f.kind === "long" || f.kind === "float") && f.value != null && (
<button style={{
background:"none",border:"none",cursor:"pointer",padding:"0 0.2em",fontSize:"0.85em",
color:"var(--muted)"
}} title="Copy value"
onClick={() => navigator.clipboard?.writeText(String(f.value))}>⎘</button>
)}
</span>
)}
</li>
))}
</ul>
<div className="trg-page-actions">
{cls && (
<button className="show-more-btn"
onClick={() => onNavigate({ kind: "field-scan", cls, fieldName: "" })}>
Scan All Instances →
</button>
)}
<button className="show-more-btn" onClick={() => onNavigate({ kind: "instance", idx, cls })}>
← Instance View
</button>
</div>
</div>
);
}
function InspectorFieldScanPage({ cls, fieldName, onNavigate }: {
cls: string; fieldName: string; onNavigate: (p: InspectPage) => void;
}) {
const wasm = (window as any).__wasmExploration;
const [rows, setRows] = React.useState<any[] | null>(null);
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
if (!wasm) return;
setLoading(true);
try {
const found = JSON.parse(wasm.find_instances(cls, 50));
if (!found.ok) { setLoading(false); return; }
const scanRows: any[] = [];
for (const inst of found.matches ?? []) {
try {
const fv = JSON.parse(wasm.get_field_values(inst.dense_idx));
if (fv.ok) {
const field = fieldName
? (fv.fields ?? []).find((f: any) => f.name === fieldName)
: (fv.fields ?? [])[0];
scanRows.push({
dense_idx: inst.dense_idx,
display_class: inst.display_class ?? cls,
retained: inst.retained,
field_name: field?.name ?? "",
field_kind: field?.kind ?? "",
field_value: field?.value ?? null,
field_display_class: field?.display_class ?? null,
});
}
} catch {}
}
setRows(scanRows);
} catch {}
setLoading(false);
}, [cls, fieldName, wasm]);
return (
<div className="inspector-page">
<h3 className="inspector-page-title">
Instances of <code>{cls.split(".").pop()}</code>
{fieldName && <> · field <code>{fieldName}</code></>}
</h3>
<p style={{ fontSize: "0.8rem", color: "var(--muted)", margin: "0 0 0.5rem" }}>
Top 50 by retained heap.{!wasm && " Load the .hprof in the browser for this view."}
</p>
{loading && <p className="trg-no-data">Scanning…</p>}
{rows && rows.length === 0 && <p className="trg-no-data">No instances found for this class in the loaded heap.</p>}
{rows && rows.length > 0 && (
<table className="trg-inst-table">
<thead>
<tr>
<th>#</th>
<th>Idx</th>
{fieldName && <th>{fieldName}</th>}
<th>Retained</th>
</tr>
</thead>
<tbody>
{rows.map((row: any, i: number) => (
<tr key={i} className="trg-inst-row"
style={{ cursor: "pointer" }}
onClick={() => onNavigate({ kind: "instance", idx: row.dense_idx, cls: row.display_class })}>
<td>{i + 1}</td>
<td><code>{row.dense_idx}</code></td>
{fieldName && (
<td style={{ maxWidth: "8rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{row.field_kind === "ref"
? (row.field_display_class ?? "").split(".").pop()
: String(row.field_value ?? "null")}
</td>
)}
<td><span title={fmtExactBytes(row.retained)}>{formatBytes(row.retained)}</span></td>
</tr>
))}
</tbody>
</table>
)}
<div className="trg-page-actions">
<button className="show-more-btn" onClick={() => onNavigate({ kind: "class", cls })}>
← Class View
</button>
</div>
</div>
);
}
// ── HeapInspector panel ───────────────────────────────────────────────────────
function HeapInspector({ report, histogram }: { report: any; histogram: any[] }) {
const [open, setOpen] = React.useState(false);
const [stack, setStack] = React.useState<InspectPage[]>([]);
const [fwd, setFwd] = React.useState<InspectPage[]>([]);
const [width, setWidth] = React.useState(360);
const dragRef = React.useRef<{ startX: number; startW: number } | null>(null);
const current = stack[stack.length - 1] ?? null;
const navigate = React.useCallback((page: InspectPage) => {
setStack(s => [...s, page]);
setFwd([]);
setOpen(true);
}, []);
React.useEffect(() => {
const h = (e: Event) => navigate((e as CustomEvent).detail as InspectPage);
window.addEventListener("inspect", h);
return () => window.removeEventListener("inspect", h);
}, [navigate]);
const goBack = React.useCallback(() => {
setStack(prev => {
if (prev.length <= 1) return prev;
const popped = prev[prev.length - 1];
setFwd(f => [popped, ...f]);
return prev.slice(0, -1);
});
}, []);
const goFwd = React.useCallback(() => {
setFwd(prev => {
if (prev.length === 0) return prev;
const page = prev[0];
setStack(s => [...s, page]);
return prev.slice(1);
});
}, []);
React.useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
const isMac = /Mac|iPhone|iPad/.test(navigator.platform);
if ((isMac ? e.metaKey : e.altKey) && e.key === "[") { e.preventDefault(); goBack(); }
else if ((isMac ? e.metaKey : e.altKey) && e.key === "]") { e.preventDefault(); goFwd(); }
else if (e.altKey && e.key === "ArrowLeft") { e.preventDefault(); goBack(); }
else if (e.altKey && e.key === "ArrowRight") { e.preventDefault(); goFwd(); }
else if (e.key === "Escape") { e.preventDefault(); setOpen(false); }
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, goBack, goFwd]);
const handleResizeMD = (e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = { startX: e.clientX, startW: width };
const mv = (ev: MouseEvent) => {
if (!dragRef.current) return;
setWidth(Math.max(260, Math.min(680, dragRef.current.startW + (dragRef.current.startX - ev.clientX))));
};
const up = () => {
dragRef.current = null;
window.removeEventListener("mousemove", mv);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", mv);
window.addEventListener("mouseup", up);
};
if (!open) return (
<button className="inspector-tab-btn" onClick={() => setOpen(true)} title="Open Heap Inspector">🔍</button>
);
return (
<div className="heap-inspector" style={{ width }}>
<div className="inspector-resize-handle" onMouseDown={handleResizeMD} />
<div className="inspector-header">
<button className="inspector-nav-btn" title="Back (Alt+←)" disabled={stack.length <= 1} onClick={goBack}>←</button>
<button className="inspector-nav-btn" title="Forward (Alt+→)" disabled={fwd.length === 0} onClick={goFwd}>→</button>
<span className="inspector-title" title={
current?.kind === "class" ? current.cls :
current?.kind === "instance" ? `${current.cls} #${current.idx}` :
current?.kind === "instances" ? `${current.cls} — instances` :
current?.kind === "fields" ? `${current.cls} #${current.idx} — fields` :
current?.kind === "field-scan" ? `${current.cls} — field scan` :
"Heap Inspector"
}>
{current?.kind === "class" ? current.cls.split(".").pop() :
current?.kind === "instance" ? `${current.cls.split(".").pop()} #${current.idx}` :
current?.kind === "instances" ? `${current.cls.split(".").pop()} (instances)` :
current?.kind === "fields" ? `Fields — #${current.idx}` :
current?.kind === "field-scan" ? `Scan — ${current.cls.split(".").pop()}` :
"Heap Inspector"}
</span>
<button className="inspector-close-btn" title="Close Inspector" onClick={() => setOpen(false)}>✕</button>
</div>
<div className="inspector-body">
{!current ? (
<div className="inspector-empty">
<p>Click <strong>⬡</strong> (Inspector) or <strong>⬡≡</strong> (list instances) next to any class name to open it here. Or use the Biggest Instance buttons in the TRG sidebar.</p>
</div>
) : current.kind === "class" ? (
<InspectorClassPage cls={current.cls} histogram={histogram} report={report} onNavigate={navigate} />
) : current.kind === "instances" ? (
<InspectorInstanceListPage cls={current.cls} page={current.page} onNavigate={navigate} />
) : current.kind === "instance" ? (
<InspectorInstancePage idx={current.idx} cls={current.cls} onNavigate={navigate} />
) : current.kind === "gcroot" ? (
<InspectorGCRootPage idx={current.idx} cls={current.cls} onNavigate={navigate} />
) : current.kind === "fields" ? (
<InspectorFieldsPage idx={current.idx} cls={current.cls} onNavigate={navigate} />
) : current.kind === "field-scan" ? (
<InspectorFieldScanPage cls={current.cls} fieldName={current.fieldName} onNavigate={navigate} />
) : null}
</div>
</div>
);
}
export default function App({ report }: { report: Report }) {
const [expandAllTables, setExpandAllTables] = React.useState(false);
const hasDomData = (report.dominator_analysis?.immediate_dominators?.pairs?.length ?? 0) > 0;
const objGraphNodes = report.obj_graph_flat?.nodes ?? null;
// ── Nav-toast: shown when a cross-section button fires without page scroll ──
const [navToast, setNavToast] = React.useState<{ label: string; sectionId: string } | null>(null);
const navToastTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
const handler = (e: Event) => {
const { label, sectionId } = (e as CustomEvent<{ label: string; sectionId: string }>).detail;
setNavToast({ label, sectionId });
if (navToastTimer.current) clearTimeout(navToastTimer.current);
navToastTimer.current = setTimeout(() => setNavToast(null), 4500);
};
window.addEventListener("nav-toast", handler);
return () => window.removeEventListener("nav-toast", handler);
}, []);
// ── Offline-save state (WASM/online mode only) ────────────────────────────
type SaveState = "idle" | "saving" | "done" | "error";
const [saveState, setSaveState] = React.useState<SaveState>("idle");
const saveStateRef = React.useRef<SaveState>("idle");
const setSaveStateSync = (s: SaveState) => { saveStateRef.current = s; setSaveState(s); };
// Ctrl+S / Cmd+S → fetch /download/offline and trigger browser download
React.useEffect(() => {
if (!(window as any).__wasmSession) return;
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (saveStateRef.current === "saving") return;
setSaveStateSync("saving");
fetch("/download/offline")
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const cd = r.headers.get("Content-Disposition") ?? "";
const match = cd.match(/filename="([^"]+)"/);
const filename = match?.[1] ?? "report-offline.html";
return r.blob().then(blob => ({ blob, filename }));
})
.then(({ blob, filename }) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
setSaveStateSync("done");
setTimeout(() => setSaveStateSync("idle"), 2500);
})
.catch(() => {
setSaveStateSync("error");
setTimeout(() => setSaveStateSync("idle"), 3000);
});
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
// Scroll to the URL hash once the DOM has been painted after initial render.
// The browser fires the native hash-scroll before React mounts, so we must
// replay it here.
React.useEffect(() => {
const hash = window.location.hash.slice(1);
if (!hash) return;
// explore/domtree hashes are handled by ObjectGraphExplorer itself
if (/^(explore|domtree)\/\d+$/.test(hash)) {
document.getElementById("object-graph")?.scrollIntoView({ behavior: "smooth" });
return;
}
requestAnimationFrame(() => {
document.getElementById(hash)?.scrollIntoView({ behavior: "smooth" });
});
}, []); // empty deps → runs once after first render
// Global keyboard shortcuts (skip when focus is inside a text input/textarea).
const gKeyTime = React.useRef(0);
React.useEffect(() => {
const SECTION_NAV: Record<string, string> = {
h: "system-overview", l: "leak-suspects",
t: "top-consumers", d: "dominator-analysis",
r: "type-ref-graph", o: "object-graph",
};
const handler = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === "/") {
e.preventDefault();
const inputs = document.querySelectorAll<HTMLInputElement>(".filter");
for (const inp of inputs) {
const rect = inp.getBoundingClientRect();
if (rect.top >= 0 && rect.bottom <= window.innerHeight) { inp.focus(); break; }
}
gKeyTime.current = 0;
return;
}
if (e.key === "Escape") {
(document.activeElement as HTMLElement | null)?.blur();
gKeyTime.current = 0;
return;
}
const now = Date.now();
if (e.key === "g") {
gKeyTime.current = now;
return;
}
if (now - gKeyTime.current < 1500 && SECTION_NAV[e.key]) {
e.preventDefault();
const id = SECTION_NAV[e.key];
const el = document.getElementById(id);
if (el) { el.scrollIntoView({ behavior: "smooth" }); window.location.hash = id; }
}
gKeyTime.current = 0;
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return (
<HasDomDataCtx.Provider value={hasDomData}>
<ObjGraphCtx.Provider value={objGraphNodes}>
<TableExpansionCtx.Provider value={expandAllTables}>
<div className="app">
<a href="#memory-triage" className="skip-link">Skip to content</a>
<ReportHeader report={report} />
<div className="theme-toggle-wrap">
<button className="theme-toggle" onClick={() => setExpandAllTables((v) => !v)}>
{expandAllTables ? "⊟ Collapse All Tables" : "⊞ Expand All Tables"}
</button>
<button className="theme-toggle" title="Save this self-contained report as an HTML file" onClick={() => saveHtml((report.overview.source_name || "heap-report").replace(/[^a-z0-9._-]/gi, "_") + ".html")}>⬇ Save HTML</button>
{(window as any).__wasmSession && (
<span className="save-hint" title="Downloads a self-contained HTML without live heap exploration">
<kbd>Ctrl+S</kbd> Save offline
</span>
)}
<span className="save-hint" title="g then h/l/t/d/r/o — jump to section; / — focus filter; Alt+←/→ — back/forward in Explorer">
<kbd>g</kbd> shortcuts
</span>
<ThemeToggle />
</div>
<Nav report={report} />
<NavBreadcrumb />
{report.truncated_input && (
<div style={{ background: "var(--warn-bg, #fefce8)", border: "1px solid var(--warn-border, #fde047)", borderRadius: 6, padding: "0.5rem 0.75rem", margin: "0.5rem 0", display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "0.875rem" }}>
<span style={{ fontSize: "1.1em" }}>⚠</span>
<span style={{ flex: 1 }}>
<strong>Truncated input:</strong> the heap dump file was incomplete (the file ended mid-record).
This report covers only the objects and classes that were successfully read before the file ended.
Totals, leak suspects, and top consumers may be understated. Re-copy the dump to get a complete analysis.
</span>
</div>
)}
{saveState !== "idle" && !!(window as any).__wasmSession && (
<div className={`save-toast save-toast-${saveState}`}>
{saveState === "saving" && "Generating offline report…"}
{saveState === "done" && "✓ Saved as offline HTML"}
{saveState === "error" && "✗ Failed to generate offline report"}
</div>
)}
{navToast && (
<div className="nav-toast">
<span>✓ {navToast.label}</span>
<button className="nav-toast-link"
onClick={() => { document.getElementById(navToast.sectionId)?.scrollIntoView({ behavior: "smooth" }); setNavToast(null); }}>
Jump there ↓
</button>
<button className="nav-toast-close" title="Dismiss" onClick={() => setNavToast(null)}>✕</button>
</div>
)}
<HeapInspector report={report} histogram={report.overview?.histogram ?? []} />
<OomTriage report={report} />
<ExecSummaryCard report={report} />
<WasteSummarySection report={report} />
<KpiStrip report={report} />
<SystemOverviewSection report={report} />
<RecordCensusSection report={report} />
<LeakSuspectsSection report={report} />
<TopConsumersSection report={report} />
<SizeDistributionSection report={report} />
<DuplicateStringsSection report={report} />
<DuplicatePrimArraysSection report={report} />
<BoxedNumbersSection report={report} />
<HeaderOverheadSection report={report} />
<DominatorAnalysisSection data={report.dominator_analysis} />
<ThreadsSection report={report} />
<FrameworkAnalysisSection items={report.framework_analysis} />
{report.top_components?.components?.length ? (
<TopComponentsSection data={report.top_components} />
) : null}
<ArraysBySizeSection data={report.arrays_by_size} totalShallow={report.overview.total_shallow} />
<CollectionsSection data={report.collections} />
<CollectionWasteBudgetSection report={report} />
{report.collection_attribution && (
<CollectionAttributionSection data={report.collection_attribution} />
)}
{report.fields_by_size && <FieldsBySizeSection data={report.fields_by_size} />}
{report.top_retainers && report.top_retainers.length > 0 && (
<TopRetainersSection rows={report.top_retainers} />
)}
{report.biggest_collections && <BiggestCollectionsSection data={report.biggest_collections} />}
{report.collection_contents && <CollectionContentsSection data={report.collection_contents} />}
<ReferencesSection data={report.references} />
<DirectByteBufferCard indicators={report.leak_indicators} />
<UnreachableObjectsSection data={report.overview} />
{report.alloc_sites?.traces_present && <AllocSitesSection data={report.alloc_sites} biggestClasses={report.top?.biggest_classes ?? []} />}
{report.obj_graph_flat && (
<section id="object-graph">
<h2>Object Graph Explorer</h2>
<p className="subtitle">
Browse individual objects: follow reference chains, inspect field values, and trace what keeps each object alive.
Click a class to list its instances; click an instance to see its fields and inbound references.
Start here after Dominator Analysis or Leak Suspects identifies a suspect class or object.
</p>
<ObjectGraphExplorer data={report.obj_graph_flat} totalHeapOverride={report.overview.total_shallow} />
</section>
)}
{report.type_ref_graph && report.type_ref_graph.length > 0 && (
<section id="type-ref-graph" className="section">
<h2>Type Reference Graph</h2>
<p className="subtitle">Reference topology between class types — each directed edge shows references from one class to another, weighted by retained heap. <strong>Retained Flow</strong> is the sum of referenced-object retained sizes across all instances of the source class; node size reflects relative memory pressure.</p>
<TypeRefGraph edges={report.type_ref_graph} histogram={report.overview.histogram} objGraph={report.obj_graph_flat} />
</section>
)}
<RetentionConcentrationSection report={report} />
<DominatorDepthSection report={report} />
<LeakIndicatorsSection data={report.leak_indicators} totalHeap={report.overview.total_shallow} />
<CustomQueriesSection report={report} />
<GlossarySection />
<BackToTop />
<footer className="report-footer">
Generated by{" "}
<a href="https://github.com/parttimenerd/hprof-analyzer" target="_blank" rel="noopener noreferrer">
hprof-analyzer
</a>
{" "}(<a href="https://crates.io/crates/hprof-analyzer" target="_blank" rel="noopener noreferrer">cargo install hprof-analyzer</a>)
{report.generated ? ` · ${formatDateNice(new Date(report.generated).getTime())}` : null}
{" · "}This report is a self-contained HTML file — no server required.
</footer>
</div>
</TableExpansionCtx.Provider>
</ObjGraphCtx.Provider>
</HasDomDataCtx.Provider>
);
}
/// Catches any render-time exception below it and shows a styled panel instead of
/// a blank page. `boot()` wraps the whole app in this, so a bug in one section
/// degrades to an error message rather than a white screen with the report data
/// still embedded but invisible.
export class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ error: Error | null }
> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
// Surface to the console for anyone with devtools open.
console.error("hprof-analyzer report render failed:", error, info);
}
render() {
if (this.state.error) {
return (
<div className="render-error" role="alert">
<h1>Report failed to render</h1>
<p>
The report data loaded, but a rendering error occurred. This is a bug
in the viewer — the underlying JSON is intact. Details:
</p>
<pre>{String(this.state.error?.stack || this.state.error)}</pre>
</div>
);
}
return this.props.children;
}
}