Skip to content
31 changes: 16 additions & 15 deletions openspec/changes/editor-destroy/tasks.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
## 1. SDK contracts

- [ ] 1.1 Add optional `destroy?(): void` to `InlineTool` and declare it explicitly on `BlockTool` in `packages/sdk/src/entities/{InlineTool,BlockTool}.ts`, with type tests that tools with and without `destroy()` both satisfy their constructor types
- [x] 1.1 Add optional `destroy?(): void` to `InlineTool` and declare it explicitly on `BlockTool` in `packages/sdk/src/entities/{InlineTool,BlockTool}.ts`, with type tests that tools with and without `destroy()` both satisfy their constructor types
- [ ] 1.2 Narrow `EditorJSAdapterPlugin` to a required `destroy(): void` in `packages/sdk/src/entities/EditorjsAdapterPlugin.ts`, and update the test adapter mocks in `packages/core` to implement it

## 2. Signal plumbing (AbortController rule, design D8/D8a)

- [ ] 2.1 Add an `options?: AddEventListenerOptions` argument to `EditorJSModel`'s typed `addEventListener` overloads and forward it
- [ ] 2.2 Keep the core `DocumentAPI.onUpdate` and `SelectionAPI.onCaretUpdate` tests green (`should stop calling the callback after the returned function is called`) while switching the implementation to a controller per subscription that returns `() => controller.abort()`
- [ ] 2.3 Write failing `BlockToolAdapter` tests: `should stop dispatching translated events after destroy`, and `should remove a subclass listener registered with the inherited signal on destroy`. Give the base class its own controller and a `protected readonly signal`, and abort it in `destroy()`
- [x] 2.1 Add an `options?: AddEventListenerOptions` argument to `EditorJSModel`'s typed `addEventListener` overloads and forward it
- [x] 2.2 Keep the core `DocumentAPI.onUpdate` and `SelectionAPI.onCaretUpdate` tests green (`should stop calling the callback after the returned function is called`) while switching the implementation to a controller per subscription that returns `() => controller.abort()`
- [x] 2.3 Write failing `BlockToolAdapter` tests: `should stop dispatching translated events after destroy`, and `should remove a subclass listener registered with the inherited signal on destroy`. Give the base class its own controller and a `protected readonly signal`, and abort it in `destroy()`
- [ ] 2.4 Make `DOMBlockToolAdapter` pass `this.signal` to its `ui:beforeinput` registration, and remove its `destroy()` override and `#beforeInputListener` field
- [ ] 2.5 Move `UndoRedoManager`, `ClipboardPlugin`, `ShortcutsPlugin`, and `CollaborationManager` listener cleanup to one controller each. Keep their existing destroy tests green, and keep explicit cleanup only for timers and the socket
- [x] 2.5 Move `UndoRedoManager` listener cleanup to one controller, keeping explicit cleanup only for its debounce timer
- [ ] 2.6 Move `ClipboardPlugin`, `ShortcutsPlugin`, and `CollaborationManager` listener cleanup to one controller each. Keep their existing destroy tests green, and keep explicit cleanup only for timers and the socket

## 3. Selection watcher reference counting (dom-adapters)

Expand All @@ -27,18 +28,18 @@

## 5. Core services teardown

- [ ] 5.1 Write failing `BlockRenderer` tests: `should call tool destroy when its block is removed`, `should destroy every remaining tool and block adapter on destroy without dispatching BlockRemovedCoreEvent`, `should stop handling model events after destroy`, and `should not dispatch BlockAddedCoreEvent for a render that settles after destroy`
- [ ] 5.2 Track tool instances per block id in `BlockRenderer`, subscribe to the model with a controller's signal, add `destroy()`, and remove the `@todo clear block tool adapter memory`
- [ ] 5.3 Write a failing `SelectionManager` test, `should stop handling caret updates after destroy`. Implement `SelectionManager.destroy()` by aborting its controller
- [ ] 5.4 Give `InlineToolFacade` a `destroy()` that destroys its shared instance once, and `ToolsManager` a `destroy()` that calls it for every inline tool, logging a throwing hook; `Core.destroy()` calls it last (design D3, D7). Test that `SelectionManager.applyInlineTool` and `ToolsManager` validation don't destroy the shared instance
- [x] 5.1 Write failing `BlockRenderer` tests: `should call tool destroy when its block is removed`, `should destroy every remaining tool and block adapter on destroy without dispatching BlockRemovedCoreEvent`, `should stop handling model events after destroy`, and `should not dispatch BlockAddedCoreEvent for a render that settles after destroy`
- [x] 5.2 Track tool instances per block id in `BlockRenderer`, subscribe to the model with a controller's signal, add `destroy()`, and remove the `@todo clear block tool adapter memory`
- [x] 5.3 Write a failing `SelectionManager` test, `should stop handling caret updates after destroy`. Implement `SelectionManager.destroy()` by aborting its controller
- [x] 5.4 Give `InlineToolFacade` a `destroy()` that destroys its shared instance once, and `ToolsManager` a `destroy()` that calls it for every inline tool, logging a throwing hook; `Core.destroy()` calls it last (design D3, D7). Test that `SelectionManager.applyInlineTool` and `ToolsManager` validation don't destroy the shared instance

