diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useEventBatcher.test.ts b/packages/omniviewdev-runtime/src/hooks/resource/useEventBatcher.test.ts index 0bd10871..34185848 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useEventBatcher.test.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useEventBatcher.test.ts @@ -126,6 +126,25 @@ describe('applyBatch', () => { expect(result.success).toBe(true); }); + it('handles class instances (non-plain objects) as oldData', () => { + class ListResult { + result: any[]; + success: boolean; + totalCount: number; + constructor() { + this.result = [makeItem('pod-1')]; + this.success = true; + this.totalCount = 1; + } + } + const classData = new ListResult(); + const events: ResourceEvent[] = [ + { type: 'ADD', payload: { data: makeItem('pod-2'), key: 'k', connection: 'c', id: 'pod-2', namespace: 'default' } }, + ]; + const result = applyBatch(classData, events, idAccessor); + expect(result.result).toHaveLength(2); + }); + it('supports nested id accessor paths', () => { const deepAccessor = 'spec.id'; const data = { diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useEventBatcher.ts b/packages/omniviewdev-runtime/src/hooks/resource/useEventBatcher.ts index aee34d4b..32d62e53 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useEventBatcher.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useEventBatcher.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef } from 'react'; import type { QueryClient, QueryKey } from '@tanstack/react-query'; -import { produce } from 'immer'; import get from 'lodash.get'; import type { WatchState } from '../../types/watch'; @@ -37,6 +36,9 @@ export type ResourceEvent = /** * Apply a batch of resource events to a list query cache entry. * Extracted for testability — this is the core batching logic. + * + * Returns a new plain object (immutable-style) without relying on Immer, + * avoiding issues with Wails binding class instances that Immer cannot draft. */ export function applyBatch( oldData: any, @@ -44,39 +46,38 @@ export function applyBatch( idAccessor: string, ): any { const base = oldData ?? { result: [], success: true, totalCount: 0 }; - return produce(base, (draft: any) => { - for (const event of events) { - switch (event.type) { - case 'ADD': { - const idx = draft.result.findIndex( - (item: any) => get(event.payload.data, idAccessor) === get(item, idAccessor), - ); - if (idx === -1) { - draft.result.push(event.payload.data); - } - break; + // Shallow-copy the result array so we never mutate the cached version. + const result = [...(base.result ?? [])]; + + for (const event of events) { + const eventId = get(event.payload.data, idAccessor); + + switch (event.type) { + case 'ADD': { + const idx = result.findIndex((item: any) => get(item, idAccessor) === eventId); + if (idx === -1) { + result.push(event.payload.data); } - case 'UPDATE': { - const idx = draft.result.findIndex( - (item: any) => get(event.payload.data, idAccessor) === get(item, idAccessor), - ); - if (idx !== -1) { - draft.result[idx] = event.payload.data; - } - break; + break; + } + case 'UPDATE': { + const idx = result.findIndex((item: any) => get(item, idAccessor) === eventId); + if (idx !== -1) { + result[idx] = event.payload.data; } - case 'DELETE': { - const idx = draft.result.findIndex( - (item: any) => get(event.payload.data, idAccessor) === get(item, idAccessor), - ); - if (idx !== -1) { - draft.result.splice(idx, 1); - } - break; + break; + } + case 'DELETE': { + const idx = result.findIndex((item: any) => get(item, idAccessor) === eventId); + if (idx !== -1) { + result.splice(idx, 1); } + break; } } - }); + } + + return { ...base, result }; } /** diff --git a/packages/omniviewdev-runtime/src/hooks/resource/useWatchState.ts b/packages/omniviewdev-runtime/src/hooks/resource/useWatchState.ts index 61e4a296..5dca8eae 100644 --- a/packages/omniviewdev-runtime/src/hooks/resource/useWatchState.ts +++ b/packages/omniviewdev-runtime/src/hooks/resource/useWatchState.ts @@ -1,8 +1,6 @@ import { useCallback, useEffect, useRef } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { produce } from 'immer'; - -import { GetWatchState } from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; +import { GetWatchState} from '../../bindings/github.com/omniviewdev/omniview/backend/pkg/plugin/resource/servicewrapper'; import { Events } from '@wailsio/runtime'; import { useResolvedPluginId } from '../useResolvedPluginId'; import type { @@ -43,20 +41,19 @@ export const useWatchState = ({ const applyEvent = useCallback((event: WatchStateEvent) => { queryClient.setQueryData(queryKey, (old) => { if (!old) return old; - return produce(old, (draft) => { - draft.resources[event.resourceKey] = event.state; - draft.resourceCounts[event.resourceKey] = event.resourceCount; - - // Recompute aggregates - let synced = 0; - let errors = 0; - for (const state of Object.values(draft.resources)) { - if (state === WatchState.WatchStateSynced) synced++; - if (state === WatchState.WatchStateError || state === WatchState.WatchStateFailed) errors++; - } - draft.syncedCount = synced; - draft.errorCount = errors; - }); + + const resources = { ...old.resources, [event.resourceKey]: event.state }; + const resourceCounts = { ...old.resourceCounts, [event.resourceKey]: event.resourceCount }; + + // Recompute aggregates + let synced = 0; + let errors = 0; + for (const state of Object.values(resources)) { + if (state === WatchState.WatchStateSynced) synced++; + if (state === WatchState.WatchStateError || state === WatchState.WatchStateFailed) errors++; + } + + return { ...old, resources, resourceCounts, totalResources: Object.keys(resources).length, syncedCount: synced, errorCount: errors }; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [queryClient, pluginID, connectionID]);