diff --git a/src/client/client-host-ref.ts b/src/client/client-host-ref.ts index eb7d169bb56..3c3d80db58e 100644 --- a/src/client/client-host-ref.ts +++ b/src/client/client-host-ref.ts @@ -1,5 +1,4 @@ import { BUILD } from '@app-data'; -import { addHostEventListeners } from '@runtime'; import type * as d from '../declarations'; @@ -68,7 +67,6 @@ export const registerHost = (hostElement: d.HostElement, cmpMeta: d.ComponentRun hostElement['s-p'] = []; hostElement['s-rc'] = []; } - addHostEventListeners(hostElement, hostRef, cmpMeta.$listeners$, false); return hostRefs.set(hostElement, hostRef); }; diff --git a/src/hydrate/platform/hydrate-app.ts b/src/hydrate/platform/hydrate-app.ts index c0f544e6048..fc554b3a27a 100644 --- a/src/hydrate/platform/hydrate-app.ts +++ b/src/hydrate/platform/hydrate-app.ts @@ -1,5 +1,5 @@ import { globalScripts } from '@app-globals'; -import { doc, getHostRef, loadModule, plt, registerHost } from '@platform'; +import { addHostEventListeners, doc, getHostRef, loadModule, plt, registerHost } from '@platform'; import { connectedCallback, insertVdomAnnotations } from '@runtime'; import type * as d from '../../declarations'; @@ -183,6 +183,9 @@ async function hydrateComponent( if (cmpMeta != null) { waitingElements.add(elm); + const hostRef = getHostRef(this); + addHostEventListeners(this, hostRef, cmpMeta.$listeners$, false); + try { connectedCallback(elm); await elm.componentOnReady(); diff --git a/src/hydrate/platform/index.ts b/src/hydrate/platform/index.ts index 536fa8bcc72..0cf8200232d 100644 --- a/src/hydrate/platform/index.ts +++ b/src/hydrate/platform/index.ts @@ -1,5 +1,4 @@ import { BUILD } from '@app-data'; -import { addHostEventListeners } from '@runtime'; import type * as d from '../../declarations'; @@ -146,7 +145,6 @@ export const registerHost = (elm: d.HostElement, cmpMeta: d.ComponentRuntimeMeta hostRef.$onReadyPromise$ = new Promise((r) => (hostRef.$onReadyResolve$ = r)); elm['s-p'] = []; elm['s-rc'] = []; - addHostEventListeners(elm, hostRef, cmpMeta.$listeners$, false); return hostRefs.set(elm, hostRef); }; diff --git a/src/runtime/bootstrap-custom-element.ts b/src/runtime/bootstrap-custom-element.ts index 815ba6738e8..0f6964e5be2 100644 --- a/src/runtime/bootstrap-custom-element.ts +++ b/src/runtime/bootstrap-custom-element.ts @@ -1,5 +1,5 @@ import { BUILD } from '@app-data'; -import { forceUpdate, getHostRef, registerHost, styles, supportsShadow } from '@platform'; +import { addHostEventListeners, forceUpdate, getHostRef, registerHost, styles, supportsShadow } from '@platform'; import { CMP_FLAGS } from '@utils'; import type * as d from '../declarations'; @@ -72,6 +72,9 @@ export const proxyCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMet registerHost(this, cmpMeta); }, connectedCallback() { + const hostRef = getHostRef(this); + addHostEventListeners(this, hostRef, cmpMeta.$listeners$, false); + connectedCallback(this); if (BUILD.connectedCallback && originalConnectedCallback) { originalConnectedCallback.call(this); diff --git a/src/runtime/bootstrap-lazy.ts b/src/runtime/bootstrap-lazy.ts index 22677f85b2b..6bb5a261488 100644 --- a/src/runtime/bootstrap-lazy.ts +++ b/src/runtime/bootstrap-lazy.ts @@ -1,5 +1,6 @@ import { BUILD } from '@app-data'; import { doc, getHostRef, plt, registerHost, supportsShadow, win } from '@platform'; +import { addHostEventListeners } from '@runtime'; import { CMP_FLAGS, queryNonceMetaTagContent } from '@utils'; import type * as d from '../declarations'; @@ -96,6 +97,7 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d. const HostElement = class extends HTMLElement { ['s-p']: Promise[]; ['s-rc']: (() => void)[]; + hasRegisteredEventListeners = false; // StencilLazyHost constructor(self: HTMLElement) { @@ -138,6 +140,19 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d. } connectedCallback() { + const hostRef = getHostRef(this); + + /** + * The `connectedCallback` lifecycle event can potentially be fired multiple times + * if the element is removed from the DOM and re-inserted. This is not a common use case, + * but it can happen in some scenarios. To prevent registering the same event listeners + * multiple times, we will only register them once. + */ + if (!this.hasRegisteredEventListeners) { + this.hasRegisteredEventListeners = true; + addHostEventListeners(this, hostRef, cmpMeta.$listeners$, false); + } + if (appLoadFallback) { clearTimeout(appLoadFallback); appLoadFallback = null; diff --git a/src/runtime/test/listen.spec.tsx b/src/runtime/test/listen.spec.tsx index 4de1e3600a9..31ec75b5ff2 100644 --- a/src/runtime/test/listen.spec.tsx +++ b/src/runtime/test/listen.spec.tsx @@ -201,4 +201,36 @@ describe('listen', () => { await waitForChanges(); expect(a).toEqualHtml(`1 7`); }); + + it('disconnects target listeners when element is not connected to DOM', async () => { + let events = 0; + @Component({ tag: 'cmp-a' }) + class CmpA { + @Listen('testEvent', { target: 'document' }) + buttonClick() { + events++; + } + + render() { + return ''; + } + } + + const { doc, waitForChanges } = await newSpecPage({ + components: [CmpA], + }); + + jest.spyOn(doc, 'addEventListener'); + jest.spyOn(doc, 'removeEventListener'); + + doc.createElement('cmp-a'); + await waitForChanges(); + + // Event listener will never be called + expect(events).toEqual(0); + + // no event listeners have been added as the element is not connected to the DOM + expect(doc.addEventListener.mock.calls.length).toBe(0); + expect(doc.removeEventListener.mock.calls.length).toBe(0); + }); }); diff --git a/src/testing/platform/testing-host-ref.ts b/src/testing/platform/testing-host-ref.ts index aa567516f59..6adcc8d9a3c 100644 --- a/src/testing/platform/testing-host-ref.ts +++ b/src/testing/platform/testing-host-ref.ts @@ -1,4 +1,3 @@ -import { addHostEventListeners } from '@runtime'; import type * as d from '@stencil/core/internal'; import { hostRefs } from './testing-constants'; @@ -57,6 +56,5 @@ export const registerHost = (elm: d.HostElement, cmpMeta: d.ComponentRuntimeMeta hostRef.$onReadyPromise$ = new Promise((r) => (hostRef.$onReadyResolve$ = r)); elm['s-p'] = []; elm['s-rc'] = []; - addHostEventListeners(elm, hostRef, cmpMeta.$listeners$, false); hostRefs.set(elm, hostRef); };