From 784db7fddd96547e995f6409b32f2ca7ff386b73 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 12 May 2026 16:38:45 -0300 Subject: [PATCH 1/5] fix(types): drain Tier 4 any from EditorTransactionEvent, awareness, ProofingError (SD-2834) Three surgical fixes to the curated public-contract file `packages/superdoc/src/core/types/index.ts`. Each one was visible in the deep audit's tier-4-public-contract bucket and represents direct customer IntelliSense pain on a documented public API. 1. `EditorTransactionEvent.transaction: any` -> `Transaction` from `prosemirror-state`. Anyone hovering over an `onTransaction` callback's transaction param now sees the real ProseMirror type with `.docChanged`, `.steps`, `.selection`, etc., instead of `any`. `prosemirror-state` is already an optional peer dep, consistent with the existing pattern for `@hocuspocus/provider`. 2. `Config.onAwarenessUpdate.states: any[]` -> `AwarenessState[]`. New small public type defines the documented fields SuperDoc populates per remote client (`user`, `clientId`, `color`) plus an index signature for application-specific keys. y-protocols' Awareness type itself uses `Record` for the value side, so a precise imported type was not available; defined locally rather than collapsing to `unknown[]` per the audit README's guidance against draining with `unknown` when a real shape is documentable. Removes the existing `eslint-disable @typescript-eslint/no-explicit-any` on the same line. 3. `ProofingError.cause?: any` -> `unknown`. Error causes are genuinely opaque (whatever the proofing provider threw), so `unknown` is the correct level of precision per the Error-cause convention. Consumers narrow with `instanceof` or shape checks before reading. Also re-exports `AwarenessState` via the JSDoc typedef in `packages/superdoc/src/index.js`, adds the AssertNotAny assertion to `tests/consumer-typecheck/src/all-public-types.ts`, and updates the existing modules-config-passthrough fixture to consume the new type through documented field access. Verified: - node tests/consumer-typecheck/typecheck-matrix.mjs: 53/0 - node tests/consumer-typecheck/deep-type-audit.mjs: 1799 -> 1791 (tier-4-public-contract: 26 -> 18) - AssertNotAny passes (check-public-types gate) --- packages/superdoc/src/core/types/index.ts | 34 ++++++++++++++++--- packages/superdoc/src/index.js | 1 + .../src/all-public-types.ts | 2 ++ .../src/modules-config-passthrough.ts | 24 +++++++------ 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 451ebf4417..997b0ddc49 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,25 @@ 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 fields + * below match what SuperDoc populates by default; consumers using a + * custom provider may attach additional fields, which surface through + * the index signature as `unknown` (consumers narrow before use). + */ +export interface AwarenessState { + /** The remote user, when one is associated with this awareness entry. */ + user?: User; + /** Yjs client identifier for the remote peer. */ + clientId?: number; + /** Color assigned to the remote user by SuperDoc's presence system. */ + color?: string; + /** Additional fields populated by application code via the awareness provider. */ + [key: string]: unknown; +} + export interface Document { /** The ID of the document. */ id?: string; @@ -1191,8 +1211,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 +1354,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 +1510,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..e69ebb5769 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,21 @@ 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 so + // consumers get IntelliSense on the documented fields (`user`, + // `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.user?.name; + const clientId = state.clientId; + const userColor = state.color; + const customField = state['customField']; // index signature still works + void userName; void clientId; + void userColor; + void customField; } }, }; From 25ee1a825e8989f13cfb496d404bfe0703edaaa6 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 12 May 2026 17:09:17 -0300 Subject: [PATCH 2/5] fix(types): sync React mirror of EditorTransactionEvent (SD-2834) Code review caught that `packages/react/src/types.ts:71` defines a hand-mirrored `SuperDocTransactionEvent` interface that still had `transaction: any` after the canonical `EditorTransactionEvent` was narrowed to `Transaction` in the previous commit. The mirror's JSDoc explicitly says "Mirrors superdoc's EditorTransactionEvent", so React consumers using `@superdoc-dev/react` would have kept seeing `transaction: any` and lost IntelliSense - the exact symptom this PR is meant to fix. Sync the React mirror: - Add `Transaction` to the `import type { ... } from 'superdoc'` (avoids adding `prosemirror-state` as a new dep; superdoc already re-exports the type via JSDoc typedef) - Change `transaction: any` -> `transaction: Transaction` - Drop "or transaction-like payload" from the JSDoc to match the canonical wording Verified the awareness and proofing fixes don't need React mirrors - those flow through `Config` directly (which React extracts from SuperDoc's constructor parameters) so consumers already see them. Verified: - pnpm --filter @superdoc-dev/react run type-check: passes --- packages/react/src/types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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. */ From 1fa1a1ab282f140156184b6fb6346519a1a99510 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 12 May 2026 17:31:23 -0300 Subject: [PATCH 3/5] fix(types): flatten AwarenessState to match runtime spread (SD-2834) Code review caught that the previous AwarenessState shape pointed consumers at fields that don't exist at runtime. The `awarenessStatesToArray` helper at `shared/common/collaboration/awareness.ts` returns `{ clientId, ...value.user, color }` per entry - i.e., the remote user's `name`, `email`, `image` are SPREAD onto the top of the state, not nested under `state.user`. The first cut typed `user?: User`, which made `state.user?.name` typecheck (always undefined at runtime) while real fields like `state.name` and `state.email` only resolved through the `unknown` index signature. That's worse than `any` - it documents the wrong shape. Flip AwarenessState to `extends User`, matching the runtime spread. Update the doc comment to spell out the flatten and point consumers at `state.name` / `state.email` directly. Update the consumer fixture to read the flattened fields (`state.name`, `state.email`, `state.clientId`, `state.color`, plus the index-signature `state['customField']`). Verified: - pnpm --filter superdoc run pack:es: ok - node tests/consumer-typecheck/typecheck-matrix.mjs: 53/0 - node tests/consumer-typecheck/deep-type-audit.mjs: 1791 (unchanged) --- packages/superdoc/src/core/types/index.ts | 27 ++++++++++++------- .../src/modules-config-passthrough.ts | 15 +++++++---- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 997b0ddc49..e50eb08694 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -80,19 +80,28 @@ export interface User { /** * 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 fields - * below match what SuperDoc populates by default; consumers using a - * custom provider may attach additional fields, which surface through - * the index signature as `unknown` (consumers narrow before use). + * 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 { - /** The remote user, when one is associated with this awareness entry. */ - user?: User; +export interface AwarenessState extends User { /** Yjs client identifier for the remote peer. */ clientId?: number; - /** Color assigned to the remote user by SuperDoc's presence system. */ + /** + * 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; - /** Additional fields populated by application code via the awareness provider. */ + /** Application-specific fields spread from the awareness provider. */ [key: string]: unknown; } diff --git a/tests/consumer-typecheck/src/modules-config-passthrough.ts b/tests/consumer-typecheck/src/modules-config-passthrough.ts index e69ebb5769..3524bf9157 100644 --- a/tests/consumer-typecheck/src/modules-config-passthrough.ts +++ b/tests/consumer-typecheck/src/modules-config-passthrough.ts @@ -122,17 +122,22 @@ const config: Config = { }, // Awareness handler reads concrete fields off each state. SD-2834 - // promoted `states` from `any[]` to a public `AwarenessState` type so - // consumers get IntelliSense on the documented fields (`user`, - // `clientId`, `color`) without giving up the pass-through index - // signature for application-specific keys. + // 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 userName = state.user?.name; + 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; From f9dd579791a537adef25e27c3f26408d31dc12f0 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 14 May 2026 08:33:58 -0300 Subject: [PATCH 4/5] test(react): stabilize SuperDocEditor CI readiness waits (SD-2834) --- packages/react/src/SuperDocEditor.test.tsx | 56 ++++++++++------------ packages/react/src/utils.ts | 8 +++- 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/packages/react/src/SuperDocEditor.test.tsx b/packages/react/src/SuperDocEditor.test.tsx index b944661d85..445eac1185 100644 --- a/packages/react/src/SuperDocEditor.test.tsx +++ b/packages/react/src/SuperDocEditor.test.tsx @@ -4,6 +4,8 @@ import { createRef, StrictMode } from 'react'; import { SuperDocEditor } from './SuperDocEditor'; import type { SuperDocRef } from './types'; +const REACT_TEST_TIMEOUT = 10000; + describe('SuperDocEditor', () => { beforeEach(() => { vi.clearAllMocks(); @@ -36,19 +38,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(); }); }); @@ -102,7 +94,7 @@ describe('SuperDocEditor', () => { () => { expect(onReady).toHaveBeenCalled(); }, - { timeout: 5000 }, + { timeout: REACT_TEST_TIMEOUT }, ); }); @@ -114,7 +106,7 @@ describe('SuperDocEditor', () => { () => { expect(onEditorCreate).toHaveBeenCalled(); }, - { timeout: 5000 }, + { timeout: REACT_TEST_TIMEOUT }, ); }); @@ -126,7 +118,7 @@ describe('SuperDocEditor', () => { const { rerender } = render(); - await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 }); + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: REACT_TEST_TIMEOUT }); const instance = ref.current?.getInstance(); expect(instance).toBeTruthy(); @@ -169,7 +161,7 @@ describe('SuperDocEditor', () => { () => { expect(onReady).toHaveBeenCalled(); }, - { timeout: 5000 }, + { timeout: REACT_TEST_TIMEOUT }, ); unmount(); @@ -178,7 +170,7 @@ describe('SuperDocEditor', () => { () => { expect(onEditorDestroy).toHaveBeenCalled(); }, - { timeout: 5000 }, + { timeout: REACT_TEST_TIMEOUT }, ); }); }); @@ -198,7 +190,7 @@ describe('SuperDocEditor', () => { // If SuperDoc handles it gracefully, onException may be called instead expect(errorContainer || onException.mock.calls.length > 0).toBeTruthy(); }, - { timeout: 5000 }, + { timeout: REACT_TEST_TIMEOUT }, ); }); }); @@ -230,7 +222,7 @@ describe('SuperDocEditor', () => { />, ); - await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 }); + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: REACT_TEST_TIMEOUT }); const instanceBefore = ref.current?.getInstance(); expect(instanceBefore).toBeTruthy(); @@ -265,7 +257,7 @@ describe('SuperDocEditor', () => { />, ); - await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 }); + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: REACT_TEST_TIMEOUT }); const instanceBefore = ref.current?.getInstance(); rerender( @@ -295,7 +287,7 @@ describe('SuperDocEditor', () => { />, ); - await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 }); + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: REACT_TEST_TIMEOUT }); const instanceBefore = ref.current?.getInstance(); rerender( @@ -308,8 +300,8 @@ describe('SuperDocEditor', () => { ); // Old instance torn down, new instance ready. - await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 }); - await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: 5000 }); + await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: REACT_TEST_TIMEOUT }); + await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: REACT_TEST_TIMEOUT }); expect(ref.current?.getInstance()).not.toBe(instanceBefore); }); @@ -329,7 +321,7 @@ describe('SuperDocEditor', () => { , ); - await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 }); + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: REACT_TEST_TIMEOUT }); const instanceBefore = ref.current?.getInstance(); const destroysBefore = onEditorDestroy.mock.calls.length; @@ -367,7 +359,7 @@ describe('SuperDocEditor', () => { , ); - await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 }); + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: REACT_TEST_TIMEOUT }); const instanceBefore = ref.current?.getInstance(); rerender( @@ -381,8 +373,8 @@ describe('SuperDocEditor', () => { , ); - await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 }); - await waitFor(() => expect(ref.current?.getInstance()).not.toBe(instanceBefore), { timeout: 5000 }); + await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: REACT_TEST_TIMEOUT }); + await waitFor(() => expect(ref.current?.getInstance()).not.toBe(instanceBefore), { timeout: REACT_TEST_TIMEOUT }); }); it('rebuilds when a new modules object is passed, even if content looks equal', async () => { @@ -404,7 +396,7 @@ describe('SuperDocEditor', () => { />, ); - await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 }); + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: REACT_TEST_TIMEOUT }); const instanceBefore = ref.current?.getInstance(); rerender( @@ -416,8 +408,8 @@ describe('SuperDocEditor', () => { />, ); - await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 }); - await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: 5000 }); + await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: REACT_TEST_TIMEOUT }); + await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: REACT_TEST_TIMEOUT }); expect(ref.current?.getInstance()).not.toBe(instanceBefore); }); }); @@ -448,7 +440,7 @@ describe('SuperDocEditor', () => { expect(onReady).toHaveBeenCalled(); expect(ref.current?.getInstance()).not.toBeNull(); }, - { timeout: 5000 }, + { timeout: REACT_TEST_TIMEOUT }, ); }); @@ -462,7 +454,7 @@ describe('SuperDocEditor', () => { () => { expect(onReady).toHaveBeenCalled(); }, - { timeout: 5000 }, + { timeout: REACT_TEST_TIMEOUT }, ); const instance = ref.current?.getInstance(); 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 From 10d96d09f81b02e84899b1d7f4df2ffbb66c1fef Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 14 May 2026 08:43:56 -0300 Subject: [PATCH 5/5] test(react): extend SuperDoc readiness test timeout (SD-2834) --- packages/react/src/SuperDocEditor.test.tsx | 637 +++++++++++---------- 1 file changed, 346 insertions(+), 291 deletions(-) diff --git a/packages/react/src/SuperDocEditor.test.tsx b/packages/react/src/SuperDocEditor.test.tsx index 445eac1185..d40ff03b64 100644 --- a/packages/react/src/SuperDocEditor.test.tsx +++ b/packages/react/src/SuperDocEditor.test.tsx @@ -4,7 +4,8 @@ import { createRef, StrictMode } from 'react'; import { SuperDocEditor } from './SuperDocEditor'; import type { SuperDocRef } from './types'; -const REACT_TEST_TIMEOUT = 10000; +const SUPERDOC_READY_WAIT_TIMEOUT = 10000; +const SUPERDOC_READY_TEST_TIMEOUT = 15000; describe('SuperDocEditor', () => { beforeEach(() => { @@ -86,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: REACT_TEST_TIMEOUT }, - ); - }); + 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 call onEditorCreate when editor is created', async () => { - const onEditorCreate = vi.fn(); - render(); + 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(); - await waitFor( - () => { - expect(onEditorCreate).toHaveBeenCalled(); - }, - { timeout: REACT_TEST_TIMEOUT }, - ); - }); + const { rerender } = render(); - 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(); + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT }); - const { rerender } = render(); + const instance = ref.current?.getInstance(); + expect(instance).toBeTruthy(); - await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: REACT_TEST_TIMEOUT }); + const transactionEvent = { + editor: {}, + sourceEditor: {}, + transaction: { docChanged: true }, + surface: 'body', + }; - const instance = ref.current?.getInstance(); - expect(instance).toBeTruthy(); + const firstCallCountBeforeManualDispatch = firstOnTransaction.mock.calls.length; + (instance as any).config.onTransaction(transactionEvent); - const transactionEvent = { - editor: {}, - sourceEditor: {}, - transaction: { docChanged: true }, - surface: 'body', - }; + expect(firstOnTransaction).toHaveBeenLastCalledWith(transactionEvent); + expect(firstOnTransaction).toHaveBeenCalledTimes(firstCallCountBeforeManualDispatch + 1); + expect(secondOnTransaction).not.toHaveBeenCalled(); - const firstCallCountBeforeManualDispatch = firstOnTransaction.mock.calls.length; - (instance as any).config.onTransaction(transactionEvent); + rerender(); - expect(firstOnTransaction).toHaveBeenLastCalledWith(transactionEvent); - expect(firstOnTransaction).toHaveBeenCalledTimes(firstCallCountBeforeManualDispatch + 1); - expect(secondOnTransaction).not.toHaveBeenCalled(); + expect(ref.current?.getInstance()).toBe(instance); - rerender(); + const firstCallCountBeforeRerenderDispatch = firstOnTransaction.mock.calls.length; + const secondCallCountBeforeManualDispatch = secondOnTransaction.mock.calls.length; + (instance as any).config.onTransaction(transactionEvent); - expect(ref.current?.getInstance()).toBe(instance); - - 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: REACT_TEST_TIMEOUT }, - ); + 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: REACT_TEST_TIMEOUT }, - ); - }); + 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: REACT_TEST_TIMEOUT }, - ); - }); + 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', () => { @@ -208,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: REACT_TEST_TIMEOUT }); - 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: REACT_TEST_TIMEOUT }); - 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: REACT_TEST_TIMEOUT }); - const instanceBefore = ref.current?.getInstance(); - - rerender( - , - ); - - // Old instance torn down, new instance ready. - await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: REACT_TEST_TIMEOUT }); - await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: REACT_TEST_TIMEOUT }); - 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( - , - ); + />, + ); + + // 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( + , + ); - await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: REACT_TEST_TIMEOUT }); - const instanceBefore = ref.current?.getInstance(); - const destroysBefore = onEditorDestroy.mock.calls.length; + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT }); + const instanceBefore = ref.current?.getInstance(); - rerender( - + 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: REACT_TEST_TIMEOUT }); - 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: REACT_TEST_TIMEOUT }); - await waitFor(() => expect(ref.current?.getInstance()).not.toBe(instanceBefore), { timeout: REACT_TEST_TIMEOUT }); - }); + // 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: REACT_TEST_TIMEOUT }); - 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: REACT_TEST_TIMEOUT }); - await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: REACT_TEST_TIMEOUT }); - 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', () => { @@ -429,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: REACT_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: REACT_TEST_TIMEOUT }, - ); + 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, + ); }); });