fix(overlays): rework setup and teardown logic of OverlayMixin

Add a failing test

chore: broken test fix

chore: harden overlay teardown tests and cleanup select-rich
This commit is contained in:
Thijs Louisse 2025-01-06 14:40:12 +01:00 committed by Thijs Louisse
parent fef94cd016
commit 8377e8d17d
11 changed files with 371 additions and 36 deletions

View file

@ -2,4 +2,4 @@
'@lion/ui': patch '@lion/ui': patch
--- ---
overlayController teardown cleanup (removes logs in test files) [overlays]: overlayController teardown cleanup (removes logs in test files)

View file

@ -0,0 +1,5 @@
---
'@lion/ui': patch
---
[overlays]: allow Subclassers to teardown at the right moment

View file

@ -0,0 +1,5 @@
---
'@lion/ui': minor
---
[overlays]: rework setup and teardown logic of OverlayMixin (allow to reconnect offline dom nodes; allow moving dom nodes in performant way)

View file

@ -229,6 +229,8 @@ describe('lion-combobox', () => {
</lion-combobox> </lion-combobox>
`) `)
); );
await el.updateComplete;
const { _inputNode } = getComboboxMembers(el); const { _inputNode } = getComboboxMembers(el);
_inputNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); _inputNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));

View file

@ -0,0 +1,43 @@
import { html, LitElement } from 'lit';
class TestRouter extends LitElement {
static properties = {
routingMap: {
type: Object,
attribute: false,
},
path: { type: String },
};
constructor() {
super();
/** @type {{ [path: string]: HTMLElement }} */
this.routingMap = {};
/** @type {string} */
this.path = '';
}
render() {
return html`
<div>
<div id="selector">
${Object.keys(this.routingMap).map(
path =>
html`<button
id="path-${path}"
@click="${() => {
this.path = path;
}}"
>
${path}
</button>`,
)}
</div>
<div id="view">${this.routingMap[this.path]}</div>
</div>
`;
}
}
customElements.define('test-router', TestRouter);

View file

@ -2,6 +2,7 @@
import { expect, fixture as _fixture, html, unsafeStatic, aTimeout } from '@open-wc/testing'; import { expect, fixture as _fixture, html, unsafeStatic, aTimeout } from '@open-wc/testing';
import { runOverlayMixinSuite } from '../../overlays/test-suites/OverlayMixin.suite.js'; import { runOverlayMixinSuite } from '../../overlays/test-suites/OverlayMixin.suite.js';
import { isActiveElement } from '../../core/test-helpers/isActiveElement.js'; import { isActiveElement } from '../../core/test-helpers/isActiveElement.js';
import '../test-helpers/test-router.js';
import '@lion/ui/define/lion-dialog.js'; import '@lion/ui/define/lion-dialog.js';
/** /**
@ -187,4 +188,57 @@ describe('lion-dialog', () => {
expect(invokerButton.getAttribute('aria-expanded')).to.equal(null); expect(invokerButton.getAttribute('aria-expanded')).to.equal(null);
}); });
}); });
describe('Edge cases', () => {
it('does not lose click event handler when was detached and reattached', async () => {
const el = await fixture(html`
<test-router
.routingMap="${{
a: html`
<lion-dialog>
<div slot="content" class="dialog">Hey there</div>
<button slot="invoker">Popup button</button>
</lion-dialog>
`,
b: html` <div>B</div> `,
}}"
path="a"
></test-router>
`);
const getDialog = () =>
/** @type {LionDialog} */ (el.shadowRoot?.querySelector('lion-dialog'));
const getInvoker = () =>
/** @type {HTMLElement} */ (getDialog().querySelector('[slot="invoker"]'));
getInvoker().click();
const dialog = getDialog();
// @ts-expect-error [allow-protected-in-tests]
await dialog._overlayCtrl._showComplete;
expect(dialog.opened).to.be.true;
dialog.close();
// @ts-expect-error [allow-protected-in-tests]
await dialog._overlayCtrl._hideComplete;
expect(dialog.opened).to.be.false;
const buttonA = /** @type {HTMLElement} */ (el.shadowRoot?.querySelector('#path-a'));
const buttonB = /** @type {HTMLElement} */ (el.shadowRoot?.querySelector('#path-b'));
buttonB.click();
await el.updateComplete;
buttonA.click();
await el.updateComplete;
await el.updateComplete;
getInvoker().click();
const dialogAfterRouteChange = getDialog();
// @ts-expect-error [allow-protected-in-tests]
expect(dialogAfterRouteChange._overlayCtrl).not.to.be.undefined;
// @ts-expect-error [allow-protected-in-tests]
await dialogAfterRouteChange._overlayCtrl._showComplete;
expect(dialogAfterRouteChange.opened).to.be.true;
});
});
}); });

