From 9c1ecb33ae7a21174e816764bec29735c658fe2f Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Thu, 10 Sep 2026 22:21:21 -0400 Subject: [PATCH] Fix stale annotation indices and redraw watchers after reload --- client/src/BaseAnnotationStore.ts | 4 +- client/src/TrackStore.spec.ts | 14 +++++++ client/src/components/LayerManager.spec.ts | 49 +++++++++++++++++++--- client/src/components/LayerManager.vue | 9 +++- 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/client/src/BaseAnnotationStore.ts b/client/src/BaseAnnotationStore.ts index 83131a473..fce6a3c38 100644 --- a/client/src/BaseAnnotationStore.ts +++ b/client/src/BaseAnnotationStore.ts @@ -206,9 +206,7 @@ export default abstract class BaseAnnotationStore { clearAll() { this.annotationMap.clear(); - this.intervalTree.items.forEach((item) => { - this.intervalTree.remove(item.key); - }); + this.intervalTree.clear(); this.annotationIds.value = []; } } diff --git a/client/src/TrackStore.spec.ts b/client/src/TrackStore.spec.ts index 979889d5f..3f380a757 100644 --- a/client/src/TrackStore.spec.ts +++ b/client/src/TrackStore.spec.ts @@ -84,3 +84,17 @@ describe('TrackStore', () => { called = false; }); }); + +describe('TrackStore pipeline reload', () => { + it('clears every indexed detection before loading replacement results', () => { + const store = new TrackStore({ markChangesPending: () => null, cameraName: 'left' }); + store.add(4, 'fish', undefined, 7); + store.add(4, 'fish', undefined, 8); + store.add(9, 'fish', undefined, 9); + store.clearAll(); + expect(store.intervalTree.search([0, 20])).toEqual([]); + store.add(4, 'fish', undefined, 7); + expect(store.intervalTree.search([4, 4])).toEqual(['7']); + expect(store.intervalTree.search([9, 9])).toEqual([]); + }); +}); diff --git a/client/src/components/LayerManager.spec.ts b/client/src/components/LayerManager.spec.ts index 01b542bc5..5f824acbe 100644 --- a/client/src/components/LayerManager.spec.ts +++ b/client/src/components/LayerManager.spec.ts @@ -1,7 +1,8 @@ /* eslint-disable max-classes-per-file -- lightweight layer doubles */ -import { - defineComponent, h, ref, +import Vue, { + defineComponent, h, ref, nextTick, } from 'vue'; +import type { Ref } from 'vue'; import { shallowMount } from '@vue/test-utils'; import Track, { Feature } from '../track'; import CameraStore from '../CameraStore'; @@ -12,6 +13,7 @@ import LayerManager from './LayerManager.vue'; const layerMocks = vi.hoisted(() => { const rectangleChangeData = vi.fn(); + const mountWidget = vi.fn(); class MockLayer { bus = { $on: vi.fn() }; @@ -44,7 +46,7 @@ const layerMocks = vi.hoisted(() => { update = vi.fn(); - addDOMWidget = vi.fn(); + addDOMWidget = mountWidget; setToolTipWidget = vi.fn(); @@ -55,7 +57,9 @@ const layerMocks = vi.hoisted(() => { changeData = rectangleChangeData; } - return { MockLayer, MockRectangleLayer, rectangleChangeData }; + return { + MockLayer, MockRectangleLayer, rectangleChangeData, mountWidget, + }; }); const provided = vi.hoisted(() => ({ @@ -316,9 +320,11 @@ function renderCamera( pendingSaveCount: ref(0), }; layerMocks.rectangleChangeData.mockClear(); - mountLayerManager({ camera }); + const wrapper = mountLayerManager({ camera }); const { calls } = layerMocks.rectangleChangeData.mock; - return calls[calls.length - 1][0] as { styleType: [string, number] }[]; + const frameData = calls[calls.length - 1][0] as { styleType: [string, number] }[]; + wrapper.destroy(); + return frameData; } describe('LayerManager multicamera hierarchy selection', () => { @@ -352,3 +358,34 @@ describe('LayerManager multicamera hierarchy selection', () => { expect(renderCamera(cameraStore, trackFilters, 'right')).toHaveLength(0); }); }); + +describe('LayerManager pipeline reload lifecycle', () => { + it('stops redraw watchers when the old viewer unmounts, including after mounting a tooltip', async () => { + // GeoJS tooltips mount their own Vue root. Exercise that real Vue lifecycle: + // mounting it during setup detaches subsequent watches from LayerManager. + const widgets: Vue[] = []; + layerMocks.mountWidget.mockImplementation(() => { + const Tooltip = defineComponent({ setup: () => () => h('span') }); + widgets.push(new Vue({ render: (createElement) => createElement(Tooltip) }).$mount()); + }); + const { cameraStore, trackFilters } = makeMultiCamFixture([['fish', 1]], [['fish', 1]], {}); + try { + renderCamera(cameraStore, trackFilters, 'left'); // mounts and destroys the old manager + const selectedKey = provided.values?.selectedKey as Ref; + layerMocks.rectangleChangeData.mockClear(); + selectedKey.value = '1'; + await nextTick(); + expect(layerMocks.rectangleChangeData).not.toHaveBeenCalled(); + + const replacement = mountLayerManager({ camera: 'left' }); + layerMocks.rectangleChangeData.mockClear(); + selectedKey.value = ''; + await nextTick(); + expect(layerMocks.rectangleChangeData).toHaveBeenCalledTimes(1); + replacement.destroy(); + } finally { + widgets.forEach((widget) => widget.$destroy()); + layerMocks.mountWidget.mockReset(); + } + }); +}); diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index 62e6bac01..c27596396 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -1,6 +1,6 @@