ui: Improve URL state management in memory overview page (#7353)

Add a few improvements to the URL state management in the memory
overview page.

- The active tab ('summary' or 'smaps', defined by MemoryOverviewTab) is
now encoded in the route (e.g. `#!/memoryoverview/<upid>/smaps`) and
controlled via callbacks rather than local state.
- Auto-redirect to the 'best' process using `location.replace()` when a
upid is omitted from the URL rather than just showing it - e.g.
`#!/memoryoverview/<bestUpid>`. This keeps the URL in sync with what the
user is seeing.
- Small refactor:
  - Refactor process stats logic out into a separate file.
  - Rename landing_page.ts/scss to memory_overview_page.ts/scss.
diff --git a/ui/src/plugins/dev.perfetto.Memscope/index.ts b/ui/src/plugins/dev.perfetto.Memscope/index.ts
index 432e3f0..b20412f 100644
--- a/ui/src/plugins/dev.perfetto.Memscope/index.ts
+++ b/ui/src/plugins/dev.perfetto.Memscope/index.ts
@@ -23,8 +23,11 @@
 import {ConnectionPage} from './views/connection';
 import {Dashboard} from './views/dashboard';
 import {LiveSession} from './sessions/live_session';
-import {MemoryOverviewPage} from './views/landing_page/landing_page';
+import {MemoryOverviewPage} from './views/landing_page/memory_overview_page';
 import {NUM} from '../../trace_processor/query_result';
+import {EmptyState} from '../../widgets/empty_state';
+import type {MemoryOverviewTab} from './views/landing_page/proc_mem_overview';
+import {getBestProcess} from './views/landing_page/proc_mem_stats';
 
 export default class MemscopePlugin implements PerfettoPlugin {
   static readonly id = 'dev.perfetto.Memscope';
@@ -98,21 +101,42 @@
     const hideDefaultChangedHint = MemscopePlugin.hideDefaultChangedHintSetting;
     const availability = await this.getMemoryOverviewAvailability(trace);
     const autoNavigated = openByDefault.get() && availability.hasSmapsSnapshots;
+    const bestUpid = await getBestProcess(trace.engine);
 
     trace.pages.registerPage({
       route: pageRoot,
-      render: (subpage) =>
-        m(MemoryOverviewPage, {
-          trace,
+      render: (subpage) => {
+        const {parsed, redirect} = resolveMemoryOverviewRoute(
           subpage,
+          bestUpid,
+          pageRoot,
+        );
+        if (redirect !== undefined) {
+          return redirect;
+        }
+
+        return m(MemoryOverviewPage, {
+          trace,
+          upid: parsed.upid,
+          tab: parsed.tab,
           autoNavigated,
           hdeAvailable: availability.hasHeapDumps,
           openByDefault,
           hideDefaultChangedHint,
-          onSubpageChange: (subpage) => {
-            trace.navigate(`#!${pageRoot}/${subpage}`);
+          onUpidChange: (newUpid) => {
+            trace.navigate(
+              `#!${pageRoot}/${formatMemoryOverviewSubpage(newUpid, parsed.tab)}`,
+            );
           },
-        }),
+          onTabChange: (tab) => {
+            if (parsed.upid !== undefined) {
+              trace.navigate(
+                `#!${pageRoot}/${formatMemoryOverviewSubpage(parsed.upid, tab)}`,
+              );
+            }
+          },
+        });
+      },
     });
 
     if (availability.hasSmapsSnapshots || availability.hasHeapDumps) {
@@ -151,3 +175,67 @@
     };
   }
 }
+
+interface MemoryOverviewSubpage {
+  readonly upid?: number;
+  readonly tab: MemoryOverviewTab;
+}
+
+function parseMemoryOverviewSubpage(subpage?: string): MemoryOverviewSubpage {
+  if (!subpage) {
+    return {tab: 'summary'};
+  }
+  const parts = subpage.split('/').filter((x) => x !== '');
+  if (parts.length === 0) {
+    return {tab: 'summary'};
+  }
+  const upid = parseInt(parts[0], 10);
+  const tab = parts[1] === 'smaps' ? 'smaps' : 'summary';
+  return {
+    upid: Number.isNaN(upid) ? Number.NaN : upid,
+    tab,
+  };
+}
+
+function formatMemoryOverviewSubpage(
+  upid: number,
+  tab?: MemoryOverviewTab,
+): string {
+  if (tab === 'summary') {
+    return `${upid}`;
+  }
+
+  if (tab !== undefined) {
+    return `${upid}/${tab}`;
+  }
+  return `${upid}`;
+}
+
+function resolveMemoryOverviewRoute(
+  subpage: string | undefined,
+  bestUpid: number | undefined,
+  pageRoot: string,
+): {parsed: MemoryOverviewSubpage; redirect?: m.Children} {
+  const parsed = parseMemoryOverviewSubpage(subpage);
+
+  if (parsed.upid === undefined) {
+    if (bestUpid !== undefined) {
+      location.replace(
+        `#!${pageRoot}/${formatMemoryOverviewSubpage(bestUpid, parsed.tab)}`,
+      );
+      return {
+        parsed,
+        redirect: m(EmptyState, {
+          icon: 'hourglass',
+          title: 'Loading process...',
+        }),
+      };
+    }
+    return {
+      parsed,
+      redirect: m(EmptyState, 'No processes with memory in this trace'),
+    };
+  }
+
+  return {parsed};
+}
diff --git a/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/landing_page.scss b/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/memory_overview_page.scss
similarity index 100%
rename from ui/src/plugins/dev.perfetto.Memscope/views/landing_page/landing_page.scss
rename to ui/src/plugins/dev.perfetto.Memscope/views/landing_page/memory_overview_page.scss
diff --git a/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/landing_page.ts b/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/memory_overview_page.ts
similarity index 64%
rename from ui/src/plugins/dev.perfetto.Memscope/views/landing_page/landing_page.ts
rename to ui/src/plugins/dev.perfetto.Memscope/views/landing_page/memory_overview_page.ts
index c662fc1..1b381f8 100644
--- a/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/landing_page.ts
+++ b/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/memory_overview_page.ts
@@ -17,12 +17,6 @@
 import {AsyncMemo} from '../../../../base/async_memo';
 import type {Setting} from '../../../../public/settings';
 import type {Trace} from '../../../../public/trace';
-import type {Engine} from '../../../../trace_processor/engine';
-import {
-  materializeRows,
-  NUM,
-  STR,
-} from '../../../../trace_processor/query_result';
 import {Button, ButtonGroup, ButtonVariant} from '../../../../widgets/button';
 import {Intent} from '../../../../widgets/common';
 import {EmptyState} from '../../../../widgets/empty_state';
@@ -32,44 +26,40 @@
 import {Callout} from '../../components/callout';
 import {Page} from '../../components/page';
 import {PreviewBanner} from '../../components/preview_banner';
-import './landing_page.scss';
-import {ProcessMemDetails} from './proc_mem_overview';
-
-// Per-process memory-capture counts, used to populate and score the process
-// picker on the overview page.
-interface ProcMemStat {
-  readonly upid: number;
-  readonly pid: number;
-  readonly procName: string;
-  readonly heapDumps: number;
-  readonly smapsSnapshots: number;
-  readonly nativeDumps: number;
-}
+import {MemoryOverviewTab, ProcessMemDetails} from './proc_mem_overview';
+import {
+  loadProcessMemoryStats,
+  type ProcMemStat,
+  type ProcWithMem,
+} from './proc_mem_stats';
+import './memory_overview_page.scss';
 
 export interface MemoryOverviewPageAttrs {
   readonly trace: Trace;
-  readonly subpage: string | undefined;
+  readonly upid?: number;
+  readonly tab: MemoryOverviewTab;
   readonly autoNavigated: boolean;
   readonly hdeAvailable: boolean;
   readonly openByDefault: Setting<boolean>;
   readonly hideDefaultChangedHint: Setting<boolean>;
-  readonly onSubpageChange: (subpage: string) => void;
+  readonly onUpidChange: (upid: number) => void;
+  readonly onTabChange: (tab: MemoryOverviewTab) => void;
 }
 
-type ProcWithMem = readonly ProcMemStat[];
-
 export class MemoryOverviewPage implements m.Component<MemoryOverviewPageAttrs> {
   private readonly slot = new AsyncMemo<ProcWithMem>();
 
   view({attrs}: m.Vnode<MemoryOverviewPageAttrs>) {
     const {
       trace,
-      subpage,
+      upid,
+      tab,
       autoNavigated,
       hdeAvailable,
       openByDefault,
       hideDefaultChangedHint,
-      onSubpageChange,
+      onUpidChange,
+      onTabChange,
     } = attrs;
 
     return m(
@@ -88,7 +78,7 @@
         hideDefaultChangedHint,
       ),
       m(PreviewBanner, {app: trace}),
-      this.renderPageContent(trace, subpage, onSubpageChange),
+      this.renderPageContent(trace, upid, tab, onUpidChange, onTabChange),
     );
   }
 
@@ -164,8 +154,10 @@
 
   private renderPageContent(
     trace: Trace,
-    subpage: string | undefined,
-    onSubpageChange: (subpage: string) => void,
+    upid: number | undefined,
+    tab: MemoryOverviewTab,
+    onUpidChange: (upid: number) => void,
+    onTabChange: (tab: MemoryOverviewTab) => void,
   ) {
     const procsWithMemResult = this.slot.use({
       key: '',
@@ -177,16 +169,11 @@
       return m(EmptyState, {icon: 'hourglass', title: 'Loading processes...'});
     }
 
-    const bestProc = pickBestProc(procs);
-    if (!bestProc) {
+    if (procs.length === 0) {
       return m(EmptyState, 'No processes with memory in this trace');
     }
 
-    // Use the upid in the url bar otherwise pick the 'best' proc - the one most
-    // likely to be what the user was tracing.
-    const selectedUpid = subpage
-      ? parseUpidFromSubpage(subpage)
-      : bestProc.upid;
+    const selectedUpid = upid;
 
     return [
       m('.pf-memscope-process-select', [
@@ -197,7 +184,7 @@
             value: selectedUpid?.toString(),
             onchange: (e: Event) => {
               assertIsInstance(e.target, HTMLSelectElement);
-              onSubpageChange(e.target.value);
+              onUpidChange(Number(e.target.value));
             },
           },
           procs.map((p) =>
@@ -205,69 +192,13 @@
           ),
         ),
       ]),
-      Number.isNaN(selectedUpid)
-        ? m('', `Unable to parse upid from url '${subpage}'`)
-        : m(ProcessMemDetails, {trace, upid: selectedUpid}),
+      selectedUpid === undefined || Number.isNaN(selectedUpid)
+        ? m('', 'Unable to parse upid from url')
+        : m(ProcessMemDetails, {trace, upid: selectedUpid, tab, onTabChange}),
     ];
   }
 }
 
