From e626ef8a0f8ce4ac1010fc440a0c27dd225b4435 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 30 Apr 2026 01:09:37 +0200 Subject: [PATCH] fix(ui-web): resolve WS race condition causing DISCONNECTED on modern skin MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Problem: when skin layouts are lazy-loaded via defineAsyncComponent, the WebSocket 'open' event can fire before the layout mounts and registers its listener. The layout then shows DISCONNECTED until manual refresh because getData() is never triggered. Root cause: UIClient connects in main.ts constructor (fire-and-forget), but the skin layout mounts asynchronously after the chunk loads. If the WS handshake completes during chunk loading, the 'open' event is dispatched to an EventTarget with zero listeners. Fix: expose connection state and fetch eagerly when already connected. - ui-common/WebSocketClient: add `get connected(): boolean` getter (returns readyState === OPEN) - ui-web/UIClient: add `isConnected(): boolean` (delegates to client.connected) - ui-web/useLayoutData: in onMounted, call getData() immediately if isConnected() is true. Otherwise the normal 'open' handler will trigger getData() when the connection establishes — no premature fetch, no error toast, no duplicate calls. - ui-web/tests: add isConnected to MockUIClient (defaults to false); add 2 explicit tests for both branches of the mount-time check --- ui/common/src/client/WebSocketClient.ts | 4 ++ ui/web/src/composables/UIClient.ts | 4 ++ .../src/shared/composables/useLayoutData.ts | 3 + ui/web/tests/unit/helpers.ts | 2 + .../shared/composables/useLayoutData.test.ts | 22 ++++++- .../unit/skins/modern/ModernLayout.test.ts | 57 ++++++++++--------- 6 files changed, 65 insertions(+), 27 deletions(-) diff --git a/ui/common/src/client/WebSocketClient.ts b/ui/common/src/client/WebSocketClient.ts index 531238ed..cfa2d8e8 100644 --- a/ui/common/src/client/WebSocketClient.ts +++ b/ui/common/src/client/WebSocketClient.ts @@ -11,6 +11,10 @@ import { WebSocketReadyState } from './types.js' export { ServerFailureError } from '../errors.js' export class WebSocketClient { + public get connected (): boolean { + return this.ws?.readyState === WebSocketReadyState.OPEN + } + public get url (): string { const scheme = this.config.secure === true ? 'wss' : 'ws' return `${scheme}://${this.config.host}:${this.config.port.toString()}` diff --git a/ui/web/src/composables/UIClient.ts b/ui/web/src/composables/UIClient.ts index 8fe631d5..c6a97d76 100644 --- a/ui/web/src/composables/UIClient.ts +++ b/ui/web/src/composables/UIClient.ts @@ -81,6 +81,10 @@ export class UIClient { }) } + public isConnected (): boolean { + return this.client.connected + } + public async listChargingStations (): Promise { return this.sendRequest(ProcedureName.LIST_CHARGING_STATIONS, {}) } diff --git a/ui/web/src/shared/composables/useLayoutData.ts b/ui/web/src/shared/composables/useLayoutData.ts index 2a00d966..73fa1902 100644 --- a/ui/web/src/shared/composables/useLayoutData.ts +++ b/ui/web/src/shared/composables/useLayoutData.ts @@ -125,6 +125,9 @@ export function useLayoutData (): LayoutData { unsubscribeRefresh = $uiClient.onRefresh(() => { getChargingStations() }) + if ($uiClient.isConnected()) { + getData() + } }) onUnmounted(() => { diff --git a/ui/web/tests/unit/helpers.ts b/ui/web/tests/unit/helpers.ts index 8a08d13d..e763c8f8 100644 --- a/ui/web/tests/unit/helpers.ts +++ b/ui/web/tests/unit/helpers.ts @@ -13,6 +13,7 @@ export interface MockUIClient { authorize: ReturnType closeConnection: ReturnType deleteChargingStation: ReturnType + isConnected: ReturnType listChargingStations: ReturnType listTemplates: ReturnType lockConnector: ReturnType @@ -140,6 +141,7 @@ export function createMockUIClient (): MockUIClient { authorize: vi.fn().mockResolvedValue(successResponse), closeConnection: vi.fn().mockResolvedValue(successResponse), deleteChargingStation: vi.fn().mockResolvedValue(successResponse), + isConnected: vi.fn().mockReturnValue(false), listChargingStations: vi.fn().mockResolvedValue({ ...successResponse, chargingStations: [] }), listTemplates: vi.fn().mockResolvedValue({ ...successResponse, templates: [] }), lockConnector: vi.fn().mockResolvedValue(successResponse), diff --git a/ui/web/tests/unit/shared/composables/useLayoutData.test.ts b/ui/web/tests/unit/shared/composables/useLayoutData.test.ts index 59533e65..3a7ee8e6 100644 --- a/ui/web/tests/unit/shared/composables/useLayoutData.test.ts +++ b/ui/web/tests/unit/shared/composables/useLayoutData.test.ts @@ -70,8 +70,11 @@ describe('useLayoutData', () => { vi.clearAllMocks() }) - it('should call simulatorState, listTemplates, and listChargingStations on getData', () => { + it('should call simulatorState, listTemplates, and listChargingStations on getData', async () => { const [result] = mountComposable() + // Wait for the initial getData() triggered by onMounted to complete so that + // the useFetchData guard (fetching === false) is reset before we call again. + await flushPromises() mockClient.simulatorState.mockClear() mockClient.listTemplates.mockClear() mockClient.listChargingStations.mockClear() @@ -168,6 +171,23 @@ describe('useLayoutData', () => { expect(unsubscribe).toHaveBeenCalledTimes(1) }) + it('should call getData immediately on mount when WS is already connected', async () => { + mockClient.isConnected.mockReturnValue(true) + mountComposable() + await flushPromises() + expect(mockClient.simulatorState).toHaveBeenCalledTimes(1) + expect(mockClient.listTemplates).toHaveBeenCalledTimes(1) + expect(mockClient.listChargingStations).toHaveBeenCalledTimes(1) + }) + + it('should not call getData on mount when WS is not yet connected', () => { + mockClient.isConnected.mockReturnValue(false) + mountComposable() + expect(mockClient.simulatorState).not.toHaveBeenCalled() + expect(mockClient.listTemplates).not.toHaveBeenCalled() + expect(mockClient.listChargingStations).not.toHaveBeenCalled() + }) + describe('error handling', () => { it('should set loading to false when getSimulatorState rejects', async () => { mockClient.simulatorState.mockRejectedValueOnce(new Error('network')) diff --git a/ui/web/tests/unit/skins/modern/ModernLayout.test.ts b/ui/web/tests/unit/skins/modern/ModernLayout.test.ts index 7cd28b88..76645574 100644 --- a/ui/web/tests/unit/skins/modern/ModernLayout.test.ts +++ b/ui/web/tests/unit/skins/modern/ModernLayout.test.ts @@ -123,32 +123,34 @@ describe('ModernLayout', () => { }) it('should render a StationCard per charging station', async () => { - const wrapper = mountView({ - chargingStations: [ - createChargingStationData({ - stationInfo: { - baseName: 'CS-1', - chargePointModel: 'm', - chargePointVendor: 'v', - chargingStationId: 'CS-1', - hashId: 'h1', - templateIndex: 0, - templateName: 't', - }, - }), - createChargingStationData({ - stationInfo: { - baseName: 'CS-2', - chargePointModel: 'm', - chargePointVendor: 'v', - chargingStationId: 'CS-2', - hashId: 'h2', - templateIndex: 0, - templateName: 't', - }, - }), - ], - }) + const stations = [ + createChargingStationData({ + stationInfo: { + baseName: 'CS-1', + chargePointModel: 'm', + chargePointVendor: 'v', + chargingStationId: 'CS-1', + hashId: 'h1', + templateIndex: 0, + templateName: 't', + }, + }), + createChargingStationData({ + stationInfo: { + baseName: 'CS-2', + chargePointModel: 'm', + chargePointVendor: 'v', + chargingStationId: 'CS-2', + hashId: 'h2', + templateIndex: 0, + templateName: 't', + }, + }), + ] + mockClient.listChargingStations = vi + .fn() + .mockResolvedValue({ chargingStations: stations, status: 'success' }) + const wrapper = mountView({ chargingStations: stations }) await flushPromises() expect(wrapper.findAll('.stub-station-card')).toHaveLength(2) }) @@ -328,6 +330,9 @@ describe('ModernLayout', () => { templateName: 't', }, }) + mockClient.listChargingStations = vi + .fn() + .mockResolvedValue({ chargingStations: [station], status: 'success' }) const wrapper = mount(ModernLayout, { global: { provide: { -- 2.43.0