mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
performance slopping but actually very informative
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
# Issue 001: Global Input Listener Churn
|
||||
|
||||
## Summary
|
||||
|
||||
The `/` page repeatedly removes and re-adds global keyboard and gamepad event listeners.
|
||||
This is the clearest and most actionable CPU problem found on the control page.
|
||||
|
||||
On weaker CPUs, this turns into measurable main-thread cost. It also makes the input layer
|
||||
fragile because every broad render/effect invalidation touches global browser listeners.
|
||||
|
||||
## Severity
|
||||
|
||||
High.
|
||||
|
||||
This should be fixed first. It is directly measured and likely amplifies other `/` issues.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/controls/inputs/KeyboardInputManager.jsx`
|
||||
- `webui/src/controls/inputs/GamepadInputManager.jsx`
|
||||
- `webui/src/controls/inputs/gamepadHub.js`
|
||||
- Related multiplier: `webui/src/controls/ControlContext.jsx`
|
||||
|
||||
## Evidence
|
||||
|
||||
### Listener Audit
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-03-31-625Z-listeners/listener-report.json
|
||||
```
|
||||
|
||||
At the second snapshot, the audit saw:
|
||||
|
||||
```txt
|
||||
keydown adds=8040 removes=8039
|
||||
keyup adds=8040 removes=8039
|
||||
blur adds=8040 removes=8039
|
||||
gamepadconnected adds=8040 removes=8039
|
||||
gamepaddisconnected adds=8040 removes=8039
|
||||
```
|
||||
|
||||
The stack mapped those to the live bundle locations corresponding to:
|
||||
|
||||
- `KeyboardInputManager.jsx` global `keydown`, `keyup`, `blur` effect
|
||||
- `gamepadHub.js` global `gamepadconnected`, `gamepaddisconnected` listener registration
|
||||
|
||||
### Root Runtime Audit
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-21-51-263Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
In a 30s `/` run at mobile viewport with 6x CPU throttle:
|
||||
|
||||
```txt
|
||||
add:window:keydown 770
|
||||
remove:window:keydown 769
|
||||
add:window:keyup 770
|
||||
remove:window:keyup 769
|
||||
add:window:blur 770
|
||||
remove:window:blur 769
|
||||
add:window:gamepadconnected 770
|
||||
remove:window:gamepadconnected 769
|
||||
add:window:gamepaddisconnected 770
|
||||
remove:window:gamepaddisconnected 769
|
||||
```
|
||||
|
||||
### Clean CPU Profile
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-15-51-398Z/report.json
|
||||
```
|
||||
|
||||
Clean `/` profile, mobile viewport, 8x CPU throttle:
|
||||
|
||||
```txt
|
||||
average frame interval: ~140ms
|
||||
p95 frame interval: ~166.8ms
|
||||
p99 frame interval: ~383.4ms
|
||||
long tasks: 67
|
||||
event listener delta: +13,481
|
||||
removeEventListener: ~1.30s self time
|
||||
addEventListener: ~0.94s self time
|
||||
```
|
||||
|
||||
## Likely Cause
|
||||
|
||||
`KeyboardInputManager` installs global listeners inside a `useEffect` with a very large
|
||||
dependency list:
|
||||
|
||||
```jsx
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event) { ... }
|
||||
function handleKeyUp(event) { ... }
|
||||
function handleBlur() { ... }
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
window.addEventListener('keyup', handleKeyUp, { capture: true });
|
||||
window.addEventListener('blur', handleBlur);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
window.removeEventListener('keyup', handleKeyUp, { capture: true });
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
};
|
||||
}, [
|
||||
actionTokens,
|
||||
blurChat,
|
||||
driveFromKeys,
|
||||
ensureServoLoop,
|
||||
ensureSongLoop,
|
||||
focusChat,
|
||||
isChatFocused,
|
||||
keymap.chatFocus,
|
||||
...
|
||||
dockAssist,
|
||||
]);
|
||||
```
|
||||
|
||||
Many dependencies change when `ControlContext` or settings/session state changes. That
|
||||
causes the effect to tear down and reinstall global listeners over and over.
|
||||
|
||||
`GamepadInputManager` has the same pattern in a subscription effect:
|
||||
|
||||
```jsx
|
||||
useEffect(() => {
|
||||
return subscribeGamepadHub((hubState) => { ... });
|
||||
}, [
|
||||
activeSignature,
|
||||
ensureProfile,
|
||||
gamepadSettings?.defaults?.profile,
|
||||
gamepadSettings?.profiles,
|
||||
handleButtonEdge,
|
||||
handleCameraAxis,
|
||||
registerInputState,
|
||||
runMacro,
|
||||
setAuxMotors,
|
||||
setDriveVector,
|
||||
setMode,
|
||||
toggleNightVision,
|
||||
dockAssist,
|
||||
]);
|
||||
```
|
||||
|
||||
When this effect resubscribes, `subscribeGamepadHub` may add/remove global device listeners
|
||||
in `gamepadHub.js`.
|
||||
|
||||
## Fix Strategy
|
||||
|
||||
### KeyboardInputManager
|
||||
|
||||
Install global listeners once:
|
||||
|
||||
```jsx
|
||||
const latestRef = useRef(null);
|
||||
|
||||
latestRef.current = {
|
||||
keymap,
|
||||
actionTokens,
|
||||
isChatFocused,
|
||||
focusChat,
|
||||
resetAll,
|
||||
driveFromKeys,
|
||||
ensureServoLoop,
|
||||
ensureSongLoop,
|
||||
...
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event) {
|
||||
const latest = latestRef.current;
|
||||
if (!latest) return;
|
||||
// Existing logic, reading from latest instead of closure variables.
|
||||
}
|
||||
|
||||
function handleKeyUp(event) {
|
||||
const latest = latestRef.current;
|
||||
if (!latest) return;
|
||||
// Existing logic.
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
latestRef.current?.resetAll();
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
window.addEventListener('keyup', handleKeyUp, { capture: true });
|
||||
window.addEventListener('blur', handleBlur);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
window.removeEventListener('keyup', handleKeyUp, { capture: true });
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
Keep mutable input state in refs as it already mostly does:
|
||||
|
||||
- `activeTokensRef`
|
||||
- `lastVectorRef`
|
||||
- `lastAuxRef`
|
||||
- `servoIntervalRef`
|
||||
- `songIntervalRef`
|
||||
- `hornActiveRef`
|
||||
|
||||
### GamepadInputManager
|
||||
|
||||
Use the same pattern:
|
||||
|
||||
- Keep latest settings/actions/state in a ref.
|
||||
- Subscribe to `subscribeGamepadHub` once.
|
||||
- The subscription callback reads latest values from the ref.
|
||||
|
||||
Do not resubscribe just because profile/settings/action references changed.
|
||||
|
||||
### gamepadHub.js
|
||||
|
||||
After `GamepadInputManager` is stable, confirm whether `gamepadHub.js` still needs changes.
|
||||
It already tries to add global device listeners only when there are subscribers. The churn
|
||||
is likely caused by subscriber churn, not necessarily a bug in the hub itself.
|
||||
|
||||
## Validation
|
||||
|
||||
Before fix:
|
||||
|
||||
```sh
|
||||
node perf/live-listener-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Expected current bad result:
|
||||
|
||||
```txt
|
||||
hundreds or thousands of add/remove pairs for:
|
||||
keydown
|
||||
keyup
|
||||
blur
|
||||
gamepadconnected
|
||||
gamepaddisconnected
|
||||
```
|
||||
|
||||
After fix:
|
||||
|
||||
```txt
|
||||
keydown add ~= 1
|
||||
keyup add ~= 1
|
||||
blur add ~= 1
|
||||
gamepadconnected add ~= 1
|
||||
gamepaddisconnected add ~= 1
|
||||
removes only on page teardown
|
||||
```
|
||||
|
||||
Also run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
CPU_THROTTLE=8 VIEWPORT=390x844 MOBILE=1 node perf/live-cpu-profile.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Expected improvements:
|
||||
|
||||
- `JSEventListeners` delta should stop climbing dramatically.
|
||||
- `addEventListener` / `removeEventListener` should disappear from top CPU self-time.
|
||||
- Average frame interval and long-task count should improve, though other issues will remain.
|
||||
|
||||
## Risks
|
||||
|
||||
- Keyboard control safety matters. Be careful to preserve `resetAll()` on blur and text-input
|
||||
ignoring behavior.
|
||||
- If using refs, avoid stale data bugs by updating the ref every render before browser events
|
||||
can fire.
|
||||
- Verify horn hold, mic push-to-talk, chat focus, drive macro, dock assist, camera tilt, song
|
||||
controls, and Home Assistant shortcuts.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Issue 002: ControlContext Broad Invalidation
|
||||
|
||||
## Summary
|
||||
|
||||
`ControlContext` exposes one large changing context value containing all control state,
|
||||
pipeline objects, limiter state, and action callbacks. This likely causes many consumers
|
||||
to re-render and effects to re-run when unrelated control state changes.
|
||||
|
||||
This is probably the multiplier behind the global input listener churn and some repeated
|
||||
DOM commits on `/`.
|
||||
|
||||
## Severity
|
||||
|
||||
High, but fix after issue 001 unless doing both together.
|
||||
|
||||
Issue 001 is the symptom with the clearest measurement. Issue 002 is the architectural
|
||||
cause that may also affect other parts of the page.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/controls/ControlContext.jsx`
|
||||
- `webui/src/controls/inputs/KeyboardInputManager.jsx`
|
||||
- `webui/src/controls/inputs/GamepadInputManager.jsx`
|
||||
- Many consumers of `useControlSystem()`
|
||||
|
||||
## Evidence
|
||||
|
||||
The `contextValue` in `ControlContext.jsx` is:
|
||||
|
||||
```jsx
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
state,
|
||||
dispatch,
|
||||
pipeline,
|
||||
overcurrentLimiter,
|
||||
actions: {
|
||||
setMode,
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
...
|
||||
},
|
||||
}),
|
||||
[
|
||||
state,
|
||||
pipeline,
|
||||
overcurrentLimiter,
|
||||
setMode,
|
||||
setDriveVector,
|
||||
...
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
Any state change changes the whole context value. Any consumer using:
|
||||
|
||||
```jsx
|
||||
const { state, actions } = useControlSystem();
|
||||
```
|
||||
|
||||
is subscribed to the broad value, even if it only needs one action or one small state field.
|
||||
|
||||
This connects to measured symptoms:
|
||||
|
||||
- Input listener effects rerun because their action/callback dependencies change.
|
||||
- `/` runtime audit saw repeated attribute commits in UI that should not need to change every
|
||||
telemetry/log tick.
|
||||
- CPU profiles show large minified React frames (`C9`, `ad`, `Fl`, etc.) alongside listener
|
||||
churn.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
React context invalidation is coarse. When provider value identity changes, every consumer
|
||||
below it can be scheduled to render. Even memoized children are not enough if they consume
|
||||
the changing context directly.
|
||||
|
||||
This project has several high-frequency update sources:
|
||||
|
||||
- telemetry frames
|
||||
- logs
|
||||
- session syncs
|
||||
- control state changes
|
||||
- settings state
|
||||
- timers
|
||||
|
||||
If `ControlContext` changes broadly, it makes it harder to isolate these updates.
|
||||
|
||||
## Likely Cause Pattern
|
||||
|
||||
Several action callbacks depend on `state` or `pipeline`, which changes their identity:
|
||||
|
||||
```jsx
|
||||
const setDriveVector = useCallback(..., [pipeline, recordControlIntent, state.manualDockAssist?.active]);
|
||||
const setServoAngle = useCallback(..., [pipeline, recordControlIntent, state.manualDockAssist?.active]);
|
||||
const runMacro = useCallback(..., [driveMacroBackoffEnabled, pipeline, ..., state.macros, ...]);
|
||||
const startHorn = useCallback(..., [dispatch, normalizedHornSettings, pipeline, ..., state.horn?.active, ...]);
|
||||
```
|
||||
|
||||
Those callbacks are then included in `contextValue`.
|
||||
|
||||
Consumers that include those callbacks in effects rerun when the callback identity changes.
|
||||
|
||||
## Fix Strategy
|
||||
|
||||
### Option A: Split State and Actions Contexts
|
||||
|
||||
Create separate contexts:
|
||||
|
||||
```jsx
|
||||
const ControlStateContext = createContext(null);
|
||||
const ControlActionsContext = createContext(null);
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```jsx
|
||||
<ControlStateContext.Provider value={stateValue}>
|
||||
<ControlActionsContext.Provider value={actionsValue}>
|
||||
{children}
|
||||
</ControlActionsContext.Provider>
|
||||
</ControlStateContext.Provider>
|
||||
```
|
||||
|
||||
Action-only consumers should not rerender when state changes.
|
||||
|
||||
### Option B: Add Selectors
|
||||
|
||||
Create a small external-store style control store, similar to `SessionContext` or
|
||||
`TelemetryContext`, so consumers can select only the state slice they need:
|
||||
|
||||
```jsx
|
||||
useControlSelector((state) => state.camera.angle)
|
||||
useControlActions()
|
||||
```
|
||||
|
||||
This is more work but scales better.
|
||||
|
||||
### Option C: Stabilize Actions With Refs
|
||||
|
||||
Keep action function identities stable and read latest state/pipeline from refs:
|
||||
|
||||
```jsx
|
||||
const latestRef = useRef(null);
|
||||
latestRef.current = { state, pipeline, overcurrentLimiter, ... };
|
||||
|
||||
const setDriveVector = useCallback((vector, meta = {}) => {
|
||||
const { state, pipeline } = latestRef.current;
|
||||
...
|
||||
}, []);
|
||||
```
|
||||
|
||||
This pairs well with issue 001 because stable actions make stable input effects easier.
|
||||
|
||||
## Recommended Path
|
||||
|
||||
For incremental work:
|
||||
|
||||
1. Fix issue 001 directly using refs in input managers.
|
||||
2. Split `ControlActionsContext` from `ControlStateContext`.
|
||||
3. Convert heavy/control-critical consumers first:
|
||||
- `KeyboardInputManager`
|
||||
- `GamepadInputManager`
|
||||
- mobile controls
|
||||
- right pane controls
|
||||
4. Later add selector hooks for state-heavy consumers.
|
||||
|
||||
## Validation
|
||||
|
||||
After issue 001 + partial issue 002:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
- listener add/remove counts near 1
|
||||
- fewer repeated input attribute mutations
|
||||
- fewer long tasks
|
||||
- lower `ScriptDuration` and `TaskDuration`
|
||||
|
||||
For development builds, React DevTools Profiler would be ideal to verify fewer renders. The
|
||||
deployed production page does not currently publish source maps, so local profiling may be
|
||||
more useful after implementing.
|
||||
|
||||
## Risks
|
||||
|
||||
- Ref-based stable actions can hide stale state bugs if refs are not updated reliably.
|
||||
- Splitting context touches many files. Prefer a small staged migration.
|
||||
- Control commands are safety-sensitive; verify stop/horn/mic/docking behavior carefully.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Issue 003: High-Volume Socket Log Stream
|
||||
|
||||
## Summary
|
||||
|
||||
The `/` page receives a very high volume of `log:entry` socket messages. These update the
|
||||
global session store even when logs are not the primary visible task.
|
||||
|
||||
This is not the single biggest CPU stack item, but it is a major source of live update
|
||||
pressure on low-end devices.
|
||||
|
||||
## Severity
|
||||
|
||||
Medium-high.
|
||||
|
||||
Fix after listener/context work, unless logs are known to be unusually noisy in production.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/context/SessionContext.jsx`
|
||||
- `webui/src/components/LogPanel/index.jsx`
|
||||
- Server log emission paths, depending on where `log:entry` is emitted
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```txt
|
||||
log:entry count: 850
|
||||
log:entry bytes: 185,008
|
||||
```
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-21-51-263Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
In a 30s audit, among the most recent sampled 1000 WebSocket messages:
|
||||
|
||||
```txt
|
||||
log:entry count: 526
|
||||
log:entry bytes: 111,535
|
||||
```
|
||||
|
||||
In `SessionContext.jsx`, each log entry does:
|
||||
|
||||
```jsx
|
||||
function handleLogEntry(entry) {
|
||||
setState((prev) => ({ ...prev, logs: [...prev.logs.slice(-199), entry] }));
|
||||
}
|
||||
```
|
||||
|
||||
This creates a new session state object and a new logs array for every log message.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Even if `LogPanel` is not visible or not currently selected, `SessionContext` still processes
|
||||
the update. Every session subscriber selector is evaluated on each `setState`.
|
||||
|
||||
At 850 log messages in 18s, this is roughly 47 log entries per second.
|
||||
|
||||
On a powerful desktop this is fine. On a weaker phone or low-end laptop, it becomes steady
|
||||
background pressure.
|
||||
|
||||
## Likely Cause
|
||||
|
||||
The server appears to broadcast operational logs very frequently. The client stores the last
|
||||
200 logs globally.
|
||||
|
||||
The `/` page includes `LogPanel` under settings and may also have other components depending
|
||||
on session state.
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Do Not Subscribe to Logs Unless Log UI Is Visible
|
||||
|
||||
Best conceptual fix:
|
||||
|
||||
- Add an explicit client event like `log:subscribe` / `log:unsubscribe`.
|
||||
- Only subscribe when `LogPanel` is open or user is admin.
|
||||
- For ordinary mobile control users, skip logs entirely.
|
||||
|
||||
### Option B: Batch Log Entries Client-Side
|
||||
|
||||
Keep receiving logs, but batch state updates:
|
||||
|
||||
```jsx
|
||||
const pendingLogsRef = useRef([]);
|
||||
const flushTimerRef = useRef(null);
|
||||
|
||||
function handleLogEntry(entry) {
|
||||
pendingLogsRef.current.push(entry);
|
||||
if (flushTimerRef.current) return;
|
||||
flushTimerRef.current = setTimeout(() => {
|
||||
const batch = pendingLogsRef.current.splice(0);
|
||||
flushTimerRef.current = null;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
logs: [...prev.logs, ...batch].slice(-200),
|
||||
}));
|
||||
}, 250);
|
||||
}
|
||||
```
|
||||
|
||||
This changes 47 state updates/sec into about 4 updates/sec.
|
||||
|
||||
### Option C: Store Logs in a Separate External Store
|
||||
|
||||
Move logs out of main `SessionContext` so unrelated session consumers are not touched by
|
||||
log spam.
|
||||
|
||||
For example:
|
||||
|
||||
```jsx
|
||||
LogStoreProvider
|
||||
useLogEntries()
|
||||
```
|
||||
|
||||
This isolates log updates from user/roster/session state.
|
||||
|
||||
### Option D: Server-Side Sampling or Severity Filtering
|
||||
|
||||
Only send:
|
||||
|
||||
- warnings/errors by default
|
||||
- full logs to admin/log panel subscribers
|
||||
- sampled info/debug entries
|
||||
|
||||
## Recommended Path
|
||||
|
||||
Start with client batching. It is low-risk and easy to validate.
|
||||
|
||||
Then consider subscription gating if logs are not essential for normal users.
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
The socket `log:entry` count may remain high if only batching client-side, but UI state
|
||||
updates should reduce. To measure batching directly, add temporary counters in
|
||||
`SessionContext` or extend the audit to count React commits after local changes.
|
||||
|
||||
Expected user-visible perf improvements:
|
||||
|
||||
- lower `ScriptDuration`
|
||||
- lower `TaskDuration`
|
||||
- fewer repeated DOM commits if log updates were causing broad subscribers to render
|
||||
|
||||
## Risks
|
||||
|
||||
- Logs may be used for operator visibility during incidents.
|
||||
- Batching can delay log display by 100-500ms. That should be acceptable for UI logs.
|
||||
- Subscription gating requires server/client protocol changes.
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# Issue 004: Sensor Telemetry Render Frequency
|
||||
|
||||
## Summary
|
||||
|
||||
The `/` page receives frequent `sensorFrame` messages and renders multiple telemetry/HUD
|
||||
consumers from those frames. On throttled mobile CPU, this shows up as repeated SVG path
|
||||
attribute mutations and significant live DOM churn.
|
||||
|
||||
## Severity
|
||||
|
||||
Medium-high.
|
||||
|
||||
This is likely the biggest non-input-manager source of steady rendering work.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/context/TelemetryContext.jsx`
|
||||
- `webui/src/components/TopDownMap/TopDownMapContent.jsx`
|
||||
- `webui/src/components/TopDownMap/visuals.jsx`
|
||||
- `webui/src/components/DriverVideo/index.jsx`
|
||||
- `webui/src/components/HudOverlays/*`
|
||||
- `webui/src/components/TelemetryPanel/index.jsx`
|
||||
- `webui/src/components/PiHostStatsCard/index.jsx`
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```txt
|
||||
sensorFrame count: 277
|
||||
sensorFrame bytes: 520,169
|
||||
```
|
||||
|
||||
That is about 15.4 sensor frames/sec.
|
||||
|
||||
The same audit saw SVG attribute mutations:
|
||||
|
||||
```txt
|
||||
path d mutations: 621
|
||||
path fill mutations: 619
|
||||
```
|
||||
|
||||
These map strongly to `TopDownMapContent.jsx` and `visuals.jsx`, where sensor values are
|
||||
converted into SVG arcs, cones, wheels, brush visuals, and colors.
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-21-51-263Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
In a 30s audit:
|
||||
|
||||
```txt
|
||||
sensorFrame count among latest sampled 1000 messages: 306
|
||||
sensorFrame bytes: 574,616
|
||||
```
|
||||
|
||||
## Current Code Path
|
||||
|
||||
`TelemetryContext.jsx` updates every incoming frame:
|
||||
|
||||
```jsx
|
||||
function handleSensorFrame({ roverId, sensors = {}, frame = {} }) {
|
||||
if (!roverId) return;
|
||||
const previous = framesRef.current[roverId] ?? {};
|
||||
framesRef.current = {
|
||||
...framesRef.current,
|
||||
[roverId]: {
|
||||
...previous,
|
||||
roverId,
|
||||
sensors,
|
||||
raw: frame?.data || null,
|
||||
receivedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
notifyRover(roverId);
|
||||
}
|
||||
```
|
||||
|
||||
Consumers subscribe by rover:
|
||||
|
||||
```jsx
|
||||
useTelemetryFrame(roverId)
|
||||
```
|
||||
|
||||
Every `notifyRover(roverId)` wakes all consumers for that rover.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Telemetry itself may need to remain high-frequency for control safety or debugging, but the
|
||||
visual UI usually does not need to render at full sensor frequency.
|
||||
|
||||
The expensive part is not receiving the socket message. It is causing React/SVG/HUD updates
|
||||
and DOM attribute changes at the same cadence.
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Throttle Render Notifications
|
||||
|
||||
Keep the latest frame in `framesRef` immediately, but notify subscribers at a lower rate.
|
||||
|
||||
Example:
|
||||
|
||||
```jsx
|
||||
const pendingRoversRef = useRef(new Set());
|
||||
const notifyScheduledRef = useRef(false);
|
||||
|
||||
function scheduleNotify(roverId) {
|
||||
pendingRoversRef.current.add(roverId);
|
||||
if (notifyScheduledRef.current) return;
|
||||
notifyScheduledRef.current = true;
|
||||
setTimeout(() => {
|
||||
notifyScheduledRef.current = false;
|
||||
const rovers = [...pendingRoversRef.current];
|
||||
pendingRoversRef.current.clear();
|
||||
rovers.forEach((id) => notifyRover(id));
|
||||
}, 100); // 10Hz visual updates
|
||||
}
|
||||
```
|
||||
|
||||
Then `handleSensorFrame` writes latest data immediately but calls `scheduleNotify(roverId)`.
|
||||
|
||||
### Option B: Separate Raw Telemetry From Visual Telemetry
|
||||
|
||||
Expose:
|
||||
|
||||
```jsx
|
||||
useTelemetryFrameRaw(roverId) // high-frequency, only for critical logic
|
||||
useTelemetryFrameVisual(roverId) // throttled for UI rendering
|
||||
```
|
||||
|
||||
Use throttled visual telemetry in:
|
||||
|
||||
- TopDownMap
|
||||
- HUD overlays
|
||||
- telemetry panels
|
||||
- battery bars
|
||||
- host stats display
|
||||
|
||||
Keep raw telemetry for safety mechanisms if needed.
|
||||
|
||||
### Option C: Memoize/Split Sensor Consumers by Field
|
||||
|
||||
Do not re-render the entire map or all overlays when only one sensor field changed.
|
||||
|
||||
Examples:
|
||||
|
||||
- Battery components subscribe only to battery fields.
|
||||
- Light bump bars subscribe only to light bump fields.
|
||||
- Overcurrent overlay subscribes only to overcurrent fields.
|
||||
|
||||
This is more architectural but can reduce unnecessary work.
|
||||
|
||||
### Option D: Render TopDownMap Less Often
|
||||
|
||||
At the component level:
|
||||
|
||||
```jsx
|
||||
const visualSensors = useThrottledValue(sensors, 100);
|
||||
```
|
||||
|
||||
This is easier than changing `TelemetryContext`, but less central.
|
||||
|
||||
## Recommended Path
|
||||
|
||||
Start with a throttled visual notification path in `TelemetryContext`. It gives the largest
|
||||
blast-radius reduction without changing every consumer immediately.
|
||||
|
||||
A reasonable target:
|
||||
|
||||
- Desktop: 10-15Hz visual updates
|
||||
- Mobile: 5-10Hz visual updates
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=18000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Expected improvements:
|
||||
|
||||
- `path d` and `path fill` mutation counts should drop.
|
||||
- `LayoutCount` and `RecalcStyleCount` may drop.
|
||||
- `ScriptDuration` should drop.
|
||||
- Frame p95 should improve.
|
||||
|
||||
Socket `sensorFrame` count may remain the same if only rendering is throttled.
|
||||
|
||||
## Risks
|
||||
|
||||
- Do not throttle logic that protects the rover or sends control commands unless you are sure
|
||||
it is only display logic.
|
||||
- Operators may expect smooth sensor visuals, but 10Hz is usually enough for dashboards.
|
||||
- Make sure low battery/overcurrent warnings still feel responsive.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# 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:
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```txt
|
||||
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:
|
||||
|
||||
```jsx
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSidebar}
|
||||
...
|
||||
className="accent-emerald-400"
|
||||
/>
|
||||
```
|
||||
|
||||
and source item checkboxes:
|
||||
|
||||
```jsx
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(item.key)}
|
||||
onChange={() => onToggle(item.key)}
|
||||
className="accent-emerald-400"
|
||||
/>
|
||||
```
|
||||
|
||||
The mobile portrait route mounts it here:
|
||||
|
||||
```jsx
|
||||
<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:
|
||||
|
||||
```jsx
|
||||
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
|
||||
|
||||
```jsx
|
||||
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:
|
||||
|
||||
```jsx
|
||||
const replaySources = useSessionSelector(
|
||||
(state) => state.session?.replaySources ?? [],
|
||||
shallowReplaySourcesEqual,
|
||||
);
|
||||
```
|
||||
|
||||
### Option C: Memoize GroupList
|
||||
|
||||
```jsx
|
||||
const GroupList = React.memo(function GroupList(...) { ... });
|
||||
```
|
||||
|
||||
Also memoize:
|
||||
|
||||
```jsx
|
||||
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:
|
||||
|
||||
```jsx
|
||||
const toggleKey = useCallback((key) => { ... }, []);
|
||||
const handleReplay = useCallback(async () => { ... }, [...]);
|
||||
```
|
||||
|
||||
This helps if child components are memoized.
|
||||
|
||||
## Recommended Path
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```txt
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Issue 006: Chat and Nickname Composer Churn
|
||||
|
||||
## Summary
|
||||
|
||||
The mobile `/` page defaults to the chat tab, and the chat/nickname composer shows repeated
|
||||
DOM attribute commits in runtime audits. This is not the top CPU issue, but it is part of
|
||||
steady idle churn.
|
||||
|
||||
## Severity
|
||||
|
||||
Medium-low to medium.
|
||||
|
||||
Fix after the listener/context/log/telemetry work unless chat remains high in mutation
|
||||
reports.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/components/ChatPanel/index.jsx`
|
||||
- `webui/src/components/NicknameForm/index.jsx`
|
||||
- `webui/src/context/ChatContext.jsx`
|
||||
- `webui/src/App.jsx`
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```txt
|
||||
input.field-input.chat-composer-input name: 666
|
||||
input.field-input.flex-1 name: 1328
|
||||
input.field-input.w-full name: 1332
|
||||
input.accent-cyan-500 name: 1324
|
||||
input.accent-cyan-500 type: 662
|
||||
```
|
||||
|
||||
These map to:
|
||||
|
||||
- chat composer input
|
||||
- nickname form input
|
||||
- TTS/speak checkbox
|
||||
- related chat form controls
|
||||
|
||||
The chat panel is mounted by default in mobile feature tabs:
|
||||
|
||||
```jsx
|
||||
const [activeTab, setActiveTab] = useState('chat');
|
||||
...
|
||||
<TabPanel id="chat">
|
||||
<ChatPanel nicknameLayout="stacked" />
|
||||
...
|
||||
</TabPanel>
|
||||
```
|
||||
|
||||
## Why This Matters
|
||||
|
||||
The chat UI is useful, but it should not re-commit its composer inputs hundreds of times
|
||||
while the user is simply watching/driving.
|
||||
|
||||
Also, text inputs and focus-related logic interact with keyboard driving. Excess churn here
|
||||
can have indirect effects on input handling.
|
||||
|
||||
## Likely Causes
|
||||
|
||||
`ChatPanel` consumes:
|
||||
|
||||
```jsx
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const { messages, typing, sendMessage, ... } = useChat();
|
||||
const { value: ttsSettings, save: saveTtsSettings } = useSettingsNamespace('tts', ...);
|
||||
```
|
||||
|
||||
It also has effects that sync local state from settings:
|
||||
|
||||
```jsx
|
||||
useEffect(() => {
|
||||
const nextEngine = ...
|
||||
if (engine !== nextEngine) setEngine(nextEngine);
|
||||
...
|
||||
}, [engine, googlePitch, googleSpeed, pitch, ttsSettings..., voice]);
|
||||
```
|
||||
|
||||
Potential problems:
|
||||
|
||||
- broad session/chat context updates rerender the whole panel
|
||||
- message list and composer are in the same component
|
||||
- typing events cause panel updates
|
||||
- nickname form may be rerendering with the whole chat panel
|
||||
- TTS settings sync effect has many dependencies
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Split ChatPanel Into Memoized Subcomponents
|
||||
|
||||
Separate:
|
||||
|
||||
- `ChatMessageList`
|
||||
- `ChatComposer`
|
||||
- `TtsControls`
|
||||
- `NicknameForm`
|
||||
|
||||
Only `ChatMessageList` should rerender when messages change.
|
||||
Only `ChatComposer` should rerender when draft/sending/focus changes.
|
||||
|
||||
### Option B: Memoize Composer
|
||||
|
||||
```jsx
|
||||
const ChatComposer = React.memo(function ChatComposer(props) { ... });
|
||||
```
|
||||
|
||||
Pass stable callbacks where possible.
|
||||
|
||||
### Option C: Debounce Typing Updates
|
||||
|
||||
`setTypingActive(Boolean(next.trim()))` fires on every keystroke. That is fine while typing,
|
||||
but should not be involved during idle. If it causes socket chatter, debounce it.
|
||||
|
||||
### Option D: Stabilize NicknameForm
|
||||
|
||||
If `NicknameForm` consumes settings/session state broadly, make it use precise selectors and
|
||||
memoize it.
|
||||
|
||||
### Option E: Avoid Re-Syncing Local TTS State Too Often
|
||||
|
||||
The settings sync effect can be simplified or guarded so it only runs when the settings
|
||||
object actually changes, not on every local state update.
|
||||
|
||||
## Recommended Path
|
||||
|
||||
1. Fix issues 001-004 first.
|
||||
2. Rerun mutation audit.
|
||||
3. If chat/nickname inputs remain high:
|
||||
- split `ChatPanel`
|
||||
- memoize composer
|
||||
- stabilize `NicknameForm`
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=18000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Expected improvements:
|
||||
|
||||
```txt
|
||||
chat-composer-input mutations should drop
|
||||
NicknameForm input mutations should drop
|
||||
accent-cyan-500 checkbox mutations should drop
|
||||
```
|
||||
|
||||
Also manually verify:
|
||||
|
||||
- typing
|
||||
- sending chat
|
||||
- TTS options
|
||||
- nickname edit/save
|
||||
- Enter-to-focus/send chat shortcut
|
||||
|
||||
## Risks
|
||||
|
||||
- Chat focus behavior is tied to keyboard controls.
|
||||
- Splitting components can accidentally break `registerInputRef`.
|
||||
- TTS settings must still persist correctly.
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# Issue 007: Timers and Polling Cleanup
|
||||
|
||||
## Summary
|
||||
|
||||
The `/` page creates multiple intervals and timeouts. They are not the dominant problem,
|
||||
but they contribute to steady background work and can wake components even when hidden.
|
||||
|
||||
## Severity
|
||||
|
||||
Low to medium.
|
||||
|
||||
Address after the larger structural issues unless a specific timer is found to be hot.
|
||||
|
||||
## Affected Files
|
||||
|
||||
Known or likely timer users:
|
||||
|
||||
- `webui/src/components/AlertFeed/index.jsx`
|
||||
- `webui/src/components/ReplaySourcesPanel/index.jsx`
|
||||
- `webui/src/components/HudOverlays/TurnsOverlay/index.jsx`
|
||||
- `webui/src/components/RoverQueuesPanel/index.jsx`
|
||||
- `webui/src/components/ModeGateOverlay/index.jsx`
|
||||
- `webui/src/components/SocketConnectionPill/index.jsx`
|
||||
- `webui/src/controls/ControlContext.jsx` horn heat interval
|
||||
- `webui/src/controls/overcurrentLimiter.js`
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```txt
|
||||
intervals created: 22
|
||||
timeouts created: 11
|
||||
interval fires: 148
|
||||
timeout fires: 6
|
||||
```
|
||||
|
||||
Most common timer registrations:
|
||||
|
||||
```txt
|
||||
interval:200 7
|
||||
timeout:12000 4
|
||||
interval:60000 4
|
||||
interval:250 4
|
||||
interval:3000 4
|
||||
interval:1000 2
|
||||
```
|
||||
|
||||
In a 30s audit:
|
||||
|
||||
```txt
|
||||
interval fires: 593
|
||||
```
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Timers wake the main thread. On a fast desktop they disappear into the noise; on a throttled
|
||||
mobile CPU, timers can combine with socket traffic and React work to create visible stalls.
|
||||
|
||||
The timer issue is not that any one interval is terrible. It is that many UI elements keep
|
||||
time independently.
|
||||
|
||||
## Likely Sources
|
||||
|
||||
### ReplaySourcesPanel
|
||||
|
||||
Cooldown display:
|
||||
|
||||
```jsx
|
||||
const interval = setInterval(update, 250);
|
||||
```
|
||||
|
||||
Only needed while cooldown is active and panel is visible.
|
||||
|
||||
### AlertFeed
|
||||
|
||||
Likely uses a frequent interval for alert lifetimes. This should only run while alerts exist.
|
||||
|
||||
### TurnsOverlay / RoverQueuesPanel
|
||||
|
||||
Countdown displays often use `setInterval(() => setNow(Date.now()), 250 or 1000)`.
|
||||
|
||||
### ControlContext Horn Heat
|
||||
|
||||
Horn heat interval at 100ms only runs when horn is active/cooling:
|
||||
|
||||
```jsx
|
||||
const tickMs = 100;
|
||||
const interval = setInterval(...)
|
||||
```
|
||||
|
||||
This is probably okay, but verify it only runs when needed.
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Visibility-Gate Timers
|
||||
|
||||
Only run panel-specific timers when the panel is visible/open.
|
||||
|
||||
Example:
|
||||
|
||||
```jsx
|
||||
if (!isVisible || !needsCountdown) return;
|
||||
```
|
||||
|
||||
### Option B: Coarse Intervals
|
||||
|
||||
Use 1000ms for human-readable countdowns unless sub-second precision matters.
|
||||
|
||||
Replay cooldowns and queue countdowns probably do not need 250ms.
|
||||
|
||||
### Option C: Shared Clock Store
|
||||
|
||||
Instead of each component creating its own interval, create one shared clock:
|
||||
|
||||
```jsx
|
||||
useClock(1000)
|
||||
useClock(250)
|
||||
```
|
||||
|
||||
Then multiple components can share a single interval.
|
||||
|
||||
### Option D: CSS Animations for Pure Visual Expiry
|
||||
|
||||
For alert progress bars, use CSS animation if the UI does not need React state each tick.
|
||||
|
||||
## Recommended Path
|
||||
|
||||
After major fixes, rerun the runtime audit. If intervals still show high:
|
||||
|
||||
1. Increase replay cooldown interval from 250ms to 1000ms.
|
||||
2. Gate timers by tab visibility.
|
||||
3. Create a shared clock hook for countdown UI.
|
||||
4. Convert alert lifetime visuals to CSS where possible.
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- lower `intervals`
|
||||
- lower `intervalFires`
|
||||
- no visible regression in countdowns/alerts
|
||||
|
||||
## Risks
|
||||
|
||||
- Timers used for control safety should not be slowed without careful review.
|
||||
- Alert expiration should still happen reliably.
|
||||
- Queue/turn countdowns can update less often, but should not become misleading.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# `/` CPU Performance Issue Index
|
||||
|
||||
This folder breaks the live-site `/` CPU investigation into separate, work-ready issues.
|
||||
Each issue can be handled in its own chat or branch without needing to re-run the whole
|
||||
investigation first.
|
||||
|
||||
The testing target was the deployed page:
|
||||
|
||||
```sh
|
||||
https://rover.otter.land/
|
||||
```
|
||||
|
||||
The important local perf tools are:
|
||||
|
||||
```sh
|
||||
node perf/live-cpu-profile.mjs https://rover.otter.land/ perf/results
|
||||
node perf/live-listener-audit.mjs https://rover.otter.land/ perf/results
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
The strongest `/` artifacts from this investigation are:
|
||||
|
||||
- `perf/results/2026-06-11T03-15-51-398Z/report.json`
|
||||
Clean `/` profile, mobile viewport, 8x CPU throttle.
|
||||
- `perf/results/2026-06-11T03-03-31-625Z-listeners/listener-report.json`
|
||||
Listener add/remove stack audit.
|
||||
- `perf/results/2026-06-11T03-21-51-263Z-root-runtime/root-runtime-report.json`
|
||||
`/` runtime audit, mobile viewport, 6x CPU throttle, 30s sample.
|
||||
- `perf/results/2026-06-11T03-22-44-714Z-root-runtime/root-runtime-report.json`
|
||||
`/` runtime audit, mobile viewport, 6x CPU throttle, 18s sample with mutation targets.
|
||||
|
||||
## Ranked Issues
|
||||
|
||||
1. [Global input listener churn](./001-global-input-listener-churn.md)
|
||||
2. [ControlContext broad invalidation](./002-control-context-broad-invalidation.md)
|
||||
3. [High-volume socket log stream](./003-high-volume-log-stream.md)
|
||||
4. [Sensor telemetry render frequency](./004-sensor-telemetry-render-frequency.md)
|
||||
5. [ReplaySourcesPanel repeated DOM commits](./005-replay-sources-panel-churn.md)
|
||||
6. [Chat and nickname composer churn](./006-chat-and-nickname-churn.md)
|
||||
7. [Timers and polling cleanup](./007-timers-and-polling.md)
|
||||
|
||||
## Quick Read
|
||||
|
||||
The highest-confidence first fix is issue 001. It is directly measured, large, and
|
||||
structurally fixable: install keyboard/gamepad global listeners once and have them read
|
||||
latest state/actions from refs.
|
||||
|
||||
Issue 002 is the likely multiplier behind issue 001 and several UI churn symptoms. The
|
||||
current `ControlContext` value changes broadly, and many callbacks depend on `state` or
|
||||
`pipeline`; that causes consumers/effects to refresh even when the visible behavior did
|
||||
not materially change.
|
||||
|
||||
Issues 003 and 004 are independent live-data pressure: the `/` page receives a lot of
|
||||
socket traffic, especially `log:entry` and `sensorFrame`, and those streams feed UI work.
|
||||
|
||||
Issue 005 and issue 006 are component-level symptoms visible in DOM mutation probes. They
|
||||
should probably be tackled after the context/listener work, because broad invalidation may
|
||||
be causing some of their repeated commits.
|
||||
|
||||
## Important Scope Note
|
||||
|
||||
There are also `/spectate` results in `perf/results` because the investigation briefly
|
||||
compared routes. The user later clarified that `/spectate` is not the current priority.
|
||||
These issue writeups intentionally focus on `/` only.
|
||||
|
||||
Reference in New Issue
Block a user