From e7aa86158109eccc1ecb76664e6ba26d2ed28c9e Mon Sep 17 00:00:00 2001 From: Syed Muhammad Minhal Rizvi Date: Thu, 24 Sep 2026 19:23:15 +0500 Subject: [PATCH] fix(blocks): destroy the Tool of a Block that leaves the collection Blocks.replace(), Blocks.insert(..., replace = true) and Blocks.removeAll() dropped their Block without ever calling Block.destroy(), so the Tool instance kept its listeners and mutation observer alive. Every blocks.update(), every conversion and every replace-on-empty-block leaked one Tool. Destroy the Block where it actually leaves the collection instead of in one caller: Blocks.remove() now owns the cleanup, which also covers BlockManager.removeAllBlocks(), and the duplicate destroy() call in BlockManager.removeBlock() is dropped so a Tool is still destroyed exactly once. --- src/components/blocks.ts | 10 +++++- src/components/modules/blockManager.ts | 1 - test/cypress/tests/api/blocks.cy.ts | 49 ++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/components/blocks.ts b/src/components/blocks.ts index 30334ae20..36581c5fb 100644 --- a/src/components/blocks.ts +++ b/src/components/blocks.ts @@ -200,6 +200,7 @@ export default class Blocks { if (replace) { this.blocks[index].holder.remove(); this.blocks[index].call(BlockToolAPI.REMOVED); + this.blocks[index].destroy(); } const deleteCount = replace ? 1 : 0; @@ -237,6 +238,8 @@ export default class Blocks { prevBlock.holder.replaceWith(block.holder); this.blocks[index] = block; + + prevBlock.destroy(); } /** @@ -291,6 +294,8 @@ export default class Blocks { this.blocks[index].call(BlockToolAPI.REMOVED); + this.blocks[index].destroy(); + this.blocks.splice(index, 1); } @@ -300,7 +305,10 @@ export default class Blocks { public removeAll(): void { this.workingArea.innerHTML = ''; - this.blocks.forEach((block) => block.call(BlockToolAPI.REMOVED)); + this.blocks.forEach((block) => { + block.call(BlockToolAPI.REMOVED); + block.destroy(); + }); this.blocks.length = 0; } diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index be8e1e247..bfaa08042 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -534,7 +534,6 @@ export default class BlockManager extends Module { } this._blocks.remove(index); - block.destroy(); /** * Force call of didMutated event on Block removal diff --git a/test/cypress/tests/api/blocks.cy.ts b/test/cypress/tests/api/blocks.cy.ts index ab97b8b5c..9fb3041dc 100644 --- a/test/cypress/tests/api/blocks.cy.ts +++ b/test/cypress/tests/api/blocks.cy.ts @@ -221,6 +221,55 @@ describe('api.blocks', () => { /** * api.blocks.insert(type, data, config, index, needToFocus, replace, id) */ + /** + * api.blocks.update() swaps the Block in the collection, so the Tool of the + * replaced Block has to be destroyed — otherwise it leaks. + */ + describe('.update() cleanup', () => { + it('should destroy the Tool of the Block it replaces', () => { + const onDestroy = cy.spy().as('onDestroy'); + + /** + * Mock of Tool that reports its destruction + */ + class DestroyableTool extends ToolMock { + /** + * Called by the editor when the Block leaves the collection + */ + public destroy(): void { + onDestroy(); + } + } + + const existingBlock = { + id: 'destroyable-id-1', + type: 'destroyableTool', + data: { + text: 'Some text', + }, + }; + + cy.createEditor({ + tools: { + destroyableTool: { + class: DestroyableTool, + }, + }, + data: { + blocks: [ + existingBlock, + ], + }, + }).then((editor) => { + editor.blocks.update(existingBlock.id, { text: 'Updated text' }); + + cy.wait(100).then(() => { + cy.get('@onDestroy').should('have.been.calledOnce'); + }); + }); + }); + }); + describe('.insert()', function () { it('should preserve block id if it is passed', function () { cy.createEditor({