View file

@ -1003,8 +1003,7 @@ export class OverlayController extends EventTarget {
* @param {{ phase: OverlayPhase }} config * @param {{ phase: OverlayPhase }} config
*/ */
_handleVisibilityTriggers({ phase }) { _handleVisibilityTriggers({ phase }) {
if (typeof this.visibilityTriggerFunction !== 'function') return; if (typeof this.visibilityTriggerFunction === 'function') {
if (phase === 'init') { if (phase === 'init') {
this.__visibilityTriggerHandler = this.visibilityTriggerFunction({ this.__visibilityTriggerHandler = this.visibilityTriggerFunction({
phase, phase,
@ -1015,6 +1014,7 @@ export class OverlayController extends EventTarget {
this.__visibilityTriggerHandler[phase](); this.__visibilityTriggerHandler[phase]();
} }
} }
}
/** /**
* @param {{ phase: OverlayPhase }} config * @param {{ phase: OverlayPhase }} config
@ -1192,7 +1192,6 @@ export class OverlayController extends EventTarget {
event.composedPath().includes(this.contentNode) || event.composedPath().includes(this.contentNode) ||
deepContains(this.contentNode, /** @type {HTMLElement|ShadowRoot} */ (event.target)); deepContains(this.contentNode, /** @type {HTMLElement|ShadowRoot} */ (event.target));
if (hasPressedInside) return; if (hasPressedInside) return;
this.hide(); this.hide();
}; };

View file

@ -24,6 +24,8 @@ export const OverlayMixinImplementation = superclass =>
}; };
} }
#hasSetup = false;
constructor() { constructor() {
super(); super();
/** /**
@ -173,22 +175,23 @@ export const OverlayMixinImplementation = superclass =>
} }
} }
/** connectedCallback() {
* @param {import('lit-element').PropertyValues } changedProperties super.connectedCallback();
*/
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.updateComplete.then(() => {
if (this.#hasSetup) return;
this._setupOverlayCtrl(); this._setupOverlayCtrl();
this.#hasSetup = true;
});
} }
async disconnectedCallback() { async disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
if (!this._overlayCtrl) return; if (await this._isPermanentlyDisconnected()) {
if (await this.#isMovingInDom()) return;
this._teardownOverlayCtrl(); this._teardownOverlayCtrl();
this.#hasSetup = false;
}
} }
/** /**
@ -238,6 +241,8 @@ export const OverlayMixinImplementation = superclass =>
/** @protected */ /** @protected */
_setupOverlayCtrl() { _setupOverlayCtrl() {
if (this._overlayCtrl) return;
/** @type {OverlayController} */ /** @type {OverlayController} */
this._overlayCtrl = this._defineOverlay({ this._overlayCtrl = this._defineOverlay({
contentNode: this._overlayContentNode, contentNode: this._overlayContentNode,
@ -253,11 +258,12 @@ export const OverlayMixinImplementation = superclass =>
/** @protected */ /** @protected */
_teardownOverlayCtrl() { _teardownOverlayCtrl() {
if (!this._overlayCtrl) return;
this._teardownOpenCloseListeners(); this._teardownOpenCloseListeners();
this.__teardownSyncFromOverlayController(); this.__teardownSyncFromOverlayController();
/** @type {OverlayController} */ /** @type {OverlayController} */ (this._overlayCtrl).teardown();
(this._overlayCtrl).teardown();
} }
/** /**
@ -396,9 +402,9 @@ export const OverlayMixinImplementation = superclass =>
* Before we decide to teardown, let's wait to see if we were not just moving nodes around. * Before we decide to teardown, let's wait to see if we were not just moving nodes around.
* @return {Promise<boolean>} * @return {Promise<boolean>}
*/ */
async #isMovingInDom() { async _isPermanentlyDisconnected() {
await this.updateComplete; await this.updateComplete;
return this.isConnected; return !this.isConnected;
} }
}; };
export const OverlayMixin = dedupeMixin(OverlayMixinImplementation); export const OverlayMixin = dedupeMixin(OverlayMixinImplementation);

View file

@ -1,18 +1,25 @@
import { expect, fixture, html, nextFrame, aTimeout } from '@open-wc/testing'; import {
unsafeStatic,
import sinon from 'sinon'; nextFrame,
aTimeout,
defineCE,
fixture,
expect,
html,
} from '@open-wc/testing';
import { overlays as overlaysManager, OverlayController } from '@lion/ui/overlays.js'; import { overlays as overlaysManager, OverlayController } from '@lion/ui/overlays.js';
import '@lion/ui/define/lion-dialog.js';
import { browserDetection } from '@lion/ui/core.js'; import { browserDetection } from '@lion/ui/core.js';
import { cache } from 'lit/directives/cache.js';
import '@lion/ui/define/lion-dialog.js';
import { LitElement } from 'lit';
import sinon from 'sinon';
/** /**
* @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig
* @typedef {import('../types/OverlayMixinTypes.js').DefineOverlayConfig} DefineOverlayConfig * @typedef {import('../types/OverlayMixinTypes.js').DefineOverlayConfig} DefineOverlayConfig
* @typedef {import('../types/OverlayMixinTypes.js').OverlayHost} OverlayHost
* @typedef {import('../types/OverlayMixinTypes.js').OverlayMixin} OverlayMixin
* @typedef {import('lit').LitElement} LitElement
* @typedef {LitElement & OverlayHost & {_overlayCtrl:OverlayController}} OverlayEl * @typedef {LitElement & OverlayHost & {_overlayCtrl:OverlayController}} OverlayEl
* @typedef {import('../types/OverlayMixinTypes.js').OverlayMixin} OverlayMixin
* @typedef {import('../types/OverlayMixinTypes.js').OverlayHost} OverlayHost
* @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig
*/ */
function getGlobalOverlayCtrls() { function getGlobalOverlayCtrls() {
@ -331,6 +338,173 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
}).to.not.throw; }).to.not.throw;
stub.restore(); stub.restore();
}); });
describe('Teardown', () => {
it('does not teardown when moving dom nodes', async () => {
const el = /** @type {OverlayEl} */ (
await fixture(html`
<${tag}>
<div slot="content">content</div>
<button slot="invoker">invoker button</button>
</${tag}>
`)
);
// @ts-expect-error [allow-protected] in tests
const teardownSpy = sinon.spy(el, '_teardownOverlayCtrl');
// @ts-expect-error [allow-protected] in tests
const spyDisconnected = sinon.spy(el, '_isPermanentlyDisconnected');
// move around in dom
const newParent = document.createElement('div');
document.body.appendChild(newParent);
newParent.appendChild(el);
expect(spyDisconnected.callCount).to.equal(1);
const otherParent = document.createElement('div');
document.body.appendChild(otherParent);
otherParent.appendChild(el);
await aTimeout(0);
expect(spyDisconnected.callCount).to.equal(2);
expect(teardownSpy.callCount).to.equal(0);
// simulate a permanent disconnect
otherParent.removeChild(el);
await aTimeout(0);
expect(teardownSpy.callCount).to.equal(1);
});
it('allows to disconnect and reconnect later', async () => {
const el = /** @type {OverlayEl} */ (
await fixture(html`
<${tag}>
<div slot="content">content</div>
<button slot="invoker">invoker button</button>
</${tag}>
`)
);
// @ts-expect-error [allow-protected] in tests
const teardownSpy = sinon.spy(el, '_teardownOverlayCtrl');
// @ts-expect-error [allow-protected] in tests
const spyDisconnected = sinon.spy(el, '_isPermanentlyDisconnected');
// @ts-expect-error [allow-protected] in tests
const setupSpy = sinon.spy(el, '_setupOverlayCtrl');
// 'permanently' disconnect (although we will reconnect later)
const offlineParent = document.createElement('div');
offlineParent.appendChild(el);
await aTimeout(0);
expect(spyDisconnected.callCount).to.equal(1);
expect(teardownSpy.callCount).to.equal(1);
// reconnect
document.body.appendChild(offlineParent);
await aTimeout(0);
expect(setupSpy.callCount).to.equal(1);
});
it('works with cache directive when reconnected', async () => {
class CachingContext extends LitElement {
static properties = {
switched: { type: Boolean },
};
constructor() {
super();
this.switched = false;
}
get overlayEl() {
return this.shadowRoot?.querySelector('#myOverlay');
}
render() {
return html`${cache(
this.switched
? html`something else`
: html`<${tag} id="myOverlay">
<div slot="content">content b</div>
<button slot="invoker">invoker button b</button>
</${tag}>`,
)}`;
}
}
const cachingTagName = defineCE(CachingContext);
const cachingTag = unsafeStatic(cachingTagName);
const el = /** @type {CachingContext} */ (
await fixture(html`
<${cachingTag}></${cachingTag}>
`)
);
// @ts-expect-error [allow-protected] in tests
const teardownSpy = sinon.spy(el.overlayEl, '_teardownOverlayCtrl');
// @ts-expect-error [allow-protected] in tests
const spyDisconnected = sinon.spy(el.overlayEl, '_isPermanentlyDisconnected');
// @ts-expect-error [allow-protected] in tests
const setupSpy = sinon.spy(el.overlayEl, '_setupOverlayCtrl');
el.switched = true;
// render the new content
await el.updateComplete;
// wait for the teardown to complete
await el.updateComplete;
expect(spyDisconnected.callCount).to.equal(1);
expect(teardownSpy.callCount).to.equal(1);
expect(setupSpy.callCount).to.equal(0);
el.switched = false;
await el.updateComplete;
expect(setupSpy.callCount).to.equal(1);
});
it('correctly removes event listeners when disconnected from dom', async () => {
const el = /** @type {OverlayEl} */ (
await fixture(html`
<${tag}>
<div slot="content">content</div>
<button slot="invoker">invoker button</button>
</${tag}>
`)
);
const eventRemoveSpy = sinon.spy(el._overlayCtrl, 'removeEventListener');
el.parentNode?.removeChild(el);
await el.updateComplete;
expect(eventRemoveSpy.callCount).to.equal(0);
await el.updateComplete;
const hasOneInstanceFor = (
/** @type {string} */ eventName,
/** @type {any[][]} */ eventsToSearch,
/** @type {string} */ methodToRemove,
) =>
Boolean(
eventsToSearch.filter(
([eventToSearch, methodToSearch]) =>
eventToSearch === eventName && methodToSearch === methodToRemove,
).length,
);
// @ts-expect-error [allow-private] in tests
expect(hasOneInstanceFor('show', eventRemoveSpy.args, el.__onOverlayCtrlShow)).to.be.true;
// @ts-expect-error [allow-private] in tests
expect(hasOneInstanceFor('hide', eventRemoveSpy.args, el.__onOverlayCtrlHide)).to.be.true;
// @ts-expect-error [allow-private] in tests
expect(hasOneInstanceFor('before-show', eventRemoveSpy.args, el.__onBeforeShow)).to.be.true;
// @ts-expect-error [allow-private] in tests
expect(hasOneInstanceFor('before-hide', eventRemoveSpy.args, el.__onBeforeHide)).to.be.true;
});
});
}); });
describe(`OverlayMixin${suffix} nested`, () => { describe(`OverlayMixin${suffix} nested`, () => {
@ -388,6 +562,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
const moveTarget = /** @type {OverlayEl} */ (await fixture('<div id="target"></div>')); const moveTarget = /** @type {OverlayEl} */ (await fixture('<div id="target"></div>'));
moveTarget.appendChild(el); moveTarget.appendChild(el);
await el.updateComplete; await el.updateComplete;
expect(getGlobalOverlayCtrls().length).to.equal(1); expect(getGlobalOverlayCtrls().length).to.equal(1);
} }
}); });

