5.5 KiB
Issue 004: Broad Spectator Session Rerenders
Summary
SpectatorContent reads the whole session object with useSession(). That means broad
session changes can rerender the top-level spectator layout, including the sidebar, rover
row, secondary row, chat, logs, room cameras, and overlays.
The audit saw 54 session:sync events in 30s, carrying about 914KB, while the DOM also
showed repeated added/removed-node bursts.
Severity
Medium-high.
This is likely a multiplier. It may not be the single hottest path, but it can cause otherwise independent components to rerender together.
Upstream Likelihood
Medium to high.
If the / backlog changes SessionContext to provide better selectors, better structural
sharing, batched log/session updates, or more stable action references, this issue may be
partly fixed upstream. However, SpectatorContent itself still needs to stop reading the
whole session object.
Related upstream issues:
perf/issues/003-high-volume-log-stream.md
perf/issues/006-chat-and-nickname-churn.md
perf/issues/007-timers-and-polling.md
Affected Files
webui/src/spectate/SpectatorApp/SpectatorContent.jsxwebui/src/hooks/useSpectatorMode.jswebui/src/context/SessionContext.jsxwebui/src/spectate/SpectatorApp/components/RoverRow.jsxwebui/src/spectate/SpectatorApp/components/SecondaryRow.jsx- sidebar components mounted by
SpectatorContent
Evidence
Artifact:
perf/results/2026-06-12T04-55-16-888Z-root-runtime/root-runtime-report.json
30s sample:
session:sync events: 54
session:sync bytes: 914,000
DOM mutation records: 5,868
DOM nodes added: 440
DOM nodes removed: 440
Largest mutation bursts:
119 records, 57 added, 58 removed
117 records, 52 added, 53 removed
113 records, 52 added, 52 removed
102 records, 49 added, 49 removed
100 records, 48 added, 48 removed
Those bursts suggest periodic component/list updates, not just single text changes.
Current Code Path
SpectatorContent subscribes to the whole session:
webui/src/spectate/SpectatorApp/SpectatorContent.jsx:22
const { session } = useSession();
It then derives:
inLockdown = session?.mode === 'lockdown'
roster = session?.roster ?? []
But because it reads the whole session object, changes to logs, users, active drivers, room cameras, replay state, chat-related session fields, or any other session branch can potentially re-render the whole spectator layout.
useSpectatorMode also reads the whole session:
webui/src/hooks/useSpectatorMode.js:7
const { session, setRole, subscribeAll, connected } = useSession();
It only needs mode, role, connected, and actions.
Why This Matters
/spectate is already rendering expensive children. A broad rerender at the top makes
other optimizations less effective because unrelated live data can still wake the route.
For example:
- a log entry should not rerender the rover media grid
- a chat composer state change should not rerender room camera feeds
- a session sync should not recreate layout props unless the selected fields changed
Fix Strategies
Option A: Replace Whole Session Reads With Selectors
In SpectatorContent, use precise selectors:
const mode = useSessionSelector((state) => state.session?.mode ?? null);
const roster = useSessionSelector((state) => state.session?.roster ?? EMPTY_ROSTER);
Then:
const inLockdown = mode === 'lockdown';
Make sure selector outputs are stable. If roster is rebuilt on every session:sync,
this still rerenders. The store may need structural sharing upstream.
Option B: Split Spectator Layout Into Memoized Regions
Split the page into:
- spectator sidebar
- rover grid
- secondary/camera row
- global overlays
Then each region subscribes only to what it needs.
Option C: Narrow useSpectatorMode
Replace:
const { session, setRole, subscribeAll, connected } = useSession();
with selectors/actions:
const mode = useSessionSelector((state) => state.session?.mode ?? null);
const role = useSessionSelector((state) => state.session?.role ?? null);
const connected = useSessionSelector((state) => state.connected);
const { setRole, subscribeAll } = useSessionActions();
Option D: Batch Or Diff session:sync
This may belong to the / backlog. If incoming sync payloads replace large object
branches each time, selector users will still rerender. Preserve references for unchanged
branches.
Recommended Path
Do this after the / session/log work if that work changes SessionContext.
Local /spectate changes:
- Remove whole-session
useSession()fromSpectatorContent. - Remove whole-session
useSession()fromuseSpectatorMode. - Split the layout so rover media rows do not depend on sidebar data.
- Re-test mutation bursts and React commit behavior.
Validation
Run:
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 \
node perf/live-root-runtime-audit.mjs https://rover.otter.land/spectate perf/results
Expected improvements:
- Fewer added/removed DOM mutation bursts.
- Lower
ScriptDuration. - Possibly lower
RecalcStyleCountandLayoutCount. session:syncsocket counts may stay the same unless server/store work changes them.
Risks
- Selector equality matters. Returning new arrays/objects from selectors can erase the win.
- Be careful with lockdown mode; the full-page lockdown branch must still update promptly.
- If
rosterreference stability is poor upstream, this issue may needSessionContextstructural sharing before it fully improves.