Skip to content

Commit 734d29a

Browse files
committed
fix(dataset): finish teardown before releasing image data
1 parent 5e14b61 commit 734d29a

7 files changed

Lines changed: 131 additions & 17 deletions

File tree

‎src/components/SliceSlider.vue‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export default {
6363
return {
6464
maxHandlePos: 0,
6565
dragging: false,
66+
pointerId: null,
6667
initialHandlePos: 0,
6768
initialMousePosY: 0,
6869
yOffset: 0,
@@ -96,27 +97,31 @@ export default {
9697
},
9798
9899
beforeUnmount() {
99-
this.resizeObserver.disconnect();
100+
this.resizeObserver?.disconnect();
100101
},
101102
102103
methods: {
103104
updateMaxHandlePos() {
105+
if (!this.$refs.handleContainer) return;
104106
this.maxHandlePos =
105107
this.$refs.handleContainer.clientHeight - this.handleHeight;
106108
},
107109
108110
onDragStart(ev) {
111+
const container = this.$refs.handleContainer;
112+
if (!container) return;
109113
ev.preventDefault();
110114
111115
this.dragging = true;
116+
this.pointerId = ev.pointerId;
112117
this.initialMousePosY = ev.pageY;
113118
114119
if (ev.target === this.$refs.handle) {
115120
const handleStyles = window.getComputedStyle(this.$refs.handle);
116121
this.initialHandlePos = getYOffsetFromTransform(handleStyles.transform);
117122
} else {
118123
// move handle to mouse pos
119-
const { y } = this.$refs.handleContainer.getBoundingClientRect();
124+
const { y } = container.getBoundingClientRect();
120125
this.initialHandlePos = Math.max(
121126
0,
122127
Math.min(this.maxHandlePos, ev.pageY - y - this.handleHeight / 2)
@@ -127,11 +132,12 @@ export default {
127132
128133
this.yOffset = 0;
129134
130-
this.$refs.handleContainer.setPointerCapture(ev.pointerId);
135+
container.setPointerCapture(ev.pointerId);
131136
},
132137
133138
onDragMove(ev) {
134-
if (!this.$refs.handleContainer.hasPointerCapture(ev.pointerId)) return;
139+
const container = this.$refs.handleContainer;
140+
if (!container?.hasPointerCapture(ev.pointerId)) return;
135141
ev.preventDefault();
136142
137143
this.yOffset = ev.pageY - this.initialMousePosY;
@@ -140,11 +146,18 @@ export default {
140146
},
141147
142148
onDragEnd(ev) {
143-
if (!this.$refs.handleContainer.hasPointerCapture(ev.pointerId)) return;
144-
ev.preventDefault();
145-
this.$refs.handleContainer.releasePointerCapture(ev.pointerId);
149+
const container = this.$refs.handleContainer;
150+
if (ev.pointerId !== this.pointerId) return;
151+
const captured = container?.hasPointerCapture(ev.pointerId);
152+
if (captured) {
153+
ev.preventDefault();
154+
container.releasePointerCapture(ev.pointerId);
155+
}
146156
147157
this.dragging = false;
158+
this.pointerId = null;
159+
if (!captured) return;
160+
148161
const slice = this.getNearestSlice(this.handlePosition);
149162
this.$emit('update:modelValue', slice);
150163
},
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { mount } from '@vue/test-utils';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import SliceSlider from '@/src/components/SliceSlider.vue';
5+
6+
const mountSlider = () =>
7+
mount(SliceSlider, {
8+
props: { min: 0, max: 10, step: 1 },
9+
});
10+
11+
describe('SliceSlider', () => {
12+
it('ignores a late pointer event after its element is gone', () => {
13+
const wrapper = mountSlider();
14+
const vm = wrapper.vm as unknown as {
15+
onDragMove: (event: PointerEvent) => void;
16+
};
17+
wrapper.unmount();
18+
19+
expect(() => vm.onDragMove({ pointerId: 7 } as PointerEvent)).not.toThrow();
20+
});
21+
22+
it('keeps dragging when an unrelated pointer ends', async () => {
23+
const wrapper = mountSlider();
24+
const container = wrapper.element as HTMLElement;
25+
const releasePointerCapture = vi.fn();
26+
container.hasPointerCapture = vi.fn((pointerId) => pointerId === 7);
27+
container.releasePointerCapture = releasePointerCapture;
28+
29+
await wrapper.trigger('pointerdown', { pointerId: 7, pageY: 10 });
30+
31+
const vm = wrapper.vm as unknown as {
32+
dragging: boolean;
33+
pointerId: number | null;
34+
onDragEnd: (event: PointerEvent) => void;
35+
};
36+
vm.onDragEnd({ pointerId: 8 } as PointerEvent);
37+
38+
expect(vm.dragging).toBe(true);
39+
expect(vm.pointerId).toBe(7);
40+
expect(releasePointerCapture).not.toHaveBeenCalled();
41+
});
42+
});

‎src/store/__tests__/datasetRemoveCascade.spec.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { beforeEach, describe, expect, it } from 'vitest';
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
22
import { setActivePinia, createPinia } from 'pinia';
3+
import { nextTick } from 'vue';
34
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
45
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
56

@@ -120,6 +121,25 @@ describe('dataset remove — synchronous reference cascade', () => {
120121
expect(viewStore.getViewsForData('img-1')).toEqual([]);
121122
});
122123

124+
it('detaches consumers before disposing the removed image', async () => {
125+
seatImage('img-1', 'CT');
126+
const imageCacheStore = useImageCacheStore();
127+
const image = imageCacheStore.imageById['img-1'];
128+
const dispose = vi.spyOn(image, 'dispose');
129+
const viewStore = useViewStore();
130+
bindFirstViewTo('img-1');
131+
132+
useDatasetStore().remove('img-1');
133+
134+
expect(imageCacheStore.imageById).not.toHaveProperty('img-1');
135+
expect(viewStore.getViewsForData('img-1')).toEqual([]);
136+
expect(dispose).not.toHaveBeenCalled();
137+
138+
await nextTick();
139+
140+
expect(dispose).toHaveBeenCalledOnce();
141+
});
142+
123143
it('drops crop state keyed by the removed image', () => {
124144
seatImage('img-1', 'CT');
125145
const cropStore = useCropStore();
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { createPinia, setActivePinia } from 'pinia';
3+
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
4+
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
5+
6+
import { useImageCacheStore } from '@/src/store/image-cache';
7+
8+
const seatImage = () => {
9+
const data = vtkImageData.newInstance();
10+
data.setDimensions(2, 2, 2);
11+
data.getPointData().setScalars(
12+
vtkDataArray.newInstance({
13+
name: 'scalars',
14+
numberOfComponents: 1,
15+
values: new Uint8Array(8),
16+
})
17+
);
18+
const store = useImageCacheStore();
19+
store.addVTKImageData(data, 'CT', { id: 'img-1' });
20+
return store;
21+
};
22+
23+
describe('image cache lifecycle', () => {
24+
beforeEach(() => {
25+
setActivePinia(createPinia());
26+
});
27+
28+
it('treats an image whose VTK data is unavailable as absent', () => {
29+
const store = seatImage();
30+
vi.spyOn(store.imageById['img-1'], 'getVtkImageData').mockReturnValue(
31+
undefined as never
32+
);
33+
34+
expect(store.getVtkImageData('img-1')).toBeNull();
35+
});
36+
});

‎src/store/datasets-images.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ export const useImageStore = defineStore('images', () => {
5151
}
5252

5353
function deleteData(id: string) {
54-
useImageCacheStore().removeImage(id);
5554
removeFromArray(idList.value, id);
55+
useImageCacheStore().removeImage(id);
5656
}
5757

5858
function checkAllImagesSameSpace() {

‎src/store/datasets.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,10 +256,12 @@ export const useDatasetStore = defineStore('dataset', () => {
256256
// Anonymous volume.
257257
loadedData.value = loadedData.value.filter((d) => d.dataID !== id);
258258
dicomStore.deleteVolume(id);
259-
imageStore.deleteData(id);
260259
layersStore.remove(id);
261260
useViewConfigStore().removeData(id);
262261
useImageStatsStore().removeData(id);
262+
// Cache eviction disposes the VTK object after Vue has flushed consumers.
263+
// Run every other synchronous reference cleanup before starting eviction.
264+
imageStore.deleteData(id);
263265
};
264266

265267
const removeAll = () => {

‎src/store/image-cache.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { Maybe } from '@/src/types';
99
import { ImageMetadata } from '@/src/types/image';
1010
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
1111
import { defineStore } from 'pinia';
12-
import { markRaw, reactive, ref } from 'vue';
12+
import { markRaw, nextTick, reactive, ref } from 'vue';
1313

1414
/**
1515
* An internal cache of progressively loadable images.
@@ -37,7 +37,7 @@ export const useImageCacheStore = defineStore('image-cache', () => {
3737
const data = image.getVtkImageData();
3838
// ProgressiveImage initializes with empty vtkImageData before actual data loads.
3939
// VTK.js volume renderer crashes on empty data (null scalar texture).
40-
if (!data.getPointData().getScalars()?.getData()?.length) return null;
40+
if (!data?.getPointData().getScalars()?.getData()?.length) return null;
4141
return data;
4242
}
4343

@@ -116,19 +116,20 @@ export const useImageCacheStore = defineStore('image-cache', () => {
116116

117117
function removeImage(id: string) {
118118
if (!(id in imageById)) return;
119+
const image = imageById[id];
119120
unregisterListeners(id);
120121

121-
// Release vtk data and any per-image caches (e.g. cine compressed frames
122-
// and decoded-frame LRU). Without this, removing a dataset leaks all of
123-
// its memory until the page reloads.
124-
imageById[id].dispose();
125-
126122
const idx = imageIds.value.indexOf(id);
127123
if (idx > -1) imageIds.value.splice(idx, 1);
128124
delete imageById[id];
129125
delete imageStatus[id];
130126
delete imageLoading[id];
131127
delete imageErrors[id];
128+
129+
// Vue tears down image consumers in its next update flush. Keep the VTK
130+
// object alive until those consumers have detached their actors and event
131+
// handlers, but make it unreachable from the cache immediately.
132+
void nextTick(() => image.dispose());
132133
[...deletionCallbacks].forEach((callback) => callback([id]));
133134
}
134135

0 commit comments

Comments
 (0)