Stabilize nav tab identity across websocket ticks

The primaryTabs/utilityTabs memos in AppLayout rebuild their arrays from
live store reads; activeAlerts is replaced wholesale whenever a state
frame carries it, so every reference-keyed <For> consumer (desktop tab
strip and the mobile bottom bar) tore down and recreated all nav buttons
even when nothing visible changed. Probed on the 50-node mock estate:
3 of 4 mobile bottom-bar buttons and 4 of 10 desktop tabs were detached
within 40s of websocket frames; taps landing mid-rebuild die silently.

Reuse previous tab object references (and the previous array identity)
when a rebuilt list is structurally unchanged, so downstream <For>s keep
their DOM. With the fix the same 40s probe keeps every nav element
connected on both form factors. MobileNavBar's proof suite now pins the
DOM-identity behavior and App.architecture pins the stabilizer wiring;
contract notes recorded in frontend-primitives, cloud-paid, and
ai-runtime.
This commit is contained in:
rcourtman
2026-08-24 08:58:49 +01:00
parent 814f700883
commit 6d3ec88769
9 changed files with 280 additions and 190 deletions
@@ -3430,7 +3430,11 @@ query...`, and `Reading storage...` before streamed tool arguments are
the shared AI-aware chrome by virtue of routing through `AppLayout`, and
must not introduce a parallel chat surface, launcher button, or model
picker on the platform page itself; cross-platform AI guidance stays
routed through Assistant and Patrol.
routed through Assistant and Patrol. The Patrol utility destination in
`AppLayout`'s tab lists shares the nav identity-stability contract owned
by `frontend-primitives` (`stableNavTabs.ts`): a websocket state frame
with structurally unchanged tab content must not recreate the Patrol
button DOM or its attention-count badge element.
The primary-nav demotion of Infrastructure / Workloads / Storage /
Recovery does not change Patrol or Assistant addressability: both
surfaces remain reachable through the utility tab strip in
@@ -629,7 +629,12 @@ its independent operational-attention count. Action authority and result truth
remain in the unified-resources and api-contracts projections. This
information-architecture choice is independent of hosted entitlement, billing,
and acquisition state. Navigation proof is owned by the App/AppLayout, routing,
mobile navigation, and desktop Actions journey tests.
mobile navigation, and desktop Actions journey tests. AppLayout's primary and
utility tab arrays are identity-stabilized through the shared
`frontend-modern/src/components/shared/stableNavTabs.ts` helper (see
`frontend-primitives`): navigation destinations, including the Actions utility
tab and its pending-approval badge, must not have their DOM recreated by a
websocket state frame whose tab content is structurally unchanged.
1. Add or change limits through `pkg/licensing/`
2. Add or change hosted entitlement issuance through `internal/cloudcp/entitlements/service.go`
@@ -66,6 +66,17 @@ four-row phone node preview before the guest list instead of visually moving
the nodes after the guest list's full virtual scroll extent; revealing the full
node estate remains an explicit table-preview action.
App-shell navigation tab lists rendered through reference-keyed `<For>`
consumers keep stable item identity across websocket state frames. AppLayout
derives its primary and utility tab arrays through the shared
`frontend-modern/src/components/shared/stableNavTabs.ts` reuse helper, which
returns previous tab object references (and the previous array identity) when a
rebuilt list is structurally unchanged, so an alerts-bearing state frame with
unchanged badge content cannot recreate nav button DOM and drop an in-flight
tap. New nav or tab-strip consumers that rebuild their item arrays from live
store reads must route through the same helper instead of `<For>`-ing over
freshly constructed objects.
Estate-sized table and card rendering routes through the shared
`PlatformWindowedRows`, `PlatformWindowedList`, and
`usePlatformWindowedItems` primitives. They preserve the complete filtered and
+16 -183
View File
@@ -1,189 +1,30 @@
{
"version": 1,
"base_sha": "87ee3cd449e47b8c9f2214c64f46e0ee92bc1ed8",
"verified_at": "2026-08-23T23:27:15Z",
"base_sha": "814f7008833c19fce7d457c1818ebc9197b83c6e",
"verified_at": "2026-08-24T07:44:48Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx",
"frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx",
"frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx",
"frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx",
"frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx",
"frontend-modern/src/components/Infrastructure/useTableWindowing.ts",
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts",
"frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx",
"frontend-modern/src/components/Settings/ResourcePicker.tsx",
"frontend-modern/src/components/Storage/DiskList.tsx",
"frontend-modern/src/components/Storage/Storage.tsx",
"frontend-modern/src/components/Storage/useStoragePageFilters.ts",
"frontend-modern/src/components/Storage/useStoragePageModel.ts",
"frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts",
"frontend-modern/src/components/Workloads/useWorkloadRouteState.ts",
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
"frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx",
"frontend-modern/src/features/alerts/AlertHistoryTableSection.tsx",
"frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx",
"frontend-modern/src/features/docker/DockerAlertsTable.tsx",
"frontend-modern/src/features/docker/DockerConfigsTable.tsx",
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/features/docker/DockerHostsTable.tsx",
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
"frontend-modern/src/features/docker/DockerNetworksTable.tsx",
"frontend-modern/src/features/docker/DockerSecretsTable.tsx",
"frontend-modern/src/features/docker/DockerServicesTable.tsx",
"frontend-modern/src/features/docker/DockerStorageUsageTable.tsx",
"frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx",
"frontend-modern/src/features/docker/DockerTasksTable.tsx",
"frontend-modern/src/features/docker/DockerVolumesTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx",
"frontend-modern/src/features/platformPage/PlatformWindowedList.tsx",
"frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts",
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
"frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts",
"frontend-modern/src/features/proxmox/proxmoxPageModel.ts",
"frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx",
"frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx",
"frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx",
"frontend-modern/src/features/truenas/TrueNASAppsTable.tsx",
"frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx",
"frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx",
"frontend-modern/src/features/truenas/TrueNASServicesTable.tsx",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx",
"frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx",
"frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx",
"frontend-modern/src/features/vmware/VsphereActivityTable.tsx",
"frontend-modern/src/features/vmware/VsphereAlertsTable.tsx",
"frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx",
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx",
"frontend-modern/src/features/vmware/VsphereNetworksTable.tsx",
"frontend-modern/src/hooks/useUnifiedResources.ts",
"frontend-modern/src/pages/Actions.tsx",
"frontend-modern/src/stores/websocket-global.ts",
"frontend-modern/src/stores/websocket.ts"
"frontend-modern/src/AppLayout.tsx",
"frontend-modern/src/components/shared/stableNavTabs.ts"
],
"content_sha256": {
"frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx": "40114f8ba34db502cdb8d3c8f09ad2795a0b90b597aa460914837163afdde8a4",
"frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx": "9a6ec90c00a42e382dd17e2354f8b2f1eded6bf9ebb7a26a1d2fe5ebef1c36d8",
"frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx": "4bcec4a652819d14108b44f06be61ab89fc3236d9c675fc1e0e73b60e3277547",
"frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx": "b418b76eda65c263fb412aba11927661eadad857f980c95621cb6bf2adf96aee",
"frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx": "38060d74c672adca17da8568413bc0eaba21075931f3e9982f8345e1d9dc8056",
"frontend-modern/src/components/Infrastructure/useTableWindowing.ts": "823b301ee9e689e4d64f43a2b9b884fb442e9c73c2a6b216311641b5a389b951",
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts": "9cb8b31f22c197d21dba3ac2a7b8ef6ce9ed7a1bddbc11450df88071ab07fd16",
"frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx": "38a6b14f30438a829af79af90e9770d812c0d60af3e6eebce5dd2da3c2b656cb",
"frontend-modern/src/components/Settings/ResourcePicker.tsx": "33333088aa85b02ce4d4ee301a59228d9bd20703beaf651efb5621d8237fc87f",
"frontend-modern/src/components/Storage/DiskList.tsx": "378fe6dca7f082a7192d4a3bed4e5ec8b13e458a64a798c7b5da8c6134fdccde",
"frontend-modern/src/components/Storage/Storage.tsx": "49f95343a8327ec756fe9a0e17dfafacc4ca65146fc198befe7d028815f0b90f",
"frontend-modern/src/components/Storage/useStoragePageFilters.ts": "454ce14da247b92ae27ea12a9ecbeaa9841d1ae582fafef79baa5ca4f65e8dca",
"frontend-modern/src/components/Storage/useStoragePageModel.ts": "c60e1769f4d4811dd1682dfba7763d90777066f69034f1c5e44304bf767bcd9b",
"frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts": "9412934e95eb2e1f273383aa69169b762ca2c14037ff586393d7d86374e5f6e4",
"frontend-modern/src/components/Workloads/useWorkloadRouteState.ts": "4a12717b73c8e836dd9fd5d049d772134bb53c19e3acc88b6bb88592291b975d",
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts": "e441a35aeeae2c73845bb28bcfc1242dcfa49630d885b9506a0fb57bf1325653",
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts": "1e3432b4df8635bf1234f049169c8249b7dc7afb5d91af5b47886d3ec25eee45",
"frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts": "acad7b727086a684b5f92c7bb015752cbd31d0d88324fb396eec7179ea8b9e45",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "bf360d72d68b02ac240ce4818d5d39d7308802405ff74592a99f9fe821c6d8ef",
"frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx": "264c55150cb49e23d104783b01dc39e7f0563c061c1633faba143b431dd0d247",
"frontend-modern/src/features/alerts/AlertHistoryTableSection.tsx": "8e875db1ff66291b4d688bd6e3a2fcba644bafca8e97a313560b151104f5ec4c",
"frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx": "4de3198bb6d9a499cbdff530f117115db8e40c8bfa356bcbe9c2f94ab6fdd823",
"frontend-modern/src/features/docker/DockerAlertsTable.tsx": "63aa2e31c66f3219facbc94ac7dd6439eabcb382189c13278da9d553b892764d",
"frontend-modern/src/features/docker/DockerConfigsTable.tsx": "6a1827fc0a1811531991f6f97201c71b4b2085cca7f889e4e57840068176654d",
"frontend-modern/src/features/docker/DockerContainersTable.tsx": "36bdf1cf9b6b2af627c7e521bb18e48a120af175af29f03edcf86dcf903e67b8",
"frontend-modern/src/features/docker/DockerHostsTable.tsx": "c60d3f9287348b5cdaea75f0d8e257f60c212ec39439dc4c515f67dc9cf6c4fe",
"frontend-modern/src/features/docker/DockerImagesTable.tsx": "ebb0d0808902f6c9d1dd8d0ad3d97742fc1ad1cedf4706e84dd24db4a9bef612",
"frontend-modern/src/features/docker/DockerNetworksTable.tsx": "0770e6ccb02ef037f9ebe22aead174ff5a01c36613db3ac330e0f3aed04d82a3",
"frontend-modern/src/features/docker/DockerSecretsTable.tsx": "5fab73a3182425e742f1b3a6c1e6068ee9d676aedf20b7ac05783b7e071d773d",
"frontend-modern/src/features/docker/DockerServicesTable.tsx": "6b3c96bff27489495504fd67a7e07d127a1830326ebd5b4b16186c258aa46e1d",
"frontend-modern/src/features/docker/DockerStorageUsageTable.tsx": "fce8b016818e3eafe55fe3f3571c9f8c0a88d10001e158dfbd5a787e6409cb10",
"frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx": "1e434e500f9d87b0eea678531e5bfa4c8219974514a6bb4011301abbf77b0f04",
"frontend-modern/src/features/docker/DockerTasksTable.tsx": "ececb440bc03ddc4428ebd9fd86a859115caaaf1b2d7c3483c252f7e5923ae67",
"frontend-modern/src/features/docker/DockerVolumesTable.tsx": "437bcca202cdbc515a6be94826c2f052e2887a259b3e74b95314180668b97843",
"frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx": "d6af4dc0cefd2a9bc60168cd1211d4e910c034084e19d8627f90a44527725090",
"frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx": "72003df3088df2ea0b86119adf98340d4911a10ea06d2429b836ad10b4f9c1ab",
"frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx": "5c35fdfbff731047380d5e80d266c40d1d9b9993e5df9e4807b802aa24dcf261",
"frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx": "2a0f5f8c6d05465580393769c15ac8b5aab3502a1442b5fce6d9e269585ee8e8",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "0a76f4fe2a71a1f3e5f22e2f39dab09a69bd534dd88934b40cf5dc066271dced",
"frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx": "464d5bc4e4465f306c2ab733fd5217ef27197ac831ff8e1d954bca1c9f033210",
"frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx": "7e116f050ddf9d159808104d4e533fa640a72260a2bc46c405cfc771c0551346",
"frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx": "6c3cb5fb8bc391b01677030df93f1fd0027f0fdd1ff080d5d23820a017843305",
"frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx": "d3d5051b3706f4187b69e238c6151cdb63a676f774f980f1e0c2092ffba4212d",
"frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx": "8dae4fe798edb6f7517ba5b96894686795bcb52a9187685a361f099ff11f5cb1",
"frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx": "c721ea951e1725842a2e2ae02465ffebd1d8e41db089d7e596d271892d5249fa",
"frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx": "c07de4d4c861eced421ec0b9b1aef666e30b8a4da193d1b77465c7b440c4033d",
"frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx": "587a5f3f3ba69d1014b4160bb2a817284e2fb9208cd168571deb328f28277972",
"frontend-modern/src/features/platformPage/PlatformWindowedList.tsx": "5865c9596ce43c8040f950fdd8944da117c8a60af304552563a30295cd75d836",
"frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx": "e9994d44b67683aafb0e2d5563b7c426967cb4812df959fb68a4cd3d71bec532",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "775abbe122738028deeed06087332fac15b6d24bcecd01490a6b05554db53da8",
"frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts": "03f7e9e5a6ca8ced24ee3f01e84b447c16904c8953e4325e56e144f10fa9a78c",
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "c450ec312fc1b66b3cd04d31ef4382b958bd39dc9d6c491fea4907f59b7901b3",
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx": "d6f14908dd53e34022d67cee24c0afd586c425a334b910fc5a7b0542fce2ec72",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx": "734d4f845ca31a58a2751469288b90e1171477bf7811fe7e71e56994e9176838",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "50006f6baf423f85ee084cdfe57674e31a7fe383508f500f19d48c0edfc6fb65",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "ade892e9efb6917324b30d77462df02cd412bf872ab0a5ac011d2e8a7a1c94ae",
"frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx": "ae7f2a35b0e393049212d4cdc1933a030f242f85292f840e14d457d499838e6b",
"frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts": "9a74e09268cba15df0aa72af3fb45c6b3f66a59c02f36f5d56ed949458ec138d",
"frontend-modern/src/features/proxmox/proxmoxPageModel.ts": "353aa4917e189d35a74a2619e841aef21403ed828426a76e12ba38c3db47df62",
"frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts": "ede175b264335ac7028a312e7cf5209e80a02398376d6e845dbb60809f153e7f",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "b923a5c901c980e9a0e4b15a27ed4e193bd437d7b564cd8dbba0309cf3c223a8",
"frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx": "c3e1be90961dc6c645ba35727e1cb9b3781e16ebce1ef1155e2a2739fc652135",
"frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx": "4428e41210224f0c474e8a1f332bd94380bd98ab5c55317657ce613d2621082d",
"frontend-modern/src/features/truenas/TrueNASAppsTable.tsx": "f5041219fa5042253833980ad45d1d4cca4362df68e1766df8319f101a9395b8",
"frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx": "848490290134fe1d09d42b95af49b7fc7a4f876d783099f152340d98328681b6",
"frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx": "8df07ae67c13130bc1b55e8efb5841cdf95f20adb47545aadc769d25a0be17eb",
"frontend-modern/src/features/truenas/TrueNASServicesTable.tsx": "ab8cf7f11017e5acbf0d748e5f2fd134e01e0bc1949030d0df6b089f7fd519d7",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "57521f1d655d1b8d724cf2ff50e4d9af0414f2b60f1b95463e91fab30da621b9",
"frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx": "91df11b1896c9de71f7d0b1f8c21bf00b108088fb908dc00809784b945688250",
"frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx": "1f15e2ea7b223ad21470a745a859a1eca64e39d9b4ff5c85f457b29351f22bd3",
"frontend-modern/src/features/vmware/VsphereActivityTable.tsx": "64fccea6ff56fc418916564887814fcfd61c3117ab517e164cc20aacf40440f0",
"frontend-modern/src/features/vmware/VsphereAlertsTable.tsx": "8c1d5c367c54d1471110b1d0756aff97dae1f36592179fcf27607731f35998fb",
"frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx": "acd0e88c8942dc4a526c20dcaa0a0931d8f01a1cd9ce1167216ccf1e9c710ca3",
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx": "063fc1821dbeafad2c2695f2595f157974bf65953a12ca5a75751a41197d5fa8",
"frontend-modern/src/features/vmware/VsphereNetworksTable.tsx": "3a066da054edf2f6e5e0aefd21107c785b24962a17cc4238fabb0db9eeb7aa9e",
"frontend-modern/src/hooks/useUnifiedResources.ts": "5e1435128cc11d56ce27927340e333796dfc0f4281edc8c127621ea0105de1fc",
"frontend-modern/src/pages/Actions.tsx": "05ecf553ac62334b2323c980436f919c13bed96a988bc15bc63e8e1dc5a684de",
"frontend-modern/src/stores/websocket-global.ts": "a6b8d424423e296dd7a5c560c1d012982cbf2377c1e420189ee4465563a4eb7e",
"frontend-modern/src/stores/websocket.ts": "aec7a7a5ed0b05b315d2eb613e199b8d7a17aa7c2b6f4e810d5d6a05c4a76741"
"frontend-modern/src/AppLayout.tsx": "60abf2ff6cbba15f57d1f0c8a5775b10fdfda96ff7827dc36953028571c5deeb",
"frontend-modern/src/components/shared/stableNavTabs.ts": "41221184dc142e55a37a0bbc4f08edce2edfd067aa7f15e1a7f02d363f654018"
},
"routes": [
"/login",
"/proxmox/overview",
"/proxmox/storage",
"/proxmox/replication",
"/proxmox/backups/date",
"/proxmox/ceph",
"/proxmox/mail",
"/docker/overview",
"/kubernetes/overview",
"/truenas/overview",
"/vmware/overview",
"/standalone/machines",
"/alerts/overview",
"/patrol",
"/actions"
],
"viewports": [
{
"width": 1280,
"height": 720
"width": 1440,
"height": 900
},
{
"width": 390,
@@ -191,21 +32,13 @@
}
],
"states": [
"The connected mock estate contained 50 Proxmox nodes, 929 guests, and 209 storage rows.",
"At 1280x720 the Overview node and guest regions and the Storage table rendered populated rows immediately after settling, with platform tab content retained for fast return navigation.",
"At 390x844 the Overview rendered the 50-node summary and 929-guest table with bounded mounted rows; Storage rendered 73 body rows including its virtual spacer instead of mounting all 209 records.",
"A direct 6200px jump through the narrow Proxmox guest estate retained 23 visible table rows in every one of 12 consecutive samples, with zero blank frames and the jump-to-top control visible.",
"Docker, Kubernetes, TrueNAS, vSphere, Machines, Alerts, and Actions all completed their narrow route render with their expected table or list content and no loading state left behind.",
"A clean phone-sized performance profile measured settled Proxmox section transitions at 48-341ms and browser heap at roughly 225-239MB, down from multi-second transitions and roughly 386MB before the realtime reconciliation fix.",
"Browser diagnostics contained no warnings or errors after the desktop, narrow, rapid-scroll, platform-route, and Proxmox section checks."
"production build served via vite preview :4173 proxied to the live dev backend (mock estate: 50 nodes / 929 guests / 1508 resources, 46+ active alerts)",
"desktop top nav (12 tabs) and mobile bottom bar (4 destinations) observed across 40s of websocket frames including activeAlerts-bearing rawData frames",
"negative control on the pre-fix build: 3 of 4 mobile nav buttons and 4 of 10 desktop tabs were detached (recreated) within the same 40s window; with this change all tagged nav elements stayed connected"
],
"interactions": [
"Loaded /proxmox/overview against the 50-node, 929-guest mock estate at desktop width and visually inspected the rendered Nodes and Guests regions.",
"Clicked Storage, Backups, Replication, Ceph, Mail Gateway, and Overview, then visually inspected Overview and Storage at 1280x720.",
"Applied a 390x844 viewport override, reloaded Overview, and confirmed nodes, guests, mobile navigation, and the fixed bottom navigation remained present.",
"Jumped the active app scroll surface directly through 6200px of guest rows, sampled visible rows across 12 consecutive frames, and visually inspected the populated middle-of-estate viewport.",
"Loaded narrow Docker, Kubernetes, TrueNAS, vSphere, Machines, Alerts, and Actions routes and confirmed each completed with expected estate content.",
"Loaded narrow Proxmox Storage directly, waited for first hydration, and confirmed the bounded table was visible with populated rows.",
"Read browser diagnostics, restored the temporary viewport override to 1280x720, and left the demo browser in its normal viewport."
"login as admin (real input)",
"Playwright CDP clicks on Proxmox sub-tabs (Storage, Backups, Mail, Overview) and mobile bottom-bar destinations (Alerts, Patrol, Actions) with navigation confirmed by URL change",
"desktop utility tab click to /alerts after the 40s stability window; zero console errors on both form factors"
]
}
+15 -2
View File
@@ -14,6 +14,11 @@ import {
type MobileNavBarPrimaryTab as PrimaryTab,
type MobileNavBarUtilityTab as UtilityTab,
} from '@/components/shared/MobileNavBar';
import {
createStableTabList,
primaryNavTabEquals,
utilityNavTabEquals,
} from '@/components/shared/stableNavTabs';
import {
buildPrimaryPlatformNavigationVisibility,
primaryPlatformNavigationIsVisible,
@@ -487,7 +492,7 @@ export function AppLayout(props: AppLayoutProps) {
// Platform/runtime nav is resource-admitted. A platform or runtime lens only
// appears when the support manifest says the surface is supported and the
// current resource snapshot proves that surface is actually present.
const primaryTabs = createMemo<PrimaryTab[]>(() => {
const primaryTabsSource = createMemo<PrimaryTab[]>(() => {
const visible = platformNavigationVisibility();
const isVisible = (id: PrimaryTab['id']) =>
primaryPlatformNavigationIsVisible(visible, id as PrimaryPlatformNavId);
@@ -563,7 +568,13 @@ export function AppLayout(props: AppLayoutProps) {
return allPrimaryTabs.filter((tab) => tab.alwaysShow || tab.enabled);
});
const utilityTabs = createMemo(() => {
// Both tab memos rebuild their arrays from live store reads (activeAlerts is
// replaced wholesale on every websocket state message), so without identity
// stabilization every reference-keyed <For> consumer recreates all nav
// buttons each tick — dropping taps that land mid-rebuild.
const primaryTabs = createStableTabList(primaryTabsSource, primaryNavTabEquals);
const utilityTabsSource = createMemo<UtilityTab[]>(() => {
const allAlerts = props.state().activeAlerts || [];
const breakdown = allAlerts.reduce(
(accumulator, alert: Alert) => {
@@ -631,6 +642,8 @@ export function AppLayout(props: AppLayoutProps) {
return tabs;
});
const utilityTabs = createStableTabList(utilityTabsSource, utilityNavTabEquals);
const handlePrimaryClick = (tab: PrimaryTab) => {
const targetRoute = resolvePrimaryNavigationRoute(tab, primaryRouteMemory);
void (async () => {
@@ -361,6 +361,16 @@ describe('App architecture', () => {
expect(appLayoutSource).not.toContain("id: 'recovery',");
expect(appLayoutSource).not.toContain('aria-label="Workspaces"');
expect(appLayoutSource).not.toContain('buildStorageRecoveryTabSpecs(');
// Nav tab arrays are rebuilt from live store reads (activeAlerts is
// replaced wholesale by state frames), so both tab lists must route
// through the shared identity stabilizer or every reference-keyed <For>
// consumer recreates all nav buttons and drops in-flight taps.
expect(appLayoutSource).toContain(
'const primaryTabs = createStableTabList(primaryTabsSource, primaryNavTabEquals);',
);
expect(appLayoutSource).toContain(
'const utilityTabs = createStableTabList(utilityTabsSource, utilityNavTabEquals);',
);
expect(appSource).not.toContain('DashboardPage');
expect(headerAuditSource).not.toContain("['src/pages/Dashboard.tsx', 'PageHeader']");
expect(appSource).toContain("import RuntimeHomePage from '@/pages/RuntimeHome';");
@@ -618,14 +628,14 @@ describe('App architecture', () => {
expect(appLayoutSource).not.toContain("label: 'Needs Attention'");
expect(appLayoutSource).not.toContain("route: '/operations',");
expect(appLayoutSource).not.toContain('props.connected()');
expect(appLayoutSource).toContain('const utilityTabs = createMemo(() =>');
expect(appLayoutSource).toContain('const utilityTabsSource = createMemo<UtilityTab[]>(() =>');
expect(appLayoutSource).toContain(
'type MobileNavBarPrimaryTab as PrimaryTab,\n type MobileNavBarUtilityTab as UtilityTab,',
);
expect(appLayoutSource).toContain("const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0';");
expect(appLayoutSource).toContain('function getDesktopUtilityTabAriaLabel(tab: UtilityTab)');
expect(appLayoutSource).toContain('return `${count} ${tab.label}`;');
expect(appLayoutSource).toContain('const primaryTabs = createMemo<PrimaryTab[]>(() =>');
expect(appLayoutSource).toContain('const primaryTabsSource = createMemo<PrimaryTab[]>(() =>');
expect(appLayoutSource).toContain("id: 'proxmox',");
expect(appLayoutSource).toContain("icon: getPlatformIcon('proxmox'),");
expect(appLayoutSource).toContain('const Icon = tab.icon;');
@@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { describe, expect, it, vi } from 'vitest';
import { type Component } from 'solid-js';
import { createSignal, type Component } from 'solid-js';
import { createStableTabList, utilityNavTabEquals } from '@/components/shared/stableNavTabs';
import mobileNavBarSource from '@/components/shared/MobileNavBar.tsx?raw';
import mobileNavBarModelSource from '@/components/shared/mobileNavBarModel.ts?raw';
import mobileNavBarStateSource from '@/components/shared/useMobileNavBarState.ts?raw';
@@ -290,6 +291,46 @@ describe('MobileNavBar', () => {
expect(button).not.toHaveAttribute('aria-current');
});
});
it('keeps destination button DOM identity when tab arrays are rebuilt with equal content', async () => {
const buildTabs = () => [
makeUtility('alerts', 'Alerts', { count: 3, breakdown: { warning: 2, critical: 1 } }),
makeUtility('ai', 'Patrol'),
makeUtility('actions', 'Actions'),
];
const [rawTabs, setRawTabs] = createSignal(buildTabs(), { equals: false });
const utilityTabs = createStableTabList(rawTabs, utilityNavTabEquals);
const { container } = render(() => (
<MobileNavBar
activeTab={() => null}
primaryTabs={() => [makePrimary('proxmox', 'Proxmox')]}
utilityTabs={() => [...utilityTabs()]}
onPrimaryClick={() => {}}
onUtilityClick={() => {}}
/>
));
const buttonsBefore = [
...container.querySelectorAll<HTMLButtonElement>('[data-mobile-nav-destination]'),
];
expect(buttonsBefore.length).toBeGreaterThan(0);
// A state frame carrying an unchanged alerts array rebuilds the tab
// objects; identical content must not recreate the rendered buttons.
setRawTabs(buildTabs());
await waitFor(() => {
const buttonsAfter = [
...container.querySelectorAll<HTMLButtonElement>('[data-mobile-nav-destination]'),
];
expect(buttonsAfter).toHaveLength(buttonsBefore.length);
buttonsAfter.forEach((button, index) => {
expect(button).toBe(buttonsBefore[index]);
});
buttonsBefore.forEach((button) => {
expect(button.isConnected).toBe(true);
});
});
});
});
describe('MobileNavBar clearance publishing', () => {
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import { createRoot, createSignal } from 'solid-js';
import { createStableTabList, primaryNavTabEquals, utilityNavTabEquals } from '../stableNavTabs';
import type { MobileNavBarIcon, MobileNavBarUtilityTab } from '../mobileNavBarModel';
const Icon: MobileNavBarIcon = () => null;
const utilityTab = (overrides: Partial<MobileNavBarUtilityTab> = {}): MobileNavBarUtilityTab => ({
id: 'alerts',
label: 'Alerts',
route: '/alerts',
tooltip: 'Review active alerts',
badge: null,
count: 3,
countLabel: undefined,
breakdown: { warning: 2, critical: 1 },
icon: Icon,
...overrides,
});
describe('createStableTabList', () => {
it('returns the identical array when a rebuilt list is structurally equal', () => {
createRoot((dispose) => {
const [tabs, setTabs] = createSignal([utilityTab()], { equals: false });
const stable = createStableTabList(tabs, utilityNavTabEquals);
const first = stable();
setTabs([utilityTab()]);
expect(stable()).toBe(first);
expect(stable()[0]).toBe(first[0]);
dispose();
});
});
it('reuses unchanged item references when one item changes', () => {
createRoot((dispose) => {
const settingsTab = utilityTab({ id: 'settings', label: 'Settings', route: '/settings' });
const [tabs, setTabs] = createSignal([utilityTab(), settingsTab], { equals: false });
const stable = createStableTabList(tabs, utilityNavTabEquals);
const first = stable();
setTabs([
utilityTab({ count: 4, breakdown: { warning: 3, critical: 1 } }),
utilityTab({ id: 'settings', label: 'Settings', route: '/settings' }),
]);
const second = stable();
expect(second).not.toBe(first);
expect(second[0]).not.toBe(first[0]);
expect(second[1]).toBe(first[1]);
dispose();
});
});
it('detects length changes', () => {
createRoot((dispose) => {
const [tabs, setTabs] = createSignal([utilityTab()], { equals: false });
const stable = createStableTabList(tabs, utilityNavTabEquals);
const first = stable();
setTabs([utilityTab(), utilityTab({ id: 'settings', route: '/settings' })]);
const second = stable();
expect(second).not.toBe(first);
expect(second[0]).toBe(first[0]);
expect(second).toHaveLength(2);
dispose();
});
});
});
describe('utilityNavTabEquals', () => {
it('compares breakdown by value', () => {
expect(utilityNavTabEquals(utilityTab(), utilityTab())).toBe(true);
expect(
utilityNavTabEquals(utilityTab(), utilityTab({ breakdown: { warning: 9, critical: 1 } })),
).toBe(false);
expect(utilityNavTabEquals(utilityTab({ breakdown: undefined }), utilityTab())).toBe(false);
expect(
utilityNavTabEquals(
utilityTab({ breakdown: undefined }),
utilityTab({ breakdown: undefined }),
),
).toBe(true);
});
it('compares scalar fields', () => {
expect(utilityNavTabEquals(utilityTab(), utilityTab({ count: undefined }))).toBe(false);
expect(utilityNavTabEquals(utilityTab(), utilityTab({ badge: 'update' }))).toBe(false);
});
});
describe('primaryNavTabEquals', () => {
it('compares all scalar fields and the icon reference', () => {
const base = {
id: 'proxmox',
label: 'Proxmox',
route: '/proxmox',
settingsRoute: '/settings/infrastructure',
tooltip: 'Proxmox',
enabled: true,
live: true,
icon: Icon,
alwaysShow: false,
badge: undefined,
};
expect(primaryNavTabEquals(base, { ...base })).toBe(true);
expect(primaryNavTabEquals(base, { ...base, live: false })).toBe(false);
const OtherIcon: MobileNavBarIcon = () => null;
expect(primaryNavTabEquals(base, { ...base, icon: OtherIcon })).toBe(false);
});
});
@@ -0,0 +1,66 @@
import { createMemo, type Accessor } from 'solid-js';
import type { MobileNavBarPrimaryTab, MobileNavBarUtilityTab } from './mobileNavBarModel';
/**
* Reuse previous item references (and the previous array itself) when a
* rebuilt tab list is structurally unchanged. The nav tab memos rebuild
* their arrays from live store reads on every websocket tick, and the
* reference-keyed <For> consumers in AppLayout and MobileNavBar tear down
* and recreate every button whenever item identity changes which drops
* taps that land mid-rebuild. Stabilizing identity here fixes every
* consumer at once.
*/
export function createStableTabList<T>(
source: Accessor<T[]>,
equals: (previous: T, next: T) => boolean,
): Accessor<T[]> {
let previous: T[] = [];
return createMemo(() => {
const next = source();
let changed = next.length !== previous.length;
const merged = next.map((item, index) => {
const previousItem = previous[index];
if (previousItem !== undefined && equals(previousItem, item)) {
return previousItem;
}
changed = true;
return item;
});
if (!changed) return previous;
previous = merged;
return merged;
});
}
export function primaryNavTabEquals(a: MobileNavBarPrimaryTab, b: MobileNavBarPrimaryTab): boolean {
return (
a.id === b.id &&
a.label === b.label &&
a.route === b.route &&
a.settingsRoute === b.settingsRoute &&
a.tooltip === b.tooltip &&
a.enabled === b.enabled &&
a.live === b.live &&
a.icon === b.icon &&
a.alwaysShow === b.alwaysShow &&
a.badge === b.badge
);
}
export function utilityNavTabEquals(a: MobileNavBarUtilityTab, b: MobileNavBarUtilityTab): boolean {
return (
a.id === b.id &&
a.label === b.label &&
a.route === b.route &&
a.tooltip === b.tooltip &&
a.badge === b.badge &&
a.count === b.count &&
a.countLabel === b.countLabel &&
a.icon === b.icon &&
(a.breakdown === b.breakdown ||
(a.breakdown !== undefined &&
b.breakdown !== undefined &&
a.breakdown.warning === b.breakdown.warning &&
a.breakdown.critical === b.breakdown.critical))
);
}