Skip to content

feat: optionally unload views that are not on screen - #3037

Open
rafaellehmkuhl wants to merge 7 commits into
bluerobotics:masterfrom
rafaellehmkuhl:issue-3036-unmount-hidden-views
Open

feat: optionally unload views that are not on screen#3037
rafaellehmkuhl wants to merge 7 commits into
bluerobotics:masterfrom
rafaellehmkuhl:issue-3036-unmount-hidden-views

Conversation

@rafaellehmkuhl

@rafaellehmkuhl rafaellehmkuhl commented Sep 9, 2026

Copy link
Copy Markdown
Member

Summary

Five commits: four unmount teardowns that were already wrong on delete, then the opt-in setting. Two more fix: commits after review: battery-indicator timers were never cleared, and a pending settings-sync write no longer lets a stale echo bounce the current view.

  • Timed snapshots (fix:). Intervals are cleared when the snapshot tool unmounts, and the existing "Timed snapshot stopped." snackbar fires so a view switch does not end a capture in silence.
  • Gauges (fix:). Unmounted VeryGenericIndicators leave the sensor log. A retain count keeps a shared display name in the log until the last copy is gone, and the entry is released under the name it was retained with, so renaming a gauge while it is on screen does not drop another gauge's entry or strand its own.
  • Custom widgets (fix:). The window resize listener on CollapsibleContainer is removed on unmount.
  • Iframe extensions (fix:). Data lake variables an embedded page subscribes to through the external API are unlistened on unmount, instead of leaving one listener behind per variable.
  • Unload hidden views (Interface: hidden views stay mounted and keep using resources #3036). Eye-open views that are not on screen stay mounted today. A machine-local setting, off by default, mounts only the current view. Toggle: Settings → Interface → Performance, or right-click the view selector → Options.

Test plan

  • Open a profile with more than one view (the ROV default has Video, Map, and HUD). Switch views with the option off and confirm the other views are still there immediately, as today.
  • Settings → Interface → Performance: turn on Unload hidden views.
  • Right-click the view selector (or the edit-mode cog) → Options: the same Unload hidden views switch is there and changes the same setting.
  • Switch views. Widgets on the view you left should start again when you come back (video reconnects, the map reloads).
  • With the option on, switch views and confirm the chosen view stays (does not bounce back to the previous one).
  • Start a timed snapshot, switch views, confirm the "Timed snapshot stopped." snackbar and that captures stop.
  • Put the same named gauge on two views, enable the option, switch between them, confirm the log keeps that name.
  • Rename a gauge that shares its new name with a gauge on another view, switch views, and confirm the other gauge is still written to the log.
  • Put an extension page in an iframe widget, switch views a few times with the option on, and confirm the extension still receives its variables when you come back.
  • Confirm the option is still off on another topside computer talking to the same vehicle (it is not synced).

Checks

  • selectViewsToShow with the option off still returns every visible view, current last; with it on, only the current view. Out-of-range indexes are clamped. Cases in src/tests/types/widgets.test.ts.
  • yarn lint and yarn test:unit clean.

Closes #3036

@rafaellehmkuhl rafaellehmkuhl self-assigned this Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 1)

3 open findings — 2 major (1.1, 5.1) and 1 minor.

Cockpit currently keeps every visible view of a profile mounted at once and just stacks the one you are looking at on top. This PR adds an opt-in switch, stored only on the computer you set it on and off by default, that changes the list of views handed to the renderer so only the view on screen is built. With it on, leaving a view destroys everything on it and coming back builds it again from scratch. The switch lives in a new "Performance" group in the interface settings, and the view-picking rule was moved out of the store into a small shared function so it could be unit tested.

What still needs attention

# Problem What it means Severity Status
1.1 Frozen gauge readings keep being recorded With the option on, the mission log and the telemetry text burned into recorded video keep writing the last value of gauges from views you are not looking at, as if those readings were still live. major
5.1 Repeating screenshot capture cannot be stopped With the option on, switching away from a view while a repeating screenshot capture is running leaves it taking pictures forever, with no button left to stop it. major
1.2 Two parts of the app can disagree on which view is current If the stored "current view" number ever points past the end of the profile, turning the option on shows an empty screen instead of a view. minor
Change map — what was established before judging

Claims

  • "Every visible view is stacked and left running (maps, video, render loops)"partly contradicted. Verified for video, maps and iframes: src/views/WidgetsView.vue:3 mounts one .widget-view per entry of viewsToShow, VideoPlayer.vue registers a stream consumer unconditionally (its only visibility-aware code is the teardown at src/components/widgets/VideoPlayer.vue:316-320), and src/components/widgets/IFrame.vue:727-729 only appends display: none for a widget that is not on the current view, so the embedded page keeps running. Contradicted for render loops: the canvas widgets already skip rendering when their view is not the current one — Attitude.vue:354, Compass.vue:241, CompassHUD.vue:743, DepthHUD.vue:302, VirtualHorizon.vue:258, Plotter.vue:550, all gating on widgetStore.isWidgetVisible (src/stores/widgetManager.ts:344-346). The saving the feature actually delivers is video sessions, leaflet instances and iframes, not idle canvas work.
  • "viewsToShow now mounts only the current view when the setting is on"verified. src/stores/widgetManager.ts:308 now delegates to selectViewsToShow (src/types/widgets.ts:1016), whose unmountHidden branch returns [current] or []. Both consumers are v-fors keyed per view (src/views/WidgetsView.vue:3, src/App.vue:60), so shrinking the array is a real unmount, not a hide.
  • "It stays off by default" and "the value is stored only on this computer"verified. src/stores/widgetManager.ts:65 uses vueuse useStorage with false, not useBlueOsStorage.
  • "Widgets on the view you left should start again when you come back"verified as the intent; findings 1.1 and 5.1 are the two places where the leaving half of that is not clean.

Entry points

Function Reached from Frequency
selectViewsToShow (src/types/widgets.ts:1016) viewsToShow computed (src/stores/widgetManager.ts:308) → v-for at src/views/WidgetsView.vue:3 and src/App.vue:60; invalidated by currentViewIndex, currentProfile.views and the new setting per user action
viewsToShow computed (src/stores/widgetManager.ts:308) the same two v-fors; no other reader in the tree per user action
setUnmountHiddenViews (src/views/ConfigurationUIView.vue:211) @update:model-value on the v-switch at src/views/ConfigurationUIView.vue:161 per user action

Invariants

The change relies on: a view dropping out of viewsToShow unmounts every widget and mini-widget under it, so anything that must outlive the view has to be owned outside the component. Every site that can violate it, and how it fares:

  • Holds — src/components/widgets/VideoPlayer.vue:316-320 and src/components/mini-widgets/MiniVideoRecorder.vue:449-453 release their stream consumer on unmount, and deactivateStreamIfUnused (src/stores/video.ts:690-700) explicitly refuses to tear a stream down while a recorder is attached, so a recording started on the view you leave keeps running.
  • Holds — src/components/widgets/Map.vue:1123-1125 flushes the live map position before unmounting; useDataLakeVariable unlistens in onUnmounted (src/composables/useDataLakeVariable.ts:47-51).
  • Violatedsrc/components/mini-widgets/VeryGenericIndicator.vue registers into the datalogger and into CurrentlyLoggedVariables (:479-487, :496-513) with no unmount teardown → finding 1.1.
  • Violatedsrc/components/mini-widgets/SnapshotTool.vue:399-442 owns two setInterval handles and the file has no unmount hook → finding 5.1.

A second invariant the PR introduces: viewsToShow and currentView must agree on which view is current. currentView clamps the index (src/stores/widgetManager.ts:295-304); the new branch does not → finding 1.2.

1. Correctness & Implementation Bugs — 2 findings

1.1 — Unmounting a view freezes its indicator values in the recorded telemetry instead of dropping them · major

registerVeryGenericData (src/libs/sensors-logging.ts:494-502) appends to datalogger.veryGenericIndicators (:231) and there is no removal API anywhere on DataLogger. collectVeryGenericData (:381-391) then copies every entry into each log point and stamps it with the current timestamp, and that runs from the logging interval at :353 inside logRoutine (:324-372), independently of what is mounted. The only thing keeping an entry truthful is the mounted indicator's watcher on parsedState (src/components/mini-widgets/VeryGenericIndicator.vue:479-487) — and that component has no onBeforeUnmount.

Today this is nearly unreachable: src/App.vue:60 mounts the bottom bar of every visible view, so an indicator only unmounts when the user deletes it. With this setting on, every view switch unmounts the bars of the views left behind. The shipped ROV default profile puts four of these on view bottom bars — "Pilot Gain", "Lights (1)" and "Cam Tilt" (src/assets/defaults.ts:219-252) plus "Water Temp" (:266-277) — so switching from the video view to the map leaves the sensor log, and the telemetry overlay burned into recorded video, writing those four at their last-seen value with a fresh timestamp for the rest of the dive.

Fix it at the shared site rather than in the new code: give DataLogger a deregisterVeryGenericData(displayName) beside registerVeryGenericData (src/libs/sensors-logging.ts:494) and call it from an onBeforeUnmount in VeryGenericIndicator.vue. That also closes the deletion path, which today drops the variable from CurrentlyLoggedVariables (src/components/MiniWidgetContainer.vue:159) but leaves the datalogger entry behind.

The same onBeforeUnmount covers a second leak with the same cause: VeryGenericIndicator.vue:509 calls CurrentlyLoggedVariables.addVariable on every mount, and the instance counter it increments (src/libs/sensors-logging.ts:182-191) is only decremented on explicit deletion (src/components/MiniWidgetContainer.vue:159). With the setting on, each return to a view adds one more instance, so after a few switches deleting the widget can no longer remove its variable from the list in Settings → Logs.


1.2 — The new branch indexes views without the clamp currentView applies · minor

currentView (src/stores/widgetManager.ts:295-304) deliberately clamps currentViewIndex into range before indexing, because cockpit-current-view-index-v1 is vehicle-synced (:64) and the views group is a separate synced key (:63) — the two do not arrive atomically, so the index can transiently point past the end of the local profile. selectViewsToShow's new branch (src/types/widgets.ts:1017-1019) indexes raw: views[currentIndex] is undefined, current?.visible is falsy, and it returns []. The app then mounts no view at all while currentBottomBarHeightPixels (:282-284) and miniWidgetContainersInCurrentView (:313-321) still describe the clamped view.

The pre-existing path is not better — with the setting off, views[5] pushed into the array makes .filter((v) => v.visible) throw inside the computed — but one line covers both branches: derive const index = Math.max(0, Math.min(currentIndex, views.length - 1)) at the top of selectViewsToShow and use it throughout, matching what currentView already does.

2. Persistence & User Data — inventory, no findings
Key Backend Change
cockpit-unmount-hidden-views machine-local — vueuse useStorage (src/stores/widgetManager.ts:65) added

Judged:

  • Backend is right. A performance trade is a property of the topside computer, not of the vehicle, so it must not travel to other operators through useBlueOsStorage. The same call shape is the established machine-local pattern here — cockpit-prevent-auto-vehicle-discovery-dialog (src/App.vue:378), cockpit-base-station-track-by-gps (src/composables/baseStation/useBaseStation.ts:35), cockpit-vehicle-address (src/stores/mainVehicle.ts:90). The key is still carried by the settings backup, which copies every localStorage key bar four (src/libs/settings-management.ts:991-992).
  • Key naming starts with cockpit-, as AGENTS.md requires.
  • Shape is a bare boolean with no field duplicating the key, no nested state, no reshape of an existing key and no automatic migration.
  • Default is false, so no already-configured user is moved onto new behaviour and nothing needs to carry them over.
5. Performance — 1 finding

5.1 — Timed snapshot capture keeps firing after its widget is unmounted · major

src/components/mini-widgets/SnapshotTool.vue:399-400 holds shotInterval and progressInterval in module-closure lets, and both are created and destroyed only by the watcher on the component-local isTakingTimedSnapshot ref (:243, watcher at :407-442). The file registers no onBeforeUnmount at all — its last hook is onMounted at :465-469 — which is the missing teardown the Cleanup rule asks for.

Until now the widget only unmounted on deletion, so the gap was latent. With this setting on, switching away from the view holding it unmounts it mid-capture: the watcher's stop branch never runs, both intervals survive, and fireTimedSnapshot (:402-405) keeps calling captureSnapshot (:274-) every N seconds — canvas capture plus a write per source — with the stop button gone from the screen. Returning to the view remounts the widget with isTakingTimedSnapshot back at false (:243), so the UI offers "start" while a loop is already running, and pressing it allocates a second pair of intervals. Nothing short of reloading Cockpit stops it.

This is also the "automatic heavy work" case: after the unmount the capture is no longer attached to anything the user did or can undo.

Fix at the real site, not in this PR's new code:

onBeforeUnmount(() => {
  if (shotInterval) clearInterval(shotInterval)
  if (progressInterval) clearInterval(progressInterval)
})
Sections with nothing to report (8)

3. AGENTS.md Adherence — ✅ (JSDoc on selectViewsToShow carries a real summary and typed, non-empty @param/@returns; the helper sits beside fillMissingBarContainers and the validate* functions already living in src/types/widgets.ts and has its call site in this same PR; the no-bottom-divider move at src/views/ConfigurationUIView.vue:122 is forced by the new panel becoming the last one, not a drive-by edit; no dependency added, package.json untouched)

4. Security — ✅ (all nine sub-checks run across the four changed files: no encoded blobs or binary-like strings, no hidden or bidirectional Unicode, no network call, no change under scripts/, .github/ or src/electron/, no new package, no eval/Function()/v-html, no secret or environment variable; pr.json, pr.diff and complexity-report.json contain no text addressed to the reviewer)