View file

@ -423,6 +423,7 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
*/ */
_teardownOverlayCtrl() { _teardownOverlayCtrl() {
super._teardownOverlayCtrl(); super._teardownOverlayCtrl();
this._overlayCtrl.removeEventListener('show', this.__overlayOnShow); this._overlayCtrl.removeEventListener('show', this.__overlayOnShow);
this._overlayCtrl.removeEventListener('before-show', this.__overlayBeforeShow); this._overlayCtrl.removeEventListener('before-show', this.__overlayBeforeShow);
this._overlayCtrl.removeEventListener('hide', this.__overlayOnHide); this._overlayCtrl.removeEventListener('hide', this.__overlayOnHide);
@ -434,11 +435,12 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
* @protected * @protected
*/ */
async _alignInvokerWidth() { async _alignInvokerWidth() {
await this.updateComplete;
if (!this._overlayCtrl?.content) { if (!this._overlayCtrl?.content) {
return; return;
} }
await this.updateComplete;
const initContentDisplay = this._overlayCtrl.content.style.display; const initContentDisplay = this._overlayCtrl.content.style.display;
const initContentMinWidth = this._overlayCtrl.contentWrapperNode.style.minWidth; const initContentMinWidth = this._overlayCtrl.contentWrapperNode.style.minWidth;
const initContentWidth = this._overlayCtrl.contentWrapperNode.style.width; const initContentWidth = this._overlayCtrl.contentWrapperNode.style.width;

View file

@ -7,6 +7,7 @@ import '@lion/ui/define/lion-select-rich.js';
import '@lion/ui/define/lion-listbox.js'; import '@lion/ui/define/lion-listbox.js';
import '@lion/ui/define/lion-option.js'; import '@lion/ui/define/lion-option.js';
import { LitElement } from 'lit'; import { LitElement } from 'lit';
import sinon from 'sinon';
import { import {
fixture as _fixture, fixture as _fixture,
unsafeStatic, unsafeStatic,
@ -711,6 +712,49 @@ describe('lion-select-rich', () => {
}); });
}); });
describe('Teardown', () => {
it('correctly removes event listeners when disconnected from dom', async () => {
const el = await fixture(html`
<lion-select-rich label="age">
<lion-option .choiceValue=${10}>Item 1</lion-option>
<lion-option .choiceValue=${20}>Item 2</lion-option>
</lion-select-rich>
`);
const { _overlayCtrl } = getSelectRichMembers(el);
const eventRemoveSpy = sinon.spy(_overlayCtrl, 'removeEventListener');
el.parentNode?.removeChild(el);
expect(eventRemoveSpy.callCount).to.equal(0);
await el.updateComplete;
expect(eventRemoveSpy.callCount).to.equal(0);
await el.updateComplete;
const hasOneInstanceFor = (
/** @type {string} */ eventName,
/** @type {any[][]} */ eventsToSearch,
/** @type {string} */ methodToRemove,
) =>
Boolean(
eventsToSearch.filter(
([eventToSearch, methodToSearch]) =>
eventToSearch === eventName && methodToSearch === methodToRemove,
).length,
);
// @ts-expect-error [allow-private] in tests
expect(hasOneInstanceFor('show', eventRemoveSpy.args, el.__overlayOnShow)).to.be.true;
// @ts-expect-error [allow-private] in tests
expect(hasOneInstanceFor('before-show', eventRemoveSpy.args, el.__overlayBeforeShow)).to.be
.true;
// @ts-expect-error [allow-private] in tests
expect(hasOneInstanceFor('hide', eventRemoveSpy.args, el.__overlayOnHide)).to.be.true;
});
});
describe('Subclassers', () => { describe('Subclassers', () => {
it('allows to override the type of overlay', async () => { it('allows to override the type of overlay', async () => {
const mySelectTagString = defineCE( const mySelectTagString = defineCE(