-// Returns a list processes that have memory dumps/smaps/profiles in the trace.
-async function loadProcessMemoryStats(engine: Engine): Promise<ProcWithMem> {
-  const result = await engine.query(`
-    SELECT
-      p.upid,
-      p.pid,
-      COALESCE(p.cmdline, p.name, '<unknown>') AS procName,
-      (
-        SELECT count(*)
-        FROM heap_graph g
-        WHERE g.upid = p.upid
-      ) AS heapDumps,
-      (
-        SELECT count(DISTINCT ts)
-        FROM profiler_smaps s
-        WHERE s.upid = p.upid
-      ) AS smapsSnapshots,
-      (
-        SELECT count(DISTINCT ts)
-        FROM heap_profile_allocation a
-        WHERE a.upid = p.upid
-      ) AS nativeDumps
-    FROM process p
-    WHERE heapDumps > 0 OR smapsSnapshots > 0 OR nativeDumps > 0
-    ORDER BY p.upid;
-  `);
-  return materializeRows(result, {
-    upid: NUM,
-    pid: NUM,
-    procName: STR,
-    heapDumps: NUM,
-    smapsSnapshots: NUM,
-    nativeDumps: NUM,
-  });
-}
-
-// Scores a process to determine how relevant it is for the landing page.
-// Higher score = more relevant. We weight by data type and count to pick
-// the process with the richest memory analysis data.
-function scoreProc(p: ProcMemStat): number {
-  // Heap dumps are the richest data source, followed by smaps, then profiles.
-  return p.heapDumps * 3 + p.smapsSnapshots * 2 + p.nativeDumps * 1;
-}
-
-function pickBestProc(procs: ProcWithMem) {
-  if (procs.length === 0) return undefined;
-  return procs.reduce((best, p) => (scoreProc(p) > scoreProc(best) ? p : best));
-}
-
-// The subpage might look like '/123' or even '/123/foo'
-function parseUpidFromSubpage(subpage: string): number {
-  const parts = subpage.split('/').filter((x) => x !== '');
-  if (parts.length === 0) return Number.NaN;
-  return parseInt(parts[0]);
-}
-
 function procOptionLabel(p: ProcMemStat): string {
   const parts: string[] = [];
   if (p.heapDumps > 0) parts.push(`${p.heapDumps} java_hprof`);
diff --git a/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/proc_mem_overview.ts b/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/proc_mem_overview.ts
index 57a8224..5ca7699 100644
--- a/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/proc_mem_overview.ts
+++ b/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/proc_mem_overview.ts
@@ -39,7 +39,6 @@
 import {MemoryMap} from './summary/memory_map';
 import {NativeSection} from './summary/native_section';
 import {TraceOverview} from './summary/trace_overview';
-import './landing_page.scss';
 
 // Sample count and observed time span of one capture source (smaps / heapprofd)
 // for a process. spanS is undefined when there are fewer than two samples.
@@ -109,9 +108,13 @@
   return Array.from(byTs.values());
 }
 
