Skip to content

docs(openspec): propose editor-destroy - #195

Open
gohabereg wants to merge 5 commits into
mainfrom
feat/editor-destroy
Open

gohabereg wants to merge 5 commits into
mainfrom
feat/editor-destroy

Conversation

@gohabereg

@gohabereg gohabereg commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Why

There is no way to tear an editor down. Neither Core nor EditorJS has a destroy(), so unmounting an editor (route change, component unmount, several editors on one page, React StrictMode) leaks the editor DOM, the document-wide selectionchange listener, the collaboration WebSocket, timers, and every tool and plugin instance. Several plugins already implement destroy(), and the core/sdk specs already say it's called on teardown, but nothing calls it.

Board card: Clean up (Core APIs).

What Changes

This PR is the OpenSpec change only (openspec/changes/editor-destroy). Implementation follows in stacked PRs.

  • Core#destroy() / EditorJS#destroy(): synchronous, idempotent. Tears down plugins in reverse construction order (unregistering their public APIs), then UndoRedoManager, SelectionManager, BlockRenderer (each block's tool instance and adapter), the adapter plugin, and last ToolsManager. A throwing step is logged and teardown continues. The model is never mutated, so nothing reaches undo history or collaboration. use()/initialize() throw afterwards.
  • Interrupted initialization: destroy() during a pending initialize() makes it reject with a DOMException named AbortError (the platform convention, as with fetch). Tool preparation stops too: ToolsManager aborts its own controller, so tools still queued aren't prepared and a tool whose prepare() settles afterwards is neither created nor announced. The bundle handles its own ready promise: AbortError is ignored, other init errors are logged, so neither is reported as an unhandled rejection; awaiting isReady still rejects.
  • Tools: optional destroy() on BlockTool/InlineTool, called once per instance the editor creates:
    • Block tools: BlockRenderer owns each per-block instance.
    • Inline tools: InlineToolFacade.create() returns one instance per tool, shared by formatting, selection and the inline toolbar. None of those consumers destroys it. The facade does, once, through ToolsManager, as the last step of teardown (D7).
  • Adapter plugin: destroy() becomes required on EditorJSAdapterPlugin.
  • Repo-wide listener rule (architecture spec): every component releases listeners through one AbortController; unsubscribe functions returned by onUpdate/onCaretUpdate are tied to its abort event. SDK base classes (BlockToolAdapter) own the controller and expose a protected signal to subclasses.
  • Shared selectionchange watcher: reference-counted, so several editors can be destroyed independently.
  • UI: teardown for every UI plugin is covered by fix(ui): remove blocks holder listeners and detach it on destroy #174, so this change has no UI plugin implementation. InlineToolbarUI doesn't destroy inline tool instances either, because they're shared (D7).

Modified capabilities: architecture, core, sdk, dom-adapters, editorjs-bundle. Supersedes the "Core currently does not expose a global destroy()" note in docs/plugins.md (docs are updated in the implementation PR).

Stack

  1. This PR: spec
  2. feat(core): add editor destroy lifecycle #196 — lifecycle and API changes: Core/EditorJS destroy(), core services, tool contracts, BlockToolAdapter controller (targets this branch)
  3. feat: implement destroy() in adapters and plugins #207 — adapters, plugins, e2e tests and the archive (stacked on feat(core): add editor destroy lifecycle #196)

Verification

  • openspec validate editor-destroy --type change passes.
  • tasks.md is intentionally unchecked here; implementation PRs check off their tasks.

🤖 Generated with Claude Code

Add the editor-destroy change: Core/EditorJS destroy() lifecycle,
optional destroy() on tools, required destroy() on the adapter plugin,
and a repo-wide rule that listeners are released through AbortController
(owned by SDK base classes where they exist).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gohabereg and others added 2 commits October 6, 2026 18:30
#174 now covers teardown for every UI plugin (listeners, holders,
popovers, the EditorjsUI wrapper, pending inline toolbar config). Drop
the ui delta and the UI tasks; keep only InlineToolbarUI destroying its
inline tool instances, done after #174 lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gohabereg and others added 2 commits November 4, 2026 23:59
Review on #196 found that the tool preparation queue kept running after
destroy(). Tools still queued were prepared, and a tool whose prepare()
settled after teardown was created and announced. D4 now describes the
fix: ToolsManager owns an AbortController that destroy() aborts, and
each queued job checks it before and after prepare(). A prepare()
already running can't be interrupted, so its result is dropped.

The core scenario for a destroy during a pending initialize() covers
it, and task 6.5 tracks the implementation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review on #195 pointed out that InlineToolFacade.create() returns one
cached instance per tool. Formatting, selection, validation and the
inline toolbar all share it, so the cleanup D7 proposed would destroy
tools still in use, several times over.

- Design: D7 is rewritten. Each inline tool's facade owns its shared
  instance, and ToolsManager destroys it once, as the last step of
  teardown. Both rejected alternatives are explained. D3 gains the
  ToolsManager step and D5 the FormattingAdapter change. The context
  table and the #174 risk are updated.
- Specs: the SDK teardown hook requirement states the rule, with a
  facade scenario. Core gains a scenario for shared inline instances
  destroyed last, once each. The dom-adapters formatting scenario no
  longer destroys them.
- The proposal and tasks 4.3, 4.4, 5.4 and 7.2 are updated to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gohabereg
gohabereg requested a review from Reversean November 5, 2026 00:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants