Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions packages/superdoc/src/core/SuperDoc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
Expand Down Expand Up @@ -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();
});
}
Expand Down
43 changes: 43 additions & 0 deletions packages/superdoc/src/core/SuperDoc.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
10 changes: 8 additions & 2 deletions packages/superdoc/src/core/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
57 changes: 57 additions & 0 deletions tests/consumer-typecheck/src/provider-collaboration-provider.ts
Original file line number Diff line number Diff line change
@@ -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<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type AssertEqual<A, B> = Equal<A, B> extends true ? true : never;

// `SuperDoc.provider` must be exactly `CollaborationProvider | undefined`.
const _sdProviderTypeIsExact: AssertEqual<typeof sd.provider, CollaborationProvider | undefined> = true;

// `Config['documents']` carries the per-document `Document` shape.
type DocumentEntry = NonNullable<Config['documents']>[number];

// `Document.provider` must be exactly `CollaborationProvider | undefined`.
declare const docEntry: DocumentEntry;
const _docProviderTypeIsExact: AssertEqual<typeof docEntry.provider, CollaborationProvider | undefined> = 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];
24 changes: 24 additions & 0 deletions tests/consumer-typecheck/typecheck-matrix.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading