From 6837cf078a601b4072c0e1961f5d9cf4f31e68ea Mon Sep 17 00:00:00 2001 From: Sawtone Date: Tue, 15 Sep 2026 16:24:05 +0800 Subject: [PATCH 1/2] fix(block): notify changes from native select elements Delegate select change events to the Block holder during bubbling, keeping Tool state updates ahead of the updated hook. Register synchronously and remove the listener on destruction to avoid deferred rebinding. Adapt the SelectTool fixture and initial regression tests from Sion612's fork-local PR, and cover composite Tools, callback ordering, and early destruction. Tests adapted from: https://github.com/Sion612/editor.js/pull/1 Fixes #2975 --- src/components/block/index.ts | 25 ++ .../fixtures/tools/CompositeSelectTool.ts | 46 +++ test/cypress/fixtures/tools/SelectTool.ts | 34 +++ test/cypress/tests/onchange.cy.ts | 262 ++++++++++++++++++ 4 files changed, 367 insertions(+) create mode 100644 test/cypress/fixtures/tools/CompositeSelectTool.ts create mode 100644 test/cypress/fixtures/tools/SelectTool.ts diff --git a/src/components/block/index.ts b/src/components/block/index.ts index 36c4aa2ae..01883731c 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -242,6 +242,13 @@ export default class Block extends EventsDispatcher { this.holder = this.compose(); + /** + * Select value changes do not produce DOM mutations. Listen on the holder + * after Tool handlers run, including when the Tool replaces its root. + * Bind synchronously so deferred initialization cannot rebind after destroy. + */ + this.holder.addEventListener('change', this.handleSelectChange); + /** * Bind block events in RIC for optimizing of constructing process time */ @@ -679,6 +686,7 @@ export default class Block extends EventsDispatcher { * Call Tool instance destroy method */ public destroy(): void { + this.holder.removeEventListener('change', this.handleSelectChange); this.unwatchBlockMutations(); this.removeInputEvents(); @@ -829,6 +837,23 @@ export default class Block extends EventsDispatcher { this.updateCurrentInput(); }; + /** + * Handles change events from native select elements. + * + * Select elements are delegated to the Block holder so that dynamically + * replaced Tool roots are observed without binding a new listener to every + * select element. + * + * @param event - native change event + */ + private readonly handleSelectChange = (event: Event): void => { + const target = event.target; + + if ($.isElement(target) && target.tagName === 'SELECT' && this.holder.contains(target)) { + this.didMutated(); + } + }; + /** * Adds focus event listeners to all inputs and contenteditable */ diff --git a/test/cypress/fixtures/tools/CompositeSelectTool.ts b/test/cypress/fixtures/tools/CompositeSelectTool.ts new file mode 100644 index 000000000..a60c3b919 --- /dev/null +++ b/test/cypress/fixtures/tools/CompositeSelectTool.ts @@ -0,0 +1,46 @@ +import type { BlockTool } from '../../../../types'; + +/** + * A composite Tool containing more than one native select. + */ +export default class CompositeSelectTool implements BlockTool { + /** + * Render two selects inside a single Tool root. + */ + public render(): HTMLDivElement { + const wrapper = document.createElement('div'); + + ['first', 'second'].forEach(name => { + const select = document.createElement('select'); + + select.dataset.cy = `${name}-select`; + + ['first', 'second'].forEach(value => { + const option = document.createElement('option'); + + option.value = value; + option.textContent = value; + select.appendChild(option); + }); + + wrapper.appendChild(select); + }); + + return wrapper; + } + + /** + * Save both selected values. + * + * @param element - rendered Tool element + */ + public save(element: HTMLElement): { first: string; second: string } { + const first = element.querySelector('[data-cy="first-select"]') as HTMLSelectElement; + const second = element.querySelector('[data-cy="second-select"]') as HTMLSelectElement; + + return { + first: first.value, + second: second.value, + }; + } +} diff --git a/test/cypress/fixtures/tools/SelectTool.ts b/test/cypress/fixtures/tools/SelectTool.ts new file mode 100644 index 000000000..6928f84cd --- /dev/null +++ b/test/cypress/fixtures/tools/SelectTool.ts @@ -0,0 +1,34 @@ +import type { BlockTool } from '../../../../types'; + +/** + * A native select without a custom change handler. + */ +export default class SelectTool implements BlockTool { + /** + * Render the select as the Tool's root element. + */ + public render(): HTMLSelectElement { + const select = document.createElement('select'); + + ['first', 'second'].forEach(value => { + const option = document.createElement('option'); + + option.value = value; + option.textContent = value; + select.appendChild(option); + }); + + return select; + } + + /** + * Save the selected value. + * + * @param element - rendered Tool element + */ + public save(element: HTMLElement): { value: string } { + const select = (element.tagName === 'SELECT' ? element : element.querySelector('select')) as HTMLSelectElement; + + return { value: select.value }; + } +} diff --git a/test/cypress/tests/onchange.cy.ts b/test/cypress/tests/onchange.cy.ts index 6727aea8d..1bf78dcb0 100644 --- a/test/cypress/tests/onchange.cy.ts +++ b/test/cypress/tests/onchange.cy.ts @@ -1,6 +1,8 @@ import Header from '@editorjs/header'; import Code from '@editorjs/code'; import ToolMock from '../fixtures/tools/ToolMock'; +import SelectTool from '../fixtures/tools/SelectTool'; +import CompositeSelectTool from '../fixtures/tools/CompositeSelectTool'; import Delimiter from '@editorjs/delimiter'; import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded'; import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged'; @@ -430,6 +432,266 @@ describe('onChange callback', () => { })); }); + describe('native select changes', () => { + /** + * Block listeners are installed in requestIdleCallback after rendering. + */ + function waitForBlockListeners(): void { + cy.window().then(win => { + return new Cypress.Promise(resolve => win.requestIdleCallback(() => resolve())); + }); + } + + /** + * Create a select block after a paragraph to verify the event's block index. + */ + function createEditorWithSelect(): void { + cy.createEditor({ + tools: { select: SelectTool }, + onChange: cy.stub().as('onChange'), + data: { + blocks: [ + { + type: 'paragraph', + data: { text: 'First block' }, + }, + { + type: 'select', + data: {}, + }, + ], + }, + }).as('editorInstance'); + + waitForBlockListeners(); + cy.clock(Date.now(), ['setTimeout', 'clearTimeout']); + cy.tick(modificationsObserverBatchTimeout); + cy.get('@onChange').should('not.be.called'); + } + + it('should notify once and save the selected value', () => { + createEditorWithSelect(); + + cy.get('[data-cy=editorjs] select').select('second'); + cy.tick(modificationsObserverBatchTimeout); + + cy.get('@onChange').should('be.calledOnce'); + cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({ + type: BlockChangedMutationType, + detail: { + index: 1, + target: { name: 'select' }, + }, + })); + + cy.get('@editorInstance').then(async editor => { + const saved = await editor.save(); + + expect(saved.blocks[1].data).to.deep.equal({ value: 'second' }); + }); + + cy.tick(modificationsObserverBatchTimeout); + cy.get('@onChange').should('be.calledOnce'); + }); + + it('should observe every select in a composite Tool', () => { + cy.createEditor({ + tools: { compositeSelect: CompositeSelectTool }, + onChange: cy.stub().as('onChange'), + data: { + blocks: [ { + type: 'compositeSelect', + data: {}, + } ], + }, + }).as('editorInstance'); + + waitForBlockListeners(); + cy.clock(Date.now(), ['setTimeout', 'clearTimeout']); + cy.tick(modificationsObserverBatchTimeout); + cy.get('@onChange').should('not.be.called'); + + cy.get('[data-cy=first-select]').select('second'); + cy.tick(modificationsObserverBatchTimeout); + cy.get('@onChange').should('be.calledOnce'); + + cy.get('[data-cy=second-select]').select('second'); + cy.tick(modificationsObserverBatchTimeout); + cy.get('@onChange').should('be.calledTwice'); + + cy.get('@editorInstance').then(async editor => { + const saved = await editor.save(); + + expect(saved.blocks[0].data).to.deep.equal({ + first: 'second', + second: 'second', + }); + }); + }); + + it('should call updated after the Tool handles the select change', () => { + const updated = cy.stub().as('updated'); + let value = 'first'; + + /** + * Keep Tool state in sync through its own native change handler. + */ + class SelectWithChangeHandler extends SelectTool { + /** + * Register the Tool's own change handler on its select. + */ + public render(): HTMLSelectElement { + const select = super.render(); + + select.addEventListener('change', () => { + value = select.value; + }); + + return select; + } + + /** + * Observe Tool state when the lifecycle hook runs. + */ + public updated(): void { + updated(value); + } + } + + cy.createEditor({ + tools: { select: SelectWithChangeHandler }, + data: { + blocks: [ { + type: 'select', + data: {}, + } ], + }, + }); + + waitForBlockListeners(); + cy.get('[data-cy=editorjs] select').select('second'); + cy.get('@updated').should('be.calledOnceWithExactly', 'second'); + }); + + it('should observe a replacement select nested in a new Tool root', () => { + createEditorWithSelect(); + + cy.get('[data-cy=editorjs] select').then(([ select ]) => { + return new Cypress.Promise(resolve => { + const observer = new select.ownerDocument.defaultView.MutationObserver(() => { + observer.disconnect(); + resolve(); + }); + + observer.observe(select.parentElement, { childList: true }); + + const wrapper = select.ownerDocument.createElement('div'); + + wrapper.appendChild(select.cloneNode(true)); + select.replaceWith(wrapper); + }); + }); + + // Let the DOM replacement notification finish before changing the value. + cy.tick(modificationsObserverBatchTimeout); + cy.get('@onChange').should('be.calledOnce'); + cy.get('@onChange').invoke('resetHistory'); + cy.get('[data-cy=editorjs] select').select('second'); + cy.tick(modificationsObserverBatchTimeout); + + cy.get('@onChange').should('be.calledOnce'); + cy.get('@editorInstance').then(async editor => { + const saved = await editor.save(); + + expect(saved.blocks[1].data).to.deep.equal({ value: 'second' }); + }); + }); + + it('should remove the select change listener when the editor is destroyed', () => { + const updated = cy.stub().as('updated'); + + /** + * Observe the Tool hook even after Block event subscriptions are removed. + */ + class SelectWithUpdatedHook extends SelectTool { + public updated = updated; + } + + cy.createEditor({ + tools: { select: SelectWithUpdatedHook }, + data: { + blocks: [ { + type: 'select', + data: {}, + } ], + }, + }).as('editorInstance'); + + waitForBlockListeners(); + cy.get('[data-cy=editorjs] select').select('second'); + cy.get('@updated').should('be.calledOnce'); + cy.get('[data-cy=editorjs] select').then(([ select ]) => { + cy.get('@editorInstance').then(editor => { + editor.destroy(); + updated.resetHistory(); + select.dispatchEvent(new select.ownerDocument.defaultView.Event('change', { bubbles: true })); + expect(updated).not.to.be.called; + }); + }); + }); + + it('should not treat other bubbling change events as select changes', () => { + createEditorWithSelect(); + + cy.get('[data-cy=editorjs] .ce-paragraph').trigger('change'); + cy.tick(modificationsObserverBatchTimeout); + + cy.get('@onChange').should('not.be.called'); + }); + + it('should not rebind select changes when destroyed before deferred initialization', () => { + const updated = cy.stub().as('updated'); + + /** + * Observe the Tool hook independently of Block Manager subscriptions. + */ + class SelectWithUpdatedHook extends SelectTool { + public updated = updated; + } + + cy.createEditor({ + tools: { select: SelectWithUpdatedHook }, + }).as('editorInstance'); + waitForBlockListeners(); + + cy.window().then(win => { + cy.get('@editorInstance').then(editor => { + const pendingCallbacks: Array<() => void> = []; + + cy.stub(win, 'requestIdleCallback').callsFake((callback: IdleRequestCallback) => { + pendingCallbacks.push(() => callback({ + didTimeout: false, + timeRemaining: () => 50, + })); + + return pendingCallbacks.length; + }); + + const block = editor.blocks.insert('select', {}, undefined, undefined, false); + const select = block.holder.querySelector('select') as HTMLSelectElement; + + editor.destroy(); + pendingCallbacks.forEach(callback => callback()); + updated.resetHistory(); + + select.value = 'second'; + select.dispatchEvent(new win.Event('change', { bubbles: true })); + expect(updated).not.to.be.called; + }); + }); + }); + }); + it('should not be fired on fake cursor adding and removing', () => { createEditor([ { type: 'paragraph', From df751083d273cda49539e33e7ec009ce183cc83f Mon Sep 17 00:00:00 2001 From: Sawtone Date: Thu, 17 Sep 2026 17:42:06 +0800 Subject: [PATCH 2/2] Bump version to 2.31.7 and add changelog entry --- docs/CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bca6f9236..4115543b8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### 2.31.7 + +- `Fix` - Trigger `onChange` for native `