+export type MemoryOverviewTab = 'summary' | 'smaps';
+
 export interface ProcessMemDetailsAttrs {
   readonly trace: Trace;
   readonly upid: number;
+  readonly tab: MemoryOverviewTab;
+  readonly onTabChange: (tab: MemoryOverviewTab) => void;
 }
 
 export class ProcessMemDetails implements m.ClassComponent<ProcessMemDetailsAttrs> {
@@ -121,7 +124,6 @@
   // Whole-trace per-snapshot smaps breakdown for the growth bar (keyed by
   // upid; independent of the page's snapshot selection).
   private readonly growthSlot = new AsyncMemo<GrowthSnapshot[]>();
-  private activeTab: 'summary' | 'smaps' = 'summary';
   // The page-wide snapshot selection, driven by the composition timeline and
   // shared with the other summary sections as they're added.
   private selection?: MemSelection;
@@ -132,7 +134,7 @@
   }
 
   view({attrs}: m.Vnode<ProcessMemDetailsAttrs>) {
-    const {trace, upid} = attrs;
+    const {trace, upid, tab, onTabChange} = attrs;
     let capture: CaptureInfo | undefined;
     let error: string | undefined;
     try {
@@ -145,7 +147,7 @@
     }
 
     const smapsMissing = capture !== undefined && capture.smaps.samples === 0;
-    const activeTab = smapsMissing ? 'summary' : this.activeTab;
+    const activeTab = smapsMissing ? 'summary' : tab;
 
     // Both tab bodies stay mounted and are toggled with a Gate (display:none
     // when hidden) rather than conditionally rendered, so switching tabs doesn't
@@ -153,7 +155,8 @@
     return [
       error !== undefined && m('p.pf-error', `Error: ${error}`),
       error === undefined && this.renderCaptureStrip(trace, capture),
-      error === undefined && this.renderTabs(activeTab, smapsMissing),
+      error === undefined &&
+        this.renderTabs(activeTab, smapsMissing, onTabChange),
       error === undefined &&
         m(
           Gate,
@@ -252,11 +255,12 @@
   }
 
   private renderTabs(
-    activeTab: 'summary' | 'smaps',
+    activeTab: MemoryOverviewTab,
     smapsMissing: boolean,
+    onTabChange: (tab: MemoryOverviewTab) => void,
   ): m.Children {
     const tabs: {
-      key: 'summary' | 'smaps';
+      key: MemoryOverviewTab;
       label: string;
       icon: string;
       disabled?: boolean;
@@ -283,7 +287,7 @@
             title: t.disabled
               ? 'No smaps data available for this process'
               : undefined,
-            onclick: () => (this.activeTab = t.key),
+            onclick: () => onTabChange(t.key),
           },
           [m(Icon, {icon: t.icon}), t.label],
         ),
diff --git a/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/proc_mem_stats.ts b/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/proc_mem_stats.ts
new file mode 100644
index 0000000..e7c30ce
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/proc_mem_stats.ts
@@ -0,0 +1,91 @@
+// 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 {Engine} from '../../../../trace_processor/engine';
+import {
+  materializeRows,
+  NUM,
+  STR,
+} from '../../../../trace_processor/query_result';
+
+// Per-process memory-capture counts, used to populate and score the process
+// picker on the overview page.
+export interface ProcMemStat {
+  readonly upid: number;
+  readonly pid: number;
+  readonly procName: string;
+  readonly heapDumps: number;
+  readonly smapsSnapshots: number;
+  readonly nativeDumps: number;
+}
+
+export type ProcWithMem = readonly ProcMemStat[];
+
+// Returns a list processes that have memory dumps/smaps/profiles in the trace.
+export async function loadProcessMemoryStats(
+  engine: Engine,
+): Promise<ProcWithMem> {
+  const result = await engine.query(`
+    SELECT
+      p.upid,
+      p.pid,
+      COALESCE(p.cmdline, p.name, '<unknown>') AS procName,
+      (
+        SELECT count(*)
+        FROM heap_graph g
+        WHERE g.upid = p.upid
+      ) AS heapDumps,
+      (
+        SELECT count(DISTINCT ts)
+        FROM profiler_smaps s
+        WHERE s.upid = p.upid
+      ) AS smapsSnapshots,
+      (
+        SELECT count(DISTINCT ts)
+        FROM heap_profile_allocation a
+        WHERE a.upid = p.upid
+      ) AS nativeDumps
+    FROM process p
+    WHERE heapDumps > 0 OR smapsSnapshots > 0 OR nativeDumps > 0
+    ORDER BY p.upid;
+  `);
+  return materializeRows(result, {
+    upid: NUM,
+    pid: NUM,
+    procName: STR,
+    heapDumps: NUM,
+    smapsSnapshots: NUM,
+    nativeDumps: NUM,
+  });
+}
+
+// Scores a process to determine how relevant it is for the landing page.
+// Higher score = more relevant. We weight by data type and count to pick
+// the process with the richest memory analysis data.
+export function scoreProc(p: ProcMemStat): number {
+  // Heap dumps are the richest data source, followed by smaps, then profiles.
+  return p.heapDumps * 3 + p.smapsSnapshots * 2 + p.nativeDumps * 1;
+}
+
+export function pickBestProc(procs: ProcWithMem): ProcMemStat | undefined {
+  if (procs.length === 0) return undefined;
+  return procs.reduce((best, p) => (scoreProc(p) > scoreProc(best) ? p : best));
+}
+
+export async function getBestProcess(
+  engine: Engine,
+): Promise<number | undefined> {
+  const procs = await loadProcessMemoryStats(engine);
+  return pickBestProc(procs)?.upid;
+}