Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Two event transports (never mixed):
- Resolves core services (`SelectionManager`, `BlocksManager`, `BlockRenderer`, `UndoRedoManager`).
- Inserts initial document into `EditorJSModel`; `BlockRenderer` reacts to each `BlockAddedEvent` to create a `BlockToolAdapter`, render the tool, and emit `BlockAddedCoreEvent`.
- Dispatches `CoreEventType.Ready`; collaboration manager connects if server config is provided.
4. `core.destroy()` tears it down: plugins in reverse construction order (UI removes its DOM, collaboration closes its socket), then `UndoRedoManager`, `SelectionManager`, `BlockRenderer` (destroying each block's tool instance and adapter), and the adapter plugin. The model is left untouched; the holder element stays in place.

---

Expand Down
20 changes: 16 additions & 4 deletions docs/diagrams/plugin-lifecycle-flow.mmd

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this file could not be rendered

Error rendering embedded code

Parse error on line 36:
...ind api.plugins;<br/>read at access time
-----------------------^
Expecting 'NEWLINE', ',', '()', 'SOLID_OPEN_ARROW', 'DOTTED_OPEN_ARROW', 'SOLID_ARROW', 'SOLID_ARROW_TOP', 'SOLID_ARROW_BOTTOM', 'STICK_ARROW_TOP', 'STICK_ARROW_BOTTOM', 'SOLID_ARROW_TOP_DOTTED', 'SOLID_ARROW_BOTTOM_DOTTED', 'STICK_ARROW_TOP_DOTTED', 'STICK_ARROW_BOTTOM_DOTTED', 'SOLID_ARROW_TOP_REVERSE', 'SOLID_ARROW_BOTTOM_REVERSE', 'STICK_ARROW_TOP_REVERSE', 'STICK_ARROW_BOTTOM_REVERSE', 'SOLID_ARROW_TOP_REVERSE_DOTTED', 'SOLID_ARROW_BOTTOM_REVERSE_DOTTED', 'STICK_ARROW_TOP_REVERSE_DOTTED', 'STICK_ARROW_BOTTOM_REVERSE_DOTTED', 'BIDIRECTIONAL_SOLID_ARROW', 'DOTTED_ARROW', 'BIDIRECTIONAL_DOTTED_ARROW', 'SOLID_CROSS', 'DOTTED_CROSS', 'SOLID_POINT', 'DOTTED_POINT', 'TXT', got 'INVALID'

For more information, see https://docs.github.com/get-started/writing-on-github/working-with-advanced-formatting/creating-diagrams#creating-mermaid-diagrams

Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ sequenceDiagram
Core->>Plugin: new SomePlugin(config, api, eventBus)
Plugin->>EventBus: wire event listeners
Core->>Registry: register(SomePlugin.name, plugin.publicApi) (if exposed)
Note over Registry: shared record behind api.plugins;<br/>read at access time, so plugins constructed<br/>earlier still see later registrations
Note over Registry: shared record behind api.plugins,<br/>read at access time, so plugins constructed<br/>earlier still see later registrations
Core->>CollabMgr: new CollaborationManager(config, api, eventBus)
CollabMgr->>EventBus: wire event listeners

Expand All @@ -68,6 +68,18 @@ sequenceDiagram
CollabMgr->>CollabMgr: connect() (if server config provided)

%% ── Destroy ──────────────────────────────────────
Dev->>Plugin: plugin.destroy()
Plugin->>EventBus: removeEventListener (cleanup)
Note over Registry: the registry lives for the lifetime of the Core<br/>instance — Core does not retain plugin instances,<br/>so nothing calls unregister() on teardown today
Dev->>Core: destroy()
loop plugins, in reverse construction order
Core->>Plugin: plugin.destroy?.()
Plugin->>EventBus: abort() its listeners' signal
Core->>Registry: unregister(plugin.name)
end
Core->>CollabMgr: destroy() (as a plugin: aborts listeners, closes OTClient socket)
Core->>IoC: UndoRedoManager.destroy(), SelectionManager.destroy()
Core->>BlockRenderer: destroy()
loop each rendered block
BlockRenderer->>Tool: instance.destroy?.()
BlockRenderer->>Adapter: destroyBlockToolAdapter(blockId)
end
Core->>Adapter: destroy()
Note over EditorJSModel: never mutated during teardown,<br/>so no removals reach undo history or collaboration
5 changes: 4 additions & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ Canonical startup order:
## Lifecycle boundary

- Plugins receive dependencies via constructor params (`config`, `api`, `eventBus`).
- Plugin instances may implement `destroy()`, but `Core` currently does not expose a global `destroy()` lifecycle hook. UI plugins abort their own EventBus/DOM subscriptions before removing their holders and disposing their popovers. Pending inline-toolbar configuration cannot recreate UI after destruction; other instances sharing the bus remain active.
- `core.destroy()` (or `editor.destroy()` on the `@editorjs/editorjs` bundle) tears the editor down synchronously. It calls each plugin's optional `destroy()` in reverse construction order and removes its `api.plugins` entry, then destroys core services, every rendered block (the tool instance's optional `destroy()`, then its adapter), and finally the adapter plugin's `destroy()`. A throwing `destroy()` is logged and teardown continues. The document model is never mutated, so teardown sends nothing to undo history or collaboration. Calling it twice is a no-op, calling it while `initialize()` is pending makes `initialize()` resolve without dispatching `core:ready`, and `use()`/`initialize()` throw afterwards.
- Release listeners through one `AbortController` per component: pass its `signal` to every `addEventListener` (EventBus, DOM) and tie the unsubscribe functions returned by `api.document.onUpdate`/`api.selection.onCaretUpdate` to its `abort` event (`signal.addEventListener('abort', api.document.onUpdate(cb))`). `destroy()` then calls `abort()` and releases whatever isn't a listener (timers, sockets, DOM nodes). `BlockToolAdapter` subclasses use the inherited `protected signal` instead of overriding `destroy()`.
- Block and inline tools may implement an optional `destroy()`. The editor calls it once per instance it creates, when it stops using that instance.
- UI plugins abort their own EventBus/DOM subscriptions before removing their holders and disposing their popovers. Pending inline-toolbar configuration cannot recreate UI after destruction; other instances sharing the bus remain active.
- Plugins are constructed in registration order, and `api.plugins` is populated as each one is constructed. **Reading another plugin's API inside your constructor returns `undefined`** — do it after `core:ready`, or lazily inside an event handler. `api.plugins` resolves entries at access time, so a plugin constructed first still sees one registered later.

