diff --git a/Android.bp b/Android.bp
index 8a329f5e2b5..c375d1016ce 100644
--- a/Android.bp
+++ b/Android.bp
@@ -808,7 +808,7 @@ cc_library_shared {
],
shared_libs: [
"android.hardware.atrace@1.0",
- "android.hardware.health-V2-ndk",
+ "android.hardware.health-V5-ndk",
"android.hardware.health@2.0",
"android.hardware.power.stats-V1-cpp",
"android.hardware.power.stats@1.0",
@@ -23159,8 +23159,8 @@ phony {
// TODO(primiano): uncomment after ag/38422283 lands.
// "system_tracing_protos_descriptor",
],
- }
- }
+ },
+ },
}
filegroup {
diff --git a/docs/heap_diff/README.md b/docs/heap_diff/README.md
new file mode 100644
index 00000000000..86b3b37a49f
--- /dev/null
+++ b/docs/heap_diff/README.md
@@ -0,0 +1,142 @@
+# Heap Dump Explorer — Diff feature
+
+End-to-end screenshots of every diff view in the Heap Dump Explorer.
+
+## Two diff modes
+
+The Heap Dump Explorer supports two diff workflows:
+
+1. **Same-trace diff** — two heap dumps in one trace. Activates the
+ *flamegraph diff* path with palette-modulated colouring (the
+ flamegraph is rendered with the standard per-name palette hues, but
+ each node is darkened / saturated for *grew*, lightened / desaturated
+ for *shrank*, kept neutral for *unchanged*, and pop-saturated for
+ *new*).
+
+2. **Cross-trace diff** — two separate traces (or hprofs) loaded as
+ primary + baseline. The Overview, Classes, Objects, Dominators,
+ Bitmaps, Strings and Arrays tabs show diff columns; the flamegraph
+ stays in single-engine mode because each trace has its own SQLite
+ engine.
+
+Status of each row is colour-coded: **GREW
+/ NEW** (red), **SHRANK / GONE**
+(blue), gray for unchanged.
+
+## Same-trace fixture (synthetic, two snapshots in one pftrace)
+
+Built by `tools/heap_dump_diff_test_app/build_rich_fixture.py` →
+`test/data/heap_diff_multi.pftrace`. ~1400 objects across two
+Android-app-shaped snapshots: dump 1 has a busy UI (4 activities × 3
+fragments × …); dump 2 has a quieter UI but heavier background services
+and network connections. Some classes appear (`NewlyAddedClass`),
+disappear (`RemovedClass`), grow, shrink or stay flat.
+
+### Overview tab
+
+Reachable instances and bytes-retained-by-heap with Δ columns.
+
+
+
+### Flamegraph tab
+
+Same-trace flamegraph diff, palette colours preserved per class,
+saturation / lightness modulated by Δ direction. UI sub-tree (left:
+Application → Activity → Fragment → ViewHolder → TextView / ImageView →
+String / Bitmap) reads as vivid (grew). Service sub-tree (right:
+ServiceManager → BackgroundService → Worker → Task) reads as faded
+(shrank).
+
+
+
+### Classes tab
+
+Per-class diff breakdown. Status pills (GREW / SHRANK / NEW / GONE) and
+signed deltas next to baseline / current values.
+
+
+
+### Objects tab
+
+
+
+### Dominators tab
+
+Dominator-tree retained-size diff per root class (size, count, native
+size, retained obj count — all four columns are diffable).
+
+
+
+### Bitmaps tab
+
+
+
+### Strings tab
+
+
+
+### Arrays tab
+
+
+
+### Primary-dump popup
+
+The primary-dump selector showing the "Diff against this dump" section.
+Selecting another dump from the same trace activates the same-trace
+flamegraph diff.
+
+
+
+## Cross-trace fixture (real JVM hprofs)
+
+Built by running the Java app at
+`tools/heap_dump_diff_test_app/HeapDumpDiffTest.java` against a 1-GB
+JVM. The app constructs an object graph with deep reference chains
+(Application → ActivityManager → … → byte[]), then calls
+`HotSpotDiagnosticMXBean.dumpHeap` twice with different scales between
+the two dumps. The resulting `current.hprof` (smaller, services-heavy)
+is opened as the primary trace and `baseline.hprof` (larger, UI-heavy)
+is loaded as the cross-trace baseline.
+
+### Overview (cross-trace)
+
+
+
+### Classes (cross-trace)
+
+
+
+### Dominators (cross-trace)
+
+
+
+### Strings (cross-trace)
+
+
+
+### Arrays (cross-trace)
+
+
+
+## Reproducing locally
+
+```sh
+# Capture two real hprofs.
+cd tools/heap_dump_diff_test_app
+javac HeapDumpDiffTest.java
+java -Xmx1g HeapDumpDiffTest baseline.hprof current.hprof
+
+# Build the same-trace synthetic fixture.
+python3 build_rich_fixture.py
+out/ui/protoc --encode=perfetto.protos.Trace -I . protos/perfetto/trace/trace.proto \
+ < /tmp/hprof_test/multi_dump_rich.textproto \
+ > test/data/heap_diff_multi.pftrace
+
+# Serve the UI.
+cd ui && pnpm install && node build.js --serve --watch
+
+# In another shell, run the playwright e2e suites.
+cd ui && pnpm exec playwright test src/test/heap_dump_flamegraph_diff.test.ts
+cd ui && pnpm exec playwright test src/test/heap_dump_diff.test.ts
+cd ui && pnpm exec playwright test src/test/heap_dump_diff_hprof_primary.test.ts
+```
diff --git a/docs/heap_diff/screenshots/01_overview_diff.png b/docs/heap_diff/screenshots/01_overview_diff.png
new file mode 100644
index 00000000000..4d235ebfb27
Binary files /dev/null and b/docs/heap_diff/screenshots/01_overview_diff.png differ
diff --git a/docs/heap_diff/screenshots/02_flamegraph_diff.png b/docs/heap_diff/screenshots/02_flamegraph_diff.png
new file mode 100644
index 00000000000..e6cd4d2c1cb
Binary files /dev/null and b/docs/heap_diff/screenshots/02_flamegraph_diff.png differ
diff --git a/docs/heap_diff/screenshots/03_classes_diff.png b/docs/heap_diff/screenshots/03_classes_diff.png
new file mode 100644
index 00000000000..3be457bfa11
Binary files /dev/null and b/docs/heap_diff/screenshots/03_classes_diff.png differ
diff --git a/docs/heap_diff/screenshots/04_objects_diff.png b/docs/heap_diff/screenshots/04_objects_diff.png
new file mode 100644
index 00000000000..2d0fa5c2336
Binary files /dev/null and b/docs/heap_diff/screenshots/04_objects_diff.png differ
diff --git a/docs/heap_diff/screenshots/05_dominators_diff.png b/docs/heap_diff/screenshots/05_dominators_diff.png
new file mode 100644
index 00000000000..1f5a37c5fd0
Binary files /dev/null and b/docs/heap_diff/screenshots/05_dominators_diff.png differ
diff --git a/docs/heap_diff/screenshots/06_bitmaps_diff.png b/docs/heap_diff/screenshots/06_bitmaps_diff.png
new file mode 100644
index 00000000000..0fd759cfbc0
Binary files /dev/null and b/docs/heap_diff/screenshots/06_bitmaps_diff.png differ
diff --git a/docs/heap_diff/screenshots/07_strings_diff.png b/docs/heap_diff/screenshots/07_strings_diff.png
new file mode 100644
index 00000000000..d6d6dc06fe8
Binary files /dev/null and b/docs/heap_diff/screenshots/07_strings_diff.png differ
diff --git a/docs/heap_diff/screenshots/08_arrays_diff.png b/docs/heap_diff/screenshots/08_arrays_diff.png
new file mode 100644
index 00000000000..4957e71e022
Binary files /dev/null and b/docs/heap_diff/screenshots/08_arrays_diff.png differ
diff --git a/docs/heap_diff/screenshots/09_popup.png b/docs/heap_diff/screenshots/09_popup.png
new file mode 100644
index 00000000000..64b23bd4b04
Binary files /dev/null and b/docs/heap_diff/screenshots/09_popup.png differ
diff --git a/docs/heap_diff/screenshots/cross_trace/01_overview_diff.png b/docs/heap_diff/screenshots/cross_trace/01_overview_diff.png
new file mode 100644
index 00000000000..6aadc0ee5dc
Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/01_overview_diff.png differ
diff --git a/docs/heap_diff/screenshots/cross_trace/02_flamegraph_diff.png b/docs/heap_diff/screenshots/cross_trace/02_flamegraph_diff.png
new file mode 100644
index 00000000000..0bf7bb8cfeb
Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/02_flamegraph_diff.png differ
diff --git a/docs/heap_diff/screenshots/cross_trace/03_classes_diff.png b/docs/heap_diff/screenshots/cross_trace/03_classes_diff.png
new file mode 100644
index 00000000000..c71b408861f
Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/03_classes_diff.png differ
diff --git a/docs/heap_diff/screenshots/cross_trace/04_objects_diff.png b/docs/heap_diff/screenshots/cross_trace/04_objects_diff.png
new file mode 100644
index 00000000000..8d4b487f0cb
Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/04_objects_diff.png differ
diff --git a/docs/heap_diff/screenshots/cross_trace/05_dominators_diff.png b/docs/heap_diff/screenshots/cross_trace/05_dominators_diff.png
new file mode 100644
index 00000000000..a95c0870fbc
Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/05_dominators_diff.png differ
diff --git a/docs/heap_diff/screenshots/cross_trace/06_bitmaps_diff.png b/docs/heap_diff/screenshots/cross_trace/06_bitmaps_diff.png
new file mode 100644
index 00000000000..bcfa2c01650
Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/06_bitmaps_diff.png differ
diff --git a/docs/heap_diff/screenshots/cross_trace/07_strings_diff.png b/docs/heap_diff/screenshots/cross_trace/07_strings_diff.png
new file mode 100644
index 00000000000..794901a7c5d
Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/07_strings_diff.png differ
diff --git a/docs/heap_diff/screenshots/cross_trace/08_arrays_diff.png b/docs/heap_diff/screenshots/cross_trace/08_arrays_diff.png
new file mode 100644
index 00000000000..62e8bae46fa
Binary files /dev/null and b/docs/heap_diff/screenshots/cross_trace/08_arrays_diff.png differ
diff --git a/tools/heap_dump_diff_test_app/HeapDumpDiffTest.java b/tools/heap_dump_diff_test_app/HeapDumpDiffTest.java
new file mode 100644
index 00000000000..47f7c6bf5b8
--- /dev/null
+++ b/tools/heap_dump_diff_test_app/HeapDumpDiffTest.java
@@ -0,0 +1,357 @@
+import com.sun.management.HotSpotDiagnosticMXBean;
+import java.io.File;
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import javax.management.MBeanServer;
+
+/**
+ * Java app that builds a rich, layered heap (Android-app-shaped) and
+ * captures two hprof snapshots — one of the "busy UI" state, one of the
+ * "background-services-heavy" state.
+ *
+ * Usage:
+ * javac HeapDumpDiffTest.java
+ * java -Xmx512m HeapDumpDiffTest baseline.hprof current.hprof
+ *
+ * The app builds:
+ * Application
+ * ActivityManager
+ * Activity*
+ * FragmentManager
+ * Fragment*
+ * ViewHolder*
+ * TextView* -> String*
+ * ImageView* -> Bitmap*
+ * ViewModel
+ * DataCache -> CacheEntry -> byte[]
+ * ApiClient -> HttpResponse -> byte[]
+ * BackStack -> BackStackEntry*
+ * ServiceManager
+ * BackgroundService*
+ * Worker -> Task -> TaskData -> byte[]
+ * -> Scheduler -> ScheduledTask*
+ * EventBus -> EventListener*
+ * ConnectivityService -> ConnectionPool -> Connection -> SocketBuffer
+ *
+ * Between dumps, the UI shrinks (user closed activities) while background
+ * services and network connections grow (long-running jobs accumulated).
+ */
+public class HeapDumpDiffTest {
+
+ // Root reference holder so nothing is reclaimed before dumpHeap runs.
+ static Application app;
+
+ public static void main(String[] args) throws IOException, InterruptedException {
+ if (args.length < 2) {
+ System.err.println(
+ "Usage: HeapDumpDiffTest ");
+ System.exit(1);
+ }
+
+ // ---- Phase 1: busy UI, light background, light network ---------
+ app = new Application();
+ app.buildState(/*activities=*/ 4, /*fragmentsPerActivity=*/ 3,
+ /*holdersPerFragment=*/ 4,
+ /*textviewsPerHolder=*/ 2, /*stringsPerTextview=*/ 2,
+ /*imageviewsPerHolder=*/ 1, /*bitmapsPerImageview=*/ 2,
+ /*cachesPerVm=*/ 2, /*entriesPerCache=*/ 4,
+ /*apisPerVm=*/ 1, /*respsPerApi=*/ 2,
+ /*backstackEntries=*/ 3,
+ /*bgServices=*/ 2, /*workersPerService=*/ 2,
+ /*tasksPerWorker=*/ 2, /*scheduledPerScheduler=*/ 2,
+ /*listenersPerEventBus=*/ 3,
+ /*connections=*/ 2, /*buffersPerConnection=*/ 2);
+ // Add some RemovedClass instances that go away in dump 2.
+ for (int i = 0; i < 8; i++) {
+ app.removed.add(new RemovedClass(i));
+ }
+ System.gc();
+ Thread.sleep(500);
+ dumpHeap(args[0]);
+ System.out.println("Wrote baseline: " + args[0]);
+
+ // ---- Phase 2: UI mostly closed, services accumulated ------------
+ // Reset to a fresh graph with the second profile. Building a fresh
+ // graph keeps each dump's structure clean and avoids leftover
+ // sub-trees from phase 1 distorting the diff.
+ app = new Application();
+ app.buildState(/*activities=*/ 2, /*fragmentsPerActivity=*/ 2,
+ /*holdersPerFragment=*/ 4,
+ /*textviewsPerHolder=*/ 2, /*stringsPerTextview=*/ 2,
+ /*imageviewsPerHolder=*/ 1, /*bitmapsPerImageview=*/ 4,
+ /*cachesPerVm=*/ 1, /*entriesPerCache=*/ 2,
+ /*apisPerVm=*/ 2, /*respsPerApi=*/ 4,
+ /*backstackEntries=*/ 2,
+ /*bgServices=*/ 3, /*workersPerService=*/ 4,
+ /*tasksPerWorker=*/ 3, /*scheduledPerScheduler=*/ 4,
+ /*listenersPerEventBus=*/ 5,
+ /*connections=*/ 4, /*buffersPerConnection=*/ 4);
+ // RemovedClass is gone; NewlyAddedClass is here.
+ for (int i = 0; i < 6; i++) {
+ app.newlyAdded.add(new NewlyAddedClass(i));
+ }
+ System.gc();
+ Thread.sleep(500);
+ dumpHeap(args[1]);
+ System.out.println("Wrote current: " + args[1]);
+ }
+
+ static void dumpHeap(String path) throws IOException {
+ File f = new File(path);
+ if (f.exists() && !f.delete()) {
+ throw new IOException("Could not delete existing " + path);
+ }
+ MBeanServer server = ManagementFactory.getPlatformMBeanServer();
+ HotSpotDiagnosticMXBean bean = ManagementFactory.newPlatformMXBeanProxy(
+ server,
+ "com.sun.management:type=HotSpotDiagnostic",
+ HotSpotDiagnosticMXBean.class);
+ bean.dumpHeap(path, /*live=*/ true);
+ }
+
+ // ----- Object graph nodes --------------------------------------------
+ //
+ // Each class is a real Java type so it shows up in the hprof with its
+ // own classloader entry. Field references hold children alive between
+ // dumps so the dominator tree has interesting structure.
+
+ static class Application {
+ ActivityManager activityManager = new ActivityManager();
+ ServiceManager serviceManager = new ServiceManager();
+ List removed = new ArrayList<>();
+ List newlyAdded = new ArrayList<>();
+
+ void buildState(int activities, int fragmentsPerActivity,
+ int holdersPerFragment, int textviewsPerHolder,
+ int stringsPerTextview, int imageviewsPerHolder,
+ int bitmapsPerImageview, int cachesPerVm,
+ int entriesPerCache, int apisPerVm, int respsPerApi,
+ int backstackEntries, int bgServices,
+ int workersPerService, int tasksPerWorker,
+ int scheduledPerScheduler, int listenersPerEventBus,
+ int connections, int buffersPerConnection) {
+ for (int a = 0; a < activities; a++) {
+ Activity act = new Activity("activity-" + a);
+ activityManager.activities.add(act);
+ for (int f = 0; f < fragmentsPerActivity; f++) {
+ Fragment frag = new Fragment("fragment-" + a + "-" + f);
+ act.fragmentManager.fragments.add(frag);
+ for (int h = 0; h < holdersPerFragment; h++) {
+ ViewHolder vh = new ViewHolder();
+ frag.holders.add(vh);
+ for (int t = 0; t < textviewsPerHolder; t++) {
+ TextView tv = new TextView();
+ vh.textViews.add(tv);
+ for (int s = 0; s < stringsPerTextview; s++) {
+ tv.strings.add(UUID.randomUUID().toString());
+ }
+ }
+ for (int i = 0; i < imageviewsPerHolder; i++) {
+ ImageView iv = new ImageView();
+ vh.imageViews.add(iv);
+ for (int b = 0; b < bitmapsPerImageview; b++) {
+ iv.bitmaps.add(new Bitmap(64 * 1024));
+ }
+ }
+ }
+ for (int c = 0; c < cachesPerVm; c++) {
+ DataCache dc = new DataCache();
+ frag.viewModel.caches.add(dc);
+ for (int e = 0; e < entriesPerCache; e++) {
+ CacheEntry ce = new CacheEntry();
+ ce.payload = new byte[8 * 1024];
+ dc.entries.add(ce);
+ }
+ }
+ for (int p = 0; p < apisPerVm; p++) {
+ ApiClient api = new ApiClient();
+ frag.viewModel.apis.add(api);
+ for (int r = 0; r < respsPerApi; r++) {
+ HttpResponse resp = new HttpResponse();
+ resp.body = new byte[16 * 1024];
+ api.responses.add(resp);
+ }
+ }
+ for (int e = 0; e < backstackEntries; e++) {
+ act.fragmentManager.backStack.entries.add(
+ new BackStackEntry("entry-" + e));
+ }
+ }
+ }
+ for (int s = 0; s < bgServices; s++) {
+ BackgroundService svc = new BackgroundService("svc-" + s);
+ serviceManager.services.add(svc);
+ for (int w = 0; w < workersPerService; w++) {
+ Worker worker = new Worker();
+ svc.workers.add(worker);
+ for (int t = 0; t < tasksPerWorker; t++) {
+ Task task = new Task();
+ task.data = new TaskData();
+ task.data.payload = new byte[2 * 1024];
+ worker.tasks.add(task);
+ }
+ for (int sc = 0; sc < scheduledPerScheduler; sc++) {
+ worker.scheduler.scheduled.add(
+ new ScheduledTask(sc));
+ }
+ }
+ for (int l = 0; l < listenersPerEventBus; l++) {
+ svc.eventBus.listeners.add(new EventListener(l));
+ }
+ }
+ for (int c = 0; c < connections; c++) {
+ Connection conn = new Connection();
+ serviceManager.connectivity.connectionPool.connections.add(conn);
+ for (int b = 0; b < buffersPerConnection; b++) {
+ SocketBuffer sb = new SocketBuffer();
+ sb.payload = new byte[32 * 1024];
+ conn.buffers.add(sb);
+ }
+ }
+ }
+ }
+
+ static class ActivityManager {
+ List activities = new ArrayList<>();
+ }
+
+ static class Activity {
+ String name;
+ FragmentManager fragmentManager = new FragmentManager();
+ Activity(String n) { this.name = n; }
+ }
+
+ static class FragmentManager {
+ List fragments = new ArrayList<>();
+ BackStack backStack = new BackStack();
+ }
+
+ static class Fragment {
+ String tag;
+ List holders = new ArrayList<>();
+ ViewModel viewModel = new ViewModel();
+ Fragment(String t) { this.tag = t; }
+ }
+
+ static class BackStack {
+ List entries = new ArrayList<>();
+ }
+
+ static class BackStackEntry {
+ String name;
+ BackStackEntry(String n) { this.name = n; }
+ }
+
+ static class ViewHolder {
+ List textViews = new ArrayList<>();
+ List imageViews = new ArrayList<>();
+ }
+
+ static class TextView {
+ List strings = new ArrayList<>();
+ }
+
+ static class ImageView {
+ List bitmaps = new ArrayList<>();
+ }
+
+ static class Bitmap {
+ byte[] pixels;
+ Bitmap(int n) { this.pixels = new byte[n]; }
+ }
+
+ static class ViewModel {
+ List caches = new ArrayList<>();
+ List apis = new ArrayList<>();
+ }
+
+ static class DataCache {
+ List entries = new ArrayList<>();
+ }
+
+ static class CacheEntry {
+ byte[] payload;
+ }
+
+ static class ApiClient {
+ List responses = new ArrayList<>();
+ }
+
+ static class HttpResponse {
+ byte[] body;
+ }
+
+ static class ServiceManager {
+ List services = new ArrayList<>();
+ ConnectivityService connectivity = new ConnectivityService();
+ }
+
+ static class BackgroundService {
+ String name;
+ List workers = new ArrayList<>();
+ EventBus eventBus = new EventBus();
+ BackgroundService(String n) { this.name = n; }
+ }
+
+ static class Worker {
+ List tasks = new ArrayList<>();
+ Scheduler scheduler = new Scheduler();
+ }
+
+ static class Task {
+ TaskData data;
+ }
+
+ static class TaskData {
+ byte[] payload;
+ }
+
+ static class Scheduler {
+ List scheduled = new ArrayList<>();
+ }
+
+ static class ScheduledTask {
+ int delayMs;
+ ScheduledTask(int s) { this.delayMs = s; }
+ }
+
+ static class EventBus {
+ List listeners = new ArrayList<>();
+ }
+
+ static class EventListener {
+ int id;
+ EventListener(int i) { this.id = i; }
+ }
+
+ static class ConnectivityService {
+ ConnectionPool connectionPool = new ConnectionPool();
+ }
+
+ static class ConnectionPool {
+ List connections = new ArrayList<>();
+ }
+
+ static class Connection {
+ List buffers = new ArrayList<>();
+ }
+
+ static class SocketBuffer {
+ byte[] payload;
+ }
+
+ static class RemovedClass {
+ int seed;
+ RemovedClass(int s) { this.seed = s; }
+ }
+
+ static class NewlyAddedClass {
+ int seed;
+ NewlyAddedClass(int s) { this.seed = s; }
+ }
+}
diff --git a/tools/heap_dump_diff_test_app/README.md b/tools/heap_dump_diff_test_app/README.md
new file mode 100644
index 00000000000..f7652838344
--- /dev/null
+++ b/tools/heap_dump_diff_test_app/README.md
@@ -0,0 +1,47 @@
+# Heap Dump Explorer test fixtures
+
+Two ways to produce a rich heap-dump fixture for the Heap Dump Explorer
+diff feature.
+
+## 1. Java app + JVM hprof
+
+`HeapDumpDiffTest.java` builds an Android-app-shaped object graph (deep,
+branchy: Application → ActivityManager → Activity → … → byte[]) and
+captures two `.hprof` snapshots via `HotSpotDiagnosticMXBean.dumpHeap`.
+
+Between dumps the UI shrinks (user closed activities) and background
+services / network connections grow, so most class branches show up
+either as GROW or SHRANK in the diff view. Two classes appear and
+disappear (`NewlyAddedClass` / `RemovedClass`) for the NEW / GONE
+states.
+
+```sh
+cd tools/heap_dump_diff_test_app
+javac HeapDumpDiffTest.java
+java -Xmx1g HeapDumpDiffTest baseline.hprof current.hprof
+```
+
+Both `.hprof` files load directly in Perfetto UI (open one as the
+primary trace, the other as the baseline via the Heap Dump Explorer's
+"Diff against another trace…" CTA). Cross-trace diff exercises the
+Overview, Classes, Objects, Dominators, Bitmaps, Strings and Arrays tab
+diffs.
+
+## 2. Synthetic same-trace fixture
+
+The same-trace flamegraph diff path (the one that shows palette-modulated
+red / blue colours on the flamegraph nodes) requires *two heap dumps in
+one trace*. JVM hprofs always carry a single dump, so for that path we
+build a synthetic two-snapshot pftrace from a textproto.
+
+```sh
+cd tools/heap_dump_diff_test_app
+python3 build_rich_fixture.py
+out/ui/protoc --encode=perfetto.protos.Trace -I . protos/perfetto/trace/trace.proto \
+ < /tmp/hprof_test/multi_dump_rich.textproto \
+ > test/data/heap_diff_multi.pftrace
+```
+
+The resulting `heap_diff_multi.pftrace` has the same Android-app-shaped
+graph at two different times (busy UI → background-services-heavy) so
+the same-trace flamegraph diff fires.
diff --git a/tools/heap_dump_diff_test_app/build_rich_fixture.py b/tools/heap_dump_diff_test_app/build_rich_fixture.py
new file mode 100644
index 00000000000..bafbb1c184f
--- /dev/null
+++ b/tools/heap_dump_diff_test_app/build_rich_fixture.py
@@ -0,0 +1,340 @@
+#!/usr/bin/env python3
+"""
+Build a rich multi-dump heap graph fixture with deep reference chains so
+the flamegraph diff exposes interesting structure across many levels.
+
+Two snapshots of the same process (pid 2, "system_server") at ts=100 and
+ts=2_000_000_100. Between snapshots:
+
+ * Activity / Fragment counts shrink (user closed UI).
+ * Background-service Workers grow (long-running jobs accumulated).
+ * DataCache / CacheEntry shrink (eviction).
+ * Bitmap / SocketBuffer grow (more in-flight network responses).
+ * NewlyAddedClass appears in dump 2.
+ * RemovedClass disappears.
+
+The class tree is ~10 levels deep, with branching across UI and Service
+sub-trees, so the flamegraph has plenty of stacks at every metric (Object
+Size, Object Count, Dominated Object Size, Dominated Object Count).
+"""
+
+import os
+import textwrap
+
+# Class catalogue. id -> (class_name, object_size). Stable across dumps.
+CLASSES = [
+ (1, 'java.lang.Object', 16),
+ (2, 'android.app.Application', 64),
+ (3, 'android.app.ActivityManager', 64),
+ (4, 'android.app.Activity', 128),
+ (5, 'android.app.FragmentManager', 64),
+ (6, 'android.app.Fragment', 96),
+ (7, 'android.app.BackStack', 32),
+ (8, 'android.app.BackStackEntry', 48),
+ (9, 'android.view.ViewHolder', 64),
+ (10, 'android.widget.TextView', 96),
+ (11, 'android.widget.ImageView', 96),
+ (12, 'android.graphics.Bitmap', 256),
+ (13, 'java.lang.String', 40),
+ (14, 'androidx.lifecycle.ViewModel', 80),
+ (15, 'com.example.DataCache', 64),
+ (16, 'com.example.CacheEntry', 48),
+ (17, 'byte[]', 32), # array; size is metadata, real bytes per-instance
+ (18, 'com.example.ApiClient', 64),
+ (19, 'com.example.HttpResponse', 64),
+ (20, 'android.app.ServiceManager', 64),
+ (21, 'com.example.BackgroundService', 64),
+ (22, 'com.example.Worker', 64),
+ (23, 'com.example.Task', 48),
+ (24, 'com.example.TaskData', 48),
+ (25, 'com.example.Scheduler', 32),
+ (26, 'com.example.ScheduledTask', 48),
+ (27, 'com.example.EventBus', 32),
+ (28, 'com.example.EventListener', 48),
+ (29, 'android.net.ConnectivityService', 64),
+ (30, 'android.net.ConnectionPool', 32),
+ (31, 'android.net.Connection', 64),
+ (32, 'android.net.SocketBuffer', 192),
+ (33, 'com.example.RemovedClass', 64), # only dump 1
+ (34, 'com.example.NewlyAddedClass', 64), # only dump 2
+]
+
+# Class-name → id reverse for ergonomic node creation.
+CLS = {name: id_ for id_, name, _ in CLASSES}
+# Self-size lookup.
+SIZE = {id_: sz for id_, _, sz in CLASSES}
+
+
+class Graph:
+ """Builds an object graph for one heap dump.
+
+ Each node has a unique numeric id. Edges are labeled with field name
+ "ref" (field_name_id=1). The roots list holds the ROOT_JAVA_FRAME
+ object ids."""
+
+ def __init__(self, base):
+ self.base = base
+ self.next_id = base
+ self.objects = [] # list of (id, type_id, self_size, [child_ids])
+ self.roots = []
+
+ def new(self, type_id, self_size=None, root=False):
+ oid = self.next_id
+ self.next_id += 1
+ self.objects.append([oid, type_id, self_size or SIZE[type_id], []])
+ if root:
+ self.roots.append(oid)
+ return oid
+
+ def link(self, parent_id, child_id):
+ for o in self.objects:
+ if o[0] == parent_id:
+ o[3].append(child_id)
+ return
+ raise KeyError(parent_id)
+
+
+def build_app_graph(g, scale):
+ """Build a layered Android-app-style object graph rooted at Application.
+
+ `scale` is a dict that lets each named branch grow/shrink between
+ dumps without touching this code. Higher numbers → more instances
+ (and therefore wider flamegraph for that branch).
+ """
+ app = g.new(CLS['android.app.Application'], root=True)
+
+ # ---- UI subtree -------------------------------------------------------
+ am = g.new(CLS['android.app.ActivityManager'])
+ g.link(app, am)
+ for _ in range(scale['activities']):
+ act = g.new(CLS['android.app.Activity'])
+ g.link(am, act)
+ fm = g.new(CLS['android.app.FragmentManager'])
+ g.link(act, fm)
+ for _ in range(scale['fragments_per_activity']):
+ frag = g.new(CLS['android.app.Fragment'])
+ g.link(fm, frag)
+ for _ in range(scale['holders_per_fragment']):
+ vh = g.new(CLS['android.view.ViewHolder'])
+ g.link(frag, vh)
+ for _ in range(scale['textviews_per_holder']):
+ tv = g.new(CLS['android.widget.TextView'])
+ g.link(vh, tv)
+ for _ in range(scale['strings_per_textview']):
+ s = g.new(CLS['java.lang.String'])
+ g.link(tv, s)
+ for _ in range(scale['imageviews_per_holder']):
+ iv = g.new(CLS['android.widget.ImageView'])
+ g.link(vh, iv)
+ for _ in range(scale['bitmaps_per_imageview']):
+ b = g.new(CLS['android.graphics.Bitmap'])
+ g.link(iv, b)
+ vm = g.new(CLS['androidx.lifecycle.ViewModel'])
+ g.link(frag, vm)
+ for _ in range(scale['caches_per_viewmodel']):
+ dc = g.new(CLS['com.example.DataCache'])
+ g.link(vm, dc)
+ for _ in range(scale['entries_per_cache']):
+ ce = g.new(CLS['com.example.CacheEntry'])
+ g.link(dc, ce)
+ ba = g.new(CLS['byte[]'], self_size=128)
+ g.link(ce, ba)
+ for _ in range(scale['apis_per_viewmodel']):
+ api = g.new(CLS['com.example.ApiClient'])
+ g.link(vm, api)
+ for _ in range(scale['resps_per_api']):
+ r = g.new(CLS['com.example.HttpResponse'])
+ g.link(api, r)
+ ba = g.new(CLS['byte[]'], self_size=512)
+ g.link(r, ba)
+ bs = g.new(CLS['android.app.BackStack'])
+ g.link(fm, bs)
+ for _ in range(scale['backstack_entries']):
+ bse = g.new(CLS['android.app.BackStackEntry'])
+ g.link(bs, bse)
+
+ # ---- Service subtree -------------------------------------------------
+ sm = g.new(CLS['android.app.ServiceManager'])
+ g.link(app, sm)
+ for _ in range(scale['bgservices']):
+ svc = g.new(CLS['com.example.BackgroundService'])
+ g.link(sm, svc)
+ for _ in range(scale['workers_per_service']):
+ w = g.new(CLS['com.example.Worker'])
+ g.link(svc, w)
+ for _ in range(scale['tasks_per_worker']):
+ t = g.new(CLS['com.example.Task'])
+ g.link(w, t)
+ td = g.new(CLS['com.example.TaskData'])
+ g.link(t, td)
+ ba = g.new(CLS['byte[]'], self_size=64)
+ g.link(td, ba)
+ sched = g.new(CLS['com.example.Scheduler'])
+ g.link(w, sched)
+ for _ in range(scale['scheduled_per_scheduler']):
+ st = g.new(CLS['com.example.ScheduledTask'])
+ g.link(sched, st)
+ eb = g.new(CLS['com.example.EventBus'])
+ g.link(svc, eb)
+ for _ in range(scale['listeners_per_eventbus']):
+ el = g.new(CLS['com.example.EventListener'])
+ g.link(eb, el)
+ cs = g.new(CLS['android.net.ConnectivityService'])
+ g.link(sm, cs)
+ cp = g.new(CLS['android.net.ConnectionPool'])
+ g.link(cs, cp)
+ for _ in range(scale['connections']):
+ c = g.new(CLS['android.net.Connection'])
+ g.link(cp, c)
+ for _ in range(scale['buffers_per_connection']):
+ sb = g.new(CLS['android.net.SocketBuffer'])
+ g.link(c, sb)
+
+
+# Profile for dump 1 (busy UI, lighter network).
+SCALE_1 = dict(
+ activities=4,
+ fragments_per_activity=3,
+ holders_per_fragment=4,
+ textviews_per_holder=2,
+ strings_per_textview=2,
+ imageviews_per_holder=1,
+ bitmaps_per_imageview=2,
+ caches_per_viewmodel=2,
+ entries_per_cache=4,
+ apis_per_viewmodel=1,
+ resps_per_api=2,
+ backstack_entries=3,
+ bgservices=2,
+ workers_per_service=2,
+ tasks_per_worker=2,
+ scheduled_per_scheduler=2,
+ listeners_per_eventbus=3,
+ connections=2,
+ buffers_per_connection=2,
+)
+# Profile for dump 2 — UI mostly closed, services accumulated, network heavy.
+SCALE_2 = dict(
+ activities=2, # ↓ user closed two activities
+ fragments_per_activity=2, # ↓ fewer fragments
+ holders_per_fragment=4,
+ textviews_per_holder=2,
+ strings_per_textview=2,
+ imageviews_per_holder=1,
+ bitmaps_per_imageview=4, # ↑ bigger image cache held longer
+ caches_per_viewmodel=1, # ↓ caches evicted
+ entries_per_cache=2, # ↓ evicted further
+ apis_per_viewmodel=2, # ↑ more api clients
+ resps_per_api=4, # ↑ many in-flight responses
+ backstack_entries=2,
+ bgservices=3, # ↑ extra service started
+ workers_per_service=4, # ↑ workers piled up
+ tasks_per_worker=3, # ↑ tasks queued
+ scheduled_per_scheduler=4, # ↑ schedule grew
+ listeners_per_eventbus=5, # ↑ more subscribers
+ connections=4, # ↑ extra outgoing connections
+ buffers_per_connection=4, # ↑ traffic up
+)
+
+
+def emit_classes(out):
+ out.append(
+ ' location_names {\n iid: 1\n str: "/system/framework/test.apk"\n }'
+ )
+ out.append(' field_names {\n iid: 1\n str: "ref"\n }')
+ for id_, name, size in CLASSES:
+ # Make every concrete class extend Object for a one-deep type
+ # hierarchy — keeps trace_processor happy.
+ sup = '\n superclass_id: 1' if id_ != 1 else ''
+ out.append(
+ textwrap.dedent(f'''
+ types {{
+ id: {id_}
+ class_name: "{name}"
+ location_id: 1
+ object_size: {size}{sup}
+ }}''').strip())
+
+
+def emit_dump(g, ts, dump_label):
+ """Emit one heap_graph packet."""
+ out = []
+ out.append('# === ' + dump_label + ' ===')
+ out.append('packet {')
+ out.append(f' trusted_packet_sequence_id: 999')
+ out.append(f' timestamp: {ts}')
+ out.append(' incremental_state_cleared: true')
+ out.append(' heap_graph {')
+ out.append(' pid: 2')
+ cls_lines = []
+ emit_classes(cls_lines)
+ out.append(textwrap.indent('\n'.join(cls_lines), ' '))
+ # Roots
+ out.append(' roots {')
+ out.append(' root_type: ROOT_JAVA_FRAME')
+ for rid in g.roots:
+ out.append(f' object_ids: {rid}')
+ out.append(' }')
+ # Objects
+ for oid, type_id, self_size, kids in g.objects:
+ out.append(' objects {')
+ out.append(f' id: {oid}')
+ out.append(f' type_id: {type_id}')
+ out.append(f' self_size: {self_size}')
+ for _ in kids:
+ out.append(f' reference_field_id: 1')
+ for kid in kids:
+ out.append(f' reference_object_id: {kid}')
+ out.append(' }')
+ out.append(' continued: false')
+ out.append(' index: 0')
+ out.append(' }')
+ out.append('}')
+ return '\n'.join(out)
+
+
+def main():
+ out = []
+ out.append('packet {')
+ out.append(' process_tree {')
+ out.append(' processes {')
+ out.append(' pid: 2')
+ out.append(' ppid: 1')
+ out.append(' cmdline: "system_server"')
+ out.append(' uid: 1000')
+ out.append(' }')
+ out.append(' }')
+ out.append('}')
+
+ # --- Dump 1 ---
+ g1 = Graph(base=0x1000)
+ build_app_graph(g1, SCALE_1)
+ # Add RemovedClass instances dangling off the app for "GONE in dump 2" demo.
+ app1 = g1.roots[0]
+ for _ in range(8):
+ rc = g1.new(CLS['com.example.RemovedClass'])
+ g1.link(app1, rc)
+ out.append(emit_dump(g1, ts=100, dump_label='DUMP 1 (initial)'))
+
+ # --- Dump 2 ---
+ g2 = Graph(base=0x10000)
+ build_app_graph(g2, SCALE_2)
+ # Add NewlyAddedClass instances for "NEW in dump 2".
+ app2 = g2.roots[0]
+ for _ in range(6):
+ nc = g2.new(CLS['com.example.NewlyAddedClass'])
+ g2.link(app2, nc)
+ out.append(emit_dump(g2, ts=2_000_000_100, dump_label='DUMP 2 (later)'))
+
+ txt = '\n'.join(out) + '\n'
+
+ out_path = '/tmp/hprof_test/multi_dump_rich.textproto'
+ with open(out_path, 'w') as f:
+ f.write(txt)
+ print(
+ f'wrote {out_path} ({len(txt)} bytes, {len(g1.objects)+len(g2.objects)} objects)'
+ )
+
+
+if __name__ == '__main__':
+ main()
diff --git a/ui/src/components/query_flamegraph.ts b/ui/src/components/query_flamegraph.ts
index 32deacda1a8..72368773416 100644
--- a/ui/src/components/query_flamegraph.ts
+++ b/ui/src/components/query_flamegraph.ts
@@ -118,6 +118,12 @@ export interface QueryFlamegraphMetric {
//
// Examples include marking inlined functions, optimized code, etc.
readonly optionalMarker?: FlamegraphOptionalMarker;
+
+ // When true, the metric's SQL is expected to project a `color_hint`
+ // column (CSS color string — `hsl(...)`, `#rgb`, `#rrggbb`). The widget
+ // uses it instead of the default name-hash palette, with intensity-coded
+ // colors. Used by diff flamegraphs to color nodes by delta direction.
+ readonly colorHint?: boolean;
}
export interface MetricsFromTableOrSubqueryOptions {
@@ -263,6 +269,7 @@ async function computeFlamegraphTree(
optionalNodeActions,
optionalRootActions,
optionalMarker,
+ colorHint: colorHintEnabled,
}: QueryFlamegraphMetric,
{filters, view}: FlamegraphState,
): Promise {
@@ -287,7 +294,22 @@ async function computeFlamegraphTree(
const agg = aggregatableProperties ?? [];
const aggCols = agg.map((x) => x.name);
- const unagg = unaggregatableProperties ?? [];
+ // When the metric opts into colorHint, auto-add `color_hint` as a
+ // hidden unaggregatable property so the layout macro projects it
+ // through. The render path lifts it out of `properties` onto
+ // FlamegraphNode.colorHint and never shows it in the tooltip.
+ const unagg: QueryFlamegraphColumn[] = [
+ ...(unaggregatableProperties ?? []),
+ ...(colorHintEnabled
+ ? [
+ {
+ name: 'color_hint',
+ displayName: 'Color',
+ isVisible: () => false,
+ } as QueryFlamegraphColumn,
+ ]
+ : []),
+ ];
const unaggCols = unagg.map((x) => x.name);
const matchingColumns = ['name', ...unaggCols];
@@ -557,6 +579,12 @@ async function computeFlamegraphTree(
marker = optionalMarker.name;
}
+ let colorHint: string | undefined;
+ if (colorHintEnabled) {
+ const p = properties.get('color_hint');
+ if (p !== undefined) colorHint = p.value;
+ properties.delete('color_hint');
+ }
nodes.push({
id: it.id,
parentId: it.parentId,
@@ -569,6 +597,7 @@ async function computeFlamegraphTree(
xEnd: it.xEnd,
properties,
marker,
+ colorHint,
});
if (it.depth === 1) {
postiveRootsValue += it.cumulativeValue;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/baseline/load_action.ts b/ui/src/plugins/com.android.HeapDumpExplorer/baseline/load_action.ts
new file mode 100644
index 00000000000..5247aca463d
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/baseline/load_action.ts
@@ -0,0 +1,98 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Module-level load state shared between the top-bar header and the Overview
+// "Load baseline" button.
+
+import m from 'mithril';
+import type {Raf} from '../../../public/raf';
+import {BaselineLoadError, loadBaseline} from './loader';
+import {addBaselineTrace, getActiveBaseline, setActiveBaseline} from './state';
+
+interface LoadState {
+ loading: boolean;
+ progressBytes: number;
+ progressTotal: number;
+ error: string | null;
+ /** Counter to make engine ids unique within the page. */
+ nextId: number;
+}
+
+const state: LoadState = {
+ loading: false,
+ progressBytes: 0,
+ progressTotal: 0,
+ error: null,
+ nextId: 1,
+};
+
+export interface LoadStateView {
+ readonly loading: boolean;
+ readonly progressPct: number;
+ readonly error: string | null;
+}
+
+export function getLoadState(): LoadStateView {
+ const pct =
+ state.progressTotal > 0
+ ? Math.round((state.progressBytes / state.progressTotal) * 100)
+ : 0;
+ return {loading: state.loading, progressPct: pct, error: state.error};
+}
+
+export function clearLoadError(): void {
+ if (state.error !== null) {
+ state.error = null;
+ m.redraw();
+ }
+}
+
+export async function triggerFileLoad(raf: Raf, file: File): Promise {
+ state.loading = true;
+ state.progressBytes = 0;
+ state.progressTotal = file.size;
+ state.error = null;
+ m.redraw();
+ try {
+ const result = await loadBaseline({
+ file,
+ raf,
+ engineId: `heapdump-baseline-${state.nextId++}`,
+ onProgress: (p) => {
+ state.progressBytes = p.bytesRead;
+ state.progressTotal = p.bytesTotal;
+ m.redraw();
+ },
+ });
+ const trace = addBaselineTrace(
+ result.engine,
+ result.filename,
+ result.dumps,
+ );
+ // Auto-pick when the trace has a single dump and nothing's selected yet.
+ if (getActiveBaseline() === null && trace.dumps.length === 1) {
+ setActiveBaseline({trace, dump: trace.dumps[0]});
+ }
+ } catch (err) {
+ if (err instanceof BaselineLoadError) {
+ state.error = err.message;
+ } else {
+ state.error = `Unexpected error: ${err instanceof Error ? err.message : String(err)}`;
+ console.error('Baseline load failed:', err);
+ }
+ } finally {
+ state.loading = false;
+ m.redraw();
+ }
+}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/baseline/loader.ts b/ui/src/plugins/com.android.HeapDumpExplorer/baseline/loader.ts
new file mode 100644
index 00000000000..7deece374ed
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/baseline/loader.ts
@@ -0,0 +1,182 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Loads a baseline trace into a fresh plugin-owned WasmEngineProxy.
+// Minimal subset of core/load_trace.ts: construct engine, reset, stream
+// the file, probe for heap_graph_object rows; reject non-heap traces.
+// Skips track/timeline machinery — heap SQL only.
+
+import {
+ LONG,
+ NUM,
+ NUM_NULL,
+ STR_NULL,
+} from '../../../trace_processor/query_result';
+import {WasmEngineProxy} from '../../../trace_processor/wasm_engine_proxy';
+import type {Raf} from '../../../public/raf';
+import type {HeapDump} from '../queries';
+
+// Mirrors core/trace_stream.ts:TRACE_SLICE_SIZE.
+const CHUNK_SIZE = 32 * 1024 * 1024;
+
+export interface LoadedBaseline {
+ readonly engine: WasmEngineProxy;
+ readonly filename: string;
+ // Sorted ascending by graph_sample_ts.
+ readonly dumps: ReadonlyArray;
+}
+
+export interface LoadProgress {
+ readonly bytesRead: number;
+ readonly bytesTotal: number;
+}
+
+export interface LoadOptions {
+ readonly file: File;
+ readonly raf: Raf;
+ readonly engineId: string;
+ readonly onProgress?: (p: LoadProgress) => void;
+}
+
+/**
+ * Errors raised by `loadBaseline` when the input file is unsuitable. Caught
+ * and rendered inline by header.ts; the engine has been disposed before the
+ * throw so no worker leaks.
+ */
+export class BaselineLoadError extends Error {
+ constructor(
+ message: string,
+ /** True for "user picked the wrong file" — render gently. */
+ readonly userFacing: boolean = true,
+ ) {
+ super(message);
+ this.name = 'BaselineLoadError';
+ }
+}
+
+// On any failure the engine is disposed before this returns / throws.
+export async function loadBaseline(opts: LoadOptions): Promise {
+ const {file, raf, engineId, onProgress} = opts;
+
+ const engine = new WasmEngineProxy(engineId);
+ engine.onResponseReceived = () => raf.scheduleFullRedraw();
+
+ try {
+ engine.resetTraceProcessor({
+ tokenizeOnly: false,
+ cropTrackEvents: false,
+ ingestFtraceInRawTable: false,
+ analyzeTraceProtoContent: false,
+ ftraceDropUntilAllCpusValid: false,
+ extraParsingDescriptors: [],
+ forceFullSort: false,
+ });
+
+ await streamFileIntoEngine(file, engine, onProgress);
+ await engine.notifyEof();
+
+ const probe = await engine.query(
+ 'SELECT count(*) AS cnt FROM heap_graph_object LIMIT 1',
+ );
+ const cnt = probe.firstRow({cnt: NUM}).cnt;
+ if (cnt === 0) {
+ throw new BaselineLoadError(
+ 'Selected file has no Java heap data. Diff mode requires a trace ' +
+ 'with an android.java_hprof or hprof heap dump.',
+ );
+ }
+
+ const dumps = await enumerateDumps(engine);
+ if (dumps.length === 0) {
+ throw new BaselineLoadError(
+ 'Selected file has heap data but no associated process metadata.',
+ );
+ }
+
+ return {engine, filename: file.name, dumps};
+ } catch (err) {
+ try {
+ engine[Symbol.dispose]();
+ } catch {
+ // ignore
+ }
+ if (err instanceof BaselineLoadError) throw err;
+ throw new BaselineLoadError(
+ `Failed to load baseline trace: ${err instanceof Error ? err.message : String(err)}`,
+ false,
+ );
+ }
+}
+
+// Mirrors `dumps/loader.ts:loadDumps` for the primary engine, but
+// returns the list instead of mutating module state.
+async function enumerateDumps(
+ engine: WasmEngineProxy,
+): Promise> {
+ const res = await engine.query(`
+ SELECT
+ o.upid AS upid,
+ o.graph_sample_ts AS ts,
+ coalesce(p.cmdline, p.name) AS pname,
+ p.pid AS pid
+ FROM heap_graph_object o
+ JOIN process p USING (upid)
+ GROUP BY o.upid, o.graph_sample_ts
+ ORDER BY o.graph_sample_ts ASC
+ `);
+ const out: HeapDump[] = [];
+ for (
+ const it = res.iter({
+ upid: NUM,
+ ts: LONG,
+ pname: STR_NULL,
+ pid: NUM_NULL,
+ });
+ it.valid();
+ it.next()
+ ) {
+ // hprof captures often have no associated process row in the trace,
+ // so `pid` may be NULL. Preserve the absence — `pid: 0` would render
+ // as a real PID and mislead the user. Downstream label helpers know
+ // how to render a null pid.
+ out.push({
+ upid: it.upid,
+ ts: it.ts,
+ processName: it.pname,
+ pid: it.pid,
+ });
+ }
+ return out;
+}
+
+/**
+ * Reads the file in CHUNK_SIZE slices and feeds each into engine.parse().
+ * Mirrors `core/load_trace.ts:206-220` behavior with progress callbacks.
+ */
+async function streamFileIntoEngine(
+ file: File,
+ engine: WasmEngineProxy,
+ onProgress?: (p: LoadProgress) => void,
+): Promise {
+ const total = file.size;
+ let offset = 0;
+ while (offset < total) {
+ const end = Math.min(offset + CHUNK_SIZE, total);
+ const slice = file.slice(offset, end);
+ const buf = await slice.arrayBuffer();
+ await engine.parse(new Uint8Array(buf));
+ offset = end;
+ onProgress?.({bytesRead: offset, bytesTotal: total});
+ }
+}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/baseline/state.ts b/ui/src/plugins/com.android.HeapDumpExplorer/baseline/state.ts
new file mode 100644
index 00000000000..1eac0cf10de
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/baseline/state.ts
@@ -0,0 +1,205 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Multi-trace baseline pool. Each pooled trace owns its own
+// WasmEngineProxy. The active baseline is one (trace, dump) ref;
+// diff views read it via `getActiveBaseline()` and re-render on change.
+
+import m from 'mithril';
+import type {Engine} from '../../../trace_processor/engine';
+import type {HeapDump} from '../queries';
+import {clearDiffRows} from '../diff/diff_debug';
+
+export interface BaselineTrace {
+ readonly id: string;
+ readonly engine: Engine;
+ readonly title: string;
+ readonly dumps: ReadonlyArray;
+ // False for the synthetic "self" entry whose engine is the primary
+ // trace's own — disposing it would tear down the primary. Defaults to
+ // true (real pooled baselines own their workers).
+ readonly disposable?: boolean;
+}
+
+export interface BaselineDumpRef {
+ readonly trace: BaselineTrace;
+ readonly dump: HeapDump;
+}
+
+export type DiffMode = 'diff' | 'current' | 'baseline';
+
+let traces: BaselineTrace[] = [];
+let active: BaselineDumpRef | null = null;
+let mode: DiffMode = 'diff';
+let nextTraceId = 1;
+
+export function getBaselineTraces(): ReadonlyArray {
+ return traces;
+}
+
+export function getActiveBaseline(): BaselineDumpRef | null {
+ return active;
+}
+
+export function getMode(): DiffMode {
+ return mode;
+}
+
+export function setMode(m: DiffMode): void {
+ mode = m;
+ redraw();
+}
+
+export function isDiffActive(): boolean {
+ return active !== null && mode === 'diff';
+}
+
+// "1=1" when no active baseline so callers can splice unconditionally
+// into a WHERE clause.
+export function baselineDumpFilterSql(alias: string = 'o'): string {
+ if (!active) return '1=1';
+ const d = active.dump;
+ return `${alias}.upid = ${d.upid} AND ${alias}.graph_sample_ts = ${d.ts}`;
+}
+
+export function addBaselineTrace(
+ engine: Engine,
+ title: string,
+ dumps: ReadonlyArray,
+): BaselineTrace {
+ const t: BaselineTrace = {
+ id: `btrace-${nextTraceId++}`,
+ engine,
+ title,
+ dumps,
+ };
+ traces = [...traces, t];
+ redraw();
+ return t;
+}
+
+// Pick a dump from the primary trace itself as the baseline. Lazily
+// registers a singleton self-baseline entry per primary engine — same
+// engine is reused for both sides; queries serialize on its single worker
+// but independent filter SQL still gives correct (different) results.
+//
+// The engine here is a per-plugin proxy and is freshly minted on every
+// access from `trace.engine`, so we cannot use reference equality to
+// dedupe — `disposable === false` is the singleton signal we own.
+export function setSelfTraceBaseline(
+ engine: Engine,
+ title: string,
+ dumps: ReadonlyArray,
+ dump: HeapDump,
+): void {
+ let self = traces.find((t) => t.disposable === false);
+ if (!self) {
+ self = {
+ id: `btrace-${nextTraceId++}`,
+ engine,
+ title,
+ dumps,
+ disposable: false,
+ };
+ traces = [...traces, self];
+ }
+ setActiveBaseline({trace: self, dump});
+}
+
+// Picking a dump flips back into 'diff' mode. To deselect call
+// clearActiveBaseline; null is intentionally not accepted here so callers
+// can't bypass clearDiffRows().
+export function setActiveBaseline(b: BaselineDumpRef): void {
+ if (active !== null && active.trace === b.trace && active.dump === b.dump) {
+ return;
+ }
+ active = b;
+ mode = 'diff';
+ redraw();
+}
+
+export function clearActiveBaseline(): void {
+ if (!active) return;
+ active = null;
+ clearDiffRows();
+ redraw();
+}
+
+export function removeBaselineTrace(traceId: string): void {
+ const t = traces.find((x) => x.id === traceId);
+ if (!t) return;
+ if (active && active.trace === t) {
+ active = null;
+ clearDiffRows();
+ }
+ traces = traces.filter((x) => x.id !== traceId);
+ disposeEngine(t);
+ redraw();
+}
+
+// Clears state BEFORE disposing engines so any in-flight fetch sees
+// active === null after its await and abandons the merge.
+export function dispose(): void {
+ if (traces.length === 0 && !active) return;
+ const old = traces;
+ active = null;
+ traces = [];
+ clearDiffRows();
+ for (const t of old) disposeEngine(t);
+ redraw();
+}
+
+function disposeEngine(t: BaselineTrace): void {
+ if (t.disposable === false) return;
+ try {
+ (t.engine as unknown as Disposable)[Symbol.dispose]();
+ } catch (e) {
+ console.error('Error disposing baseline engine:', e);
+ }
+}
+
+function redraw(): void {
+ m.redraw();
+}
+
+// window.__heapdumpDebug — Playwright surface, not consumed by app code.
+
+export interface HeapdumpDebugApi {
+ hasBaseline(): boolean;
+ baselineFilename(): string | null;
+ mode(): DiffMode;
+ poolSize(): number;
+ pickBaseline(title: string): boolean;
+}
+
+declare global {
+ interface Window {
+ __heapdumpDebug?: HeapdumpDebugApi;
+ }
+}
+
+if (typeof window !== 'undefined') {
+ window.__heapdumpDebug = {
+ hasBaseline: () => active !== null,
+ baselineFilename: () => active?.trace.title ?? null,
+ mode: () => mode,
+ poolSize: () => traces.length,
+ pickBaseline: (title) => {
+ const t = traces.find((x) => x.title === title);
+ if (!t || t.dumps.length === 0) return false;
+ setActiveBaseline({trace: t, dump: t.dumps[0]});
+ return true;
+ },
+ };
+}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_debug.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_debug.ts
new file mode 100644
index 00000000000..703c6e9ffe3
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_debug.ts
@@ -0,0 +1,63 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import type {DiffRow} from './diff_rows';
+
+export type DiffViewName =
+ | 'classes'
+ | 'strings'
+ | 'arrays'
+ | 'bitmaps'
+ | 'dominators'
+ | 'overview';
+
+interface Snapshot {
+ readonly gen: number;
+ readonly rows: ReadonlyArray;
+}
+
+const snapshots = new Map();
+let nextGen = 1;
+
+export function publishDiffRows(
+ view: DiffViewName,
+ rows: ReadonlyArray,
+): void {
+ snapshots.set(view, {gen: nextGen++, rows});
+}
+
+export function clearDiffRows(view?: DiffViewName): void {
+ if (view) snapshots.delete(view);
+ else snapshots.clear();
+}
+
+export interface HeapdumpDiffDebugApi {
+ rows(view: DiffViewName): ReadonlyArray | null;
+ gen(view: DiffViewName): number;
+ views(): DiffViewName[];
+}
+
+declare global {
+ interface Window {
+ __heapdumpDiff?: HeapdumpDiffDebugApi;
+ }
+}
+
+if (typeof window !== 'undefined') {
+ window.__heapdumpDiff = {
+ rows: (view) => snapshots.get(view)?.rows ?? null,
+ gen: (view) => snapshots.get(view)?.gen ?? 0,
+ views: () => Array.from(snapshots.keys()),
+ };
+}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_rows.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_rows.ts
new file mode 100644
index 00000000000..70b0948e7fe
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_rows.ts
@@ -0,0 +1,237 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// JS outer-join for diff views: one row per key with baseline / current /
+// delta columns plus a status. Cross-engine SQL JOIN isn't possible
+// (each baseline trace lives in its own Worker / sqlite), so the merge
+// runs in JS over already-aggregated rows.
+
+import type {Row, SqlValue} from '../../../trace_processor/query_result';
+
+export type Num = number | bigint;
+
+export type DiffStatus = 'NEW' | 'REMOVED' | 'GREW' | 'SHRANK' | 'UNCHANGED';
+
+export const KEY_COL = 'key';
+export const STATUS_COL = 'status';
+export const baselineCol = (field: string) => `_b_${field}`;
+export const currentCol = (field: string) => `_c_${field}`;
+export const deltaCol = (field: string) => `_d_${field}`;
+
+export interface MergeOptions {
+ readonly baseline: readonly T[];
+ readonly current: readonly T[];
+ readonly keyOf: (row: T) => string;
+ readonly numericFields: readonly string[];
+ // Non-numeric columns to copy through (preferring current side).
+ readonly passThroughFields?: readonly string[];
+ readonly primaryDeltaField: string;
+ // |Δ(primaryDeltaField)| ≤ threshold → UNCHANGED. Defaults to 0.
+ readonly statusThreshold?: Num;
+}
+
+export interface DiffRow extends Row {
+ readonly [KEY_COL]: string;
+ readonly [STATUS_COL]: DiffStatus;
+ readonly [field: string]: SqlValue;
+}
+
+// Outer-join `baseline` and `current` by `keyOf`. Throws on duplicate keys
+// (inputs must already be aggregated by the join key — see `dedupeByKey`).
+export function mergeRows(opts: MergeOptions): DiffRow[] {
+ const {
+ baseline,
+ current,
+ keyOf,
+ numericFields,
+ passThroughFields = [],
+ primaryDeltaField,
+ statusThreshold,
+ } = opts;
+
+ if (!numericFields.includes(primaryDeltaField)) {
+ throw new Error(
+ `mergeRows: primaryDeltaField '${primaryDeltaField}' must be in numericFields`,
+ );
+ }
+
+ const baselineMap = indexByKey(baseline, keyOf, 'baseline');
+ const currentMap = indexByKey(current, keyOf, 'current');
+
+ const allKeys = new Set();
+ for (const k of baselineMap.keys()) allKeys.add(k);
+ for (const k of currentMap.keys()) allKeys.add(k);
+
+ const result: DiffRow[] = [];
+ for (const key of allKeys) {
+ const b = baselineMap.get(key);
+ const c = currentMap.get(key);
+
+ const row: Record = {
+ [KEY_COL]: key,
+ [STATUS_COL]: classify(b, c, primaryDeltaField, statusThreshold ?? 0),
+ };
+
+ for (const field of numericFields) {
+ const bv = numericOrNull(b, field);
+ const cv = numericOrNull(c, field);
+ row[baselineCol(field)] = bv as SqlValue;
+ row[currentCol(field)] = cv as SqlValue;
+ row[deltaCol(field)] = delta(bv, cv) as SqlValue;
+ }
+
+ for (const field of passThroughFields) {
+ row[field] = (c?.[field] ?? b?.[field] ?? null) as SqlValue;
+ }
+
+ result.push(row as DiffRow);
+ }
+
+ return result;
+}
+
+// b - a. Coerces to bigint if either side is bigint (heap sizes can
+// exceed 2^53). Treats null as 0.
+export function delta(a: Num | null, b: Num | null): Num {
+ if (a == null && b == null) return 0;
+ if (typeof a === 'bigint' || typeof b === 'bigint') {
+ return toBigInt(b) - toBigInt(a);
+ }
+ return ((b as number) ?? 0) - ((a as number) ?? 0);
+}
+
+export function abs(v: Num): Num {
+ if (typeof v === 'bigint') return v < 0n ? -v : v;
+ return Math.abs(v);
+}
+
+export function compareNum(a: Num | null, b: Num | null): number {
+ if (a == null && b == null) return 0;
+ if (a == null) return -1;
+ if (b == null) return 1;
+ if (typeof a === 'bigint' || typeof b === 'bigint') {
+ const ab = toBigInt(a);
+ const bb = toBigInt(b);
+ return ab < bb ? -1 : ab > bb ? 1 : 0;
+ }
+ const an = a as number;
+ const bn = b as number;
+ return an < bn ? -1 : an > bn ? 1 : 0;
+}
+
+function toBigInt(v: Num | null): bigint {
+ if (v == null) return 0n;
+ if (typeof v === 'bigint') return v;
+ if (Number.isInteger(v)) return BigInt(v);
+ return BigInt(Math.trunc(v));
+}
+
+function numericOrNull(row: Row | undefined, field: string): Num | null {
+ if (!row) return null;
+ const v = row[field];
+ if (v == null) return null;
+ if (typeof v === 'number' || typeof v === 'bigint') return v;
+ if (typeof v === 'string') {
+ const n = Number(v);
+ if (!Number.isNaN(n)) return n;
+ }
+ return null;
+}
+
+// Sum-merges duplicate keys before `mergeRows`. Use when SQL GROUP BY
+// can't fully unique-ify the join key (e.g. type_name shared across
+// classloaders).
+export function dedupeByKey(
+ rows: readonly T[],
+ keyOf: (r: T) => string,
+ numericFields: readonly string[],
+): T[] {
+ const map = new Map();
+ for (const r of rows) {
+ const k = keyOf(r);
+ const existing = map.get(k);
+ if (existing === undefined) {
+ map.set(k, {...r});
+ continue;
+ }
+ const merged = existing as Record;
+ for (const f of numericFields) {
+ const a = numericOrNull(existing, f);
+ const b = numericOrNull(r, f);
+ if (a == null && b == null) {
+ merged[f] = null;
+ } else if (typeof a === 'bigint' || typeof b === 'bigint') {
+ merged[f] = (toBigInt(a) + toBigInt(b)) as SqlValue;
+ } else {
+ merged[f] = ((a ?? 0) as number) + ((b ?? 0) as number);
+ }
+ }
+ }
+ return Array.from(map.values());
+}
+
+function indexByKey(
+ rows: readonly T[],
+ keyOf: (r: T) => string,
+ side: string,
+): Map {
+ const map = new Map();
+ for (const r of rows) {
+ const k = keyOf(r);
+ if (map.has(k)) {
+ throw new Error(
+ `mergeRows: duplicate key '${k}' on ${side} side. Both inputs must ` +
+ `be pre-aggregated by the join key.`,
+ );
+ }
+ map.set(k, r);
+ }
+ return map;
+}
+
+function classify(
+ b: Row | undefined,
+ c: Row | undefined,
+ field: string,
+ threshold: Num,
+): DiffStatus {
+ // NEW/REMOVED comes from row presence, never value==0 — a class with
+ // delta=0 that exists on both sides is UNCHANGED, not NEW.
+ if (b === undefined && c === undefined) return 'UNCHANGED';
+ if (b === undefined) return 'NEW';
+ if (c === undefined) return 'REMOVED';
+ const d = delta(numericOrNull(b, field), numericOrNull(c, field));
+ if (compareAbs(d, threshold) <= 0) return 'UNCHANGED';
+ return compareNum(d, 0) > 0 ? 'GREW' : 'SHRANK';
+}
+
+function compareAbs(a: Num, b: Num): number {
+ return compareNum(abs(a), abs(b));
+}
+
+// Sort by |Δ| desc, ties broken by key.
+export function compareByAbsDeltaDesc(
+ primaryDeltaField: string,
+): (a: DiffRow, b: DiffRow) => number {
+ const col = deltaCol(primaryDeltaField);
+ return (a, b) => {
+ const av = a[col] as Num | null;
+ const bv = b[col] as Num | null;
+ const aa = av == null ? 0n : abs(av as Num);
+ const bb = bv == null ? 0n : abs(bv as Num);
+ const cmp = compareNum(bb, aa);
+ if (cmp !== 0) return cmp;
+ return String(a[KEY_COL]).localeCompare(String(b[KEY_COL]));
+ };
+}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_rows_unittest.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_rows_unittest.ts
new file mode 100644
index 00000000000..54da413b576
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_rows_unittest.ts
@@ -0,0 +1,310 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Unit tests for the pure JS-side diff merger. No engine, no Mithril.
+
+import type {DiffRow} from './diff_rows';
+import {
+ abs,
+ baselineCol,
+ compareByAbsDeltaDesc,
+ compareNum,
+ currentCol,
+ delta,
+ deltaCol,
+ KEY_COL,
+ mergeRows,
+ STATUS_COL,
+} from './diff_rows';
+
+describe('delta', () => {
+ it('handles two numbers', () => {
+ expect(delta(5, 8)).toBe(3);
+ expect(delta(8, 5)).toBe(-3);
+ expect(delta(0, 0)).toBe(0);
+ });
+
+ it('treats null as 0', () => {
+ expect(delta(null, 5)).toBe(5);
+ expect(delta(5, null)).toBe(-5);
+ expect(delta(null, null)).toBe(0);
+ });
+
+ it('promotes to bigint when either side is bigint', () => {
+ expect(delta(5n, 8n)).toBe(3n);
+ expect(delta(5, 8n)).toBe(3n);
+ expect(delta(8n, 5)).toBe(-3n);
+ expect(delta(null, 5n)).toBe(5n);
+ expect(delta(5n, null)).toBe(-5n);
+ });
+
+ it('preserves precision above 2^53 for bigint', () => {
+ const huge = 1n << 60n;
+ expect(delta(0n, huge)).toBe(huge);
+ expect(delta(huge, 2n * huge)).toBe(huge);
+ // Compare against the bigint result, not number-truncated value.
+ expect(delta(huge, huge + 1n)).toBe(1n);
+ });
+});
+
+describe('compareNum', () => {
+ it('orders numbers correctly', () => {
+ expect(compareNum(1, 2)).toBe(-1);
+ expect(compareNum(2, 1)).toBe(1);
+ expect(compareNum(1, 1)).toBe(0);
+ });
+
+ it('orders bigints correctly', () => {
+ expect(compareNum(1n, 2n)).toBe(-1);
+ expect(compareNum(2n, 1n)).toBe(1);
+ expect(compareNum(1n, 1n)).toBe(0);
+ });
+
+ it('mixes number and bigint without throwing', () => {
+ expect(compareNum(1, 2n)).toBe(-1);
+ expect(compareNum(2n, 1)).toBe(1);
+ });
+
+ it('treats null as smallest', () => {
+ expect(compareNum(null, 1)).toBe(-1);
+ expect(compareNum(1, null)).toBe(1);
+ expect(compareNum(null, null)).toBe(0);
+ });
+});
+
+describe('abs', () => {
+ it('handles number', () => {
+ expect(abs(5)).toBe(5);
+ expect(abs(-5)).toBe(5);
+ expect(abs(0)).toBe(0);
+ });
+ it('handles bigint', () => {
+ expect(abs(5n)).toBe(5n);
+ expect(abs(-5n)).toBe(5n);
+ expect(abs(0n)).toBe(0n);
+ });
+});
+
+describe('mergeRows', () => {
+ const opts = {
+ keyOf: (r: {cls: string}) => r.cls,
+ numericFields: ['cnt', 'retained'],
+ primaryDeltaField: 'retained',
+ };
+
+ it('produces UNCHANGED for identical rows', () => {
+ const baseline = [{cls: 'Foo', cnt: 10, retained: 1000}];
+ const current = [{cls: 'Foo', cnt: 10, retained: 1000}];
+ const out = mergeRows({baseline, current, ...opts});
+ expect(out).toHaveLength(1);
+ expect(out[0][KEY_COL]).toBe('Foo');
+ expect(out[0][STATUS_COL]).toBe('UNCHANGED');
+ expect(out[0][deltaCol('retained')]).toBe(0);
+ expect(out[0][baselineCol('retained')]).toBe(1000);
+ expect(out[0][currentCol('retained')]).toBe(1000);
+ });
+
+ it('flags GREW when current > baseline', () => {
+ const baseline = [{cls: 'Foo', cnt: 10, retained: 1000}];
+ const current = [{cls: 'Foo', cnt: 12, retained: 1500}];
+ const out = mergeRows({baseline, current, ...opts});
+ expect(out[0][STATUS_COL]).toBe('GREW');
+ expect(out[0][deltaCol('retained')]).toBe(500);
+ expect(out[0][deltaCol('cnt')]).toBe(2);
+ });
+
+ it('flags SHRANK when current < baseline', () => {
+ const baseline = [{cls: 'Foo', cnt: 10, retained: 1500}];
+ const current = [{cls: 'Foo', cnt: 8, retained: 1000}];
+ const out = mergeRows({baseline, current, ...opts});
+ expect(out[0][STATUS_COL]).toBe('SHRANK');
+ expect(out[0][deltaCol('retained')]).toBe(-500);
+ });
+
+ it('flags NEW when row only in current', () => {
+ const out = mergeRows({
+ baseline: [],
+ current: [{cls: 'Foo', cnt: 5, retained: 500}],
+ ...opts,
+ });
+ expect(out[0][STATUS_COL]).toBe('NEW');
+ expect(out[0][baselineCol('retained')]).toBeNull();
+ expect(out[0][currentCol('retained')]).toBe(500);
+ expect(out[0][deltaCol('retained')]).toBe(500);
+ });
+
+ it('flags REMOVED when row only in baseline', () => {
+ const out = mergeRows({
+ baseline: [{cls: 'Foo', cnt: 5, retained: 500}],
+ current: [],
+ ...opts,
+ });
+ expect(out[0][STATUS_COL]).toBe('REMOVED');
+ expect(out[0][baselineCol('retained')]).toBe(500);
+ expect(out[0][currentCol('retained')]).toBeNull();
+ expect(out[0][deltaCol('retained')]).toBe(-500);
+ });
+
+ it('classifies present-on-both rows by delta, not by zero-on-one-side', () => {
+ // A class present in both snapshots with dominated_size_bytes=0 in
+ // baseline (common — many classes are reachable but not dominators of
+ // anything substantial) must be GREW, not NEW. NEW is reserved for
+ // rows that did not exist in the baseline input at all.
+ const out = mergeRows({
+ baseline: [{cls: 'Foo', cnt: 5, retained: 0}],
+ current: [{cls: 'Foo', cnt: 100, retained: 1000}],
+ ...opts,
+ });
+ expect(out[0][STATUS_COL]).toBe('GREW');
+ });
+
+ it('handles empty inputs on both sides', () => {
+ expect(mergeRows({baseline: [], current: [], ...opts})).toEqual([]);
+ });
+
+ it('produces NEW + REMOVED + UNCHANGED in mixed input', () => {
+ const baseline = [
+ {cls: 'A', cnt: 1, retained: 100},
+ {cls: 'B', cnt: 2, retained: 200},
+ {cls: 'C', cnt: 3, retained: 300},
+ ];
+ const current = [
+ {cls: 'A', cnt: 1, retained: 100}, // unchanged
+ {cls: 'C', cnt: 5, retained: 500}, // grew
+ {cls: 'D', cnt: 1, retained: 100}, // new
+ // B removed
+ ];
+ const out = mergeRows({baseline, current, ...opts});
+ const byKey = new Map(out.map((r) => [String(r[KEY_COL]), r]));
+ expect(byKey.get('A')?.[STATUS_COL]).toBe('UNCHANGED');
+ expect(byKey.get('B')?.[STATUS_COL]).toBe('REMOVED');
+ expect(byKey.get('C')?.[STATUS_COL]).toBe('GREW');
+ expect(byKey.get('D')?.[STATUS_COL]).toBe('NEW');
+ });
+
+ it('mixes bigint and number across sides without throwing', () => {
+ const baseline = [{cls: 'Foo', cnt: 10n, retained: 1000n}];
+ const current = [{cls: 'Foo', cnt: 12, retained: 1500}];
+ const out = mergeRows({baseline, current, ...opts});
+ expect(out[0][STATUS_COL]).toBe('GREW');
+ // Both sides coerced to bigint when one is.
+ expect(out[0][deltaCol('retained')]).toBe(500n);
+ });
+
+ it('preserves precision above 2^53 with bigint', () => {
+ const huge = 1n << 55n; // > 2^53
+ const baseline = [{cls: 'Foo', cnt: 1n, retained: huge}];
+ const current = [{cls: 'Foo', cnt: 1n, retained: huge + 1n}];
+ const out = mergeRows({baseline, current, ...opts});
+ expect(out[0][deltaCol('retained')]).toBe(1n);
+ });
+
+ it('throws on duplicate keys (defensive)', () => {
+ const baseline = [
+ {cls: 'Foo', cnt: 1, retained: 100},
+ {cls: 'Foo', cnt: 2, retained: 200},
+ ];
+ expect(() => mergeRows({baseline, current: [], ...opts})).toThrow(
+ /duplicate key/,
+ );
+ });
+
+ it('throws if primaryDeltaField not in numericFields', () => {
+ expect(() =>
+ mergeRows({
+ baseline: [],
+ current: [],
+ keyOf: (r: {cls: string}) => r.cls,
+ numericFields: ['cnt'],
+ primaryDeltaField: 'retained',
+ }),
+ ).toThrow(/primaryDeltaField/);
+ });
+
+ it('handles non-ASCII Kotlin lambda class names', () => {
+ const baseline = [
+ {cls: '$$Lambda$Foo$abc', cnt: 1, retained: 100},
+ {cls: 'kotlin.coroutines.intrinsics. Suspended', cnt: 1, retained: 100},
+ ];
+ const current = [
+ {cls: '$$Lambda$Foo$abc', cnt: 2, retained: 200},
+ {cls: 'kotlin.coroutines.intrinsics. Suspended', cnt: 1, retained: 100},
+ ];
+ const out = mergeRows({baseline, current, ...opts});
+ expect(out).toHaveLength(2);
+ const lambda = out.find((r) => String(r[KEY_COL]).includes('Lambda'));
+ expect(lambda?.[STATUS_COL]).toBe('GREW');
+ expect(lambda?.[deltaCol('retained')]).toBe(100);
+ });
+
+ it('passes through extra string fields preferring current', () => {
+ const baseline = [{cls: 'Foo', cnt: 1, retained: 100, label: 'old'}];
+ const current = [{cls: 'Foo', cnt: 2, retained: 200, label: 'new'}];
+ const out = mergeRows({
+ baseline,
+ current,
+ ...opts,
+ passThroughFields: ['label'],
+ });
+ expect(out[0].label).toBe('new');
+ });
+
+ it('passes through extra fields falling back to baseline', () => {
+ const baseline = [{cls: 'Foo', cnt: 1, retained: 100, label: 'kept'}];
+ const current: typeof baseline = [];
+ const out = mergeRows({
+ baseline,
+ current,
+ ...opts,
+ passThroughFields: ['label'],
+ });
+ expect(out[0].label).toBe('kept');
+ });
+});
+
+describe('compareByAbsDeltaDesc', () => {
+ it('sorts by |delta| descending, ties broken by key', () => {
+ const rows = [
+ {[KEY_COL]: 'small', [deltaCol('retained')]: 100, [STATUS_COL]: 'GREW'},
+ {
+ [KEY_COL]: 'big-grew',
+ [deltaCol('retained')]: 1000,
+ [STATUS_COL]: 'GREW',
+ },
+ {
+ [KEY_COL]: 'big-shrank',
+ [deltaCol('retained')]: -1000,
+ [STATUS_COL]: 'SHRANK',
+ },
+ {[KEY_COL]: 'zero', [deltaCol('retained')]: 0, [STATUS_COL]: 'UNCHANGED'},
+ ] as DiffRow[];
+ rows.sort(compareByAbsDeltaDesc('retained'));
+ expect(rows.map((r) => String(r[KEY_COL]))).toEqual([
+ 'big-grew', // ties at |1000|; localeCompare puts 'big-grew' before 'big-shrank'
+ 'big-shrank',
+ 'small',
+ 'zero',
+ ]);
+ });
+
+ it('handles bigint deltas', () => {
+ const huge = 1n << 60n;
+ const rows = [
+ {[KEY_COL]: 'a', [deltaCol('retained')]: huge, [STATUS_COL]: 'GREW'},
+ {[KEY_COL]: 'b', [deltaCol('retained')]: huge - 1n, [STATUS_COL]: 'GREW'},
+ ] as DiffRow[];
+ rows.sort(compareByAbsDeltaDesc('retained'));
+ expect(String(rows[0][KEY_COL])).toBe('a');
+ });
+});
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_schemas.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_schemas.ts
new file mode 100644
index 00000000000..e92f4fef2dd
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/diff_schemas.ts
@@ -0,0 +1,250 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Cell renderers + schema factories for diff views. Plain coloured text,
+// no pills or chips. aria-labels carry the sign in words so colour isn't
+// the only cue.
+
+import m from 'mithril';
+import type {
+ CellRenderResult,
+ CellRenderer,
+ ColumnDef,
+ SchemaRegistry,
+} from '../../../components/widgets/datagrid/datagrid_schema';
+import type {SqlValue} from '../../../trace_processor/query_result';
+import {fmtSize} from '../format';
+import type {DiffStatus, Num} from './diff_rows';
+import {
+ KEY_COL,
+ STATUS_COL,
+ baselineCol,
+ currentCol,
+ deltaCol,
+} from './diff_rows';
+
+const STATUS_LABEL: Record = {
+ NEW: 'NEW',
+ REMOVED: 'REMOVED',
+ GREW: 'GREW',
+ SHRANK: 'SHRANK',
+ UNCHANGED: '',
+};
+
+const STATUS_CLASS: Record = {
+ NEW: 'ah-status-text ah-status-text--new',
+ REMOVED: 'ah-status-text ah-status-text--removed',
+ GREW: 'ah-status-text ah-status-text--grew',
+ SHRANK: 'ah-status-text ah-status-text--shrank',
+ UNCHANGED: 'ah-status-text ah-status-text--unchanged',
+};
+
+export const statusRenderer: CellRenderer = (
+ value: SqlValue,
+): CellRenderResult => {
+ const s = String(value ?? 'UNCHANGED') as DiffStatus;
+ const label = STATUS_LABEL[s] ?? s;
+ if (label === '') {
+ return {content: m('span'), align: 'left'};
+ }
+ return {
+ content: m(
+ 'span',
+ {
+ 'class': STATUS_CLASS[s] ?? 'ah-status-text',
+ 'aria-label': `Status: ${label}`,
+ },
+ label,
+ ),
+ align: 'left',
+ };
+};
+
+export const deltaSizeRenderer: CellRenderer = (
+ value: SqlValue,
+): CellRenderResult => {
+ return renderDelta(value, (n) => fmtSize(Number(n)));
+};
+
+export const deltaCountRenderer: CellRenderer = (
+ value: SqlValue,
+): CellRenderResult => {
+ return renderDelta(value, (n) => Math.abs(Number(n)).toLocaleString());
+};
+
+function renderDelta(
+ value: SqlValue,
+ fmtMagnitude: (n: Num) => string,
+): CellRenderResult {
+ const n = toNum(value);
+ if (n == null) {
+ return {
+ content: m('span', {class: 'ah-mono ah-muted'}, '—'),
+ align: 'right',
+ };
+ }
+ const sign = compareToZero(n);
+ if (sign === 0) {
+ return {
+ content: m(
+ 'span',
+ {'class': 'ah-mono ah-delta--zero', 'aria-label': 'No change'},
+ '0',
+ ),
+ align: 'right',
+ };
+ }
+ const symbol = sign > 0 ? '+' : '−';
+ const cls = sign > 0 ? 'ah-mono ah-delta--grew' : 'ah-mono ah-delta--shrank';
+ const word = sign > 0 ? 'increased by' : 'decreased by';
+ const magnitude = fmtMagnitude(absNum(n));
+ return {
+ content: m(
+ 'span',
+ {'class': cls, 'aria-label': `${word} ${magnitude}`},
+ `${symbol}${magnitude}`,
+ ),
+ align: 'right',
+ };
+}
+
+// null → "—" so empty cells are visible (the column has data on the
+// other side).
+export const sideSizeRenderer: CellRenderer = (
+ value: SqlValue,
+): CellRenderResult => {
+ const n = toNum(value);
+ if (n == null) {
+ return {
+ content: m('span', {class: 'ah-mono ah-muted'}, '—'),
+ align: 'right',
+ };
+ }
+ return {
+ content: m('span', {class: 'ah-mono'}, fmtSize(Number(n))),
+ align: 'right',
+ };
+};
+
+export const sideCountRenderer: CellRenderer = (
+ value: SqlValue,
+): CellRenderResult => {
+ const n = toNum(value);
+ if (n == null) {
+ return {
+ content: m('span', {class: 'ah-mono ah-muted'}, '—'),
+ align: 'right',
+ };
+ }
+ return {
+ content: m('span', {class: 'ah-mono'}, Number(n).toLocaleString()),
+ align: 'right',
+ };
+};
+
+function toNum(v: SqlValue): Num | null {
+ if (v == null) return null;
+ if (typeof v === 'number' || typeof v === 'bigint') return v;
+ if (typeof v === 'string') {
+ const n = Number(v);
+ if (!Number.isNaN(n)) return n;
+ }
+ return null;
+}
+
+function compareToZero(n: Num): number {
+ if (typeof n === 'bigint') return n < 0n ? -1 : n > 0n ? 1 : 0;
+ return n < 0 ? -1 : n > 0 ? 1 : 0;
+}
+
+function absNum(n: Num): Num {
+ if (typeof n === 'bigint') return n < 0n ? -n : n;
+ return Math.abs(n);
+}
+
+// `field` is the raw column name in the merged DiffRow; `title` is the
+// user-facing label.
+export interface DiffNumericField {
+ readonly field: string;
+ readonly title: string;
+ readonly kind: 'size' | 'count';
+}
+
+export function buildSizeCountSchema(opts: {
+ readonly keyTitle: string;
+ readonly keyRenderer?: CellRenderer;
+ readonly size: DiffNumericField;
+ readonly count: DiffNumericField;
+ readonly extraFields?: ReadonlyArray;
+}): SchemaRegistry {
+ const cols: Record = {
+ [KEY_COL]: {
+ title: opts.keyTitle,
+ columnType: 'text',
+ cellRenderer: opts.keyRenderer,
+ },
+ [STATUS_COL]: {
+ title: 'Status',
+ columnType: 'text',
+ cellRenderer: statusRenderer,
+ },
+ };
+ for (const ef of [opts.size, opts.count, ...(opts.extraFields ?? [])]) {
+ const sideRenderer =
+ ef.kind === 'size' ? sideSizeRenderer : sideCountRenderer;
+ const dRenderer =
+ ef.kind === 'size' ? deltaSizeRenderer : deltaCountRenderer;
+ cols[deltaCol(ef.field)] = {
+ title: `Δ ${ef.title}`,
+ columnType: 'quantitative',
+ cellRenderer: dRenderer,
+ };
+ cols[baselineCol(ef.field)] = {
+ title: `Baseline ${ef.title}`,
+ columnType: 'quantitative',
+ cellRenderer: sideRenderer,
+ };
+ cols[currentCol(ef.field)] = {
+ title: `Current ${ef.title}`,
+ columnType: 'quantitative',
+ cellRenderer: sideRenderer,
+ };
+ }
+ return {query: cols};
+}
+
+// Default sort: |Δ size| desc — biggest mover first.
+export function buildSizeCountInitialColumns(opts: {
+ readonly size: DiffNumericField;
+ readonly count: DiffNumericField;
+ readonly extraFields?: ReadonlyArray;
+}): Array<{id: string; field: string; sort?: 'ASC' | 'DESC'}> {
+ const cols: Array<{id: string; field: string; sort?: 'ASC' | 'DESC'}> = [
+ {id: KEY_COL, field: KEY_COL},
+ {id: STATUS_COL, field: STATUS_COL},
+ ];
+ let sortApplied = false;
+ for (const ef of [opts.size, opts.count, ...(opts.extraFields ?? [])]) {
+ const isPrimarySize = ef === opts.size;
+ cols.push({
+ id: deltaCol(ef.field),
+ field: deltaCol(ef.field),
+ sort: !sortApplied && isPrimarySize ? 'DESC' : undefined,
+ });
+ if (!sortApplied && isPrimarySize) sortApplied = true;
+ cols.push({id: baselineCol(ef.field), field: baselineCol(ef.field)});
+ cols.push({id: currentCol(ef.field), field: currentCol(ef.field)});
+ }
+ return cols;
+}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/object_pairing.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/object_pairing.ts
new file mode 100644
index 00000000000..4a9ab37bf97
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/object_pairing.ts
@@ -0,0 +1,206 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Per-instance pairing for the Objects diff. Within a single call this is
+// AHAT's Diff.java kernel (heapdump/Diff.java): partition both sides by an
+// equivalence-class Key (class + heap_type + value_string + array_length),
+// sort each bucket by retained size desc, then zip positionally. Leftovers
+// become NEW / REMOVED. heap_graph_object id is parser-internal — never
+// stable across traces — so we cannot use it as cross-snapshot identity.
+//
+// AHAT additionally walks the dominator tree top-down, applying this
+// kernel at each level so siblings only match if their dominators
+// matched. We approximate that recursively by user navigation: callers
+// invoke pairObjects once per scope (per-class for the Objects tab,
+// per-parent for "Immediately Dominated Objects") and the drill-down
+// chain reproduces AHAT's level-by-level walk on demand.
+
+import type {DiffStatus} from './diff_rows';
+
+export interface ObjectRowRaw {
+ readonly id: number;
+ readonly className: string;
+ readonly heapType: string | null;
+ readonly valueString: string | null;
+ readonly arrayLength: number | null;
+ readonly shallow: number;
+ readonly shallowNative: number;
+ readonly retained: number;
+ readonly retainedNative: number;
+ readonly retainedCount: number;
+}
+
+export interface ObjectPairRow {
+ readonly key: string;
+ readonly status: DiffStatus;
+ readonly className: string;
+ readonly heapType: string | null;
+ readonly valueString: string | null;
+ readonly c_id: number | null;
+ readonly b_id: number | null;
+ readonly c_shallow: number | null;
+ readonly b_shallow: number | null;
+ readonly c_shallow_native: number | null;
+ readonly b_shallow_native: number | null;
+ readonly c_retained: number | null;
+ readonly b_retained: number | null;
+ readonly c_retained_native: number | null;
+ readonly b_retained_native: number | null;
+ readonly c_retained_count: number | null;
+ readonly b_retained_count: number | null;
+ readonly delta_retained: number;
+ readonly delta_shallow: number;
+}
+
+function bucketKey(r: ObjectRowRaw): string {
+ // \x1f as field separator avoids collisions when any field contains the
+ // empty string, '|', or other characters a user-controlled value_string
+ // could include.
+ return [
+ r.className,
+ r.heapType ?? '',
+ r.valueString ?? '',
+ r.arrayLength ?? '',
+ ].join('\x1f');
+}
+
+function compareByRetainedDesc(a: ObjectRowRaw, b: ObjectRowRaw): number {
+ // Tie-break by id for deterministic order within an engine. Cross-trace
+ // ties remain inherently unstable (different parser ids).
+ if (b.retained !== a.retained) return b.retained - a.retained;
+ return a.id - b.id;
+}
+
+function classifyDelta(c: number, b: number): DiffStatus {
+ if (c > b) return 'GREW';
+ if (c < b) return 'SHRANK';
+ return 'UNCHANGED';
+}
+
+/**
+ * Pair instances from `current` and `baseline`. Within each Key bucket,
+ * sort both sides by retained-size desc and zip positionally. Returns a
+ * flat list of pair rows — one per pair, plus one per leftover.
+ */
+export function pairObjects(
+ current: ReadonlyArray,
+ baseline: ReadonlyArray,
+): ObjectPairRow[] {
+ const cBuckets = new Map();
+ const bBuckets = new Map();
+ for (const r of current) {
+ const k = bucketKey(r);
+ let bucket = cBuckets.get(k);
+ if (!bucket) {
+ bucket = [];
+ cBuckets.set(k, bucket);
+ }
+ bucket.push(r);
+ }
+ for (const r of baseline) {
+ const k = bucketKey(r);
+ let bucket = bBuckets.get(k);
+ if (!bucket) {
+ bucket = [];
+ bBuckets.set(k, bucket);
+ }
+ bucket.push(r);
+ }
+ const allKeys = new Set();
+ for (const k of cBuckets.keys()) allKeys.add(k);
+ for (const k of bBuckets.keys()) allKeys.add(k);
+
+ const out: ObjectPairRow[] = [];
+ for (const k of allKeys) {
+ const cs = cBuckets.get(k) ?? [];
+ const bs = bBuckets.get(k) ?? [];
+ cs.sort(compareByRetainedDesc);
+ bs.sort(compareByRetainedDesc);
+ const common = Math.min(cs.length, bs.length);
+ for (let i = 0; i < common; i++) {
+ const c = cs[i];
+ const b = bs[i];
+ out.push({
+ key: `${k}\x1f${i}`,
+ status: classifyDelta(c.retained, b.retained),
+ className: c.className,
+ heapType: c.heapType,
+ valueString: c.valueString,
+ c_id: c.id,
+ b_id: b.id,
+ c_shallow: c.shallow,
+ b_shallow: b.shallow,
+ c_shallow_native: c.shallowNative,
+ b_shallow_native: b.shallowNative,
+ c_retained: c.retained,
+ b_retained: b.retained,
+ c_retained_native: c.retainedNative,
+ b_retained_native: b.retainedNative,
+ c_retained_count: c.retainedCount,
+ b_retained_count: b.retainedCount,
+ delta_retained: c.retained - b.retained,
+ delta_shallow: c.shallow - b.shallow,
+ });
+ }
+ for (let i = common; i < cs.length; i++) {
+ const c = cs[i];
+ out.push({
+ key: `${k}\x1f${i}`,
+ status: 'NEW',
+ className: c.className,
+ heapType: c.heapType,
+ valueString: c.valueString,
+ c_id: c.id,
+ b_id: null,
+ c_shallow: c.shallow,
+ b_shallow: null,
+ c_shallow_native: c.shallowNative,
+ b_shallow_native: null,
+ c_retained: c.retained,
+ b_retained: null,
+ c_retained_native: c.retainedNative,
+ b_retained_native: null,
+ c_retained_count: c.retainedCount,
+ b_retained_count: null,
+ delta_retained: c.retained,
+ delta_shallow: c.shallow,
+ });
+ }
+ for (let i = common; i < bs.length; i++) {
+ const b = bs[i];
+ out.push({
+ key: `${k}\x1f${i}`,
+ status: 'REMOVED',
+ className: b.className,
+ heapType: b.heapType,
+ valueString: b.valueString,
+ c_id: null,
+ b_id: b.id,
+ c_shallow: null,
+ b_shallow: b.shallow,
+ c_shallow_native: null,
+ b_shallow_native: b.shallowNative,
+ c_retained: null,
+ b_retained: b.retained,
+ c_retained_native: null,
+ b_retained_native: b.retainedNative,
+ c_retained_count: null,
+ b_retained_count: b.retainedCount,
+ delta_retained: -b.retained,
+ delta_shallow: -b.shallow,
+ });
+ }
+ }
+ return out;
+}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/diff/object_pairing_unittest.ts b/ui/src/plugins/com.android.HeapDumpExplorer/diff/object_pairing_unittest.ts
new file mode 100644
index 00000000000..650dc7a1aa8
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/diff/object_pairing_unittest.ts
@@ -0,0 +1,201 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {pairObjects, type ObjectRowRaw} from './object_pairing';
+
+function row(
+ opts: Partial & Pick,
+): ObjectRowRaw {
+ return {
+ className: 'C',
+ heapType: 'app',
+ valueString: null,
+ arrayLength: null,
+ shallow: 16,
+ shallowNative: 0,
+ retained: 16,
+ retainedNative: 0,
+ retainedCount: 1,
+ ...opts,
+ };
+}
+
+describe('pairObjects', () => {
+ test('empty inputs → no rows', () => {
+ expect(pairObjects([], [])).toEqual([]);
+ });
+
+ test('paired in one bucket: equal retained → UNCHANGED', () => {
+ const out = pairObjects(
+ [row({id: 1, retained: 100})],
+ [row({id: 2, retained: 100})],
+ );
+ expect(out).toHaveLength(1);
+ expect(out[0].status).toBe('UNCHANGED');
+ expect(out[0].c_id).toBe(1);
+ expect(out[0].b_id).toBe(2);
+ expect(out[0].delta_retained).toBe(0);
+ });
+
+ test('paired in one bucket: current > baseline → GREW', () => {
+ const out = pairObjects(
+ [row({id: 1, retained: 200})],
+ [row({id: 2, retained: 100})],
+ );
+ expect(out[0].status).toBe('GREW');
+ expect(out[0].delta_retained).toBe(100);
+ });
+
+ test('paired in one bucket: current < baseline → SHRANK', () => {
+ const out = pairObjects(
+ [row({id: 1, retained: 80})],
+ [row({id: 2, retained: 100})],
+ );
+ expect(out[0].status).toBe('SHRANK');
+ expect(out[0].delta_retained).toBe(-20);
+ });
+
+ test('excess on current side → NEW', () => {
+ const out = pairObjects(
+ [row({id: 1, retained: 100}), row({id: 2, retained: 50})],
+ [row({id: 9, retained: 100})],
+ );
+ expect(out).toHaveLength(2);
+ const news = out.filter((r) => r.status === 'NEW');
+ expect(news).toHaveLength(1);
+ expect(news[0].c_id).toBe(2);
+ expect(news[0].b_id).toBeNull();
+ expect(news[0].delta_retained).toBe(50);
+ });
+
+ test('excess on baseline side → REMOVED', () => {
+ const out = pairObjects(
+ [row({id: 1, retained: 100})],
+ [row({id: 8, retained: 100}), row({id: 9, retained: 50})],
+ );
+ expect(out).toHaveLength(2);
+ const gone = out.filter((r) => r.status === 'REMOVED');
+ expect(gone).toHaveLength(1);
+ expect(gone[0].c_id).toBeNull();
+ expect(gone[0].b_id).toBe(9);
+ expect(gone[0].delta_retained).toBe(-50);
+ });
+
+ test('different className → distinct buckets, no pairing', () => {
+ const out = pairObjects(
+ [row({id: 1, className: 'Foo', retained: 100})],
+ [row({id: 2, className: 'Bar', retained: 100})],
+ );
+ expect(out).toHaveLength(2);
+ expect(out.map((r) => r.status).sort()).toEqual(['NEW', 'REMOVED']);
+ });
+
+ test('different heapType → distinct buckets', () => {
+ const out = pairObjects(
+ [row({id: 1, heapType: 'app', retained: 100})],
+ [row({id: 2, heapType: 'zygote', retained: 100})],
+ );
+ expect(out).toHaveLength(2);
+ expect(out.map((r) => r.status).sort()).toEqual(['NEW', 'REMOVED']);
+ });
+
+ test('different valueString → distinct buckets', () => {
+ const out = pairObjects(
+ [row({id: 1, valueString: 'hello', retained: 40})],
+ [row({id: 2, valueString: 'world', retained: 40})],
+ );
+ expect(out.map((r) => r.status).sort()).toEqual(['NEW', 'REMOVED']);
+ });
+
+ test('different arrayLength → distinct buckets', () => {
+ const out = pairObjects(
+ [row({id: 1, arrayLength: 4, retained: 40})],
+ [row({id: 2, arrayLength: 8, retained: 40})],
+ );
+ expect(out.map((r) => r.status).sort()).toEqual(['NEW', 'REMOVED']);
+ });
+
+ test('zip is by retained-desc within a bucket', () => {
+ // Three current, two baseline. Sort desc on each side, zip positions
+ // 0,1; current[2] is the leftover NEW.
+ const out = pairObjects(
+ [
+ row({id: 1, retained: 50}),
+ row({id: 2, retained: 200}),
+ row({id: 3, retained: 100}),
+ ],
+ [row({id: 8, retained: 80}), row({id: 9, retained: 150})],
+ );
+ expect(out).toHaveLength(3);
+ const byPair = (cId: number) => out.find((r) => r.c_id === cId);
+ // Position 0: current=200, baseline=150 → paired, GREW
+ expect(byPair(2)?.status).toBe('GREW');
+ expect(byPair(2)?.b_id).toBe(9);
+ // Position 1: current=100, baseline=80 → paired, GREW
+ expect(byPair(3)?.status).toBe('GREW');
+ expect(byPair(3)?.b_id).toBe(8);
+ // Leftover current at pos 2: id=1, retained=50 → NEW
+ expect(byPair(1)?.status).toBe('NEW');
+ expect(byPair(1)?.b_id).toBeNull();
+ });
+
+ test('tie on retained → deterministic by id', () => {
+ // Two current with the same retained, two baseline same retained.
+ // Both sides sort by id ascending as tiebreaker → consistent pairing
+ // across runs.
+ const out1 = pairObjects(
+ [row({id: 5, retained: 100}), row({id: 1, retained: 100})],
+ [row({id: 9, retained: 100}), row({id: 2, retained: 100})],
+ );
+ const out2 = pairObjects(
+ [row({id: 1, retained: 100}), row({id: 5, retained: 100})],
+ [row({id: 2, retained: 100}), row({id: 9, retained: 100})],
+ );
+ const sig = (rs: ReturnType) =>
+ rs
+ .map((r) => `${r.c_id}/${r.b_id}`)
+ .sort()
+ .join(',');
+ expect(sig(out1)).toBe(sig(out2));
+ expect(sig(out1)).toBe('1/2,5/9');
+ });
+
+ test('null heapType matches null heapType', () => {
+ const out = pairObjects(
+ [row({id: 1, heapType: null, retained: 50})],
+ [row({id: 2, heapType: null, retained: 80})],
+ );
+ expect(out).toHaveLength(1);
+ expect(out[0].status).toBe('SHRANK');
+ });
+
+ test('valueString containing pipe does not collide with empty bucket', () => {
+ // Regression check: the bucket separator must be unambiguous so a
+ // user-supplied string can never look like a different bucket.
+ const out = pairObjects(
+ [row({id: 1, valueString: 'a|b', retained: 10})],
+ [row({id: 2, valueString: 'a', retained: 10, arrayLength: null})],
+ );
+ expect(out).toHaveLength(2);
+ expect(out.map((r) => r.status).sort()).toEqual(['NEW', 'REMOVED']);
+ });
+
+ test('all-zero deltas after pairing → all UNCHANGED', () => {
+ const out = pairObjects(
+ [row({id: 1}), row({id: 2})],
+ [row({id: 8}), row({id: 9})],
+ );
+ expect(out.every((r) => r.status === 'UNCHANGED')).toBe(true);
+ });
+});
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/header.ts b/ui/src/plugins/com.android.HeapDumpExplorer/header.ts
new file mode 100644
index 00000000000..8963ddb3223
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/header.ts
@@ -0,0 +1,288 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Baseline-pool controls inlined into the top bar. Renders nothing when
+// no baseline is loaded and no load is in flight — the "Diff against
+// another trace" entry point lives in Overview in that case.
+
+import m from 'mithril';
+import {Trace} from '../../public/trace';
+import {Button, ButtonVariant} from '../../widgets/button';
+import {Callout} from '../../widgets/callout';
+import {Intent} from '../../widgets/common';
+import {RadioGroup} from '../../widgets/radio_group';
+import {MenuDivider, MenuItem, PopupMenu} from '../../widgets/menu';
+import type {DiffMode, BaselineDumpRef, BaselineTrace} from './baseline/state';
+import {
+ clearActiveBaseline,
+ dispose as disposeAllBaselines,
+ getActiveBaseline,
+ getBaselineTraces,
+ getMode,
+ removeBaselineTrace,
+ setActiveBaseline,
+ setMode,
+} from './baseline/state';
+import {
+ clearLoadError,
+ getLoadState,
+ triggerFileLoad,
+} from './baseline/load_action';
+import type {HeapDump} from './queries';
+
+const MODES: ReadonlyArray<{key: DiffMode; label: string}> = [
+ {key: 'diff', label: 'Diff'},
+ {key: 'current', label: 'Current'},
+ {key: 'baseline', label: 'Baseline'},
+];
+
+const FILE_ACCEPT =
+ '.pftrace,.hprof,.perfetto-trace,.pb,.gz,application/octet-stream';
+
+/**
+ * True when the baseline header should contribute UI to the top bar. The
+ * parent uses this to decide whether to render the row at all in
+ * combination with the primary dump selector.
+ */
+export function shouldShowBaselineHeader(): boolean {
+ const traces = getBaselineTraces();
+ const {loading, error} = getLoadState();
+ const hasError = error !== null && getActiveBaseline() === null;
+ return traces.length > 0 || loading || hasError;
+}
+
+/**
+ * Triggers the hidden file input owned by the most recently mounted
+ * baseline header instance, if any. Lets the Overview-tab CTA share the
+ * same picker as the top-bar selector — no parallel hidden inputs that
+ * need separate state to coordinate.
+ */
+let openFilePickerImpl: (() => void) | null = null;
+export function openBaselineFilePicker(): void {
+ if (openFilePickerImpl) {
+ openFilePickerImpl();
+ } else {
+ requestAnimationFrame(() => openFilePickerImpl?.());
+ }
+}
+
+interface HeapDumpDiffHeaderAttrs {
+ readonly trace: Trace;
+}
+
+export class HeapDumpDiffHeader
+ implements m.ClassComponent
+{
+ private inputEl: HTMLInputElement | null = null;
+
+ oncreate() {
+ openFilePickerImpl = () => this.inputEl?.click();
+ }
+ onremove() {
+ openFilePickerImpl = null;
+ }
+ view({attrs}: m.Vnode): m.Children {
+ const traces = getBaselineTraces();
+ const active = getActiveBaseline();
+ const {loading, progressPct, error} = getLoadState();
+ const hidden = !shouldShowBaselineHeader();
+
+ // Always render the hidden input so the Overview CTA can click it.
+ const fileInput = m('input', {
+ 'type': 'file',
+ 'accept': FILE_ACCEPT,
+ 'style': 'display:none',
+ 'aria-hidden': 'true',
+ 'oncreate': (v) => {
+ this.inputEl = v.dom as HTMLInputElement;
+ },
+ 'onchange': async (e: Event) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (!file) return;
+ await triggerFileLoad(attrs.trace.raf, file);
+ if (this.inputEl) this.inputEl.value = '';
+ },
+ });
+
+ if (hidden) return fileInput;
+
+ return [
+ fileInput,
+ loading ? renderLoadingCallout(progressPct) : null,
+ error && !active ? renderErrorCallout(error) : null,
+ m('span', {class: 'ah-top-bar__label'}, 'Baseline:'),
+ renderBaselineSelector(traces, active, () => this.inputEl?.click()),
+ m('span', {class: 'ah-top-bar__spacer'}),
+ active
+ ? m(
+ RadioGroup,
+ {
+ 'selectedValue': getMode(),
+ 'onValueChange': (value: string) => setMode(value as DiffMode),
+ 'aria-label': 'Diff mode',
+ },
+ MODES.map((mode) =>
+ m(RadioGroup.Button, {value: mode.key}, mode.label),
+ ),
+ )
+ : null,
+ // One-click affordances: mirror the popup MenuItems but exposed
+ // directly in the top-bar so users can deactivate or tear down
+ // baselines without re-opening the picker. The popup keeps the
+ // same actions as the canonical entry point.
+ active
+ ? m(Button, {
+ 'icon': 'close',
+ 'compact': true,
+ 'aria-label': 'Clear active baseline',
+ 'title': 'Clear active baseline',
+ 'onclick': () => clearActiveBaseline(),
+ })
+ : null,
+ traces.length > 0
+ ? m(Button, {
+ 'icon': 'delete_sweep',
+ 'compact': true,
+ 'aria-label': 'Remove all baseline traces',
+ 'title': 'Remove all baseline traces',
+ 'onclick': () => disposeAllBaselines(),
+ })
+ : null,
+ ];
+ }
+}
+
+function renderLoadingCallout(progressPct: number): m.Children {
+ return m(
+ Callout,
+ {icon: 'hourglass_empty', intent: Intent.None},
+ `Loading baseline trace… ${progressPct}%`,
+ );
+}
+
+function renderErrorCallout(message: string): m.Children {
+ return m(
+ Callout,
+ {
+ 'icon': 'error',
+ 'intent': Intent.Danger,
+ 'dismissible': true,
+ 'onDismiss': () => clearLoadError(),
+ 'role': 'alert',
+ 'aria-live': 'assertive',
+ },
+ message,
+ );
+}
+
+function renderBaselineSelector(
+ traces: ReadonlyArray,
+ active: BaselineDumpRef | null,
+ openFilePicker: () => void,
+): m.Children {
+ const triggerLabel = active ? activeLabel(active) : 'None — pick to diff';
+ return m(
+ PopupMenu,
+ {
+ trigger: m(Button, {
+ label: triggerLabel,
+ icon: 'difference',
+ rightIcon: 'arrow_drop_down',
+ variant: ButtonVariant.Outlined,
+ compact: true,
+ }),
+ },
+ [
+ ...traces.flatMap((t) => renderTraceSection(t, active)),
+ traces.length > 0 ? m(MenuDivider) : null,
+ m(MenuItem, {
+ label: 'Add baseline trace…',
+ icon: 'upload_file',
+ onclick: openFilePicker,
+ }),
+ active
+ ? m(MenuItem, {
+ label: 'Clear active baseline',
+ icon: 'close',
+ onclick: () => clearActiveBaseline(),
+ })
+ : null,
+ ],
+ );
+}
+
+function renderTraceSection(
+ t: BaselineTrace,
+ active: BaselineDumpRef | null,
+): m.Children[] {
+ const heading = m(MenuItem, {
+ label: m('span', {class: 'ah-top-bar__section-title'}, t.title),
+ icon: 'folder_open',
+ closePopupOnClick: false,
+ onclick: () => {},
+ });
+ const dumpItems = t.dumps.map((d) =>
+ m(MenuItem, {
+ label: dumpLabel(d, t.dumps),
+ icon: active && active.trace === t && active.dump === d ? 'check' : '',
+ onclick: () => setActiveBaseline({trace: t, dump: d}),
+ }),
+ );
+ const removeItem = m(MenuItem, {
+ label: `Remove ${t.title}`,
+ icon: 'delete',
+ onclick: () => removeBaselineTrace(t.id),
+ });
+ return [heading, ...dumpItems, removeItem, m(MenuDivider)];
+}
+
+function activeLabel(b: BaselineDumpRef): string {
+ return `${b.trace.title} · ${dumpProcessLabel(b.dump)}`;
+}
+
+// Multi-dump traces show an offset from `dumps[0].ts`; baseline trace
+// times aren't commensurable with the primary's absolute start.
+function dumpLabel(d: HeapDump, dumps: ReadonlyArray): string {
+ if (dumps.length <= 1) return dumpProcessLabel(d);
+ return `${dumpProcessLabel(d)} — ${formatBaselineOffset(d, dumps)}`;
+}
+
+function dumpProcessLabel(d: HeapDump): string {
+ // hprof has no real pid — trace_processor reports 0. "pid 0" reads
+ // like kernel, so treat 0 as missing.
+ const hasPid = d.pid !== null && d.pid !== 0;
+ if (d.processName !== null && hasPid) {
+ return `${d.processName} (pid ${d.pid})`;
+ }
+ if (d.processName !== null) return d.processName;
+ if (hasPid) return `pid ${d.pid}`;
+ return 'Java heap dump';
+}
+
+// "first" / "+250ms" / "+5.2s" / "+3m 15s".
+function formatBaselineOffset(
+ d: HeapDump,
+ dumps: ReadonlyArray,
+): string {
+ const start = dumps[0].ts;
+ const deltaNs = (d.ts as bigint) - (start as bigint);
+ if (deltaNs === 0n) return 'first';
+ const ms = Number(deltaNs / 1_000_000n);
+ if (ms < 1000) return `+${ms}ms`;
+ const sec = ms / 1000;
+ if (sec < 60) return `+${sec.toFixed(1)}s`;
+ const minutes = Math.floor(sec / 60);
+ const remSec = Math.round(sec - minutes * 60);
+ return remSec === 0 ? `+${minutes}m` : `+${minutes}m ${remSec}s`;
+}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/heap_dump_page.ts b/ui/src/plugins/com.android.HeapDumpExplorer/heap_dump_page.ts
index 7391c4e42fc..f89001a2017 100644
--- a/ui/src/plugins/com.android.HeapDumpExplorer/heap_dump_page.ts
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/heap_dump_page.ts
@@ -15,12 +15,14 @@
import m from 'mithril';
import {Time} from '../../base/time';
import {Spinner} from '../../widgets/spinner';
-import {Button, ButtonVariant} from '../../widgets/button';
-import {MenuItem, PopupMenu} from '../../widgets/menu';
+
import {Tabs} from '../../widgets/tabs';
import type {TabsTab} from '../../widgets/tabs';
-import {formatDuration} from '../../components/time_utils';
+
import type {NavState, NavView} from './nav_state';
+import type {Engine} from '../../trace_processor/engine';
+
+import {EmptyState} from '../../widgets/empty_state';
import type {OverviewData} from './types';
import * as queries from './queries';
import OverviewView from './views/overview_view';
@@ -35,6 +37,22 @@ import FlamegraphObjectsView from './views/flamegraph_objects_view';
import FlamegraphView from './views/flamegraph_view';
import type {HeapDumpExplorerSession} from './session';
+import {
+ baselineDumpFilterSql,
+ dispose as disposeBaseline,
+ getActiveBaseline,
+ getMode,
+ isDiffActive,
+} from './baseline/state';
+import {TopBar} from './top_bar';
+import ClassesDiffView from './views/diff/classes_diff_view';
+import StringsDiffView from './views/diff/strings_diff_view';
+import ArraysDiffView from './views/diff/arrays_diff_view';
+import BitmapsDiffView from './views/diff/bitmaps_diff_view';
+import DominatorsDiffView from './views/diff/dominators_diff_view';
+import AllObjectsDiffView from './views/diff/all_objects_diff_view';
+import ObjectDiffView from './views/diff/object_diff_view';
+
interface HeapDumpPageAttrs {
readonly session: HeapDumpExplorerSession;
readonly subpage: string | undefined;
@@ -43,6 +61,29 @@ interface HeapDumpPageAttrs {
const FG_KEY_PREFIX = 'fg-';
const INSTANCE_KEY_PREFIX = 'inst-';
+// Overview cache keyed on (engine identity, filter SQL): two engines
+// may share (upid, ts) values, and one engine serves several dumps
+// over the page's lifetime.
+const overviewCache = new Map();
+const overviewLoadingFor = new Set();
+
+let nextEngineUid = 1;
+const engineUid = new WeakMap();
+function engineKey(engine: Engine, filterSql: string): string {
+ let id = engineUid.get(engine);
+ if (id === undefined) {
+ id = nextEngineUid++;
+ engineUid.set(engine, id);
+ }
+ return `${id}:${filterSql}`;
+}
+
+export function resetCachedOverview(): void {
+ overviewCache.clear();
+ overviewLoadingFor.clear();
+}
+let lastTabContext: string | null = null;
+
function fgTabKey(id: number): string {
return `${FG_KEY_PREFIX}${id}`;
}
@@ -109,10 +150,22 @@ function buildTabs(
activeDump: queries.HeapDump,
state: NavState,
overview: OverviewData,
+ baselineOverview: OverviewData | undefined,
+ baselineLoading: boolean,
): TabsTab[] {
const {engine, trace, navigateWithTabs, clearNavParam} = session;
const hideExplanationSetting = session.hideDefaultChangedHint;
const hideHint = hideExplanationSetting.get();
+ const diffActive = isDiffActive();
+ const activeBaseline = getActiveBaseline();
+ const baselineEngine = activeBaseline?.trace.engine;
+ // Same-engine baseline (only kind that supports the SQL-JOINed
+ // flamegraph diff). `trace.engine` is a fresh proxy per access, so we
+ // identify the singleton by its `disposable === false` flag instead.
+ const sameEngineBaselineDump =
+ diffActive && activeBaseline?.trace.disposable === false
+ ? activeBaseline.dump
+ : undefined;
const tabs: TabsTab[] = [
{
key: 'overview',
@@ -120,6 +173,9 @@ function buildTabs(
content: m(OverviewView, {
overview,
activeDump,
+ diffActive,
+ baselineOverview: diffActive ? baselineOverview : undefined,
+ baselineLoading: diffActive && baselineLoading,
navigate: navigateWithTabs,
showDefaultChangedHint: session.autoNavigated && !hideHint,
onBackToTimeline: () => trace.navigate('#!/viewer'),
@@ -142,77 +198,128 @@ function buildTabs(
upid: activeDump.upid,
ts: activeDump.ts,
}),
+ baseline: sameEngineBaselineDump
+ ? {
+ upid: sameEngineBaselineDump.upid,
+ ts: Time.fromRaw(sameEngineBaselineDump.ts),
+ }
+ : undefined,
}),
},
{
key: 'classes',
title: 'Classes',
- content: m(ClassesView, {
- engine,
- activeDump,
- navigate: navigateWithTabs,
- clearNavParam,
- initialRootClass:
- state.view === 'classes' ? state.params.rootClass : undefined,
- }),
+ content:
+ diffActive && baselineEngine
+ ? m(ClassesDiffView, {
+ currentEngine: engine,
+ baselineEngine,
+ navigate: navigateWithTabs,
+ })
+ : m(ClassesView, {
+ engine,
+ activeDump,
+ navigate: navigateWithTabs,
+ clearNavParam,
+ initialRootClass:
+ state.view === 'classes' ? state.params.rootClass : undefined,
+ }),
},
{
key: 'objects',
title: 'Objects',
- content: m(AllObjectsView, {
- engine,
- activeDump,
- navigate: navigateWithTabs,
- clearNavParam,
- initialClass: state.view === 'objects' ? state.params.cls : undefined,
- }),
+ content:
+ diffActive && baselineEngine
+ ? m(AllObjectsDiffView, {
+ currentEngine: engine,
+ baselineEngine,
+ cls: state.view === 'objects' ? state.params.cls : undefined,
+ navigate: navigateWithTabs,
+ })
+ : m(AllObjectsView, {
+ engine,
+ activeDump,
+ navigate: navigateWithTabs,
+ clearNavParam,
+ initialClass:
+ state.view === 'objects' ? state.params.cls : undefined,
+ }),
},
{
key: 'dominators',
title: 'Dominators',
- content: m(DominatorsView, {
- engine,
- activeDump,
- navigate: navigateWithTabs,
- }),
+ content:
+ diffActive && baselineEngine
+ ? m(DominatorsDiffView, {
+ currentEngine: engine,
+ baselineEngine,
+ navigate: navigateWithTabs,
+ })
+ : m(DominatorsView, {
+ engine,
+ activeDump,
+ navigate: navigateWithTabs,
+ }),
},
{
key: 'bitmaps',
title: 'Bitmaps',
- content: m(BitmapGalleryView, {
- engine,
- activeDump,
- navigate: navigateWithTabs,
- clearNavParam,
- hasFieldValues: overview.hasFieldValues,
- filterKey:
- state.view === 'bitmaps' ? state.params.filterKey : undefined,
- }),
+ content:
+ diffActive && baselineEngine
+ ? m(BitmapsDiffView, {
+ currentEngine: engine,
+ baselineEngine,
+ navigate: navigateWithTabs,
+ })
+ : m(BitmapGalleryView, {
+ engine,
+ activeDump,
+ navigate: navigateWithTabs,
+ clearNavParam,
+ hasFieldValues: overview.hasFieldValues,
+ filterKey:
+ state.view === 'bitmaps' ? state.params.filterKey : undefined,
+ }),
},
{
key: 'strings',
title: 'Strings',
- content: m(StringsView, {
- engine,
- activeDump,
- navigate: navigateWithTabs,
- clearNavParam,
- initialQuery: state.view === 'strings' ? state.params.q : undefined,
- hasFieldValues: overview.hasFieldValues,
- }),
+ content:
+ diffActive && baselineEngine
+ ? m(StringsDiffView, {
+ currentEngine: engine,
+ baselineEngine,
+ navigate: navigateWithTabs,
+ })
+ : m(StringsView, {
+ engine,
+ activeDump,
+ navigate: navigateWithTabs,
+ clearNavParam,
+ initialQuery:
+ state.view === 'strings' ? state.params.q : undefined,
+ hasFieldValues: overview.hasFieldValues,
+ }),
},
{
key: 'arrays',
title: 'Arrays',
- content: m(ArraysView, {
- engine,
- activeDump,
- navigate: navigateWithTabs,
- clearNavParam,
- initialArrayHash:
- state.view === 'arrays' ? state.params.arrayHash : undefined,
- hasFieldValues: overview.hasFieldValues,
- }),
+ content:
+ diffActive && baselineEngine
+ ? m(ArraysDiffView, {
+ currentEngine: engine,
+ baselineEngine,
+ navigate: navigateWithTabs,
+ })
+ : m(ArraysView, {
+ engine,
+ activeDump,
+ navigate: navigateWithTabs,
+ clearNavParam,
+ initialArrayHash:
+ state.view === 'arrays' ? state.params.arrayHash : undefined,
+ hasFieldValues: overview.hasFieldValues,
+ }),
},
];
@@ -239,61 +346,30 @@ function buildTabs(
key: instanceTabKey(obj.id),
title: obj.label,
closeButton: true,
- content: m(ObjectView, {
- engine,
- activeDump,
- heaps: overview.heaps,
- navigate: navigateWithTabs,
- openFlamegraphPivotedAt: session.openFlamegraphPivotedAt,
- params: {id: obj.objId},
- }),
+ content:
+ diffActive && baselineEngine
+ ? m(ObjectDiffView, {
+ currentEngine: engine,
+ baselineEngine,
+ activeDump,
+ currentId: obj.currentId,
+ baselineId: obj.baselineId,
+ navigate: navigateWithTabs,
+ })
+ : m(ObjectView, {
+ engine,
+ activeDump,
+ heaps: overview.heaps,
+ navigate: navigateWithTabs,
+ openFlamegraphPivotedAt: session.openFlamegraphPivotedAt,
+ params: {id: obj.objId},
+ }),
});
}
return tabs;
}
-function processLabel(d: queries.HeapDump): string {
- return d.processName !== null
- ? `${d.processName} (pid ${d.pid})`
- : `pid ${d.pid}`;
-}
-
-function renderDumpSelector(session: HeapDumpExplorerSession): m.Children {
- const allDumps = session.dumps;
- const active = session.activeDump;
- if (allDumps.length <= 1 || active === null) return null;
-
- return m(
- 'div',
- {class: 'ah-dump-selector'},
- m('span', {class: 'ah-dump-selector__label'}, 'Heap dump:'),
- m(
- PopupMenu,
- {
- trigger: m(Button, {
- label: processLabel(active),
- icon: 'memory',
- rightIcon: 'arrow_drop_down',
- variant: ButtonVariant.Outlined,
- compact: true,
- }),
- },
- allDumps.map((d) => {
- const offset = Time.diff(
- Time.fromRaw(d.ts),
- session.trace.traceInfo.start,
- );
- return m(MenuItem, {
- label: `${processLabel(d)} — ${formatDuration(session.trace, offset)}`,
- active: d === active,
- onclick: () => session.selectDump(d),
- });
- }),
- ),
- );
-}
-
export class HeapDumpPage implements m.ClassComponent {
oncreate({attrs}: m.VnodeDOM) {
attrs.session.setNavigateCallback((sub) => {
@@ -306,36 +382,128 @@ export class HeapDumpPage implements m.ClassComponent {
attrs.session.setNavigateCallback(undefined);
}
+ private kickOverviewLoadFor(engine: Engine | null, filterSql: string): void {
+ if (!engine) return;
+ const key = engineKey(engine, filterSql);
+ if (overviewCache.has(key) || overviewLoadingFor.has(key)) return;
+ overviewLoadingFor.add(key);
+ queries
+ .getOverview(engine, filterSql)
+ .then((data) => {
+ overviewCache.set(key, data);
+ })
+ .catch((err) => {
+ console.error('Failed to load overview:', err);
+ })
+ .finally(() => {
+ overviewLoadingFor.delete(key);
+ m.redraw();
+ });
+ }
+
view({attrs}: m.Vnode) {
const {session, subpage} = attrs;
session.syncFromSubpage(subpage);
session.syncInstanceTabFromNav();
const active = session.activeDump;
- const overview = session.cachedOverview;
- if (active === null || overview === null) {
+ if (active === null) {
+ return m(
+ 'div',
+ {class: 'ah-page'},
+ m(EmptyState, {
+ icon: 'memory',
+ title: 'No heap graph data in this trace',
+ fillHeight: true,
+ }),
+ );
+ }
+
+ const topBar = m(TopBar, {
+ trace: session.trace,
+ session,
+ onDumpChanged: () => {},
+ });
+
+ const mode = getMode();
+ const baseline = getActiveBaseline();
+ const activeIsBaseline = mode === 'baseline' && baseline !== null;
+ queries.setDumpFilterOverride(activeIsBaseline ? baseline!.dump : null);
+ queries.setActiveDumpForDiff(session.activeDump);
+
+ const diffActive = isDiffActive();
+ const tabContext = diffActive
+ ? 'diff'
+ : activeIsBaseline
+ ? 'baseline'
+ : 'primary';
+ if (lastTabContext !== null && lastTabContext !== tabContext) {
+ session.clearInstanceTabs();
+ }
+ lastTabContext = tabContext;
+
+ const overviewEngine = activeIsBaseline
+ ? baseline!.trace.engine
+ : session.engine;
+ const overviewFilter = queries.dumpFilterSql(undefined, 'o');
+ this.kickOverviewLoadFor(overviewEngine, overviewFilter);
+ const overview = overviewCache.get(
+ engineKey(overviewEngine, overviewFilter),
+ );
+
+ const baselineEngine = baseline?.trace.engine ?? null;
+ const baselineFilter = baselineDumpFilterSql('o');
+ if (baselineEngine && mode === 'diff') {
+ this.kickOverviewLoadFor(baselineEngine, baselineFilter);
+ }
+ const baselineCacheKey =
+ baselineEngine !== null
+ ? engineKey(baselineEngine, baselineFilter)
+ : null;
+ const baselineOverview =
+ baselineCacheKey !== null
+ ? overviewCache.get(baselineCacheKey)
+ : undefined;
+ const baselineLoading =
+ baselineCacheKey !== null &&
+ baselineOverview === undefined &&
+ overviewLoadingFor.has(baselineCacheKey);
+
+ if (!overview) {
return m(
'div',
{class: 'ah-page'},
- renderDumpSelector(session),
+ topBar,
m('div', {class: 'ah-loading'}, m(Spinner, {easing: true})),
);
}
- // Keyed so Mithril remounts views (and their SQLDataSources) on
- // dump switch.
- const tabsKey = `${active.upid}:${active.ts}`;
+ // Key the Tabs widget on (primary dump, baseline dump, mode) — mode
+ // changes swap engines / filters used by standard views, which only
+ // capture them at oninit. Remount forces re-fetch.
+ const primaryKey = `${active.upid}:${active.ts}`;
+ const baselineKey = baseline
+ ? `${baseline.trace.id}:${baseline.dump.upid}:${baseline.dump.ts}`
+ : 'none';
+ const tabsKey = `${primaryKey}|${baselineKey}|${mode}`;
return m(
'div',
{class: 'ah-page'},
- renderDumpSelector(session),
+ topBar,
m(
'main',
{class: 'ah-main'},
m(Tabs, {
key: tabsKey,
- tabs: buildTabs(session, active, session.nav, overview),
+ tabs: buildTabs(
+ session,
+ active,
+ session.nav,
+ overview,
+ baselineOverview,
+ baselineLoading,
+ ),
activeTabKey: activeTabKey(session),
onTabChange: (key: string) => handleTabChange(session, key),
onTabClose: (key: string) => handleTabClose(session, key),
@@ -344,3 +512,6 @@ export class HeapDumpPage implements m.ClassComponent {
);
}
}
+
+/** Re-exported convenience for index.ts so it can dispose on trace change. */
+export {disposeBaseline};
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/index.ts b/ui/src/plugins/com.android.HeapDumpExplorer/index.ts
index 3a1944953b0..29ff36a723d 100644
--- a/ui/src/plugins/com.android.HeapDumpExplorer/index.ts
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/index.ts
@@ -18,7 +18,11 @@ import {PerfettoPlugin} from '../../public/plugin';
import {Trace} from '../../public/trace';
import {NUM} from '../../trace_processor/query_result';
import HeapProfilePlugin from '../dev.perfetto.HeapProfile';
-import {HeapDumpPage} from './heap_dump_page';
+import {
+ HeapDumpPage,
+ resetCachedOverview,
+ disposeBaseline,
+} from './heap_dump_page';
import {HeapDumpExplorerSession} from './session';
export default class implements PerfettoPlugin {
@@ -47,6 +51,9 @@ export default class implements PerfettoPlugin {
);
await session.loadDumps();
+ resetCachedOverview();
+ disposeBaseline();
+
ctx.pages.registerPage({
route: '/heapdump',
render: (subpage) => m(HeapDumpPage, {session, subpage}),
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/queries.ts b/ui/src/plugins/com.android.HeapDumpExplorer/queries.ts
index d2c747f446a..594df9deb52 100644
--- a/ui/src/plugins/com.android.HeapDumpExplorer/queries.ts
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/queries.ts
@@ -44,7 +44,8 @@ export interface HeapDump {
readonly upid: number;
readonly ts: bigint;
readonly processName: string | null;
- readonly pid: number;
+ /** Null when the trace has no process metadata (typical for raw hprof). */
+ readonly pid: number | null;
}
export async function loadDumpsList(engine: Engine): Promise {
@@ -74,17 +75,36 @@ export async function loadDumpsList(engine: Engine): Promise {
upid: it.upid,
ts: it.ts,
processName: it.pname,
- pid: it.pid ?? 0,
+ pid: it.pid,
});
}
return result;
}
-export function dumpFilterSql(dump: HeapDump, alias: string = 'o'): string {
- return (
- `${alias}.upid = ${dump.upid} ` +
- `AND ${alias}.graph_sample_ts = ${dump.ts}`
- );
+// When set, dumpFilterSql() yields this dump's filter instead of the
+// passed dump. Heap_dump_page sets this in Baseline-only mode so
+// the standard (non-diff) views read baseline data without changing the
+// user-visible primary selection.
+let dumpFilterOverride: HeapDump | null = null;
+
+export function setDumpFilterOverride(d: HeapDump | null): void {
+ dumpFilterOverride = d;
+}
+
+let activeDumpForDiff: HeapDump | null = null;
+
+export function setActiveDumpForDiff(d: HeapDump | null): void {
+ activeDumpForDiff = d;
+}
+
+export function getActiveDump(): HeapDump | null {
+ return activeDumpForDiff;
+}
+
+export function dumpFilterSql(dump?: HeapDump, alias: string = 'o'): string {
+ const d = dump ?? dumpFilterOverride;
+ if (!d) return '1=1';
+ return `${alias}.upid = ${d.upid} AND ${alias}.graph_sample_ts = ${d.ts}`;
}
async function requireDominatorTree(engine: Engine): Promise {
@@ -239,11 +259,24 @@ async function batchBitmapBufferHashes(
return result;
}
+// `dumpOrFilter` can be a `HeapDump` object or a pre-computed filter string.
+// Passing a filter string is useful in diff mode where the filter must match
+// the specific engine (e.g. baseline engine).
export async function getOverview(
engine: Engine,
- activeDump: HeapDump,
+ dumpOrFilter: HeapDump | string,
+ activeDump?: HeapDump,
): Promise {
- const dumpFilter = dumpFilterSql(activeDump, 'o');
+ // Accept either a HeapDump (computes its own filter) or a pre-computed
+ // filter string (used by diff mode where the filter must match a
+ // specific engine's dumps). `activeDump` lets the caller pass a HeapDump
+ // alongside a filter string for downstream code (e.g. bitmap content
+ // hashing) that needs the full dump record.
+ const dumpFilter =
+ typeof dumpOrFilter === 'string'
+ ? dumpOrFilter
+ : dumpFilterSql(dumpOrFilter, 'o');
+ const dump = typeof dumpOrFilter === 'string' ? activeDump : dumpOrFilter;
const countRes = await engine.query(`
SELECT
sum(iif(o.reachable, 1, 0)) AS reachable,
@@ -343,8 +376,8 @@ export async function getOverview(
.filter((b) => b.nativePtr !== null)
.map((b) => ({objectId: b.id, nativePtr: b.nativePtr!}));
const hashes =
- hashInputs.length > 0
- ? await batchBitmapBufferHashes(engine, activeDump, hashInputs)
+ hashInputs.length > 0 && dump
+ ? await batchBitmapBufferHashes(engine, dump, hashInputs)
: new Map();
const hashGroups = new Map<
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/session.ts b/ui/src/plugins/com.android.HeapDumpExplorer/session.ts
index 531a98ffbb7..ae3abc25ba8 100644
--- a/ui/src/plugins/com.android.HeapDumpExplorer/session.ts
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/session.ts
@@ -50,6 +50,8 @@ interface InstanceTab {
readonly id: number;
readonly objId: number;
readonly label: string;
+ readonly currentId: number | null;
+ readonly baselineId: number | null;
}
const INSTANCE_LABEL_MAX = 30;
@@ -148,6 +150,8 @@ export class HeapDumpExplorerSession {
this.openInstanceTab(
params?.id as number,
params?.label as string | undefined,
+ params?.currentId as number | null | undefined,
+ params?.baselineId as number | null | undefined,
);
this.navigate(view, params);
return;
@@ -250,7 +254,12 @@ export class HeapDumpExplorerSession {
this._activeInstanceId = null;
}
- private openInstanceTab(objId: number, label?: string): void {
+ private openInstanceTab(
+ objId: number,
+ label?: string,
+ currentId?: number | null,
+ baselineId?: number | null,
+ ): void {
const existing = this._instanceTabs.find((t) => t.objId === objId);
if (existing) {
this._activeInstanceId = existing.id;
@@ -260,6 +269,8 @@ export class HeapDumpExplorerSession {
id: this._nextInstanceId++,
objId,
label: truncateInstanceLabel(label ?? 'Instance'),
+ currentId: currentId === undefined ? objId : currentId,
+ baselineId: baselineId === undefined ? null : baselineId,
};
this._instanceTabs.push(tab);
this._activeInstanceId = tab.id;
@@ -275,6 +286,13 @@ export class HeapDumpExplorerSession {
}
}
+ clearInstanceTabs(): void {
+ this._instanceTabs.length = 0;
+ this._activeInstanceId = null;
+ if (this._nav.view === 'object') {
+ this.navigate('overview');
+ }
+ }
syncInstanceTabFromNav(): void {
if (this._nav.view !== 'object') {
this._activeInstanceId = null;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/styles.scss b/ui/src/plugins/com.android.HeapDumpExplorer/styles.scss
index 8893cc848d6..4a5ccb4c8be 100644
--- a/ui/src/plugins/com.android.HeapDumpExplorer/styles.scss
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/styles.scss
@@ -106,21 +106,6 @@
flex: 1;
}
-.ah-dump-selector {
- flex: none;
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.5rem 1rem;
- background: var(--pf-color-background);
- border-bottom: 1px solid var(--pf-color-border);
- font-size: 0.875rem;
-}
-
-.ah-dump-selector__label {
- color: var(--pf-color-text-hint);
-}
-
.ah-error-text {
color: var(--pf-color-danger);
padding: 1rem;
@@ -426,6 +411,101 @@
flex-shrink: 0;
}
+// Combined top bar that hosts both the primary heap-dump selector and the
+// baseline pool / diff controls in a single row above the tabs. Multiple
+// inline groups are separated by a vertical divider.
+.ah-top-bar {
+ flex: none;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background: var(--pf-color-background);
+ border-bottom: 1px solid var(--pf-color-border);
+ font-size: 0.875rem;
+}
+
+// Collapsed top bar — keeps a hidden file input mounted but renders no
+// visible row. `display: none` would also drop the input from the layout
+// tree but `aria-hidden` on the input is enough for screen readers; a
+// zero-height container avoids reflow churn when the bar appears.
+.ah-top-bar--hidden {
+ padding: 0;
+ border-bottom: 0;
+}
+
+.ah-top-bar__label {
+ color: var(--pf-color-text-hint);
+}
+
+// Vertical divider between the primary and baseline groups.
+.ah-top-bar__separator {
+ color: var(--pf-color-border);
+ user-select: none;
+}
+
+// Flex spacer that pushes the diff-mode toggle (and trailing controls)
+// to the right edge of the top bar.
+.ah-top-bar__spacer {
+ flex: 1;
+}
+
+// Subdued group heading inside the baseline-pool popup (above each
+// pooled trace's dump list).
+.ah-top-bar__section-title {
+ font-weight: 600;
+ color: var(--pf-color-text-muted);
+}
+
+// Vertically scrolling content area for the Overview tab — the cards
+// below the heading exceed the viewport on small screens. The Tabs widget
+// gives this element a fixed height, so a child with `overflow-y: auto`
+// is the right scroll container.
+.ah-view-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding-right: 0.25rem; // keep scrollbar from overlapping last column
+}
+
+// Status badge text inside diff DataGrid status cells. Plain coloured
+// uppercase, no pill — same visual weight as AHAT.
+.ah-status-text {
+ font-size: 0.75rem;
+ letter-spacing: 0.04em;
+
+ &--new {
+ color: var(--pf-color-warning);
+ }
+ &--grew {
+ color: var(--pf-color-danger);
+ }
+ &--shrank {
+ color: var(--pf-color-success);
+ }
+ &--removed {
+ color: var(--pf-color-text-muted);
+ }
+ &--unchanged {
+ color: var(--pf-color-text-hint);
+ }
+}
+
+// Diff delta values (sized + count cells in diff DataGrids and Overview
+// `dWasted`). Sign carries direction: `+` red, `−` green, 0 muted.
+.ah-delta {
+ &--grew {
+ color: var(--pf-color-danger);
+ }
+ &--shrank {
+ color: var(--pf-color-success);
+ }
+ &--zero {
+ color: var(--pf-color-text-muted);
+ }
+}
+
// Spacing (minimal set)
.ah-mt-1 {
margin-top: 0.25rem;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/top_bar.ts b/ui/src/plugins/com.android.HeapDumpExplorer/top_bar.ts
new file mode 100644
index 00000000000..fadf262aac4
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/top_bar.ts
@@ -0,0 +1,125 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Top bar hosting the primary heap-dump selector and baseline pool
+// controls. Hides when neither has anything to show.
+
+import m from 'mithril';
+import {Trace} from '../../public/trace';
+import {Time} from '../../base/time';
+import {Button, ButtonVariant} from '../../widgets/button';
+import {MenuDivider, MenuItem, MenuTitle, PopupMenu} from '../../widgets/menu';
+import {formatDuration} from '../../components/time_utils';
+import * as queries from './queries';
+import {HeapDumpDiffHeader, shouldShowBaselineHeader} from './header';
+import {setSelfTraceBaseline} from './baseline/state';
+import {HeapDumpExplorerSession} from './session';
+
+interface TopBarAttrs {
+ readonly trace: Trace;
+ readonly session: HeapDumpExplorerSession;
+ readonly onDumpChanged: () => void;
+}
+
+export class TopBar implements m.ClassComponent {
+ view({attrs}: m.Vnode): m.Children {
+ const hasPrimary =
+ attrs.session.dumps.length > 1 && attrs.session.activeDump !== null;
+ const hasBaseline = shouldShowBaselineHeader();
+ // The HeapDumpDiffHeader always keeps a hidden file input mounted so
+ // the Overview-tab CTA can fire it. Render it even when the visible
+ // row collapses — it returns just the input in that case.
+ if (!hasPrimary && !hasBaseline) {
+ return m('div', {class: 'ah-top-bar ah-top-bar--hidden'}, [
+ m(HeapDumpDiffHeader, {trace: attrs.trace}),
+ ]);
+ }
+ return m(
+ 'div',
+ {class: 'ah-top-bar'},
+ hasPrimary ? renderPrimarySelector(attrs) : null,
+ hasPrimary && hasBaseline
+ ? m('span', {class: 'ah-top-bar__separator'}, '|')
+ : null,
+ m(HeapDumpDiffHeader, {trace: attrs.trace}),
+ );
+ }
+}
+
+function renderPrimarySelector(attrs: TopBarAttrs): m.Children {
+ const allDumps = attrs.session.dumps;
+ const active = attrs.session.activeDump!;
+ const otherDumps = allDumps.filter((d) => d !== active);
+ return [
+ m('span', {class: 'ah-top-bar__label'}, 'Heap dump:'),
+ m(
+ PopupMenu,
+ {
+ trigger: m(Button, {
+ label: processLabel(active),
+ icon: 'memory',
+ rightIcon: 'arrow_drop_down',
+ variant: ButtonVariant.Outlined,
+ compact: true,
+ }),
+ },
+ [
+ ...allDumps.map((d) =>
+ m(MenuItem, {
+ label: itemLabel(d, attrs.trace),
+ active: d === active,
+ onclick: () => {
+ attrs.session.selectDump(d);
+ attrs.onDumpChanged();
+ },
+ }),
+ ),
+ otherDumps.length > 0 ? m(MenuDivider) : null,
+ otherDumps.length > 0
+ ? m(MenuTitle, {label: 'Diff against this dump:'})
+ : null,
+ ...otherDumps.map((d) =>
+ m(MenuItem, {
+ label: itemLabel(d, attrs.trace),
+ icon: 'difference',
+ onclick: () =>
+ setSelfTraceBaseline(
+ attrs.trace.engine,
+ attrs.trace.traceInfo.traceTitle,
+ allDumps,
+ d,
+ ),
+ }),
+ ),
+ ],
+ ),
+ ];
+}
+
+function processLabel(d: queries.HeapDump): string {
+ // See header.ts:dumpProcessLabel — pid 0 means "unknown" for hprofs
+ // without process metadata.
+ const hasPid = d.pid !== null && d.pid !== 0;
+ if (d.processName !== null && hasPid) {
+ return `${d.processName} (pid ${d.pid})`;
+ }
+ if (d.processName !== null) return d.processName;
+ if (hasPid) return `pid ${d.pid}`;
+ return 'Java heap dump';
+}
+
+function itemLabel(d: queries.HeapDump, trace: Trace): string {
+ const offset = Time.diff(Time.fromRaw(d.ts), trace.traceInfo.start);
+ return `${processLabel(d)} — ${formatDuration(trace, offset)}`;
+}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/all_objects_diff_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/all_objects_diff_view.ts
new file mode 100644
index 00000000000..423c17064a1
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/all_objects_diff_view.ts
@@ -0,0 +1,433 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Per-class instance diff. Loads all instances of `cls` from both engines,
+// pairs them via the AHAT-style bucket-key + retained-sort-zip in
+// object_pairing.ts, then renders a paired DataGrid. Without `cls` shows
+// an empty state — global instance pairing is too expensive to compute on
+// the main thread, and class scope is what every navigation entry point
+// (Classes, Dominators) already provides.
+
+import m from 'mithril';
+import {Spinner} from '../../../../widgets/spinner';
+import {EmptyState} from '../../../../widgets/empty_state';
+import type {Engine} from '../../../../trace_processor/engine';
+import {
+ NUM,
+ NUM_NULL,
+ STR_NULL,
+ type SqlValue,
+} from '../../../../trace_processor/query_result';
+import {DataGrid} from '../../../../components/widgets/datagrid/datagrid';
+import {InMemoryDataSource} from '../../../../components/widgets/datagrid/in_memory_data_source';
+import type {
+ CellRenderResult,
+ CellRenderer,
+ ColumnDef,
+ SchemaRegistry,
+} from '../../../../components/widgets/datagrid/datagrid_schema';
+import {type NavFn, shortClassName, SQL_PREAMBLE} from '../../components';
+import {fmtHex} from '../../format';
+import {
+ deltaSizeRenderer,
+ sideSizeRenderer,
+ sideCountRenderer,
+ statusRenderer,
+} from '../../diff/diff_schemas';
+import {
+ pairObjects,
+ type ObjectPairRow,
+ type ObjectRowRaw,
+} from '../../diff/object_pairing';
+import {baselineDumpFilterSql, getActiveBaseline} from '../../baseline/state';
+import {dumpFilterSql, getActiveDump} from '../../queries';
+
+interface AllObjectsDiffViewAttrs {
+ readonly currentEngine: Engine;
+ readonly baselineEngine: Engine;
+ readonly cls: string | undefined;
+ readonly navigate: NavFn;
+}
+
+const ITER_SPEC = {
+ id: NUM,
+ cls: STR_NULL,
+ heap_type: STR_NULL,
+ self_size: NUM,
+ native_size: NUM,
+ retained: NUM_NULL,
+ retained_native: NUM_NULL,
+ retained_count: NUM_NULL,
+ value_string: STR_NULL,
+ array_len: NUM_NULL,
+};
+
+function sqlEsc(s: string): string {
+ return s.replace(/'/g, "''");
+}
+
+function buildQuery(filterSql: string, cls: string): string {
+ const escaped = sqlEsc(cls);
+ return `
+ SELECT
+ o.id AS id,
+ coalesce(c.deobfuscated_name, c.name) AS cls,
+ o.heap_type AS heap_type,
+ o.self_size AS self_size,
+ o.native_size AS native_size,
+ coalesce(d.dominated_size_bytes, o.self_size) AS retained,
+ coalesce(d.dominated_native_size_bytes, o.native_size) AS retained_native,
+ coalesce(d.dominated_obj_count, 1) AS retained_count,
+ od.value_string AS value_string,
+ od.array_element_count AS array_len
+ FROM heap_graph_object o
+ JOIN heap_graph_class c ON o.type_id = c.id
+ LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
+ LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
+ WHERE o.reachable != 0
+ AND ${filterSql}
+ AND (c.name = '${escaped}' OR c.deobfuscated_name = '${escaped}')
+ `;
+}
+
+async function fetchObjectsForClass(
+ engine: Engine,
+ filterSql: string,
+ cls: string,
+): Promise {
+ await engine.query(SQL_PREAMBLE);
+ const res = await engine.query(buildQuery(filterSql, cls));
+ const out: ObjectRowRaw[] = [];
+ for (const it = res.iter(ITER_SPEC); it.valid(); it.next()) {
+ if (it.cls === null) continue;
+ out.push({
+ id: Number(it.id),
+ className: it.cls,
+ heapType: it.heap_type,
+ valueString: it.value_string,
+ arrayLength: it.array_len,
+ shallow: it.self_size,
+ shallowNative: it.native_size,
+ retained: it.retained ?? it.self_size,
+ retainedNative: it.retained_native ?? it.native_size,
+ retainedCount: it.retained_count ?? 1,
+ });
+ }
+ return out;
+}
+
+function pairToGridRow(p: ObjectPairRow): Record {
+ // Choose a stable display id: the current id if present, else the
+ // baseline id. The Object column renderer reads c_id / b_id directly to
+ // wire its click target.
+ return {
+ key: p.key,
+ status: p.status,
+ cls: p.className,
+ heap: p.heapType,
+ str: p.valueString,
+ c_id: p.c_id,
+ b_id: p.b_id,
+ delta_retained: p.delta_retained,
+ c_retained: p.c_retained,
+ b_retained: p.b_retained,
+ delta_shallow: p.delta_shallow,
+ c_shallow: p.c_shallow,
+ b_shallow: p.b_shallow,
+ c_retained_count: p.c_retained_count,
+ b_retained_count: p.b_retained_count,
+ };
+}
+
+function buildSchema(navigate: NavFn): SchemaRegistry {
+ const idRenderer: CellRenderer = (value) => {
+ if (value === null || value === undefined) {
+ return {
+ content: m('span', {class: 'ah-mono ah-muted'}, '—'),
+ align: 'right',
+ } satisfies CellRenderResult;
+ }
+ return {
+ content: m('span', {class: 'ah-mono'}, fmtHex(Number(value))),
+ align: 'right',
+ } satisfies CellRenderResult;
+ };
+
+ // The Object column reads c_id / b_id from the same row to wire its
+ // click target. InMemoryDataSource strips fields not declared in the
+ // schema, so c_id and b_id MUST appear as columns below — even if we
+ // also render them via this single composite cell.
+ const objectRenderer: CellRenderer = (_value, row) => {
+ const cls = String(row.cls ?? '');
+ const cId = row.c_id == null ? null : Number(row.c_id);
+ const bId = row.b_id == null ? null : Number(row.b_id);
+ const str = row.str == null ? null : String(row.str);
+ const displayId = cId ?? bId ?? 0;
+ const display = `${shortClassName(cls)} ${fmtHex(displayId)}`;
+ return {
+ content: m('span', [
+ m(
+ 'button',
+ {
+ class: 'ah-link',
+ onclick: () =>
+ navigate('object', {
+ id: cId ?? bId ?? 0,
+ baselineId: bId,
+ currentId: cId,
+ label: str ? `"${truncate(str, 30)}"` : display,
+ }),
+ },
+ display,
+ ),
+ str
+ ? m('span', {class: 'ah-str-badge'}, ` "${truncate(str, 40)}"`)
+ : null,
+ ]),
+ align: 'left',
+ } satisfies CellRenderResult;
+ };
+
+ const cols: Record = {
+ cls: {title: 'Object', columnType: 'text', cellRenderer: objectRenderer},
+ status: {
+ title: 'Status',
+ columnType: 'text',
+ cellRenderer: statusRenderer,
+ },
+ delta_retained: {
+ title: 'Δ Retained',
+ columnType: 'quantitative',
+ cellRenderer: deltaSizeRenderer,
+ },
+ b_retained: {
+ title: 'Baseline Retained',
+ columnType: 'quantitative',
+ cellRenderer: sideSizeRenderer,
+ },
+ c_retained: {
+ title: 'Current Retained',
+ columnType: 'quantitative',
+ cellRenderer: sideSizeRenderer,
+ },
+ delta_shallow: {
+ title: 'Δ Shallow',
+ columnType: 'quantitative',
+ cellRenderer: deltaSizeRenderer,
+ },
+ b_shallow: {
+ title: 'Baseline Shallow',
+ columnType: 'quantitative',
+ cellRenderer: sideSizeRenderer,
+ },
+ c_shallow: {
+ title: 'Current Shallow',
+ columnType: 'quantitative',
+ cellRenderer: sideSizeRenderer,
+ },
+ b_retained_count: {
+ title: 'Baseline Retained #',
+ columnType: 'quantitative',
+ cellRenderer: sideCountRenderer,
+ },
+ c_retained_count: {
+ title: 'Current Retained #',
+ columnType: 'quantitative',
+ cellRenderer: sideCountRenderer,
+ },
+ heap: {title: 'Heap', columnType: 'text'},
+ str: {title: 'String Value', columnType: 'text'},
+ c_id: {
+ title: 'Current id',
+ columnType: 'identifier',
+ cellRenderer: idRenderer,
+ },
+ b_id: {
+ title: 'Baseline id',
+ columnType: 'identifier',
+ cellRenderer: idRenderer,
+ },
+ };
+ return {query: cols};
+}
+
+function truncate(s: string, n: number): string {
+ return s.length > n ? s.slice(0, n) + '…' : s;
+}
+
+function AllObjectsDiffView(): m.Component {
+ let rows: ObjectPairRow[] | null = null;
+ let loading = false;
+ let error: string | null = null;
+ let dataSource: InMemoryDataSource | null = null;
+
+ let lastCurrentEngine: Engine | null = null;
+ let lastBaselineEngine: Engine | null = null;
+ let lastCls: string | undefined = undefined;
+
+ async function load(
+ currentEngine: Engine,
+ baselineEngine: Engine,
+ cls: string,
+ ) {
+ const primarySnap = getActiveDump();
+ const baselineSnap = getActiveBaseline();
+ if (!primarySnap || !baselineSnap) return;
+ const isStale = () =>
+ getActiveDump() !== primarySnap || getActiveBaseline() !== baselineSnap;
+ loading = true;
+ error = null;
+ try {
+ const baselineFilter = baselineDumpFilterSql('o');
+ const currentFilter = dumpFilterSql(undefined, 'o');
+ const [baselineRows, currentRows] = await Promise.all([
+ fetchObjectsForClass(baselineEngine, baselineFilter, cls),
+ fetchObjectsForClass(currentEngine, currentFilter, cls),
+ ]);
+ if (isStale()) return;
+ const paired = pairObjects(currentRows, baselineRows);
+ // Default sort: largest |Δ retained| first.
+ paired.sort(
+ (a, b) => Math.abs(b.delta_retained) - Math.abs(a.delta_retained),
+ );
+ rows = paired;
+ dataSource = new InMemoryDataSource(paired.map(pairToGridRow));
+ } catch (err) {
+ if (isStale()) return;
+ error = err instanceof Error ? err.message : String(err);
+ console.error('Objects diff load failed:', err);
+ } finally {
+ loading = false;
+ m.redraw();
+ }
+ }
+
+ function ensureLoaded(
+ currentEngine: Engine,
+ baselineEngine: Engine,
+ cls: string | undefined,
+ ) {
+ if (
+ currentEngine === lastCurrentEngine &&
+ baselineEngine === lastBaselineEngine &&
+ cls === lastCls
+ ) {
+ return;
+ }
+ lastCurrentEngine = currentEngine;
+ lastBaselineEngine = baselineEngine;
+ lastCls = cls;
+ rows = null;
+ dataSource = null;
+ error = null;
+ if (cls !== undefined) {
+ load(currentEngine, baselineEngine, cls).catch(console.error);
+ }
+ }
+
+ return {
+ oninit(vnode) {
+ ensureLoaded(
+ vnode.attrs.currentEngine,
+ vnode.attrs.baselineEngine,
+ vnode.attrs.cls,
+ );
+ },
+ onupdate(vnode) {
+ ensureLoaded(
+ vnode.attrs.currentEngine,
+ vnode.attrs.baselineEngine,
+ vnode.attrs.cls,
+ );
+ },
+ view(vnode) {
+ const {cls, navigate} = vnode.attrs;
+
+ if (cls === undefined) {
+ return m(
+ EmptyState,
+ {
+ icon: 'filter_list',
+ title: 'Pick a class to diff its instances',
+ fillHeight: true,
+ },
+ m(
+ 'p',
+ {class: 'ah-muted'},
+ 'Open a class from the Classes or Dominators tab to see ' +
+ 'paired instance-level diffs.',
+ ),
+ );
+ }
+
+ if (loading && !rows) {
+ return m('div', {class: 'ah-loading'}, m(Spinner, {easing: true}));
+ }
+ if (error) {
+ return m(EmptyState, {
+ icon: 'error',
+ title: `Failed to compute Objects diff: ${error}`,
+ fillHeight: true,
+ });
+ }
+ if (!rows || !dataSource) {
+ return m(EmptyState, {
+ icon: 'memory',
+ title: `No instances of ${cls} in either dump`,
+ fillHeight: true,
+ });
+ }
+
+ const schema = buildSchema(navigate);
+ return m('div', {class: 'ah-view-content'}, [
+ m('h2', {class: 'ah-view-heading'}, [
+ 'Objects diff ',
+ m(
+ 'span',
+ {class: 'ah-muted'},
+ `(${cls} — ${rows.length.toLocaleString()} pairs)`,
+ ),
+ ]),
+ m(DataGrid, {
+ schema,
+ rootSchema: 'query',
+ data: dataSource,
+ fillHeight: true,
+ initialColumns: [
+ {id: 'cls', field: 'cls'},
+ {id: 'status', field: 'status'},
+ {id: 'delta_retained', field: 'delta_retained', sort: 'DESC'},
+ {id: 'b_retained', field: 'b_retained'},
+ {id: 'c_retained', field: 'c_retained'},
+ {id: 'delta_shallow', field: 'delta_shallow'},
+ {id: 'b_shallow', field: 'b_shallow'},
+ {id: 'c_shallow', field: 'c_shallow'},
+ {id: 'b_retained_count', field: 'b_retained_count'},
+ {id: 'c_retained_count', field: 'c_retained_count'},
+ {id: 'heap', field: 'heap'},
+ {id: 'str', field: 'str'},
+ // Required for the Object column's click renderer to read both
+ // ids — InMemoryDataSource projects rows down to declared
+ // columns, so omitting these silently zeros them in the row.
+ {id: 'c_id', field: 'c_id'},
+ {id: 'b_id', field: 'b_id'},
+ ],
+ showExportButton: true,
+ }),
+ ]);
+ },
+ };
+}
+
+export default AllObjectsDiffView;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/arrays_diff_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/arrays_diff_view.ts
new file mode 100644
index 00000000000..0bff5c08a04
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/arrays_diff_view.ts
@@ -0,0 +1,211 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Arrays diff: GROUP BY array_data_hash (content hash) per engine,
+// JS outer-join.
+
+import m from 'mithril';
+import {Spinner} from '../../../../widgets/spinner';
+import {EmptyState} from '../../../../widgets/empty_state';
+import type {Engine} from '../../../../trace_processor/engine';
+import {LONG_NULL, NUM} from '../../../../trace_processor/query_result';
+import type {Row} from '../../../../trace_processor/query_result';
+import {DataGrid} from '../../../../components/widgets/datagrid/datagrid';
+import {InMemoryDataSource} from '../../../../components/widgets/datagrid/in_memory_data_source';
+import type {NavFn} from '../../components';
+import type {DiffRow} from '../../diff/diff_rows';
+import {
+ compareByAbsDeltaDesc,
+ dedupeByKey,
+ mergeRows,
+} from '../../diff/diff_rows';
+import {publishDiffRows} from '../../diff/diff_debug';
+import {
+ buildSizeCountInitialColumns,
+ buildSizeCountSchema,
+} from '../../diff/diff_schemas';
+import {baselineDumpFilterSql, getActiveBaseline} from '../../baseline/state';
+import {dumpFilterSql, getActiveDump} from '../../queries';
+
+function buildQuery(filterSql: string): string {
+ return `
+ SELECT
+ od.array_data_hash AS h,
+ COUNT(*) AS cnt,
+ SUM(o.self_size) AS retained
+ FROM heap_graph_object o
+ JOIN heap_graph_object_data od ON o.object_data_id = od.id
+ WHERE o.reachable != 0
+ AND ${filterSql}
+ AND od.array_data_hash IS NOT NULL
+ GROUP BY od.array_data_hash
+ `;
+}
+
+const ITER_SPEC = {h: LONG_NULL, cnt: NUM, retained: NUM};
+
+interface ArraysDiffViewAttrs {
+ readonly currentEngine: Engine;
+ readonly baselineEngine: Engine;
+ readonly navigate: NavFn;
+}
+
+const NUMERIC_FIELDS = ['cnt', 'retained'];
+
+async function runQuery(engine: Engine, filterSql: string): Promise {
+ const res = await engine.query(buildQuery(filterSql));
+ const out: Row[] = [];
+ for (const it = res.iter(ITER_SPEC); it.valid(); it.next()) {
+ if (it.h === null) continue;
+ out.push({
+ h: it.h.toString(),
+ cnt: it.cnt,
+ retained: it.retained,
+ });
+ }
+ return dedupeByKey(out, (r) => String(r.h ?? ''), NUMERIC_FIELDS);
+}
+
+function ArraysDiffView(): m.Component {
+ let rows: DiffRow[] | null = null;
+ let loading = false;
+ let error: string | null = null;
+ let dataSource: InMemoryDataSource | null = null;
+ let lastB: Engine | null = null;
+ let lastC: Engine | null = null;
+
+ async function load(currentEngine: Engine, baselineEngine: Engine) {
+ // Snapshot active refs; any change during the awaits means the
+ // result is stale and we drop it. The same check in catch silently
+ // swallows rejections from a disposed engine.
+ const primarySnap = getActiveDump();
+ const baselineSnap = getActiveBaseline();
+ if (!primarySnap || !baselineSnap) return;
+ const isStale = () =>
+ getActiveDump() !== primarySnap || getActiveBaseline() !== baselineSnap;
+ loading = true;
+ error = null;
+ try {
+ const baselineFilter = baselineDumpFilterSql('o');
+ const currentFilter = dumpFilterSql(undefined, 'o');
+ const [b, c] = await Promise.all([
+ runQuery(baselineEngine, baselineFilter),
+ runQuery(currentEngine, currentFilter),
+ ]);
+ if (isStale()) return;
+ const merged = mergeRows({
+ baseline: b,
+ current: c,
+ keyOf: (r) => String(r.h ?? ''),
+ numericFields: NUMERIC_FIELDS,
+ primaryDeltaField: 'retained',
+ });
+ merged.sort(compareByAbsDeltaDesc('retained'));
+ rows = merged;
+ dataSource = new InMemoryDataSource(merged);
+ publishDiffRows('arrays', merged);
+ } catch (err) {
+ if (isStale()) return;
+ error = err instanceof Error ? err.message : String(err);
+ console.error('Arrays diff load failed:', err);
+ } finally {
+ loading = false;
+ m.redraw();
+ }
+ }
+
+ function ensure(currentEngine: Engine, baselineEngine: Engine) {
+ if (currentEngine !== lastC || baselineEngine !== lastB) {
+ lastC = currentEngine;
+ lastB = baselineEngine;
+ rows = null;
+ dataSource = null;
+ load(currentEngine, baselineEngine).catch(console.error);
+ }
+ }
+
+ return {
+ oninit(vnode) {
+ ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine);
+ },
+ onupdate(vnode) {
+ ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine);
+ },
+ view(vnode) {
+ const {navigate} = vnode.attrs;
+ if (loading && !rows) {
+ return m('div', {class: 'ah-loading'}, m(Spinner, {easing: true}));
+ }
+ if (error) {
+ return m(EmptyState, {
+ icon: 'error',
+ title: `Failed to compute Arrays diff: ${error}`,
+ fillHeight: true,
+ });
+ }
+ if (!rows || !dataSource) {
+ return m(EmptyState, {
+ icon: 'data_array',
+ title: 'No primitive arrays to diff',
+ fillHeight: true,
+ });
+ }
+
+ const size = {
+ field: 'retained',
+ title: 'Retained',
+ kind: 'size' as const,
+ };
+ const count = {field: 'cnt', title: 'Count', kind: 'count' as const};
+ const schema = buildSizeCountSchema({
+ keyTitle: 'Array hash',
+ keyRenderer: (value) =>
+ m(
+ 'button',
+ {
+ class: 'ah-link ah-mono',
+ onclick: () =>
+ navigate('arrays', {arrayHash: String(value ?? '')}),
+ },
+ String(value ?? ''),
+ ),
+ size,
+ count,
+ });
+
+ const initialColumns = buildSizeCountInitialColumns({size, count});
+
+ return m('div', {class: 'ah-view-content'}, [
+ m('h2', {class: 'ah-view-heading'}, [
+ 'Arrays diff ',
+ m(
+ 'span',
+ {class: 'ah-muted'},
+ `(${rows.length.toLocaleString()} hashes)`,
+ ),
+ ]),
+ m(DataGrid, {
+ schema,
+ rootSchema: 'query',
+ data: dataSource,
+ fillHeight: true,
+ initialColumns,
+ showExportButton: true,
+ }),
+ ]);
+ },
+ };
+}
+
+export default ArraysDiffView;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/bitmaps_diff_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/bitmaps_diff_view.ts
new file mode 100644
index 00000000000..700d43165a5
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/bitmaps_diff_view.ts
@@ -0,0 +1,231 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Bitmaps diff: GROUP BY (width, height) per engine, JS outer-join.
+// Pixel-content hashes rarely match across snapshots; dimensions answer
+// "how many bitmaps of this size?".
+
+import m from 'mithril';
+import {Spinner} from '../../../../widgets/spinner';
+import {EmptyState} from '../../../../widgets/empty_state';
+import type {Engine} from '../../../../trace_processor/engine';
+import {NUM, NUM_NULL} from '../../../../trace_processor/query_result';
+import type {Row} from '../../../../trace_processor/query_result';
+import {DataGrid} from '../../../../components/widgets/datagrid/datagrid';
+import {InMemoryDataSource} from '../../../../components/widgets/datagrid/in_memory_data_source';
+import type {NavFn} from '../../components';
+import type {DiffRow} from '../../diff/diff_rows';
+import {
+ compareByAbsDeltaDesc,
+ dedupeByKey,
+ mergeRows,
+} from '../../diff/diff_rows';
+import {publishDiffRows} from '../../diff/diff_debug';
+import {
+ buildSizeCountInitialColumns,
+ buildSizeCountSchema,
+} from '../../diff/diff_schemas';
+import {baselineDumpFilterSql, getActiveBaseline} from '../../baseline/state';
+import {dumpFilterSql, getActiveDump} from '../../queries';
+
+function buildQuery(filterSql: string): string {
+ return `
+ SELECT
+ width,
+ height,
+ COUNT(*) AS cnt,
+ SUM(retained) AS retained
+ FROM (
+ SELECT
+ MAX(CASE WHEN f.field_name GLOB '*mWidth'
+ THEN f.int_value END) AS width,
+ MAX(CASE WHEN f.field_name GLOB '*mHeight'
+ THEN f.int_value END) AS height,
+ ifnull(d.dominated_size_bytes, o.self_size)
+ + ifnull(d.dominated_native_size_bytes, o.native_size) AS retained
+ FROM heap_graph_object o
+ JOIN heap_graph_class c ON o.type_id = c.id
+ LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
+ LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
+ LEFT JOIN heap_graph_primitive f ON f.field_set_id = od.field_set_id
+ WHERE o.reachable != 0
+ AND ${filterSql}
+ AND (c.name = 'android.graphics.Bitmap'
+ OR c.deobfuscated_name = 'android.graphics.Bitmap')
+ GROUP BY o.id
+ )
+ WHERE width > 0 AND height > 0
+ GROUP BY width, height
+ `;
+}
+
+const ITER_SPEC = {
+ width: NUM_NULL,
+ height: NUM_NULL,
+ cnt: NUM,
+ retained: NUM,
+};
+
+interface BitmapsDiffViewAttrs {
+ readonly currentEngine: Engine;
+ readonly baselineEngine: Engine;
+ readonly navigate: NavFn;
+}
+
+const NUMERIC_FIELDS = ['cnt', 'retained'];
+
+async function runQuery(engine: Engine, filterSql: string): Promise {
+ const res = await engine.query(buildQuery(filterSql));
+ const out: Row[] = [];
+ const it = res.iter(ITER_SPEC);
+ for (; it.valid(); it.next()) {
+ out.push({
+ width: it.width,
+ height: it.height,
+ dimensions: `${it.width ?? 0} × ${it.height ?? 0}`,
+ cnt: it.cnt,
+ retained: it.retained,
+ });
+ }
+ return dedupeByKey(out, (r) => String(r.dimensions ?? ''), NUMERIC_FIELDS);
+}
+
+function BitmapsDiffView(): m.Component {
+ let rows: DiffRow[] | null = null;
+ let loading = false;
+ let error: string | null = null;
+ let dataSource: InMemoryDataSource | null = null;
+ let lastB: Engine | null = null;
+ let lastC: Engine | null = null;
+
+ async function load(currentEngine: Engine, baselineEngine: Engine) {
+ const primarySnap = getActiveDump();
+ const baselineSnap = getActiveBaseline();
+ if (!primarySnap || !baselineSnap) return;
+ const isStale = () =>
+ getActiveDump() !== primarySnap || getActiveBaseline() !== baselineSnap;
+ loading = true;
+ error = null;
+ try {
+ const baselineFilter = baselineDumpFilterSql('o');
+ const currentFilter = dumpFilterSql(undefined, 'o');
+ const [b, c] = await Promise.all([
+ runQuery(baselineEngine, baselineFilter),
+ runQuery(currentEngine, currentFilter),
+ ]);
+ if (isStale()) return;
+ const merged = mergeRows({
+ baseline: b,
+ current: c,
+ keyOf: (r) => String(r.dimensions ?? ''),
+ numericFields: NUMERIC_FIELDS,
+ primaryDeltaField: 'retained',
+ });
+ merged.sort(compareByAbsDeltaDesc('retained'));
+ rows = merged;
+ dataSource = new InMemoryDataSource(merged);
+ publishDiffRows('bitmaps', merged);
+ } catch (err) {
+ if (isStale()) return;
+ error = err instanceof Error ? err.message : String(err);
+ console.error('Bitmaps diff load failed:', err);
+ } finally {
+ loading = false;
+ m.redraw();
+ }
+ }
+
+ function ensure(currentEngine: Engine, baselineEngine: Engine) {
+ if (currentEngine !== lastC || baselineEngine !== lastB) {
+ lastC = currentEngine;
+ lastB = baselineEngine;
+ rows = null;
+ dataSource = null;
+ load(currentEngine, baselineEngine).catch(console.error);
+ }
+ }
+
+ return {
+ oninit(vnode) {
+ ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine);
+ },
+ onupdate(vnode) {
+ ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine);
+ },
+ view(vnode) {
+ const {navigate} = vnode.attrs;
+ if (loading && !rows) {
+ return m('div', {class: 'ah-loading'}, m(Spinner, {easing: true}));
+ }
+ if (error) {
+ return m(EmptyState, {
+ icon: 'error',
+ title: `Failed to compute Bitmaps diff: ${error}`,
+ fillHeight: true,
+ });
+ }
+ if (!rows || !dataSource) {
+ return m(EmptyState, {
+ icon: 'image',
+ title: 'No bitmaps to diff',
+ fillHeight: true,
+ });
+ }
+
+ const size = {
+ field: 'retained',
+ title: 'Retained',
+ kind: 'size' as const,
+ };
+ const count = {field: 'cnt', title: 'Count', kind: 'count' as const};
+ const schema = buildSizeCountSchema({
+ keyTitle: 'Dimensions',
+ keyRenderer: (value) =>
+ m(
+ 'button',
+ {
+ class: 'ah-link ah-mono',
+ onclick: () => navigate('bitmaps'),
+ },
+ String(value ?? ''),
+ ),
+ size,
+ count,
+ });
+
+ const initialColumns = buildSizeCountInitialColumns({size, count});
+
+ return m('div', {class: 'ah-view-content'}, [
+ m('h2', {class: 'ah-view-heading'}, [
+ 'Bitmaps diff ',
+ m(
+ 'span',
+ {class: 'ah-muted'},
+ `(${rows.length.toLocaleString()} dimension groups)`,
+ ),
+ ]),
+ m(DataGrid, {
+ schema,
+ rootSchema: 'query',
+ data: dataSource,
+ fillHeight: true,
+ initialColumns,
+ showExportButton: true,
+ }),
+ ]);
+ },
+ };
+}
+
+export default BitmapsDiffView;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/classes_diff_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/classes_diff_view.ts
new file mode 100644
index 00000000000..dbc0f021adb
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/classes_diff_view.ts
@@ -0,0 +1,268 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Classes diff: runs the same `android_heap_graph_class_aggregation`
+// query the single-engine view uses, with no LIMIT, then merges by
+// class name.
+
+import m from 'mithril';
+import {Spinner} from '../../../../widgets/spinner';
+import {EmptyState} from '../../../../widgets/empty_state';
+import type {Engine} from '../../../../trace_processor/engine';
+import type {Row} from '../../../../trace_processor/query_result';
+import {
+ NUM,
+ NUM_NULL,
+ STR_NULL,
+} from '../../../../trace_processor/query_result';
+import {DataGrid} from '../../../../components/widgets/datagrid/datagrid';
+import {InMemoryDataSource} from '../../../../components/widgets/datagrid/in_memory_data_source';
+import type {NavFn} from '../../components';
+import type {DiffRow} from '../../diff/diff_rows';
+import {
+ compareByAbsDeltaDesc,
+ dedupeByKey,
+ mergeRows,
+} from '../../diff/diff_rows';
+import {publishDiffRows} from '../../diff/diff_debug';
+import {
+ buildSizeCountInitialColumns,
+ buildSizeCountSchema,
+} from '../../diff/diff_schemas';
+import {baselineDumpFilterSql, getActiveBaseline} from '../../baseline/state';
+import {dumpFilterSql, getActiveDump} from '../../queries';
+
+const PREAMBLE =
+ 'INCLUDE PERFETTO MODULE android.memory.heap_graph.heap_graph_class_aggregation';
+
+function buildQuery(filterSql: string): string {
+ return `
+ SELECT
+ type_name,
+ reachable_obj_count,
+ reachable_size_bytes,
+ reachable_native_size_bytes,
+ dominated_size_bytes,
+ dominated_native_size_bytes,
+ dominated_obj_count
+ FROM android_heap_graph_class_aggregation a
+ WHERE a.reachable_obj_count > 0 AND ${filterSql}
+ `;
+}
+
+const ITER_SPEC = {
+ // NULL when both name and deobfuscated_name are missing; rows skipped
+ // since there's no usable join key without a class name.
+ type_name: STR_NULL,
+ reachable_obj_count: NUM,
+ reachable_size_bytes: NUM,
+ reachable_native_size_bytes: NUM_NULL,
+ dominated_size_bytes: NUM_NULL,
+ dominated_native_size_bytes: NUM_NULL,
+ dominated_obj_count: NUM_NULL,
+};
+
+const NUMERIC_FIELDS = [
+ 'reachable_obj_count',
+ 'reachable_size_bytes',
+ 'reachable_native_size_bytes',
+ 'dominated_size_bytes',
+ 'dominated_native_size_bytes',
+ 'dominated_obj_count',
+];
+
+interface ClassesDiffViewAttrs {
+ readonly currentEngine: Engine;
+ readonly baselineEngine: Engine;
+ readonly navigate: NavFn;
+}
+
+async function fetchAll(engine: Engine, filterSql: string): Promise {
+ await engine.query(PREAMBLE);
+ const res = await engine.query(buildQuery(filterSql));
+ const out: Row[] = [];
+ for (const it = res.iter(ITER_SPEC); it.valid(); it.next()) {
+ if (it.type_name === null) continue;
+ out.push({
+ type_name: it.type_name,
+ reachable_obj_count: it.reachable_obj_count,
+ reachable_size_bytes: it.reachable_size_bytes,
+ reachable_native_size_bytes: it.reachable_native_size_bytes,
+ dominated_size_bytes: it.dominated_size_bytes,
+ dominated_native_size_bytes: it.dominated_native_size_bytes,
+ dominated_obj_count: it.dominated_obj_count,
+ });
+ }
+ return dedupeByKey(out, (r) => String(r.type_name ?? ''), NUMERIC_FIELDS);
+}
+
+function ClassesDiffView(): m.Component {
+ let rows: DiffRow[] | null = null;
+ let loading = false;
+ let error: string | null = null;
+ let dataSource: InMemoryDataSource | null = null;
+
+ let lastBaselineEngine: Engine | null = null;
+ let lastCurrentEngine: Engine | null = null;
+
+ async function load(currentEngine: Engine, baselineEngine: Engine) {
+ // Snapshot active refs; any change during the awaits means the
+ // result is stale and we drop it. The same check in catch silently
+ // swallows rejections from a disposed engine.
+ const primarySnap = getActiveDump();
+ const baselineSnap = getActiveBaseline();
+ if (!primarySnap || !baselineSnap) return;
+ const isStale = () =>
+ getActiveDump() !== primarySnap || getActiveBaseline() !== baselineSnap;
+ loading = true;
+ error = null;
+ try {
+ const baselineFilter = baselineDumpFilterSql('a');
+ const currentFilter = dumpFilterSql(undefined, 'a');
+ const [baselineRows, currentRows] = await Promise.all([
+ fetchAll(baselineEngine, baselineFilter),
+ fetchAll(currentEngine, currentFilter),
+ ]);
+ if (isStale()) return;
+ const merged = mergeRows({
+ baseline: baselineRows,
+ current: currentRows,
+ keyOf: (r) => String(r.type_name ?? ''),
+ numericFields: NUMERIC_FIELDS,
+ primaryDeltaField: 'dominated_size_bytes',
+ });
+ merged.sort(compareByAbsDeltaDesc('dominated_size_bytes'));
+ rows = merged;
+ dataSource = new InMemoryDataSource(merged);
+ publishDiffRows('classes', merged);
+ } catch (err) {
+ if (isStale()) return;
+ error = err instanceof Error ? err.message : String(err);
+ console.error('Classes diff load failed:', err);
+ } finally {
+ loading = false;
+ m.redraw();
+ }
+ }
+
+ function ensureLoaded(currentEngine: Engine, baselineEngine: Engine) {
+ if (
+ currentEngine !== lastCurrentEngine ||
+ baselineEngine !== lastBaselineEngine
+ ) {
+ lastCurrentEngine = currentEngine;
+ lastBaselineEngine = baselineEngine;
+ rows = null;
+ dataSource = null;
+ load(currentEngine, baselineEngine).catch(console.error);
+ }
+ }
+
+ return {
+ oninit(vnode) {
+ ensureLoaded(vnode.attrs.currentEngine, vnode.attrs.baselineEngine);
+ },
+ onupdate(vnode) {
+ ensureLoaded(vnode.attrs.currentEngine, vnode.attrs.baselineEngine);
+ },
+ view(vnode) {
+ const {navigate} = vnode.attrs;
+ if (loading && !rows) {
+ return m('div', {class: 'ah-loading'}, m(Spinner, {easing: true}));
+ }
+ if (error) {
+ return m(EmptyState, {
+ icon: 'error',
+ title: `Failed to compute Classes diff: ${error}`,
+ fillHeight: true,
+ });
+ }
+ if (!rows || !dataSource) {
+ return m(EmptyState, {
+ icon: 'memory',
+ title: 'No Java heap data to diff',
+ fillHeight: true,
+ });
+ }
+
+ // Same column titles as the non-diff Classes view (`Retained`,
+ // `Count`, `Reachable`, etc.) so users moving between modes see
+ // the same vocabulary, just with Δ/Baseline/Current prefixes.
+ const size = {
+ field: 'dominated_size_bytes',
+ title: 'Retained',
+ kind: 'size' as const,
+ };
+ const count = {
+ field: 'reachable_obj_count',
+ title: 'Count',
+ kind: 'count' as const,
+ };
+ const extraFields = [
+ {
+ field: 'reachable_size_bytes',
+ title: 'Reachable',
+ kind: 'size' as const,
+ },
+ {
+ field: 'dominated_obj_count',
+ title: 'Retained Count',
+ kind: 'count' as const,
+ },
+ ];
+ const schema = buildSizeCountSchema({
+ keyTitle: 'Class',
+ keyRenderer: (value) =>
+ m(
+ 'button',
+ {
+ class: 'ah-link',
+ onclick: () => navigate('objects', {cls: String(value)}),
+ },
+ String(value),
+ ),
+ size,
+ count,
+ extraFields,
+ });
+
+ const initialColumns = buildSizeCountInitialColumns({
+ size,
+ count,
+ extraFields,
+ });
+
+ return m('div', {class: 'ah-view-content'}, [
+ m('h2', {class: 'ah-view-heading'}, [
+ 'Classes diff ',
+ m(
+ 'span',
+ {class: 'ah-muted'},
+ `(${rows.length.toLocaleString()} classes)`,
+ ),
+ ]),
+ m(DataGrid, {
+ schema,
+ rootSchema: 'query',
+ data: dataSource,
+ fillHeight: true,
+ initialColumns,
+ showExportButton: true,
+ }),
+ ]);
+ },
+ };
+}
+
+export default ClassesDiffView;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/dominators_diff_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/dominators_diff_view.ts
new file mode 100644
index 00000000000..113a3d32296
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/dominators_diff_view.ts
@@ -0,0 +1,245 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Dominators diff: GROUP BY root class name. Root object ids aren't
+// stable across snapshots, so class is the natural join key.
+
+import m from 'mithril';
+import {Spinner} from '../../../../widgets/spinner';
+import {EmptyState} from '../../../../widgets/empty_state';
+import type {Engine} from '../../../../trace_processor/engine';
+import {NUM, STR_NULL} from '../../../../trace_processor/query_result';
+import type {Row} from '../../../../trace_processor/query_result';
+import {DataGrid} from '../../../../components/widgets/datagrid/datagrid';
+import {InMemoryDataSource} from '../../../../components/widgets/datagrid/in_memory_data_source';
+import type {NavFn} from '../../components';
+import type {DiffRow} from '../../diff/diff_rows';
+import {
+ compareByAbsDeltaDesc,
+ dedupeByKey,
+ mergeRows,
+} from '../../diff/diff_rows';
+import {publishDiffRows} from '../../diff/diff_debug';
+import {
+ buildSizeCountInitialColumns,
+ buildSizeCountSchema,
+} from '../../diff/diff_schemas';
+import {SQL_PREAMBLE} from '../../components';
+import {baselineDumpFilterSql, getActiveBaseline} from '../../baseline/state';
+import {dumpFilterSql, getActiveDump} from '../../queries';
+
+function buildQuery(filterSql: string): string {
+ return `
+ SELECT
+ ifnull(cls.deobfuscated_name, cls.name) AS root_class,
+ COUNT(*) AS cnt,
+ SUM(d.dominated_size_bytes) AS dominated_size_bytes,
+ SUM(d.dominated_native_size_bytes) AS dominated_native_size_bytes,
+ SUM(d.dominated_obj_count) AS dominated_obj_count
+ FROM heap_graph_dominator_tree d
+ JOIN heap_graph_object o ON o.id = d.id
+ JOIN heap_graph_class cls ON cls.id = o.type_id
+ WHERE d.idom_id IS NULL AND ${filterSql}
+ GROUP BY root_class
+ `;
+}
+
+const ITER_SPEC = {
+ root_class: STR_NULL,
+ cnt: NUM,
+ dominated_size_bytes: NUM,
+ dominated_native_size_bytes: NUM,
+ dominated_obj_count: NUM,
+};
+
+interface DominatorsDiffViewAttrs {
+ readonly currentEngine: Engine;
+ readonly baselineEngine: Engine;
+ readonly navigate: NavFn;
+}
+
+const NUMERIC_FIELDS = [
+ 'cnt',
+ 'dominated_size_bytes',
+ 'dominated_native_size_bytes',
+ 'dominated_obj_count',
+];
+
+async function runQuery(engine: Engine, filterSql: string): Promise {
+ await engine.query(SQL_PREAMBLE);
+ const res = await engine.query(buildQuery(filterSql));
+ const out: Row[] = [];
+ const it = res.iter(ITER_SPEC);
+ for (; it.valid(); it.next()) {
+ if (it.root_class === null) continue;
+ out.push({
+ root_class: it.root_class,
+ cnt: it.cnt,
+ dominated_size_bytes: it.dominated_size_bytes,
+ dominated_native_size_bytes: it.dominated_native_size_bytes,
+ dominated_obj_count: it.dominated_obj_count,
+ });
+ }
+ return dedupeByKey(out, (r) => String(r.root_class ?? ''), NUMERIC_FIELDS);
+}
+
+function DominatorsDiffView(): m.Component {
+ let rows: DiffRow[] | null = null;
+ let loading = false;
+ let error: string | null = null;
+ let dataSource: InMemoryDataSource | null = null;
+ let lastB: Engine | null = null;
+ let lastC: Engine | null = null;
+
+ async function load(currentEngine: Engine, baselineEngine: Engine) {
+ const primarySnap = getActiveDump();
+ const baselineSnap = getActiveBaseline();
+ if (!primarySnap || !baselineSnap) return;
+ const isStale = () =>
+ getActiveDump() !== primarySnap || getActiveBaseline() !== baselineSnap;
+ loading = true;
+ error = null;
+ try {
+ const baselineFilter = baselineDumpFilterSql('o');
+ const currentFilter = dumpFilterSql(undefined, 'o');
+ const [b, c] = await Promise.all([
+ runQuery(baselineEngine, baselineFilter),
+ runQuery(currentEngine, currentFilter),
+ ]);
+ if (isStale()) return;
+ const merged = mergeRows({
+ baseline: b,
+ current: c,
+ keyOf: (r) => String(r.root_class ?? ''),
+ numericFields: NUMERIC_FIELDS,
+ primaryDeltaField: 'dominated_size_bytes',
+ });
+ merged.sort(compareByAbsDeltaDesc('dominated_size_bytes'));
+ rows = merged;
+ dataSource = new InMemoryDataSource(merged);
+ publishDiffRows('dominators', merged);
+ } catch (err) {
+ if (isStale()) return;
+ error = err instanceof Error ? err.message : String(err);
+ console.error('Dominators diff load failed:', err);
+ } finally {
+ loading = false;
+ m.redraw();
+ }
+ }
+
+ function ensure(currentEngine: Engine, baselineEngine: Engine) {
+ if (currentEngine !== lastC || baselineEngine !== lastB) {
+ lastC = currentEngine;
+ lastB = baselineEngine;
+ rows = null;
+ dataSource = null;
+ load(currentEngine, baselineEngine).catch(console.error);
+ }
+ }
+
+ return {
+ oninit(vnode) {
+ ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine);
+ },
+ onupdate(vnode) {
+ ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine);
+ },
+ view(vnode) {
+ const {navigate} = vnode.attrs;
+ if (loading && !rows) {
+ return m('div', {class: 'ah-loading'}, m(Spinner, {easing: true}));
+ }
+ if (error) {
+ return m(EmptyState, {
+ icon: 'error',
+ title: `Failed to compute Dominators diff: ${error}`,
+ fillHeight: true,
+ });
+ }
+ if (!rows || !dataSource) {
+ return m(EmptyState, {
+ icon: 'account_tree',
+ title: 'No dominator-tree roots to diff',
+ fillHeight: true,
+ });
+ }
+
+ const size = {
+ field: 'dominated_size_bytes',
+ title: 'Retained',
+ kind: 'size' as const,
+ };
+ const count = {
+ field: 'cnt',
+ title: 'Roots',
+ kind: 'count' as const,
+ };
+ const extraFields = [
+ {
+ field: 'dominated_native_size_bytes',
+ title: 'Retained Native',
+ kind: 'size' as const,
+ },
+ {
+ field: 'dominated_obj_count',
+ title: 'Retained Count',
+ kind: 'count' as const,
+ },
+ ];
+ const schema = buildSizeCountSchema({
+ keyTitle: 'Root Class',
+ keyRenderer: (value) =>
+ m(
+ 'button',
+ {
+ class: 'ah-link',
+ onclick: () => navigate('objects', {cls: String(value ?? '')}),
+ },
+ String(value ?? ''),
+ ),
+ size,
+ count,
+ extraFields,
+ });
+
+ const initialColumns = buildSizeCountInitialColumns({
+ size,
+ count,
+ extraFields,
+ });
+
+ return m('div', {class: 'ah-view-content'}, [
+ m('h2', {class: 'ah-view-heading'}, [
+ 'Dominators diff ',
+ m(
+ 'span',
+ {class: 'ah-muted'},
+ `(${rows.length.toLocaleString()} root classes)`,
+ ),
+ ]),
+ m(DataGrid, {
+ schema,
+ rootSchema: 'query',
+ data: dataSource,
+ fillHeight: true,
+ initialColumns,
+ showExportButton: true,
+ }),
+ ]);
+ },
+ };
+}
+
+export default DominatorsDiffView;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/object_diff_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/object_diff_view.ts
new file mode 100644
index 00000000000..6686d003c1d
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/object_diff_view.ts
@@ -0,0 +1,745 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Per-object diff. Loads `getInstance(currentEngine, currentId)` and
+// `getInstance(baselineEngine, baselineId)` in parallel and renders both
+// sides plus deltas for size, fields, and array elements. NEW / REMOVED
+// renders a single side with the status banner.
+
+import m from 'mithril';
+import {Spinner} from '../../../../widgets/spinner';
+import {EmptyState} from '../../../../widgets/empty_state';
+import type {Engine} from '../../../../trace_processor/engine';
+import {DataGrid} from '../../../../components/widgets/datagrid/datagrid';
+import type {
+ CellRenderResult,
+ CellRenderer,
+ SchemaRegistry,
+} from '../../../../components/widgets/datagrid/datagrid_schema';
+import type {Row} from '../../../../trace_processor/query_result';
+import type {InstanceDetail, InstanceRow, PrimOrRef} from '../../types';
+import {
+ pairObjects,
+ type ObjectPairRow,
+ type ObjectRowRaw,
+} from '../../diff/object_pairing';
+import {fmtHex} from '../../format';
+import {type NavFn, Section, shortClassName} from '../../components';
+import * as queries from '../../queries';
+import {getActiveBaseline} from '../../baseline/state';
+import {type HeapDump} from '../../queries';
+import {
+ deltaSizeRenderer,
+ deltaCountRenderer,
+ sideSizeRenderer,
+ sideCountRenderer,
+ statusRenderer,
+} from '../../diff/diff_schemas';
+import type {DiffStatus} from '../../diff/diff_rows';
+
+interface ObjectDiffViewAttrs {
+ readonly currentEngine: Engine;
+ readonly baselineEngine: Engine;
+ readonly activeDump: HeapDump;
+ readonly currentId: number | null;
+ readonly baselineId: number | null;
+ readonly navigate: NavFn;
+}
+
+function renderPrimOrRef(v: PrimOrRef): string {
+ return v.kind === 'prim' ? v.v : v.display;
+}
+
+function primOrRefEqual(a: PrimOrRef, b: PrimOrRef): boolean {
+ if (a.kind !== b.kind) return false;
+ if (a.kind === 'prim' && b.kind === 'prim') return a.v === b.v;
+ if (a.kind === 'ref' && b.kind === 'ref') {
+ // Pure ID equality is useless cross-trace as parser IDs are unstable.
+ // As a heuristic for field-level diffs where full object pairing is
+ // unavailable, we consider references equal if they point to objects
+ // of the same class and with the same string value (if present).
+ //
+ // Note: `a.display` is formatted as "ClassName 0xAddress". We extract
+ // the class name by taking the part before the space.
+ const classA = a.display.split(' ')[0];
+ const classB = b.display.split(' ')[0];
+ return classA === classB && (a.str ?? '') === (b.str ?? '');
+ }
+ return false;
+}
+
+function fieldStatus(
+ c: PrimOrRef | undefined,
+ b: PrimOrRef | undefined,
+): DiffStatus {
+ if (c === undefined && b === undefined) return 'UNCHANGED';
+ if (c === undefined) return 'REMOVED';
+ if (b === undefined) return 'NEW';
+ return primOrRefEqual(c, b) ? 'UNCHANGED' : 'GREW';
+}
+
+const FIELD_DIFF_SCHEMA: SchemaRegistry = {
+ query: {
+ name: {title: 'Name', columnType: 'text'},
+ type_name: {title: 'Type', columnType: 'text'},
+ status: {
+ title: 'Status',
+ columnType: 'text',
+ cellRenderer: statusRenderer,
+ },
+ b_value: {title: 'Baseline', columnType: 'text'},
+ c_value: {title: 'Current', columnType: 'text'},
+ },
+};
+
+const FIELD_DIFF_COLS = [
+ {id: 'name', field: 'name'},
+ {id: 'type_name', field: 'type_name'},
+ {id: 'status', field: 'status'},
+ {id: 'b_value', field: 'b_value'},
+ {id: 'c_value', field: 'c_value'},
+];
+
+const ARRAY_DIFF_SCHEMA: SchemaRegistry = {
+ query: {
+ idx: {title: 'Index', columnType: 'quantitative'},
+ status: {
+ title: 'Status',
+ columnType: 'text',
+ cellRenderer: statusRenderer,
+ },
+ b_value: {title: 'Baseline', columnType: 'text'},
+ c_value: {title: 'Current', columnType: 'text'},
+ },
+};
+
+const ARRAY_DIFF_COLS = [
+ {id: 'idx', field: 'idx'},
+ {id: 'status', field: 'status'},
+ {id: 'b_value', field: 'b_value'},
+ {id: 'c_value', field: 'c_value'},
+];
+
+interface SideValueRow extends Row {
+ metric: string;
+ baseline: number | null;
+ current: number | null;
+ delta: number | null;
+}
+
+// Per-row renderer dispatch: metrics whose name ends in 'Count' format
+// values as plain integers, the rest as byte sizes. Folds the "size" and
+// "count" rows into one grid so the section body holds a single child.
+const SIZE_OR_COUNT_SCHEMA: SchemaRegistry = (() => {
+ const isCountRow = (row: Row): boolean =>
+ String(row.metric ?? '').endsWith('Count');
+ const sideRenderer: CellRenderer = (v, row) =>
+ isCountRow(row) ? sideCountRenderer(v, row) : sideSizeRenderer(v, row);
+ const deltaRenderer: CellRenderer = (v, row) => {
+ if (v === null) {
+ return {
+ content: m('span', {class: 'ah-mono ah-muted'}, '—'),
+ align: 'right',
+ } satisfies CellRenderResult;
+ }
+ return isCountRow(row)
+ ? deltaCountRenderer(v, row)
+ : deltaSizeRenderer(v, row);
+ };
+ return {
+ query: {
+ metric: {title: 'Metric', columnType: 'text'},
+ baseline: {
+ title: 'Baseline',
+ columnType: 'quantitative',
+ cellRenderer: sideRenderer,
+ },
+ current: {
+ title: 'Current',
+ columnType: 'quantitative',
+ cellRenderer: sideRenderer,
+ },
+ delta: {
+ title: 'Δ',
+ columnType: 'quantitative',
+ cellRenderer: deltaRenderer,
+ },
+ },
+ };
+})();
+
+const SIZE_INITIAL_COLS = [
+ {id: 'metric', field: 'metric'},
+ {id: 'baseline', field: 'baseline'},
+ {id: 'current', field: 'current'},
+ {id: 'delta', field: 'delta'},
+];
+
+function diffStatus(
+ c: InstanceDetail | null,
+ b: InstanceDetail | null,
+): DiffStatus {
+ if (c && !b) return 'NEW';
+ if (!c && b) return 'REMOVED';
+ if (!c || !b) return 'UNCHANGED';
+ const cR = c.row.retainedTotal;
+ const bR = b.row.retainedTotal;
+ if (cR > bR) return 'GREW';
+ if (cR < bR) return 'SHRANK';
+ return 'UNCHANGED';
+}
+
+function nullableDelta(c: number | null, b: number | null): number | null {
+ if (c === null && b === null) return null;
+ return (c ?? 0) - (b ?? 0);
+}
+
+function renderSizeTable(
+ c: InstanceDetail | null,
+ b: InstanceDetail | null,
+): m.Children {
+ function retainedTotal(d: InstanceDetail): {java: number; native_: number} {
+ let java = 0;
+ let nat = 0;
+ for (const h of d.row.retainedByHeap) {
+ java += h.java;
+ nat += h.native_;
+ }
+ return {java, native_: nat};
+ }
+ const cRet = c ? retainedTotal(c) : null;
+ const bRet = b ? retainedTotal(b) : null;
+ const rows: SideValueRow[] = [
+ {
+ metric: 'Shallow',
+ baseline: b?.row.shallowJava ?? null,
+ current: c?.row.shallowJava ?? null,
+ delta: nullableDelta(
+ c?.row.shallowJava ?? null,
+ b?.row.shallowJava ?? null,
+ ),
+ },
+ {
+ metric: 'Shallow Native',
+ baseline: b?.row.shallowNative ?? null,
+ current: c?.row.shallowNative ?? null,
+ delta: nullableDelta(
+ c?.row.shallowNative ?? null,
+ b?.row.shallowNative ?? null,
+ ),
+ },
+ {
+ metric: 'Retained',
+ baseline: bRet?.java ?? null,
+ current: cRet?.java ?? null,
+ delta: nullableDelta(cRet?.java ?? null, bRet?.java ?? null),
+ },
+ {
+ metric: 'Retained Native',
+ baseline: bRet?.native_ ?? null,
+ current: cRet?.native_ ?? null,
+ delta: nullableDelta(cRet?.native_ ?? null, bRet?.native_ ?? null),
+ },
+ {
+ metric: 'Retained Count',
+ baseline: b?.row.retainedCount ?? null,
+ current: c?.row.retainedCount ?? null,
+ delta: nullableDelta(
+ c?.row.retainedCount ?? null,
+ b?.row.retainedCount ?? null,
+ ),
+ },
+ ];
+ return m(DataGrid, {
+ schema: SIZE_OR_COUNT_SCHEMA,
+ rootSchema: 'query',
+ data: rows,
+ initialColumns: SIZE_INITIAL_COLS,
+ });
+}
+
+function renderHeader(
+ c: InstanceDetail | null,
+ b: InstanceDetail | null,
+ status: DiffStatus,
+ navigate: NavFn,
+): m.Children {
+ const present = c ?? b;
+ if (present === null) return null;
+ const className = present.row.className;
+ const heap = present.row.heap;
+ const cId = c?.row.id ?? null;
+ const bId = b?.row.id ?? null;
+ // Plain header div (no card), matching non-diff ObjectView. The parent
+ // ah-view-stack already spaces it from the first Section — wrapping in
+ // ah-card + ah-mb-4 stacks two margin sources and looks cramped.
+ return m('div', [
+ m('h2', {class: 'ah-view-heading ah-view-heading--tight'}, [
+ `${shortClassName(className)} `,
+ cId !== null ? fmtHex(cId) : bId !== null ? fmtHex(bId) : '',
+ ' ',
+ m(
+ 'span',
+ {
+ 'class': statusClass(status),
+ 'aria-label': `Status: ${status}`,
+ },
+ statusLabel(status),
+ ),
+ ]),
+ m('div', {class: 'ah-info-grid'}, [
+ m('span', {class: 'ah-info-grid__label'}, 'Class:'),
+ m('span', className),
+ m('span', {class: 'ah-info-grid__label'}, 'Heap:'),
+ m('span', heap),
+ cId !== null
+ ? [
+ m('span', {class: 'ah-info-grid__label'}, 'Current id:'),
+ m('span', [
+ m(
+ 'button',
+ {
+ class: 'ah-link',
+ onclick: () =>
+ navigate('object', {id: cId, label: undefined}),
+ },
+ fmtHex(cId),
+ ),
+ ]),
+ ]
+ : null,
+ bId !== null
+ ? [
+ m('span', {class: 'ah-info-grid__label'}, 'Baseline id:'),
+ m('span', {class: 'ah-mono ah-muted'}, fmtHex(bId)),
+ ]
+ : null,
+ ]),
+ ]);
+}
+
+function statusLabel(s: DiffStatus): string {
+ switch (s) {
+ case 'NEW':
+ return 'NEW';
+ case 'REMOVED':
+ return 'REMOVED';
+ case 'GREW':
+ return 'GREW';
+ case 'SHRANK':
+ return 'SHRANK';
+ case 'UNCHANGED':
+ return 'unchanged';
+ }
+}
+
+function statusClass(s: DiffStatus): string {
+ return `ah-status-text ah-status-text--${s.toLowerCase()}`;
+}
+
+function renderFieldsDiff(
+ c: InstanceDetail | null,
+ b: InstanceDetail | null,
+): m.Children {
+ const cFields = c?.instanceFields ?? [];
+ const bFields = b?.instanceFields ?? [];
+ if (cFields.length === 0 && bFields.length === 0) return null;
+ const byKey = new Map<
+ string,
+ {
+ name: string;
+ typeName: string;
+ c?: PrimOrRef;
+ b?: PrimOrRef;
+ }
+ >();
+ for (const f of cFields) {
+ const k = `${f.name}\x1f${f.typeName}`;
+ byKey.set(k, {name: f.name, typeName: f.typeName, c: f.value});
+ }
+ for (const f of bFields) {
+ const k = `${f.name}\x1f${f.typeName}`;
+ const existing = byKey.get(k);
+ if (existing) {
+ existing.b = f.value;
+ } else {
+ byKey.set(k, {name: f.name, typeName: f.typeName, b: f.value});
+ }
+ }
+ const rows: Row[] = [];
+ for (const f of byKey.values()) {
+ rows.push({
+ name: f.name,
+ type_name: f.typeName,
+ status: fieldStatus(f.c, f.b),
+ c_value: f.c ? renderPrimOrRef(f.c) : null,
+ b_value: f.b ? renderPrimOrRef(f.b) : null,
+ });
+ }
+ rows.sort((a, b) => String(a.name).localeCompare(String(b.name)));
+ return m(
+ Section,
+ {title: 'Fields', defaultOpen: rows.length < 50},
+ m(DataGrid, {
+ schema: FIELD_DIFF_SCHEMA,
+ rootSchema: 'query',
+ data: rows,
+ initialColumns: FIELD_DIFF_COLS,
+ }),
+ );
+}
+
+function instanceRowToRaw(r: InstanceRow): ObjectRowRaw {
+ return {
+ id: r.id,
+ className: r.className,
+ heapType: r.heap,
+ valueString: r.str,
+ arrayLength: null,
+ shallow: r.shallowJava,
+ shallowNative: r.shallowNative,
+ retained: r.retainedTotal,
+ retainedNative: 0,
+ retainedCount: r.retainedCount,
+ };
+}
+
+function buildInstanceDiffSchema(navigate: NavFn): SchemaRegistry {
+ const idRenderer: CellRenderer = (value) => {
+ if (value === null || value === undefined) {
+ return {
+ content: m('span', {class: 'ah-mono ah-muted'}, '—'),
+ align: 'right',
+ } satisfies CellRenderResult;
+ }
+ return {
+ content: m('span', {class: 'ah-mono'}, fmtHex(Number(value))),
+ align: 'right',
+ } satisfies CellRenderResult;
+ };
+ const objectRenderer: CellRenderer = (_value, row) => {
+ const cls = String(row.cls ?? '');
+ const cId = row.c_id == null ? null : Number(row.c_id);
+ const bId = row.b_id == null ? null : Number(row.b_id);
+ const str = row.str == null ? null : String(row.str);
+ const displayId = cId ?? bId ?? 0;
+ const display = `${shortClassName(cls)} ${fmtHex(displayId)}`;
+ return {
+ content: m(
+ 'button',
+ {
+ class: 'ah-link',
+ onclick: () =>
+ navigate('object', {
+ id: cId ?? bId ?? 0,
+ currentId: cId,
+ baselineId: bId,
+ label: str ? `"${str.slice(0, 30)}"` : display,
+ }),
+ },
+ display,
+ ),
+ align: 'left',
+ } satisfies CellRenderResult;
+ };
+ return {
+ query: {
+ cls: {
+ title: 'Object',
+ columnType: 'text',
+ cellRenderer: objectRenderer,
+ },
+ status: {
+ title: 'Status',
+ columnType: 'text',
+ cellRenderer: statusRenderer,
+ },
+ delta_retained: {
+ title: 'Δ Retained',
+ columnType: 'quantitative',
+ cellRenderer: deltaSizeRenderer,
+ },
+ b_retained: {
+ title: 'Baseline Retained',
+ columnType: 'quantitative',
+ cellRenderer: sideSizeRenderer,
+ },
+ c_retained: {
+ title: 'Current Retained',
+ columnType: 'quantitative',
+ cellRenderer: sideSizeRenderer,
+ },
+ str: {title: 'String Value', columnType: 'text'},
+ c_id: {
+ title: 'Current id',
+ columnType: 'identifier',
+ cellRenderer: idRenderer,
+ },
+ b_id: {
+ title: 'Baseline id',
+ columnType: 'identifier',
+ cellRenderer: idRenderer,
+ },
+ },
+ };
+}
+
+const INSTANCE_DIFF_COLS = [
+ {id: 'cls', field: 'cls'},
+ {id: 'status', field: 'status'},
+ {id: 'delta_retained', field: 'delta_retained', sort: 'DESC' as const},
+ {id: 'b_retained', field: 'b_retained'},
+ {id: 'c_retained', field: 'c_retained'},
+ {id: 'str', field: 'str'},
+ // c_id / b_id are required by the Object column's click renderer —
+ // InMemoryDataSource drops fields not declared as visible columns.
+ {id: 'c_id', field: 'c_id'},
+ {id: 'b_id', field: 'b_id'},
+];
+
+function pairToInstanceDiffRow(p: ObjectPairRow): Row {
+ return {
+ cls: p.className,
+ status: p.status,
+ str: p.valueString,
+ c_id: p.c_id,
+ b_id: p.b_id,
+ delta_retained: p.delta_retained,
+ c_retained: p.c_retained,
+ b_retained: p.b_retained,
+ };
+}
+
+function renderInstanceListDiff(
+ title: string,
+ current: ReadonlyArray,
+ baseline: ReadonlyArray,
+ navigate: NavFn,
+): m.Children {
+ if (current.length === 0 && baseline.length === 0) return null;
+ const paired = pairObjects(
+ current.map(instanceRowToRaw),
+ baseline.map(instanceRowToRaw),
+ );
+ paired.sort(
+ (a, b) => Math.abs(b.delta_retained) - Math.abs(a.delta_retained),
+ );
+ const rows = paired.map(pairToInstanceDiffRow);
+ return m(
+ Section,
+ {
+ title: `${title} (${paired.length})`,
+ defaultOpen: paired.length > 0 && paired.length < 50,
+ },
+ m(DataGrid, {
+ schema: buildInstanceDiffSchema(navigate),
+ rootSchema: 'query',
+ data: rows,
+ initialColumns: INSTANCE_DIFF_COLS,
+ }),
+ );
+}
+
+function renderArrayDiff(
+ c: InstanceDetail | null,
+ b: InstanceDetail | null,
+): m.Children {
+ const cIsArr = c?.isArrayInstance ?? false;
+ const bIsArr = b?.isArrayInstance ?? false;
+ if (!cIsArr && !bIsArr) return null;
+ const cElems = c?.arrayElems ?? [];
+ const bElems = b?.arrayElems ?? [];
+ const cByIdx = new Map(
+ cElems.map((e) => [e.idx, e.value]),
+ );
+ const bByIdx = new Map(
+ bElems.map((e) => [e.idx, e.value]),
+ );
+ const idxs = new Set([...cByIdx.keys(), ...bByIdx.keys()]);
+ const rows: Row[] = [];
+ for (const i of idxs) {
+ const cv = cByIdx.get(i);
+ const bv = bByIdx.get(i);
+ rows.push({
+ idx: i,
+ status: fieldStatus(cv, bv),
+ c_value: cv ? renderPrimOrRef(cv) : null,
+ b_value: bv ? renderPrimOrRef(bv) : null,
+ });
+ }
+ rows.sort((a, b) => Number(a.idx) - Number(b.idx));
+ const cLen = c?.arrayLength ?? 0;
+ const bLen = b?.arrayLength ?? 0;
+ const title = `Array Elements (current: ${cLen}, baseline: ${bLen})`;
+ return m(
+ Section,
+ {title, defaultOpen: rows.length < 50},
+ m(DataGrid, {
+ schema: ARRAY_DIFF_SCHEMA,
+ rootSchema: 'query',
+ data: rows,
+ initialColumns: ARRAY_DIFF_COLS,
+ }),
+ );
+}
+
+function ObjectDiffView(): m.Component {
+ let currentVnode: m.Vnode;
+ let current: InstanceDetail | null = null;
+ let baseline: InstanceDetail | null = null;
+ let loading = false;
+ let error: string | null = null;
+ let lastCid: number | null | undefined = undefined;
+ let lastBid: number | null | undefined = undefined;
+ let lastCe: Engine | null = null;
+ let lastBe: Engine | null = null;
+
+ async function load(
+ currentEngine: Engine,
+ baselineEngine: Engine,
+ currentId: number | null,
+ baselineId: number | null,
+ ) {
+ const primarySnap = currentVnode.attrs.activeDump;
+ const baselineSnap = getActiveBaseline();
+ if (baselineSnap === null) return;
+ const isStale = () =>
+ currentVnode.attrs.activeDump !== primarySnap ||
+ getActiveBaseline() !== baselineSnap;
+ loading = true;
+ error = null;
+ try {
+ const [c, b] = await Promise.all([
+ currentId !== null
+ ? queries.getInstance(currentEngine, primarySnap, currentId)
+ : Promise.resolve(null),
+ baselineId !== null
+ ? queries.getInstance(baselineEngine, baselineSnap.dump, baselineId)
+ : Promise.resolve(null),
+ ]);
+ if (isStale()) return;
+ current = c;
+ baseline = b;
+ } catch (err) {
+ if (isStale()) return;
+ error = err instanceof Error ? err.message : String(err);
+ console.error('Object diff load failed:', err);
+ } finally {
+ loading = false;
+ m.redraw();
+ }
+ }
+
+ function ensureLoaded(
+ currentEngine: Engine,
+ baselineEngine: Engine,
+ currentId: number | null,
+ baselineId: number | null,
+ ) {
+ if (
+ currentEngine === lastCe &&
+ baselineEngine === lastBe &&
+ currentId === lastCid &&
+ baselineId === lastBid
+ ) {
+ return;
+ }
+ lastCe = currentEngine;
+ lastBe = baselineEngine;
+ lastCid = currentId;
+ lastBid = baselineId;
+ current = null;
+ baseline = null;
+ error = null;
+ if (currentId !== null || baselineId !== null) {
+ load(currentEngine, baselineEngine, currentId, baselineId).catch(
+ console.error,
+ );
+ }
+ }
+
+ return {
+ oninit(vnode) {
+ currentVnode = vnode;
+ ensureLoaded(
+ vnode.attrs.currentEngine,
+ vnode.attrs.baselineEngine,
+ vnode.attrs.currentId,
+ vnode.attrs.baselineId,
+ );
+ },
+ onupdate(vnode) {
+ currentVnode = vnode;
+ ensureLoaded(
+ vnode.attrs.currentEngine,
+ vnode.attrs.baselineEngine,
+ vnode.attrs.currentId,
+ vnode.attrs.baselineId,
+ );
+ },
+ view(vnode) {
+ currentVnode = vnode;
+ const {currentId, baselineId, navigate} = vnode.attrs;
+ if (currentId === null && baselineId === null) {
+ return m(EmptyState, {
+ icon: 'memory',
+ title: 'No object selected',
+ fillHeight: true,
+ });
+ }
+ if (loading && !current && !baseline) {
+ return m('div', {class: 'ah-loading'}, m(Spinner, {easing: true}));
+ }
+ if (error) {
+ return m(EmptyState, {
+ icon: 'error',
+ title: `Failed to load object diff: ${error}`,
+ fillHeight: true,
+ });
+ }
+ if (!current && !baseline) {
+ return m(EmptyState, {
+ icon: 'memory',
+ title: 'Object not found',
+ fillHeight: true,
+ });
+ }
+ const status = diffStatus(current, baseline);
+ return m('div', {class: 'ah-view-scroll ah-view-stack'}, [
+ renderHeader(current, baseline, status, navigate),
+ m(
+ Section,
+ {title: 'Object Size', defaultOpen: true},
+ renderSizeTable(current, baseline),
+ ),
+ renderFieldsDiff(current, baseline),
+ renderArrayDiff(current, baseline),
+ renderInstanceListDiff(
+ 'Objects with References to this Object',
+ current?.reverseRefs ?? [],
+ baseline?.reverseRefs ?? [],
+ navigate,
+ ),
+ renderInstanceListDiff(
+ 'Immediately Dominated Objects',
+ current?.dominated ?? [],
+ baseline?.dominated ?? [],
+ navigate,
+ ),
+ ]);
+ },
+ };
+}
+
+export default ObjectDiffView;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/strings_diff_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/strings_diff_view.ts
new file mode 100644
index 00000000000..94f0a932ba9
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/diff/strings_diff_view.ts
@@ -0,0 +1,208 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Strings diff: GROUP BY value_string per engine, JS outer-join.
+
+import m from 'mithril';
+import {Spinner} from '../../../../widgets/spinner';
+import {EmptyState} from '../../../../widgets/empty_state';
+import type {Engine} from '../../../../trace_processor/engine';
+import {NUM, STR} from '../../../../trace_processor/query_result';
+import type {Row} from '../../../../trace_processor/query_result';
+import {DataGrid} from '../../../../components/widgets/datagrid/datagrid';
+import {InMemoryDataSource} from '../../../../components/widgets/datagrid/in_memory_data_source';
+import type {NavFn} from '../../components';
+import type {DiffRow} from '../../diff/diff_rows';
+import {
+ compareByAbsDeltaDesc,
+ dedupeByKey,
+ mergeRows,
+} from '../../diff/diff_rows';
+import {publishDiffRows} from '../../diff/diff_debug';
+import {
+ buildSizeCountInitialColumns,
+ buildSizeCountSchema,
+} from '../../diff/diff_schemas';
+import {baselineDumpFilterSql, getActiveBaseline} from '../../baseline/state';
+import {dumpFilterSql, getActiveDump} from '../../queries';
+
+function buildQuery(filterSql: string): string {
+ return `
+ SELECT
+ od.value_string AS value,
+ COUNT(*) AS cnt,
+ SUM(o.self_size) AS retained
+ FROM heap_graph_object o
+ JOIN heap_graph_class c ON o.type_id = c.id
+ JOIN heap_graph_object_data od ON o.object_data_id = od.id
+ WHERE o.reachable != 0
+ AND ${filterSql}
+ AND od.value_string IS NOT NULL
+ AND (c.name = 'java.lang.String'
+ OR c.deobfuscated_name = 'java.lang.String')
+ GROUP BY od.value_string
+ `;
+}
+
+const ITER_SPEC = {value: STR, cnt: NUM, retained: NUM};
+
+interface StringsDiffViewAttrs {
+ readonly currentEngine: Engine;
+ readonly baselineEngine: Engine;
+ readonly navigate: NavFn;
+}
+
+const NUMERIC_FIELDS = ['cnt', 'retained'];
+
+async function runQuery(engine: Engine, filterSql: string): Promise {
+ const res = await engine.query(buildQuery(filterSql));
+ const out: Row[] = [];
+ for (const it = res.iter(ITER_SPEC); it.valid(); it.next()) {
+ out.push({value: it.value, cnt: it.cnt, retained: it.retained});
+ }
+ return dedupeByKey(out, (r) => String(r.value ?? ''), NUMERIC_FIELDS);
+}
+
+function StringsDiffView(): m.Component {
+ let rows: DiffRow[] | null = null;
+ let loading = false;
+ let error: string | null = null;
+ let dataSource: InMemoryDataSource | null = null;
+ let lastB: Engine | null = null;
+ let lastC: Engine | null = null;
+
+ async function load(currentEngine: Engine, baselineEngine: Engine) {
+ const primarySnap = getActiveDump();
+ const baselineSnap = getActiveBaseline();
+ if (!primarySnap || !baselineSnap) return;
+ const isStale = () =>
+ getActiveDump() !== primarySnap || getActiveBaseline() !== baselineSnap;
+ loading = true;
+ error = null;
+ try {
+ const baselineFilter = baselineDumpFilterSql('o');
+ const currentFilter = dumpFilterSql(undefined, 'o');
+ const [b, c] = await Promise.all([
+ runQuery(baselineEngine, baselineFilter),
+ runQuery(currentEngine, currentFilter),
+ ]);
+ if (isStale()) return;
+ const merged = mergeRows({
+ baseline: b,
+ current: c,
+ keyOf: (r) => String(r.value ?? ''),
+ numericFields: NUMERIC_FIELDS,
+ primaryDeltaField: 'retained',
+ });
+ merged.sort(compareByAbsDeltaDesc('retained'));
+ rows = merged;
+ dataSource = new InMemoryDataSource(merged);
+ publishDiffRows('strings', merged);
+ } catch (err) {
+ if (isStale()) return;
+ error = err instanceof Error ? err.message : String(err);
+ console.error('Strings diff load failed:', err);
+ } finally {
+ loading = false;
+ m.redraw();
+ }
+ }
+
+ function ensure(currentEngine: Engine, baselineEngine: Engine) {
+ if (currentEngine !== lastC || baselineEngine !== lastB) {
+ lastC = currentEngine;
+ lastB = baselineEngine;
+ rows = null;
+ dataSource = null;
+ load(currentEngine, baselineEngine).catch(console.error);
+ }
+ }
+
+ return {
+ oninit(vnode) {
+ ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine);
+ },
+ onupdate(vnode) {
+ ensure(vnode.attrs.currentEngine, vnode.attrs.baselineEngine);
+ },
+ view(vnode) {
+ const {navigate} = vnode.attrs;
+ if (loading && !rows) {
+ return m('div', {class: 'ah-loading'}, m(Spinner, {easing: true}));
+ }
+ if (error) {
+ return m(EmptyState, {
+ icon: 'error',
+ title: `Failed to compute Strings diff: ${error}`,
+ fillHeight: true,
+ });
+ }
+ if (!rows || !dataSource) {
+ return m(EmptyState, {
+ icon: 'text_fields',
+ title: 'No string data to diff',
+ fillHeight: true,
+ });
+ }
+
+ const size = {
+ field: 'retained',
+ title: 'Retained',
+ kind: 'size' as const,
+ };
+ const count = {field: 'cnt', title: 'Count', kind: 'count' as const};
+ const schema = buildSizeCountSchema({
+ keyTitle: 'Value',
+ keyRenderer: (value) => {
+ const str = String(value ?? '');
+ // Display-only truncation; navigation uses the full string.
+ const truncated = str.length > 200 ? `${str.slice(0, 200)}…` : str;
+ return m(
+ 'button',
+ {
+ class: 'ah-link ah-mono ah-break-all ah-str-color',
+ onclick: () => navigate('strings', {q: str}),
+ },
+ JSON.stringify(truncated),
+ );
+ },
+ size,
+ count,
+ });
+
+ const initialColumns = buildSizeCountInitialColumns({size, count});
+
+ return m('div', {class: 'ah-view-content'}, [
+ m('h2', {class: 'ah-view-heading'}, [
+ 'Strings diff ',
+ m(
+ 'span',
+ {class: 'ah-muted'},
+ `(${rows.length.toLocaleString()} values)`,
+ ),
+ ]),
+ m(DataGrid, {
+ schema,
+ rootSchema: 'query',
+ data: dataSource,
+ fillHeight: true,
+ initialColumns,
+ showExportButton: true,
+ }),
+ ]);
+ },
+ };
+}
+
+export default StringsDiffView;
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/flamegraph_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/flamegraph_view.ts
index 5603d4171f8..c06a7559dbc 100644
--- a/ui/src/plugins/com.android.HeapDumpExplorer/views/flamegraph_view.ts
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/flamegraph_view.ts
@@ -27,6 +27,15 @@ import {
export const METRIC_OBJECT_SIZE = 'Object Size';
export const METRIC_DOMINATED_OBJECT_SIZE = 'Dominated Object Size';
+// Same-trace baseline: dump (upid, ts) within the same engine. The diff
+// SQL JOINs the current and baseline class trees on `path_hash_stable`
+// (computed by hashing parent path + type_id + heap_type — stable across
+// dumps in a single trace because class ids are trace-global).
+export interface FlamegraphBaselineRef {
+ readonly upid: number;
+ readonly ts: time;
+}
+
interface FlamegraphViewAttrs {
readonly trace: Trace;
readonly upid: number;
@@ -35,6 +44,10 @@ interface FlamegraphViewAttrs {
readonly onStateChange: (state: FlamegraphState) => void;
// Open the flamegraph-objects tab for `pathHashes` (CSV).
readonly onShowObjects: (pathHashes: string, isDominator: boolean) => void;
+ // When set, build diff metrics that color nodes by delta direction and
+ // size them by |delta|. Only same-trace baselines are supported here —
+ // cross-engine SQL JOINs aren't possible across separate workers.
+ readonly baseline?: FlamegraphBaselineRef;
}
// path_hash_stable is exposed unaggregatable (and CAST to TEXT in SQL,
@@ -162,29 +175,228 @@ function buildHeapGraphMetrics(
);
}
+// ---------- Diff metrics (same-trace) -------------------------------------
+
+// Build a JAVA_HEAP_GRAPH diff metric. `value` is `abs(delta)` so width
+// reflects movement magnitude; `color_hint` encodes direction.
+//
+// Pairing uses a name-based path hash recomputed via _graph_scan: the
+// stdlib's `path_hash_stable` is hashed from class **ids**, which are
+// per-process and not even always shared across dumps of one upid.
+// Hashing class names + heap_type instead makes the join key stable
+// across processes and across dumps within one trace.
+function buildDiffMetric(
+ cur: FlamegraphBaselineRef,
+ base: FlamegraphBaselineRef,
+ name: string,
+ unit: string,
+ valueColumn: 'self_size' | 'self_count',
+ isDominator: boolean,
+ showObjectsAction: FlamegraphOptionalAction,
+): QueryFlamegraphMetric {
+ const tree = isDominator
+ ? '_heap_graph_dominator_class_tree'
+ : '_heap_graph_class_tree';
+ const dependencyModule = isDominator
+ ? 'android.memory.heap_graph.dominator_class_tree'
+ : 'android.memory.heap_graph.class_tree';
+ const dim = valueColumn === 'self_size' ? 'size' : 'count';
+ // _graph_scan propagates a hash from each node to its children, where
+ // each step folds the child's name + heap_type into the parent hash.
+ // The resulting `h` is a path-of-names hash — stable across dumps and
+ // processes wherever the same class-name path exists.
+ const pathHashScan = (upid: number, ts: number | bigint): string => `
+ _graph_scan!(
+ (
+ SELECT parent_id AS source_node_id, id AS dest_node_id
+ FROM ${tree}
+ WHERE upid = ${upid} AND graph_sample_ts = ${ts}
+ AND parent_id IS NOT NULL
+ ),
+ (
+ SELECT id, HASH(IFNULL(name, ''), IFNULL(heap_type, '')) AS h
+ FROM ${tree}
+ WHERE upid = ${upid} AND graph_sample_ts = ${ts}
+ AND parent_id IS NULL
+ ),
+ (h),
+ (
+ SELECT t.id,
+ HASH(t.h, IFNULL(c.name, ''), IFNULL(c.heap_type, '')) AS h
+ FROM $table t
+ JOIN ${tree} c ON c.id = t.id
+ )
+ )
+ `;
+ const statement = `
+ WITH
+ cur_path_hash AS (SELECT * FROM ${pathHashScan(cur.upid, cur.ts)}),
+ base_path_hash AS (SELECT * FROM ${pathHashScan(base.upid, base.ts)}),
+ cur_nodes AS (
+ SELECT t.id, t.parent_id, ifnull(t.name, '[Unknown]') AS name,
+ t.root_type, t.heap_type,
+ ph.h AS path_h,
+ t.self_size AS c_self_size, t.self_count AS c_self_count
+ FROM ${tree} t JOIN cur_path_hash ph USING (id)
+ WHERE t.upid = ${cur.upid} AND t.graph_sample_ts = ${cur.ts}
+ ),
+ base_nodes AS (
+ SELECT ph.h AS path_h,
+ t.self_size AS b_self_size,
+ t.self_count AS b_self_count
+ FROM ${tree} t JOIN base_path_hash ph USING (id)
+ WHERE t.upid = ${base.upid} AND t.graph_sample_ts = ${base.ts}
+ ),
+ joined AS (
+ SELECT
+ c.id,
+ c.parent_id,
+ c.name,
+ c.root_type,
+ c.heap_type,
+ c.path_h,
+ c.c_self_size, c.c_self_count,
+ ifnull(b.b_self_size, 0) AS b_self_size,
+ ifnull(b.b_self_count, 0) AS b_self_count,
+ c.c_self_size - ifnull(b.b_self_size, 0) AS delta_size,
+ c.c_self_count - ifnull(b.b_self_count, 0) AS delta_count,
+ b.path_h IS NULL AS is_new
+ FROM cur_nodes c LEFT JOIN base_nodes b USING (path_h)
+ ),
+ stats AS (SELECT max(abs(delta_${dim})) AS m FROM joined)
+ SELECT
+ j.id,
+ j.parent_id AS parentId,
+ j.name,
+ j.root_type,
+ j.heap_type,
+ CAST(j.path_h AS TEXT) AS path_hash_stable,
+ abs(j.delta_${dim}) AS value,
+ j.c_self_size, j.b_self_size, j.delta_size,
+ j.c_self_count, j.b_self_count, j.delta_count,
+ -- color_hint format: see getColorSchemeFromHint in flamegraph.ts.
+ CASE
+ WHEN s.m IS NULL OR s.m = 0 THEN 'palette:u'
+ WHEN j.is_new = 1 THEN 'palette:n'
+ WHEN j.delta_${dim} = 0 THEN 'palette:u'
+ WHEN j.delta_${dim} > 0
+ THEN printf('palette:g:%.3f',
+ abs(j.delta_${dim}) * 1.0 / s.m)
+ ELSE printf('palette:s:%.3f',
+ abs(j.delta_${dim}) * 1.0 / s.m)
+ END AS color_hint
+ FROM joined j CROSS JOIN stats s
+ `;
+ return {
+ name,
+ unit,
+ dependencySql:
+ `include perfetto module ${dependencyModule};\n` +
+ `include perfetto module graphs.scan;`,
+ statement,
+ unaggregatableProperties: UNAGG_PROPS,
+ aggregatableProperties: [
+ {
+ name: 'c_self_size',
+ displayName: 'Current Size',
+ mergeAggregation: 'SUM' as const,
+ },
+ {
+ name: 'b_self_size',
+ displayName: 'Baseline Size',
+ mergeAggregation: 'SUM' as const,
+ },
+ {
+ name: 'delta_size',
+ displayName: 'Δ Size',
+ mergeAggregation: 'SUM' as const,
+ },
+ {
+ name: 'c_self_count',
+ displayName: 'Current Count',
+ mergeAggregation: 'SUM' as const,
+ },
+ {
+ name: 'b_self_count',
+ displayName: 'Baseline Count',
+ mergeAggregation: 'SUM' as const,
+ },
+ {
+ name: 'delta_count',
+ displayName: 'Δ Count',
+ mergeAggregation: 'SUM' as const,
+ },
+ ],
+ optionalNodeActions: [showObjectsAction],
+ colorHint: true,
+ };
+}
+
+function buildHeapGraphDiffMetrics(
+ cur: FlamegraphBaselineRef,
+ base: FlamegraphBaselineRef,
+ onShowObjects: (pathHashes: string, isDominator: boolean) => void,
+): ReadonlyArray {
+ const showObjectsAction = (
+ isDominator: boolean,
+ ): FlamegraphOptionalAction => ({
+ name: 'Show objects from this class',
+ execute: async ({properties}) => {
+ const pathHashes = properties.get('path_hash_stable');
+ if (pathHashes === undefined) return;
+ onShowObjects(pathHashes, isDominator);
+ },
+ });
+ // Only nodes present in current are paired here — nodes that exist
+ // in baseline but not current (REMOVED) are dropped because they have
+ // no place in the current tree's id/parent_id structure. They show up
+ // when the user flips primary and baseline.
+ return METRIC_SPECS.map((s) =>
+ buildDiffMetric(
+ cur,
+ base,
+ `Δ ${s.name}`,
+ s.unit,
+ s.valueColumn,
+ s.isDominator,
+ showObjectsAction(s.isDominator),
+ ),
+ );
+}
+
const FlamegraphView: m.ClosureComponent = () => {
let cachedMetrics: ReadonlyArray | undefined;
let cachedKey: string | undefined;
return {
view({attrs}) {
- const key = `${attrs.upid}:${attrs.ts}`;
- if (cachedMetrics === undefined || key !== cachedKey) {
- cachedMetrics = buildHeapGraphMetrics(
- attrs.upid,
- attrs.ts,
- attrs.onShowObjects,
- );
+ const baselineKey = attrs.baseline
+ ? `${attrs.baseline.upid}:${attrs.baseline.ts}`
+ : 'none';
+ const key = `${attrs.upid}:${attrs.ts}|${baselineKey}`;
+ const metricsChanged = cachedMetrics === undefined || key !== cachedKey;
+ if (metricsChanged || cachedMetrics === undefined) {
+ cachedMetrics = attrs.baseline
+ ? buildHeapGraphDiffMetrics(
+ {upid: attrs.upid, ts: attrs.ts},
+ attrs.baseline,
+ attrs.onShowObjects,
+ )
+ : buildHeapGraphMetrics(attrs.upid, attrs.ts, attrs.onShowObjects);
cachedKey = key;
}
- const metrics = cachedMetrics;
-
- // First render or after a dump-change reset: create a default
- // state so the panel renders meaningfully on the same frame.
+ const metrics: ReadonlyArray = cachedMetrics;
+ // Either first render OR a dump/baseline change just swapped the
+ // metric list. Diff mode renames every metric (`Δ Object Size`
+ // etc.), so a stale state.selectedMetricName from before the flip
+ // points at a metric that no longer exists. Flamegraph.updateState
+ // rebuilds the state, falling back to the first metric when the
+ // selection disappeared. Without this the panel either renders
+ // nothing or stays on the old metric set.
let state = attrs.state;
- if (state === undefined) {
- state = Flamegraph.createDefaultState(metrics);
+ if (state === undefined || metricsChanged) {
+ state = Flamegraph.updateState(state, metrics);
attrs.onStateChange(state);
}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/overview/duplicates_section.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/overview/duplicates_section.ts
new file mode 100644
index 00000000000..4b583a792a5
--- /dev/null
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/overview/duplicates_section.ts
@@ -0,0 +1,640 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Duplicate-Bitmaps / Strings / Arrays cards on the Overview tab. Single
+// engine: a flat DataGrid. Diff: outer-join on the duplicate key with
+// Baseline / Current / Δ columns.
+
+import m from 'mithril';
+import type {SqlValue, Row} from '../../../../trace_processor/query_result';
+import {DataGrid} from '../../../../components/widgets/datagrid/datagrid';
+import type {
+ CellRenderer,
+ CellRenderResult,
+ ColumnDef,
+ SchemaRegistry,
+} from '../../../../components/widgets/datagrid/datagrid_schema';
+import type {OverviewData} from '../../types';
+import {fmtSize} from '../../format';
+import type {NavState} from '../../nav_state';
+import {type NavFn, sizeRenderer} from '../../components';
+import {
+ baselineCol,
+ currentCol,
+ dedupeByKey,
+ deltaCol,
+ mergeRows,
+ KEY_COL,
+ STATUS_COL,
+} from '../../diff/diff_rows';
+import {
+ deltaCountRenderer,
+ deltaSizeRenderer,
+ sideCountRenderer,
+ sideSizeRenderer,
+ statusRenderer,
+} from '../../diff/diff_schemas';
+
+export function renderDuplicateBitmapsCard(
+ overview: OverviewData,
+ baselineOverview: OverviewData | undefined,
+ navigate: NavFn,
+): m.Children {
+ const cur = overview.duplicateBitmaps ?? [];
+ const base = baselineOverview?.duplicateBitmaps ?? [];
+ if (cur.length === 0 && base.length === 0 && !overview.hasFieldValues) {
+ return null;
+ }
+ if (cur.length === 0 && base.length === 0) {
+ return m(
+ 'div',
+ {class: 'ah-card ah-mb-4'},
+ m('p', {class: 'ah-muted'}, 'No duplicate bitmaps found.'),
+ );
+ }
+ const isDiff = baselineOverview !== undefined;
+ const summary = makeSummary('group', cur, base, (g) => g.wastedBytes, isDiff);
+
+ if (!isDiff) {
+ return renderDuplicateSectionSingle({
+ title: 'Duplicate Bitmaps',
+ summary,
+ targetView: 'bitmaps',
+ linkLabel: 'View Bitmaps',
+ navigate,
+ data: cur.map((g) => ({
+ dimensions: `${g.width} × ${g.height}`,
+ groupKey: g.groupKey,
+ copies: g.count,
+ total_bytes: g.totalBytes,
+ wasted_bytes: g.wastedBytes,
+ })),
+ schema: {
+ query: {
+ dimensions: {title: 'Dimensions', columnType: 'text'},
+ groupKey: {title: 'Hash', columnType: 'text'},
+ copies: {
+ title: 'Copies',
+ columnType: 'quantitative',
+ cellRenderer: makeNavCountRenderer((row) =>
+ navigate('bitmaps', {filterKey: String(row.groupKey ?? '')}),
+ ),
+ },
+ total_bytes: {
+ title: 'Total',
+ columnType: 'quantitative',
+ cellRenderer: sizeRenderer,
+ },
+ wasted_bytes: {
+ title: 'Wasted',
+ columnType: 'quantitative',
+ cellRenderer: sizeRenderer,
+ },
+ },
+ },
+ initialColumns: [
+ {id: 'dimensions', field: 'dimensions'},
+ {id: 'groupKey', field: 'groupKey'},
+ {id: 'copies', field: 'copies'},
+ {id: 'total_bytes', field: 'total_bytes'},
+ {id: 'wasted_bytes', field: 'wasted_bytes'},
+ ],
+ });
+ }
+
+ const numericFields = ['copies', 'total_bytes', 'wasted_bytes'];
+ const baseRows = dedupeByKey(
+ base.map((g) => ({
+ key: g.groupKey,
+ dimensions: `${g.width} × ${g.height}`,
+ copies: g.count,
+ total_bytes: g.totalBytes,
+ wasted_bytes: g.wastedBytes,
+ })),
+ (r) => String(r.key),
+ numericFields,
+ );
+ const curRows = dedupeByKey(
+ cur.map((g) => ({
+ key: g.groupKey,
+ dimensions: `${g.width} × ${g.height}`,
+ copies: g.count,
+ total_bytes: g.totalBytes,
+ wasted_bytes: g.wastedBytes,
+ })),
+ (r) => String(r.key),
+ numericFields,
+ );
+ const merged = mergeRows({
+ baseline: baseRows,
+ current: curRows,
+ keyOf: (r) => String(r.key),
+ numericFields,
+ passThroughFields: ['dimensions'],
+ primaryDeltaField: 'wasted_bytes',
+ });
+ return renderDuplicateSectionDiff({
+ title: 'Duplicate Bitmaps',
+ summary,
+ targetView: 'bitmaps',
+ linkLabel: 'View Bitmaps diff',
+ navigate,
+ data: merged.map((r) => ({...r, groupKey: r[KEY_COL]})),
+ keyTitle: 'Hash',
+ keyField: 'groupKey',
+ extraTextFields: [{field: 'dimensions', title: 'Dimensions'}],
+ sizeFields: [
+ {field: 'wasted_bytes', title: 'Wasted'},
+ {field: 'total_bytes', title: 'Total'},
+ ],
+ countFields: [{field: 'copies', title: 'Copies'}],
+ primarySortField: 'wasted_bytes',
+ });
+}
+
+export function renderDuplicateStringsCard(
+ overview: OverviewData,
+ baselineOverview: OverviewData | undefined,
+ navigate: NavFn,
+): m.Children {
+ const cur = overview.duplicateStrings ?? [];
+ const base = baselineOverview?.duplicateStrings ?? [];
+ if (cur.length === 0 && base.length === 0 && !overview.hasFieldValues) {
+ return null;
+ }
+ if (cur.length === 0 && base.length === 0) {
+ return m(
+ 'div',
+ {class: 'ah-card ah-mb-4'},
+ m('p', {class: 'ah-muted'}, 'No duplicate strings found.'),
+ );
+ }
+ const isDiff = baselineOverview !== undefined;
+ const summary = makeSummary('group', cur, base, (g) => g.wastedBytes, isDiff);
+
+ if (!isDiff) {
+ return renderDuplicateSectionSingle({
+ title: 'Duplicate Strings',
+ summary,
+ targetView: 'strings',
+ linkLabel: 'View Strings',
+ navigate,
+ data: cur.map((g) => ({
+ value: g.value,
+ copies: g.count,
+ total_bytes: g.totalBytes,
+ wasted_bytes: g.wastedBytes,
+ })),
+ schema: {
+ query: {
+ value: {
+ title: 'Value',
+ columnType: 'text',
+ cellRenderer: makeStringRenderer((row) =>
+ navigate('strings', {q: String(row.value ?? '')}),
+ ),
+ },
+ copies: {
+ title: 'Copies',
+ columnType: 'quantitative',
+ cellRenderer: makeNavCountRenderer((row) =>
+ navigate('strings', {q: String(row.value ?? '')}),
+ ),
+ },
+ total_bytes: {
+ title: 'Total',
+ columnType: 'quantitative',
+ cellRenderer: sizeRenderer,
+ },
+ wasted_bytes: {
+ title: 'Wasted',
+ columnType: 'quantitative',
+ cellRenderer: sizeRenderer,
+ },
+ },
+ },
+ initialColumns: [
+ {id: 'value', field: 'value'},
+ {id: 'copies', field: 'copies'},
+ {id: 'total_bytes', field: 'total_bytes'},
+ {id: 'wasted_bytes', field: 'wasted_bytes'},
+ ],
+ });
+ }
+
+ const numericFields = ['copies', 'total_bytes', 'wasted_bytes'];
+ const baseRows = dedupeByKey(
+ base.map((g) => ({
+ key: g.value,
+ copies: g.count,
+ total_bytes: g.totalBytes,
+ wasted_bytes: g.wastedBytes,
+ })),
+ (r) => String(r.key),
+ numericFields,
+ );
+ const curRows = dedupeByKey(
+ cur.map((g) => ({
+ key: g.value,
+ copies: g.count,
+ total_bytes: g.totalBytes,
+ wasted_bytes: g.wastedBytes,
+ })),
+ (r) => String(r.key),
+ numericFields,
+ );
+ const merged = mergeRows({
+ baseline: baseRows,
+ current: curRows,
+ keyOf: (r) => String(r.key),
+ numericFields,
+ primaryDeltaField: 'wasted_bytes',
+ });
+ return renderDuplicateSectionDiff({
+ title: 'Duplicate Strings',
+ summary,
+ targetView: 'strings',
+ linkLabel: 'View Strings diff',
+ navigate,
+ data: merged.map((r) => ({...r, value: r[KEY_COL]})),
+ keyTitle: 'Value',
+ keyField: 'value',
+ keyRenderer: makeStringRenderer((row) =>
+ navigate('strings', {q: String(row.value ?? '')}),
+ ),
+ extraTextFields: [],
+ sizeFields: [
+ {field: 'wasted_bytes', title: 'Wasted'},
+ {field: 'total_bytes', title: 'Total'},
+ ],
+ countFields: [{field: 'copies', title: 'Copies'}],
+ primarySortField: 'wasted_bytes',
+ });
+}
+
+export function renderDuplicateArraysCard(
+ overview: OverviewData,
+ baselineOverview: OverviewData | undefined,
+ navigate: NavFn,
+): m.Children {
+ const cur = overview.duplicateArrays ?? [];
+ const base = baselineOverview?.duplicateArrays ?? [];
+ if (cur.length === 0 && base.length === 0) return null;
+ const isDiff = baselineOverview !== undefined;
+ const summary = makeSummary('group', cur, base, (g) => g.wastedBytes, isDiff);
+
+ if (!isDiff) {
+ return renderDuplicateSectionSingle({
+ title: 'Duplicate Primitive Arrays',
+ summary,
+ targetView: 'arrays',
+ linkLabel: 'View Arrays',
+ navigate,
+ data: cur.map((g) => ({
+ className: g.className,
+ arrayHash: g.arrayHash,
+ copies: g.count,
+ total_bytes: g.totalBytes,
+ wasted_bytes: g.wastedBytes,
+ })),
+ schema: {
+ query: {
+ className: {
+ title: 'Array Type',
+ columnType: 'text',
+ cellRenderer: (value: SqlValue) =>
+ ({
+ content: m(
+ 'button',
+ {
+ class: 'ah-link',
+ onclick: () =>
+ navigate('objects', {cls: String(value ?? '')}),
+ },
+ String(value ?? ''),
+ ),
+ align: 'left',
+ }) as CellRenderResult,
+ },
+ arrayHash: {title: 'Hash', columnType: 'text'},
+ copies: {
+ title: 'Copies',
+ columnType: 'quantitative',
+ cellRenderer: makeNavCountRenderer((row) =>
+ navigate('arrays', {arrayHash: String(row.arrayHash ?? '')}),
+ ),
+ },
+ total_bytes: {
+ title: 'Total',
+ columnType: 'quantitative',
+ cellRenderer: sizeRenderer,
+ },
+ wasted_bytes: {
+ title: 'Wasted',
+ columnType: 'quantitative',
+ cellRenderer: sizeRenderer,
+ },
+ },
+ },
+ initialColumns: [
+ {id: 'className', field: 'className'},
+ {id: 'arrayHash', field: 'arrayHash'},
+ {id: 'copies', field: 'copies'},
+ {id: 'total_bytes', field: 'total_bytes'},
+ {id: 'wasted_bytes', field: 'wasted_bytes'},
+ ],
+ });
+ }
+
+ const numericFields = ['copies', 'total_bytes', 'wasted_bytes'];
+ const baseRows = dedupeByKey(
+ base.map((g) => ({
+ key: g.arrayHash,
+ className: g.className,
+ copies: g.count,
+ total_bytes: g.totalBytes,
+ wasted_bytes: g.wastedBytes,
+ })),
+ (r) => String(r.key),
+ numericFields,
+ );
+ const curRows = dedupeByKey(
+ cur.map((g) => ({
+ key: g.arrayHash,
+ className: g.className,
+ copies: g.count,
+ total_bytes: g.totalBytes,
+ wasted_bytes: g.wastedBytes,
+ })),
+ (r) => String(r.key),
+ numericFields,
+ );
+ const merged = mergeRows({
+ baseline: baseRows,
+ current: curRows,
+ keyOf: (r) => String(r.key),
+ numericFields,
+ passThroughFields: ['className'],
+ primaryDeltaField: 'wasted_bytes',
+ });
+ return renderDuplicateSectionDiff({
+ title: 'Duplicate Primitive Arrays',
+ summary,
+ targetView: 'arrays',
+ linkLabel: 'View Arrays diff',
+ navigate,
+ data: merged.map((r) => ({...r, arrayHash: r[KEY_COL]})),
+ keyTitle: 'Hash',
+ keyField: 'arrayHash',
+ extraTextFields: [{field: 'className', title: 'Array Type'}],
+ sizeFields: [
+ {field: 'wasted_bytes', title: 'Wasted'},
+ {field: 'total_bytes', title: 'Total'},
+ ],
+ countFields: [{field: 'copies', title: 'Copies'}],
+ primarySortField: 'wasted_bytes',
+ });
+}
+
+// ----- Shared helpers ------------------------------------------------------
+
+interface SingleSectionOpts {
+ readonly title: string;
+ readonly summary: m.Children;
+ readonly targetView: string;
+ readonly linkLabel: string;
+ readonly navigate: NavFn;
+ readonly data: Row[];
+ readonly schema: SchemaRegistry;
+ readonly initialColumns: Array<{id: string; field: string}>;
+}
+
+function renderDuplicateSectionSingle(opts: SingleSectionOpts): m.Children {
+ return m('div', {class: 'ah-card ah-mb-4'}, [
+ m('h3', {class: 'ah-sub-heading'}, opts.title),
+ m('p', {class: 'ah-desc'}, [
+ opts.summary,
+ ' ',
+ m(
+ 'button',
+ {
+ class: 'ah-link--alt',
+ onclick: () => opts.navigate(opts.targetView as NavState['view']),
+ },
+ opts.linkLabel,
+ ),
+ ]),
+ m(DataGrid, {
+ schema: opts.schema,
+ rootSchema: 'query',
+ data: opts.data,
+ initialColumns: opts.initialColumns,
+ }),
+ ]);
+}
+
+interface DiffSectionOpts {
+ readonly title: string;
+ readonly summary: m.Children;
+ readonly targetView: string;
+ readonly linkLabel: string;
+ readonly navigate: NavFn;
+ readonly data: Row[];
+ readonly keyTitle: string;
+ readonly keyField: string;
+ readonly keyRenderer?: CellRenderer;
+ readonly extraTextFields: ReadonlyArray<{field: string; title: string}>;
+ readonly sizeFields: ReadonlyArray<{field: string; title: string}>;
+ readonly countFields: ReadonlyArray<{field: string; title: string}>;
+ /** Numeric field used for default sort by `|Δ|` desc. */
+ readonly primarySortField: string;
+}
+
+function renderDuplicateSectionDiff(opts: DiffSectionOpts): m.Children {
+ const cols: Record = {
+ [opts.keyField]: {
+ title: opts.keyTitle,
+ columnType: 'text',
+ cellRenderer: opts.keyRenderer,
+ },
+ [STATUS_COL]: {
+ title: 'Status',
+ columnType: 'text',
+ cellRenderer: statusRenderer,
+ },
+ };
+ for (const tf of opts.extraTextFields) {
+ cols[tf.field] = {title: tf.title, columnType: 'text'};
+ }
+ for (const f of opts.sizeFields) {
+ cols[deltaCol(f.field)] = {
+ title: 'Δ ' + f.title,
+ columnType: 'quantitative',
+ cellRenderer: deltaSizeRenderer,
+ };
+ cols[baselineCol(f.field)] = {
+ title: 'Baseline ' + f.title,
+ columnType: 'quantitative',
+ cellRenderer: sideSizeRenderer,
+ };
+ cols[currentCol(f.field)] = {
+ title: 'Current ' + f.title,
+ columnType: 'quantitative',
+ cellRenderer: sideSizeRenderer,
+ };
+ }
+ for (const f of opts.countFields) {
+ cols[deltaCol(f.field)] = {
+ title: 'Δ ' + f.title,
+ columnType: 'quantitative',
+ cellRenderer: deltaCountRenderer,
+ };
+ cols[baselineCol(f.field)] = {
+ title: 'Baseline ' + f.title,
+ columnType: 'quantitative',
+ cellRenderer: sideCountRenderer,
+ };
+ cols[currentCol(f.field)] = {
+ title: 'Current ' + f.title,
+ columnType: 'quantitative',
+ cellRenderer: sideCountRenderer,
+ };
+ }
+ const initialColumns: Array<{
+ id: string;
+ field: string;
+ sort?: 'ASC' | 'DESC';
+ }> = [
+ {id: opts.keyField, field: opts.keyField},
+ {id: STATUS_COL, field: STATUS_COL},
+ ];
+ for (const tf of opts.extraTextFields) {
+ initialColumns.push({id: tf.field, field: tf.field});
+ }
+ for (const f of [...opts.sizeFields, ...opts.countFields]) {
+ if (f.field === opts.primarySortField) {
+ initialColumns.push({
+ id: deltaCol(f.field),
+ field: deltaCol(f.field),
+ sort: 'DESC',
+ });
+ } else {
+ initialColumns.push({id: deltaCol(f.field), field: deltaCol(f.field)});
+ }
+ initialColumns.push({
+ id: baselineCol(f.field),
+ field: baselineCol(f.field),
+ });
+ initialColumns.push({id: currentCol(f.field), field: currentCol(f.field)});
+ }
+ return m('div', {class: 'ah-card ah-mb-4'}, [
+ m('h3', {class: 'ah-sub-heading'}, opts.title),
+ m('p', {class: 'ah-desc'}, [
+ opts.summary,
+ ' ',
+ m(
+ 'button',
+ {
+ class: 'ah-link--alt',
+ onclick: () => opts.navigate(opts.targetView as NavState['view']),
+ },
+ opts.linkLabel,
+ ),
+ ]),
+ m(DataGrid, {
+ schema: {query: cols},
+ rootSchema: 'query',
+ data: opts.data,
+ initialColumns,
+ }),
+ ]);
+}
+
+function makeNavCountRenderer(onclick: (row: Row) => void): CellRenderer {
+ return (value: SqlValue, row: Row): CellRenderResult => ({
+ content: m(
+ 'button',
+ {class: 'ah-link', onclick: () => onclick(row)},
+ String(value ?? '0'),
+ ),
+ align: 'right',
+ });
+}
+
+function makeStringRenderer(onclick: (row: Row) => void): CellRenderer {
+ return (value: SqlValue, row: Row): CellRenderResult => {
+ const s = String(value ?? '');
+ const display = s.length > 200 ? s.slice(0, 200) + '…' : s;
+ return {
+ content: m(
+ 'button',
+ {
+ class: 'ah-link ah-mono ah-break-all ah-str-color',
+ onclick: () => onclick(row),
+ },
+ '"' + display + '"',
+ ),
+ align: 'left',
+ };
+ };
+}
+
+interface DuplicateGroupLike {
+ readonly wastedBytes: number;
+}
+
+function makeSummary(
+ unit: string,
+ cur: ReadonlyArray,
+ base: ReadonlyArray,
+ wastedBytes: (g: T) => number,
+ isDiff: boolean,
+): m.Children {
+ const cWasted = cur.reduce((a, g) => a + wastedBytes(g), 0);
+ if (!isDiff) {
+ return [
+ cur.length +
+ ' ' +
+ unit +
+ (cur.length !== 1 ? 's' : '') +
+ ' detected, wasting ',
+ m('span', {class: 'ah-mono ah-semibold'}, fmtSize(cWasted)),
+ '.',
+ ];
+ }
+ const bWasted = base.reduce((a, g) => a + wastedBytes(g), 0);
+ const dWasted = cWasted - bWasted;
+ const dGroups = cur.length - base.length;
+ return [
+ `${cur.length} ${unit}${cur.length !== 1 ? 's' : ''} `,
+ m('span', {class: 'ah-mono'}, `(${dGroups >= 0 ? '+' : ''}${dGroups})`),
+ ', wasting ',
+ m('span', {class: 'ah-mono ah-semibold'}, fmtSize(cWasted)),
+ ' ',
+ m(
+ 'span',
+ {
+ class:
+ 'ah-mono ' +
+ (dWasted > 0
+ ? 'ah-delta--grew'
+ : dWasted < 0
+ ? 'ah-delta--shrank'
+ : 'ah-delta--zero'),
+ },
+ `(${dWasted >= 0 ? '+' : '−'}${fmtSize(Math.abs(dWasted))})`,
+ ),
+ '.',
+ ];
+}
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/views/overview_view.ts b/ui/src/plugins/com.android.HeapDumpExplorer/views/overview_view.ts
index e3a7e09b429..2621b6fc587 100644
--- a/ui/src/plugins/com.android.HeapDumpExplorer/views/overview_view.ts
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/views/overview_view.ts
@@ -12,436 +12,527 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+// Overview tab. Five DataGrid cards. When `baselineOverview` is present
+// each card gains Baseline / Current / Δ columns merged via mergeRows.
+
import m from 'mithril';
import type {SqlValue, Row} from '../../../trace_processor/query_result';
import {DataGrid} from '../../../components/widgets/datagrid/datagrid';
-import type {SchemaRegistry} from '../../../components/widgets/datagrid/datagrid_schema';
-import type {OverviewData} from '../types';
-import {fmtSize} from '../format';
-import type {NavState} from '../nav_state';
-import {type NavFn, sizeRenderer} from '../components';
-import type {HeapDump} from '../queries';
+import type {
+ CellRenderer,
+ CellRenderResult,
+ ColumnDef,
+ SchemaRegistry,
+} from '../../../components/widgets/datagrid/datagrid_schema';
import {Callout} from '../../../widgets/callout';
-import {Button} from '../../../widgets/button';
-
-const HEAP_SCHEMA: SchemaRegistry = {
- query: {
- heap: {
- title: 'Heap',
- columnType: 'text',
- },
- java_size: {
- title: 'Java Size',
- columnType: 'quantitative',
- cellRenderer: sizeRenderer,
- },
- native_size: {
- title: 'Native Size',
- columnType: 'quantitative',
- cellRenderer: sizeRenderer,
- },
- total_size: {
- title: 'Total Size',
- columnType: 'quantitative',
- cellRenderer: sizeRenderer,
- },
- },
-};
-
-const INFO_SCHEMA: SchemaRegistry = {
- query: {
- property: {
- title: 'Property',
- columnType: 'text',
- },
- value: {
- title: 'Value',
- columnType: 'text',
- },
- },
-};
-
-function makeDuplicateBitmapSchema(navigate: NavFn): SchemaRegistry {
- return {
- query: {
- dimensions: {
- title: 'Dimensions',
- columnType: 'text',
- },
- copies: {
- title: 'Copies',
- columnType: 'quantitative',
- cellRenderer: (value: SqlValue, row) =>
- m(
- 'button',
- {
- class: 'ah-link',
- onclick: () =>
- navigate('bitmaps', {
- filterKey: String(row.groupKey ?? ''),
- }),
- },
- String(value),
- ),
- },
- total_bytes: {
- title: 'Total',
- columnType: 'quantitative',
- cellRenderer: sizeRenderer,
- },
- wasted_bytes: {
- title: 'Wasted',
- columnType: 'quantitative',
- cellRenderer: sizeRenderer,
- },
- },
- };
-}
-
-function makeDuplicateArraySchema(navigate: NavFn): SchemaRegistry {
- return {
- query: {
- className: {
- title: 'Array Type',
- columnType: 'text',
- cellRenderer: (value: SqlValue) =>
- m(
- 'button',
- {
- class: 'ah-link',
- onclick: () => navigate('objects', {cls: String(value ?? '')}),
- },
- String(value ?? ''),
- ),
- },
- arrayHash: {
- title: 'Hash',
- columnType: 'text',
- },
- copies: {
- title: 'Copies',
- columnType: 'quantitative',
- cellRenderer: (value: SqlValue, row) =>
- m(
- 'button',
- {
- class: 'ah-link',
- onclick: () =>
- navigate('arrays', {
- arrayHash: String(row.arrayHash ?? ''),
- }),
- },
- String(value),
- ),
- },
- total_bytes: {
- title: 'Total',
- columnType: 'quantitative',
- cellRenderer: sizeRenderer,
- },
- wasted_bytes: {
- title: 'Wasted',
- columnType: 'quantitative',
- cellRenderer: sizeRenderer,
- },
- },
- };
-}
-
-function makeDuplicateStringSchema(navigate: NavFn): SchemaRegistry {
- return {
- query: {
- value: {
- title: 'Value',
- columnType: 'text',
- cellRenderer: (value: SqlValue) =>
- m(
- 'button',
- {
- class: 'ah-link ah-mono ah-break-all ah-str-color',
- onclick: () =>
- navigate('strings', {
- q: String(value ?? ''),
- }),
- },
- '"' +
- (String(value ?? '').length > 200
- ? String(value).slice(0, 200) + '\u2026'
- : String(value ?? '')) +
- '"',
- ),
- },
- copies: {
- title: 'Copies',
- columnType: 'quantitative',
- cellRenderer: (value: SqlValue, row) =>
- m(
- 'button',
- {
- class: 'ah-link',
- onclick: () => navigate('strings', {q: String(row.value ?? '')}),
- },
- String(value),
- ),
- },
- total_bytes: {
- title: 'Total',
- columnType: 'quantitative',
- cellRenderer: sizeRenderer,
- },
- wasted_bytes: {
- title: 'Wasted',
- columnType: 'quantitative',
- cellRenderer: sizeRenderer,
- },
- },
- };
-}
+import {Intent} from '../../../widgets/common';
+import type {OverviewData, HeapInfo} from '../types';
+import {type NavFn, sizeRenderer, countRenderer} from '../components';
+import type {HeapDump} from '../queries';
+import {getLoadState} from '../baseline/load_action';
+import {openBaselineFilePicker, shouldShowBaselineHeader} from '../header';
+import {Button, ButtonVariant} from '../../../widgets/button';
+import {
+ baselineCol,
+ currentCol,
+ deltaCol,
+ mergeRows,
+ KEY_COL,
+ STATUS_COL,
+} from '../diff/diff_rows';
+import {
+ deltaCountRenderer,
+ deltaSizeRenderer,
+ sideSizeRenderer,
+ statusRenderer,
+} from '../diff/diff_schemas';
+import {
+ renderDuplicateArraysCard,
+ renderDuplicateBitmapsCard,
+ renderDuplicateStringsCard,
+} from './overview/duplicates_section';
-function renderDuplicateSection(
- title: string,
- groupCount: number,
- totalWasted: number,
- targetView: string,
- linkLabel: string,
- navigate: NavFn,
- schema: SchemaRegistry,
- data: Row[],
- columns: Array<{id: string; field: string}>,
-): m.Children {
- return m('div', {class: 'ah-card ah-mt-4'}, [
- m('h3', {class: 'ah-sub-heading'}, title),
- m('p', {class: 'ah-desc'}, [
- groupCount +
- ' group' +
- (groupCount > 1 ? 's' : '') +
- ' detected, wasting ',
- m('span', {class: 'ah-mono ah-semibold'}, fmtSize(totalWasted)),
- '. ',
- m(
- 'button',
- {
- class: 'ah-link--alt',
- onclick: () => navigate(targetView as NavState['view']),
- },
- linkLabel,
- ),
- ]),
- m('div', {class: 'ah-dup-grid-container'}, [
- m(DataGrid, {
- schema,
- rootSchema: 'query',
- data,
- initialColumns: columns,
- fillHeight: true,
- }),
- ]),
- ]);
-}
+export const HIDE_DEFAULT_CHANGED_KEY =
+ 'hideHeapDumpExplorerDefaultChangedHint';
interface OverviewViewAttrs {
readonly overview: OverviewData;
readonly activeDump: HeapDump;
+ /**
+ * True when a baseline is selected (mode is diff). The heading flips even
+ * before the baseline overview query resolves so the user sees that the
+ * page is in diff mode immediately.
+ */
+ readonly diffActive: boolean;
+ /** When present → render diff columns alongside current values. */
+ readonly baselineOverview?: OverviewData;
+ /**
+ * True when a baseline is loaded but its overview query is still running
+ * (so diff columns are coming, not absent).
+ */
+ readonly baselineLoading?: boolean;
readonly navigate: NavFn;
readonly showDefaultChangedHint: boolean;
readonly onBackToTimeline: () => void;
readonly onDismissDefaultChangedHint: () => void;
}
+
function OverviewView(): m.Component {
return {
view(vnode) {
const {
overview,
activeDump,
+ diffActive,
+ baselineOverview,
+ baselineLoading,
navigate,
showDefaultChangedHint,
onBackToTimeline,
onDismissDefaultChangedHint,
} = vnode.attrs;
- const showHint = showDefaultChangedHint;
- const heapIndices: number[] = [];
- for (let i = 0; i < overview.heaps.length; i++) {
- const h = overview.heaps[i];
- if (h.java + h.native_ > 0) {
- heapIndices.push(i);
- }
- }
- const heaps = heapIndices.map((i) => overview.heaps[i]);
- const totalJava = heaps.reduce((a, h) => a + h.java, 0);
- const totalNative = heaps.reduce((a, h) => a + h.native_, 0);
+ const isDiff = baselineOverview !== undefined;
+ const heading = diffActive ? 'Overview diff' : 'Overview';
+ // Mode tag baked into every card's vnode key. DataGrid captures its
+ // `initialColumns` only on `oninit`, so we must force a remount when
+ // we flip between the single-engine and diff column sets.
+ const mode = isDiff ? 'diff' : 'single';
- const heapRows: Row[] = [
- {
- heap: 'Total',
- java_size: totalJava,
- native_size: totalNative,
- total_size: totalJava + totalNative,
- },
- ...heaps.map((h) => ({
- heap: h.name,
- java_size: h.java,
- native_size: h.native_,
- total_size: h.java + h.native_,
- })),
- ];
-
- const processLabel =
- (activeDump.processName ?? '') +
- (activeDump.pid ? ` (pid ${activeDump.pid})` : '');
- const infoRows: Row[] = [
- {property: 'Process', value: processLabel},
- {property: 'Classes', value: overview.classCount.toLocaleString()},
- {
- property: 'Reachable instances',
- value: overview.reachableInstanceCount.toLocaleString(),
- },
- {
- property: 'Unreachable instances',
- value: overview.unreachableInstanceCount.toLocaleString(),
- },
- ];
+ // Mithril requires sibling vnodes in a fragment to either all have
+ // keys or none. Wrap each top-level child in a keyed div so we can
+ // freely use keys on individual cards (force-remount on mode flip)
+ // without triggering "all-or-none keys" runtime errors.
+ const child = (key: string, content: m.Children): m.Vnode =>
+ m('div', {key}, content);
return m('div', {class: 'ah-view-scroll'}, [
- m('h2', {class: 'ah-view-heading'}, 'Overview'),
- showHint
- ? m(
- Callout,
- {
- className: 'ah-default-changed-callout',
- icon: 'info',
- dismissible: true,
- onDismiss: onDismissDefaultChangedHint,
- },
- m('p', [
- m(
- 'span',
- 'Heapdump Explorer is now the default view for traces ' +
- 'with heap-graph data.',
- ),
- m(Button, {
- label: 'Back to Timeline',
- icon: 'arrow_back',
- compact: true,
- onclick: onBackToTimeline,
- }),
- ]),
- )
- : null,
-
- m('div', {class: 'ah-card ah-mb-4'}, [
- m('h3', {class: 'ah-sub-heading'}, 'General Information'),
- m(DataGrid, {
- schema: INFO_SCHEMA,
- rootSchema: 'query',
- data: infoRows,
- initialColumns: [
- {id: 'property', field: 'property'},
- {id: 'value', field: 'value'},
- ],
- }),
- ]),
- m('div', {class: 'ah-card'}, [
- m('h3', {class: 'ah-sub-heading'}, 'Bytes Retained by Heap'),
- m(DataGrid, {
- schema: HEAP_SCHEMA,
- rootSchema: 'query',
- data: heapRows,
- initialColumns: [
- {id: 'heap', field: 'heap'},
- {id: 'java_size', field: 'java_size'},
- {id: 'native_size', field: 'native_size'},
- {id: 'total_size', field: 'total_size'},
- ],
- }),
- ]),
- overview.duplicateBitmaps && overview.duplicateBitmaps.length > 0
- ? renderDuplicateSection(
- 'Duplicate Bitmaps',
- overview.duplicateBitmaps.length,
- overview.duplicateBitmaps.reduce((a, g) => a + g.wastedBytes, 0),
- 'bitmaps',
- 'View Bitmaps',
- navigate,
- makeDuplicateBitmapSchema(navigate),
- overview.duplicateBitmaps.map((g) => ({
- dimensions: `${g.width} \u00d7 ${g.height}`,
- groupKey: g.groupKey,
- copies: g.count,
- total_bytes: g.totalBytes,
- wasted_bytes: g.wastedBytes,
- })),
- [
- {id: 'dimensions', field: 'dimensions'},
- {id: 'groupKey', field: 'groupKey'},
- {id: 'copies', field: 'copies'},
- {id: 'total_bytes', field: 'total_bytes'},
- {id: 'wasted_bytes', field: 'wasted_bytes'},
- ],
- )
- : overview.hasFieldValues
+ child('heading', m('h2', {class: 'ah-view-heading'}, heading)),
+ child(
+ 'default-changed-hint',
+ showDefaultChangedHint
? m(
- 'div',
- {class: 'ah-card ah-mt-4 ah-mb-4'},
- m('p', {class: 'ah-muted'}, 'No duplicate bitmaps found.'),
+ Callout,
+ {
+ className: 'ah-default-changed-callout ah-mb-4',
+ icon: 'info',
+ dismissible: true,
+ onDismiss: onDismissDefaultChangedHint,
+ },
+ m('p', [
+ m(
+ 'span',
+ 'Heapdump Explorer is now the default view for traces ' +
+ 'with heap-graph data. ',
+ ),
+ m(Button, {
+ label: 'Back to Timeline',
+ icon: 'arrow_back',
+ compact: true,
+ onclick: onBackToTimeline,
+ }),
+ ]),
)
: null,
- overview.duplicateStrings && overview.duplicateStrings.length > 0
- ? renderDuplicateSection(
- 'Duplicate Strings',
- overview.duplicateStrings.length,
- overview.duplicateStrings.reduce((a, g) => a + g.wastedBytes, 0),
- 'strings',
- 'View Strings',
- navigate,
- makeDuplicateStringSchema(navigate),
- overview.duplicateStrings.map((g) => ({
- value: g.value,
- copies: g.count,
- total_bytes: g.totalBytes,
- wasted_bytes: g.wastedBytes,
- })),
- [
- {id: 'value', field: 'value'},
- {id: 'copies', field: 'copies'},
- {id: 'total_bytes', field: 'total_bytes'},
- {id: 'wasted_bytes', field: 'wasted_bytes'},
- ],
- )
- : overview.hasFieldValues
+ ),
+ child('load', renderLoadBaselineSection()),
+ child(
+ 'loading',
+ baselineLoading === true && !isDiff
? m(
- 'div',
- {class: 'ah-card ah-mb-4'},
- m('p', {class: 'ah-muted'}, 'No duplicate strings found.'),
+ Callout,
+ {
+ icon: 'hourglass_empty',
+ intent: Intent.None,
+ className: 'ah-mb-4',
+ role: 'status',
+ },
+ 'Computing baseline overview… diff columns will appear once it finishes.',
)
: null,
- overview.duplicateArrays && overview.duplicateArrays.length > 0
- ? renderDuplicateSection(
- 'Duplicate Primitive Arrays',
- overview.duplicateArrays.length,
- overview.duplicateArrays.reduce((a, g) => a + g.wastedBytes, 0),
- 'arrays',
- 'View Arrays',
- navigate,
- makeDuplicateArraySchema(navigate),
- overview.duplicateArrays.map((g) => ({
- className: g.className,
- arrayHash: g.arrayHash,
- copies: g.count,
- total_bytes: g.totalBytes,
- wasted_bytes: g.wastedBytes,
- })),
- [
- {id: 'className', field: 'className'},
- {id: 'arrayHash', field: 'arrayHash'},
- {id: 'copies', field: 'copies'},
- {id: 'total_bytes', field: 'total_bytes'},
- {id: 'wasted_bytes', field: 'wasted_bytes'},
- ],
- )
- : null,
+ ),
+ child(
+ `info-${mode}`,
+ renderInfoCard(activeDump, overview, baselineOverview),
+ ),
+ child(`heaps-${mode}`, renderHeapsCard(overview, baselineOverview)),
+ child(
+ `bitmaps-${mode}`,
+ renderDuplicateBitmapsCard(overview, baselineOverview, navigate),
+ ),
+ child(
+ `strings-${mode}`,
+ renderDuplicateStringsCard(overview, baselineOverview, navigate),
+ ),
+ child(
+ `arrays-${mode}`,
+ renderDuplicateArraysCard(overview, baselineOverview, navigate),
+ ),
]);
},
};
}
+// ----- Top-of-tab "Load baseline" affordance -------------------------------
+//
+// Only rendered in single-engine mode. When a baseline IS loaded, the slim
+// header above the tabs holds the controls — no need to repeat them here.
+
+function renderLoadBaselineSection(): m.Children {
+ // The Overview-tab CTA is the discovery entry point for diff mode in the
+ // common single-trace, no-diff workflow. Once the top bar is showing
+ // baseline state (a load is in flight, an error needs reading, or a
+ // pool / active baseline exists) the row's selector takes over and the
+ // CTA collapses to keep the page free of duplicated affordances.
+ if (shouldShowBaselineHeader()) return null;
+ const {error} = getLoadState();
+ // Bare button + helper text; we deliberately don't wrap in a Callout
+ // here because the Callout's leading icon collides visually with the
+ // button's `difference` icon (two adjacent glyphs reading the same).
+ return [
+ m(
+ 'div',
+ {class: 'ah-heading-row ah-mb-4'},
+ m(Button, {
+ label: 'Diff against another trace…',
+ icon: 'difference',
+ intent: Intent.Primary,
+ variant: ButtonVariant.Filled,
+ onclick: () => openBaselineFilePicker(),
+ }),
+ ),
+ error &&
+ m(
+ Callout,
+ {
+ icon: 'error',
+ intent: Intent.Danger,
+ role: 'alert',
+ className: 'ah-mb-4',
+ },
+ error,
+ ),
+ ];
+}
+
+// ----- General Information --------------------------------------------------
+
+function renderInfoCard(
+ activeDump: HeapDump,
+ overview: OverviewData,
+ baselineOverview: OverviewData | undefined,
+): m.Children {
+ const processLabel =
+ (activeDump.processName ?? '') +
+ (activeDump.pid !== null ? ` (pid ${activeDump.pid})` : '');
+ if (baselineOverview === undefined) {
+ const rows: Row[] = [
+ {property: 'Process', value: processLabel},
+ {property: 'Classes', value: overview.classCount.toLocaleString()},
+ {
+ property: 'Reachable instances',
+ value: overview.reachableInstanceCount.toLocaleString(),
+ },
+ {
+ property: 'Unreachable instances',
+ value: overview.unreachableInstanceCount.toLocaleString(),
+ },
+ {
+ property: 'Heaps',
+ value: overview.heaps.map((h) => h.name).join(', '),
+ },
+ ];
+ const schema: SchemaRegistry = {
+ query: {
+ property: {title: 'Property', columnType: 'text'},
+ value: {title: 'Value', columnType: 'text'},
+ },
+ };
+ return m('div', {class: 'ah-card ah-mb-4'}, [
+ m('h3', {class: 'ah-sub-heading'}, 'General Information'),
+ m(DataGrid, {
+ schema,
+ rootSchema: 'query',
+ data: rows,
+ initialColumns: [
+ {id: 'property', field: 'property'},
+ {id: 'value', field: 'value'},
+ ],
+ }),
+ ]);
+ }
+
+ const rows: Row[] = [
+ {
+ property: 'Reachable instances',
+ baseline: baselineOverview.reachableInstanceCount,
+ current: overview.reachableInstanceCount,
+ delta:
+ overview.reachableInstanceCount -
+ baselineOverview.reachableInstanceCount,
+ },
+ {
+ property: 'Heaps',
+ baseline: baselineOverview.heaps.map((h) => h.name).join(', '),
+ current: overview.heaps.map((h) => h.name).join(', '),
+ delta: heapDeltaSummary(overview.heaps, baselineOverview.heaps),
+ },
+ ];
+ const schema: SchemaRegistry = {
+ query: {
+ property: {title: 'Property', columnType: 'text'},
+ baseline: {
+ title: 'Baseline',
+ columnType: 'text',
+ cellRenderer: maybeNumericRenderer,
+ },
+ current: {
+ title: 'Current',
+ columnType: 'text',
+ cellRenderer: maybeNumericRenderer,
+ },
+ delta: {
+ title: 'Δ',
+ columnType: 'text',
+ cellRenderer: maybeDeltaCountRenderer,
+ },
+ },
+ };
+ return m('div', {class: 'ah-card ah-mb-4'}, [
+ m('h3', {class: 'ah-sub-heading'}, 'General Information'),
+ m(DataGrid, {
+ schema,
+ rootSchema: 'query',
+ data: rows,
+ initialColumns: [
+ {id: 'property', field: 'property'},
+ {id: 'baseline', field: 'baseline'},
+ {id: 'current', field: 'current'},
+ {id: 'delta', field: 'delta'},
+ ],
+ }),
+ ]);
+}
+
+const maybeNumericRenderer: CellRenderer = (value: SqlValue) => {
+ if (typeof value === 'number' || typeof value === 'bigint') {
+ return countRenderer(value);
+ }
+ return {
+ content: m('span', String(value ?? '')),
+ align: 'left',
+ } satisfies CellRenderResult;
+};
+
+const maybeDeltaCountRenderer: CellRenderer = (value: SqlValue, row: Row) => {
+ if (typeof value === 'number' || typeof value === 'bigint') {
+ return deltaCountRenderer(value, row);
+ }
+ return {
+ content: m('span', {class: 'ah-muted'}, String(value ?? '')),
+ align: 'left',
+ } satisfies CellRenderResult;
+};
+
+function heapDeltaSummary(current: HeapInfo[], baseline: HeapInfo[]): string {
+ const cSet = new Set(current.map((h) => h.name));
+ const bSet = new Set(baseline.map((h) => h.name));
+ const added = [...cSet].filter((h) => !bSet.has(h));
+ const removed = [...bSet].filter((h) => !cSet.has(h));
+ if (added.length === 0 && removed.length === 0) return 'same';
+ const parts: string[] = [];
+ if (added.length) parts.push(`+${added.join(', ')}`);
+ if (removed.length) parts.push(`−${removed.join(', ')}`);
+ return parts.join('; ');
+}
+
+// ----- Bytes retained by heap ----------------------------------------------
+
+function renderHeapsCard(
+ overview: OverviewData,
+ baselineOverview: OverviewData | undefined,
+): m.Children {
+ // Only show heaps with non-zero retention on at least one side.
+ const filterNonZero = (heaps: HeapInfo[]) =>
+ heaps.filter((h) => h.java + h.native_ > 0);
+ const cHeaps = filterNonZero(overview.heaps);
+
+ if (baselineOverview === undefined) {
+ const rows: Row[] = withTotalRow(
+ cHeaps.map((h) => ({
+ heap: h.name,
+ java_size: h.java,
+ native_size: h.native_,
+ total_size: h.java + h.native_,
+ })),
+ 'heap',
+ );
+ return m('div', {class: 'ah-card ah-mb-4'}, [
+ m('h3', {class: 'ah-sub-heading'}, 'Bytes Retained by Heap'),
+ m(DataGrid, {
+ schema: {
+ query: {
+ heap: {title: 'Heap', columnType: 'text'},
+ java_size: {
+ title: 'Java',
+ columnType: 'quantitative',
+ cellRenderer: sizeRenderer,
+ },
+ native_size: {
+ title: 'Native',
+ columnType: 'quantitative',
+ cellRenderer: sizeRenderer,
+ },
+ total_size: {
+ title: 'Total',
+ columnType: 'quantitative',
+ cellRenderer: sizeRenderer,
+ },
+ },
+ },
+ rootSchema: 'query',
+ data: rows,
+ initialColumns: [
+ {id: 'heap', field: 'heap'},
+ {id: 'java_size', field: 'java_size'},
+ {id: 'native_size', field: 'native_size'},
+ {id: 'total_size', field: 'total_size'},
+ ],
+ }),
+ ]);
+ }
+
+ const bHeaps = filterNonZero(baselineOverview.heaps);
+ const merged = mergeRows({
+ baseline: bHeaps.map((h) => ({
+ heap: h.name,
+ java_size: h.java,
+ native_size: h.native_,
+ total_size: h.java + h.native_,
+ })),
+ current: cHeaps.map((h) => ({
+ heap: h.name,
+ java_size: h.java,
+ native_size: h.native_,
+ total_size: h.java + h.native_,
+ })),
+ keyOf: (r) => String(r.heap ?? ''),
+ numericFields: ['java_size', 'native_size', 'total_size'],
+ primaryDeltaField: 'total_size',
+ });
+ // Rename the merged-row 'key' column to 'heap' for the schema, and pin
+ // a synthesized Total row at the top — same rollup the non-diff card
+ // shows, kept here so users don't lose the bottom-line view in diff mode.
+ const totalRow = sumDiffRows(merged, 'Total', [
+ 'java_size',
+ 'native_size',
+ 'total_size',
+ ]);
+ const dataRows: Row[] = [totalRow, ...merged].map((r) => ({
+ ...r,
+ heap: r[KEY_COL],
+ }));
+ const schema = buildHeapsDiffSchema();
+ return m('div', {class: 'ah-card ah-mb-4'}, [
+ m('h3', {class: 'ah-sub-heading'}, 'Bytes Retained by Heap'),
+ m(DataGrid, {
+ schema,
+ rootSchema: 'query',
+ data: dataRows,
+ initialColumns: [
+ {id: 'heap', field: 'heap'},
+ {id: STATUS_COL, field: STATUS_COL},
+ {
+ id: deltaCol('total_size'),
+ field: deltaCol('total_size'),
+ sort: 'DESC',
+ },
+ {id: baselineCol('total_size'), field: baselineCol('total_size')},
+ {id: currentCol('total_size'), field: currentCol('total_size')},
+ {id: deltaCol('java_size'), field: deltaCol('java_size')},
+ {id: baselineCol('java_size'), field: baselineCol('java_size')},
+ {id: currentCol('java_size'), field: currentCol('java_size')},
+ {id: deltaCol('native_size'), field: deltaCol('native_size')},
+ {id: baselineCol('native_size'), field: baselineCol('native_size')},
+ {id: currentCol('native_size'), field: currentCol('native_size')},
+ ],
+ }),
+ ]);
+}
+
+function buildHeapsDiffSchema(): SchemaRegistry {
+ const cols: Record = {
+ heap: {title: 'Heap', columnType: 'text'},
+ [STATUS_COL]: {
+ title: 'Status',
+ columnType: 'text',
+ cellRenderer: statusRenderer,
+ },
+ };
+ const fields: Array<{field: string; title: string}> = [
+ {field: 'total_size', title: 'Total'},
+ {field: 'java_size', title: 'Java'},
+ {field: 'native_size', title: 'Native'},
+ ];
+ for (const f of fields) {
+ cols[deltaCol(f.field)] = {
+ title: 'Δ ' + f.title,
+ columnType: 'quantitative',
+ cellRenderer: deltaSizeRenderer,
+ };
+ cols[baselineCol(f.field)] = {
+ title: 'Baseline ' + f.title,
+ columnType: 'quantitative',
+ cellRenderer: sideSizeRenderer,
+ };
+ cols[currentCol(f.field)] = {
+ title: 'Current ' + f.title,
+ columnType: 'quantitative',
+ cellRenderer: sideSizeRenderer,
+ };
+ }
+ return {query: cols};
+}
+
+function withTotalRow(rows: T[], keyField: keyof T): Row[] {
+ if (rows.length === 0) return rows;
+ const total: Row = {[keyField as string]: 'Total'};
+ for (const r of rows) {
+ for (const k of Object.keys(r)) {
+ if (k === keyField) continue;
+ const v = r[k];
+ if (typeof v === 'number') {
+ total[k] = ((total[k] as number | undefined) ?? 0) + v;
+ }
+ }
+ }
+ return [total, ...rows];
+}
+
+// Roll-up row for diff-mode tables — sums each numeric field's
+// baseline / current / delta across `rows`. Sets STATUS_COL based on the
+// total `total_size` delta sign (GREW / SHRANK / UNCHANGED) — NEW /
+// REMOVED don't apply to a synthetic aggregate.
+function sumDiffRows(
+ rows: ReadonlyArray,
+ keyName: string,
+ numericFields: ReadonlyArray,
+): Row {
+ const out: Row = {[KEY_COL]: keyName};
+ for (const field of numericFields) {
+ let bSum = 0;
+ let cSum = 0;
+ for (const r of rows) {
+ bSum += Number(r[baselineCol(field)] ?? 0);
+ cSum += Number(r[currentCol(field)] ?? 0);
+ }
+ out[baselineCol(field)] = bSum;
+ out[currentCol(field)] = cSum;
+ out[deltaCol(field)] = cSum - bSum;
+ }
+ const totalDelta = Number(out[deltaCol('total_size')] ?? 0);
+ out[STATUS_COL] =
+ totalDelta > 0 ? 'GREW' : totalDelta < 0 ? 'SHRANK' : 'UNCHANGED';
+ return out;
+}
+
export default OverviewView;
diff --git a/ui/src/test/heap_dump_diff.test.ts b/ui/src/test/heap_dump_diff.test.ts
new file mode 100644
index 00000000000..2684c698da5
--- /dev/null
+++ b/ui/src/test/heap_dump_diff.test.ts
@@ -0,0 +1,842 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// End-to-end tests for the Heapdump Explorer baseline / diff mode.
+//
+// Test fixtures (from upstream test/data/, downloaded by tools/test_data):
+// - system-server-heap-graph.pftrace ← baseline
+// - system-server-heap-graph-new.pftrace ← current (later snapshot,
+// same process)
+// - test-dump.hprof ← raw hprof for the hprof test
+// - api34_startup_cold.perfetto-trace ← non-heap trace for rejection
+//
+// The pftrace pair is two real heap dumps of the same process taken at
+// different times — perfect for asserting non-zero deltas. The fixtures are
+// already part of the upstream repo, so no new files are committed for tests.
+//
+// UI selectors map to Perfetto widgets:
+// - "Diff against another trace" → Primary CTA button inside
+// the Overview tab when no baseline is loaded.
+// - Clear baseline → Button[aria-label="Clear active baseline"]
+// in the top bar's baseline controls.
+// - Mode toggle → SegmentedButtons (.pf-segmented-buttons) with
+// buttons labelled Diff / Current / Baseline.
+// - Diff status text → Plain coloured uppercase span text inside
+// a DataGrid cell. We assert by *text*
+// (`text=GREW`, etc.) — there is no pill class.
+// - Inline error → Callout (.pf-callout.pf-intent-danger).
+
+import {test, expect, Page} from '@playwright/test';
+import path from 'path';
+import fs from 'fs';
+import {PerfettoTestHelper} from './perfetto_ui_test_helper';
+// Side-effect imports: bring the global `Window.__heapdumpDebug` and
+// `Window.__heapdumpDiff` augmentations into scope so
+// `page.evaluate(() => window.__heapdumpDebug)` etc. are typed.
+import '../plugins/com.android.HeapDumpExplorer/baseline/state';
+import '../plugins/com.android.HeapDumpExplorer/diff/diff_debug';
+
+test.describe.configure({mode: 'serial'});
+
+const PRIMARY_TRACE = 'system-server-heap-graph-new.pftrace';
+const BASELINE_TRACE = 'system-server-heap-graph.pftrace';
+const HPROF_TRACE = 'test-dump.hprof';
+const NON_HEAP_TRACE = 'api34_startup_cold.perfetto-trace';
+
+let pth: PerfettoTestHelper;
+let page: Page;
+
+function tracePath(name: string): string {
+ const cwd = process.cwd();
+ const parts = ['test', 'data', name];
+ if (cwd.endsWith('/ui')) parts.unshift('..');
+ const p = path.join(...parts);
+ if (!fs.existsSync(p)) {
+ throw new Error(`Missing test fixture ${p} (cwd=${cwd})`);
+ }
+ return p;
+}
+
+/**
+ * Locator for the hidden file input that powers the "Load baseline" button.
+ * Only present on the page when no baseline is currently loaded (the
+ * LoadBaselineButton lives in the Overview tab).
+ */
+function fileInputLocator() {
+ return page.locator('input[type=file][aria-hidden="true"]');
+}
+
+async function ensureOnOverview(): Promise {
+ await page.locator('.pf-tabs__tab:has-text("Overview")').first().click();
+ await pth.waitForPerfettoIdle();
+}
+
+async function loadBaseline(name: string): Promise {
+ // The "Load baseline" affordance lives in the Overview tab when no
+ // baseline is currently loaded.
+ await ensureOnOverview();
+ await fileInputLocator().setInputFiles(tracePath(name));
+ await page.waitForFunction(
+ () => window.__heapdumpDebug?.hasBaseline(),
+ null,
+ {timeout: 60_000},
+ );
+ await pth.waitForPerfettoIdle();
+}
+
+// Page errors and console errors collected during a test, asserted to be
+// empty in the dedicated console-clean test. Reset by beforeEach.
+let pageErrors: string[] = [];
+let consoleErrors: string[] = [];
+
+test.beforeAll(async ({browser}) => {
+ page = await browser.newPage();
+ pth = new PerfettoTestHelper(page);
+ page.on('pageerror', (err) => {
+ pageErrors.push(err.message);
+ });
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') consoleErrors.push(msg.text());
+ });
+ await pth.openTraceFile(PRIMARY_TRACE);
+ // Hash-only navigation so we don't reload the page (which would drop the
+ // in-memory trace; pth.navigate uses page.goto which is a full reload).
+ await page.evaluate(() => {
+ window.location.hash = '#!/heapdump';
+ });
+ await pth.waitForPerfettoIdle();
+});
+
+test.beforeEach(() => {
+ pageErrors = [];
+ consoleErrors = [];
+});
+
+test.afterEach(async () => {
+ // Reset baseline pool between tests so each starts clean. "Remove all
+ // baseline traces" disposes every pooled engine — equivalent to the
+ // pre-pool "close baseline" but tear-down semantics rather than
+ // deselect-only.
+ await page.evaluate(() => {
+ document
+ .querySelector(
+ 'button[aria-label="Remove all baseline traces"]',
+ )
+ ?.click();
+ });
+});
+
+// 1. Overview tab exposes the Load baseline button when no baseline is loaded.
+test('overview shows Load baseline button initially', async () => {
+ await ensureOnOverview();
+ await expect(
+ page.locator('button:has-text("Diff against another trace")').first(),
+ ).toBeVisible();
+ // No slim baseline-status callout when nothing is loaded.
+ await expect(
+ page.locator('button[aria-label="Clear active baseline"]'),
+ ).toHaveCount(0);
+ // Debug surface is wired up.
+ const hasBaseline = await page.evaluate(() =>
+ window.__heapdumpDebug?.hasBaseline(),
+ );
+ expect(hasBaseline).toBe(false);
+});
+
+// 2. Load baseline → diff mode is active by default and the slim status
+// header is visible with a Close button.
+test('loading a baseline activates diff mode', async () => {
+ await loadBaseline(BASELINE_TRACE);
+ const filename = await page.evaluate(() =>
+ window.__heapdumpDebug!.baselineFilename(),
+ );
+ expect(filename).toBe(BASELINE_TRACE);
+ const mode = await page.evaluate(() => window.__heapdumpDebug!.mode());
+ expect(mode).toBe('diff');
+ // Close button appears once a baseline is loaded.
+ await expect(
+ page.locator('button[aria-label="Clear active baseline"]'),
+ ).toBeVisible();
+});
+
+// 3. Same trace as primary AND baseline → every status should be UNCHANGED
+// (rendered as empty span); no GREW/SHRANK/NEW/REMOVED text in the grid.
+test('same trace as baseline produces all-zero deltas', async () => {
+ test.setTimeout(120_000);
+ await loadBaseline(PRIMARY_TRACE); // same file as primary
+ await page.locator('.pf-tabs__tab:has-text("Classes")').click();
+ // Wait for the diff to finish: the heading "Classes diff" appears with a
+ // resolved row count (no leading spinner anymore).
+ await page
+ .locator('.ah-view-heading:has-text("Classes diff")')
+ .first()
+ .waitFor({state: 'attached', timeout: 90_000});
+ // Wait for at least one DataGrid row to be attached so we know rendering
+ // completed (otherwise an empty grid trivially passes).
+ await page
+ .locator('.pf-data-grid .pf-grid__row')
+ .first()
+ .waitFor({state: 'attached', timeout: 90_000});
+ // Status text cells in DataGrid: count any GREW/SHRANK/NEW/REMOVED.
+ const nonUnchanged = await page
+ .locator('.pf-data-grid')
+ .locator('text=/^(GREW|SHRANK|NEW|REMOVED)$/')
+ .count();
+ expect(nonUnchanged).toBe(0);
+});
+
+// 4. Different traces → at least one row appears with a non-UNCHANGED status.
+test('different baseline produces visible diffs', async () => {
+ test.setTimeout(120_000);
+ await loadBaseline(BASELINE_TRACE);
+ await page.locator('.pf-tabs__tab:has-text("Classes")').click();
+ // The two-phase query against 1M+ heap_graph_object rows can take ~30s.
+ // Wait for any GREW/SHRANK/NEW/REMOVED text inside a DataGrid cell.
+ await page
+ .locator('.pf-data-grid')
+ .locator('text=/^(GREW|SHRANK|NEW|REMOVED)$/')
+ .first()
+ .waitFor({state: 'attached', timeout: 90_000});
+ const nonUnchanged = await page
+ .locator('.pf-data-grid')
+ .locator('text=/^(GREW|SHRANK|NEW|REMOVED)$/')
+ .count();
+ expect(nonUnchanged).toBeGreaterThan(0);
+
+ // Parity-style invariants on the merged DiffRow array (exposed via the
+ // window.__heapdumpDiff debug API). The diff classifier must be self-
+ // consistent: every row has a status drawn from a fixed enum, classes
+ // present in only one side are NEW/REMOVED (not GREW/SHRANK), and the
+ // sum of status buckets equals the row count. These are the same rules
+ // a hand-written diff against ahat would expect to hold.
+ await page.waitForFunction(
+ () => (window.__heapdumpDiff?.gen('classes') ?? 0) > 0,
+ null,
+ {timeout: 30_000},
+ );
+ const summary = await page.evaluate(() => {
+ const rows = window.__heapdumpDiff!.rows('classes')!;
+ const counts = {NEW: 0, REMOVED: 0, GREW: 0, SHRANK: 0, UNCHANGED: 0};
+ let presenceViolations = 0;
+ for (const r of rows) {
+ counts[r.status as keyof typeof counts]++;
+ const bAbs = r._b_reachable_obj_count;
+ const cAbs = r._c_reachable_obj_count;
+ // If status is NEW, the baseline side must be missing (encoded as
+ // null on _b_*). If REMOVED, the current side must be missing.
+ if (r.status === 'NEW' && bAbs !== null) presenceViolations++;
+ if (r.status === 'REMOVED' && cAbs !== null) presenceViolations++;
+ }
+ return {total: rows.length, counts, presenceViolations};
+ });
+ expect(summary.total).toBeGreaterThan(0);
+ expect(summary.presenceViolations).toBe(0);
+ // Bucket sum must equal total.
+ const sum = Object.values(summary.counts).reduce((a, b) => a + b, 0);
+ expect(sum).toBe(summary.total);
+ // Different traces should produce at least one non-UNCHANGED row in the
+ // merged data structure too.
+ expect(summary.counts.UNCHANGED).toBeLessThan(summary.total);
+});
+
+// 5. HPROF baseline loads as well as a pftrace baseline.
+test('hprof baseline loads', async () => {
+ await loadBaseline(HPROF_TRACE);
+ const filename = await page.evaluate(() =>
+ window.__heapdumpDebug!.baselineFilename(),
+ );
+ expect(filename).toBe(HPROF_TRACE);
+});
+
+// 6. A non-heap trace as baseline is rejected with an inline error Callout.
+test('non-heap trace rejected with inline error', async () => {
+ await ensureOnOverview();
+ await fileInputLocator().setInputFiles(tracePath(NON_HEAP_TRACE));
+ // Wait for the danger-intent Callout (rendered both inside Overview and
+ // in the slim header above the tabs).
+ await page
+ .locator('.pf-callout.pf-intent-danger')
+ .first()
+ .waitFor({state: 'visible', timeout: 60_000});
+ const err = await page
+ .locator('.pf-callout.pf-intent-danger')
+ .first()
+ .innerText();
+ expect(err).toMatch(/no Java heap data|heap dump/i);
+ // Baseline should NOT be set.
+ const hasBaseline = await page.evaluate(() =>
+ window.__heapdumpDebug!.hasBaseline(),
+ );
+ expect(hasBaseline).toBe(false);
+});
+
+// 7. Tearing down the pool returns the page to the load-button state.
+test('removing all baselines disposes engines and reverts header', async () => {
+ await loadBaseline(BASELINE_TRACE);
+ // "Remove all baseline traces" disposes every pooled engine and clears
+ // the active selection — the pre-pool "close baseline" UX condensed into
+ // the explicit teardown gesture.
+ await page.locator('button[aria-label="Remove all baseline traces"]').click();
+ await page.waitForFunction(
+ () =>
+ window.__heapdumpDebug!.hasBaseline() === false &&
+ window.__heapdumpDebug!.poolSize() === 0,
+ null,
+ {timeout: 5_000},
+ );
+ // Discovery CTA is back inside the Overview tab.
+ await ensureOnOverview();
+ await expect(
+ page.locator('button:has-text("Diff against another trace")').first(),
+ ).toBeVisible();
+});
+
+// 8. Mode toggle: switching to "Current" shows the existing single-engine
+// Classes view (no diff Δ columns).
+test('Current-only mode hides diff columns', async () => {
+ await loadBaseline(BASELINE_TRACE);
+ // Switch the SegmentedButtons mode toggle to "Current".
+ await page.locator('.pf-segmented-button:has-text("Current")').click();
+ await pth.waitForPerfettoIdle();
+ await page.locator('.pf-tabs__tab:has-text("Classes")').click();
+ await pth.waitForPerfettoIdle();
+ // The diff view's view-heading reads "Classes diff (N classes)";
+ // the single-engine ClassesView heading reads "Classes (M)".
+ // If Current-only correctly swapped views, no "Classes diff" header
+ // should be visible anywhere on the page.
+ const visibleDiffHeader = await page
+ .locator('.ah-view-heading:visible:has-text("Classes diff")')
+ .count();
+ expect(visibleDiffHeader).toBe(0);
+ // The single-engine "Classes" heading is visible.
+ await page
+ .locator('.ah-view-heading:visible')
+ .first()
+ .waitFor({timeout: 30_000});
+});
+
+// 9. Smoke for each diff-capable tab: navigate, ensure no error renders.
+test('every diff-capable tab renders without error', async () => {
+ test.setTimeout(300_000);
+ await loadBaseline(BASELINE_TRACE);
+ for (const tab of [
+ 'Overview',
+ 'Classes',
+ 'Strings',
+ 'Arrays',
+ 'Bitmaps',
+ 'Dominators',
+ ]) {
+ await page.locator(`.pf-tabs__tab:has-text("${tab}")`).click();
+ // Give the diff query time to complete; we don't wait for content
+ // because Strings on a 1M-object trace can be slow.
+ await page.waitForTimeout(15_000);
+ // No error/empty state with "Failed".
+ const failed = await page
+ .locator('.pf-empty-state:has-text("Failed")')
+ .count();
+ expect(failed, `tab ${tab} renders an error`).toBe(0);
+ }
+});
+
+// 10. Overview tab MUST switch to the diff layout once the baseline overview
+// has finished loading. The 'Overview diff' heading proves the unified
+// view received `baselineOverview` and rendered the diff branch.
+test('Overview tab swaps to diff layout when baseline loads', async () => {
+ test.setTimeout(180_000);
+ await loadBaseline(BASELINE_TRACE);
+ await ensureOnOverview();
+ // The view-heading text flips from "Overview" → "Overview diff" once
+ // baselineOverview is computed and threaded in. Use :visible to skip
+ // any hidden tab-content headings the Tabs widget may keep around.
+ await expect(
+ page.locator('.ah-view-heading:visible:has-text("Overview diff")'),
+ ).toBeVisible({timeout: 120_000});
+ // The General Information card now has Baseline / Current / Δ columns.
+ // Same :visible discipline; also a longer toContainText timeout in case
+ // the overview rerender cascades over multiple frames.
+ const infoCard = page
+ .locator('.ah-card:visible:has-text("General Information")')
+ .first();
+ await expect(infoCard).toContainText('Baseline', {timeout: 30_000});
+ await expect(infoCard).toContainText('Current', {timeout: 5_000});
+ // Bytes Retained by Heap card too.
+ await expect(
+ page.locator('.ah-card:visible:has-text("Bytes Retained by Heap")').first(),
+ ).toContainText('Δ Total', {timeout: 30_000});
+});
+
+// 11a. Multi-trace pool: pool grows to two distinct baseline traces; the
+// first is auto-picked, the second is just queued. The active dump's
+// identity comes from the first trace; pool size reflects both.
+test('multi-trace pool grows when adding a second baseline', async () => {
+ test.setTimeout(180_000);
+ // 1) First baseline (auto-picks since the pool was empty + single-dump).
+ await loadBaseline(BASELINE_TRACE);
+ expect(await page.evaluate(() => window.__heapdumpDebug!.poolSize())).toBe(1);
+ expect(
+ await page.evaluate(() => window.__heapdumpDebug!.baselineFilename()),
+ ).toBe(BASELINE_TRACE);
+
+ // 2) Add a second baseline (the hprof). The header keeps the file
+ // input mounted; we don't need to navigate the popup — the same
+ // hidden input services the Overview CTA *and* the popup's
+ // "Add baseline trace…" menu item. Setting files directly is
+ // representative because that's what either path would do once
+ // the OS picker resolves.
+ await fileInputLocator().setInputFiles(tracePath(HPROF_TRACE));
+ await page.waitForFunction(
+ () => window.__heapdumpDebug!.poolSize() === 2,
+ null,
+ {timeout: 60_000},
+ );
+ await pth.waitForPerfettoIdle();
+
+ // 3) Active baseline did NOT change — we already had one, so the
+ // auto-pick rule (only when active===null && dumps.length===1)
+ // didn't fire for the second trace.
+ expect(
+ await page.evaluate(() => window.__heapdumpDebug!.baselineFilename()),
+ ).toBe(BASELINE_TRACE);
+});
+
+// 11b. The popup must list both pooled traces' titles so the user can
+// see what's loaded and pick a dump from either.
+test('popup lists every pooled baseline trace', async () => {
+ test.setTimeout(180_000);
+ await loadBaseline(BASELINE_TRACE);
+ await fileInputLocator().setInputFiles(tracePath(HPROF_TRACE));
+ await page.waitForFunction(
+ () => window.__heapdumpDebug!.poolSize() === 2,
+ null,
+ {timeout: 60_000},
+ );
+ await pth.waitForPerfettoIdle();
+ // Open the popup by clicking the trigger (its label is the active
+ // baseline's "title · process".
+ await page
+ .locator('.ah-top-bar')
+ .locator('button:has-text("system-server-heap-graph.pftrace")')
+ .first()
+ .click();
+ // Both trace section headings appear (they're MenuItems with
+ // folder_open icons; we just match the title text).
+ await expect(
+ page.locator('.pf-menu-item:has-text("system-server-heap-graph.pftrace")'),
+ ).toHaveCount(2); // section heading + Remove "..." item
+ await expect(
+ page.locator('.pf-menu-item:has-text("test-dump.hprof")'),
+ ).toHaveCount(2); // same for second trace
+ // Add baseline trace… is always last.
+ await expect(
+ page.locator('.pf-menu-item:has-text("Add baseline trace…")'),
+ ).toBeVisible();
+});
+
+// 11b. Pretty column titles: the diff Classes view headers are
+// human-readable ("Δ Retained", "Current Retained") not the raw
+// snake_case field names ("Δ dominated_size_bytes"). Catches a
+// regression where the schema would fall back to field names.
+test('Classes diff has human-readable column titles', async () => {
+ test.setTimeout(120_000);
+ await loadBaseline(BASELINE_TRACE);
+ await page.locator('.pf-tabs__tab:has-text("Classes")').click();
+ await page
+ .locator('.ah-view-heading:has-text("Classes diff")')
+ .first()
+ .waitFor({state: 'attached', timeout: 90_000});
+ const headerText = (
+ await page.locator('.pf-data-grid .pf-grid-header-cell').allInnerTexts()
+ ).join(' | ');
+ // We just need to assert that *some* nice header is visible AND
+ // that no raw snake_case header leaked through.
+ expect(headerText).toMatch(/Δ Retained/);
+ expect(headerText).toMatch(/Current Retained/);
+ expect(headerText).toMatch(/Baseline Retained/);
+ expect(headerText).not.toMatch(/dominated_size_bytes/);
+ expect(headerText).not.toMatch(/reachable_obj_count/);
+});
+
+// 11c. Top bar layout: when a baseline is loaded the .ah-top-bar row
+// is visible, has the expected children (label, popup trigger,
+// mode toggle, clear/dispose buttons), and is a single horizontal
+// row (height < 80px on a 1080p viewport).
+test('top bar renders as a single styled row when baseline loaded', async () => {
+ await loadBaseline(BASELINE_TRACE);
+ const bar = page.locator('.ah-top-bar:not(.ah-top-bar--hidden)');
+ await expect(bar).toBeVisible();
+ const box = await bar.boundingBox();
+ expect(box, 'top bar should have a layout box').not.toBeNull();
+ expect(box!.height, 'top bar should be a single row').toBeLessThan(80);
+ // Required pieces are inside.
+ await expect(bar.locator('text=Baseline:')).toBeVisible();
+ await expect(bar.locator('.pf-segmented-buttons')).toBeVisible();
+ await expect(
+ bar.locator('button[aria-label="Clear active baseline"]'),
+ ).toBeVisible();
+ await expect(
+ bar.locator('button[aria-label="Remove all baseline traces"]'),
+ ).toBeVisible();
+});
+
+// 11d. Status colour cues: the GREW/SHRANK badges render with the
+// expected --pf-color-* tokens. Asserts colour is the dominant
+// semantic so colour-blind users have the aria-label as backup.
+test('Status badges use Perfetto color tokens', async () => {
+ test.setTimeout(120_000);
+ await loadBaseline(BASELINE_TRACE);
+ await page.locator('.pf-tabs__tab:has-text("Classes")').click();
+ // The .ah-status-text span only renders for non-UNCHANGED rows.
+ // Wait for at least one to attach.
+ await page
+ .locator('.pf-data-grid .ah-status-text')
+ .first()
+ .waitFor({state: 'attached', timeout: 90_000});
+ // Find every status badge in the grid and partition by aria-label
+ // (the source-of-truth attribute we set in the renderer). We do this
+ // in one page.evaluate so we don't pay for many roundtrips on a long
+ // grid, and so we get a single deterministic snapshot.
+ const samples = await page.evaluate(() => {
+ const out: Array<{label: string; aria: string; color: string}> = [];
+ const cells = document.querySelectorAll(
+ '.pf-data-grid .ah-status-text',
+ );
+ for (const c of cells) {
+ out.push({
+ label: (c.textContent ?? '').trim(),
+ aria: c.getAttribute('aria-label') ?? '',
+ color: getComputedStyle(c).color,
+ });
+ if (out.length >= 200) break;
+ }
+ return out;
+ });
+ expect(samples.length, 'no status badges in grid').toBeGreaterThan(0);
+ // The DataGrid is virtualized — only currently-visible status cells
+ // are in the DOM (sorted Δ-DESC, so usually NEW or large GREW). Map
+ // each visible status to its expected colour invariant and assert.
+ const expects: Record boolean> = {
+ NEW: ([r, g, b]) => r > 100 && g > 100 && b < 100, // amber/warning
+ GREW: ([r, g, b]) => r > g && r > b, // red/danger
+ SHRANK: ([r, g, b]) => g > r && g > b, // green/success
+ REMOVED: ([r, g, b]) => Math.abs(r - g) < 30 && Math.abs(g - b) < 30, // muted gray
+ };
+ let checked = 0;
+ for (const s of samples) {
+ const pred = expects[s.label];
+ if (pred === undefined) continue;
+ const rgb = (s.color.match(/\d+/g) ?? []).map(Number);
+ expect(
+ pred(rgb),
+ `${s.label} colour ${s.color} fails its hue invariant`,
+ ).toBe(true);
+ checked++;
+ }
+ expect(
+ checked,
+ `no recognised status badges in sample; got: ${samples.map((s) => s.label).join(',')}`,
+ ).toBeGreaterThan(0);
+});
+
+// 11e. Multi-trace pool switch: load two baselines, programmatically pick
+// the second, verify the active baseline flips and the diff content
+// actually changes. Uses a window-exposed test helper so we don't
+// have to fight DataGrid header order or popup ordering.
+test('switching active baseline between two pooled traces re-renders diff', async () => {
+ test.setTimeout(180_000);
+ await loadBaseline(BASELINE_TRACE);
+ await fileInputLocator().setInputFiles(tracePath(HPROF_TRACE));
+ await page.waitForFunction(
+ () => window.__heapdumpDebug!.poolSize() === 2,
+ null,
+ {timeout: 60_000},
+ );
+ await pth.waitForPerfettoIdle();
+
+ // Initial state: BASELINE_TRACE is active.
+ expect(
+ await page.evaluate(() => window.__heapdumpDebug!.baselineFilename()),
+ ).toBe(BASELINE_TRACE);
+
+ // Render the Classes diff once with BASELINE_TRACE so a snapshot is
+ // published.
+ await page.locator('.pf-tabs__tab:has-text("Classes")').click();
+ await page
+ .locator('.ah-view-heading:has-text("Classes diff")')
+ .first()
+ .waitFor({state: 'attached', timeout: 90_000});
+ await page.waitForFunction(
+ () => (window.__heapdumpDiff?.gen('classes') ?? 0) > 0,
+ null,
+ {timeout: 30_000},
+ );
+ const genBefore = await page.evaluate(
+ () => window.__heapdumpDiff!.gen('classes')!,
+ );
+ const totalBefore = await page.evaluate(
+ () => window.__heapdumpDiff!.rows('classes')!.length,
+ );
+
+ // Switch to the hprof baseline.
+ await page.evaluate(() =>
+ window.__heapdumpDebug!.pickBaseline('test-dump.hprof'),
+ );
+ expect(
+ await page.evaluate(() => window.__heapdumpDebug!.baselineFilename()),
+ ).toBe(HPROF_TRACE);
+
+ // Wait for a fresh classes snapshot to land — the gen counter must
+ // strictly advance because the diff view remounted (tabsKey change)
+ // and re-published.
+ await page.waitForFunction(
+ (g) => (window.__heapdumpDiff?.gen('classes') ?? 0) > g,
+ genBefore,
+ {timeout: 90_000},
+ );
+ const totalAfter = await page.evaluate(
+ () => window.__heapdumpDiff!.rows('classes')!.length,
+ );
+ // The two baselines have different class sets — totals should differ.
+ expect(totalAfter).not.toBe(totalBefore);
+});
+
+// 11f. Mid-flight cancellation: start a Classes diff, immediately clear
+// the baseline before it can finish. The published snapshot for
+// 'classes' must be empty (cleared by clearActiveBaseline → the
+// pending fetch's snapshot guard aborts the publish).
+test('clearing baseline mid-flight aborts the diff cleanly', async () => {
+ test.setTimeout(120_000);
+ await loadBaseline(BASELINE_TRACE);
+ await page.locator('.pf-tabs__tab:has-text("Classes")').click();
+ // Yield one frame so the diff view's load() begins.
+ await page.evaluate(() => new Promise((r) => requestAnimationFrame(r)));
+ // Clear before the query completes.
+ await page.locator('button[aria-label="Clear active baseline"]').click();
+ // After clear, the published rows are empty; gen also resets to 0
+ // (clearDiffRows wipes the snapshot map).
+ await page.waitForFunction(
+ () => window.__heapdumpDebug!.hasBaseline() === false,
+ null,
+ {timeout: 5_000},
+ );
+ expect(await page.evaluate(() => window.__heapdumpDiff!.gen('classes'))).toBe(
+ 0,
+ );
+ expect(
+ await page.evaluate(() => window.__heapdumpDiff!.rows('classes')),
+ ).toBeNull();
+ // No error appears — the abort is silent.
+ expect(
+ await page.locator('.pf-empty-state:visible:has-text("Failed")').count(),
+ ).toBe(0);
+});
+
+// 11g. Re-load a previously-cleared trace's same file. The pool stays
+// with one trace BUT it's now a fresh entry (new engine, new id);
+// the active dump auto-picks. Verifies clear-vs-remove distinction
+// and that the auto-pick rule still works after a clear.
+test('clear + reload same baseline file replaces the pooled entry', async () => {
+ test.setTimeout(120_000);
+ await loadBaseline(BASELINE_TRACE);
+ expect(await page.evaluate(() => window.__heapdumpDebug!.poolSize())).toBe(1);
+
+ // Clear (pool keeps the trace) and reload the same file (now we have
+ // 2 entries, both with title BASELINE_TRACE).
+ await page.locator('button[aria-label="Clear active baseline"]').click();
+ await page.waitForFunction(
+ () => window.__heapdumpDebug!.hasBaseline() === false,
+ null,
+ {timeout: 5_000},
+ );
+ await fileInputLocator().setInputFiles(tracePath(BASELINE_TRACE));
+ await page.waitForFunction(
+ () => window.__heapdumpDebug!.poolSize() === 2,
+ null,
+ {timeout: 60_000},
+ );
+ // Auto-pick fired this time (active was null + single-dump trace).
+ await page.waitForFunction(
+ () => window.__heapdumpDebug!.hasBaseline() === true,
+ null,
+ {timeout: 5_000},
+ );
+ expect(
+ await page.evaluate(() => window.__heapdumpDebug!.baselineFilename()),
+ ).toBe(BASELINE_TRACE);
+});
+
+// 11h. Switching tabs while a diff is loading does not crash, and the
+// destination tab eventually shows its data.
+test('rapid tab switching during diff load is safe', async () => {
+ test.setTimeout(180_000);
+ await loadBaseline(BASELINE_TRACE);
+ // Visit each diff-capable tab in quick succession. No waits between
+ // clicks — we want overlapping in-flight fetches.
+ for (const tab of ['Classes', 'Strings', 'Arrays', 'Bitmaps', 'Dominators']) {
+ await page.locator(`.pf-tabs__tab:has-text("${tab}")`).click();
+ }
+ // Land on Classes and let it finish.
+ await page.locator('.pf-tabs__tab:has-text("Classes")').click();
+ await page
+ .locator('.ah-view-heading:has-text("Classes diff")')
+ .first()
+ .waitFor({state: 'attached', timeout: 120_000});
+ await page.waitForFunction(
+ () => (window.__heapdumpDiff?.gen('classes') ?? 0) > 0,
+ null,
+ {timeout: 90_000},
+ );
+ // No errors anywhere on the page.
+ expect(
+ await page.locator('.pf-empty-state:visible:has-text("Failed")').count(),
+ ).toBe(0);
+});
+
+// 11i. Loading the SAME file as both primary (already loaded) and baseline
+// yields the all-UNCHANGED contract. Specifically, every status
+// bucket except UNCHANGED is empty in the published rows.
+test('same trace as primary + baseline → only UNCHANGED rows', async () => {
+ test.setTimeout(120_000);
+ await loadBaseline(PRIMARY_TRACE); // same file as primary
+ await page.locator('.pf-tabs__tab:has-text("Classes")').click();
+ await page
+ .locator('.ah-view-heading:has-text("Classes diff")')
+ .first()
+ .waitFor({state: 'attached', timeout: 90_000});
+ await page.waitForFunction(
+ () => (window.__heapdumpDiff?.gen('classes') ?? 0) > 0,
+ null,
+ {timeout: 30_000},
+ );
+ const summary = await page.evaluate(() => {
+ const rows = window.__heapdumpDiff!.rows('classes')!;
+ const counts = {NEW: 0, REMOVED: 0, GREW: 0, SHRANK: 0, UNCHANGED: 0};
+ for (const r of rows) counts[r.status as keyof typeof counts]++;
+ return {total: rows.length, counts};
+ });
+ expect(summary.total).toBeGreaterThan(0);
+ expect(summary.counts.NEW).toBe(0);
+ expect(summary.counts.REMOVED).toBe(0);
+ expect(summary.counts.GREW).toBe(0);
+ expect(summary.counts.SHRANK).toBe(0);
+ expect(summary.counts.UNCHANGED).toBe(summary.total);
+});
+
+// 11j. Mode toggle "Baseline" — show the baseline trace's data using
+// the single-engine views (no Δ columns, no diff math). The
+// heading must NOT contain "diff".
+test('Baseline-only mode shows baseline data via single-engine views', async () => {
+ test.setTimeout(120_000);
+ await loadBaseline(BASELINE_TRACE);
+ await page.locator('.pf-segmented-button:has-text("Baseline")').click();
+ await pth.waitForPerfettoIdle();
+ await page.locator('.pf-tabs__tab:has-text("Classes")').click();
+ await pth.waitForPerfettoIdle();
+ // No "Classes diff" heading.
+ expect(
+ await page
+ .locator('.ah-view-heading:visible:has-text("Classes diff")')
+ .count(),
+ ).toBe(0);
+ // Single-engine "Classes" heading IS visible.
+ await expect(page.locator('.ah-view-heading:visible').first()).toContainText(
+ /^Classes/,
+ {timeout: 30_000},
+ );
+ // The mode debug API agrees.
+ expect(await page.evaluate(() => window.__heapdumpDebug!.mode())).toBe(
+ 'baseline',
+ );
+});
+
+// 12. No uncaught page errors or console.error across loading + tab nav.
+// Catches regressions where a missing import / runtime crash would only
+// surface as a console scribble and an empty card.
+// Wait for the pool to reach `expected` traces. The base `loadBaseline`
+// helper waits on hasBaseline() which goes true on the first load and
+// stays true through subsequent loads — this waits on poolSize change.
+async function waitForPoolSize(expected: number): Promise {
+ await page.waitForFunction(
+ (n) => window.__heapdumpDebug?.poolSize() === n,
+ expected,
+ {timeout: 60_000},
+ );
+}
+
+// Edge: removing a non-active pooled baseline preserves the active
+// selection.
+test('removing a non-active pooled trace keeps the active selection', async () => {
+ test.setTimeout(180_000);
+ await loadBaseline(BASELINE_TRACE);
+ await waitForPoolSize(1);
+ await fileInputLocator().setInputFiles(tracePath(HPROF_TRACE));
+ await waitForPoolSize(2);
+ await pth.waitForPerfettoIdle();
+ // Switch active to the hprof trace.
+ await page.evaluate(
+ (title) => window.__heapdumpDebug?.pickBaseline(title),
+ HPROF_TRACE,
+ );
+ expect(
+ await page.evaluate(() => window.__heapdumpDebug?.baselineFilename()),
+ ).toBe(HPROF_TRACE);
+ // Remove the non-active trace by clicking its row's "Remove …" item.
+ await page.locator('.ah-top-bar button:has-text("·")').first().click();
+ await page.locator(`text=Remove ${BASELINE_TRACE}`).click();
+ expect(
+ await page.evaluate(() => window.__heapdumpDebug?.baselineFilename()),
+ ).toBe(HPROF_TRACE);
+ expect(await page.evaluate(() => window.__heapdumpDebug?.poolSize())).toBe(1);
+});
+
+// Edge: removing the active pooled trace clears the selection but
+// leaves the pool with the remaining trace.
+test('removing the active pooled trace clears the selection', async () => {
+ test.setTimeout(180_000);
+ await loadBaseline(BASELINE_TRACE);
+ await waitForPoolSize(1);
+ await fileInputLocator().setInputFiles(tracePath(HPROF_TRACE));
+ await waitForPoolSize(2);
+ await pth.waitForPerfettoIdle();
+ const before = await page.evaluate(() =>
+ window.__heapdumpDebug?.baselineFilename(),
+ );
+ expect(before).not.toBeNull();
+ await page.locator('.ah-top-bar button:has-text("·")').first().click();
+ await page.locator(`text=Remove ${before}`).click();
+ expect(await page.evaluate(() => window.__heapdumpDebug?.hasBaseline())).toBe(
+ false,
+ );
+ expect(await page.evaluate(() => window.__heapdumpDebug?.poolSize())).toBe(1);
+});
+
+test('no console errors during baseline load and tab navigation', async () => {
+ test.setTimeout(120_000);
+ await loadBaseline(BASELINE_TRACE);
+ await ensureOnOverview();
+ await page.waitForTimeout(8_000); // let baseline overview finish
+ for (const tab of ['Classes', 'Strings', 'Bitmaps', 'Overview']) {
+ await page.locator(`.pf-tabs__tab:has-text("${tab}")`).click();
+ await page.waitForTimeout(4_000);
+ }
+ expect(pageErrors, 'page emitted uncaught errors').toEqual([]);
+ expect(
+ consoleErrors.filter(
+ // Allow trace-processor's noisy benign warnings through; they're not
+ // from the plugin code.
+ (e) => !e.includes('TraceProcessor') && !e.includes('WebGL'),
+ ),
+ 'plugin emitted console errors',
+ ).toEqual([]);
+});
diff --git a/ui/src/test/heap_dump_diff_hprof_primary.test.ts b/ui/src/test/heap_dump_diff_hprof_primary.test.ts
new file mode 100644
index 00000000000..14ff1b0d710
--- /dev/null
+++ b/ui/src/test/heap_dump_diff_hprof_primary.test.ts
@@ -0,0 +1,174 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Companion to heap_dump_diff.test.ts: opens a *raw hprof* as the
+// primary trace (the other suite uses a perfetto pftrace as primary).
+// Confirms the explorer renders, the diff CTA is reachable, and a
+// pftrace baseline diffs cleanly against an hprof primary.
+
+import {test, expect, Page} from '@playwright/test';
+import path from 'path';
+import fs from 'fs';
+import {PerfettoTestHelper} from './perfetto_ui_test_helper';
+import '../plugins/com.android.HeapDumpExplorer/baseline/state';
+import '../plugins/com.android.HeapDumpExplorer/diff/diff_debug';
+
+test.describe.configure({mode: 'serial'});
+
+const PRIMARY_HPROF = 'test-dump.hprof';
+const BASELINE_PFTRACE = 'system-server-heap-graph.pftrace';
+
+let pth: PerfettoTestHelper;
+let page: Page;
+
+function tracePath(name: string): string {
+ const cwd = process.cwd();
+ const parts = ['test', 'data', name];
+ if (cwd.endsWith('/ui')) parts.unshift('..');
+ const p = path.join(...parts);
+ if (!fs.existsSync(p)) throw new Error(`missing ${p} (cwd=${cwd})`);
+ return p;
+}
+
+function fileInputLocator() {
+ return page.locator('input[type=file][aria-hidden="true"]');
+}
+
+async function ensureOnOverview(): Promise {
+ await page.locator('.pf-tabs__tab:has-text("Overview")').first().click();
+ await pth.waitForPerfettoIdle();
+}
+
+test.beforeAll(async ({browser}) => {
+ page = await browser.newPage();
+ pth = new PerfettoTestHelper(page);
+ await pth.openTraceFile(PRIMARY_HPROF);
+ await page.evaluate(() => {
+ window.location.hash = '#!/heapdump';
+ });
+ await pth.waitForPerfettoIdle();
+});
+
+test.afterEach(async () => {
+ await page.evaluate(() => {
+ document
+ .querySelector(
+ 'button[aria-label="Remove all baseline traces"]',
+ )
+ ?.click();
+ });
+});
+
+// 1. Hprof opens cleanly as the primary trace.
+test('hprof primary loads and shows the Overview heading', async () => {
+ await ensureOnOverview();
+ await expect(
+ page.locator('.ah-view-heading:has-text("Overview")').first(),
+ ).toBeVisible();
+ // The Diff CTA is rendered (no baseline yet).
+ await expect(
+ page.locator('button:has-text("Diff against another trace")'),
+ ).toBeVisible();
+});
+
+// 2. Hprof primary + pftrace baseline → real diff with non-zero deltas.
+test('hprof primary + pftrace baseline produces a usable diff', async () => {
+ test.setTimeout(180_000);
+ await ensureOnOverview();
+ await fileInputLocator().setInputFiles(tracePath(BASELINE_PFTRACE));
+ await page.waitForFunction(
+ () => window.__heapdumpDebug?.hasBaseline(),
+ null,
+ {timeout: 60_000},
+ );
+ await pth.waitForPerfettoIdle();
+ // Open Classes diff and wait for non-UNCHANGED rows.
+ await page.locator('.pf-tabs__tab:has-text("Classes")').click();
+ await page
+ .locator('.ah-view-heading:has-text("Classes diff")')
+ .first()
+ .waitFor({state: 'attached', timeout: 90_000});
+ await page.waitForFunction(
+ () => (window.__heapdumpDiff?.gen('classes') ?? 0) > 0,
+ null,
+ {timeout: 30_000},
+ );
+ const summary = await page.evaluate(() => {
+ const rows = window.__heapdumpDiff!.rows('classes')!;
+ const counts = {NEW: 0, REMOVED: 0, GREW: 0, SHRANK: 0, UNCHANGED: 0};
+ for (const r of rows) counts[r.status as keyof typeof counts]++;
+ return {total: rows.length, counts};
+ });
+ expect(summary.total).toBeGreaterThan(0);
+ // Two completely different traces: a healthy mix of statuses.
+ expect(summary.counts.NEW + summary.counts.REMOVED).toBeGreaterThan(0);
+});
+
+// 3. Hprof + hprof reload (same file as primary loaded as baseline).
+// Verifies the engine isolation works across two parses of the same
+// file: each owns its own Wasm heap.
+test('hprof loaded twice (primary & baseline) yields a consistent diff', async () => {
+ test.setTimeout(120_000);
+ await ensureOnOverview();
+ await fileInputLocator().setInputFiles(tracePath(PRIMARY_HPROF));
+ await page.waitForFunction(
+ () => window.__heapdumpDebug?.hasBaseline(),
+ null,
+ {timeout: 60_000},
+ );
+ await pth.waitForPerfettoIdle();
+ await page.locator('.pf-tabs__tab:has-text("Classes")').click();
+ await page
+ .locator('.ah-view-heading:has-text("Classes diff")')
+ .first()
+ .waitFor({state: 'attached', timeout: 90_000});
+ await page.waitForFunction(
+ () => (window.__heapdumpDiff?.gen('classes') ?? 0) > 0,
+ null,
+ {timeout: 30_000},
+ );
+ const summary = await page.evaluate(() => {
+ const rows = window.__heapdumpDiff!.rows('classes')!;
+ const counts = {NEW: 0, REMOVED: 0, GREW: 0, SHRANK: 0, UNCHANGED: 0};
+ for (const r of rows) counts[r.status as keyof typeof counts]++;
+ return {total: rows.length, counts};
+ });
+ expect(summary.total).toBeGreaterThan(0);
+ // Same file diffed against itself: every row is UNCHANGED.
+ expect(summary.counts.NEW).toBe(0);
+ expect(summary.counts.REMOVED).toBe(0);
+ expect(summary.counts.GREW).toBe(0);
+ expect(summary.counts.SHRANK).toBe(0);
+});
+
+// 4. Baseline label for hprof: "Java heap dump" rather than "pid 0"
+// when the trace has no process metadata.
+test('hprof baseline label is "Java heap dump" when pid is unknown', async () => {
+ await ensureOnOverview();
+ await fileInputLocator().setInputFiles(tracePath(PRIMARY_HPROF));
+ await page.waitForFunction(
+ () => window.__heapdumpDebug?.hasBaseline(),
+ null,
+ {timeout: 60_000},
+ );
+ // The trigger label includes the dump's process label.
+ const trigger = page
+ .locator('.ah-top-bar')
+ .locator(`button:has-text("${PRIMARY_HPROF}")`)
+ .first();
+ await expect(trigger).toBeVisible();
+ // Either "Java heap dump" (preferred) or "pid X" with X != 0.
+ const label = (await trigger.textContent()) ?? '';
+ expect(label).not.toContain('pid 0');
+});
diff --git a/ui/src/widgets/flamegraph.ts b/ui/src/widgets/flamegraph.ts
index f3091e7aa7e..8a843f789b8 100644
--- a/ui/src/widgets/flamegraph.ts
+++ b/ui/src/widgets/flamegraph.ts
@@ -124,6 +124,11 @@ export interface FlamegraphNode {
readonly marker?: string;
readonly xStart: number;
readonly xEnd: number;
+ // Opaque modulation hint of the form `palette:DIR[:INTENSITY]`. Keeps
+ // the per-name palette hue and shifts saturation/lightness only — see
+ // getColorSchemeFromHint. Today only the heap-dump diff metric emits
+ // these.
+ readonly colorHint?: string;
}
export interface FlamegraphQueryData {
@@ -691,7 +696,12 @@ export class Flamegraph implements m.ClassComponent {
colorScheme = getFlamegraphColorScheme(name, state === 'PARTIAL');
} else {
name = nodes[source.queryIdx].name;
- colorScheme = getFlamegraphColorScheme(name, state === 'PARTIAL');
+ const hint = nodes[source.queryIdx].colorHint;
+ if (hint !== undefined && state !== 'PARTIAL') {
+ colorScheme = getColorSchemeFromHint(hint, name);
+ } else {
+ colorScheme = getFlamegraphColorScheme(name, state === 'PARTIAL');
+ }
}
const bgColor = hover ? colorScheme.variant : colorScheme.base;
const textColor = hover ? colorScheme.textVariant : colorScheme.textBase;
@@ -1629,6 +1639,61 @@ const ROOT_COLOR_SCHEME = makeColorScheme(
// Cache for computed color schemes by name
const colorSchemeCache = new Map();
+// Schemes derived from `palette:DIR[:INTENSITY]` hints. Cache key
+// includes the node name because the hue depends on it.
+const hintSchemeCache = new Map();
+
+// Resolve a palette-modulator hint into a ColorScheme. Format:
+// `palette:DIR[:INTENSITY]` where DIR ∈ {g, s, n, u} (grew, shrank, new,
+// unchanged) and INTENSITY ∈ [0, 1] scales how far the colour shifts
+// from the base palette. Hue is always preserved so each node keeps the
+// identity it has in the non-diff palette.
+function getColorSchemeFromHint(hint: string, name: string): ColorScheme {
+ const cacheKey = `${name}|${hint}`;
+ const cached = hintSchemeCache.get(cacheKey);
+ if (cached !== undefined) return cached;
+ const [, dir, intensityStr] = hint.split(':');
+ const i = clampUnit(Number(intensityStr ?? '1'));
+ const base = modulatePalette(paletteHsl(name), dir, i);
+ const scheme = makeColorScheme(base, base.darken(12).saturate(15));
+ hintSchemeCache.set(cacheKey, scheme);
+ return scheme;
+}
+
+function modulatePalette(
+ base: HSLColor,
+ dir: string,
+ intensity: number,
+): HSLColor {
+ switch (dir) {
+ case 'g': // grew: more vivid, slightly darker
+ return base
+ .saturate(Math.round(25 * intensity))
+ .darken(Math.round(8 * intensity));
+ case 's': // shrank: faded toward background, hue retained
+ return base
+ .lighten(Math.round(15 * intensity))
+ .desaturate(Math.round(15 * intensity));
+ case 'n': // new
+ return base.saturate(25).darken(5);
+ case 'u': // unchanged
+ return base.lighten(8).desaturate(10);
+ default:
+ return base;
+ }
+}
+
+function clampUnit(x: number): number {
+ return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 1;
+}
+
+// Base palette colour for a node name. Hashing the name to a hue keeps
+// the same class in the same colour across diff and non-diff modes; the
+// fixed saturation / lightness match the pprof web UI.
+function paletteHsl(name: string): HSLColor {
+ return new HSLColor({h: hash(name, 360), s: 46, l: 80});
+}
+
function getFlamegraphColorScheme(name: string, greyed: boolean): ColorScheme {
if (greyed) {
return GREYED_COLOR_SCHEME;
@@ -1636,17 +1701,11 @@ function getFlamegraphColorScheme(name: string, greyed: boolean): ColorScheme {
if (name === 'unknown' || name === 'root') {
return ROOT_COLOR_SCHEME;
}
-
- // Check cache first
let scheme = colorSchemeCache.get(name);
if (scheme !== undefined) {
return scheme;
}
-
- // Hash the name to get a predictable hue, then create color with fixed
- // saturation and lightness values to match what pprof web UI does.
- const hue = hash(name, 360);
- const base = new HSLColor({h: hue, s: 46, l: 80});
+ const base = paletteHsl(name);
scheme = makeColorScheme(base, base.darken(15).saturate(15));
colorSchemeCache.set(name, scheme);
return scheme;