diff --git a/packages/react/src/SuperDocEditor.test.tsx b/packages/react/src/SuperDocEditor.test.tsx
index b944661d85..d40ff03b64 100644
--- a/packages/react/src/SuperDocEditor.test.tsx
+++ b/packages/react/src/SuperDocEditor.test.tsx
@@ -4,6 +4,9 @@ import { createRef, StrictMode } from 'react';
import { SuperDocEditor } from './SuperDocEditor';
import type { SuperDocRef } from './types';
+const SUPERDOC_READY_WAIT_TIMEOUT = 10000;
+const SUPERDOC_READY_TEST_TIMEOUT = 15000;
+
describe('SuperDocEditor', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -36,19 +39,9 @@ describe('SuperDocEditor', () => {
expect((wrapper as HTMLElement)?.style.backgroundColor).toBe('red');
});
- it('should handle unmount without throwing', async () => {
- const onReady = vi.fn();
- const { unmount } = render();
-
- // Wait for initialization to complete
- await waitFor(
- () => {
- expect(onReady).toHaveBeenCalled();
- },
- { timeout: 5000 },
- );
+ it('should handle unmount without throwing', () => {
+ const { unmount } = render();
- // Unmount should not throw
expect(() => unmount()).not.toThrow();
});
});
@@ -94,113 +87,133 @@ describe('SuperDocEditor', () => {
});
describe('callbacks', () => {
- it('should call onReady when SuperDoc is ready', async () => {
- const onReady = vi.fn();
- render();
-
- await waitFor(
- () => {
- expect(onReady).toHaveBeenCalled();
- },
- { timeout: 5000 },
- );
- });
-
- it('should call onEditorCreate when editor is created', async () => {
- const onEditorCreate = vi.fn();
- render();
-
- await waitFor(
- () => {
- expect(onEditorCreate).toHaveBeenCalled();
- },
- { timeout: 5000 },
- );
- });
+ it(
+ 'should call onReady when SuperDoc is ready',
+ async () => {
+ const onReady = vi.fn();
+ render();
+
+ await waitFor(
+ () => {
+ expect(onReady).toHaveBeenCalled();
+ },
+ { timeout: SUPERDOC_READY_WAIT_TIMEOUT },
+ );
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
+
+ it(
+ 'should call onEditorCreate when editor is created',
+ async () => {
+ const onEditorCreate = vi.fn();
+ render();
+
+ await waitFor(
+ () => {
+ expect(onEditorCreate).toHaveBeenCalled();
+ },
+ { timeout: SUPERDOC_READY_WAIT_TIMEOUT },
+ );
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
- it('should route onTransaction through the latest callback after rerender', async () => {
- const ref = createRef();
- const onReady = vi.fn();
- const firstOnTransaction = vi.fn();
- const secondOnTransaction = vi.fn();
+ it(
+ 'should route onTransaction through the latest callback after rerender',
+ async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+ const firstOnTransaction = vi.fn();
+ const secondOnTransaction = vi.fn();
- const { rerender } = render();
+ const { rerender } = render();
- await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
- const instance = ref.current?.getInstance();
- expect(instance).toBeTruthy();
+ const instance = ref.current?.getInstance();
+ expect(instance).toBeTruthy();
- const transactionEvent = {
- editor: {},
- sourceEditor: {},
- transaction: { docChanged: true },
- surface: 'body',
- };
+ const transactionEvent = {
+ editor: {},
+ sourceEditor: {},
+ transaction: { docChanged: true },
+ surface: 'body',
+ };
- const firstCallCountBeforeManualDispatch = firstOnTransaction.mock.calls.length;
- (instance as any).config.onTransaction(transactionEvent);
+ const firstCallCountBeforeManualDispatch = firstOnTransaction.mock.calls.length;
+ (instance as any).config.onTransaction(transactionEvent);
- expect(firstOnTransaction).toHaveBeenLastCalledWith(transactionEvent);
- expect(firstOnTransaction).toHaveBeenCalledTimes(firstCallCountBeforeManualDispatch + 1);
- expect(secondOnTransaction).not.toHaveBeenCalled();
+ expect(firstOnTransaction).toHaveBeenLastCalledWith(transactionEvent);
+ expect(firstOnTransaction).toHaveBeenCalledTimes(firstCallCountBeforeManualDispatch + 1);
+ expect(secondOnTransaction).not.toHaveBeenCalled();
- rerender();
+ rerender();
- expect(ref.current?.getInstance()).toBe(instance);
+ expect(ref.current?.getInstance()).toBe(instance);
- const firstCallCountBeforeRerenderDispatch = firstOnTransaction.mock.calls.length;
- const secondCallCountBeforeManualDispatch = secondOnTransaction.mock.calls.length;
- (instance as any).config.onTransaction(transactionEvent);
+ const firstCallCountBeforeRerenderDispatch = firstOnTransaction.mock.calls.length;
+ const secondCallCountBeforeManualDispatch = secondOnTransaction.mock.calls.length;
+ (instance as any).config.onTransaction(transactionEvent);
- expect(firstOnTransaction).toHaveBeenCalledTimes(firstCallCountBeforeRerenderDispatch);
- expect(secondOnTransaction).toHaveBeenLastCalledWith(transactionEvent);
- expect(secondOnTransaction).toHaveBeenCalledTimes(secondCallCountBeforeManualDispatch + 1);
- });
+ expect(firstOnTransaction).toHaveBeenCalledTimes(firstCallCountBeforeRerenderDispatch);
+ expect(secondOnTransaction).toHaveBeenLastCalledWith(transactionEvent);
+ expect(secondOnTransaction).toHaveBeenCalledTimes(secondCallCountBeforeManualDispatch + 1);
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
});
describe('onEditorDestroy', () => {
- it('should call onEditorDestroy when component unmounts', async () => {
- const onReady = vi.fn();
- const onEditorDestroy = vi.fn();
- const { unmount } = render();
-
- await waitFor(
- () => {
- expect(onReady).toHaveBeenCalled();
- },
- { timeout: 5000 },
- );
+ it(
+ 'should call onEditorDestroy when component unmounts',
+ async () => {
+ const onReady = vi.fn();
+ const onEditorDestroy = vi.fn();
+ const { unmount } = render();
+
+ await waitFor(
+ () => {
+ expect(onReady).toHaveBeenCalled();
+ },
+ { timeout: SUPERDOC_READY_WAIT_TIMEOUT },
+ );
- unmount();
+ unmount();
- await waitFor(
- () => {
- expect(onEditorDestroy).toHaveBeenCalled();
- },
- { timeout: 5000 },
- );
- });
+ await waitFor(
+ () => {
+ expect(onEditorDestroy).toHaveBeenCalled();
+ },
+ { timeout: SUPERDOC_READY_WAIT_TIMEOUT },
+ );
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
});
describe('error states', () => {
- it('should show error container when initialization fails', async () => {
- // Force an error by providing an invalid document
- const onException = vi.fn();
- const { container } = render(
- ,
- );
+ it(
+ 'should show error container when initialization fails',
+ async () => {
+ // Force an error by providing an invalid document
+ const onException = vi.fn();
+ const { container } = render(
+ ,
+ );
- await waitFor(
- () => {
- const errorContainer = container.querySelector('.superdoc-error-container');
- // If SuperDoc throws on invalid input, error UI shows
- // If SuperDoc handles it gracefully, onException may be called instead
- expect(errorContainer || onException.mock.calls.length > 0).toBeTruthy();
- },
- { timeout: 5000 },
- );
- });
+ await waitFor(
+ () => {
+ const errorContainer = container.querySelector('.superdoc-error-container');
+ // If SuperDoc throws on invalid input, error UI shows
+ // If SuperDoc handles it gracefully, onException may be called instead
+ expect(errorContainer || onException.mock.calls.length > 0).toBeTruthy();
+ },
+ { timeout: SUPERDOC_READY_WAIT_TIMEOUT },
+ );
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
});
describe('Strict Mode compatibility', () => {
@@ -216,210 +229,236 @@ describe('SuperDocEditor', () => {
});
describe('prop stability (SD-2635)', () => {
- it('does not destroy/re-init when user prop is a new object literal with identical content', async () => {
- const ref = createRef();
- const onReady = vi.fn();
- const onEditorDestroy = vi.fn();
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
- const instanceBefore = ref.current?.getInstance();
- expect(instanceBefore).toBeTruthy();
-
- // Re-render with a *new* object literal carrying the same content —
- // this is the idiomatic React pattern that used to trigger a full
- // destroy + re-init loop before SD-2635.
- rerender(
- ,
- );
-
- // Same underlying instance proves no destroy+rebuild happened.
- expect(ref.current?.getInstance()).toBe(instanceBefore);
- expect(onEditorDestroy).not.toHaveBeenCalled();
- });
-
- it('does not destroy/re-init when users prop is a new array literal with identical content', async () => {
- const ref = createRef();
- const onReady = vi.fn();
- const onEditorDestroy = vi.fn();
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
- const instanceBefore = ref.current?.getInstance();
-
- rerender(
- ,
- );
-
- expect(ref.current?.getInstance()).toBe(instanceBefore);
- expect(onEditorDestroy).not.toHaveBeenCalled();
- });
-
- it('rebuilds and remounts a new instance when user prop value actually changes', async () => {
- const ref = createRef();
- const onReady = vi.fn();
- const onEditorDestroy = vi.fn();
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
- const instanceBefore = ref.current?.getInstance();
-
- rerender(
- ,
- );
-
- // Old instance torn down, new instance ready.
- await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 });
- await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: 5000 });
- expect(ref.current?.getInstance()).not.toBe(instanceBefore);
- });
+ it(
+ 'does not destroy/re-init when user prop is a new object literal with identical content',
+ async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+ const onEditorDestroy = vi.fn();
+
+ const { rerender } = render(
+ ,
+ );
- it('stays stable under StrictMode double-invocation on rerender', async () => {
- const ref = createRef();
- const onReady = vi.fn();
- const onEditorDestroy = vi.fn();
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
+ const instanceBefore = ref.current?.getInstance();
+ expect(instanceBefore).toBeTruthy();
- const { rerender } = render(
-
+ // Re-render with a *new* object literal carrying the same content —
+ // this is the idiomatic React pattern that used to trigger a full
+ // destroy + re-init loop before SD-2635.
+ rerender(
- ,
- );
+ />,
+ );
- await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
- const instanceBefore = ref.current?.getInstance();
- const destroysBefore = onEditorDestroy.mock.calls.length;
+ // Same underlying instance proves no destroy+rebuild happened.
+ expect(ref.current?.getInstance()).toBe(instanceBefore);
+ expect(onEditorDestroy).not.toHaveBeenCalled();
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
+
+ it(
+ 'does not destroy/re-init when users prop is a new array literal with identical content',
+ async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+ const onEditorDestroy = vi.fn();
+
+ const { rerender } = render(
+ ,
+ );
- rerender(
-
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
+ const instanceBefore = ref.current?.getInstance();
+
+ rerender(
- ,
- );
+ />,
+ );
- expect(ref.current?.getInstance()).toBe(instanceBefore);
- expect(onEditorDestroy.mock.calls.length).toBe(destroysBefore);
- });
+ expect(ref.current?.getInstance()).toBe(instanceBefore);
+ expect(onEditorDestroy).not.toHaveBeenCalled();
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
- it('still rebuilds under StrictMode when user prop value actually changes', async () => {
- // The same-content StrictMode test above proves memoization survives
- // double-invocation. This test proves the positive path — a real
- // value change under StrictMode still tears down and remounts.
- const ref = createRef();
- const onReady = vi.fn();
- const onEditorDestroy = vi.fn();
+ it(
+ 'rebuilds and remounts a new instance when user prop value actually changes',
+ async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+ const onEditorDestroy = vi.fn();
- const { rerender } = render(
-
+ const { rerender } = render(
- ,
- );
+ />,
+ );
- await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
- const instanceBefore = ref.current?.getInstance();
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
+ const instanceBefore = ref.current?.getInstance();
- rerender(
-
+ rerender(
- ,
- );
+ />,
+ );
- await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 });
- await waitFor(() => expect(ref.current?.getInstance()).not.toBe(instanceBefore), { timeout: 5000 });
- });
+ // Old instance torn down, new instance ready.
+ await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
+ await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
+ expect(ref.current?.getInstance()).not.toBe(instanceBefore);
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
+
+ it(
+ 'stays stable under StrictMode double-invocation on rerender',
+ async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+ const onEditorDestroy = vi.fn();
+
+ const { rerender } = render(
+
+
+ ,
+ );
- it('rebuilds when a new modules object is passed, even if content looks equal', async () => {
- // `modules` is intentionally kept on reference identity in the dep
- // array because it can carry functions and live objects that a
- // structural compare would miss. This test pins that contract —
- // if a future refactor wraps `modules` in useStructuralMemo, this
- // test will fail and flag the regression.
- const ref = createRef();
- const onReady = vi.fn();
- const onEditorDestroy = vi.fn();
-
- const { rerender } = render(
- ,
- );
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
+ const instanceBefore = ref.current?.getInstance();
+ const destroysBefore = onEditorDestroy.mock.calls.length;
- await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
- const instanceBefore = ref.current?.getInstance();
+ rerender(
+
+
+ ,
+ );
- rerender(
- ,
- );
+ expect(ref.current?.getInstance()).toBe(instanceBefore);
+ expect(onEditorDestroy.mock.calls.length).toBe(destroysBefore);
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
+
+ it(
+ 'still rebuilds under StrictMode when user prop value actually changes',
+ async () => {
+ // The same-content StrictMode test above proves memoization survives
+ // double-invocation. This test proves the positive path — a real
+ // value change under StrictMode still tears down and remounts.
+ const ref = createRef();
+ const onReady = vi.fn();
+ const onEditorDestroy = vi.fn();
+
+ const { rerender } = render(
+
+
+ ,
+ );
- await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 });
- await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: 5000 });
- expect(ref.current?.getInstance()).not.toBe(instanceBefore);
- });
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
+ const instanceBefore = ref.current?.getInstance();
+
+ rerender(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
+ await waitFor(() => expect(ref.current?.getInstance()).not.toBe(instanceBefore), {
+ timeout: SUPERDOC_READY_WAIT_TIMEOUT,
+ });
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
+
+ it(
+ 'rebuilds when a new modules object is passed, even if content looks equal',
+ async () => {
+ // `modules` is intentionally kept on reference identity in the dep
+ // array because it can carry functions and live objects that a
+ // structural compare would miss. This test pins that contract —
+ // if a future refactor wraps `modules` in useStructuralMemo, this
+ // test will fail and flag the regression.
+ const ref = createRef();
+ const onReady = vi.fn();
+ const onEditorDestroy = vi.fn();
+
+ const { rerender } = render(
+ ,
+ );
+
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
+ const instanceBefore = ref.current?.getInstance();
+
+ rerender(
+ ,
+ );
+
+ await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
+ await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
+ expect(ref.current?.getInstance()).not.toBe(instanceBefore);
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
});
describe('unique IDs', () => {
@@ -437,38 +476,46 @@ describe('SuperDocEditor', () => {
});
describe('with real superdoc', () => {
- it('should initialize superdoc instance', async () => {
- const ref = createRef();
- const onReady = vi.fn();
-
- render();
-
- await waitFor(
- () => {
- expect(onReady).toHaveBeenCalled();
- expect(ref.current?.getInstance()).not.toBeNull();
- },
- { timeout: 5000 },
- );
- });
-
- it('should provide access to superdoc methods after ready', async () => {
- const ref = createRef();
- const onReady = vi.fn();
-
- render();
-
- await waitFor(
- () => {
- expect(onReady).toHaveBeenCalled();
- },
- { timeout: 5000 },
- );
+ it(
+ 'should initialize superdoc instance',
+ async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+
+ render();
+
+ await waitFor(
+ () => {
+ expect(onReady).toHaveBeenCalled();
+ expect(ref.current?.getInstance()).not.toBeNull();
+ },
+ { timeout: SUPERDOC_READY_WAIT_TIMEOUT },
+ );
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
+
+ it(
+ 'should provide access to superdoc methods after ready',
+ async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+
+ render();
+
+ await waitFor(
+ () => {
+ expect(onReady).toHaveBeenCalled();
+ },
+ { timeout: SUPERDOC_READY_WAIT_TIMEOUT },
+ );
- const instance = ref.current?.getInstance();
- expect(instance).toBeTruthy();
- expect(typeof instance?.destroy).toBe('function');
- expect(typeof instance?.setDocumentMode).toBe('function');
- });
+ const instance = ref.current?.getInstance();
+ expect(instance).toBeTruthy();
+ expect(typeof instance?.destroy).toBe('function');
+ expect(typeof instance?.setDocumentMode).toBe('function');
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
});
});
diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts
index 709a83f6da..80666788b4 100644
--- a/packages/react/src/types.ts
+++ b/packages/react/src/types.ts
@@ -1,5 +1,5 @@
import type { CSSProperties, ReactNode } from 'react';
-import type { SuperDoc, Editor } from 'superdoc';
+import type { SuperDoc, Editor, Transaction } from 'superdoc';
/**
* Types for @superdoc-dev/react
@@ -73,8 +73,8 @@ export interface SuperDocTransactionEvent {
editor: Editor;
/** The editor instance that emitted the transaction. For body edits, this matches `editor`. */
sourceEditor: Editor;
- /** The ProseMirror transaction or transaction-like payload emitted by the source editor. */
- transaction: any;
+ /** The ProseMirror transaction emitted by the source editor. */
+ transaction: Transaction;
/** Time spent applying the transaction, in milliseconds. */
duration?: number;
/** The surface where the transaction originated. */
diff --git a/packages/react/src/utils.ts b/packages/react/src/utils.ts
index 782e8fe7af..80a1bd4877 100644
--- a/packages/react/src/utils.ts
+++ b/packages/react/src/utils.ts
@@ -2,6 +2,10 @@
import * as React from 'react';
+type ReactWithUseId = typeof React & {
+ useId?: () => string;
+};
+
/**
* Polyfill for React.useId() for React versions < 18.
* Uses useRef to generate a stable random ID once per component instance.
@@ -23,8 +27,8 @@ function useIdPolyfill(): string {
* - React 18+: useId() returns ":r0:" → "superdoc:r0:"
* - Polyfill: returns "-1707345123456-abc1d2e" → "superdoc-1707345123456-abc1d2e"
*/
-export const useStableId: () => string =
- typeof (React as any).useId === 'function' ? (React as any).useId : useIdPolyfill;
+const reactUseId = (React as ReactWithUseId).useId;
+export const useStableId: () => string = typeof reactUseId === 'function' ? reactUseId : useIdPolyfill;
/**
* Returns a reference-stable version of `value` — identity only changes
diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts
index 451ebf4417..e50eb08694 100644
--- a/packages/superdoc/src/core/types/index.ts
+++ b/packages/superdoc/src/core/types/index.ts
@@ -13,6 +13,7 @@
import type { Doc as YDoc } from 'yjs';
import type { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
+import type { Transaction } from 'prosemirror-state';
import type { Ref, ComputedRef } from 'vue';
import type {
@@ -76,6 +77,34 @@ export interface User {
color?: string;
}
+/**
+ * One entry in the `states` array delivered to
+ * {@link Config.onAwarenessUpdate}. SuperDoc emits an entry per remote
+ * client, derived from the underlying Yjs awareness states.
+ *
+ * The runtime helper `awarenessStatesToArray` spreads each remote user
+ * onto the top of the entry (`{ clientId, ...value.user, color }`), so
+ * `User` fields like `name`, `email`, `image` appear at the top level
+ * (not nested under a `user` property). Consumers should read
+ * `state.name` / `state.email`, not `state.user.name`.
+ *
+ * Application-specific fields attached to the awareness state by the
+ * provider surface through the `[key: string]: unknown` index
+ * signature; consumers narrow before use.
+ */
+export interface AwarenessState extends User {
+ /** Yjs client identifier for the remote peer. */
+ clientId?: number;
+ /**
+ * Color assigned by SuperDoc's presence system. Overrides
+ * {@link User.color} when the presence system computes a stable
+ * palette assignment for the remote peer.
+ */
+ color?: string;
+ /** Application-specific fields spread from the awareness provider. */
+ [key: string]: unknown;
+}
+
export interface Document {
/** The ID of the document. */
id?: string;
@@ -1191,8 +1220,8 @@ export interface EditorTransactionEvent {
editor: Editor;
/** The editor instance that emitted the transaction. For body edits, this matches `editor`. */
sourceEditor: Editor;
- /** The ProseMirror transaction or transaction-like payload emitted by the source editor. */
- transaction: any;
+ /** The ProseMirror transaction emitted by the source editor. */
+ transaction: Transaction;
/** Time spent applying the transaction, in milliseconds. */
duration?: number;
/** The surface where the transaction originated. */
@@ -1334,8 +1363,7 @@ export interface Config {
/** Callback when comments are updated. */
onCommentsUpdate?: (params: { type: string; data: object }) => void;
/** Callback when awareness is updated. */
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- onAwarenessUpdate?: (params: { context: SuperDoc; states: any[] }) => void;
+ onAwarenessUpdate?: (params: { context: SuperDoc; states: AwarenessState[] }) => void;
/** Callback when the SuperDoc is locked. */
onLocked?: (params: { isLocked: boolean; lockedBy: User }) => void;
/** Callback when the PDF document is ready. */
@@ -1491,7 +1519,12 @@ export interface ProofingError {
kind: 'provider-error' | 'validation-error' | 'timeout';
message: string;
segmentIds?: string[];
- cause?: any;
+ /**
+ * Underlying error (genuinely opaque: whatever the proofing provider
+ * threw). Use `unknown` per Error-cause convention; consumers narrow
+ * with `instanceof` or shape checks before reading fields.
+ */
+ cause?: unknown;
}
export interface ProofingConfig {
diff --git a/packages/superdoc/src/index.js b/packages/superdoc/src/index.js
index d0bf89b4ad..daaed5f22a 100644
--- a/packages/superdoc/src/index.js
+++ b/packages/superdoc/src/index.js
@@ -186,6 +186,7 @@ import { getSchemaIntrospection } from './helpers/schema-introspection.js';
* @typedef {import('./core/types/index.js').CommentAddress} CommentAddress
* @typedef {import('./core/types/index.js').TrackedChangeAddress} TrackedChangeAddress
* @typedef {import('./core/types/index.js').NavigableAddress} NavigableAddress
+ * @typedef {import('./core/types/index.js').AwarenessState} AwarenessState
*
* @typedef {import('./core/types/index.js').Config} Config
* @typedef {import('./core/types/index.js').Modules} Modules
diff --git a/tests/consumer-typecheck/src/all-public-types.ts b/tests/consumer-typecheck/src/all-public-types.ts
index f95772c886..bb08998126 100644
--- a/tests/consumer-typecheck/src/all-public-types.ts
+++ b/tests/consumer-typecheck/src/all-public-types.ts
@@ -91,6 +91,7 @@ import type {
LinkPopoverContext,
LinkPopoverResolution,
LinkPopoverResolver,
+ AwarenessState,
ListDefinitionsPayload,
Measure,
Modules,
@@ -258,6 +259,7 @@ const _real_LayoutUpdatePayload: AssertNotAny = true;
const _real_LinkPopoverContext: AssertNotAny = true;
const _real_LinkPopoverResolution: AssertNotAny = true;
const _real_LinkPopoverResolver: AssertNotAny = true;
+const _real_AwarenessState: AssertNotAny = true;
const _real_ListDefinitionsPayload: AssertNotAny = true;
const _real_Measure: AssertNotAny = true;
const _real_Modules: AssertNotAny = true;
diff --git a/tests/consumer-typecheck/src/modules-config-passthrough.ts b/tests/consumer-typecheck/src/modules-config-passthrough.ts
index fddd9e536f..3524bf9157 100644
--- a/tests/consumer-typecheck/src/modules-config-passthrough.ts
+++ b/tests/consumer-typecheck/src/modules-config-passthrough.ts
@@ -17,7 +17,7 @@
* - SD-2869 review pass flagged `onAwarenessUpdate.states` narrowed from
* JSDoc `Array` (= `any[]`) to `unknown[]`.
*/
-import type { Config } from 'superdoc';
+import type { Config, AwarenessState } from 'superdoc';
// A realistic config with the documented fields plus the pass-through extras
// the runtime accepts. If any of these stops compiling under strict mode,
@@ -121,17 +121,26 @@ const config: Config = {
whiteboard: false, // disable sentinel — must compile
},
- // Awareness handler reads concrete fields off each state. The JSDoc
- // original typed `states` as `Array` (= `any[]`); the conversion
- // preserved that. If a future change narrows to `unknown[]`, this access
- // breaks under strict mode.
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- onAwarenessUpdate: ({ states }: { states: any[] }) => {
+ // Awareness handler reads concrete fields off each state. SD-2834
+ // promoted `states` from `any[]` to a public `AwarenessState` type
+ // (which extends `User`, since the runtime helper
+ // `awarenessStatesToArray` spreads user fields at the top level via
+ // `{ clientId, ...value.user, color }`). Consumers get IntelliSense
+ // on the flattened fields (`name`, `email`, `clientId`, `color`)
+ // without giving up the pass-through index signature for
+ // application-specific keys.
+ onAwarenessUpdate: ({ states }: { states: AwarenessState[] }) => {
for (const state of states) {
- const userId = state?.user?.id;
- const clientId = state?.clientId;
- void userId;
+ const userName = state.name;
+ const userEmail = state.email;
+ const clientId = state.clientId;
+ const userColor = state.color;
+ const customField = state['customField']; // index signature still works
+ void userName;
+ void userEmail;
void clientId;
+ void userColor;
+ void customField;
}
},
};