Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/editor-destroy/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-05
138 changes: 138 additions & 0 deletions openspec/changes/editor-destroy/design.md

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions openspec/changes/editor-destroy/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
## Why

There is no way to tear an editor down. Neither `Core` nor `EditorJS` has a `destroy()`, so an integrator who unmounts an editor (SPA route change, React/Vue component unmount, several editors on one page) leaks the editor DOM, a document-wide `selectionchange` listener, the collaboration WebSocket, timers, and every tool and plugin instance. Several plugins already implement `destroy()` and the `core` and `sdk` specs already describe it being called on teardown ("Registry is cleared on teardown", "Destroying a plugin"), but nothing calls it. This change makes those requirements true.

## What Changes

- Add `destroy(): void` to `Core`. It is synchronous, safe to call more than once, and safe to call while `initialize()` is still pending, in which case the pending `initialize()` rejects with an `AbortError`. After it returns, `use()` and `initialize()` throw.
- `Core` keeps the plugin instances it creates. `destroy()` calls `destroy()` on each of them in reverse construction order and removes each one's entry from the plugin registry. A plugin whose `destroy()` throws is logged and doesn't stop the rest of the teardown.
- `Core` tears down its own services: `UndoRedoManager` (already has `destroy()`), `BlockRenderer` and `SelectionManager` (new), each removing its model and EventBus listeners.
- `BlockRenderer` keeps the block tool instance for each rendered block. When a block is removed, and when the editor is destroyed, it calls the tool's `destroy()` and destroys the block's adapter. Teardown does not touch the model, so no removal operations reach undo history or collaboration.
- Add an optional `destroy()` to the `InlineTool` contract in `@editorjs/sdk`. `BlockTool` already inherits an optional `destroy()` from the v2 type; the SDK spec now names both. The editor calls `destroy()` once on each tool instance it creates, when it stops using that instance. Inline tools have one instance shared by all consumers, so their facade destroys it, through `ToolsManager`, as the last step of teardown.
- Add `destroy()` to the adapter plugin. `DOMAdapters.destroy()` destroys the remaining block adapters, stops `CaretAdapter` watching selection changes and caret updates, and stops `FormattingAdapter` listening to tool and model events. The shared `selectionchange` watcher removes its `document` listener once its last subscriber is gone.
- UI plugin teardown (every UI plugin unsubscribing, removing its DOM and popovers, and `EditorjsUI` removing its wrapper while leaving the holder) is covered by #174, so this change has no UI plugin implementation.
- Listeners are released through `AbortController` across the repo. Each component owns one controller, passes its `signal` to every listener it registers, and calls `abort()` on teardown. When an SDK base class registers listeners (`BlockToolAdapter`), the base class owns the controller, gives subclasses a protected `signal`, and aborts it in its own `destroy()`. Subclasses don't override `destroy()` to remove listeners.
- The `EditorAPI` subscription methods keep their signatures. Internally `onUpdate` and `onCaretUpdate` subscribe through their own controller and return `() => controller.abort()`. Components tie that function to their own controller's `abort` event.
- Add `destroy()` to the `EditorJS` bundle, which delegates to `Core.destroy()`. Because `CollaborationManager.destroy()` already closes the `OTClient` socket, the bundle closes its collaboration connection too.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `architecture`: adds the repo-wide rule that listeners are released through `AbortController`, owned by the SDK base class where one exists.
- `core`: adds an editor teardown requirement to `Core` (idempotency, ordering, mid-initialization destroy, post-destroy guards) and adds per-block tool destruction to the block rendering lifecycle.
- `sdk`: `BlockToolAdapter` owns its controller and exposes `signal` to subclasses, the tool contracts gain an optional `destroy()` on block and inline tools, and the adapter plugin contract gains a teardown that covers adapters not destroyed yet.
- `dom-adapters`: `DOMAdapters` releases the caret, selection, and formatting subscriptions and the block adapters on destroy, and the shared selection watcher detaches from `document` when it has no subscribers left.
- `editorjs-bundle`: `EditorJS` exposes `destroy()`.

## Impact