## 6. Core.destroy()

- [ ] 6.1 Write failing `Core` tests (`packages/core/src/index.spec.ts`): `should call plugin destroy in reverse construction order`, `should unregister plugin public APIs`, `should continue teardown and log when a plugin destroy throws`, `should destroy UndoRedoManager, SelectionManager, BlockRenderer and then the adapter`, `should not emit model events during destroy`, and `should be a no-op when called twice`
- [ ] 6.2 Keep plugin instances in `#initializePlugin`, track the initialization stage, and implement `destroy()` in the order from design D3
- [ ] 6.3 Write failing tests: `should not construct anything when destroyed before initialize`, `should reject initialize with an AbortError without dispatching ready when destroyed mid-initialization`, and `should throw from use and initialize after destroy`
- [ ] 6.4 Implement the `#destroyed` checks after each `await` in `initialize()`, and the post-destroy guards in `use()` and `initialize()`
- [ ] 6.5 Write failing `ToolsManager` tests, `should not prepare the tools still queued` and `should neither create nor announce a tool whose prepare() settles after destroy`. Give `ToolsManager` an `AbortController` that `destroy()` aborts, and check its signal before and after each `prepare()` (design D4)
- [x] 6.1 Write failing `Core` tests (`packages/core/src/index.spec.ts`): `should call plugin destroy in reverse construction order`, `should unregister plugin public APIs`, `should continue teardown and log when a plugin destroy throws`, `should destroy UndoRedoManager, SelectionManager, BlockRenderer and then the adapter`, `should not emit model events during destroy`, and `should be a no-op when called twice`
- [x] 6.2 Keep plugin instances in `#initializePlugin`, track the initialization stage, and implement `destroy()` in the order from design D3
- [x] 6.3 Write failing tests: `should not construct anything when destroyed before initialize`, `should reject initialize with an AbortError without dispatching ready when destroyed mid-initialization`, and `should throw from use and initialize after destroy`
- [x] 6.4 Implement the `#destroyed` checks after each `await` in `initialize()`, and the post-destroy guards in `use()` and `initialize()`
- [x] 6.5 Write failing `ToolsManager` tests, `should not prepare the tools still queued` and `should neither create nor announce a tool whose prepare() settles after destroy`. Give `ToolsManager` an `AbortController` that `destroy()` aborts, and check its signal before and after each `prepare()` (design D4)

## 7. UI

Expand All @@ -47,7 +48,7 @@

## 8. Bundle and integration

- [ ] 8.1 Write a failing `EditorJS` test, `should delegate destroy to Core`, and implement `EditorJS#destroy()`. Add tests that an `AbortError` from `isReady` isn't reported as an unhandled rejection while any other initialization error is logged, and handle that on the bundle's ready promise
- [x] 8.1 Write a failing `EditorJS` test, `should delegate destroy to Core`, and implement `EditorJS#destroy()`. Add tests that an `AbortError` from `isReady` isn't reported as an unhandled rejection while any other initialization error is logged, and handle that on the bundle's ready promise
- [ ] 8.2 Write the integration test in `packages/editorjs`, `should leave no DOM nodes or document/holder listeners after creating and destroying several editors on one page`, spying on `document`/holder `add/removeEventListener` and checking that the counts balance and the holders are empty
- [ ] 8.3 Add an integration test that destroys the editor before `isReady` settles and checks that `isReady` rejects with an `AbortError` and nothing is rendered
- [ ] 8.4 Check that `CollaborationManager.destroy()` closes the `OTClient` socket when it's reached through `EditorJS#destroy()` (mock WebSocket)
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,47 @@ describe('DocumentAPI', () => {
expect(dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({ name: 'redo' }));
});
});

