From 13ff0a9ab15bc58badaeedbf456bdd2acf1d7984 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 3 May 2026 20:51:53 -0300 Subject: [PATCH 1/4] refactor(superdoc): widen provider fields to CollaborationProvider (SD-2828) --- packages/superdoc/src/core/SuperDoc.js | 10 +++- packages/superdoc/src/core/types/index.ts | 10 +++- .../src/provider-collaboration-provider.ts | 51 +++++++++++++++++++ tests/consumer-typecheck/typecheck-matrix.mjs | 24 +++++++++ 4 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 tests/consumer-typecheck/src/provider-collaboration-provider.ts diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 00aabe2338..0bac7adc63 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} */ 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..6d030b22da --- /dev/null +++ b/tests/consumer-typecheck/src/provider-collaboration-provider.ts @@ -0,0 +1,51 @@ +/** + * 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; + +// `SuperDoc.provider` is `CollaborationProvider | undefined`. A consumer +// using a non-Hocuspocus provider can read it directly without `as`. +const sdProvider: CollaborationProvider | undefined = sd.provider; + +// `Config['documents']` carries the per-document `Document` shape. +type DocumentEntry = NonNullable[number]; + +// `Document.provider` is `CollaborationProvider | undefined`. Same shape +// as the SuperDoc-level field; consumers reading `doc.provider` see +// the same widened type. +declare const docEntry: DocumentEntry; +const docProvider: CollaborationProvider | undefined = docEntry.provider; + +// Construct a minimal `CollaborationProvider`-shaped object — the public +// interface only requires the Yjs-shaped `on` / `off` methods. Consumers +// of non-Hocuspocus providers (Liveblocks, Tiptap, custom) must be able +// to assign such a value and have it satisfy the `Document.provider` +// shape. +const minimalProvider: CollaborationProvider = { + on: () => {}, + off: () => {}, +}; + +const docWithMinimalProvider: DocumentEntry = { + type: 'docx', + provider: minimalProvider, +}; + +// Reference all bindings so `tsc --noEmit` doesn't strip them. +void [sdProvider, docProvider, 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'); From f2b35bf39c9929991d9755d1af3994b7bc39a6f3 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 3 May 2026 21:01:27 -0300 Subject: [PATCH 2/4] fix(superdoc): tighten provider fixture (drop em-dash, exact-type asserts, accurate comments) --- .../src/provider-collaboration-provider.ts | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/tests/consumer-typecheck/src/provider-collaboration-provider.ts b/tests/consumer-typecheck/src/provider-collaboration-provider.ts index 6d030b22da..14c7e4fde2 100644 --- a/tests/consumer-typecheck/src/provider-collaboration-provider.ts +++ b/tests/consumer-typecheck/src/provider-collaboration-provider.ts @@ -4,7 +4,7 @@ * * The runtime stores whatever provider the consumer passed via * `Config.modules.collaboration.provider`. Consumers may pass any - * Yjs-compatible provider — Hocuspocus, LiveblocksYjsProvider, + * 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 @@ -19,24 +19,30 @@ import type { CollaborationProvider, Config, SuperDoc } from 'superdoc'; declare const sd: SuperDoc; -// `SuperDoc.provider` is `CollaborationProvider | undefined`. A consumer -// using a non-Hocuspocus provider can read it directly without `as`. -const sdProvider: CollaborationProvider | undefined = sd.provider; +// 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` is `CollaborationProvider | undefined`. Same shape -// as the SuperDoc-level field; consumers reading `doc.provider` see -// the same widened type. +// `Document.provider` must be exactly `CollaborationProvider | undefined`. declare const docEntry: DocumentEntry; -const docProvider: CollaborationProvider | undefined = docEntry.provider; +const _docProviderTypeIsExact: AssertEqual = true; -// Construct a minimal `CollaborationProvider`-shaped object — the public -// interface only requires the Yjs-shaped `on` / `off` methods. Consumers -// of non-Hocuspocus providers (Liveblocks, Tiptap, custom) must be able -// to assign such a value and have it satisfy the `Document.provider` -// shape. +// 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: () => {}, @@ -48,4 +54,4 @@ const docWithMinimalProvider: DocumentEntry = { }; // Reference all bindings so `tsc --noEmit` doesn't strip them. -void [sdProvider, docProvider, minimalProvider, docWithMinimalProvider]; +void [_sdProviderTypeIsExact, _docProviderTypeIsExact, minimalProvider, docWithMinimalProvider]; From 03ccdee7f41d9b2648a21b8c527b24e75e9a2eb8 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 3 May 2026 21:05:33 -0300 Subject: [PATCH 3/4] fix(superdoc): guard optional disconnect/destroy on widened provider type Reviewer flagged that widening Document.provider to CollaborationProvider allows providers without disconnect/destroy methods (Liveblocks-style adapters), so destroy() would throw under such providers. Use double optional chain to guard both the provider and its method. --- packages/superdoc/src/core/SuperDoc.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 0bac7adc63..f321299fc9 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -1880,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(); }); } From c0ccf9b2a06fb6f92ffef6b9c127acf7de24fd86 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 3 May 2026 21:12:45 -0300 Subject: [PATCH 4/4] test(superdoc): destroy() tolerates providers without disconnect/destroy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CollaborationProvider.disconnect and .destroy are optional, so cleanup must guard the method, not just the provider. Adds a regression test covering a minimal { on, off } provider — the shape Liveblocks-style adapters use. --- packages/superdoc/src/core/SuperDoc.test.js | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) 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({