]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ui-web): resolve WS race condition causing DISCONNECTED on modern skin
authorJérôme Benoit <jerome.benoit@sap.com>
Wed, 29 Apr 2026 23:09:37 +0000 (01:09 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Wed, 29 Apr 2026 23:09:37 +0000 (01:09 +0200)
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
ui/web/src/composables/UIClient.ts
ui/web/src/shared/composables/useLayoutData.ts
ui/web/tests/unit/helpers.ts
ui/web/tests/unit/shared/composables/useLayoutData.test.ts
ui/web/tests/unit/skins/modern/ModernLayout.test.ts

index 531238edf1a0a3a13e78a5c3335c742d74c4e540..cfa2d8e8192ba464537d6910e02760091adf46ae 100644 (file)
@@ -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()}`
index 8fe631d54e16de227873e77d260112dda241ec71..c6a97d76b14c5d1aaa32f67713bc5c9d788dd1b5 100644 (file)
@@ -81,6 +81,10 @@ export class UIClient {
     })
   }
 
+  public isConnected (): boolean {
+    return this.client.connected
+  }
+
   public async listChargingStations (): Promise<ResponsePayload> {
     return this.sendRequest(ProcedureName.LIST_CHARGING_STATIONS, {})
   }
index 2a00d966c8f0ef215dbe64c389b619d3c617bec7..73fa19029c9d31c7e6d93ec1a301218730144f14 100644 (file)
@@ -125,6 +125,9 @@ export function useLayoutData (): LayoutData {
     unsubscribeRefresh = $uiClient.onRefresh(() => {
       getChargingStations()
     })
+    if ($uiClient.isConnected()) {
+      getData()
+    }
   })
 
   onUnmounted(() => {
index 8a08d13de31b21d538538e6264b812d9ba855f99..e763c8f85a2e550ce45c44fe245274c346429973 100644 (file)
@@ -13,6 +13,7 @@ export interface MockUIClient {
   authorize: ReturnType<typeof vi.fn>
   closeConnection: ReturnType<typeof vi.fn>
   deleteChargingStation: ReturnType<typeof vi.fn>
+  isConnected: ReturnType<typeof vi.fn>
   listChargingStations: ReturnType<typeof vi.fn>
   listTemplates: ReturnType<typeof vi.fn>
   lockConnector: ReturnType<typeof vi.fn>
@@ -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),
index 59533e65776e8350ca60866cbcd2f6a292d406f5..3a7ee8e676e12fd0b950654027563ee55564837f 100644 (file)
@@ -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'))
index 7cd28b882113dbf117c886cdf17ded7b35bf1ca7..76645574305bacbc5c5b93e90fe58e9bb5da8fb0 100644 (file)
@@ -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: {