From 51c0fab1ce1c384e05d989155f2235e390b11dd1 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 30 Jul 2026 21:33:37 -0700 Subject: [PATCH] fix: Do not activate pan & zoom on a detached element (#2640) - Closes: #2633 --- src/components-lib/zoom/zoom-controller.ts | 6 ++ .../zoom/zoom-controller.test.ts | 86 +++++++++++++------ 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/src/components-lib/zoom/zoom-controller.ts b/src/components-lib/zoom/zoom-controller.ts index 802cea30..f29ccae8 100644 --- a/src/components-lib/zoom/zoom-controller.ts +++ b/src/components-lib/zoom/zoom-controller.ts @@ -111,6 +111,12 @@ export class ZoomController { } public activate(): void { + // Panzoom throws if it is constructed against an element that is not + // attached to the DOM. + if (!this._element.isConnected) { + return; + } + const config = this._getConfigToUse(); const converted = this._convertPercentToXYPan( config?.pan?.x ?? ZOOM_DEFAULT_PAN_X, diff --git a/tests/components-lib/zoom/zoom-controller.test.ts b/tests/components-lib/zoom/zoom-controller.test.ts index 18040b7a..16f7bc29 100644 --- a/tests/components-lib/zoom/zoom-controller.test.ts +++ b/tests/components-lib/zoom/zoom-controller.test.ts @@ -2,7 +2,16 @@ import Panzoom, { type PanzoomEventDetail, type PanzoomObject, } from '@dermotduffy/panzoom'; -import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from 'vitest'; import { mock, mockClear } from 'vitest-mock-extended'; import { ZoomController } from '../../../src/components-lib/zoom/zoom-controller'; @@ -29,6 +38,13 @@ const triggerResizeObserver = (): void => { resizeObserverTrigger([], mock()); }; +// The controller only activates against an attached element. +const createAttachedElement = (): HTMLElement => { + const element = document.createElement('div'); + document.body.appendChild(element); + return element; +}; + const setElementToDefaultCardSize = (element: HTMLElement, multiple?: number): void => { element.getBoundingClientRect = vi.fn().mockReturnValue({ width: 492 * (multiple ?? 1), @@ -65,15 +81,19 @@ describe('ZoomController', () => { mediaSpy.mockReturnValue({ matches: true }); }); + afterEach(() => { + document.body.replaceChildren(); + }); + it('should be creatable', () => { - const element = document.createElement('div'); + const element = createAttachedElement(); const zoom = new ZoomController(element); expect(zoom).toBeTruthy(); }); describe('should pan and zoom', () => { it('should respond with pointer', () => { - const element = document.createElement('div'); + const element = createAttachedElement(); const panzoom = createMockPanZoom(); vi.mocked(Panzoom).mockReturnValueOnce(panzoom); @@ -109,7 +129,7 @@ describe('ZoomController', () => { }); it('should not respond to pointer when not zoomed', () => { - const element = document.createElement('div'); + const element = createAttachedElement(); const panzoom = createMockPanZoom(); vi.mocked(Panzoom).mockReturnValueOnce(panzoom); @@ -132,7 +152,7 @@ describe('ZoomController', () => { it('should respond with touch', () => { mediaSpy.mockReturnValue({ matches: false }); - const element = document.createElement('div'); + const element = createAttachedElement(); const panzoom = createMockPanZoom(); vi.mocked(Panzoom).mockReturnValueOnce(panzoom); @@ -162,8 +182,8 @@ describe('ZoomController', () => { }); it('should ignore click after pointerdown', () => { - const outer = document.createElement('div'); - const inner = document.createElement('div'); + const outer = createAttachedElement(); + const inner = createAttachedElement(); outer.appendChild(inner); const clickHandler = vi.fn(); outer.addEventListener('click', clickHandler); @@ -204,7 +224,7 @@ describe('ZoomController', () => { }); it('deactivate should remove event handlers', () => { - const element = document.createElement('div'); + const element = createAttachedElement(); const panzoom = createMockPanZoom(); vi.mocked(Panzoom).mockReturnValueOnce(panzoom); @@ -222,7 +242,7 @@ describe('ZoomController', () => { describe('should fire events', () => { it('on zoom/unzoom', () => { - const element = document.createElement('div'); + const element = createAttachedElement(); const zoomedFunc = vi.fn(); const unzoomedFunc = vi.fn(); element.addEventListener('advanced-camera-card:zoom:zoomed', zoomedFunc); @@ -258,7 +278,7 @@ describe('ZoomController', () => { }); it('when state has not changed or spurious events received', () => { - const element = document.createElement('div'); + const element = createAttachedElement(); const zoomedFunc = vi.fn(); const unzoomedFunc = vi.fn(); element.addEventListener('advanced-camera-card:zoom:zoomed', zoomedFunc); @@ -302,7 +322,7 @@ describe('ZoomController', () => { describe('on default/non-default', () => { it('without explicit default', () => { - const element = document.createElement('div'); + const element = createAttachedElement(); setElementToDefaultCardSize(element); const changeFunc = vi.fn(); @@ -347,7 +367,7 @@ describe('ZoomController', () => { }); it('with complete explicit default', () => { - const element = document.createElement('div'); + const element = createAttachedElement(); setElementToDefaultCardSize(element); const changeFunc = vi.fn(); @@ -393,7 +413,7 @@ describe('ZoomController', () => { }); it('with partial explicit default', () => { - const element = document.createElement('div'); + const element = createAttachedElement(); setElementToDefaultCardSize(element); const changeFunc = vi.fn(); @@ -446,7 +466,7 @@ describe('ZoomController', () => { const panzoom = createMockPanZoom(); vi.mocked(Panzoom).mockReturnValueOnce(panzoom); - const element = document.createElement('div'); + const element = createAttachedElement(); setElementToDefaultCardSize(element); const controller = new ZoomController(element); @@ -477,7 +497,7 @@ describe('ZoomController', () => { const panzoom = createMockPanZoom(); vi.mocked(Panzoom).mockReturnValueOnce(panzoom); - const element = document.createElement('div'); + const element = createAttachedElement(); setElementToDefaultCardSize(element); const controller = createAndRegisterZoom(element); @@ -496,7 +516,7 @@ describe('ZoomController', () => { const panzoom = createMockPanZoom(); vi.mocked(Panzoom).mockReturnValueOnce(panzoom); - const element = document.createElement('div'); + const element = createAttachedElement(); setElementToDefaultCardSize(element); const controller = createAndRegisterZoom(element); @@ -528,7 +548,7 @@ describe('ZoomController', () => { const panzoom = createMockPanZoom(); vi.mocked(Panzoom).mockReturnValueOnce(panzoom); - const element = document.createElement('div'); + const element = createAttachedElement(); setElementToDefaultCardSize(element); const controller = createAndRegisterZoom(element); @@ -567,7 +587,7 @@ describe('ZoomController', () => { const panzoom = createMockPanZoom(); vi.mocked(Panzoom).mockReturnValueOnce(panzoom); - const element = document.createElement('div'); + const element = createAttachedElement(); setElementToDefaultCardSize(element); const controller = createAndRegisterZoom(element); @@ -588,7 +608,7 @@ describe('ZoomController', () => { const panzoom = createMockPanZoom(); vi.mocked(Panzoom).mockReturnValueOnce(panzoom); - const element = document.createElement('div'); + const element = createAttachedElement(); setElementToDefaultCardSize(element); const controller = createAndRegisterZoom(element); @@ -617,7 +637,7 @@ describe('ZoomController', () => { const panzoom = createMockPanZoom(); vi.mocked(Panzoom).mockReturnValueOnce(panzoom); - const element = document.createElement('div'); + const element = createAttachedElement(); setElementToDefaultCardSize(element); new ZoomController(element); @@ -632,7 +652,7 @@ describe('ZoomController', () => { const panzoom = createMockPanZoom(); vi.mocked(Panzoom).mockReturnValueOnce(panzoom); - const element = document.createElement('div'); + const element = createAttachedElement(); element.getBoundingClientRect = vi.fn().mockReturnValue({ width: 0, height: 0, @@ -647,7 +667,7 @@ describe('ZoomController', () => { }); it('should report activation state', () => { - const element = document.createElement('div'); + const element = createAttachedElement(); vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom()); const controller = new ZoomController(element); @@ -660,8 +680,26 @@ describe('ZoomController', () => { expect(controller.isActivated()).toBe(false); }); - it('should not zoom or pan when zoom is disabled', () => { + it('should not activate when the element is detached', () => { const element = document.createElement('div'); + vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom()); + + const controller = new ZoomController(element); + controller.activate(); + + expect(Panzoom).not.toHaveBeenCalled(); + expect(controller.isActivated()).toBe(false); + + // Activation succeeds once the element is attached. + document.body.appendChild(element); + controller.activate(); + + expect(Panzoom).toHaveBeenCalled(); + expect(controller.isActivated()).toBe(true); + }); + + it('should not zoom or pan when zoom is disabled', () => { + const element = createAttachedElement(); const panzoom = createMockPanZoom(); vi.mocked(Panzoom).mockReturnValueOnce(panzoom); @@ -680,7 +718,7 @@ describe('ZoomController', () => { }); it('should set touch action on zoom/unzoom', () => { - const element = document.createElement('div'); + const element = createAttachedElement(); vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom()); createAndRegisterZoom(element);