Repository navigation
Conversation
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>
- Core#destroy(): destroys plugins in reverse construction order and unregisters their public APIs, then UndoRedoManager, SelectionManager, BlockRenderer and the adapter. Idempotent, safe before and during initialize(), never mutates the model. use()/initialize() throw after it. - EditorJS#destroy() delegates to Core. - BlockRenderer keeps each block's tool instance and destroys it on block removal and on teardown; a render settling after teardown is dropped. - Optional destroy() on BlockTool/InlineTool; throwaway inline tool instances (SelectionManager, ToolsManager validation) are destroyed right after use. - Listeners are released through AbortController: onUpdate/onCaretUpdate subscribe through their own controller, BlockToolAdapter owns one and exposes a protected signal to subclasses, core services abort theirs on destroy. Model addEventListener overloads accept options. Part of the editor-destroy OpenSpec change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Cover teardown error logging, services that were never resolved, an
adapter without destroy(), and blocks removed without a tracked tool.
- Cover ToolsManager inline tool validation around the destroyed
validation instance.
- Drop the redundant destroyed guard in Core.destroy(): clearing plugin
instances and services already makes a second call a no-op.
- Drop { once: true } on the BlockToolAdapter abort listener: a signal
aborts only once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tError - Core.initialize() rejects with a DOMException named AbortError when destroy() runs while tools are being prepared, instead of resolving. - The EditorJS bundle handles its own ready promise: an AbortError is ignored, any other initialization error is logged, so neither is reported as an unhandled rejection. Awaiting isReady still rejects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Unit Tests
Mutation Tests
|
#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>
neSpecc
reviewed
Oct 30, 2026
| * The document model is left untouched, so no removal reaches undo history or collaboration. | ||
| * Safe to call more than once and while {@link initialize} is pending. After it, `use()` and `initialize()` throw | ||
| */ | ||
| public destroy(): void { |
Contributor
There was a problem hiding this comment.
destroy() never releases the onModelUpdate listener registered in the constructor.
It is this.#model.addEventListener(EventType.Changed, ...) with no signal, and teardown does not call removeEventListener for it. The other listeners in this PR are tied to an AbortController and aborted here. This one stays for as long as the model is reachable.
destroy() does not mutate the model, so the callback does not run during teardown itself. It does run on any later change: something still holding api, or a collaboration op that arrives after unmount. The integrator passed that callback and then unmounted, so it fires into a tree that is already gone.
Abort it from here, same as the service listeners.
Reversean
requested changes
Nov 4, 2026
…cade InlineToolFacade.create() hands the same cached instance to every consumer, but ToolsManager's validation and SelectionManager destroyed it after each use, so a tool with a destroy() hook was torn down at startup and after every formatting action, and then used again. - InlineToolFacade.destroy() destroys the shared instance once, dropping it before the hook runs. - ToolsManager.destroy() destroys every inline tool facade; a throwing hook is logged and the remaining tools are still destroyed. - Core.destroy() calls it last, after the adapter, since the adapter and the UI use the instances until then. - Validation and SelectionManager.applyInlineTool no longer destroy the instance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core registered its onModelUpdate model listener without a way to remove it. An integrator that kept the model it was handed could still reach the callback after teardown. The listener now uses Core's own controller, which destroy() aborts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The tool preparation queue kept running after destroy(). Tools still queued were prepared, and a tool whose prepare() settled after teardown was created, registered and announced with ToolLoaded. Its inline instance then outlived ToolsManager.destroy(). ToolsManager now owns an AbortController that destroy() aborts. Each queued job checks the signal before calling prepare() and again once it settles. A prepare() already running can't be interrupted, since it takes no signal, so its result is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # openspec/changes/editor-destroy/tasks.md
gohabereg
force-pushed
the
feat/editor-destroy-lifecycle
branch
from
November 5, 2026 00:00
a8c4898 to
b17b8ea
Compare
Reversean
approved these changes
Nov 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
First implementation step of the
editor-destroyOpenSpec change (spec PR #195, the base of this one). Adds the editor teardown lifecycle and the API changes it needs; the adapters/UI/plugins side follows in a stacked PR.What Changes
Core#destroy(): plugins in reverse construction order (destroy?.()+PluginRegistry.unregister), thenUndoRedoManager,SelectionManager,BlockRenderer, the adapter (destroy?.(), made required in the next PR along withDOMAdapters.destroy()), and lastToolsManager. Each step runs through a helper that logs and continues. Services resolved ininitialize()are recorded in#services, so teardown never constructs one. A second call is a no-op because instances and services are cleared.use()/initialize()throw afterwards. Core'sonModelUpdatemodel listener goes through a controller thatdestroy()aborts, so a model the integrator kept no longer reaches the callback after teardown.initialize()rejects with aDOMExceptionnamedAbortErrorinstead of booting the document or dispatchingcore:ready.ToolsManagerowns anAbortControllerthat itsdestroy()aborts. Its preparation queue checks that signal before and after each tool'sprepare(). Tools still queued are never prepared, and a tool whoseprepare()settles after destroy is never created, registered or announced withToolLoaded. Aprepare()already running can't be interrupted, because it takes no signal, so its result is dropped.EditorJS#destroy()delegates toCore. The bundle attachesreportUnlessAborted()to its ready promise:AbortErroris ignored and other init errors are logged, so nothing is reported as an unhandled rejection. AwaitingisReadystill rejects.BlockRendererkeeps each block's tool instance and destroys it on block removal and on teardown. A render that settles after teardown dispatches nothing.destroy()onBlockTool/InlineTool, called once per instance by its owner:BlockRendererowns each per-block instance.InlineToolFacade.create()returns one cached instance per tool, shared by formatting, selection, validation and the inline toolbar. None of those consumers destroys it. The facade's newdestroy()does, once and only if the instance was ever created, andToolsManager.destroy()calls it for every inline tool, logging a throwing hook and moving on to the next.AbortController:onUpdate/onCaretUpdatesubscribe through their own controller (signatures unchanged).UndoRedoManager,SelectionManagerandBlockRendererabort one controller each.BlockToolAdapterowns its controller and exposesprotected signalto subclasses.EditorJSModel's typedaddEventListeneroverloads acceptoptions.Verification
-w): all mutants killed incore, and insdk/editorjsvia an ad-hoc run (those packages have no Stryker config).modelchanges are type-only. The packages' ownyarn test:mutationspass.prepare()that settles after destroySelectionManagerorToolsManagervalidationAbortErrorhandling, including that it is not left unhandledonModelUpdateafter destroyNotes
ToolsManager's inline-toolrendercheck is pinned by tests as-is; removing it is tracked on the board ("Remove inverted inline tool render check in ToolsManager").Stack
🤖 Generated with Claude Code