From 444d808707423d1036a2b567ccb2ced1855c14cf Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 08:39:11 -0300 Subject: [PATCH 1/3] fix(types): type SuperDoc config callback bridge + correct callback payload shapes (SD-673) Replaces the any-cast asEventListener bridge with a typed #onConfig(event, listener) helper. The previous bridge let consumer Config.onX callbacks register against runtime SuperDocEventMap events without type-checking the payload match, which masked several incorrect Config types that drifted from what the runtime actually emits. These are type contract corrections to match existing runtime behavior; no intended runtime behavior change except that #onConfig now ignores explicit undefined callbacks instead of registering them as event listeners. Callback contract fixes (each is a breaking change for consumers who were destructuring against the previous wrong shape): - Config.onLocked: { isLocked, lockedBy: User } -> SuperDocLockedPayload (lockedBy is non-optional User | null; runtime always emits the key, value may be null on unlock or unattributed locks) - Config.onEditorBeforeCreate / Config.onEditorCreate / Config.onCollaborationReady: bare Editor -> SuperDocEditorPayload (the runtime wraps as { editor }; bare Editor never matched runtime) - Config.onCommentsUpdate: { type, data: object } -> SuperDocCommentsUpdatePayload ({ type, comment?, changes? }; runtime never emits a 'data' field) - Config.onAwarenessUpdate: { context, states } -> SuperDocAwarenessUpdatePayload ({ states, added, removed, superdoc }; field rename + 2 missing fields) - Config.onListDefinitionsChange: (params: {}) -> (params: ListDefinitionsPayload). The typed bridge alone did not catch this: {} is contravariantly assignable, so it accepted the wrong shape; the consumer fixture caught it. - Config.onReady: parameter named 'editor' but typed { superdoc }; renamed to 'params' and typed against SuperDocReadyPayload. - EditorUpdateEvent reconciled: editor/sourceEditor made optional (runtime can produce undefined when both are missing); headerId/sectionType made required string | null (runtime payload builder always sets them, defaulting to null). New named payload types exported through the public facade: SuperDocReadyPayload, SuperDocEditorPayload, SuperDocLockedPayload, SuperDocCommentsUpdatePayload, SuperDocAwarenessUpdatePayload. Added to all-public-types AssertNotAny and the root-classification snapshot as supported-root entries. Consumer fixture (config-callback-payloads.ts) locks the corrected shapes via AssertEqual on Parameters>[0]. Verified: pnpm check:types -> PASS; pnpm check:public:superdoc --skip-build -> PASS (9 ran, 1 skipped, 127.7s); SuperDoc unit tests -> PASS (1054/1054). --- packages/superdoc/src/core/SuperDoc.ts | 121 +++++++----------- packages/superdoc/src/core/types/index.ts | 114 ++++++++++++++--- packages/superdoc/src/public/index.ts | 5 + .../superdoc-root-classification.json | 61 ++++++++- .../snapshots/superdoc-root-classification.md | 13 +- .../snapshots/superdoc-root-exports.json | 23 +++- .../snapshots/superdoc-root-exports.md | 24 +++- .../src/all-public-types.ts | 10 ++ .../src/config-callback-payloads.ts | 102 +++++++++++++++ .../consumer-typecheck/src/superdoc-events.ts | 6 +- 10 files changed, 365 insertions(+), 114 deletions(-) create mode 100644 tests/consumer-typecheck/src/config-callback-payloads.ts diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index 9f97eae57d..77886949f3 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -68,14 +68,20 @@ import type { Config, DocumentMode, Editor, + EditorUpdateEvent, ExportParams, InternalConfig, Modules, NavigableAddress, RuntimeDocument, SearchMatch, + SuperDocAwarenessUpdatePayload, + SuperDocCommentsUpdatePayload, + SuperDocEditorPayload, SuperDocExceptionPayload, SuperDocExceptionStorePayload, + SuperDocLockedPayload, + SuperDocReadyPayload, SuperDocState, SurfaceHandle, SurfaceRequest, @@ -88,13 +94,8 @@ import type * as Y from 'yjs'; // as a type here without a separate `import type` declaration. import type { WhiteboardData } from './whiteboard/Whiteboard.js'; -// Event payload shapes (formerly JSDoc typedefs above the class). -interface SuperDocReadyPayload { - superdoc: SuperDoc; -} -interface SuperDocEditorPayload { - editor: Editor; -} +// Internal-only event payload shapes (consumer-facing payloads are +// exported from `core/types/index.ts` and imported above). interface SuperDocWhiteboardPayload { whiteboard: Whiteboard; } @@ -116,28 +117,6 @@ interface SuperDocContentErrorPayload { error: unknown; editor: Editor; } -interface SuperDocLockedPayload { - isLocked: boolean; - lockedBy?: User | null; -} -interface SuperDocEditorUpdatePayload { - editor?: Editor; - sourceEditor?: Editor; - surface: string; - headerId: string | null; - sectionType: string | null; -} -interface SuperDocAwarenessUpdatePayload { - states: AwarenessState[]; - added: number[]; - removed: number[]; - superdoc: SuperDoc; -} -interface SuperDocCommentsUpdatePayload { - type: string; - comment?: Comment; - changes?: Array<{ key: string; commentId: string; fileId?: string | null }>; -} /** * SuperDoc lifecycle event registry. Keys are event names emitted via @@ -155,7 +134,7 @@ interface SuperDocEventMap { zoomChange: [SuperDocZoomPayload]; 'formatting-marks-change': [SuperDocFormattingMarksPayload]; 'document-mode-change': [SuperDocDocumentModeChangePayload]; - 'editor-update': [SuperDocEditorUpdatePayload]; + 'editor-update': [EditorUpdateEvent]; 'content-error': [SuperDocContentErrorPayload]; 'fonts-resolved': [FontsResolvedPayload]; 'pagination-update': [SuperDocPaginationPayload]; @@ -188,30 +167,6 @@ interface SuperDocEventMap { // through SuperDoc instead) is a follow-up; typing it here matches the // current consumer-visible contract. -/** - * Adapts an optional `Config` callback to EventEmitter's - * `(...args: any[]) => void` listener signature. - * - * Every callback wrapped by this helper defaults to `() => null` in the - * class-field initializer, so EventEmitter receives a function in normal - * use. This helper is a runtime identity cast: behavior is unchanged if - * that invariant ever breaks (e.g. a consumer explicitly passes - * `undefined`), and EventEmitter sees the same value it would have - * without the wrapper. Sites with a `null` default (`onFontsResolved`, - * `onTrackedChangeBubbleAccept`, `onTrackedChangeBubbleReject`) use a - * separate `if`-guard pattern instead of this helper. - * - * The `any[]` here is correct: EventEmitter dispatches whatever payload - * each emit site supplies, and the consumer-supplied callback only - * inspects the args its own signature names. Narrower typing would force - * every callsite below to cast. - */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -function asEventListener(listener: ((...args: any[]) => void) | undefined): (...args: any[]) => void { - return listener as (...args: any[]) => void; -} -/* eslint-enable @typescript-eslint/no-explicit-any */ - /** * SuperDoc class * Expects a config object @@ -857,26 +812,41 @@ export class SuperDoc extends EventEmitter { this.#syncViewingVisibility(); } + /** + * Register an optional `Config` callback as a listener for the matching + * SuperDoc event. The event key constrains `K`, so TypeScript verifies + * that the consumer-typed `Config.onX` matches the runtime + * `SuperDocEventMap[event]` payload at compile time. No-ops on + * `undefined`, so optional callbacks do not register dead listeners. + * + * This is the gate that prevents callback/event contract drift; the + * earlier `any → any` bridge let mismatches like `lockedBy: User` vs + * runtime `User | null` ship undetected. + */ + #onConfig( + event: K, + listener: EventEmitter.EventListener | undefined, + ): void { + if (listener) this.on(event, listener); + } + #initListeners() { - this.on('editorBeforeCreate', asEventListener(this.config.onEditorBeforeCreate)); - this.on('editorCreate', asEventListener(this.config.onEditorCreate)); - this.on('editorDestroy', asEventListener(this.config.onEditorDestroy)); - this.on('ready', asEventListener(this.config.onReady)); - this.on('comments-update', asEventListener(this.config.onCommentsUpdate)); - this.on('awareness-update', asEventListener(this.config.onAwarenessUpdate)); - this.on('locked', asEventListener(this.config.onLocked)); - this.on('pdf:document-ready', asEventListener(this.config.onPdfDocumentReady)); - this.on('sidebar-toggle', asEventListener(this.config.onSidebarToggle)); - this.on('collaboration-ready', asEventListener(this.config.onCollaborationReady)); - this.on('editor-update', asEventListener(this.config.onEditorUpdate)); + this.#onConfig('editorBeforeCreate', this.config.onEditorBeforeCreate); + this.#onConfig('editorCreate', this.config.onEditorCreate); + this.#onConfig('editorDestroy', this.config.onEditorDestroy); + this.#onConfig('ready', this.config.onReady); + this.#onConfig('comments-update', this.config.onCommentsUpdate); + this.#onConfig('awareness-update', this.config.onAwarenessUpdate); + this.#onConfig('locked', this.config.onLocked); + this.#onConfig('pdf:document-ready', this.config.onPdfDocumentReady); + this.#onConfig('sidebar-toggle', this.config.onSidebarToggle); + this.#onConfig('collaboration-ready', this.config.onCollaborationReady); + this.#onConfig('editor-update', this.config.onEditorUpdate); this.on('content-error', this.onContentError); - this.on('exception', asEventListener(this.config.onException)); - this.on('list-definitions-change', asEventListener(this.config.onListDefinitionsChange)); - this.on('pagination-update', asEventListener(this.config.onPaginationUpdate)); - - if (this.config.onFontsResolved) { - this.on('fonts-resolved', this.config.onFontsResolved); - } + this.#onConfig('exception', this.config.onException); + this.#onConfig('list-definitions-change', this.config.onListDefinitionsChange); + this.#onConfig('pagination-update', this.config.onPaginationUpdate); + this.#onConfig('fonts-resolved', this.config.onFontsResolved); } /** @@ -1624,7 +1594,12 @@ export class SuperDoc extends EventEmitter { this.toolbar = new SuperToolbar(config); - this.toolbar.on('exception', asEventListener(this.config.onException)); + // Toolbar bridge: forwards SuperToolbar's exception events into the + // user's Config.onException callback. SuperToolbar's event types are + // not aligned with SuperDocEventMap, so this is intentionally a + // local cast rather than going through the typed `#onConfig` helper. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.toolbar.on('exception', this.config.onException as any); // `this.toolbar` infers as `SuperToolbar | null` from the field's // first assignment in `#addToolbar` (the `null` placeholder a few // lines up). The closure registers after the SuperToolbar instance diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index fe2e2c9cee..2580c44bb8 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -26,8 +26,10 @@ import type { TrackedChangeAddress as SuperEditorTrackedChangeAddress, NavigableAddress as SuperEditorNavigableAddress, CollaborationProvider as SuperEditorCollaborationProvider, + Comment, FontConfig, FontsResolvedPayload, + ListDefinitionsPayload, ProofingProvider, User, } from '@superdoc/super-editor'; @@ -1280,16 +1282,87 @@ export interface ExportParams { export type EditorSurface = 'body' | 'header' | 'footer'; export interface EditorUpdateEvent { - /** The primary editor associated with the update. For header/footer edits, this is the main body editor. */ - editor: Editor; + /** + * The primary editor associated with the update. For header/footer + * edits, this is the main body editor. Optional because the runtime + * payload builder falls back to `sourceEditor` and emits `undefined` + * when neither is present (defensive in test/stub paths); consumers + * should narrow before use. + */ + editor?: Editor; /** The editor instance that emitted the update. For body edits, this matches `editor`. */ - sourceEditor: Editor; + sourceEditor?: Editor; /** The surface where the edit originated. */ surface: EditorSurface; - /** Relationship ID for header/footer edits. */ - headerId?: string | null; - /** Header/footer variant (`default`, `first`, `even`, `odd`) when available. */ - sectionType?: string | null; + /** + * Relationship ID for header/footer edits. Always present (the + * runtime payload builder defaults to `null`); may be `null` for + * body edits. + */ + headerId: string | null; + /** + * Header/footer variant (`default`, `first`, `even`, `odd`) when + * available. Always present (defaults to `null`); may be `null`. + */ + sectionType: string | null; +} + +/** + * Payload emitted with the `ready` event and passed to `Config.onReady`. + * Carries the live SuperDoc instance. + */ +export interface SuperDocReadyPayload { + superdoc: SuperDoc; +} + +/** + * Payload emitted with the `editorCreate` / `editorBeforeCreate` / + * `collaboration-ready` events and passed to the matching `Config.onX` + * callbacks. The runtime always wraps the editor in this shape; bare + * `Editor` references in earlier callback typings were incorrect. + */ +export interface SuperDocEditorPayload { + editor: Editor; +} + +/** + * Payload emitted with the `locked` event and passed to + * `Config.onLocked`. `lockedBy` is non-optional because the runtime + * always includes the key (`lockSuperdoc` defaults `lockedBy` to + * `null`); the value may be `User | null` because unlocking and + * unattributed locks both pass `null`. + */ +export interface SuperDocLockedPayload { + isLocked: boolean; + lockedBy: User | null; +} + +/** + * Payload emitted with the `awareness-update` event and passed to + * `Config.onAwarenessUpdate`. Field set differs from older inline + * declarations: the runtime emits `superdoc` (not `context`) and + * includes `added` / `removed` client-id arrays alongside `states`. + */ +export interface SuperDocAwarenessUpdatePayload { + states: AwarenessState[]; + added: number[]; + removed: number[]; + superdoc: SuperDoc; +} + +/** + * Payload emitted with the `comments-update` event and passed to + * `Config.onCommentsUpdate`. Field set differs from older inline + * declarations: the runtime emits `comment?` and `changes?` (never a + * `data` field). + */ +export interface SuperDocCommentsUpdatePayload { + /** Update kind (e.g. `'created'`, `'updated'`, `'deleted'`); set by the comments store. */ + type: string; + /** The comment object the update refers to, when applicable. */ + comment?: Comment; + /** Per-field change set when the update is a mutation. */ + changes?: Array<{ key: string; commentId: string; fileId?: string | null }>; } export interface EditorTransactionEvent { @@ -1496,10 +1569,10 @@ export interface Config { * routing. */ experimental?: { unifiedHistory?: boolean }; - /** Callback before an editor is created. */ - onEditorBeforeCreate?: (editor: Editor) => void; - /** Callback after an editor is created. */ - onEditorCreate?: (editor: Editor) => void; + /** Callback before an editor is created. Receives a wrapper carrying the editor. */ + onEditorBeforeCreate?: (params: SuperDocEditorPayload) => void; + /** Callback after an editor is created. Receives a wrapper carrying the editor. */ + onEditorCreate?: (params: SuperDocEditorPayload) => void; /** Callback when a transaction is made. */ onTransaction?: (params: EditorTransactionEvent) => void; /** Callback after an editor is destroyed. */ @@ -1519,20 +1592,20 @@ export interface Config { documentId: string; file: File | Blob | null | undefined; }) => void; - /** Callback when the SuperDoc is ready. */ - onReady?: (editor: { superdoc: SuperDoc }) => void; + /** Callback when the SuperDoc is ready. Receives a wrapper carrying the live SuperDoc instance. */ + onReady?: (params: SuperDocReadyPayload) => void; /** Callback when comments are updated. */ - onCommentsUpdate?: (params: { type: string; data: object }) => void; + onCommentsUpdate?: (params: SuperDocCommentsUpdatePayload) => void; /** Callback when awareness is updated. */ - onAwarenessUpdate?: (params: { context: SuperDoc; states: AwarenessState[] }) => void; - /** Callback when the SuperDoc is locked. */ - onLocked?: (params: { isLocked: boolean; lockedBy: User }) => void; + onAwarenessUpdate?: (params: SuperDocAwarenessUpdatePayload) => void; + /** Callback when the SuperDoc is locked or unlocked. */ + onLocked?: (params: SuperDocLockedPayload) => void; /** Callback when the PDF document is ready. */ onPdfDocumentReady?: () => void; /** Callback when the sidebar is toggled. */ onSidebarToggle?: (isOpened: boolean) => void; - /** Callback when collaboration is ready. */ - onCollaborationReady?: (params: { editor: Editor }) => void; + /** Callback when collaboration is ready. Receives a wrapper carrying the editor. */ + onCollaborationReady?: (params: SuperDocEditorPayload) => void; /** Callback when document is updated. */ onEditorUpdate?: (params: EditorUpdateEvent) => void; /** @@ -1550,8 +1623,7 @@ export interface Config { */ onPaginationUpdate?: (params: { totalPages: number; superdoc: SuperDoc }) => void; /** Callback when the list definitions change. */ - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - onListDefinitionsChange?: (params: {}) => void; + onListDefinitionsChange?: (params: ListDefinitionsPayload) => void; /** The format of the document (docx, pdf, html). */ format?: string; /** The extensions to load for the editor. */ diff --git a/packages/superdoc/src/public/index.ts b/packages/superdoc/src/public/index.ts index 643965ca72..8d918d087d 100644 --- a/packages/superdoc/src/public/index.ts +++ b/packages/superdoc/src/public/index.ts @@ -94,11 +94,16 @@ export type { ResolvedFindReplaceTexts } from '../core/types/index.js'; export type { ResolvedPasswordPromptTexts } from '../core/types/index.js'; export type { SearchMatch } from '../core/types/index.js'; export type { StoryLocator } from '../core/types/index.js'; +export type { SuperDocAwarenessUpdatePayload } from '../core/types/index.js'; +export type { SuperDocCommentsUpdatePayload } from '../core/types/index.js'; +export type { SuperDocEditorPayload } from '../core/types/index.js'; export type { SuperDocExceptionEditorPayload } from '../core/types/index.js'; export type { SuperDocExceptionPayload } from '../core/types/index.js'; export type { SuperDocExceptionRestorePayload } from '../core/types/index.js'; export type { SuperDocExceptionStorePayload } from '../core/types/index.js'; export type { SuperDocLayoutEngineOptions } from '../core/types/index.js'; +export type { SuperDocLockedPayload } from '../core/types/index.js'; +export type { SuperDocReadyPayload } from '../core/types/index.js'; export type { SuperDocState } from '../core/types/index.js'; export type { SuperDocTelemetryConfig } from '../core/types/index.js'; export type { SurfaceComponentProps } from '../core/types/index.js'; diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-classification.json b/tests/consumer-typecheck/snapshots/superdoc-root-classification.json index cc3dea3865..d3e2fa5a76 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-classification.json +++ b/tests/consumer-typecheck/snapshots/superdoc-root-classification.json @@ -1,14 +1,14 @@ { "generatedAt": "2026-05-19T11:33:50.546Z", "summary": { - "total": 208, + "total": 213, "byBucket": { "legacy-root": 60, "internal-candidate": 8, - "supported-root": 140 + "supported-root": 145 }, "byConfidence": { - "high": 105, + "high": 110, "medium": 101, "low": 2 } @@ -1675,6 +1675,39 @@ "inEsm": true, "inCjs": true }, + { + "name": "SuperDocAwarenessUpdatePayload", + "bucket": "supported-root", + "rationale": "Payload emitted with the awareness-update event and passed to Config.onAwarenessUpdate; promoted to a named public type so callback signatures stop using inline shapes.", + "confidence": "high", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SuperDocCommentsUpdatePayload", + "bucket": "supported-root", + "rationale": "Payload emitted with the comments-update event and passed to Config.onCommentsUpdate; promoted to a named public type so callback signatures stop using inline shapes.", + "confidence": "high", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SuperDocEditorPayload", + "bucket": "supported-root", + "rationale": "Wrapper payload emitted with editorBeforeCreate / editorCreate / collaboration-ready events; promoted to a named public type so callback signatures match the runtime wrapper instead of a bare Editor.", + "confidence": "high", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, { "name": "SuperDocExceptionEditorPayload", "bucket": "supported-root", @@ -1730,6 +1763,28 @@ "inEsm": false, "inCjs": false }, + { + "name": "SuperDocLockedPayload", + "bucket": "supported-root", + "rationale": "Payload emitted with the locked event and passed to Config.onLocked; promoted to a named public type so the lockedBy: User | null contract is consumer-typable.", + "confidence": "high", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, + { + "name": "SuperDocReadyPayload", + "bucket": "supported-root", + "rationale": "Payload emitted with the ready event and passed to Config.onReady; promoted to a named public type for consistency with the other event payloads.", + "confidence": "high", + "source": "core", + "inDts": true, + "inDcts": true, + "inEsm": false, + "inCjs": false + }, { "name": "SuperDocState", "bucket": "supported-root", diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-classification.md b/tests/consumer-typecheck/snapshots/superdoc-root-classification.md index 68b861fa97..4179c95eec 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-classification.md +++ b/tests/consumer-typecheck/snapshots/superdoc-root-classification.md @@ -7,16 +7,16 @@ Input: tests/consumer-typecheck/snapshots/superdoc-root-exports.json (205 names, | Bucket | Count | |---|---| -| supported-root | 140 | +| supported-root | 145 | | legacy-root | 60 | | move-to-subpath | 0 | | internal-candidate | 8 | | NEEDS-REVIEW | 0 | -| **total** | **208** | +| **total** | **213** | -Confidence: high=105, medium=101, needs-review=0. +Confidence: high=110, medium=101, needs-review=0. -## supported-root (140) +## supported-root (145) | Name | Confidence | Source | Rationale | |---|---|---|---| @@ -116,7 +116,12 @@ Confidence: high=105, medium=101, needs-review=0. | `SelectionInfo` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | | `StoryLocator` | high | doc-api | Document API navigation/address/selection type. Promoted into the root facade by SD-3185. | | `SuperDoc` | medium | core | Customer-facing core API type or runtime export. Type-reachable through documented config / callback / event / method surfaces; runtime exports are documented utilities. | +| `SuperDocAwarenessUpdatePayload` | high | core | Payload emitted with the awareness-update event and passed to Config.onAwarenessUpdate; promoted to a named public type so callback signatures stop using inline shapes. | +| `SuperDocCommentsUpdatePayload` | high | core | Payload emitted with the comments-update event and passed to Config.onCommentsUpdate; promoted to a named public type so callback signatures stop using inline shapes. | +| `SuperDocEditorPayload` | high | core | Wrapper payload emitted with editorBeforeCreate / editorCreate / collaboration-ready events; promoted to a named public type so callback signatures match the runtime wrapper instead of a bare Editor. | | `SuperDocLayoutEngineOptions` | high | locked | Types Config.layoutEngineOptions at core/types/index.ts:1350,1505. Documented Config field. | +| `SuperDocLockedPayload` | high | core | Payload emitted with the locked event and passed to Config.onLocked; promoted to a named public type so the lockedBy: User \| null contract is consumer-typable. | +| `SuperDocReadyPayload` | high | core | Payload emitted with the ready event and passed to Config.onReady; promoted to a named public type for consistency with the other event payloads. | | `SuperDocState` | high | core | Public return shape of the SuperDoc#state getter; introduced to replace an inline anonymous return that leaked the internal RuntimeDocument type. Exposes `documents` as Document[] (the public view). | | `SuperDocTelemetryConfig` | high | locked | Backs Config.telemetry; documented at apps/docs/resources/telemetry.mdx (enabled/endpoint/metadata/licenseKey). | | `SurfaceComponentProps` | medium | surface | Headless Surface API type. Public extension surface for custom UI integrations. | diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json index a02888b671..5ee156da06 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-05-26T00:59:49.957Z", + "generatedAt": "2026-05-26T10:45:50.855Z", "ticket": "SD-3212 PR A0", "package": "superdoc", "rootExport": { @@ -165,11 +165,16 @@ "StoryLocator", "SuperConverter", "SuperDoc", + "SuperDocAwarenessUpdatePayload", + "SuperDocCommentsUpdatePayload", + "SuperDocEditorPayload", "SuperDocExceptionEditorPayload", "SuperDocExceptionPayload", "SuperDocExceptionRestorePayload", "SuperDocExceptionStorePayload", "SuperDocLayoutEngineOptions", + "SuperDocLockedPayload", + "SuperDocReadyPayload", "SuperDocState", "SuperDocTelemetryConfig", "SuperEditor", @@ -379,11 +384,16 @@ "StoryLocator", "SuperConverter", "SuperDoc", + "SuperDocAwarenessUpdatePayload", + "SuperDocCommentsUpdatePayload", + "SuperDocEditorPayload", "SuperDocExceptionEditorPayload", "SuperDocExceptionPayload", "SuperDocExceptionRestorePayload", "SuperDocExceptionStorePayload", "SuperDocLayoutEngineOptions", + "SuperDocLockedPayload", + "SuperDocReadyPayload", "SuperDocState", "SuperDocTelemetryConfig", "SuperEditor", @@ -535,11 +545,11 @@ } }, "counts": { - "types.import": 208, - "types.require": 208, + "types.import": 213, + "types.require": 213, "import": 41, "require": 41, - "union": 208 + "union": 213 }, "divergences": { "typesImportVsRequire": { @@ -687,11 +697,16 @@ "SelectionHandle", "SelectionInfo", "StoryLocator", + "SuperDocAwarenessUpdatePayload", + "SuperDocCommentsUpdatePayload", + "SuperDocEditorPayload", "SuperDocExceptionEditorPayload", "SuperDocExceptionPayload", "SuperDocExceptionRestorePayload", "SuperDocExceptionStorePayload", "SuperDocLayoutEngineOptions", + "SuperDocLockedPayload", + "SuperDocReadyPayload", "SuperDocState", "SuperDocTelemetryConfig", "SurfaceComponentProps", diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md index 154f3e03c7..bd5e282847 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md @@ -1,17 +1,17 @@ # superdoc root export inventory (SD-3212 PR A0) -Generated: 2026-05-26T00:59:49.957Z +Generated: 2026-05-26T10:45:50.855Z Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` ## Counts | Source | Path | Count | |---|---|---| -| types.import | `./dist/superdoc/src/public/index.d.ts` | 208 | -| types.require | `./dist/superdoc/src/public/index.d.cts` | 208 | +| types.import | `./dist/superdoc/src/public/index.d.ts` | 213 | +| types.require | `./dist/superdoc/src/public/index.d.cts` | 213 | | import | `./dist/superdoc.es.js` | 41 | | require | `./dist/superdoc.cjs` | 41 | -| **union** | | **208** | +| **union** | | **213** | ## Divergences @@ -19,7 +19,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - types.require only (not in types.import): 0 - ESM only (not in CJS): 0 - CJS only (not in ESM): 0 -- typed but no runtime export (phantom risk): 167 +- typed but no runtime export (phantom risk): 172 - runtime export but not typed (silent shadow on root): 0 ### Type-only names (no runtime) @@ -159,11 +159,16 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - `SelectionHandle` - `SelectionInfo` - `StoryLocator` +- `SuperDocAwarenessUpdatePayload` +- `SuperDocCommentsUpdatePayload` +- `SuperDocEditorPayload` - `SuperDocExceptionEditorPayload` - `SuperDocExceptionPayload` - `SuperDocExceptionRestorePayload` - `SuperDocExceptionStorePayload` - `SuperDocLayoutEngineOptions` +- `SuperDocLockedPayload` +- `SuperDocReadyPayload` - `SuperDocState` - `SuperDocTelemetryConfig` - `SurfaceComponentProps` @@ -223,7 +228,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `CommentsPayload` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `CommentsPluginKey` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | | `CommentsType` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `Config` | ✓ | ✓ | | | 6 | ✓ | 2 | 1 | 2 | ✓ | +| `Config` | ✓ | ✓ | | | 7 | ✓ | 2 | 1 | 2 | ✓ | | `ContextMenu` | ✓ | ✓ | ✓ | ✓ | 1 | | 7 | 0 | 31 | | | `ContextMenuConfig` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `ContextMenuContext` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | @@ -286,7 +291,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `LinkPopoverContext` | ✓ | ✓ | | | 1 | ✓ | 2 | 0 | 0 | | | `LinkPopoverResolution` | ✓ | ✓ | | | 1 | ✓ | 1 | 0 | 0 | | | `LinkPopoverResolver` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `ListDefinitionsPayload` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | +| `ListDefinitionsPayload` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `Measure` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 1 | | | `Modules` | ✓ | ✓ | | | 1 | ✓ | 4 | 0 | 0 | | | `NavigableAddress` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | @@ -347,11 +352,16 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `StoryLocator` | ✓ | ✓ | | | 1 | ✓ | 116 | 0 | 3 | | | `SuperConverter` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 3 | ✓ | | `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 21 | | 1014 | 180 | 244 | ✓ | +| `SuperDocAwarenessUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `SuperDocCommentsUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `SuperDocEditorPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocExceptionEditorPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocExceptionPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocExceptionRestorePayload` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `SuperDocExceptionStorePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocLayoutEngineOptions` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | +| `SuperDocLockedPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `SuperDocReadyPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocState` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocTelemetryConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `SuperEditor` | ✓ | ✓ | ✓ | ✓ | 1 | | 16 | 0 | 5 | | diff --git a/tests/consumer-typecheck/src/all-public-types.ts b/tests/consumer-typecheck/src/all-public-types.ts index b96354d930..38e0938f2e 100644 --- a/tests/consumer-typecheck/src/all-public-types.ts +++ b/tests/consumer-typecheck/src/all-public-types.ts @@ -159,11 +159,16 @@ import type { SelectionHandle, SelectionInfo, StoryLocator, + SuperDocAwarenessUpdatePayload, + SuperDocCommentsUpdatePayload, + SuperDocEditorPayload, SuperDocExceptionEditorPayload, SuperDocExceptionPayload, SuperDocExceptionRestorePayload, SuperDocExceptionStorePayload, SuperDocLayoutEngineOptions, + SuperDocLockedPayload, + SuperDocReadyPayload, SuperDocState, SuperDocTelemetryConfig, SurfaceComponentProps, @@ -335,11 +340,16 @@ const _real_SelectionCurrentInput: AssertNotAny = true; const _real_SelectionHandle: AssertNotAny = true; const _real_SelectionInfo: AssertNotAny = true; const _real_StoryLocator: AssertNotAny = true; +const _real_SuperDocAwarenessUpdatePayload: AssertNotAny = true; +const _real_SuperDocCommentsUpdatePayload: AssertNotAny = true; +const _real_SuperDocEditorPayload: AssertNotAny = true; const _real_SuperDocExceptionEditorPayload: AssertNotAny = true; const _real_SuperDocExceptionPayload: AssertNotAny = true; const _real_SuperDocExceptionRestorePayload: AssertNotAny = true; const _real_SuperDocExceptionStorePayload: AssertNotAny = true; const _real_SuperDocLayoutEngineOptions: AssertNotAny = true; +const _real_SuperDocLockedPayload: AssertNotAny = true; +const _real_SuperDocReadyPayload: AssertNotAny = true; const _real_SuperDocState: AssertNotAny = true; const _real_SuperDocTelemetryConfig: AssertNotAny = true; const _real_SurfaceComponentProps: AssertNotAny = true; diff --git a/tests/consumer-typecheck/src/config-callback-payloads.ts b/tests/consumer-typecheck/src/config-callback-payloads.ts new file mode 100644 index 0000000000..9f681c770c --- /dev/null +++ b/tests/consumer-typecheck/src/config-callback-payloads.ts @@ -0,0 +1,102 @@ +/** + * Consumer typecheck: SuperDoc `Config` callback payload shapes. + * + * Locks the corrected callback contracts against the emitted `.d.ts` + * with strict identity equality. Each assertion proves the named + * public payload type is what `Config.onX` receives, not an inline + * literal or a stale shape that drifted from the runtime emit. + * + * Why these assertions exist: the `Config.onX` callbacks register + * through an `EventEmitter` bridge. Before the typed bridge work, + * that bridge cast through `any`, which silently allowed several + * mismatches between `Config.onX` and `SuperDocEventMap[event]`: + * + * - `onLocked` declared `lockedBy: User`; runtime emits + * `lockedBy: User | null` (lockSuperdoc defaults lockedBy to null). + * - `onEditorCreate` / `onEditorBeforeCreate` declared + * `(editor: Editor)`; runtime emits `(payload: { editor: Editor })`. + * - `onCommentsUpdate` declared `{ type, data: object }`; runtime + * emits `{ type, comment?, changes? }` (never a `data` field). + * - `onAwarenessUpdate` declared `{ context, states }`; runtime + * emits `{ states, added, removed, superdoc }`. + * - `onListDefinitionsChange` declared `(params: {})`; runtime + * emits a typed `ListDefinitionsPayload`. + * - `onReady` declared its parameter as `editor` while the type + * was `{ superdoc: SuperDoc }`; parameter renamed to `params` + * and typed against the named payload. + * + * The typed `#onConfig(event, listener)` bridge in SuperDoc + * catches event/callback drift at registration sites. This fixture + * locks exact emitted consumer shapes, including the cases the bridge + * does not catch on its own: broad types like `{}` are contravariantly + * assignable to any narrower payload, so the bridge accepted + * `Config.onListDefinitionsChange?: (params: {}) => void` even + * though the runtime emits a typed `ListDefinitionsPayload`. The + * AssertEqual against the exported payload type is what surfaces + * that class of mismatch. + */ +import type { + Config, + EditorUpdateEvent, + ListDefinitionsPayload, + SuperDocAwarenessUpdatePayload, + SuperDocCommentsUpdatePayload, + SuperDocEditorPayload, + SuperDocLockedPayload, + SuperDocReadyPayload, +} from 'superdoc'; + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type AssertEqual = Equal extends true ? true : never; + +// Extract the first parameter type of an optional callback. F is +// constrained so `NonNullable` actually resolves to a function and +// `Parameters<...>` can extract from it. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ParamOf any) | undefined> = Parameters>[0]; + +// ─── onReady ──────────────────────────────────────────────────────── +const _onReadyOk: AssertEqual, SuperDocReadyPayload> = true; + +// ─── onEditorBeforeCreate / onEditorCreate ────────────────────────── +// Both receive the wrapper `{ editor: Editor }`, not a bare Editor. +const _onEditorBeforeCreateOk: AssertEqual, SuperDocEditorPayload> = true; +const _onEditorCreateOk: AssertEqual, SuperDocEditorPayload> = true; +const _onCollaborationReadyOk: AssertEqual, SuperDocEditorPayload> = true; + +// ─── onLocked ─────────────────────────────────────────────────────── +// lockedBy is `User | null` (non-optional) - runtime always emits the +// key, value may be null on unlock or unattributed locks. +const _onLockedOk: AssertEqual, SuperDocLockedPayload> = true; + +// ─── onCommentsUpdate ─────────────────────────────────────────────── +const _onCommentsUpdateOk: AssertEqual, SuperDocCommentsUpdatePayload> = true; + +// ─── onAwarenessUpdate ────────────────────────────────────────────── +// Field set is `{ states, added, removed, superdoc }` - NOT `context`. +const _onAwarenessUpdateOk: AssertEqual, SuperDocAwarenessUpdatePayload> = true; + +// ─── onListDefinitionsChange ──────────────────────────────────────── +const _onListDefinitionsChangeOk: AssertEqual< + ParamOf, + ListDefinitionsPayload +> = true; + +// ─── onEditorUpdate ───────────────────────────────────────────────── +// EditorUpdateEvent was reconciled in this PR: editor / sourceEditor +// became optional (runtime can produce undefined when both are +// missing), headerId / sectionType became required `string | null` +// (runtime payload builder always sets them, defaulting to null). +const _onEditorUpdateOk: AssertEqual, EditorUpdateEvent> = true; + +void [ + _onReadyOk, + _onEditorBeforeCreateOk, + _onEditorCreateOk, + _onCollaborationReadyOk, + _onLockedOk, + _onCommentsUpdateOk, + _onAwarenessUpdateOk, + _onListDefinitionsChangeOk, + _onEditorUpdateOk, +]; diff --git a/tests/consumer-typecheck/src/superdoc-events.ts b/tests/consumer-typecheck/src/superdoc-events.ts index 3acf0d9c4f..be611fa4f7 100644 --- a/tests/consumer-typecheck/src/superdoc-events.ts +++ b/tests/consumer-typecheck/src/superdoc-events.ts @@ -132,8 +132,10 @@ superdoc.on('list-definitions-change', (payload) => { // --- Comments events ------------------------------------------------------- superdoc.on('comments-update', (event) => { - // `type` is the closed `CommentEvent` union; switching on it lets - // consumers narrow per-case. + // `type` is `string` on the public payload; the runtime emits + // discrete update kinds (e.g. `'created'`, `'updated'`, `'deleted'`) + // but the type is intentionally open so consumers can match on + // current and future kinds without recompilation. const type: string = event.type; void type; if (event.comment) { From b15d3a55f91cbe2851a802740a5c2c4624f2878c Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 08:42:32 -0300 Subject: [PATCH 2/3] docs(superdoc): soften #onConfig docblock to acknowledge contravariance gap --- packages/superdoc/src/core/SuperDoc.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index 77886949f3..3ee6350a37 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -814,14 +814,18 @@ export class SuperDoc extends EventEmitter { /** * Register an optional `Config` callback as a listener for the matching - * SuperDoc event. The event key constrains `K`, so TypeScript verifies - * that the consumer-typed `Config.onX` matches the runtime - * `SuperDocEventMap[event]` payload at compile time. No-ops on + * SuperDoc event. The event key constrains `K`, so TypeScript checks + * that the consumer-typed `Config.onX` is assignable to + * `SuperDocEventMap[event]` at the registration site. No-ops on * `undefined`, so optional callbacks do not register dead listeners. * - * This is the gate that prevents callback/event contract drift; the + * This catches most event/callback drift at registration sites; the * earlier `any → any` bridge let mismatches like `lockedBy: User` vs - * runtime `User | null` ship undetected. + * runtime `User | null` ship undetected. It does not catch overly + * wide callback types: `(p: {}) => void` is contravariantly + * assignable to any narrower payload, so consumer fixtures still + * need to lock the exact emitted payload shape per callback (see + * `tests/consumer-typecheck/src/config-callback-payloads.ts`). */ #onConfig( event: K, From 6a12a3fde6ae3957d2da0cf5ab4264b76087edfd Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 09:44:26 -0300 Subject: [PATCH 3/3] fix(superdoc): guard undefined onException in toolbar bridge Pre-existing behavior: the toolbar exception bridge passed this.config.onException directly to eventemitter3's .on(), which throws TypeError('The listener must be a function') at registration time when the value is undefined. Same issue existed pre-PR through the old asEventListener identity cast; bot review on #3503 flagged it during this PR's bridge migration. Adds a truthy guard mirroring #onConfig's semantics: skip absent callbacks (consumer explicitly passes { onException: undefined }), but pass through truthy non-function values so eventemitter3 still throws loudly for real type violations. Verified: pnpm check:types -> PASS; pnpm --filter superdoc test --run -> PASS (1054/1054); pnpm check:public:superdoc --skip-build -> PASS (9 ran, 1 skipped). --- packages/superdoc/src/core/SuperDoc.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index 3ee6350a37..1fc61f2ee3 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -1602,8 +1602,14 @@ export class SuperDoc extends EventEmitter { // user's Config.onException callback. SuperToolbar's event types are // not aligned with SuperDocEventMap, so this is intentionally a // local cast rather than going through the typed `#onConfig` helper. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.toolbar.on('exception', this.config.onException as any); + // Truthy guard mirrors `#onConfig`: skip absent callbacks (consumer + // passes `{ onException: undefined }` explicitly), but pass through + // truthy non-function values so eventemitter3 throws loudly at + // registration time. + if (this.config.onException) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.toolbar.on('exception', this.config.onException as any); + } // `this.toolbar` infers as `SuperToolbar | null` from the field's // first assignment in `#addToolbar` (the `null` placeholder a few // lines up). The closure registers after the SuperToolbar instance