- **Code**: `packages/core/src/index.ts`, `packages/core/src/components/{BlockRenderer,SelectionManager}.ts`, `packages/core/src/tools/ToolsManager.ts`, `packages/sdk/src/entities/{InlineTool,EditorjsAdapterPlugin,BlockToolAdapter}.ts`, `packages/sdk/src/tools/facades/InlineToolFacade.ts`, `packages/core/src/api/{DocumentAPI/DocumentAPI,SelectionAPI}.ts`, `packages/core/src/components/UndoRedoManager.ts`, `packages/model/src/EditorJSModel.ts` (`addEventListener` overloads gain `options`), `packages/plugins/{clipboard-plugin,shortcuts-plugin}`, `packages/collaboration-manager/src/CollaborationManager.ts`, `packages/dom-adapters/src/{index.ts,CaretAdapter,FormattingAdapter,utils/useSelectionChange.ts}`, `packages/editorjs/src/index.ts`.
- **Public API**: adds `Core#destroy()`, `EditorJS#destroy()`, optional `InlineTool#destroy()`, and `EditorJSAdapterPlugin#destroy()`. Nothing breaks: the tool hooks are optional, and `DOMAdapters` is the only adapter implementation in the repo.
- **Docs**: `docs/plugins.md` ("Lifecycle boundary") says "`Core` currently does not expose a global `destroy()` lifecycle hook". This change replaces that sentence. `docs/README.md` ("Lifecycle") gains a teardown step.
- **Related work**: #174 (UI plugin teardown), card "Clean up" on the DocumentModel board.
21 changes: 21 additions & 0 deletions openspec/changes/editor-destroy/specs/architecture/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## ADDED Requirements

### Requirement: Listener teardown through AbortController
Every first-party component that registers event listeners (core services, adapters, plugins, UI, and SDK base classes) SHALL own an `AbortController`, pass its `signal` to each listener registration it makes, and release all of those listeners by calling `abort()` in its teardown. This applies to `EventTarget` registrations (EventBus, the model, DOM nodes, `document`, `window`). For subscriptions that return an unsubscribe function (`api.document.onUpdate`, `api.selection.onCaretUpdate`, the `selectionchange` watcher), the component SHALL register that function as an `abort` listener on its controller's `signal`. A component SHALL NOT store handler references or unsubscribe functions in fields only to remove listeners. Resources that aren't listeners, such as timers, sockets, DOM nodes, popovers, and tool instances, are released explicitly.

When an SDK base class registers listeners, the base class SHALL own the controller, expose its `signal` to subclasses as a protected member, and abort it in its own `destroy()`. Subclasses SHALL pass that `signal` to their own registrations rather than creating a second controller or overriding `destroy()` to remove listeners.

#### Scenario: Teardown removes every listener at once
- **GIVEN** a component that registered listeners on the EventBus, the model, and a DOM node using its controller's `signal`
- **WHEN** its teardown calls `abort()`
- **THEN** none of those listeners fire for events dispatched afterwards

#### Scenario: Subscription that returns an unsubscribe function
- **GIVEN** a component subscribed through `api.document.onUpdate` and registered the returned function as an `abort` listener on its controller's `signal`
- **WHEN** the component's teardown calls `abort()`
- **THEN** the subscription is removed, and later model changes don't reach its callback

#### Scenario: Subclass of an SDK base class
- **GIVEN** a class extending an SDK base class registers an extra EventBus listener with the inherited `signal`
- **WHEN** the base class's `destroy()` runs
- **THEN** both the base class's listeners and the subclass's listener are removed, and the subclass doesn't need to override `destroy()` to do it
86 changes: 86 additions & 0 deletions openspec/changes/editor-destroy/specs/core/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
## ADDED Requirements

### Requirement: Editor teardown
`Core` SHALL expose a synchronous `destroy()` that releases everything the editor created: plugin instances, the adapter plugin, block and inline tool instances, block adapters, and every model, EventBus, DOM, timer, and network subscription that editor-owned code registered. Teardown SHALL NOT mutate the document model, so no removal events reach undo history, collaboration, or `onModelUpdate`. The holder element passed in the configuration belongs to the integrator and SHALL be left in the DOM.

#### Scenario: Plugins are destroyed in reverse construction order
- **GIVEN** plugins A, B, and C were constructed in that order during `initialize()`
- **WHEN** `destroy()` is called
- **THEN** `destroy()` is called on C, then B, then A, and plugins that don't implement `destroy()` are skipped

