diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 00aabe2338..f321299fc9 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -129,7 +129,15 @@ export class SuperDoc extends EventEmitter { /** @type {import('yjs').Doc | undefined} */ ydoc; - /** @type {import('@hocuspocus/provider').HocuspocusProvider | undefined} */ + /** + * Provider for the SuperDoc-level collaboration room (separate from + * per-document providers). Widened to `CollaborationProvider` to match + * the runtime, which stores whatever provider the consumer passed via + * `Config.modules.collaboration.provider`. Consumers needing Hocuspocus- + * specific members must narrow before use. + * + * @type {import('./types/index.js').CollaborationProvider | undefined} + */ provider; /** @type {Whiteboard | null} */ @@ -1872,12 +1880,12 @@ export class SuperDoc extends EventEmitter { cfg.socket?.destroy(); this.ydoc?.destroy(); - this.provider?.disconnect(); - this.provider?.destroy(); + this.provider?.disconnect?.(); + this.provider?.destroy?.(); cfg.documents.forEach((doc) => { - doc.provider?.disconnect(); - doc.provider?.destroy(); + doc.provider?.disconnect?.(); + doc.provider?.destroy?.(); doc.ydoc?.destroy(); }); } diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 772c5ec103..e677783ebc 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -880,6 +880,49 @@ describe('SuperDoc core', () => { expect(instance.listenerCount('ready')).toBe(0); }); + it('destroy() does not throw when providers omit optional disconnect/destroy methods', async () => { + createAppHarness(); + + // SD-2828: `CollaborationProvider` has optional `disconnect` and `destroy`. + // Liveblocks-style adapters legally satisfy the type with just on/off, so + // cleanup must guard the method, not just the provider. + const minimalSuperdocProvider = { on: vi.fn(), off: vi.fn() }; + const minimalDocProvider = { on: vi.fn(), off: vi.fn() }; + + initSuperdocYdocMock.mockImplementationOnce(() => ({ + ydoc: { destroy: vi.fn() }, + provider: minimalSuperdocProvider, + })); + makeDocumentsCollaborativeMock.mockImplementationOnce((superdoc) => + superdoc.config.documents.map((doc, index) => { + Object.assign(doc, { + id: doc.id || `doc-${index}`, + provider: minimalDocProvider, + ydoc: { destroyed: false, destroy: vi.fn() }, + socket: superdoc.config.socket, + }); + return doc; + }), + ); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { + comments: {}, + toolbar: {}, + collaboration: { providerType: 'hocuspocus', url: 'wss://example.com' }, + }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + expect(() => instance.destroy()).not.toThrow(); + }); + it('mounts Vue on a wrapper element inside the user container', async () => { const { app } = createAppHarness(); const instance = new SuperDoc({ diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 2061984df6..11f7cfb3d2 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -91,8 +91,14 @@ export interface Document { isNewFile?: boolean; /** The Yjs document for collaboration. */ ydoc?: YDoc; - /** The provider for collaboration. */ - provider?: HocuspocusProvider; + /** + * The provider for collaboration. Widened from `HocuspocusProvider` to + * `CollaborationProvider` to match the runtime, which stores whatever + * provider the consumer passed via `Config.modules.collaboration.provider` + * (HocuspocusProvider, LiveblocksYjsProvider, TiptapCollabProvider, etc.). + * Consumers needing Hocuspocus-specific members must narrow before use. + */ + provider?: CollaborationProvider; } /** diff --git a/tests/consumer-typecheck/src/provider-collaboration-provider.ts b/tests/consumer-typecheck/src/provider-collaboration-provider.ts new file mode 100644 index 0000000000..14c7e4fde2 --- /dev/null +++ b/tests/consumer-typecheck/src/provider-collaboration-provider.ts @@ -0,0 +1,57 @@ +/** + * Consumer typecheck: `Document.provider` and `SuperDoc.provider` are typed + * as `CollaborationProvider`, not `HocuspocusProvider` (SD-2828). + * + * The runtime stores whatever provider the consumer passed via + * `Config.modules.collaboration.provider`. Consumers may pass any + * Yjs-compatible provider: Hocuspocus, LiveblocksYjsProvider, + * TiptapCollabProvider, or a hand-rolled adapter that conforms to the + * `CollaborationProvider` shape. The previous typedef narrowed both + * fields to `HocuspocusProvider`, which lied about the runtime for any + * non-Hocuspocus consumer. + * + * This fixture pins the contract: the field types accept any + * `CollaborationProvider`-shaped value. If a future change re-narrows + * either field to `HocuspocusProvider`, the assignments below stop + * compiling and CI fails. + */ +import type { CollaborationProvider, Config, SuperDoc } from 'superdoc'; + +declare const sd: SuperDoc; + +// Strict type-equality assertion. A narrower type (e.g. `HocuspocusProvider`) +// would still be assignable to `CollaborationProvider | undefined`, so a +// plain assignment here would silently pass under a re-narrowing +// regression. The `Equal` trick fails the test if the field's exact type +// drifts in either direction. +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type AssertEqual = Equal extends true ? true : never; + +// `SuperDoc.provider` must be exactly `CollaborationProvider | undefined`. +const _sdProviderTypeIsExact: AssertEqual = true; + +// `Config['documents']` carries the per-document `Document` shape. +type DocumentEntry = NonNullable[number]; + +// `Document.provider` must be exactly `CollaborationProvider | undefined`. +declare const docEntry: DocumentEntry; +const _docProviderTypeIsExact: AssertEqual = true; + +// Construct a `CollaborationProvider`-shaped object with the Yjs-style +// `on` / `off` methods consumers typically supply. Every field on the +// public `CollaborationProvider` interface is optional, so even an empty +// `{}` would satisfy the type; including `on`/`off` here mirrors what +// real non-Hocuspocus providers (Liveblocks, Tiptap, custom adapters) +// expose and what the runtime calls into. +const minimalProvider: CollaborationProvider = { + on: () => {}, + off: () => {}, +}; + +const docWithMinimalProvider: DocumentEntry = { + type: 'docx', + provider: minimalProvider, +}; + +// Reference all bindings so `tsc --noEmit` doesn't strip them. +void [_sdProviderTypeIsExact, _docProviderTypeIsExact, minimalProvider, docWithMinimalProvider]; diff --git a/tests/consumer-typecheck/typecheck-matrix.mjs b/tests/consumer-typecheck/typecheck-matrix.mjs index af23a9d9f1..b1b8bb8ef3 100644 --- a/tests/consumer-typecheck/typecheck-matrix.mjs +++ b/tests/consumer-typecheck/typecheck-matrix.mjs @@ -471,6 +471,30 @@ const scenarios = [ files: ['src/user-email-nullable.ts'], mustPass: true, }, + // SD-2828: `Document.provider` and `SuperDoc.provider` are typed as + // `CollaborationProvider`, not `HocuspocusProvider`. The runtime stores + // whatever provider the consumer passed (Hocuspocus, Liveblocks-Yjs, + // TiptapCollab, etc.); pinning the wider contract here so a future + // re-narrowing to `HocuspocusProvider` would surface as a typecheck + // failure on the public surface. + { + name: 'bundler / provider is CollaborationProvider (SD-2828)', + module: 'ESNext', + moduleResolution: 'bundler', + skipLibCheck: true, + strict: true, + files: ['src/provider-collaboration-provider.ts'], + mustPass: true, + }, + { + name: 'node16 / provider is CollaborationProvider (SD-2828)', + module: 'Node16', + moduleResolution: 'node16', + skipLibCheck: true, + strict: true, + files: ['src/provider-collaboration-provider.ts'], + mustPass: true, + }, ]; const tscPath = join(__dirname, 'node_modules', '.bin', 'tsc');