diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 52733c148d..7161bdb8fb 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -17,6 +17,7 @@ import type { import { shallowEqual } from './equality.js'; import { scrollRangeIntoView } from './scroll-into-view.js'; import { createCustomCommandsRegistry } from './custom-commands.js'; +import { createScope } from './scope.js'; import type { CommandHandle, CommandsHandle, @@ -35,6 +36,7 @@ import type { SuperDocEditorLike, SuperDocUI, SuperDocUIOptions, + SuperDocUIScope, SuperDocUIState, Subscribable, ToolbarCommandHandleState, @@ -1658,9 +1660,53 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }, }; + // Live scopes created via `ui.createScope()`. The controller's + // `destroy()` cascades into every entry before tearing down its own + // resources, so consumers do not need to call `scope.destroy()` + // themselves on shutdown. Calling `ui.destroy()` is enough. + const liveScopes = new Set(); + + const createScopeFn = (): SuperDocUIScope => { + if (destroyed) { + // Mirror the destroyed-parent behavior of `scope.child()`: + // return an already-destroyed scope so consumers in shutdown + // races do not get a live scope that the controller will never + // cascade-destroy. Methods on the returned scope follow the + // documented post-destroy contract (`add` runs synchronously, + // `on` is a no-op, `register` throws, `child` returns destroyed). + const inert = createScope({ + register: customCommandsRegistry.register.bind(customCommandsRegistry), + trackScope: () => () => undefined, + }); + inert.destroy(); + return inert; + } + return createScope({ + register: customCommandsRegistry.register.bind(customCommandsRegistry), + trackScope: (scope) => { + liveScopes.add(scope); + return () => { + liveScopes.delete(scope); + }; + }, + }); + }; + const destroy = () => { if (destroyed) return; destroyed = true; + // Cascade into scopes first. Each scope's own destroy untracks + // itself from `liveScopes`, so iterate a snapshot to avoid mutating + // the set during iteration. + const scopeSnapshot = [...liveScopes]; + liveScopes.clear(); + for (const scope of scopeSnapshot) { + try { + scope.destroy(); + } catch (err) { + console.error('[superdoc/ui] scope destroy threw during ui.destroy()', err); + } + } stateChangeListeners.clear(); commandHandleCache.clear(); commandSubscribableCache.clear(); @@ -1675,5 +1721,16 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { teardown.length = 0; }; - return { select, toolbar, commands, comments, trackChanges, selection, viewport, document, destroy }; + return { + select, + toolbar, + commands, + comments, + trackChanges, + selection, + viewport, + document, + createScope: createScopeFn, + destroy, + }; } diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts index 73fa37f06d..62e9782b9f 100644 --- a/packages/super-editor/src/ui/index.ts +++ b/packages/super-editor/src/ui/index.ts @@ -86,6 +86,7 @@ export type { // Controller SuperDocUI, SuperDocUIOptions, + SuperDocUIScope, SuperDocUIState, // Selection diff --git a/packages/super-editor/src/ui/scope.test.ts b/packages/super-editor/src/ui/scope.test.ts new file mode 100644 index 0000000000..abef2be68e --- /dev/null +++ b/packages/super-editor/src/ui/scope.test.ts @@ -0,0 +1,266 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createSuperDocUI } from './create-super-doc-ui.js'; +import type { SuperDocLike } from './types.js'; + +/** + * Minimal SuperDoc stub for scope tests. Only the bits the controller + * reads on `createSuperDocUI({ superdoc })` and during `destroy()` are + * present; scope behavior is intentionally independent of editor + * state. + */ +function makeSuperdocStub(): SuperDocLike { + return { + activeEditor: { + on: vi.fn(), + off: vi.fn(), + doc: { + selection: { + current: vi.fn(() => ({ empty: true, text: undefined, target: null })), + }, + }, + }, + config: { documentMode: 'editing' }, + on: vi.fn(), + off: vi.fn(), + }; +} + +describe('SuperDocUIScope', () => { + let teardown: Array<() => void> = []; + + afterEach(() => { + teardown.forEach((fn) => fn()); + teardown = []; + }); + + describe('add()', () => { + it('runs every queued teardown in reverse order on destroy', () => { + const ui = createSuperDocUI({ superdoc: makeSuperdocStub() }); + teardown.push(() => ui.destroy()); + const scope = ui.createScope(); + + const order: number[] = []; + scope.add(() => order.push(1)); + scope.add(() => order.push(2)); + scope.add(() => order.push(3)); + + scope.destroy(); + expect(order).toEqual([3, 2, 1]); + }); + + it('invokes the teardown immediately when the scope is already destroyed', () => { + const ui = createSuperDocUI({ superdoc: makeSuperdocStub() }); + teardown.push(() => ui.destroy()); + const scope = ui.createScope(); + scope.destroy(); + + const fn = vi.fn(); + scope.add(fn); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('continues running later teardowns when an earlier one throws', () => { + const ui = createSuperDocUI({ superdoc: makeSuperdocStub() }); + teardown.push(() => ui.destroy()); + const scope = ui.createScope(); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const ok = vi.fn(); + scope.add(ok); + scope.add(() => { + throw new Error('first teardown blew up'); + }); + + scope.destroy(); + expect(ok).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + }); + + describe('on()', () => { + it('attaches the listener and removes it on destroy with the same options', () => { + const ui = createSuperDocUI({ superdoc: makeSuperdocStub() }); + teardown.push(() => ui.destroy()); + const scope = ui.createScope(); + + const target = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as EventTarget & { + addEventListener: ReturnType; + removeEventListener: ReturnType; + }; + + const listener = vi.fn(); + const options = { capture: true, passive: true } as const; + scope.on(target, 'click', listener, options); + + expect(target.addEventListener).toHaveBeenCalledWith('click', listener, options); + expect(target.removeEventListener).not.toHaveBeenCalled(); + + scope.destroy(); + expect(target.removeEventListener).toHaveBeenCalledWith('click', listener, options); + }); + + it('is a no-op when the scope is already destroyed', () => { + const ui = createSuperDocUI({ superdoc: makeSuperdocStub() }); + teardown.push(() => ui.destroy()); + const scope = ui.createScope(); + scope.destroy(); + + const target = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as EventTarget & { + addEventListener: ReturnType; + }; + scope.on(target, 'click', vi.fn()); + expect(target.addEventListener).not.toHaveBeenCalled(); + }); + }); + + describe('register()', () => { + it('forwards to the controller registry and unregisters on destroy', () => { + const ui = createSuperDocUI({ superdoc: makeSuperdocStub() }); + teardown.push(() => ui.destroy()); + const scope = ui.createScope(); + + const execute = vi.fn(() => true); + const reg = scope.register({ id: 'company.test', execute }); + + // Active: the controller's get() resolves to a real handle. + expect(ui.commands.get('company.test')).toBeDefined(); + + scope.destroy(); + + // Destroyed: the registry no longer carries the id. + expect(ui.commands.get('company.test')).toBeUndefined(); + + // The returned handle stays inert (consumer keeps the reference + // around their own teardown, which is fine). + expect(typeof reg.handle.execute).toBe('function'); + }); + + it('throws when called on a destroyed scope', () => { + const ui = createSuperDocUI({ superdoc: makeSuperdocStub() }); + teardown.push(() => ui.destroy()); + const scope = ui.createScope(); + scope.destroy(); + + expect(() => scope.register({ id: 'company.dead', execute: () => true })).toThrow(/scope has been destroyed/); + }); + }); + + describe('child()', () => { + it('destroying the parent destroys live children', () => { + const ui = createSuperDocUI({ superdoc: makeSuperdocStub() }); + teardown.push(() => ui.destroy()); + const parent = ui.createScope(); + const child = parent.child(); + + const childTeardown = vi.fn(); + child.add(childTeardown); + + parent.destroy(); + expect(child.destroyed).toBe(true); + expect(childTeardown).toHaveBeenCalledTimes(1); + }); + + it('children destroy before the parent runs its own teardowns', () => { + const ui = createSuperDocUI({ superdoc: makeSuperdocStub() }); + teardown.push(() => ui.destroy()); + const parent = ui.createScope(); + const child = parent.child(); + + const order: string[] = []; + child.add(() => order.push('child')); + parent.add(() => order.push('parent')); + + parent.destroy(); + expect(order).toEqual(['child', 'parent']); + }); + + it('returns an already-destroyed scope when the parent is destroyed', () => { + const ui = createSuperDocUI({ superdoc: makeSuperdocStub() }); + teardown.push(() => ui.destroy()); + const parent = ui.createScope(); + parent.destroy(); + + const child = parent.child(); + expect(child.destroyed).toBe(true); + }); + }); + + describe('destroy()', () => { + it('is idempotent', () => { + const ui = createSuperDocUI({ superdoc: makeSuperdocStub() }); + teardown.push(() => ui.destroy()); + const scope = ui.createScope(); + + const fn = vi.fn(); + scope.add(fn); + scope.destroy(); + scope.destroy(); + + expect(fn).toHaveBeenCalledTimes(1); + expect(scope.destroyed).toBe(true); + }); + }); + + describe('ui.createScope() after ui.destroy()', () => { + it('returns an already-destroyed scope so it cannot leak', () => { + const ui = createSuperDocUI({ superdoc: makeSuperdocStub() }); + ui.destroy(); + + const scope = ui.createScope(); + expect(scope.destroyed).toBe(true); + + // Methods follow the standard destroyed-scope contract. + const fn = vi.fn(); + scope.add(fn); + expect(fn).toHaveBeenCalledTimes(1); + + expect(() => scope.register({ id: 'company.late', execute: () => true })).toThrow(/scope has been destroyed/); + }); + }); + + describe('cascade from ui.destroy()', () => { + it('destroys every live scope before tearing down the controller', () => { + const ui = createSuperDocUI({ superdoc: makeSuperdocStub() }); + const scopeA = ui.createScope(); + const scopeB = ui.createScope(); + + const aTeardown = vi.fn(); + const bTeardown = vi.fn(); + scopeA.add(aTeardown); + scopeB.add(bTeardown); + + ui.destroy(); + + expect(aTeardown).toHaveBeenCalledTimes(1); + expect(bTeardown).toHaveBeenCalledTimes(1); + expect(scopeA.destroyed).toBe(true); + expect(scopeB.destroyed).toBe(true); + }); + + it('still destroys remaining scopes when one scope throws during destroy', () => { + const ui = createSuperDocUI({ superdoc: makeSuperdocStub() }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const scopeA = ui.createScope(); + scopeA.add(() => { + throw new Error('A blew up'); + }); + const scopeB = ui.createScope(); + const bTeardown = vi.fn(); + scopeB.add(bTeardown); + + ui.destroy(); + + expect(bTeardown).toHaveBeenCalledTimes(1); + errorSpy.mockRestore(); + }); + }); +}); diff --git a/packages/super-editor/src/ui/scope.ts b/packages/super-editor/src/ui/scope.ts new file mode 100644 index 0000000000..abe6705b96 --- /dev/null +++ b/packages/super-editor/src/ui/scope.ts @@ -0,0 +1,165 @@ +/** + * `SuperDocUIScope`: controller-aware lifecycle helper. + * + * Without React's effect lifecycle, every consumer ends up tracking + * subscriptions, custom-command registrations, and DOM listeners by + * hand so they can be torn down on hot reload, on tab close, and when + * the controller is destroyed. `ui.createScope()` collects all three + * categories and tears them down with one call. Scopes also chain to + * the controller's own destroy: calling `ui.destroy()` cascades into + * every live scope before the controller releases its own resources, + * so consumers do not need to remember a separate teardown. + * + * The minimum public surface is intentionally small. Convenience + * sugar like `scope.subscribe(handle, fn)` / `scope.observe(handle, + * fn)` is held back until SD-2919 unifies the subscribe and observe + * shapes across domain handles; until then `scope.add(handle.subscribe(...))` + * is the canonical pattern. + * + * Post-destroy semantics: + * - `scope.add(teardown)` invokes `teardown()` synchronously. The + * typical caller is `scope.add(handle.subscribe(...))`, where the + * subscribe call has already happened; running its returned + * unsubscribe immediately matches what the consumer would have done + * anyway with a `try { ... } finally { off(); }` pattern. + * - `scope.on(...)` is a no-op. The listener is never installed. + * - `scope.register(...)` throws. Registering a custom command and + * immediately unregistering it would still fire registry + * invalidation paths and warning hooks, so the lifecycle error is + * surfaced explicitly instead of swallowed. + * - `scope.child()` returns an already-destroyed child whose own + * methods follow the same rules. + */ + +import type { CustomCommandRegistration, CustomCommandRegistrationResult, SuperDocUIScope } from './types.js'; + +/** + * Internal collaborator the scope needs from its owner (the + * controller, or a parent scope). Kept narrow so the scope module + * does not transitively depend on the entire controller surface. + */ +export interface ScopeOwner { + /** + * Forward a custom-command registration to whoever owns the + * underlying registry (always the controller in practice). Child + * scopes share their parent's `register`, which ultimately points at + * the same `customCommandsRegistry.register` instance. + */ + register( + registration: CustomCommandRegistration, + ): CustomCommandRegistrationResult; + /** + * Tell the owner this scope is alive so the owner can cascade-destroy. + * Returns an `untrack` function the scope calls during its own + * teardown so the owner does not hold a stale reference after the + * scope is gone. + */ + trackScope(scope: SuperDocUIScope): () => void; +} + +/** + * Create a new {@link SuperDocUIScope}. Internal helper: the public + * entry point is `ui.createScope()`, which calls this with the + * controller as the owner. + */ +export function createScope(owner: ScopeOwner): SuperDocUIScope { + const teardowns: Array<() => void> = []; + const childScopes = new Set(); + let destroyed = false; + + const scope: SuperDocUIScope = { + get destroyed() { + return destroyed; + }, + + add(teardown) { + if (destroyed) { + runTeardown(teardown); + return; + } + teardowns.push(teardown); + }, + + register(registration) { + if (destroyed) { + throw new Error('[superdoc/ui] scope has been destroyed; cannot register a new command.'); + } + const result = owner.register(registration); + teardowns.push(() => result.unregister()); + return result; + }, + + on(target, type, listener, options) { + if (destroyed) return; + target.addEventListener(type, listener, options); + teardowns.push(() => target.removeEventListener(type, listener, options)); + }, + + child() { + if (destroyed) { + // Return an already-destroyed scope so callers don't have to + // null-check. Its methods follow the destroyed-scope contract. + const inert = createScope({ + register: owner.register, + trackScope: () => () => undefined, + }); + inert.destroy(); + return inert; + } + const childScope = createScope({ + register: owner.register, + trackScope: (s) => { + childScopes.add(s); + return () => { + childScopes.delete(s); + }; + }, + }); + return childScope; + }, + + destroy() { + if (destroyed) return; + destroyed = true; + // Children destroy before the parent's own teardowns: a child's + // teardowns may have read state set up by something this parent + // owns (event listeners, registrations), so unwinding leaf-first + // matches typical resource-ownership expectations. + const childSnapshot = [...childScopes]; + childScopes.clear(); + for (const child of childSnapshot) { + try { + child.destroy(); + } catch (err) { + console.error('[superdoc/ui] child scope destroy threw', err); + } + } + // Reverse order is the standard effect-cleanup convention: most + // recently added cleanup runs first. Mirrors `useEffect` cleanup + // order across multiple effects in React. + for (let i = teardowns.length - 1; i >= 0; i -= 1) { + runTeardown(teardowns[i]!); + } + teardowns.length = 0; + }, + }; + + // Register with the owner so cascade-destroy works. The untrack + // call goes onto the teardown stack at index 0, which means it runs + // last in the reverse-order loop in `destroy()` above. Running last + // matches typical cleanup ordering: consumer-supplied teardowns may + // still reference `scope` (e.g. through closures) while running, so + // we hold the owner's reference until all of those have completed. + const untrack = owner.trackScope(scope); + teardowns.unshift(untrack); + + return scope; +} + +function runTeardown(teardown: () => void): void { + try { + teardown(); + } catch (err) { + console.error('[superdoc/ui] scope teardown threw', err); + } +} diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index 6706e735b1..19f5bf01a8 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -462,14 +462,110 @@ export interface SuperDocUI { */ document: DocumentHandle; + /** + * Create a {@link SuperDocUIScope} for collecting subscriptions, + * custom-command registrations, and DOM listeners under one + * lifecycle. Calling `ui.destroy()` cascades into every live scope + * before tearing down the controller's own resources, so a typical + * non-React consumer needs only `scope.destroy()` (or just + * `ui.destroy()`) to clean up. + */ + createScope(): SuperDocUIScope; + /** * Tear down all internal subscriptions to the editor / SuperDoc - * instance / presentation editor. After destroy, no listeners will + * instance / presentation editor, plus every scope created via + * {@link SuperDocUI.createScope}. After destroy, no listeners will * fire and `select(...)` should not be called. */ destroy(): void; } +/** + * Lifecycle helper returned by {@link SuperDocUI.createScope}. + * + * Collects subscription unsubscribes, custom-command registrations, + * and DOM event listeners under a single tear-down call. Calling + * `ui.destroy()` automatically destroys every live scope first, so + * consumers can either call `scope.destroy()` themselves on unmount / + * HMR or rely on the cascade. + * + * Post-destroy semantics (idempotent: calling `destroy()` twice is + * a no-op): + * - `add(teardown)` invokes the teardown synchronously. + * - `on(target, type, listener)` is a no-op; the listener is never + * installed. + * - `register(registration)` throws. + * - `child()` returns an already-destroyed scope. + */ +export interface SuperDocUIScope { + /** + * Add a teardown function. Typically the unsubscribe returned by a + * domain handle's `subscribe()` / `observe()` call: + * + * ```ts + * scope.add(ui.commands.bold.observe((state) => render(state))); + * scope.add(ui.comments.subscribe(({ snapshot }) => renderList(snapshot))); + * ``` + * + * Calling `add` after `destroy` invokes the teardown immediately: + * the canonical caller has already executed the side-effecting + * subscribe call, so running the unsubscribe right away matches + * what a `try { ... } finally { off(); }` pattern would do. + */ + add(teardown: () => void): void; + + /** + * Register a custom toolbar command. Returns the full + * {@link CustomCommandRegistrationResult} so consumers retain access + * to `handle.observe(...)` and `invalidate()`. The scope retains + * the `unregister()` callback and runs it when the scope is + * destroyed; consumers may still call `result.unregister()` + * manually before that, which is idempotent on the registry side. + * + * Throws when called on a destroyed scope. A register-then-unregister + * cycle would still fire the registry's invalidation paths and any + * collision-warning hooks, so we surface the lifecycle error + * explicitly instead of swallowing it. + */ + register( + registration: CustomCommandRegistration, + ): CustomCommandRegistrationResult; + + /** + * Add a DOM event listener. Calls `target.addEventListener(type, + * listener, options)` and queues a `removeEventListener` with the + * same arguments for scope teardown. No-op when called on a + * destroyed scope. + */ + on( + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions | boolean, + ): void; + + /** + * Create a child scope. Destroying the parent destroys every child + * first; child scopes share the controller's command registry so + * `child.register(...)` registers against the same surface as + * `ui.commands.register(...)`. Returns an already-destroyed scope + * when called on a destroyed parent. + */ + child(): SuperDocUIScope; + + /** + * Tear down every collected teardown and child scope. Idempotent. + * Errors thrown by individual teardowns are caught and logged to + * `console.error`; one failure does not prevent the rest from + * running. + */ + destroy(): void; + + /** True after {@link destroy} has been called. */ + readonly destroyed: boolean; +} + /** * Document slice exposed on `state.document` and through * {@link DocumentHandle}. diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts index d2847ec3cb..e6679666db 100644 --- a/packages/superdoc/src/ui.d.ts +++ b/packages/superdoc/src/ui.d.ts @@ -34,6 +34,7 @@ export { type SuperDocLike, type SuperDocUI, type SuperDocUIOptions, + type SuperDocUIScope, type SuperDocUIState, type TextAddress, type TextSegment,