#### Scenario: Plugin registry entries are removed
- **GIVEN** a plugin registered a `publicApi` under its `name`
- **WHEN** `destroy()` is called
- **THEN** that name is no longer present on `api.plugins`

#### Scenario: A failing plugin does not abort teardown
- **GIVEN** one plugin's `destroy()` throws
- **WHEN** `destroy()` is called
- **THEN** the error is logged with `console.error`, and every other plugin, the core services, the rendered blocks, and the adapter plugin are still torn down

#### Scenario: Core services stop listening
- **GIVEN** an initialized editor
- **WHEN** `destroy()` is called
- **THEN** `UndoRedoManager`, `BlockRenderer`, and `SelectionManager` remove their model and EventBus listeners, so model changes or `core:undo`/`core:redo` events dispatched afterwards have no effect on them

#### Scenario: Rendered blocks are released without touching the model
- **GIVEN** an initialized editor with rendered blocks
- **WHEN** `destroy()` is called
- **THEN** each rendered block tool's `destroy()` is called, each block's adapter is destroyed, the adapter plugin's `destroy()` is called, and the model emits no events

#### Scenario: Shared inline tool instances are destroyed last, once each
- **GIVEN** an initialized editor whose inline tools have been used by the formatting adapter, the selection manager and the inline toolbar
- **WHEN** `destroy()` is called
- **THEN** each inline tool instance's `destroy()` is called once, after the adapter plugin's `destroy()`; a throwing hook is logged with `console.error` and the other inline tools are still destroyed; inline tools that were never instantiated are not constructed

#### Scenario: Destroying twice is a no-op
- **GIVEN** `destroy()` has already been called
- **WHEN** `destroy()` is called again
- **THEN** it returns without calling any plugin, tool, or adapter `destroy()` a second time

#### Scenario: Destroying before initialization
- **GIVEN** `use()` has been called but `initialize()` has not
- **WHEN** `destroy()` is called
- **THEN** it completes without error and no plugin or tool is constructed

#### Scenario: Destroying while initialization is pending
- **GIVEN** `initialize()` has been called and its promise has not settled
- **WHEN** `destroy()` is called
- **THEN** whatever was constructed so far is torn down, the pending `initialize()` rejects with a `DOMException` named `AbortError` without initializing the document or dispatching `CoreEventType.Ready`, any tool or plugin it would have constructed afterwards is not constructed, tools still queued for preparation are not prepared, and a tool whose `prepare()` settles after `destroy()` is neither created nor registered and dispatches no `CoreEventType.ToolLoaded`

#### Scenario: Using a destroyed editor
- **GIVEN** `destroy()` has been called
- **WHEN** `use()` or `initialize()` is called
- **THEN** it throws an error stating that the editor has been destroyed

## MODIFIED Requirements

### Requirement: Block rendering lifecycle
The system SHALL provide `BlockRenderer`, which listens for model `BlockAddedEvent`/`BlockRemovedEvent`, creates a `BlockToolAdapter` per block, instantiates and renders the corresponding tool, and dispatches `BlockAddedCoreEvent`/`BlockRemovedCoreEvent`. `BlockRenderer` SHALL keep the tool instance of each rendered block, keyed by block id, and SHALL call that instance's optional `destroy()` exactly once, either when the block is removed or when the editor is destroyed.

#### Scenario: Malformed or unknown block events
- **GIVEN** a block event with an undefined index, or referencing a tool name that isn't registered
- **WHEN** `BlockRenderer` processes the event
- **THEN** it throws `[BlockRenderer] Block index should be defined...` or `[BlockRenderer] Block Tool <name> not found` respectively

#### Scenario: Tool render failure is logged, not thrown
- **GIVEN** a tool's `render()` method returns a rejected promise
- **WHEN** `BlockRenderer` renders that block
- **THEN** the rejection is logged rather than propagated as an uncaught error

#### Scenario: Removing a block destroys its tool instance
- **GIVEN** a rendered block whose tool instance implements `destroy()`
- **WHEN** the model emits `BlockRemovedEvent` for that block
- **THEN** the tool instance's `destroy()` is called, the block's adapter is destroyed, and `BlockRemovedCoreEvent` is dispatched

