Skip to content

Commit a26f003

Browse files
authored
refactor(superdoc): close strict-null cluster in SuperDoc.js (SD-2867) (#3084)
* refactor(superdoc): close strict-null cluster in SuperDoc.js (SD-2867) * fix(superdoc): make documents invariant real, soften JSDoc claims (SD-2867) * docs(superdoc): note store-field lifecycle, link SD-2916 follow-up
1 parent 3f6b90d commit a26f003

2 files changed

Lines changed: 86 additions & 19 deletions

File tree

packages/superdoc/src/core/SuperDoc.js

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const DEFAULT_AWARENESS_PALETTE = Object.freeze([
6464
/** @typedef {import('./types/index.js').Editor} Editor */
6565
/** @typedef {import('./types/index.js').DocumentMode} DocumentMode */
6666
/** @typedef {import('./types/index.js').Config} Config */
67+
/** @typedef {import('./types/index.js').InternalConfig} InternalConfig */
6768
/** @typedef {import('./types/index.js').ExportParams} ExportParams */
6869
/** @typedef {import('./types/index.js').UpgradeToCollaborationOptions} UpgradeToCollaborationOptions */
6970
/** @typedef {import('./types/index.js').SurfaceRequest} SurfaceRequest */
@@ -121,6 +122,38 @@ export class SuperDoc extends EventEmitter {
121122
*/
122123
colors = [];
123124

125+
/**
126+
* Pinia stores and Vue runtime references. Populated by `#initVueApp`
127+
* inside the async `#init`, which runs *after* `await #initCollaboration`,
128+
* so these fields are briefly `undefined` between `new SuperDoc(config)`
129+
* returning and the `ready` event firing. The non-null JSDoc here matches
130+
* the existing pattern used for `users` / `version` / `whiteboard` and
131+
* assumes consumers wait for `ready` before dereferencing. SD-2916 tracks
132+
* the systematic soundness fix across all of these fields (declaring them
133+
* `T | undefined` and casting at internal post-init access sites).
134+
*
135+
* @type {ReturnType<typeof import('../stores/superdoc-store.js').useSuperdocStore>}
136+
*/
137+
superdocStore;
138+
139+
/** @type {ReturnType<typeof import('../stores/comments-store.js').useCommentsStore>} */
140+
commentsStore;
141+
142+
/** @type {ReturnType<typeof import('../composables/use-high-contrast-mode.js').useHighContrastMode>} */
143+
highContrastModeStore;
144+
145+
/** @type {import('vue').App} */
146+
app;
147+
148+
/** @type {import('pinia').Pinia} */
149+
pinia;
150+
151+
/** @type {number} Count of editors that have signaled `editorCreate`. */
152+
readyEditors = 0;
153+
154+
/** @type {number} Outstanding async saves waiting for collaboration ack. */
155+
pendingCollaborationSaves = 0;
156+
124157
/** @type {Config} */
125158
config = {
126159
superdocId: null,
@@ -240,6 +273,16 @@ export class SuperDoc extends EventEmitter {
240273
}
241274
normalizeTrackChangesConfig(this.config);
242275

276+
// Defensive defaults so the `InternalConfig` runtime invariants hold
277+
// for every reachable code path. The class-field initializer seeds
278+
// both `documents: []` and `layoutEngineOptions` is filled in by
279+
// `normalizeTrackChangesConfig` above, but a consumer that explicitly
280+
// passes `{ documents: undefined }` or omits `layoutEngineOptions`
281+
// when track-changes hasn't initialized it yet would otherwise leave
282+
// these undefined and break later non-null casts.
283+
this.config.documents = this.config.documents || [];
284+
this.config.layoutEngineOptions = this.config.layoutEngineOptions || {};
285+
243286
// Web layout behavior:
244287
// - Backward compatible default: web layout still uses PM rendering.
245288
// - Opt-in semantic path: allow layout engine only when flowMode === 'semantic'.
@@ -503,7 +546,7 @@ export class SuperDoc extends EventEmitter {
503546
this.superdocStore.setExceptionHandler((/** @type {unknown} */ payload) => this.emit('exception', payload));
504547
}
505548
this.superdocStore.init(this.config);
506-
const commentsModuleConfig = this.config.modules.comments;
549+
const commentsModuleConfig = /** @type {InternalConfig} */ (this.config).modules.comments;
507550
// `commentsModuleConfig` is `false | object | undefined`. A truthy
508551
// check already rules out both `false` and `undefined`, so an
509552
// explicit `!== false` afterwards is redundant.
@@ -629,7 +672,7 @@ export class SuperDoc extends EventEmitter {
629672
this.#assignUserColor();
630673
this._cleanupAwareness = setupAwarenessHandler(provider, this, this.config.user);
631674

632-
this.config.documents.forEach((doc) => {
675+
/** @type {InternalConfig} */ (this.config).documents.forEach((doc) => {
633676
doc.ydoc = ydoc;
634677
doc.provider = provider;
635678
doc.role = this.config.role;
@@ -652,9 +695,10 @@ export class SuperDoc extends EventEmitter {
652695
this._commentsCollabInitialized = false;
653696
this.ydoc = undefined;
654697
this.provider = undefined;
655-
delete this.config.modules.collaboration;
698+
const cfg = /** @type {InternalConfig} */ (this.config);
699+
delete cfg.modules.collaboration;
656700

657-
this.config.documents.forEach((doc) => {
701+
cfg.documents.forEach((doc) => {
658702
delete doc.ydoc;
659703
delete doc.provider;
660704
});
@@ -722,7 +766,7 @@ export class SuperDoc extends EventEmitter {
722766
overwriteRoomLockState(ydoc, { isLocked: this.isLocked, lockedBy: this.lockedBy });
723767

724768
// --- Attach collaboration config (awareness, flags, config.documents) ---
725-
this.config.modules.collaboration = { ydoc, provider };
769+
/** @type {InternalConfig} */ (this.config).modules.collaboration = { ydoc, provider };
726770
this.#attachExternalCollaboration(ydoc, provider);
727771

728772
// --- Update live store documents in place (no Vue unmount) ---
@@ -967,14 +1011,15 @@ export class SuperDoc extends EventEmitter {
9671011
throw new Error('SuperDoc: upgradeToCollaboration() requires both ydoc and provider');
9681012
}
9691013

970-
const docxDocs = this.config.documents.filter((d) => d.type === DOCX);
1014+
const cfg = /** @type {InternalConfig} */ (this.config);
1015+
const docxDocs = cfg.documents.filter((d) => d.type === DOCX);
9711016
if (docxDocs.length === 0) {
9721017
throw new Error('SuperDoc: no DOCX document found for upgrade');
9731018
}
9741019
if (docxDocs.length > 1) {
9751020
throw new Error('SuperDoc: upgradeToCollaboration() only supports a single DOCX document');
9761021
}
977-
if (this.config.documents.length !== docxDocs.length) {
1022+
if (cfg.documents.length !== docxDocs.length) {
9781023
throw new Error('SuperDoc: upgradeToCollaboration() only supports single-DOCX instances');
9791024
}
9801025
}
@@ -986,7 +1031,12 @@ export class SuperDoc extends EventEmitter {
9861031
* @throws {Error} If the editor is not yet created
9871032
*/
9881033
#resolveSourceEditor() {
989-
const docxDoc = this.config.documents.find((d) => d.type === DOCX);
1034+
// Upstream `#assertCanUpgrade` already verified at least one DOCX
1035+
// document exists; cast the find result to assert non-null without
1036+
// changing runtime behavior.
1037+
const docxDoc = /** @type {Document} */ (
1038+
/** @type {InternalConfig} */ (this.config).documents.find((d) => d.type === DOCX)
1039+
);
9901040
const storeDoc = this.superdocStore.documents.find((d) => d.id === docxDoc.id);
9911041
const editor = storeDoc?.getEditor?.();
9921042

@@ -1021,7 +1071,10 @@ export class SuperDoc extends EventEmitter {
10211071
*/
10221072
onContentError({ error, editor }) {
10231073
const { documentId } = editor.options;
1024-
const doc = this.superdocStore.documents.find((d) => d.id === documentId);
1074+
// The errored editor came from `superdocStore.documents`, so the find
1075+
// by its `documentId` is expected to hit. Cast the find result to a
1076+
// RuntimeDocument to assert non-null at the consumer callback.
1077+
const doc = /** @type {RuntimeDocument} */ (this.superdocStore.documents.find((d) => d.id === documentId));
10251078
// `onContentError` is typed as optional on the public Config typedef
10261079
// because consumers don't have to wire a handler. The class field
10271080
// initializer installs a `() => null` default, but `#init` spreads
@@ -1549,7 +1602,7 @@ export class SuperDoc extends EventEmitter {
15491602
* @param {boolean} lock
15501603
*/
15511604
setLocked(lock = true) {
1552-
this.config.documents.forEach((doc) => {
1605+
/** @type {InternalConfig} */ (this.config).documents.forEach((doc) => {
15531606
// setLocked is a collaboration-only API; the surrounding flow only
15541607
// calls it once each document has a Yjs doc attached. Cast away the
15551608
// optional shape on the public Document typedef without changing
@@ -1777,15 +1830,16 @@ export class SuperDoc extends EventEmitter {
17771830
this._cleanupAwareness = null;
17781831
}
17791832

1780-
this.config.socket?.cancelWebsocketRetry();
1781-
this.config.socket?.disconnect();
1782-
this.config.socket?.destroy();
1833+
const cfg = /** @type {InternalConfig} */ (this.config);
1834+
cfg.socket?.cancelWebsocketRetry();
1835+
cfg.socket?.disconnect();
1836+
cfg.socket?.destroy();
17831837

17841838
this.ydoc?.destroy();
17851839
this.provider?.disconnect();
17861840
this.provider?.destroy();
17871841

1788-
this.config.documents.forEach((doc) => {
1842+
cfg.documents.forEach((doc) => {
17891843
doc.provider?.disconnect();
17901844
doc.provider?.destroy();
17911845
doc.ydoc?.destroy();

packages/superdoc/src/core/types/index.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,11 +1356,16 @@ export interface Config {
13561356
}
13571357

13581358
/**
1359-
* Internal augmentation of `Config` for runtime-only fields that must not
1360-
* appear on the published consumer surface. The `Config` interface above is
1361-
* the public contract; this type adds the fields SuperDoc sets/reads
1362-
* internally so the implementation can be type-checked without leaking the
1363-
* fields into customer IDE autocomplete.
1359+
* Internal augmentation of `Config` for runtime-only fields and tightened
1360+
* invariants that must not appear on the published consumer surface. The
1361+
* `Config` interface above is the public contract; this type adds the
1362+
* fields SuperDoc sets/reads internally so the implementation can be
1363+
* type-checked without leaking the fields into customer IDE autocomplete.
1364+
*
1365+
* The four overrides below mark fields that `Config` exposes as optional
1366+
* but `SuperDoc.#init` always normalizes to a populated shape. Internal
1367+
* call sites cast `this.config` to this type so they can access these
1368+
* invariants without per-site null guards.
13641369
*
13651370
* Use this from internal SuperDoc.js callsites that need the augmented shape
13661371
* (e.g. `/** @type {InternalConfig} *\/ (this.config).socket = ...`).
@@ -1372,6 +1377,14 @@ export interface InternalConfig extends Config {
13721377
* not part of the public Config surface.
13731378
*/
13741379
socket?: HocuspocusProviderWebsocket;
1380+
/** Normalized to `[]` by `#init` if the consumer passes nothing or `undefined`. */
1381+
documents: Document[];
1382+
/** Normalized to `{}` by `#init` if the consumer passes nothing or `undefined`. */
1383+
modules: Modules;
1384+
/** Spread of `DEFAULT_USER` over consumer input by `#init`; `name` always present. */
1385+
user: User;
1386+
/** Normalized to `{}` by `#init` if the consumer passes nothing or `undefined`. */
1387+
layoutEngineOptions: SuperDocLayoutEngineOptions;
13751388
}
13761389

13771390
/**

0 commit comments

Comments
 (0)