6. UI / UX — ✅ (the switch reuses the exact token set already in the tree at src/views/ConfigurationMissionView.vue:10-17color="white", base-color="#FFFFFF33", hide-details, mt-2 -mb-2 ml-3 inside the same px-4 pt-5 column the sibling panels use, so no new inset is stacked; panel title and label are sentence case and the label does not restate the panel; the setting is grouped in an ExpansiblePanel per the space-economy rule; no overlay-teleporting control is added, so theme="dark" does not apply; logUserAction is called from the handler in past tense naming the target, not from a watcher a BlueOS sync could retrigger; the #info slot renders through the existing info affordance at src/components/ExpansiblePanel.vue:51-55,81 and warns that widgets restart)

7. Code Quality & Style — ✅ (complexity-report.json reports 157 functions measured over the 4 changed files with triggered empty and truncated false, so no function's complexity or depth was raised past the thresholds; no any, no new scoped CSS duplicating a Tailwind utility, no existing comment deleted or reworded, setUnmountHiddenViews(value: unknown) matches the setDistanceUnit shape two functions above it, and the largest recipient — src/types/widgets.ts — gains 18 lines)

8. Commit Hygiene — ✅ (one commit, feat: matching a feature addition and consistent with the feat:/scope-prefixed mix in recent history; the body states the why; no #N, owner/repo#N or closing keyword in the message — Closes #3036 stays in the PR body; nothing to squash, split or revert)

9. Tests — ✅ (four cases added to src/tests/types/widgets.test.ts, none removed or weakened; the view() factory supplies all six required fields of the View type at src/types/widgets.ts:823-849, and the cases cover both branches plus the hidden-current-view edge)

10. Documentation — ✅ (nothing in the changed code touches window.electronAPI, isElectron() or any Electron-only module, so the setting behaves identically in Lite and Standalone and the README's parity table needs no entry)

11. Nitpicks / Optional — ✅ (nothing worth raising)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-3036-unmount-hidden-views branch from 6862d80 to 8e4e4c4 Compare September 9, 2026 20:03
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 1

Done

  • src/libs/sensors-logging.ts / src/components/mini-widgets/VeryGenericIndicator.vue (1.1 — frozen gauge readings): deregisterVeryGenericData plus onBeforeUnmount that drops the indicator from the log and decrements CurrentlyLoggedVariables.
  • src/components/mini-widgets/SnapshotTool.vue (5.1 — timed snapshot keeps firing): onBeforeUnmount clears both intervals.
  • src/types/widgets.ts (1.2 — unclamped index): selectViewsToShow now clamps like currentView, with an empty-list guard. Test added.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
📝 MINOR SUGGESTIONS (Automated PR Review — round 2)

4 open findings — all minor — and 3 closed (1.1, 1.2 and 5.1, all addressed this round).

Cockpit keeps every visible view of a profile built and running at once, stacking the one you are looking at on top. This PR adds an opt-in switch, stored only on the computer you set it on and off by default, that builds only the view on screen; leaving a view then destroys everything on it and coming back builds it again. This round the author also fixed the two teardown bugs the last review found — a repeating screenshot capture that could not be stopped, and gauge readings that kept being written to the dive log after their gauge was gone — and made the view-picking rule tolerate an out-of-range view number. What is left is smaller: one more widget that leaves something behind when it is destroyed, one gauge case the new clean-up keys wrongly, a capture that now ends without telling anyone, and three bug fixes buried inside the feature's single commit.

What still needs attention

# Problem What it means Severity Status
1.3 A repeated gauge vanishes from the recorded log If the same gauge is placed on two views, leaving one of them stops the gauge being written to the dive log and the video overlay until its value happens to change again. minor
5.2 Custom widget containers leak on every view switch Each switch leaves a piece of the discarded widget behind for good, so the option meant to lower memory use slowly raises it. minor
6.1 Repeating screenshot capture now stops in silence Switching views quietly ends a repeating screenshot capture, and nothing tells the operator, who can come back believing pictures were taken the whole time. minor
8.1 Three separate bug fixes hidden inside the feature commit The branch's single commit also fixes bugs it never mentions, so those fixes cannot be reviewed, reverted or shipped on their own. minor
Since round 1 — 3 closed, comparing 6862d808e4e4c4

The range. incremental.diff for 6862d80...8e4e4c4 came back holding the entire PR (every hunk in it is also in pr.diff, including the original feature hunks in src/types/widgets.ts and src/views/ConfigurationUIView.vue), and pr.json lists a single commit whose oid is the head — so the branch was squashed and force-pushed, and the round-1 head is no longer reachable. I did not use it: every status below is judged against pr.diff and the base checkout.

✅ 1.1 — Addressed. deregisterVeryGenericData now exists beside registerVeryGenericData on DataLogger (src/libs/sensors-logging.ts:509-511) and is called from a new onBeforeUnmount in src/components/mini-widgets/VeryGenericIndicator.vue. Each part of what the finding asked for landed: the datalogger entry is dropped on unmount, so the logging routine stops stamping a stale value with a fresh timestamp; the widget-deletion path is covered too, since deletion unmounts the component; and the CurrentlyLoggedVariables instance counter is now decremented under the same widgetStore.editingMode === false guard the mount-time addVariable uses (:508-510), which keeps it from double-counting against the trash-drop decrement in src/components/MiniWidgetContainer.vue:159. Finding 1.3 below is a new defect in this new code, not the old one resurfacing.

✅ 1.2 — Addressed. selectViewsToShow (src/types/widgets.ts:1016-1027) now returns [] for an empty view list and clamps with Math.max(0, Math.min(currentIndex, views.length - 1)), matching currentView (src/stores/widgetManager.ts:297-298) exactly, and both branches use the clamped index — so the pre-existing throw on the setting-off path is closed as well. A test covers both branches at an out-of-range index (src/tests/types/widgets.test.ts).

✅ 5.1 — Addressed. src/components/mini-widgets/SnapshotTool.vue gains an onBeforeUnmount that clears and nulls both shotInterval and progressInterval. No captures survive the unmount, and the "press start and allocate a second pair of intervals" path is gone with them. The silence around that cancellation is a separate, new finding (6.1).

Discussion. @rafaellehmkuhl posted a follow-up listing the three fixes as done. Treating it as a claim rather than evidence, I checked each against the diff: all three hold, as recorded above. The second comment is a bare /review and carries nothing to act on. No /resolve command and no decision vote has been issued on this PR.

Change map — what was established before judging

Claims

  • "One commit: an opt-in, machine-local setting so hidden views and their widgets are not kept mounted"verified for the setting, incomplete as a description of the diff. The single commit also carries three teardown fixes to code this feature does not introduce (SnapshotTool.vue, VeryGenericIndicator.vue, sensors-logging.ts), and neither the commit body nor the PR summary mentions them → finding 8.1.
  • "Every visible view is stacked and left running (maps, video, render loops)"partly contradicted, re-verified this round. True for video, maps and iframes: src/views/WidgetsView.vue:3 mounts one .widget-view per entry of viewsToShow, src/components/widgets/VideoPlayer.vue registers its stream consumer unconditionally and only releases it at :316-320, and src/components/widgets/IFrame.vue:727 merely hides a widget that is not on the current view. False for render loops: the canvas widgets already skip drawing off-view, all gating on widgetStore.isWidgetVisible (src/stores/widgetManager.ts:344-346) — Attitude.vue:354, Compass.vue:241, DepthHUD.vue:302, VirtualHorizon.vue:258, Plotter.vue:550, CompassHUD.vue:246,743. What the feature actually saves is video sessions, leaflet instances and iframes.
  • "viewsToShow now mounts only the current view when the setting is on"verified. src/stores/widgetManager.ts:306 delegates to selectViewsToShow, whose unmountHidden branch returns [current] or []; both consumers are per-view v-fors (src/views/WidgetsView.vue:3, src/App.vue:60), so a shorter array is a real unmount and not a hide.
  • "It stays off by default" / "the value is stored only on this computer"verified. src/stores/widgetManager.ts:65 uses vueuse useStorage with false, not useBlueOsStorage.

Failure site

The feature itself fixes no bug, but the three changes folded in with it do, and all three are fixed at the site that misbehaves rather than around it: SnapshotTool.vue:399-442, where two module-closure interval handles were created and destroyed only by a watcher on a component-local ref; and the DataLogger registry (src/libs/sensors-logging.ts:231, :494-501), which had a register API and no counterpart. Both were reachable before this PR through widget deletion; the setting turns every view switch into another way to reach them.

Entry points

Function Reached from Frequency
selectViewsToShow (src/types/widgets.ts:1016) viewsToShow computed (src/stores/widgetManager.ts:306) → v-for at src/views/WidgetsView.vue:3 and src/App.vue:60; recomputed on currentViewIndex, currentProfile.views and the new setting per user action
viewsToShow computed (src/stores/widgetManager.ts:306) those same two v-fors and no other reader (four hits tree-wide) per user action
setUnmountHiddenViews (src/views/ConfigurationUIView.vue:211) @update:model-value on the v-switch at :161 per user action
DataLogger.deregisterVeryGenericData (src/libs/sensors-logging.ts:509) the new onBeforeUnmount in VeryGenericIndicator.vue ← Vue unmount: a view switch with the setting on, a widget deletion, a profile change per user action
onBeforeUnmount in VeryGenericIndicator.vue Vue unmount, as above per user action
onBeforeUnmount in SnapshotTool.vue Vue unmount, as above per user action

Every changed function fires per user action, so none of them is on a hot path. The multiplier is on the other side of the registry the two new hooks edit: collectVeryGenericData (src/libs/sensors-logging.ts:381-391) is called from the self-rescheduling logRoutine (:324-373, re-armed at :368) on every log point, which is why an entry left stale — or removed early, as in 1.3 — outlives the interaction that caused it.

Invariants

  1. A view leaving viewsToShow unmounts every widget and mini-widget under it, so anything that must outlive the view has to be owned outside the component. I enumerated the violators by listing every file under src/components/widgets/ and src/components/mini-widgets/ that registers a timer, listener, animation frame or socket and has no onBeforeUnmount/onUnmounted:
    • Holds — VideoPlayer.vue:316-320 and MiniVideoRecorder.vue:449-453 release their stream consumer, and deactivateStreamIfUnused (src/stores/video.ts:690-700) refuses to tear a stream down while a recorder is attached, so a recording started on the view you leave keeps running.
    • Holds — Map.vue:1123-1129 flushes the live map view and removes its own listeners; useDataLakeVariable unlistens in onUnmounted (src/composables/useDataLakeVariable.ts:48-52).
    • Now holds — SnapshotTool.vue and VeryGenericIndicator.vue, fixed by this PR (findings 5.1 and 1.1).
    • Violatedsrc/components/widgets/CollapsibleContainer.vue:529-535 → finding 5.2. (MissionControlPanel.vue:168-175 adds and removes its window listeners inside the same drag pair, so it is clean.)
    • Traded silentlySnapshotTool.vue now cancels a running timed capture instead of carrying it, with no feedback → finding 6.1.
  2. An entry in datalogger.veryGenericIndicators is owned by exactly one mounted indicator. The registry is keyed on displayName (src/libs/sensors-logging.ts:495-500), which two indicators can share, and registration is gated on variableName while the new deregistration is gated on displayName → finding 1.3.
  3. viewsToShow and currentView agree on which view is current. Both now clamp identically (src/types/widgets.ts:1018 vs src/stores/widgetManager.ts:297-298) — closed with 1.2.
1. Correctness & Implementation Bugs — 1 finding

1.3 — The new deregistration is keyed on the display name, so it drops an entry a second indicator still owns · minor

deregisterVeryGenericData (src/libs/sensors-logging.ts:509-511) filters the registry by displayName, and the new onBeforeUnmount in src/components/mini-widgets/VeryGenericIndicator.vue calls it with the unmounting widget's own name. Registration is keyed the same way — registerVeryGenericData (:494-501) updates the entry whose displayName matches rather than adding a second — so two indicators showing the same gauge share one registry entry.

That is a shape the codebase already expects: CurrentlyLoggedVariables (:170-213) exists in its current form precisely because several indicators can carry one name, which is why it counts instances instead of deleting on the first removal, and why the same new hook is correct on that side and wrong on this one. Placing the same gauge on two views is the natural way to keep it visible wherever you are, and with the setting on, switching between those views unmounts one of them: the shared entry is deleted while the surviving indicator is still mounted and still displaying. The survivor re-registers only on its next parsedState change (watcher at :479-487, whose immediate run happened at its own setup), so for a value that moves only when the pilot moves it — "Cam Tilt", "Lights (1)" and "Pilot Gain" all come from the shipped ROV profile — the variable is absent from the sensor log and from the telemetry overlay burned into recorded video until the pilot next touches it.

The guard if (!displayName) return in the same hook then skips the one entry that can never be cleaned any other way. logCurrentState (:457-461) registers whenever options.variableName is set, whatever the display name is, and the config dialog closes regardless of whether the widget was configured (closeVgiDialog, :433-444, only skips the CurrentlyLoggedVariables bookkeeping). An indicator left with a variable and a blank name therefore registers an entry under '' that survives every unmount, freezing its last value into every later log point — exactly the defect 1.1 described, still reachable for that one shape.

Fix: make the two sides agree on the key. Either count instances in DataLogger the way CurrentlyLoggedVariables already does, or key registerVeryGenericData/deregisterVeryGenericData on the widget hash (miniWidget.value.hash, already to hand in the component) and keep displayName as a payload field, which also lets the early return go.

2. Persistence & User Data — inventory, no findings
Key Backend Change
cockpit-unmount-hidden-views machine-local — vueuse useStorage (src/stores/widgetManager.ts:65) added

Judged:

  • Backend is right. A performance trade belongs to the topside computer, not to the vehicle, so it must not reach other operators through useBlueOsStorage. The same call shape is the established machine-local pattern here — cockpit-prevent-auto-vehicle-discovery-dialog (src/App.vue:378), cockpit-base-station-track-by-gps (src/composables/baseStation/useBaseStation.ts:35), cockpit-vehicle-address (src/stores/mainVehicle.ts:90).
  • Key naming starts with cockpit-, as AGENTS.md requires.
  • Shape is a bare boolean: no field duplicating the key, no nested state, no reshape of an existing key, no automatic migration.
  • Default is false, so nobody already configured is moved onto the new behaviour and there is nothing to carry over.
  • Nothing in this round's three teardown fixes touches a persisted key; they only change in-memory registries.
5. Performance — 1 finding

5.2 — CollapsibleContainer leaks a window resize listener on every view switch · minor

src/components/widgets/CollapsibleContainer.vue:529-535 registers window.addEventListener('resize', updateWrapDirection) in onMounted, and the file has no onBeforeUnmount or onUnmounted at all — the removeEventListener calls it does contain (:434-435, :464-465) belong to the column-drag handlers and never touch this one. It is the last remaining violator of the invariant this PR depends on, after the two the author fixed this round.

Today a custom widget unmounts only when it is deleted, its view is hidden, or the profile changes, so one orphan handler is the practical ceiling. With the setting on, every switch away from a view holding a custom widget leaks another listener and, through its closure, the whole setup scope of the discarded component — so the toggle a user reaches for to lower memory pressure raises it a little on each switch, which is the opposite of the trade the panel offers them. The handler is inert once unmounted (updateWrapDirection, :320-332, returns immediately when the widgetBase template ref is null), so this is retention and per-resize churn rather than wrong output, which is why it is minor and not the major 5.1 was.

Fix at the real site, in the form Map.vue:1129 already uses:

onBeforeUnmount(() => window.removeEventListener('resize', updateWrapDirection))
6. UI / UX — 1 finding

6.1 — A running timed snapshot capture is now cancelled by a view switch without telling anyone · minor

The 5.1 fix is right about the intervals, but it makes the capture end in silence. Every other way that loop ends announces itself: the watcher's stop branch opens "Timed snapshot stopped." (src/components/mini-widgets/SnapshotTool.vue:432), and starting one promises captures "every N seconds until you press the camera button again" (:411-415). The new onBeforeUnmount clears both handles and nothing else, and returning to the view remounts the widget with isTakingTimedSnapshot back at false (:243) — so a capture that stopped at the first view switch looks exactly like one that ran the whole dive. The Performance panel's own copy tells the user that widgets on other views "start again when you switch to them" (src/views/ConfigurationUIView.vue:154-156), which reads as resumption, not cancellation. An operator running timed captures across a survey and switching to the map to check position loses the rest of the series with no signal at all.

Either of the first two closes it; the third goes with whichever is chosen:

  • Say it. Open the existing "Timed snapshot stopped." snackbar (or one naming the view switch as the cause) from the unmount path, so the loop never ends without the pilot hearing about it.
  • Or keep the promise. The video recorder is the in-tree precedent for work that must outlive its mini-widget: the video store owns the recording and deactivateStreamIfUnused (src/stores/video.ts:690-700) refuses to tear the stream down while a recorder is attached, so leaving the view does not stop it. Owning the snapshot timer in the snapshot store the same way would let the capture survive a switch and the widget resume its progress bar on remount.
  • While there. The new hook repeats the watcher's stop branch verbatim (:433-440). A single stopTimedSnapshotIntervals() called from both keeps the two copies from drifting the next time the loop grows a handle.
8. Commit Hygiene — 1 finding

8.1 — Three independent bug fixes squashed into the feature commit · minor

Per pr.json, the branch is one commit, feat: optionally unload views that are not on screen (8e4e4c4), whose body describes only the setting. It also carries three fixes to code this feature does not introduce: the interval teardown in SnapshotTool.vue, the deregistration hook in VeryGenericIndicator.vue, and the new deregisterVeryGenericData on DataLogger. Each corrects behaviour users hit on today's release — deleting a mini-widget mid-capture leaves its intervals running forever, and deleting an indicator leaves its last value being written into the dive log — so each is reviewable, revertable and backportable on its own, which is what the behaviour-changes-ride-alone rule protects. As it stands, someone bisecting or cherry-picking the snapshot fix has to take the whole feature with it, and someone reading git log never learns the fixes exist: the PR summary still says "One commit: an opt-in, machine-local setting" and lists nothing else.

feat: fits the feature and matches the feat:/scope-prefixed mix in recent history; there is no #N reference or closing keyword in the message, no wip/fixup! noise, and nothing self-correcting. Split into three commits — the SnapshotTool teardown, the indicator/datalogger deregistration, then the feature — and name the two fixes in the PR body so the test plan covers them.

Sections with nothing to report (6)

3. AGENTS.md Adherence — ✅ (JSDoc on both added public functions carries a real summary and typed, non-empty @param/@returns, as jsdoc/require-jsdoc demands of a MethodDefinition; every added arrow function declares its return type per @typescript-eslint/explicit-function-return-type; the three teardown hooks are the smallest form of their fix and each has its call site here, with no exported helper left unused; no dependency added and package.json untouched; no comment deleted or reworded; the no-bottom-divider move at ConfigurationUIView.vue:122 is forced by the new panel becoming the last one)

4. Security — ✅ (all nine sub-checks run over the seven changed files: no encoded blob or binary-like string, no hidden or bidirectional Unicode, no network call, nothing under scripts/, .github/ or src/electron/, no new package, no eval/Function()/v-html, no secret or environment variable; pr.json, pr.diff, new-comments.json and complexity-report.json contain no text addressed to the reviewer)

7. Code Quality & Style — ✅ (the complexity report for this head states 281 functions measured across 7 changed files with triggered empty and truncated false, so by its account no function's complexity or nesting was pushed past the thresholds; no any, no new scoped CSS, no rule in .eslintrc.cjs that the added lines plausibly trip, and the largest recipient — src/types/widgets.ts — gains 20 lines; the only duplication added is the interval-clearing pair in SnapshotTool.vue, raised as the third sub-item of 6.1 rather than counted twice here)

9. Tests — ✅ (five cases now in src/tests/types/widgets.test.ts, none removed or weakened; the view() factory supplies all six required fields of View (src/types/widgets.ts:823-849), and the new case pins the clamp on both the setting-on and setting-off branches)

10. Documentation — ✅ (nothing in the changed code touches window.electronAPI, isElectron() or any Electron-only module — SnapshotTool.vue's existing isElectron use is untouched — so the setting and the teardown fixes behave identically in Lite and Standalone and the README parity table needs no entry)

11. Nitpicks / Optional — ✅ (nothing worth raising beyond the findings above)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-3036-unmount-hidden-views branch from 8e4e4c4 to 8fabf57 Compare September 9, 2026 20:22
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 2

Done

  • src/libs/sensors-logging.ts / src/components/mini-widgets/VeryGenericIndicator.vue (1.3 — shared gauge dropped): retain count on the display name; the log entry stays until the last widget using it unmounts. Blank names are released too.
  • src/components/widgets/CollapsibleContainer.vue (5.2 — resize listener leak): onBeforeUnmount removes the window listener.
  • src/components/mini-widgets/SnapshotTool.vue (6.1 — silent stop): shared stopTimedSnapshotIntervals(); unmount uses the existing "Timed snapshot stopped." snackbar.
  • History (8.1 — fixes inside the feature commit): split into three fix: commits, then the feature. PR body lists them.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
📝 MINOR SUGGESTIONS (Automated PR Review — round 3)

2 open findings — both minor — and 6 closed (5.2, 6.1 and 8.1 addressed this round).

Cockpit keeps every visible view of a profile built and running at once, stacking the one you are looking at on top. This PR adds an opt-in switch, stored only on the computer you set it on and off by default, that builds only the view on screen; leaving a view then destroys everything on it and coming back builds it again. Because that turns every view switch into a teardown, the branch also fixes three things that were never released when a widget went away: a repeating screenshot capture, gauge readings written into the dive log, and a window listener on custom widget containers. This round the author fixed the container listener, made the cancelled screenshot capture announce itself, and split the three fixes into their own commits. What is left is one gauge case the clean-up still keys wrongly and one more widget that leaves a data feed behind.

What still needs attention

# Problem What it means Severity Status
1.3 Renaming a gauge confuses the new clean-up count A gauge renamed while it is on screen can either wipe another gauge with the same name out of the recorded log, or leave a dead reading being written into it for the rest of the session. minor 🟡
5.3 Iframe extensions leave a data feed behind on every view switch With the new option on, a view holding an embedded extension page leaks a little work and memory each time you leave it, so the setting meant to lower load slowly raises it. minor
Since round 2 — 3 closed, 1 partly, comparing 8e4e4c48fabf57

The range. incremental.diff for 8e4e4c4...8fabf57 again came back holding the entire PR: all eight files, and every hunk in it also appears in pr.diff, including feature hunks in src/types/widgets.ts and src/views/ConfigurationUIView.vue that this round did not touch. pr.json now lists four commits, the last of which is the head, and none of which is 8e4e4c4 — so the branch was re-split and force-pushed and the round-2 head is no longer reachable. I did not rely on it: every status below is judged against pr.diff and the base checkout.

🟡 1.3 — Partially addressed. The finding asked for the two sides of the registry to agree on their key, and named two ways to do it. The author took the first: DataLogger now counts owners (veryGenericOwners, src/libs/sensors-logging.ts:232), retainVeryGenericData (:510) increments on mount and deregisterVeryGenericData (:519) only drops the entry when the count reaches zero. That closes the case the finding led with — two gauges that carry the same name at mount time now hold the entry until the second one unmounts — and the blank-name early return is gone, so '' is released rather than skipped. What did not land is agreement across time: the retain reads displayName at mount (src/components/mini-widgets/VeryGenericIndicator.vue:513) and the deregister reads it again at unmount (:517), and the name is user-editable in between. Detail and both failure directions in section 1.

✅ 5.2 — Addressed. src/components/widgets/CollapsibleContainer.vue:538 now removes the resize handler that :531 adds, and it is the same function reference — updateWrapDirection is a single const arrow at :320, not re-created per call — so the listener and the setup scope it closed over are both released. The form matches the in-tree one at Map.vue:1129. With that, the file is no longer a violator of the invariant this feature depends on.

✅ 6.1 — Addressed. All three sub-items landed. stopTimedSnapshotIntervals (src/components/mini-widgets/SnapshotTool.vue:407-419) is now the single place both handles are cleared, called from the watcher's stop branch (:447) and from the new onBeforeUnmount (:477-481), so the duplication the third sub-item named is gone. The hook opens the existing "Timed snapshot stopped." snackbar, and only when something was actually running: wasRunning tests both handles against null, and both are initialised to null (:399-400), so a view switch with no capture in progress stays silent instead of announcing a stop that never happened. openSnackbar keeps its state in a module-level reactive (src/composables/snackbar.ts:55-57), so calling it from a teardown hook is safe.

✅ 8.1 — Addressed. pr.json now lists four commits — fix: stop timed snapshots when the snapshot tool unmounts (f5bb353), fix: drop unmounted gauges from the sensor log (3feb71b), fix: release the custom widget resize listener on unmount (e8b1378), then feat: optionally unload views that are not on screen (8fabf57) — so each behaviour fix is reviewable, revertable and backportable without the feature. The fix:/feat: prefixes fit their changes, the split is per logical change rather than per file, no message carries a #N or a closing keyword, and none of the four reverts or reimplements another. The PR body now lists the three fixes and the test plan covers them.

Discussion. @rafaellehmkuhl left a follow-up (#issuecomment-5608206240) listing four items as done. Treating it as a claim rather than evidence, I checked each against the diff: the resize listener, the shared stopTimedSnapshotIntervals with the snackbar, and the commit split all hold, as recorded above. One clause does not: "Blank names are released too" holds only while no gauge has been renamed during the session, because the '' retained at mount is never released by a widget that has since been given a name — see 1.3. The second comment is a bare /review and carries nothing to act on. resolutions.json and decisions.json are both empty, so no /resolve has been issued and no dispute has been put to a vote on this PR; nothing is waiting on a maintainer decision. None of the untrusted inputs contains text addressed to the reviewer.

Change map — what was established before judging

Claims

  • "Four commits: three unmount teardowns that were already wrong on delete, then the opt-in setting"verified against the commit list in pr.json (f5bb353, 3feb71b, e8b1378, 8fabf57). All three fixed defects are reachable on today's release by deleting the widget; the setting adds a second way to reach them. This is what closes 8.1.
  • "the existing 'Timed snapshot stopped.' snackbar fires so a view switch does not end a capture in silence"verified, src/components/mini-widgets/SnapshotTool.vue:477-481, guarded by the wasRunning return of :407-419.
  • "A retain count keeps a shared display name in the log until the last copy is gone"verified for a name the widget already carries at mount, contradicted when the name changes while mounted. displayName is bound with v-model in the config dialog (src/components/mini-widgets/VeryGenericIndicator.vue:56), defaults to '' for a newly placed gauge (:327) and is overwritten wholesale by the presets (:608), so the retained key and the deregistered key routinely differ → finding 1.3.
  • "The window resize listener on CollapsibleContainer is removed on unmount"verified, src/components/widgets/CollapsibleContainer.vue:538 against the registration at :531.
  • "A machine-local setting, off by default, mounts only the current view" / "it is not synced"verified. src/stores/widgetManager.ts:65 uses vueuse useStorage with false, not useBlueOsStorage; selectViewsToShow (src/types/widgets.ts:1016-1027) returns [current] or [] on that branch, and both consumers are per-view v-fors (src/views/WidgetsView.vue:3, src/App.vue:60), so a shorter array is a real unmount and not a hide.
  • "Every visible view is stacked and left running (maps, video, render loops)"partly contradicted, unchanged from round 2. True for video (src/components/widgets/VideoPlayer.vue registers its stream consumer unconditionally, releasing at :316-320), maps, and iframes (src/components/widgets/IFrame.vue:727 only hides a widget that is off-view). False for render loops: the canvas widgets already skip drawing off-view via widgetStore.isWidgetVisible (src/stores/widgetManager.ts:344-346) — Attitude.vue:354, Compass.vue:241, DepthHUD.vue:302, VirtualHorizon.vue:258, Plotter.vue:550, CompassHUD.vue:246,743. What the option actually saves is video sessions, leaflet instances and iframes.

Failure site

The feature fixes no bug; the three commits folded around it do, and all three are fixed at the site that misbehaves rather than around it: SnapshotTool.vue:399-447 (two interval handles created and destroyed only by a watcher on a component-local ref), the DataLogger registry (src/libs/sensors-logging.ts:231, :494-502, which had a register API and no counterpart), and CollapsibleContainer.vue:531 (a window listener with no remover). The one remaining site of that same class is not in the diff — the external-API listeners at src/components/widgets/IFrame.vue:654 → finding 5.3.

Entry points

Function Reached from Frequency
selectViewsToShow (src/types/widgets.ts:1016) viewsToShow computed (src/stores/widgetManager.ts:308) → v-for at src/views/WidgetsView.vue:3 and src/App.vue:60; recomputed on currentViewIndex, currentProfile.views and the new setting per user action
viewsToShow computed (src/stores/widgetManager.ts:308) those same two v-fors and no other reader (four hits tree-wide) per user action
setUnmountHiddenViews (src/views/ConfigurationUIView.vue:212) @update:model-value on the v-switch at :161 per user action
DataLogger.retainVeryGenericData (src/libs/sensors-logging.ts:510) onMounted in VeryGenericIndicator.vue:513 ← Vue mount: a view switch with the setting on, a widget being placed, a profile change per user action
DataLogger.deregisterVeryGenericData (src/libs/sensors-logging.ts:519) onBeforeUnmount in VeryGenericIndicator.vue:516-522 ← Vue unmount, as above per user action
stopTimedSnapshotIntervals (src/components/mini-widgets/SnapshotTool.vue:407) the isTakingTimedSnapshot watcher (:447) and onBeforeUnmount (:477-481) per user action
onBeforeUnmount in CollapsibleContainer.vue:538 Vue unmount, as above per user action
consumer side, unchanged: collectVeryGenericData (src/libs/sensors-logging.ts:381-391) the self-rescheduling logRoutine (:324-372, re-armed at :368) per log point

Every changed function fires per user action, so none of them is on a hot path. The multipliers are on the far side of the two registries the new hooks edit: collectVeryGenericData runs on every log point, which is why an entry removed early — or one left behind, as in 1.3 — outlives the interaction that caused it; and notifyDataLakeVariableListeners (src/libs/actions/data-lake.ts:206-218) runs per incoming value, which is what makes 5.3 accumulate rather than merely retain.

Invariants

  1. A view leaving viewsToShow unmounts every widget and mini-widget under it, so anything that must outlive the view has to be owned outside the component. Re-enumerated this round, and by call site rather than by "does the file have a teardown hook", which is what a partial teardown hides:
    • Holds — VideoPlayer.vue:316-320 and MiniVideoRecorder.vue:449-453 release their stream consumer, and deactivateStreamIfUnused (src/stores/video.ts:690-700) refuses to tear a stream down while a recorder is attached, so a recording started on the view you leave keeps running.
    • Holds — Map.vue:1123-1129; EkfStateIndicator.vue:237-245, Plotter.vue:287, Dial.vue:360, Slider.vue:192, Switch.vue and useDataLakeVariable (src/composables/useDataLakeVariable.ts:48-52) all unlisten the data-lake listeners they created; MissionControlPanel.vue:168-175 adds and removes its window listeners inside the same drag pair.
    • Now holds — SnapshotTool.vue, VeryGenericIndicator.vue and CollapsibleContainer.vue:538, all fixed by this PR (findings 5.1, 1.1 and 5.2).
    • ViolatedIFrame.vue: its onBeforeUnmount (:714-720) covers the message handler and the vehicle-address listener but not the listeners the external API creates at :654, whose ids are discarded. Tree-wide, that is the only listenDataLakeVariable( call whose return value is not stored → finding 5.3.
  2. The owner count for an entry in datalogger.veryGenericIndicators is retained and released under the same key. Registration and the new count are both keyed on displayName (src/libs/sensors-logging.ts:494-502, :510, :519), and displayName is user-editable while the widget is mounted (VeryGenericIndicator.vue:56, :608) and starts empty (:327). Nothing in the component pins the key that was retained — lastWidgetName (:422) does exactly that job for the CurrentlyLoggedVariables side, and closeVgiDialog (:433-444) keeps it in step on rename, but the datalogger side has no equivalent → finding 1.3.
  3. viewsToShow and currentView agree on which view is current. Both clamp identically (src/types/widgets.ts:1018 vs src/stores/widgetManager.ts:297-298) — closed with 1.2 and still true.
1. Correctness & Implementation Bugs — 1 finding

1.3 — The owner count is retained under the mount-time display name and released under the unmount-time one · minor · (carried from round 2, partially addressed)

The retain count is the right mechanism and closes the case round 2 led with: two gauges that both carry "Depth" when they mount take the count to 2 (src/libs/sensors-logging.ts:510), so unmounting one leaves the entry in place (:519-527) and the survivor keeps being written into the log. Releasing '' unconditionally (src/components/mini-widgets/VeryGenericIndicator.vue:517-518) also removes the early return that used to skip the one entry nothing else could clean.

What remains is that the two calls read the name at different times, and the name is not stable. displayName is bound straight to the config dialog with v-model (:56), it defaults to '' for a freshly placed gauge (:327), and the presets overwrite it wholesale (:608) — so "mount, then name it" is the normal path, not an edge case. retainVeryGenericData runs in onMounted with the name as it is then (:513); deregisterVeryGenericData runs in onBeforeUnmount with the name as it is by then (:517). Both directions of the mismatch are reachable:

  • Under-count on the new name — an entry a live gauge still owns gets dropped. Gauge A mounts as "Depth" (count Depth: 1), gauge B is renamed onto "Depth" while mounted, and B unmounts on a view switch: the deregister falls back to (undefined ?? 1) - 1 === 0 (:520), deletes the count and filters "Depth" out of veryGenericIndicators (:525-526) although A is still mounted and still displaying. A re-registers only on its next parsedState change (watcher at :479-487, whose immediate run already happened at its own setup), so for a value the pilot has to move — "Cam Tilt", "Lights (1)" and "Pilot Gain" in the shipped ROV profile — the variable is missing from the sensor log and from the telemetry overlay burned into recorded video until it is next touched. That is the defect this finding was raised for, now narrowed to renames.
  • Over-count on the old name — an entry nobody owns is never dropped. Every gauge placed in a session retains '' at mount and, once named, releases something else, so the '' count only grows. A gauge configured with a variable but no display name still registers an entry under '' (logCurrentState, :457-461, gates on variableName alone), and when it unmounts the count is now greater than one, so the entry survives (:521-523) and collectVeryGenericData (src/libs/sensors-logging.ts:381-391) keeps stamping its last value into every later log point for the rest of the session. That is finding 1.1's defect, on precisely the blank-name shape the follow-up comment reports as released.

Fix: release the key that was retained, rather than the name that happens to be current. The component already keeps lastWidgetName (:422) for exactly this reason on the CurrentlyLoggedVariables side, and closeVgiDialog (:433-444) already does the remove/add pair on rename — mirroring that (deregister lastWidgetName, retain the new name, and deregister lastWidgetName on unmount) keeps both sides in step with one existing pattern. Keying the registry on miniWidget.value.hash and carrying displayName as a payload field is the other option from round 2 and makes the whole class impossible. While there: retainVeryGenericData reads as one half of a retain/release pair and deregisterVeryGenericData as one half of a register/deregister pair, so the two names suggest two mechanisms where there is one.

2. Persistence & User Data — inventory, no findings
Key Backend Change
cockpit-unmount-hidden-views machine-local — vueuse useStorage (src/stores/widgetManager.ts:65) added

Judged:

  • Backend is right. A performance trade belongs to the topside computer, not to the vehicle, so it must not reach other operators through useBlueOsStorage. The same call shape is the established machine-local pattern here — cockpit-prevent-auto-vehicle-discovery-dialog (src/App.vue:378), cockpit-base-station-track-by-gps (src/composables/baseStation/useBaseStation.ts:35), cockpit-vehicle-address (src/stores/mainVehicle.ts:90).
  • Key naming starts with cockpit-, as AGENTS.md requires.
  • Shape is a bare boolean: no field duplicating the key, no nested state, no reshape of an existing key, no automatic migration.
  • Default is false, so nobody already configured is moved onto the new behaviour and there is nothing to carry over.
  • Nothing in the three teardown fixes touches a persisted key; veryGenericOwners (src/libs/sensors-logging.ts:232) is in-memory only, and the log points themselves are unchanged in shape.
5. Performance — 1 finding

5.3 — The iframe widget's external-API data-lake listeners are never released, so each view switch leaks one per subscribed variable · minor

apiEventCallback (src/components/widgets/IFrame.vue:649-657) answers the cockpit:listenToDatalakeVariables message from embedded content by calling listenDataLakeVariable(variable, …) at :654 and discarding the id it returns. The component's onBeforeUnmount (:714-720) removes the message handler and unlistens vehicle-address, but it cannot unlisten these — nothing kept their ids. Tree-wide this is the only listenDataLakeVariable( call whose return value is not stored; every other caller keeps it and releases it on unmount (EkfStateIndicator.vue:237-245, Plotter.vue:287, Dial.vue:360, Slider.vue:192).

This is not a hypothetical path: it is the shipped extension API, whose client half posts that exact message (src/libs/external-api/api.ts:29), so any extension page a user embeds in an iframe widget subscribes this way. listenDataLakeVariable (src/libs/actions/data-lake.ts:177-192) parks the callback in dataLakeVariableListeners[variableId], and notifyDataLakeVariableListeners (:206-218) walks every listener registered for a variable on each value update — the per-incoming-message path. Today the widget unmounts only on deletion, view hiding or a profile change, so one orphan per subscription is the practical ceiling; with the new setting on, every switch away from that view leaks another set, and coming back registers a fresh set on top. The orphaned callbacks are near no-ops — iframe is a template ref, so iframe.value?.contentWindow?.postMessage (:655) is skipped after unmount — which is why this is retention plus per-message churn rather than wrong output, and minor rather than major, the same grading 5.2 carried for the same reason. It is the last violator of invariant 1, and it means the option a user reaches for to reduce load raises it a little on each switch.

Fix at the real site, in the shape EkfStateIndicator.vue:191-245 already uses: record each subscription in apiEventCallback (a Map<string, string> of variable → listener id, or an array of pairs, since one variable can be requested more than once) and unlisten them in the existing onBeforeUnmount next to the vehicle-address call. Dropping a duplicate subscription for a variable the same iframe already listens to would also keep the map from growing while the widget is alive.

Sections with nothing to report (8)

3. AGENTS.md Adherence — ✅ (both new DataLogger methods carry a real JSDoc summary with typed, non-empty @param/@returns, which jsdoc/require-jsdoc demands of a MethodDefinition; stopTimedSnapshotIntervals declares : boolean and the teardown arrows are call arguments, allowed by explicit-function-return-type's allowExpressions: true; the change to SnapshotTool.vue removed the duplicated interval-clearing block rather than adding a second copy; no dependency added, package.json untouched, useStorage comes from the already-installed vueuse; no comment deleted or reworded on unchanged code; nothing added is left without a call site in this PR)

4. Security — ✅ (all nine sub-checks run over the eight changed files: no encoded blob or binary-like string, no hidden or bidirectional Unicode, no network call, nothing under scripts/, .github/ or src/electron/, no new package, no eval/Function()/v-html, no secret or environment variable; pr.json, pr.diff, incremental.diff, new-comments.json, complexity-report.json, resolutions.json and decisions.json contain no text addressed to the reviewer)

6. UI / UX — ✅ (the cancelled capture now announces itself through the existing snackbar, closing 6.1; the v-switch repeats the established pattern — color="white", base-color="#FFFFFF33", class="mt-2 -mb-2 ml-3" — used six times in ConfigurationMissionView.vue:10-61, so its margins and tokens are house style rather than hand-tuned here; the #info slot it fills exists on ExpansiblePanel.vue:51,81; the panel title and switch label are sentence case, the no-bottom-divider move at ConfigurationUIView.vue:122 is forced by the new panel becoming the last one, and setUnmountHiddenViews logs one past-tense logUserAction entry per toggle)

7. Code Quality & Style — ✅ (the complexity report for this head states 320 functions measured across 8 changed files with triggered empty, triggeredCount 0 and truncated false, so by its account no function's cyclomatic complexity or nesting was pushed past the 12/4 thresholds; no any, no new scoped CSS, no rule in .eslintrc.cjs that the added lines plausibly trip, the counting logic sits in src/libs/ rather than in a component, and the largest recipient — src/libs/sensors-logging.ts — gains 25 lines)

8. Commit Hygiene — ✅ (the four commits in pr.json are one logical change each, fixes before feature, prefixes matching their content, no #N or closing keyword in any message, no wip/fixup! noise, nothing self-correcting, none oversized, and no commit replicated from a sibling branch — closing 8.1)

9. Tests — ✅ (the five selectViewsToShow cases in src/tests/types/widgets.test.ts are unchanged this round, none removed or weakened; the view() factory still supplies all six required fields of View (src/types/widgets.ts:823-849) and the clamp is pinned on both branches)

10. Documentation — ✅ (nothing in the changed code touches window.electronAPI, isElectron() or any Electron-only module — SnapshotTool.vue's existing isElectron use is untouched — so the setting and the three teardown fixes behave identically in Lite and Standalone and the README parity table needs no entry)

11. Nitpicks / Optional — ✅ (the only taste point is the retain/deregister naming pair, folded into 1.3's fix note rather than raised on its own)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-3036-unmount-hidden-views branch from 8fabf57 to 85ce744 Compare September 9, 2026 20:48
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 3

Done

  • src/components/mini-widgets/VeryGenericIndicator.vue / src/libs/sensors-logging.ts (1.3 — retain and release read the name at different times): the component now tracks retainedLogName, the key it actually retained. A watcher on options.displayName releases the old key and retains the new one on every rename, and onBeforeUnmount releases retainedLogName instead of the current name. Both directions close: a gauge renamed onto a name another gauge owns no longer drops that entry, and the '' a freshly placed gauge retains is released when it is named. deregisterVeryGenericData is now releaseVeryGenericData, so the pair reads as one mechanism.
  • src/components/widgets/IFrame.vue (5.3 — external-API listeners never unlistened): apiEventCallback records each subscription in an apiListenerIds map keyed by variable and skips a variable the widget already listens to; the existing onBeforeUnmount unlistens them all next to the vehicle-address call.

Done differently

  • History (5.3): the iframe fix is its own fix: commit placed with the other three teardowns, before the feature, rather than folded into one of them. Five commits now — four fixes, then the feature. The PR body lists the new one and the test plan covers the rename and iframe cases.

Won't change (with reasoning)

  • 1.3 — keying the registry on miniWidget.hash: took the other option from the finding instead. veryGenericIndicators and collectVeryGenericData are both keyed on the display name, so carrying the hash would mean reshaping the entry list and the log-point output as well; tracking the retained key is the change that fits inside the fix commit.
  • No unit test for the retain/release pair: src/libs/sensors-logging.ts cannot be imported under the current vitest setup — its chain reaches settings-management.ts and Vuetify component CSS, which the harness is not configured to load. Making it importable is a test-infrastructure change, not part of this PR.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
READY TO MERGE (Automated PR Review — round 4)

No open findings — 8 closed in total, 2 of them this round (1.3 and 5.3).

Cockpit keeps every visible view of a profile built and running at once, stacking the one you are looking at on top. This PR adds an opt-in switch, stored only on the computer you set it on and off by default, that builds only the view on screen; leaving a view then destroys everything on it and coming back builds it again. Because that turns every view switch into a teardown, the branch also fixes four things that were never released when a widget went away: a repeating screenshot capture, gauge readings written into the dive log, a window listener on custom widget containers, and the data feeds an embedded extension page subscribes to. This round the author made the gauge clean-up track the name it actually claimed, so renaming a gauge no longer disturbs another one, and released the extension feeds on unmount.

What still needs attention

Nothing. Both findings carried into this round are addressed, and re-running the sections over the whole diff raised nothing new.

Since round 3 — 2 closed, comparing 8fabf5785ce744

The range. incremental.diff for 8fabf57...85ce744 came back holding eight of the nine files in the PR — everything except SnapshotTool.vue — and every hunk in it also appears in pr.diff, including feature hunks in src/types/widgets.ts and src/views/ConfigurationUIView.vue that this round did not touch. pr.json now lists five commits, none of them 8fabf57, and the two middle fixes carry new oids (ed77d77, 7a09653) where round 3 had 3feb71b and e8b1378: the branch was rebased and force-pushed again, so the comparison fell back to the last commit the two heads share (f5bb353, the snapshot fix) and the round-3 head is unreachable. I did not rely on it — every status below is judged against pr.diff and the base checkout.

✅ 1.3 — Addressed. The finding asked for one thing with two halves: release the key that was actually retained, and keep the two sides in step while the name changes. Both landed. retainedLogName (src/components/mini-widgets/VeryGenericIndicator.vue:426) holds the key the widget claimed; onMounted retains that key (:527) and onBeforeUnmount releases that same key (:532) rather than whatever the name has become. A watcher on options.displayName (:501-508) releases the old key and retains the new one on each change, which covers all three writers of that option — the config dialog's v-model (:56), setIndicatorFromTemplate (:608), and the in-place BlueOS profile patch the comment at :361-363 documents — since it watches the value rather than the writer. Walking both failure directions from round 3 against the new code: a gauge renamed onto "Depth" while mounted now takes that count to 2 before it can release it, so its unmount leaves the entry the other gauge still feeds (src/libs/sensors-logging.ts:521-524); and the '' a freshly placed gauge retains is released on the first keystroke that names it, so the count no longer only grows and the blank entry is filtered out (:526-527). The retain/release pair is symmetric on every path I traced — mount, rename, repeated renames while typing, and unmount — so no key is released that was not retained first. deregisterVeryGenericData is now releaseVeryGenericData (:520), closing the naming point the fix note carried.

✅ 5.3 — Addressed. apiEventCallback now stores the id listenDataLakeVariable returns in apiListenerIds (src/components/widgets/IFrame.vue:650, set at :661), and the existing onBeforeUnmount (:719) unlistens every entry and clears the map (:721-722). Map.forEach yields (value, key), so (listenerId, variable) => unlistenDataLakeVariable(variable, listenerId) passes them in the order unlistenDataLakeVariable(variableId, listenerId) declares (src/libs/actions/data-lake.ts). The map is declared inside <script setup>, so it is per widget instance and not shared between two iframe widgets. The :657 early return for a variable the widget already listens to is safe against the client half of the API: listenToDatalakeVariable (src/libs/external-api/api.ts) adds its own message handler per call, so one forwarded update still reaches every callback the page registered, and a page that reloads inside the same iframe keeps receiving values because iframe.value?.contentWindow is resolved at call time. With this, no listenDataLakeVariable( call in the tree discards its id.

Discussion. @rafaellehmkuhl left a follow-up (#issuecomment-5608547107) listing the two fixes as done. Treating it as a claim rather than evidence, I checked each against the diff: both hold, as recorded above. Of the two "won't change" items, the first — taking the retained-key option instead of keying the registry on miniWidget.hash — is one of the two fixes the round-3 finding named, so it closes it either way; the second, that no unit test covers the retain/release pair, is not something this review asks for, since section 9 explicitly does not require tests for logic a PR adds, so I neither graded nor verified the claim about the vitest setup. The second comment is a bare /review and carries nothing to act on. resolutions.json and decisions.json are both empty, so no /resolve has been issued and no dispute has ever been put to a vote on this PR; nothing is waiting on a maintainer decision, and no id was submitted that I could not find in the ledger. None of the untrusted inputs contains text addressed to the reviewer.

Change map — what was established before judging

Claims

  • "Five commits: four unmount teardowns that were already wrong on delete, then the opt-in setting"verified against the commit list in pr.json (f5bb353, ed77d77, 7a09653, b124913, 85ce744). All four fixed defects are reachable on today's release by deleting the widget or hiding its view; the setting adds a second way to reach them.
  • "the entry is released under the name it was retained with, so renaming a gauge while it is on screen does not drop another gauge's entry or strand its own"verified, VeryGenericIndicator.vue:426 (the retained key), :501-508 (the rename pair), :527 and :532 (retain and release), against sensors-logging.ts:510 and :520-527. This is what closes 1.3.
  • "Data lake variables an embedded page subscribes to through the external API are unlistened on unmount"verified, IFrame.vue:650, :657-661, :721-722, against the client half in src/libs/external-api/api.ts and unlistenDataLakeVariable in src/libs/actions/data-lake.ts. This is what closes 5.3.
  • "A machine-local setting, off by default, mounts only the current view" / "it is not synced"verified. src/stores/widgetManager.ts:65 uses vueuse useStorage with false, not useBlueOsStorage; selectViewsToShow (src/types/widgets.ts:1016-1026) returns [current] or [] on that branch, and both consumers are per-view v-fors (src/views/WidgetsView.vue:3, src/App.vue:60), so a shorter array is a real unmount and not a hide.
  • "Hidden-but-enabled views stay mounted and keep maps, video, and iframes running"partly contradicted, unchanged from rounds 2 and 3. True for video (VideoPlayer.vue registers its stream consumer unconditionally, releasing at :316-320), maps, and iframes (IFrame.vue only hides a widget that is off-view). False for render loops: the canvas widgets already skip drawing off-view via widgetStore.isWidgetVisible (src/stores/widgetManager.ts:344-346) — Attitude.vue:354, Compass.vue:241, DepthHUD.vue:302, VirtualHorizon.vue:258, Plotter.vue:550, CompassHUD.vue:246,743. What the option actually saves is video sessions, leaflet instances and iframes.

Failure site

The feature fixes no bug; the four commits folded around it do, and all four are fixed at the site that misbehaves rather than around it: SnapshotTool.vue:399-447 (two interval handles created and destroyed only by a watcher on a component-local ref), the DataLogger registry (sensors-logging.ts:231, :494-502, which had a register API and no counterpart), CollapsibleContainer.vue:531 (a window listener with no remover), and IFrame.vue:654 in the base (the one listenDataLakeVariable( call tree-wide whose id was discarded). Re-running that last search this round: every listenDataLakeVariable( call in src/components now stores its id, and the base-file hit is the line this PR changes.

Entry points

Function Reached from Frequency
selectViewsToShow (src/types/widgets.ts:1016) viewsToShow computed (widgetManager.ts:308) → v-for at WidgetsView.vue:3 and App.vue:60; recomputed on currentViewIndex, currentProfile.views and the new setting per user action
viewsToShow computed (src/stores/widgetManager.ts:308) those same two v-fors and no other reader (four hits tree-wide) per user action
setUnmountHiddenViews (src/views/ConfigurationUIView.vue:211) @update:model-value on the v-switch at :161 per user action
DataLogger.retainVeryGenericData (src/libs/sensors-logging.ts:510) onMounted (VeryGenericIndicator.vue:527) and the rename watcher (:501-508) per user action
DataLogger.releaseVeryGenericData (src/libs/sensors-logging.ts:520) the same rename watcher (:504) and onBeforeUnmount (:532) per user action
displayName watcher (VeryGenericIndicator.vue:501) the config dialog's v-model (:56), setIndicatorFromTemplate (:608), an in-place BlueOS profile patch (:361-363) per user action (per keystroke while the name field is edited)
stopTimedSnapshotIntervals (src/components/mini-widgets/SnapshotTool.vue:407) the isTakingTimedSnapshot watcher (:447) and onBeforeUnmount (:477-481) per user action
onBeforeUnmount in CollapsibleContainer.vue:538 Vue unmount: a view switch with the setting on, widget deletion, a profile change per user action
apiEventCallback (src/components/widgets/IFrame.vue:652) window message listener registered in onBeforeMount, fired by any frame that posts a message per incoming message
onBeforeUnmount in IFrame.vue:719 Vue unmount, as above per user action
consumer side, unchanged: collectVeryGenericData (src/libs/sensors-logging.ts:381-391) the self-rescheduling logRoutine (:324-372, re-armed at :368) per log point

Only apiEventCallback sits on a message path, and its added work is one Map.has before the early return, so the dedupe makes that path cheaper rather than dearer. Everything else fires per user action. The multipliers stay on the far side of the two registries the hooks edit: collectVeryGenericData runs on every log point, and notifyDataLakeVariableListeners (src/libs/actions/data-lake.ts:206-218) runs per incoming value — which is what made 5.3 accumulate, and what the unlisten loop now bounds.

Invariants

  1. A view leaving viewsToShow unmounts every widget and mini-widget under it, so anything that must outlive the view has to be owned outside the component. Re-enumerated this round by call site rather than by "does the file have a teardown hook":
    • Holds — VideoPlayer.vue:316-320 and MiniVideoRecorder.vue:449-453 release their stream consumer, and deactivateStreamIfUnused (src/stores/video.ts:690-700) refuses to tear a stream down while a recorder is attached, so a recording started on the view you leave keeps running and can be stopped when you come back, its state living in the video store rather than in the widget.
    • Holds — Map.vue:1123-1129; EkfStateIndicator.vue:237-245, Plotter.vue:287, Dial.vue:360, Slider.vue:192, Switch.vue and useDataLakeVariable (src/composables/useDataLakeVariable.ts:48-52) all unlisten what they created; MissionControlPanel.vue:168-175 adds and removes its window listeners inside the same drag pair.
    • Now holds — SnapshotTool.vue, VeryGenericIndicator.vue, CollapsibleContainer.vue:538 and IFrame.vue:721-722, all fixed by this PR (findings 5.1, 1.1/1.3, 5.2 and 5.3). No violator left: no listenDataLakeVariable( call in the tree discards its id, and no widget or mini-widget file registering a setInterval or a window.addEventListener is without a teardown hook.
  2. The owner count for an entry in datalogger.veryGenericIndicators is retained and released under the same key. Now enforced in the component: retainedLogName (VeryGenericIndicator.vue:426) pins the key, and the watcher at :501-508 moves the count from the old key to the new one before the key is reassigned, so every one of the three writers of displayName keeps both sides in step. Closed with 1.3.
  3. viewsToShow and currentView agree on which view is current. Both clamp identically (src/types/widgets.ts:1017 vs src/stores/widgetManager.ts:297-298) — closed with 1.2 and still true.
2. Persistence & User Data — inventory, no findings
Key Backend Change
cockpit-unmount-hidden-views machine-local — vueuse useStorage (src/stores/widgetManager.ts:65) added

Judged:

  • Backend is right. A performance trade belongs to the topside computer, not to the vehicle, so it must not reach other operators through useBlueOsStorage. The same call shape is the established machine-local pattern here — cockpit-prevent-auto-vehicle-discovery-dialog (src/App.vue:378), cockpit-base-station-track-by-gps (src/composables/baseStation/useBaseStation.ts:35), cockpit-vehicle-address (src/stores/mainVehicle.ts:90).
  • Key naming starts with cockpit-, as AGENTS.md requires.
  • Shape is a bare boolean: no field duplicating the key, no nested state, no reshape of an existing key, no automatic migration.
  • Default is false, so nobody already configured is moved onto the new behaviour and there is nothing to carry over.
  • Nothing in the four teardown fixes touches a persisted key. veryGenericOwners (src/libs/sensors-logging.ts:232), retainedLogName (VeryGenericIndicator.vue:426) and apiListenerIds (IFrame.vue:650) are all in-memory, and the log points themselves are unchanged in shape.
Sections with nothing to report (10)

1. Correctness & Implementation Bugs — ✅ (traced the retain/release pair through mount, rename, repeated renames while the name field is typed into, and unmount, and through all three writers of options.displayName — dialog v-model :56, preset :608, in-place profile patch :361-363 — with no path releasing a key that was not retained first; checked the iframe dedupe against the client half of the external API, which registers one message handler per listenToDatalakeVariable call, so a single forwarded update still reaches every callback an embedded page registered, and against an iframe reload, where contentWindow is resolved at call time)

3. AGENTS.md Adherence — ✅ (both DataLogger methods carry a real JSDoc summary with typed, non-empty @param/@returns, which jsdoc/require-jsdoc demands of a MethodDefinition; the deregisterVeryGenericDatareleaseVeryGenericData rename is on a method this PR itself introduced, so it is not a rename of existing code under the scope-discipline rule; the two added comments say why rather than what and are one sentence each; the unlisten arrow is a call argument, allowed by explicit-function-return-type's allowExpressions: true; no dependency added, package.json untouched, useStorage comes from the already-installed vueuse 9.8.1; no comment deleted or reworded on unchanged code; nothing added is left without a call site in this PR)

4. Security — ✅ (all nine sub-checks run over the nine changed files: no encoded blob or binary-like string, no hidden or bidirectional Unicode, no network call, nothing under scripts/, .github/ or src/electron/, no new package, no eval/Function()/v-html, no secret or environment variable; the wildcard-origin postMessage at IFrame.vue:659 is a context line in the hunk, unchanged by this PR, and the :657 dedupe narrows rather than widens what a page posting message events can make Cockpit register; pr.json, pr.diff, incremental.diff, new-comments.json, complexity-report.json, resolutions.json and decisions.json contain no text addressed to the reviewer)

5. Performance — ✅ (every registration the diff adds or touches has a matching teardown, checked per call site under invariant 1 rather than per file; apiListenerIds is <script setup> state, so per widget instance and released with it, and the :657 early return bounds it by the number of distinct variables the page asks for; the rename watcher does two Map operations and one filter over a short array per keystroke in the name field, off any hot path; no canvas work, no added work on dataLake:notifyListeners or any MAVLink path)

6. UI / UX — ✅ (unchanged this round; the v-switch repeats the established pattern — color="white", base-color="#FFFFFF33", class="mt-2 -mb-2 ml-3" — used six times in ConfigurationMissionView.vue:10-61; the #info slot it fills exists on ExpansiblePanel.vue:51,81; the panel title and switch label are sentence case, the info text names no protocol or internal id, the no-bottom-divider move at ConfigurationUIView.vue:122 is forced by the new panel becoming the last one, and setUnmountHiddenViews logs one past-tense logUserAction entry per toggle from the @update:model-value handler rather than a watcher)

7. Code Quality & Style — ✅ (the complexity report for this head — 85ce744, matching HEAD_SHA — states 373 functions measured across 9 changed files with triggered empty, triggeredCount 0 and truncated false, so by its account no function's cyclomatic complexity or nesting was pushed past the 12/4 thresholds; no any, no new scoped CSS, no rule in .eslintrc.cjs that the added lines plausibly trip, the counting logic sits in src/libs/ rather than in a component, and the largest recipient — src/libs/sensors-logging.ts — gains 26 lines)

8. Commit Hygiene — ✅ (the five commits in pr.json are one logical change each, four fixes then the feature, prefixes matching their content — including the fix: widgets: area form the history already uses — no #N or closing keyword in any message, no wip/fixup! noise, none oversized; this round's gauge change was amended into ed77d77 rather than appended as a follow-up commit, so nothing in the history reverts or reimplements anything before it, and the #3036 reference lives in the PR body where it belongs)

9. Tests — ✅ (the five selectViewsToShow cases in src/tests/types/widgets.test.ts are unchanged this round, none removed or weakened; the view() factory still supplies all six required fields of View (src/types/widgets.ts:823-849) and the clamp is pinned on both branches; no new test is asked of the retain/release pair, per this section's own rule)

10. Documentation — ✅ (nothing in the changed code touches window.electronAPI, isElectron() or any Electron-only module — SnapshotTool.vue's existing isElectron use is untouched, and IFrame.vue's changes are window/data-lake only — so the setting and the four teardown fixes behave identically in Lite and Standalone and the README parity table needs no entry)

11. Nitpicks / Optional — ✅ (nothing left; retainedLogName being a ref nothing in the template reads matches lastWidgetName two lines above it, so it is the file's own form rather than a taste point, and round 3's naming point was answered by the rename to releaseVeryGenericData)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl rafaellehmkuhl removed their assignment Sep 10, 2026
The capture intervals lived only in the start/stop watcher, so deleting
the widget or leaving its view left them firing with no way to stop.
A VeryGenericIndicator left its last reading in every later log point
after it was removed. Keep a retain count so two gauges that share a
name stay in the log until the last one is gone.
The window resize handler was never removed, so each discarded custom
widget left a listener and its setup scope behind.
The external API answers a subscription message by keeping only the callback, so
every unmount left one listener per subscribed variable running.
@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-3036-unmount-hidden-views branch from 85ce744 to 7e6cb39 Compare September 11, 2026 19:11

@ArturoManzoli ArturoManzoli left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some UI fixes needed and one UX suggestion:

(1) Reduce the top margin in 10px. It should be 50px in total, as the other content inside collapsible panels;

(2) Add a 'Options' menu item to the View selector mini widget with one of these two behaviors:
*1- A regular config modal that has the same 'Unload hidden views' switch;
*2- A shortcut to the 'Interface configuration menu page', that will allow the user to find and change that option. (My suggestion is *1).
This is a very useful feature and its hidden inside the Main menu -> Settings -> Interface -> Performance. I think many people would not be able to find it.

Image

@ArturoManzoli

ArturoManzoli commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Two more things, all when 'unload views mode' is enabled': :

1- When switching views, it most of the times quickly switches back to last view. Probably a settings sync issue

2- Is the map loading normally to you?
Here, when I get back from 'video view' or 'HUD view' to Map View, it loads like this:
image

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

Two more things:

1- When switching from 'video view' to 'map view', it most of the times quickly switches back to video.

2- Is the map loading normally to you? Here, when I get back from 'video view' or 'HUD view' to Map View, it loads like this:

This is a problem that happens to me from time to time, not specifically from this PR.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-3036-unmount-hidden-views branch from 7e6cb39 to 25a742c Compare September 11, 2026 19:48
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 4

Done

  • src/views/ConfigurationUIView.vue (1 — Performance panel top margin): dropped the extra mt-2 on the switch so the content inset matches the other collapsible panels in this page.
  • src/components/mini-widgets/ViewSelector.vue / src/types/widgets.ts (2 — Options on the view selector): right-click Options (and the edit-mode cog) opens a config modal with the same Unload hidden views switch, bound to the same machine-local setting.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 5)

6 open findings — 1 major (5.4), 4 minor and 1 nit — and 8 closed, none of them this round.

Cockpit keeps every visible view of a profile built and running at once, stacking the one you are looking at on top. This PR adds an opt-in switch, stored only on the computer you set it on and off by default, that builds only the view on screen; leaving a view then destroys everything on it and coming back builds it again. Because that turns every view switch into a teardown, the branch also fixes four things that were never released on unmount. This round the switch gained a second home: right-clicking the view selector now offers the same toggle.

Three of the findings below are not about code this push added. They are consequences of the option that were reachable at the previous round and that my enumeration then missed; the re-review is of the pull request, not of the last push.

What still needs attention

# Problem What it means Severity Status
5.4 Which view is loaded follows a setting the vehicle can overwrite With the option on, a stray settings sync can throw you back to the view you just left — and now it destroys and rebuilds both views instead of just re-stacking them, so the video reconnects and the map reloads for nothing. major
2.1 Leaving the map now writes to the vehicle every time Every glance away from the map view saves your map position onto the vehicle, where it becomes the position every other operator's map opens at. minor
5.5 The battery indicator's cleanup never runs A battery indicator placed on a view leaves a timer running forever each time you switch away from that view, so the app slowly accumulates dead work — the opposite of what this option is for. minor
6.2 The new pop-up indents its contents twice The switch and its explanation sit further in from the edge than the pop-up's own title, so the small window looks lopsided. minor
7.1 The same switch is written out twice The toggle and its explanation now exist in two files, so a later wording or behaviour change will be made in one and forgotten in the other. minor
11.1 The description names only one place to find the setting Anyone reading the pull request will not learn that the setting is also reachable from the view selector. nit
Since round 4 — 0 closed, comparing 85ce74425a742c

Range. incremental.diff is not usable this round and I did not use it. The branch was rewritten — every commit in pr.json carries a fresh commit date and 85ce744 is no longer reachable — and the file that came back lists 56 files, among them src/components/widgets/MiniMap.vue, src/components/BaseStationGnssSetupDialog.vue and src/libs/sensors/gnss.ts, none of which are among this PR's 10 files. That is a rebase onto a newer master showing through the compare, not the author's edits. Every status and every finding below was judged against pr.diff and against the PR's own base, 72c3cc2 (the revision complexity-report.json names as base); where a finding rests on a file the PR does not touch I re-read that file at 72c3cc2 rather than at the checkout's master tip.

What the push changed. Reading pr.diff against the previous round, three edits: the mt-2 dropped from the switch in src/views/ConfigurationUIView.vue:467; src/types/widgets.ts:404 flipping ViewSelector to configurable; and src/components/mini-widgets/ViewSelector.vue gaining a miniWidget prop and a config dialog carrying the same toggle (+50/−1).

Status changes. None. All eight carried findings were addressed at round 4 and stay closed; nothing this push touched reopens any of them.

Findings that are not new code. 5.4, 5.5 and 2.1 all describe the option's consequences rather than this push. 5.4 and 5.5 were reachable at round 4 and my enumeration then missed them — round 4 checked whether each widget file has a teardown hook and whether the four fixed sites were complete; it did not check whether the hooks that already existed do anything, nor what the mount set follows. 2.1 lives in src/components/widgets/Map.vue at the PR's base; I verified the code is identical at 72c3cc2, but because the branch has been rebased I cannot say whether it was reachable at round 4 and I am not claiming it was.

Resolutions and decisions. resolutions.json and decisions.json are both empty: no /resolve has been issued on this PR and no dispute has ever been put to a vote. Nothing to apply, and no id to report back as unmatched.

Discussion since round 4.

  • ArturoManzoli reported two things, both with the option on (comment): that switching views "most of the times quickly switches back to last view. Probably a settings sync issue", and that the map comes back rendered wrong after a trip to the video or HUD view, with a screenshot. rafaellehmkuhl answered that this "is a problem that happens to me from time to time, not specifically from this PR" (comment), and the push that followed addressed neither. I treated both as claims and checked them.
  • On the first: the mechanism is real and I found it. currentViewIndex is vehicle-synced (src/stores/widgetManager.ts:63), local writes to a synced ref are held for 3 s before being pushed (src/composables/settingsSyncer.ts:11, :107), and the listener at :130 will write a value it receives in that window straight back into the ref. So for three seconds after you pick a view, any settings-manager notification for that key replays the old index and the view flips back. The author is right that this is not caused by this PR — but it is now the input to what gets mounted, which is finding 5.4. His comment answers the cause; it does not answer the cost, so I have raised 5.4 as open rather than disputed. If he means it is also not worth fixing here, that is a different argument and he should say so.
  • On the second I found nothing, and I am reporting that rather than inventing a cause. Leaflet teardown on unmount is complete (Map.vue:1131 calls map.remove()); the container the map initialises into already has its final geometry at mount (.widget-view is position: absolute at 100%/100% in src/views/WidgetsView.vue:97-106, and WidgetHugger.vue's only transition is on opacity, so there is no size animation racing the leaflet init); and the center/zoom round-trip through missionStore.userLastMapZoom/userLastMapCenter (Map.vue:353-354, :1112) restores the same values it saved. Nothing in the diff or in its blast radius explains the screenshot. I cannot run the head code, so this needs a human repro before merge — the author's own test plan expects "the map reloads" on this path, and whether it reloads correctly is exactly what is in question.
  • rafaellehmkuhl's follow-up (comment) lists two items numbered 1 and 2 that do not correspond to anything in the visible thread; ArturoManzoli's "Two more things" implies an earlier list I do not have. I checked both claims in it against the diff and both hold: the mt-2 is gone from ConfigurationUIView.vue:467, and the Options dialog exists and is bound to the same machine-local key. I am not grading the view-selector dialog as unrequested scope, since the evidence is that it was asked for.
Change map — what was established before judging

Claims.

  • Four teardowns "already wrong on delete"verified, each in the diff: stopTimedSnapshotIntervals plus an onBeforeUnmount at src/components/mini-widgets/SnapshotTool.vue:407,477; retain/release around the gauge log name at src/components/mini-widgets/VeryGenericIndicator.vue:82,92,108,111 and src/libs/sensors-logging.ts:270,280; the resize listener at src/components/widgets/CollapsibleContainer.vue:211; the iframe listener ids at src/components/widgets/IFrame.vue:224,236,244.
  • "A machine-local setting, off by default"verified. useStorage('cockpit-unmount-hidden-views', false) at src/stores/widgetManager.ts:65 is vueuse-over-localStorage, not useBlueOsStorage, so it does not reach the vehicle; the cockpit- prefix on a useStorage key matches src/stores/mission.ts:54 and src/stores/mainVehicle.ts:90.
  • "Mounts only the current view"verified at src/types/widgets.ts:419-430, including the visible check on the current view and the index clamp.
  • "Toggle: Settings → Interface → Performance"incomplete. There is now a second toggle, in the view selector's Options dialog (src/components/mini-widgets/ViewSelector.vue:144-152), which the body does not mention. Finding 11.1.
  • "yarn lint and yarn test:unit clean"not verifiable here; I do not execute head code. complexity-report.json (head 25a742c, base 72c3cc2) reports 375 functions measured across 10 changed files, triggered: 0, not truncated.

Failure site. For the four fix: commits the misbehaving code is in the diff, at the lines above. For finding 5.5 it is not: the no-op teardown lives at src/components/mini-widgets/BatteryIndicator.vue:364-367, which this PR does not touch, and the smaller fix belongs there. For 5.4 the replay that moves the index lives at src/composables/settingsSyncer.ts:109-144, also outside the diff; what is inside the diff is the decision to mount off that value.

Entry points.

Function Reached from Frequency
stopTimedSnapshotIntervals (SnapshotTool.vue:407) watch(isTakingTimedSnapshot) and the new onBeforeUnmount per user action
onBeforeUnmount (SnapshotTool.vue:477) Vue unmount — widget delete, profile change, or a view switch with the option on per user action
watch(options.displayName) (VeryGenericIndicator.vue:92) renaming the gauge in its config per user action
onMounted / onBeforeUnmount (VeryGenericIndicator.vue:108,111) Vue mount and unmount one-shot per instance / per user action
DataLogger.retainVeryGenericData (sensors-logging.ts:270) those two hooks only per user action
DataLogger.releaseVeryGenericData (sensors-logging.ts:280) those two hooks and the rename watcher per user action
onBeforeUnmount (CollapsibleContainer.vue:211) Vue unmount per user action
apiEventCallback (IFrame.vue:226) window message listener from embedded content per incoming message
onBeforeUnmount (IFrame.vue:242) Vue unmount per user action
selectViewsToShow (types/widgets.ts:419) the viewsToShow computed (widgetManager.ts:308) → WidgetsView.vue:3 and the bottom bars at App.vue:60 per user action, and on any write to the synced cockpit-current-view-index-v1
setUnmountHiddenViews (ConfigurationUIView.vue:211) the settings switch @update:model-value per user action
setUnmountHiddenViews (ViewSelector.vue:187) the new dialog's switch @update:model-value per user action
onSelectView (ViewSelector.vue:182) the view dropdown per user action

The last row of selectViewsToShow is the multiplier the diff does not show and the reason for 5.4: with the option on, that computed decides what exists, and one of its inputs is a value the vehicle can move.

Invariants.

  • A widget releases on unmount everything it acquired. This is the PR's premise, and the four fixes are four sites of it. I enumerated the other side rather than trusting the count: all 12 files under src/components/widgets/ and src/components/mini-widgets/ that register a teardown hook, read by body. Eleven release cleanly. BatteryIndicator.vue:364-367 has a hook whose guard is inverted, so it releases nothing — finding 5.5. The invariant is closed at N producers with nothing enforcing it, which is why a twelfth site was there to find.
  • The converse, which the PR does not establish: unmount must not itself do expensive or externally-visible work, because unmount is now per view switch rather than per widget deletion. Same enumeration: every other teardown body is a pure release (clearInterval, removeEventListener, unlisten*, unregisterStreamConsumer). Map.vue:1112 is the one that writes — finding 2.1.
  • With the option on, exactly one view is mounted and it is the one the user chose. This needs currentViewIndex to be locally authoritative. It is not: it is vehicle-synced and its local writes are replayable for 3 s. Finding 5.4.
2. Persistence & User Data — inventory, 1 finding

Inventory.

Key Backend What happened
cockpit-unmount-hidden-views machine-local — vueuse useStorage over localStorage (src/stores/widgetManager.ts:65) added; boolean, default false
cockpit-current-view-index-v1 vehicle-synced — useBlueOsStorage (src/stores/widgetManager.ts:63) not modified; the PR gives it a new role — it now decides what is mounted, not only what is on top (see 5.4)
cockpit-user-last-map-center vehicle-synced — useBlueOsStorage (src/stores/mission.ts:66) not modified; written far more often (see 2.1)
cockpit-user-last-map-zoom vehicle-synced — useBlueOsStorage (src/stores/mission.ts:67) not modified; written far more often (see 2.1)

Judging the added key: machine-local is the right backend — whether a topside computer is slow enough to need this is a property of that computer, and the PR's own test plan checks the value does not appear on a second topside. The cockpit- prefix is present, as AGENTS.md requires, and useStorage with such a prefix is the established form for machine-local values in this tree (mission.ts:54, mainVehicle.ts:90, useBaseStation.ts:35). The stored shape is a bare boolean that does not repeat its key. No migration, no default change stranding anyone: existing users get false, which is today's behaviour. Nothing here needs a cockpit-foo-v2.

2.1 minorLeaving the Map view now pushes the map position to the vehicle on every view switch.

src/components/widgets/Map.vue:1110-1112 persists the live map view from onBeforeUnmount, into missionStore.saveLastMapPosition (src/stores/mission.ts:531-534), which writes userLastMapCenter and userLastMapZoom — both useBlueOsStorage, so both reach the vehicle and every other operator of it. The comment on that line explains the intent it was written for: a leave that used to mean the widget was going away, or Mission Planning taking over.

With unmountHiddenViews on, that hook fires every time the user switches off the Map view. Two vehicle-synced keys are written per switch, each debounced 3 s and then pushed (src/composables/settingsSyncer.ts:107-125). The values are also the ones another operator's map opens at, so the position churns for everyone on that vehicle at the rate one person flips between views.

Consequence: every glance away from the map view saves your map position onto the vehicle, where it becomes the position every other operator's map opens at.

Remedy: guard the write on the values having actually moved — keep the last persisted center/zoom and skip saveLastMapPosition when neither changed, which costs a couple of lines at the call site and removes the churn for the common case of switching away and back without touching the map. Alternatively, state in the PR body that the extra churn is accepted; what should not happen is it arriving unnoticed with a performance option.

5. Performance — 2 findings

5.4 majorWith the option on, what is mounted follows a vehicle-synced value, so a sync bounce destroys and rebuilds views instead of re-stacking them.

selectViewsToShow(currentProfile.value.views, currentViewIndex.value, unmountHiddenViews.value) at src/stores/widgetManager.ts:308-310 reads currentViewIndex, which is useBlueOsStorage<number>('cockpit-current-view-index-v1', 0) (:63) — vehicle-synced. Two properties of that syncer matter:

  • a local write is held in a setTimeout for defaultSettingsSyncDebounceMs, 3000 ms (src/composables/settingsSyncer.ts:11, :107-125), before it reaches the settings manager at all; and
  • the listener at :130-144 writes any value the settings manager notifies for that key straight into the ref, and the settings manager notifies per changed key from notifyAllListenersAboutSettingsChange (src/libs/settings-management.ts:393-411, reached on the user/vehicle switch at :1291-1297, i.e. on vehicle connect) and from handleStorageChanging (:1313-1329, a storage event from another window or an external write).

So for three seconds after the user picks a view, the settings manager still holds the old index, and any notification in that window replays it over the user's choice. That is the mechanism behind ArturoManzoli's report, and the author is right that it predates this PR.

What this PR changes is what the bounce costs. With the option off, a bounce reorders viewsToShow and nothing unmounts. With it on, viewsToShow goes from [chosen] to [previous] and back: both views are torn down and rebuilt in under three seconds — the video stream reconnects (VideoPlayer.vue:316-320), the leaflet instance is destroyed and recreated (Map.vue:768, :1131), every iframe reloads, and per 2.1 two vehicle-synced keys are written. None of it was requested by the user, none of it is attributable to anything they did, and none of it can be stopped, which is the shape section 5 escalates.

Consequence: with the option on, a stray settings sync can throw you back to the view you just left, and now it destroys and rebuilds both views instead of just re-stacking them, so the video reconnects and the map reloads for nothing.

Remedy, at the chokepoint rather than at N producers — viewsToShow is one computed:

  • In widgetManager.ts, do not let the mount set shrink on a transient index. Keep the view being left mounted until the index has held still for longer than the syncer's write debounce, so a bounce costs a re-stack exactly as it does today and only a settled switch unmounts anything. Selection stays on the synced key; only mounting waits for it to settle.
  • The real site, which fixes it for every synced key and not just this one, is src/composables/settingsSyncer.ts:109-144: the listener accepts a notification for a key whose local write is still pending in watchUpdaterTimeout. Flushing the pending write before accepting such a notification — or ignoring one whose value equals the value that pending write is replacing — closes the replay window. That is a change to shared code and larger than this PR; naming it is not a request to do it here, but the local remedy above should not be mistaken for a fix of the underlying race.

5.5 minorBatteryIndicator's unmount guard is inverted, so its toggle interval survives every unmount — including the ones this PR adds.

src/components/mini-widgets/BatteryIndicator.vue:364-367 (identical at the PR's base 72c3cc2):

onUnmounted(() => {
  if (toggleIntervaler.value !== undefined) return
  clearInterval(toggleIntervaler.value)
})

The guard returns precisely when there is an interval to clear, so clearInterval only ever runs on the handle that is already undefined. The interval is set at :338-340 whenever both showCurrent and showPower are enabled, firing at options.toggleInterval (floor 500 ms, :248) to flip showCurrent. It is never cleared, so it keeps running against a destroyed component and holds that component's whole setup scope alive with it; errorMessageTimeout (:345) is never cleared either.

This is the fifth site of the invariant the PR's four fix: commits close, and it is the one an enumeration by "does this file have a teardown hook" cannot see, because the hook is there. The default profiles put the battery indicator in the global top bar (src/assets/defaults.ts:933-938), which does not unmount on a view switch, so the exposure is a user who has placed one in a view's own bar — at which point, with the option on, every switch away from that view leaks one permanent timer.

Consequence: a battery indicator placed on a view leaves a timer running forever each time you switch away from that view, so the app slowly accumulates dead work — the opposite of what this option is for.

Remedy: at BatteryIndicator.vue:364-367, drop the guard and clear both handles unconditionally (clearInterval and clearTimeout on undefined are no-ops), matching the four fixes already in this branch. It is a one-line change in a file the PR does not otherwise touch; as a fifth fix: commit it sits naturally alongside the other four.

6. UI / UX — 1 finding

6.2 minorThe new config dialog indents its contents past its own title.

src/components/mini-widgets/ViewSelector.vue:128-158. The anatomy is right and follows in-tree precedent closely: centred v-card-title, the close X as a v-btn icon with aria-label="Close" at absolute top-2 right-2 with equal insets, one glass layer on the card surface, no divider under the header and no footer to need a separator. It is the same shell as ModeSelector.vue:12-31, DepthIndicator.vue:16-31 and BatteryIndicator.vue:73-88, down to the class strings.

What differs from those three is the padding. The card owns pa-4 and v-card-text adds its own horizontal inset, and then both children add more on top: class="mt-2 ml-3" on the switch (:150) and class="text-sm opacity-80 mt-2 ml-3" on the paragraph (:153). The three precedent dialogs put no lateral margin on their content for exactly this reason (ModeSelector.vue:28, DepthIndicator.vue:32, BatteryIndicator.vue:89); ml-3 is the settings-page idiom, where it pairs with the px-4 pt-5 wrapper that ConfigurationUIView.vue:460 has and this dialog does not. The result is content sitting further in than the centred title above it, in a 500 px-wide window where that reads immediately.

This is the same class of thing the author fixed one round ago on the sibling instance of this switch, by dropping the mt-2 at ConfigurationUIView.vue:467.

Consequence: the switch and its explanation sit further in from the edge than the pop-up's own title, so the small window looks lopsided.

Remedy: drop mt-2 ml-3 from both children and let the card's pa-4 own the inset, as the three neighbouring dialogs do; if the switch and paragraph need separating, class="flex flex-col gap-y-4" on the v-card-text is the in-tree form (DepthIndicator.vue:19) rather than per-child margins.

7. Code Quality & Style — 1 finding

7.1 minorThe setting's handler and its explanation are now written out twice.

setUnmountHiddenViews is byte-identical in two files — src/views/ConfigurationUIView.vue:211-215 and src/components/mini-widgets/ViewSelector.vue:187-191 — down to the logUserAction string, so the log cannot distinguish which surface the user toggled it from. The user-facing explanation is duplicated too, and has already diverged: "When enabled, only the view you are looking at stays loaded. Widgets on other views start again when you switch to them. Turn this on if this computer slows down with several views." at ConfigurationUIView.vue:455-456 against "Only the view you are looking at stays loaded. Widgets on other views start again when you switch to them." at ViewSelector.vue:154. Two copies of a setting's copy and its handler, added in the same push, are two places a later wording or behaviour change has to be made — and the second is the one that gets forgotten.

AGENTS.md is explicit on this: "when the same logic genuinely lives (or would live) in two or more places, you extract a shared abstraction rather than duplicating it."

Consequence: the toggle and its explanation now exist in two files, so a later wording or behaviour change will be made in one and forgotten in the other.

Remedy: one source for both. Move the setter next to the ref it writes, as an action on the widget-manager store (src/stores/widgetManager.ts, alongside unmountHiddenViews), and have both surfaces call it; keep the description in one exported string, or render the switch plus its copy from a single small component that both the panel and the dialog mount. Either removes the duplicate handler and the second copy of the text at once, without adding an abstraction beyond the two real call sites.

11. Nitpicks / Optional — 1 finding

11.1 nitThe PR body names one place to find the setting; there are two.

The body says "Toggle: Settings → Interface → Performance". Since this push the same toggle is also in the view selector's Options dialog (src/components/mini-widgets/ViewSelector.vue:144-152, reachable via right-click Options through MiniWidgetInstantiator.vue:86-88 and the edit-mode cog, enabled by src/types/widgets.ts:404). The test plan reaches it only through Settings, so the second entry point is untested by the plan as written.

Consequence: anyone reading the pull request will not learn that the setting is also reachable from the view selector.

Remedy: name the second toggle in that bullet and add a test-plan line for it.

Sections with nothing to report (6)

1. Correctness & Implementation Bugs — ✅ (traced both new switch handlers: :model-value plus @update:model-value rather than v-model, so logUserAction fires on the click and not on a settings-sync write, and Boolean(null) from v-switch lands as false; ViewSelector is instantiated only through MiniWidgetInstantiator.vue:3 from the four default profiles, so its new required miniWidget prop is always supplied, and the dialog is keyed per instance by hash, so several view selectors in one profile do not share dialog state)

3. AGENTS.md Adherence — ✅ (useStorage with a cockpit- prefix for a machine-local value matches mission.ts:54, mainVehicle.ts:90 and useBaseStation.ts:35; no dependency added, so no package.json ordering to check; selectViewsToShow and both new DataLogger methods carry non-empty typed JSDoc with @param/@returns; nothing is exported without a call site in this PR; no comment reworded over unchanged code)

4. Security — ✅ (ran all nine sub-checks across the 10 changed files: no new dependency, no fetch/XHR/websocket, no encoded blob or binary-like constant, no hidden Unicode or homoglyph in the added identifiers, no v-html/eval/Function, no env var or credential, and nothing under .github/, scripts/ or src/electron/; the one listener the diff registers is removed at IFrame.vue:244)

8. Commit Hygiene — ✅ (read the five commits from pr.json: four fix: teardowns then the feat:, each fix: genuinely a fix and standing alone, scope prefixes matching git log on master, no issue or PR reference in any message, no wip/fixup!/"address review" noise, and no commit reverting or reimplementing another — the split made for 8.1 survived the rebase, and this round's edits were folded into the feature commit they belong to)

9. Tests — ✅ (src/tests/types/widgets.test.ts adds five cases for selectViewsToShow covering both modes, a hidden non-current view, a hidden current view, and an out-of-range index in both modes; no existing test removed, weakened or made order-dependent)

10. Documentation — ✅ (the setting behaves identically in Lite and Standalone — no isElectron() branch or Electron API anywhere in the diff — so the README's Lite-vs-Standalone notes need nothing; in-code JSDoc covered under section 3, and the PR-body gap is 11.1)

Generated by Claude. This is advisory; a human reviewer must still approve.

Hidden-but-enabled views stay mounted and keep maps, video, and iframes
running, which hurts constrained machines. Add a machine-local setting,
off by default, that mounts only the current view.
The unmount guard returned when an interval existed, so the toggle
interval and error timeout were never cleared.
A local change is held for three seconds before it reaches the settings
manager, so a notification in that window used to replay the old value
and bounce the UI.
@rafaellehmkuhl
rafaellehmkuhl force-pushed the issue-3036-unmount-hidden-views branch from 25a742c to 03a687b Compare September 11, 2026 20:14
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 5

Done

  • src/components/mini-widgets/BatteryIndicator.vue (5.5 — inverted unmount guard): the guard no longer returns when an interval exists; both the toggle interval and the error timeout are cleared.
  • src/components/mini-widgets/ViewSelector.vue (6.2 — double indent): dropped mt-2 ml-3; v-card-text uses flex flex-col gap-y-4 like the neighbouring config dialogs.
  • PR body (11.1 — toggle location): the summary names the view-selector Options dialog, and the test plan covers it.

Done differently

  • src/composables/settingsSyncer.ts (5.4 — sync bounce unmounts both views): ignored notifications of the value a pending local write is replacing, instead of keeping the previous view mounted for 3 s. That workaround would load two views on every switch. This is also Arturo's "switches back to last view".
  • src/stores/widgetManager.ts (7.1 — duplicated switch): setUnmountHiddenViews now lives next to the ref and both surfaces call it. The copy stays per-surface — the settings page already has the longer text in the panel info slot; the dialog keeps the short line.

Won't change (with reasoning)

  • 2.1 — map position written on every leave: persistLiveMapView saves the live center/zoom so Mission Planning can mount with it. useBlueOsStorage already skips a write whose serialization matches the last one, so a glance away without panning does not push to the vehicle. If the map did move, saving on leave is the intended path. Pre-existing, and this PR does not touch Map.vue.
  • Arturo — map renders wrong after a trip to video/HUD: already answered as something that happens off this branch too. Leaflet teardown looks complete and nothing in this diff explains the screenshot.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 6)

3 open — 1 major (5.6) and 2 minor, both disputed — and 12 closed, 4 of them this round.

The PR adds an opt-in setting that keeps only the view you are currently looking at loaded, and unloads the widgets on every other view until you switch back to it. Because that turns a view switch into a full teardown and rebuild of everything on the view you left, the rest of the PR fixes widgets whose cleanup was never exercised before: timed snapshots that kept firing, a resize listener and a set of data-lake subscriptions that were never released, gauges that stayed in the telemetry log, and — added this round — a battery indicator whose timers survived. Also added this round is a guard in the settings-sync layer so a sync echo arriving while the user's own choice is still being written no longer bounces them back to the view they just left.

What still needs attention

# Problem What it means Severity Status
5.6 The "recording in progress" close warning belongs to a widget With the new option on, a video recording can be silently lost when you close the app, or the app can refuse to close claiming a recording is running when none is. major
2.1 Leaving the map writes to the vehicle every time Every glance away from the map view saves your map position onto the vehicle, where it becomes the position every other operator's map opens at. minor 💬
7.1 The setting's explanation is written out twice The wording shown to the user lives in two files, so a later change will be made in one and forgotten in the other. minor 💬
Since round 5 — 4 closed, comparing 25a742c03a687b

The range. incremental.diff for this round is not a 25a742c…03a687b delta and I did not judge anything from it. The feature commit was amended (e0772d0 now carries a commit date of 20:14:34Z, three hours after the four fix: commits below it), so 25a742c is unreachable and the compare fell back to the merge-base 7b9b494 — the file's seven entries have +/- counts identical to the PR-level totals in pr.json, i.e. it re-includes the whole feature commit. Every status below is therefore judged against pr.diff and the base checkout, using round 5's own quoted lines as the before state.

No maintainer settlements. resolutions.json is [] and decisions.json is []: no /resolve has been issued on this PR and no dispute has been put to a vote, so there are no ids to apply and none that failed to match the ledger.

✅ 5.4 — Addressed (major). The remedy landed at the shared site rather than in widgetManager.ts, which is the second of the two options round 5 named. src/composables/settingsSyncer.ts:141-144 adds, after the existing isEqual(newValue, refedValue.value) early return:

// A pending local write has not reached the store yet; applying its old value would bounce the UI back.
if (firstPendingChangeEpoch !== undefined && isEqual(newValue, oldRefedValue)) {
  return
}

I checked this closes the window rather than narrowing it. oldRefedValue is captured in the watch before the write (settingsSyncer.ts:86-94) and holds the pre-write value for exactly as long as the write is pending; firstPendingChangeEpoch is set when the watch fires (:100-102) and cleared inside the debounced write (:109-125). So during the 3 s window the listener now rejects precisely the notification that carries the value the user just replaced — which is the replayed stale index — while still accepting a genuinely different remote value. A real remote index change does still tear down and rebuild both views with the option on, but that is a deliberate switch and not the bounce this finding was about.

✅ 5.5 — Addressed (minor). src/components/mini-widgets/BatteryIndicator.vue:362-367 drops the inverted guard and clears both handles, including the errorMessageTimeout the finding named:

 onUnmounted(() => {
-  if (toggleIntervaler.value !== undefined) return
   clearInterval(toggleIntervaler.value)
+  clearTimeout(errorMessageTimeout.value)
 })

Landed as its own commit (53a38ef fix: widgets: clear battery indicator timers on unmount), matching the shape of the other four.

✅ 6.2 — Addressed (minor). ViewSelector.vue:14 is now <v-card-text class="flex flex-col gap-y-4"> with no mt-2 ml-3 on either child (:27-34, :35-37), which is the DepthIndicator.vue:19 form the finding pointed at. The dialog is now structurally identical to DepthIndicator.vue:16-31.

✅ 11.1 — Addressed (nit). The PR body now names both locations ("Settings → Interface → Performance, or right-click the view selector → Options") and adds test-plan lines for them.

💬 2.1 — Disputed (minor, was :x:). The author's position is "won't change", on the grounds that useBlueOsStorage skips a write whose serialization matches the last one. Half of that checks out and half does not, so the finding stays open with its severity unchanged — details in section 2, including a correction to my own round-5 wording.

💬 7.1 — Disputed (minor, was :x:). Half the finding is fixed in code: setUnmountHiddenViews now exists once, as a store action at src/stores/widgetManager.ts:312-320, and both surfaces call it. The duplicated user-facing copy is declined on the stated grounds that the two surfaces want different lengths of text. Reprinted in section 7 against what remains.

❌ 5.6 — New (major). Raised this round from the same invariant enumeration as 5.1-5.5, on a site none of the earlier rounds reached: MiniVideoRecorder.vue owns window.onbeforeunload. Full text in section 5.

Discussion. rafaellehmkuhl's round-5 follow-up (#issuecomment-5640119976) is the only substantive comment; I verified each of its claims against the code rather than taking them as the state of the branch, and the two "won't change"/"done differently" items are what the :speech_balloon: rows above record. His note that ArturoManzoli's map-render report predates this PR is consistent with what round 5 already said; 5.4 was never about who introduced the race, only about what the new option makes it cost, and the guard at settingsSyncer.ts:141-144 answers that either way. The /review command itself carried no content.

Change map — what was established before judging

Claims. The PR body asserts (a) that several views mounted at once slow down weak topside computers, (b) that the cause is every view's widgets running whether or not they are visible, and (c) that the mechanism is an opt-in setting narrowing the mount set to the current view. (a) is a premise I cannot verify from code and take as stated. (b) is verified: viewsToShow at src/stores/widgetManager.ts:308-310 previously returned every view, reordered, and the container renders all of them. (c) is verified: unmountHiddenViews is useStorage('cockpit-unmount-hidden-views', false) (widgetManager.ts:65) and feeds selectViewsToShow (src/types/widgets.ts:1018-1037). The body also claims the four fix: commits are prerequisites; verified — each closes a teardown that only this option makes reachable per view switch.

Failure site. For the four prerequisite fixes and for 5.5, the misbehaving code is in the widgets themselves, and the PR does fix it there (SnapshotTool.vue, IFrame.vue, CollapsibleContainer.vue, VeryGenericIndicator.vue + sensors-logging.ts, BatteryIndicator.vue). For the bounce 5.4 described, the failure site is src/composables/settingsSyncer.ts:130-144, and it is now in the diff. For 5.6 the failure site is src/components/mini-widgets/MiniVideoRecorder.vue:469-489 and it is not in the diff.

Entry points.

Function Reached from Frequency
selectViewsToShow (types/widgets.ts:1018) viewsToShow computed (widgetManager.ts:308) → view container render per user action (view switch), plus once per settings-sync notification for the view index
setUnmountHiddenViews (widgetManager.ts:312) the switch in ConfigurationUIView.vue:161-169 and in ViewSelector.vue:27-34 per user action
retainVeryGenericIndicatorVariable / releaseVeryGenericIndicatorVariable (libs/sensors-logging.ts) VeryGenericIndicator.vue onBeforeMount / onUnmounted per user action (now once per view switch, per gauge)
settings-sync listener callback (settingsSyncer.ts:130-144) notifyAllListenersAboutSettingsChange (libs/settings-management.ts:393-411) and handleStorageChanging (:1313-1329) per incoming message (vehicle connect, cross-window write)
onUnmounted bodies in SnapshotTool.vue, IFrame.vue, CollapsibleContainer.vue, BatteryIndicator.vue Vue teardown of a view's widget tree per user action (per view switch, with the option on)
selectViewsToShow test cases (tests/types/widgets.test.ts) vitest one-shot

Invariants.

  1. Every widget's teardown releases everything its setup acquired. Enumerated across src/components/widgets/, src/components/mini-widgets/ and src/components/custom-widget-elements/. Covered by the PR: the four prerequisite sites plus BatteryIndicator. Verified clean this round: the custom-widget elements all unlisten their data-lake variables; Dial's document listeners are drag-scoped; CollapsibleContainer, IFrame, MiniMap and MissionControlPanel register only transient mouseup/dragend handlers; SnapshotTool's flash overlay self-heals on a setTimeout; VideoPlayer clears its interval. One site is not covered: MiniVideoRecorder.vue assigns window.onbeforeunload and never resets it on teardown — finding 5.6.
  2. The converse: unmount must not do externally-visible work, since unmount is now per view switch rather than per widget deletion. Every other teardown body is a pure release. src/components/widgets/Map.vue:1110-1112 is the one that writes, via missionStore.saveLastMapPosition — finding 2.1.
  3. Nothing transient may decide the mount set. The mount set follows currentViewIndex, which is vehicle-synced (widgetManager.ts:63). The guard added at settingsSyncer.ts:141-144 closes the one transient source round 5 identified (a sync echo replaying the pre-write value); see 5.4 above.
2. Persistence & User Data — inventory, 1 finding (carried from round 5)

Inventory.

Key Backend What happened
cockpit-unmount-hidden-views machine-local — useStorage (src/stores/widgetManager.ts:65) added
cockpit-current-view-index-v1 vehicle-synced — useBlueOsStorage (src/stores/widgetManager.ts:63) not modified; now also decides what is mounted, not only what is on top
cockpit-user-last-map-center vehicle-synced — useBlueOsStorage (src/stores/mission.ts:66) not modified; written on every view switch away from the map (see 2.1)
cockpit-user-last-map-zoom vehicle-synced — useBlueOsStorage (src/stores/mission.ts:67) not modified; write attempted on every such switch, normally deduplicated
per-widget options (VeryGenericIndicator, SnapshotTool, IFrame) vehicle-synced profile storage not reshaped; no Options entries added or removed

Judging the added key: machine-local is the right backend, since whether a topside computer needs this is a property of that computer, and the PR's test plan checks the value does not appear on a second topside. The cockpit- prefix is present as AGENTS.md requires, and useStorage with that prefix is the established machine-local form in this tree (mission.ts:54, mainVehicle.ts:90, useBaseStation.ts:35). The stored shape is a bare boolean that does not repeat its key. No migration, no default change stranding anyone: existing users get false, which is today's behaviour.

2.1 minorLeaving the Map view pushes the map centre to the vehicle on every view switch. (carried from round 5, now disputed)

src/components/widgets/Map.vue:1110-1112 persists the live map view from onBeforeUnmount, into missionStore.saveLastMapPosition (src/stores/mission.ts:519-522), which writes userLastMapCenter and userLastMapZoom — both useBlueOsStorage, so both reach the vehicle and every other operator of it. The comment on that line explains the intent it was written for: a leave that used to mean the widget was going away, or Mission Planning taking over. With unmountHiddenViews on, that hook fires every time the user switches off the Map view.

The author's answer is that useBlueOsStorage skips a write whose serialization matches the last one, so switching away without panning pushes nothing. The first half of that is correct, and I checked it: settingsSyncer.ts:86-94 compares prettyFormat(newValue) against prettyFormat(oldRefedValue) and returns before scheduling anything. My round-5 wording was wrong to say two keys are written per switch, and I withdraw that sentence — the zoom key is an integer that matches its stored value, so it is genuinely deduplicated.

The centre is not, and that is why the finding stays open. persistLiveMapView (src/libs/map/utils-map.ts:312-325, unchanged at this PR's base) does not save the stored ref; it saves what leaflet reports:

const { lat, lng } = map.getCenter()
save(map.getZoom(), [lat, lng])

map.getCenter() is derived from the current pixel origin, so it comes back rounded to the rendered pixel grid and differs from the value that was restored into the map in its low-order decimals. The serialization therefore differs, the dedupe does not fire, and one vehicle-synced key churns per switch away from the map even when the user never touched it. That value is also what every other operator's map opens at.

Consequence: every glance away from the map view saves your map position onto the vehicle, where it becomes the position every other operator's map opens at.

Remedy, unchanged and now a smaller change than it was: in Map.vue, keep the last persisted centre and skip saveLastMapPosition when it has not moved beyond a small epsilon — or compare against missionStore.userLastMapCenter before calling, which is the same two lines at the call site. Either makes the dedupe the author is relying on actually cover this path. Alternatively, state in the PR body that the churn is accepted; what should not happen is it arriving unnoticed with a performance option.

5. Performance — 1 finding

5.6 majorThe "recording in progress" close guard is owned by a widget, so unloading hidden views leaves window.onbeforeunload either stale or absent.

src/components/mini-widgets/MiniVideoRecorder.vue:469-489 installs and removes the app's only guard against closing during a recording, from a component watcher: when the widget's stream stops recording it sets window.onbeforeunload = null (:472), and when it starts it installs a handler returning the "recording will be lost" warning (:485-488). The teardown hook at :454-458 clears the stream-connection interval, the loading timeout and the stream consumer registration — it does not reset onbeforeunload, and the watcher is not immediate, so it fires on transitions only.

That was harmless while the widget's lifetime matched the app's. It no longer does. The default ROV profile places MiniVideoRecorder in view-scoped bars — the Video view's own bar (src/assets/defaults.ts:294-299) and the HUD view's (:637-642) — not in the global containers that start at :897. With the option on, switching views unmounts it. Recording itself keeps going: deactivateStreamIfUnused (src/stores/video.ts:690-698) explicitly refuses to tear down a stream while a mediaRecorder is attached, which is correct and is what makes both halves of this reachable.

  • Stale guard. A recording stops while the widget is unmounted — from stop_recording_all_streams or toggle_recording_all_streams, registered as cockpit actions at video.ts:1580-1591 and bindable to a joystick button or a key, or internally on an error path (video.ts:1150, :1178). No watcher runs, so onbeforeunload keeps returning the warning. Returning to the view does not clear it either: onBeforeMount (:205-217) resolves selectedExternalId synchronously, so isRecording (:370-378) evaluates to its settled value with no transition for the watcher to see. The user is now told a recording will be lost every time they close Cockpit, indefinitely, with nothing recording.
  • Missing guard. The mirror case: a recording is started by those same actions while the widget is unmounted, or is running when the user switches away and closes the app from another view. No handler is installed, so the window closes with no warning and the in-flight recording is lost.

Consequence: with the option on, closing Cockpit can silently discard a video recording that was still being written, or refuse to close claiming a recording is in progress when there is none.

Remedy: the guard is global state about a global fact, so it should not be owned by a widget at all. Move the watcher into src/stores/video.ts, next to the recording state it observes, and have it set and clear window.onbeforeunload there — the store outlives every view, so both transitions are always seen and no widget has to be mounted for the warning to be right. That also deletes the block from MiniVideoRecorder.vue rather than adding to it. Like 5.5, this is outside the diff, so it belongs in a small fix: commit of its own alongside the other five.

7. Code Quality & Style — 1 finding (carried from round 5)

7.1 minorThe setting's user-facing explanation is written out twice. (carried from round 5, partly fixed, now disputed)

The handler half of this is fixed and I confirmed it: setUnmountHiddenViews exists once, as a store action at src/stores/widgetManager.ts:312-320, next to the ref it writes, and both surfaces call it (ViewSelector.vue:27-34 and ConfigurationUIView.vue:161-169 both bind @update:model-value="widgetStore.setUnmountHiddenViews"). That is exactly the first remedy round 5 named, and the logUserAction string now exists once too.

What remains is the copy, which the author declines on the grounds that the panel wants the longer explanation and the dialog the short line. The two strings are still two strings describing one setting, and they already share a sentence verbatim:

  • ConfigurationUIView.vue:154-157 — "When enabled, only the view you are looking at stays loaded. Widgets on other views start again when you switch to them. Turn this on if this computer slows down with several views."
  • ViewSelector.vue:35-37 — "Only the view you are looking at stays loaded. Widgets on other views start again when you switch to them."

AGENTS.md: "when the same logic genuinely lives (or would live) in two or more places, you extract a shared abstraction rather than duplicating it." The severity stays minor — nothing reaches a user wrong today.

Consequence: the wording shown to the user lives in two files, so a later change to what this option does will be made in one and forgotten in the other.

Remedy, sized to the objection rather than to the original finding: export the shared sentence once (next to the store action, or in src/types/widgets.ts beside selectViewsToShow) and have the panel render it followed by its extra "Turn this on if…" line, which is the only part that is genuinely panel-specific. That keeps both lengths and leaves one place to edit.

Complexity, for this section. complexity-report.json for this head reports 393 functions measured across the 12 changed files, untruncated, with no function tripping any threshold — those are the report's figures, produced by the PR's own CI run rather than measured here. No complexity finding this round.

Sections with nothing to report (8)

1. Correctness & Implementation Bugs — ✅ (re-checked the clamp in selectViewsToShow at types/widgets.ts:1018-1037, the retain/release pairing in sensors-logging.ts against the three delete paths at widgetManager.ts:576/:581 and MiniWidgetContainer.vue:159, and the Boolean(value) coercion in the new store action against Vuetify's null switch value)

3. AGENTS.md Adherence — ✅ (the three added functions carry typed @param JSDoc; the absent @returns on the void setUnmountHiddenViews matches selectView at widgetManager.ts:463-467 and .eslintrc.cjs does not require one for void; no dependency added, package.json untouched, and nothing exported without a call site in this PR)

4. Security — ✅ (whole diff scanned for non-ASCII and zero-width characters, encoded blobs, eval/Function/v-html, new network hosts, and changes under .github/, scripts/ or src/electron/; none present, and no input file carried text addressed to the reviewer)

6. UI / UX — ✅ (the ViewSelector dialog now matches DepthIndicator.vue:16-31 clause for clause — centred title, keyboard-reachable v-btn icon close X with equal insets, one glass layer, no divider under the header, no footer — the settings switch keeps the in-tree -mb-2 ml-3 idiom of ConfigurationMissionView, and the toggle is logged through logUserAction in the store action)

8. Commit Hygiene — ✅ (seven commits read off pr.json; each fix: closes exactly one teardown site, the two added this round are separate commits rather than folded into the feature, which is what 8.1 asked for, and there are no issue references, no wip/fixup!, and no commit reverting another)

9. Tests — ✅ (src/tests/types/widgets.test.ts adds cases for selectViewsToShow including the out-of-range index, and removes or weakens no existing assertion)

10. Documentation — ✅ (the option behaves identically in Lite and Standalone, so AGENTS.md's README-parity requirement does not apply, and the PR body now names both places the toggle lives)

11. Nitpicks / Optional — ✅ (11.1 closed this round; nothing further raised)

Generated by Claude. This is advisory; a human reviewer must still approve.

@github-actions

Copy link
Copy Markdown

🙋 Decision needed — 2.1

Leaving the Map view persists the map position to two vehicle-synced keys on every view switch once hidden views are unloaded

The author's argument: The vehicle-synced storage layer already skips a write whose serialized value matches the last one, so switching away from the map without panning does not push anything to the vehicle, and saving the position when the map is left is the intended behaviour.

How to vote on this dispute

React to this comment and the next /review applies the answer:

  • 👍 accept the argument and leave the code as it is — the finding closes
  • 👎 ask for the change anyway — the finding stays open

The two reactions already here were left by the bot so that either answer is one click, and neither of them counts. Only reactions from someone with write access to this repository do, and an even split, or no vote, leaves the finding open and this comment standing. Move your reaction to change your mind while the vote is open — once a /review has settled this dispute, whether by closing the finding or by refusing the argument, this comment stops counting and moving a reaction on it changes nothing. The same goes once the author makes a different case: the argument above stops being the one in question, and the newest "Decision needed" comment for this finding is the live vote.

@github-actions

Copy link
Copy Markdown

🙋 Decision needed — 7.1

setUnmountHiddenViews and the setting's user-facing explanation are duplicated between ConfigurationUIView.vue and ViewSelector.vue

The author's argument: The handler now lives once in the widget-manager store and both surfaces call it; the explanation stays duplicated on purpose because the settings panel needs the longer wording and the view-selector dialog the short one.

How to vote on this dispute

React to this comment and the next /review applies the answer:

  • 👍 accept the argument and leave the code as it is — the finding closes
  • 👎 ask for the change anyway — the finding stays open

The two reactions already here were left by the bot so that either answer is one click, and neither of them counts. Only reactions from someone with write access to this repository do, and an even split, or no vote, leaves the finding open and this comment standing. Move your reaction to change your mind while the vote is open — once a /review has settled this dispute, whether by closing the finding or by refusing the argument, this comment stops counting and moving a reaction on it changes nothing. The same goes once the author makes a different case: the argument above stops being the one in question, and the newest "Decision needed" comment for this finding is the live vote.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Interface: hidden views stay mounted and keep using resources

2 participants