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; } }, };