diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte b/frontend/src/lib/document/TimelineDensityFilter.svelte index 322dd3c3..5c8e99b6 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte @@ -160,6 +160,13 @@ function cleanupDragListeners() { document.removeEventListener('pointercancel', handleDocumentCancel); } +// Strip any in-flight document listeners if the component unmounts mid-drag +// (route change, view toggle, breakpoint drop). Without this they survive on +// document and keep writing to torn-down state cells. +$effect(() => { + return cleanupDragListeners; +}); + function finalizeDrag() { if (dragStartIndex === null || dragEndIndex === null) return; const start = dragStartIndex; diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts index 0be61ffa..cff9180e 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts @@ -389,6 +389,30 @@ describe('TimelineDensityFilter — aria-live during drag', () => { }); }); +describe('TimelineDensityFilter — listener cleanup on unmount', () => { + it('removes document pointer listeners when unmounted mid-drag', async () => { + const removed: string[] = []; + const realRemove = document.removeEventListener.bind(document); + const removeSpy = vi + .spyOn(document, 'removeEventListener') + .mockImplementation((type: string, listener, options) => { + removed.push(type); + return realRemove(type, listener as EventListener, options); + }); + + render(TimelineDensityFilter, makeProps()); + const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement; + bar.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 })); + + cleanup(); + + expect(removed).toContain('pointermove'); + expect(removed).toContain('pointerup'); + expect(removed).toContain('pointercancel'); + removeSpy.mockRestore(); + }); +}); + describe('TimelineDensityFilter — drag-to-select-range', () => { function pointerDown(el: HTMLElement) { const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 });