#### Scenario: Destroying the renderer releases every remaining block
- **GIVEN** several rendered blocks
- **WHEN** `BlockRenderer.destroy()` is called
- **THEN** each remaining tool instance's `destroy()` is called, each remaining block's adapter is destroyed, no `BlockRemovedCoreEvent` is dispatched, and the renderer stops listening to model events

#### Scenario: A block that finishes rendering after teardown
- **GIVEN** a block tool's `render()` is still pending when `BlockRenderer.destroy()` is called
- **WHEN** `render()` settles
- **THEN** no `BlockAddedCoreEvent` is dispatched for it, and the tool instance and its adapter are still destroyed

Implemented in `src/components/BlockRenderer.ts`, validated by its co-located `.spec.ts`.
24 changes: 24 additions & 0 deletions openspec/changes/editor-destroy/specs/dom-adapters/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## ADDED Requirements

### Requirement: Adapter teardown
`DOMAdapters` SHALL implement `destroy()`. It SHALL destroy every `DOMBlockToolAdapter` still alive and clear the `InputsRegistry`, stop `CaretAdapter` watching DOM selection changes and model caret updates, and stop `FormattingAdapter` listening to `core:ToolLoaded` and model updates. `FormattingAdapter` SHALL drop its inline tools without destroying them, because their instances are shared and their facades destroy them. The shared `selectionchange` watcher SHALL remove its `document` listener once its last subscriber unsubscribes, and add it back on the next subscription.

#### Scenario: Caret adapter stops tracking the holder
- **GIVEN** an initialized `DOMAdapters`
- **WHEN** `destroy()` is called and a `selectionchange` event then fires inside the holder
- **THEN** the model caret isn't updated, and later model caret updates don't change the DOM selection

#### Scenario: Remaining block adapters are destroyed
- **GIVEN** block adapters created for several blocks that haven't been removed
- **WHEN** `destroy()` is called
- **THEN** each adapter's `destroy()` runs, removing its model and `ui:beforeinput` listeners, and the inputs registry is empty

#### Scenario: Formatting adapter releases inline tools
- **GIVEN** inline tools attached to `FormattingAdapter` through `core:ToolLoaded`
- **WHEN** `destroy()` is called
- **THEN** no inline tool instance's `destroy()` is called, the adapter holds none of them anymore, and later model formatting events don't touch the DOM

#### Scenario: Two editors on one page
- **GIVEN** two editors are initialized on the same page and share the `selectionchange` watcher
- **WHEN** the first editor is destroyed
- **THEN** the second editor still receives selection changes, and the `document` listener is removed only after the second editor is destroyed too
29 changes: 29 additions & 0 deletions openspec/changes/editor-destroy/specs/editorjs-bundle/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
## ADDED Requirements

### Requirement: Editor teardown
The `EditorJS` class SHALL expose a synchronous `destroy()` that delegates to the underlying `Core.destroy()`. This tears down the default UI, plugins, tools, DOM adapter, and the collaboration connection.

#### Scenario: Destroying a ready editor
- **GIVEN** `editor.isReady` has resolved
- **WHEN** `editor.destroy()` is called
- **THEN** the editor's DOM is removed from the holder, its collaboration socket is closed, and no listener it added to `document` or the holder remains

#### Scenario: Destroying before ready
- **GIVEN** `editor.isReady` has not settled
- **WHEN** `editor.destroy()` is called
- **THEN** `editor.isReady` rejects with a `DOMException` named `AbortError`, the editor never becomes interactive, and nothing is left in the holder

#### Scenario: Destroying before ready without awaiting readiness
- **GIVEN** nobody awaits or handles `editor.isReady`
- **WHEN** `editor.destroy()` is called before it settles
- **THEN** the `AbortError` rejection is not reported as an unhandled promise rejection

#### Scenario: Initialization failure without awaiting readiness
- **GIVEN** nobody awaits or handles `editor.isReady`
- **WHEN** initialization fails with an error other than `AbortError`
- **THEN** that error is logged with `console.error`, is not reported as an unhandled promise rejection, and `editor.isReady` still rejects with it for anyone who awaits it

#### Scenario: Several editors on one page
- **GIVEN** several `EditorJS` instances are mounted on one page
- **WHEN** each of them is destroyed
- **THEN** each destroy leaves the others working, and once all are destroyed no editor DOM nodes or editor-registered `document`/holder listeners remain
Loading
Loading