## Keyboard input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ Resources that outlive an editor if nobody releases them:

| Owner | Resource | Today |
|---|---|---|
| `EditorjsUI` | wrapper `div` appended to the integrator's holder | covered by #174, out of scope here |
| `BlocksUI` | blocks holder DOM listeners (`beforeinput`, `keydown`, `copy`), block wrappers | covered by #174, out of scope here |
| `ToolbarUI` / `ToolboxUI` / `InlineToolbarUI` | EventBus listeners, popovers | covered by #174, out of scope here; `InlineToolbarUI`'s inline tool instances are handled here after #174 lands (D7) |
| `EditorjsUI` | wrapper `div` appended to the integrator's holder | fixed by #174 |
| `BlocksUI` | blocks holder DOM listeners (`beforeinput`, `keydown`, `copy`), block wrappers | fixed by #174 |
| `ToolbarUI` / `ToolboxUI` / `InlineToolbarUI` | EventBus listeners, popovers | fixed by #174; `InlineToolbarUI`'s inline tool instances are released here (D7) |
| `CaretAdapter` | subscription to the singleton `useSelectionChange` watcher (`document` `selectionchange`) and `api.selection.onCaretUpdate` | `@todo Unsubscribe on adapter destruction` |
| `FormattingAdapter` | `core:ToolLoaded` and `api.document.onUpdate` listeners, inline tool instances | never released |
| `DOMAdapters` | `DOMBlockToolAdapter` per block (model and `ui:beforeinput` listeners) | released only on block removal |
Expand Down Expand Up @@ -114,7 +114,9 @@ Plugins and tools are interfaces, not base classes, so they own their own contro
- [An async `render()` settles after teardown and dispatches `BlockAddedCoreEvent` into a dead EventBus] → `BlockRenderer` checks a `#destroyed` flag after the `await`, then destroys the late instance and its adapter instead of dispatching.
- [Ops not yet sent in `CollaborationManager`'s debounced batch are lost on destroy] → This already happens when a tab closes. Flushing would need async teardown (Non-Goal) and is listed in Open Questions.
- [The adapter contract narrows `destroy?()` to `destroy()`, which is a type-level break for third-party adapters] → No third-party adapters exist yet, and the package is pre-1.0.
- [#174 overlaps with UI teardown] → #174 covers every UI plugin's teardown, so this change has no UI plugin implementation. The only UI code here is `InlineToolbarUI` destroying its inline tool instances, which is done after #174 lands because #174 rewrites that file. Both changes edit the same `docs/plugins.md` lifecycle line, so whichever lands second merges that one line.
- [#174 overlapped with UI teardown] → #174 (merged) covers every UI plugin's teardown, so the only UI code here is `InlineToolbarUI` destroying its inline tool instances, built on #174's version of that file.
- [A tool whose `getToolbarConfig` rejects fails the whole inline toolbar build] → the rejection is dropped by an existing `@todo` in `InlineToolbarUI`, and the instances that build created aren't released. Deciding how a failed build behaves belongs to that todo, so this change leaves it and notes the instances there.
- [ui-kit's tooltip singleton kept a `window` `scroll` listener and its element] → it was created lazily and never destroyed, so a page that showed (or merely tore down) a hint kept both after every editor was gone. Fixed upstream: `codex-tooltip` 1.1.0 (codex-team/codex.tooltips#32) only holds them while a hint is shown, and `@editorjs/ui-kit` 2.0.1 (editor-js/utils#43) bundles it and no longer creates the tooltip just to hide it. This change depends on `@editorjs/ui-kit` `^2.0.1`.

## Migration Plan

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ There is no way to tear an editor down. Neither `Core` nor `EditorJS` has a `des
- `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.
- 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 (destroying its inline tool instances). 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. The one UI piece left here is `InlineToolbarUI` destroying the inline tool instances it creates (the tool `destroy()` contract below); it's done after #174 lands, since #174 rewrites that file.
- 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 (merged), so this change has no UI plugin teardown of its own. The one UI piece here is `InlineToolbarUI` destroying the inline tool instances it creates, which follows from the tool `destroy()` contract below.
- 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.
Expand All @@ -28,10 +28,11 @@ None.
- `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()`.
- `ui`: the UI plugin listener lifetime requirement (from #174) gains a scenario for releasing inline tool instances with their popover.

## Impact

- **Code**: `packages/core/src/index.ts`, `packages/core/src/components/{BlockRenderer,SelectionManager}.ts`, `packages/sdk/src/entities/{InlineTool,EditorjsAdapterPlugin,BlockToolAdapter}.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/ui/src/InlineToolbar/InlineToolbar.ts` (tool instances, after #174), `packages/editorjs/src/index.ts`.
- **Code**: `packages/core/src/index.ts`, `packages/core/src/components/{BlockRenderer,SelectionManager}.ts`, `packages/sdk/src/entities/{InlineTool,EditorjsAdapterPlugin,BlockToolAdapter}.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/ui/src/InlineToolbar/InlineToolbar.ts` (tool instances), `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.
- **Related work**: #174 (UI plugin teardown, merged), card "Clean up" on the DocumentModel board.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
## 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.
Every first-party component that has a teardown (`Core` itself, its services, adapters, plugins, UI plugins, the collaboration client, 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. Listeners registered on objects that the component owns or that the editor hands to it, and that go away with those objects, are outside this rule: the model's listeners between its own nodes, or a tool's listener on its block adapter, are released when the model or the adapter is.

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.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
## ADDED Requirements

### Requirement: Tool instance teardown hook
The `BlockTool` and `InlineTool` instance contracts SHALL each declare an optional `destroy(): void`. The editor SHALL call `destroy()` exactly once on every tool instance it creates, when it stops using that instance: for long-lived instances, when the owning block is removed, the owning component is rebuilt, or the editor is destroyed; for instances created for a single operation, right after that operation completes. A tool that doesn't implement `destroy()` SHALL keep working unchanged.
The `BlockTool` and `InlineTool` instance contracts SHALL each declare an optional `destroy(): void`. The editor SHALL call `destroy()` exactly once on every tool instance it creates, when it stops using that instance: for long-lived instances, when the owning block is removed, the owning component is rebuilt, or the editor is destroyed; for instances created for a single operation, right after that operation completes. The one exception is an inline toolbar build that fails because a tool's `getToolbarConfig` rejects: the instances it created are not released, since how a failed build behaves is still undecided. A tool that doesn't implement `destroy()` SHALL keep working unchanged.

#### Scenario: Inline tool declares destroy
- **GIVEN** an inline tool class implements `destroy()`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
## MODIFIED Requirements

### Requirement: UI plugin listener lifetime
Each UI plugin SHALL unsubscribe its own EventBus and DOM listeners when destroyed, detach its owned holder, and dispose its owned popover. Destroying one instance SHALL leave other consumers on the shared EventBus active. Teardown SHALL be safe to repeat.

#### Scenario: Events after teardown
- **GIVEN** a UI plugin has been destroyed
- **WHEN** the shared EventBus emits a previously subscribed event or a retained DOM element receives an event
- **THEN** that instance no longer renders, moves elements, or dispatches UI actions
- **AND** unrelated listeners and a replacement plugin still receive their events

#### Scenario: Pending inline toolbar configuration
- **GIVEN** inline tool configuration is still resolving when the toolbar is destroyed
- **WHEN** the configuration finishes resolving
- **THEN** no popover is created, shown, or positioned by the destroyed toolbar

#### Scenario: Opening a destroyed toolbox
- **GIVEN** `ToolboxUI` has been destroyed
- **WHEN** its `open()` is called
- **THEN** no popover is shown and no `ToolboxOpenedUIEvent` is dispatched

#### Scenario: Repeated teardown
- **GIVEN** a UI plugin has been destroyed
- **WHEN** `destroy()` is called again
- **THEN** it returns without releasing anything a second time

#### Scenario: Inline tool instances are released with their popover
- **GIVEN** the inline toolbar created inline tool instances to build its popover
- **WHEN** the popover is rebuilt for a new selection, the toolbar is hidden, a render is superseded before it finishes, or the toolbar is destroyed
- **THEN** each instance created for that popover has its optional `destroy()` called once, and the instances behind the popover still shown are not destroyed

Validated by `src/lifecycle.spec.ts` using real DOM/EventBus listeners and a substituted popover boundary. Vendor-owned global listener internals are outside this requirement's verification scope.
Loading
Loading