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:
@@ -160,6 +160,13 @@ function cleanupDragListeners() {
|
|||||||
document.removeEventListener('pointercancel', handleDocumentCancel);
|
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() {
|
function finalizeDrag() {
|
||||||
if (dragStartIndex === null || dragEndIndex === null) return;
|
if (dragStartIndex === null || dragEndIndex === null) return;
|
||||||
const start = dragStartIndex;
|
const start = dragStartIndex;
|
||||||
|
|||||||
@@ -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', () => {
|
describe('TimelineDensityFilter — drag-to-select-range', () => {
|
||||||
function pointerDown(el: HTMLElement) {
|
function pointerDown(el: HTMLElement) {
|
||||||
const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 });
|
const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 });
|
||||||
|
|||||||
Reference in New Issue
Block a user