Files
MultiRoombaRover/perf/issues/005-replay-sources-panel-churn.md

4.9 KiB

Issue 005: ReplaySourcesPanel Repeated DOM Commits

Summary

ReplaySourcesPanel appears to re-commit many input/checkbox attributes repeatedly on /, especially in mobile portrait where the panel is always mounted near the top of the page.

This may be a component-local issue, or it may be caused by broader context/session updates. Tackle it after issues 001 and 002 unless profiling still shows it as large.

Severity

Medium.

It is visible in mutation probes, but likely partly downstream of broader invalidation.

Affected Files

  • webui/src/components/ReplaySourcesPanel/index.jsx
  • webui/src/App.jsx
  • webui/src/context/SessionContext.jsx
  • webui/src/settings/SettingsProvider.jsx

Evidence

Artifact:

perf/results/2026-06-11T03-22-44-714Z-root-runtime/root-runtime-report.json

In an 18s / audit at mobile viewport with 6x CPU throttle, top mutation targets included:

input.accent-emerald-400 name: 5300
input.accent-emerald-400 type: 2650
input#replay-sources-mobile-portrait-title name: 666
input#replay-sources-mobile-portrait-title type: 333

The accent-emerald-400 class maps to ReplaySourcesPanel checkboxes:

<input
  type="checkbox"
  checked={includeSidebar}
  ...
  className="accent-emerald-400"
/>

and source item checkboxes:

<input
  type="checkbox"
  checked={selected.includes(item.key)}
  onChange={() => onToggle(item.key)}
  className="accent-emerald-400"
/>

The mobile portrait route mounts it here:

<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />

in App.jsx.

Why This Matters

Repeated DOM attribute commits are a symptom of repeated React commits. Even if the attribute values do not change semantically, React still touches DOM attributes when the subtree rerenders/commits.

The panel is not central to driving, so it should not be doing meaningful work during normal control-page idle.

Likely Causes

The component subscribes to several session slices:

const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []);
const mode = useSessionSelector((state) => state.session?.mode || null);
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const replayState = useSessionSelector((state) => state.session?.replay || null);
const latestReplay = useSessionSelector((state) => state.latestReplay);

Potential problems:

  • session:sync may provide new array/object identities even when contents are unchanged.
  • normalizeSources(replaySources || []) runs every render and produces new objects.
  • GroupList is not memoized.
  • selected.includes(item.key) is recomputed for each item on each render.
  • remainingMs interval updates every 250ms when cooldown is active.
  • Parent/mobile layout re-renders can also re-render this panel.

Fix Strategies

Option A: Memoize Normalized Sources

const sources = useMemo(
  () => normalizeSources(replaySources || []),
  [replaySources],
);

This is already not memoized in the current code.

Option B: Use Shallow Equality Selectors

For arrays like replaySources and roster, use a selector/equality function that avoids rerendering when content did not change.

Example:

const replaySources = useSessionSelector(
  (state) => state.session?.replaySources ?? [],
  shallowReplaySourcesEqual,
);

Option C: Memoize GroupList

const GroupList = React.memo(function GroupList(...) { ... });

Also memoize:

const selectedSet = useMemo(() => new Set(selected), [selected]);

Then use selectedSet.has(item.key).

Option D: Do Not Mount Replay Panel By Default on Mobile

If replay is not a core mobile driving workflow, consider putting it behind a tab/accordion or lazy mounting it only when expanded.

This is a product decision.

Option E: Stop Passing Unstable Callbacks

Memoize callbacks:

const toggleKey = useCallback((key) => { ... }, []);
const handleReplay = useCallback(async () => { ... }, [...]);

This helps if child components are memoized.

  1. Fix issues 001 and 002 first.
  2. Rerun root runtime audit.
  3. If ReplaySourcesPanel still dominates mutation targets:
    • memoize sources
    • memoize GroupList
    • add shallow equality selectors
    • consider lazy mounting on mobile

Validation

Run:

CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=18000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results

Expected improvement:

input.accent-emerald-400 name/type mutations should drop sharply
replay title input name/type mutations should drop

Risks

  • Replay source selections and saved panel settings must still update correctly.
  • If lazy mounting, preserve persisted title/sidebar settings.
  • Do not break replay cooldown display.