describe('.onUpdate()', () => {
/**
* EventTarget stands in for the model, so listeners are really added and removed
* @param target - stand-in for the model
*/
const createAPI = (target: EventTarget): InstanceType<typeof DocumentAPI> => new DocumentAPI(
target as unknown as InstanceType<typeof EditorJSModel>,
{} as unknown as CoreConfigValidated,
{ dispatchEvent } as unknown as EventBus
);

it('should call the callback on model update', () => {
const target = new EventTarget();
const callback = jest.fn();

createAPI(target).onUpdate(callback);
target.dispatchEvent(new Event('update'));

expect(callback).toHaveBeenCalledTimes(1);
});

it('should stop calling the callback after the returned function is called', () => {
const target = new EventTarget();
const callback = jest.fn();

const unsubscribe = createAPI(target).onUpdate(callback);

unsubscribe();
target.dispatchEvent(new Event('update'));

expect(callback).not.toHaveBeenCalled();
});

it('should subscribe through an abort signal', () => {
const target = new EventTarget();
const addEventListener = jest.spyOn(target, 'addEventListener');

createAPI(target).onUpdate(jest.fn());

expect(addEventListener).toHaveBeenCalledWith('update', expect.any(Function), { signal: expect.any(AbortSignal) });
});
});
});
8 changes: 4 additions & 4 deletions packages/core/src/api/DocumentAPI/DocumentAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ export class DocumentAPI implements DocumentApiInterface {
* @param callback - callback called on model update
*/
public onUpdate(callback: (event: ModelEvents) => void): () => void {
this.#model.addEventListener(EventType.Changed, callback);
const controller = new AbortController();

return () => {
this.#model.removeEventListener(EventType.Changed, callback);
};
this.#model.addEventListener(EventType.Changed, callback, { signal: controller.signal });

return () => controller.abort();
}

/**
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/api/SelectionAPI.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,47 @@ describe('SelectionAPI', () => {
});
});
});

describe('.onCaretUpdate()', () => {
/**
* EventTarget stands in for the model, so listeners are really added and removed
* @param target - stand-in for the model
*/
const createAPI = (target: EventTarget): InstanceType<typeof SelectionAPI> => new SelectionAPI(
selectionManager as unknown as InstanceType<typeof SelectionManager>,
target as unknown as InstanceType<typeof EditorJSModel>,
{} as unknown as CoreConfigValidated
);

it('should call the callback on caret update', () => {
const target = new EventTarget();
const callback = jest.fn();

createAPI(target).onCaretUpdate(callback);
target.dispatchEvent(new Event('update'));

expect(callback).toHaveBeenCalledTimes(1);
});

it('should stop calling the callback after the returned function is called', () => {
const target = new EventTarget();
const callback = jest.fn();

const unsubscribe = createAPI(target).onCaretUpdate(callback);

unsubscribe();
target.dispatchEvent(new Event('update'));

expect(callback).not.toHaveBeenCalled();
});

it('should subscribe through an abort signal', () => {
const target = new EventTarget();
const addEventListener = jest.spyOn(target, 'addEventListener');

createAPI(target).onCaretUpdate(jest.fn());

expect(addEventListener).toHaveBeenCalledWith('update', expect.any(Function), { signal: expect.any(AbortSignal) });
});
});
});
8 changes: 4 additions & 4 deletions packages/core/src/api/SelectionAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,11 @@ export class SelectionAPI implements SelectionApiInterface {
* @param callback - callback for CaretManager updates
*/
public onCaretUpdate(callback: (event: CaretManagerEvents) => void): () => void {
this.#model.addEventListener(EventType.CaretManagerUpdated, callback);
const controller = new AbortController();

return () => {
this.#model.removeEventListener(EventType.CaretManagerUpdated, callback);
};
this.#model.addEventListener(EventType.CaretManagerUpdated, callback, { signal: controller.signal });

return () => controller.abort();
}

/**
Expand Down
192 changes: 192 additions & 0 deletions packages/core/src/components/BlockRenderer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,195 @@ describe('BlockRenderer (unit, mocked deps)', () => {
});
});
});

describe('BlockRenderer lifecycle', () => {
beforeEach(() => {
(console.error as jest.Mock).mockClear();
});

interface Setup {
renderer: InstanceType<typeof BlockRenderer>;
emit: (event: unknown) => Promise<void>;
signal: () => AbortSignal | undefined;
dispatchEvent: jest.Mock;
adapter: { createBlockToolAdapter: jest.Mock;
destroyBlockToolAdapter: jest.Mock; };
}

/**
* Creates a renderer whose model honours the abort signal, like a real EventTarget
* @param createTool - returns the tool instance for each added block
*/
function setup(createTool: () => { render: () => Promise<unknown>;
destroy?: () => void; }): Setup {
let listener: ((event: unknown) => void | Promise<void>) | undefined;
let listenerSignal: AbortSignal | undefined;

const model = {
addEventListener: jest.fn((_type: string, callback: (event: unknown) => void, options?: AddEventListenerOptions) => {
listener = callback;
listenerSignal = options?.signal;
}),
};
const dispatchEvent = jest.fn();
const adapter = {
createBlockToolAdapter: jest.fn(() => ({})),
destroyBlockToolAdapter: jest.fn(),
};
const toolsManager = {
blockTools: {
get: jest.fn(() => ({
name: 'tool',
create: createTool,
})),
},
};

const renderer = new BlockRenderer(
model as unknown as InstanceType<typeof EditorJSModel>,
{ dispatchEvent } as unknown as InstanceType<typeof EventBus>,
toolsManager as unknown as InstanceType<typeof ToolsManager>,
adapter as unknown as EditorJSAdapterPlugin
);

return {
renderer,
emit: async (event: unknown) => {
if (listenerSignal?.aborted === true) {
return;
}

await listener?.(event);
},
signal: () => listenerSignal,
dispatchEvent,
adapter,
};
}

const added = (id: string, blockIndex = 0): unknown => new BlockAddedEvent(
{ blockIndex } as unknown as BlockIndex,
{ name: 'tool',
id: id as BlockId,
data: {} },
USER_ID
);

const removed = (id: string, blockIndex = 0): unknown => new BlockRemovedEvent(
{ blockIndex } as unknown as BlockIndex,
{ name: 'tool',
id: id as BlockId,
data: {} },
USER_ID
);

it('should call tool destroy when its block is removed', async () => {
const destroy = jest.fn();
const { emit, adapter, dispatchEvent } = setup(() => ({ render: () => Promise.resolve({}),
destroy }));

await emit(added('a'));
await emit(removed('a'));

expect(destroy).toHaveBeenCalledTimes(1);
expect(adapter.destroyBlockToolAdapter).toHaveBeenCalledWith('a');
expect(dispatchEvent).toHaveBeenCalledTimes(2);
});

it('should destroy every remaining tool and block adapter on destroy without dispatching BlockRemovedCoreEvent', async () => {
const destroy = jest.fn();
const { renderer, emit, adapter, dispatchEvent } = setup(() => ({ render: () => Promise.resolve({}),
destroy }));

await emit(added('a', 0));
await emit(added('b', 1));
dispatchEvent.mockClear();

renderer.destroy();

expect(destroy).toHaveBeenCalledTimes(2);
expect(adapter.destroyBlockToolAdapter).toHaveBeenCalledWith('a');
expect(adapter.destroyBlockToolAdapter).toHaveBeenCalledWith('b');
expect(dispatchEvent).not.toHaveBeenCalled();
});

it('should not destroy a removed block again on destroy', async () => {
const destroy = jest.fn();
const { renderer, emit } = setup(() => ({ render: () => Promise.resolve({}),
destroy }));

await emit(added('a'));
await emit(removed('a'));
renderer.destroy();

expect(destroy).toHaveBeenCalledTimes(1);
});

it('should stop handling model events after destroy', async () => {
const { renderer, emit, signal, dispatchEvent } = setup(() => ({ render: () => Promise.resolve({}) }));

renderer.destroy();
await emit(added('a'));

expect(signal()?.aborted).toBe(true);
expect(dispatchEvent).not.toHaveBeenCalled();
});

it('should not dispatch BlockAddedCoreEvent for a render that settles after destroy', async () => {
let resolveRender: (value: unknown) => void = () => undefined;
const destroy = jest.fn();
const { renderer, emit, adapter, dispatchEvent } = setup(() => ({
render: () => new Promise((resolve) => {
resolveRender = resolve;
}),
destroy,
}));

const pending = emit(added('a'));

renderer.destroy();
resolveRender({});
await pending;

expect(dispatchEvent).not.toHaveBeenCalled();
expect(destroy).toHaveBeenCalledTimes(1);
expect(adapter.destroyBlockToolAdapter).toHaveBeenCalledWith('a');
});

it('should keep tearing down when a tool destroy throws', async () => {
let calls = 0;
const { renderer, emit, adapter } = setup(() => ({
render: () => Promise.resolve({}),
destroy: () => {
calls++;
throw new Error('broken tool');
},
}));

await emit(added('a', 0));
await emit(added('b', 1));

expect(() => renderer.destroy()).not.toThrow();
expect(calls).toBe(2);
expect(adapter.destroyBlockToolAdapter).toHaveBeenCalledTimes(2);
expect(console.error).toHaveBeenCalledWith('[BlockRenderer] Block Tool failed to destroy block a', expect.any(Error));
});

it('should not log when a tool has no destroy', async () => {
const { renderer, emit } = setup(() => ({ render: () => Promise.resolve({}) }));

await emit(added('a'));
renderer.destroy();

expect(console.error).not.toHaveBeenCalled();
});

it('should destroy the adapter of a removed block that has no tracked tool instance', async () => {
const { emit, adapter } = setup(() => ({ render: () => Promise.resolve({}) }));

await emit(removed('unknown'));

expect(adapter.destroyBlockToolAdapter).toHaveBeenCalledWith('unknown');
expect(console.error).not.toHaveBeenCalled();
});
});
Loading
Loading