From 2db8bc8cda53e8795accee6d898bd43df0c22132 Mon Sep 17 00:00:00 2001 From: Ferit Date: Sun, 18 Oct 2026 23:50:40 +0300 Subject: [PATCH 1/3] Deactivate menu and clear pending match on disconnect --- src/text-expander-element.ts | 4 +- test/text-expander-element-test.js | 86 ++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/text-expander-element.ts b/src/text-expander-element.ts index 2269cf4..fe2ef21 100644 --- a/src/text-expander-element.ts +++ b/src/text-expander-element.ts @@ -66,6 +66,8 @@ class TextExpander { } destroy() { + this.match = null + this.deactivate() this.input.removeEventListener('paste', this.onpaste) this.input.removeEventListener('input', this.oninput) ;(this.input as HTMLElement).removeEventListener('keydown', this.onkeydown) @@ -310,8 +312,8 @@ export default class TextExpanderElement extends HTMLElement { disconnectedCallback(): void { const state: TextExpander = states.get(this) if (!state) return - state.destroy() states.delete(this) + state.destroy() } dismiss(): void { diff --git a/test/text-expander-element-test.js b/test/text-expander-element-test.js index fa8b4b7..c0ef73a 100644 --- a/test/text-expander-element-test.js +++ b/test/text-expander-element-test.js @@ -72,6 +72,92 @@ describe('text-expander element', function () { assert.isNull(expander.querySelector('ul')) }) + it('removes the active menu and combobox state when disconnected', async function () { + const expander = document.querySelector('text-expander') + const input = expander.querySelector('textarea') + const menu = document.createElement('ul') + const item = document.createElement('li') + item.setAttribute('role', 'option') + menu.append(item) + expander.addEventListener('text-expander-change', event => { + event.detail.provide({matched: true, fragment: menu}) + }) + input.focus() + triggerInput(input, ':') + await waitForAnimationFrame() + assert.equal(input.getAttribute('aria-expanded'), 'true') + + menu.dispatchEvent(new MouseEvent('mousedown')) + expander.remove() + assert.isNull(expander.querySelector('ul')) + assert.notEqual(input.getAttribute('aria-expanded'), 'true') + + document.body.append(expander) + input.focus() + triggerInput(input, ':new') + await waitForAnimationFrame() + assert.exists(expander.querySelector('ul')) + }) + + it('keeps the new instance usable when deactivation reconnects the element', async function () { + const expander = document.querySelector('text-expander') + const input = expander.querySelector('textarea') + const menu = document.createElement('ul') + menu.append(document.createElement('li')) + expander.addEventListener('text-expander-change', event => { + event.detail.provide({matched: true, fragment: menu}) + }) + input.focus() + triggerInput(input, ':') + await waitForAnimationFrame() + menu.dispatchEvent(new MouseEvent('mousedown')) + expander.addEventListener( + 'text-expander-deactivate', + () => { + document.body.append(expander) + }, + {once: true} + ) + + expander.remove() + assert.isTrue(expander.isConnected) + input.focus() + triggerInput(input, ':new') + await waitForAnimationFrame() + assert.exists(expander.querySelector('ul')) + expander.dismiss() + assert.isNull(expander.querySelector('ul')) + }) + + it('does not reactivate a disconnected instance after its provider resolves', async function () { + const expander = document.querySelector('text-expander') + const input = expander.querySelector('textarea') + const menu = document.createElement('ul') + menu.append(document.createElement('li')) + let resolveProvider + const pending = new Promise(resolve => { + resolveProvider = resolve + }) + expander.addEventListener( + 'text-expander-change', + event => { + event.detail.provide(pending) + }, + {once: true} + ) + input.focus() + triggerInput(input, ':') + expander.remove() + document.body.append(expander) + input.focus() + + resolveProvider({matched: true, fragment: menu}) + await pending + await waitForAnimationFrame() + assert.isNull(expander.querySelector('ul')) + assert.notEqual(input.getAttribute('aria-expanded'), 'true') + }) + it('dispatches change events for 2 char activation keys', async function () { const expander = document.querySelector('text-expander') const input = expander.querySelector('textarea') From d8aab236694415200c6e659000e0047acc5af516 Mon Sep 17 00:00:00 2001 From: Ferit Date: Tue, 20 Oct 2026 10:08:08 +0300 Subject: [PATCH 2/3] fix: make expander event teardown reentrant --- src/text-expander-element.ts | 15 +++--- test/text-expander-element-test.js | 80 ++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/src/text-expander-element.ts b/src/text-expander-element.ts index fe2ef21..47cdd34 100644 --- a/src/text-expander-element.ts +++ b/src/text-expander-element.ts @@ -66,12 +66,12 @@ class TextExpander { } destroy() { - this.match = null - this.deactivate() this.input.removeEventListener('paste', this.onpaste) this.input.removeEventListener('input', this.oninput) ;(this.input as HTMLElement).removeEventListener('keydown', this.onkeydown) this.input.removeEventListener('blur', this.onblur) + this.match = null + this.deactivate() } dismissMenu() { @@ -93,6 +93,7 @@ class TextExpander { this.combobox = new Combobox(this.input, menu) this.expander.dispatchEvent(new Event('text-expander-activate')) + if (!this.combobox || this.menu !== menu) return this.positionMenu(menu, match.position) @@ -129,17 +130,17 @@ class TextExpander { private deactivate() { const menu = this.menu - if (!menu || !this.combobox) return false + const combobox = this.combobox + if (!menu || !combobox) return false + this.menu = null + this.combobox = null this.expander.dispatchEvent(new Event('text-expander-deactivate')) - this.menu = null - menu.removeEventListener('combobox-commit', this.oncommit) menu.removeEventListener('mousedown', this.onmousedown) - this.combobox.destroy() - this.combobox = null + combobox.destroy() menu.remove() return true diff --git a/test/text-expander-element-test.js b/test/text-expander-element-test.js index c0ef73a..d3eecc1 100644 --- a/test/text-expander-element-test.js +++ b/test/text-expander-element-test.js @@ -72,6 +72,86 @@ describe('text-expander element', function () { assert.isNull(expander.querySelector('ul')) }) + it('stops activation when an activation listener disconnects the element', async function () { + const expander = document.querySelector('text-expander') + const input = expander.querySelector('textarea') + expander.remove() + const addEventListener = input.addEventListener + let onInput + input.addEventListener = function (type, listener, options) { + if (type === 'input') onInput = listener + return addEventListener.call(this, type, listener, options) + } + document.body.append(expander) + input.addEventListener = addEventListener + const menu = document.createElement('ul') + menu.append(document.createElement('li')) + expander.addEventListener('text-expander-change', event => { + event.detail.provide({matched: true, fragment: menu}) + }) + expander.addEventListener('text-expander-activate', () => expander.remove(), {once: true}) + input.focus() + input.value = ':' + input.setSelectionRange(1, 1) + await onInput(new Event('input')) + assert.isFalse(expander.isConnected) + assert.isNull(expander.querySelector('ul')) + assert.notEqual(input.getAttribute('aria-expanded'), 'true') + }) + + it('deactivates only once when a deactivation listener disconnects the element', async function () { + const expander = document.querySelector('text-expander') + const input = expander.querySelector('textarea') + const menu = document.createElement('ul') + menu.append(document.createElement('li')) + expander.addEventListener('text-expander-change', event => { + event.detail.provide({matched: true, fragment: menu}) + }) + input.focus() + triggerInput(input, ':') + await waitForAnimationFrame() + let deactivations = 0 + expander.addEventListener('text-expander-deactivate', () => { + deactivations++ + expander.remove() + }) + assert.doesNotThrow(() => expander.dismiss()) + assert.equal(deactivations, 1) + assert.isNull(expander.querySelector('ul')) + }) + + it('detaches old input handlers before deactivation reconnects and types', async function () { + const expander = document.querySelector('text-expander') + const input = expander.querySelector('textarea') + let requests = 0 + expander.addEventListener('text-expander-change', event => { + requests++ + const menu = document.createElement('ul') + menu.append(document.createElement('li')) + event.detail.provide({matched: true, fragment: menu}) + }) + input.focus() + triggerInput(input, ':') + await waitForAnimationFrame() + expander.querySelector('ul').dispatchEvent(new MouseEvent('mousedown')) + requests = 0 + expander.addEventListener( + 'text-expander-deactivate', + () => { + document.body.append(expander) + input.focus() + triggerInput(input, ':new') + }, + {once: true} + ) + expander.remove() + await waitForAnimationFrame() + assert.equal(requests, 1) + assert.equal(expander.querySelectorAll('ul').length, 1) + expander.dismiss() + assert.isNull(expander.querySelector('ul')) + }) + it('removes the active menu and combobox state when disconnected', async function () { const expander = document.querySelector('text-expander') const input = expander.querySelector('textarea') From 43567f7729c319a8760fdaa10903704f5d3cbf89 Mon Sep 17 00:00:00 2001 From: Ferit Date: Fri, 23 Oct 2026 10:05:16 +0300 Subject: [PATCH 3/3] fix: stop menu replacement after instance teardown --- src/text-expander-element.ts | 1 + test/text-expander-element-test.js | 42 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/text-expander-element.ts b/src/text-expander-element.ts index 47cdd34..4a1b9f5 100644 --- a/src/text-expander-element.ts +++ b/src/text-expander-element.ts @@ -86,6 +86,7 @@ class TextExpander { } this.deactivate() + if (states.get(this.expander) !== this) return this.menu = menu if (!menu.id) menu.id = `text-expander-${Math.floor(Math.random() * 100000).toString()}` diff --git a/test/text-expander-element-test.js b/test/text-expander-element-test.js index d3eecc1..c3c0038 100644 --- a/test/text-expander-element-test.js +++ b/test/text-expander-element-test.js @@ -120,6 +120,48 @@ describe('text-expander element', function () { assert.isNull(expander.querySelector('ul')) }) + for (const reconnect of [false, true]) { + it(`stops menu replacement when deactivation disconnects${reconnect ? ' and reconnects' : ''}`, async function () { + const expander = document.querySelector('text-expander') + const input = expander.querySelector('textarea') + expander.addEventListener('text-expander-change', event => { + const menu = document.createElement('ul') + menu.append(document.createElement('li')) + event.detail.provide({matched: true, fragment: menu}) + }) + input.focus() + triggerInput(input, ':') + await waitForAnimationFrame() + assert.exists(expander.querySelector('ul')) + let activations = 0 + expander.addEventListener('text-expander-activate', () => activations++) + expander.addEventListener( + 'text-expander-deactivate', + () => { + expander.remove() + if (reconnect) { + document.body.append(expander) + input.focus() + } + }, + {once: true} + ) + triggerInput(input, ':new') + await waitForAnimationFrame() + assert.equal(activations, 0) + assert.isNull(expander.querySelector('ul')) + assert.notEqual(input.getAttribute('aria-expanded'), 'true') + if (reconnect) { + triggerInput(input, ':next') + await waitForAnimationFrame() + assert.equal(activations, 1) + assert.exists(expander.querySelector('ul')) + expander.dismiss() + assert.isNull(expander.querySelector('ul')) + } + }) + } + it('detaches old input handlers before deactivation reconnects and types', async function () { const expander = document.querySelector('text-expander') const input = expander.querySelector('textarea')