fix(documents): cleanup timeline drag listeners on unmount (#385)

Pointerdown attaches three document-level listeners. Without an
explicit teardown, an unmount mid-drag (route change, view toggle,
viewport drops below lg) left them attached and they kept writing
to torn-down state cells.

Wrap the cleanup in $effect's return, which Svelte 5 invokes on
unmount. The listener-removal regression test pins this so the bug
cannot come back silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-08 10:56:58 +02:00
parent 2e9ce8e1da
commit 3b6b117c75
2 changed files with 31 additions and 0 deletions

View File

@@ -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;

View File

@@ -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 });