Skip to content

Commit 1f827f4

Browse files
authored
fix(react): stabilize user/users/modules props to prevent re-init flicker (SD-2635) (#2876)
* fix(react): stabilize user/users/modules props to prevent re-init flicker (SD-2635) Consumers passing inline object literals (the idiomatic React pattern) caused a full SuperDoc destroy + rebuild on every parent re-render because the main useEffect compared these props by reference identity. When a consumer stored the SuperDoc instance in state from onReady, the resulting re-render supplied fresh references and triggered another cycle — observed as 4 full destroy/re-init cycles in ~100ms with visible display:none/block flicker. Wrap user, users, and modules in a new useStableValue hook that returns a reference-stable version only changing identity when the structural content actually changes (via JSON.stringify compare, run only on reference-change). Semantics are strictly a superset of the prior behavior — value changes still rebuild; reference-only changes no longer do. * fix(react): drop modules from structural memo; harden tests (SD-2635 review round 2) Review feedback addressed: - Correctness: `modules` carries function-valued options (`permissionResolver`, `popoverResolver`) and live objects (`collaboration.ydoc`/`provider`) that JSON.stringify silently drops or collapses. Keeping it on structural compare would silently ignore real config changes. Reverted to reference identity for `modules`; `user` and `users` stay memoized since they are plain data. - Naming: renamed `useStableValue` → `useStructuralMemo` to match the useMemo family and signal non-referential equality. - JSDoc: expanded the hook's docblock to spell out every JSON.stringify footgun consumers need to know about (functions dropped, class instances collapsed, undefined values dropped, NaN/Infinity → null, circular refs, key-insertion-order sensitivity). - Tests: replaced the brittle `setTimeout(100)` negative assertion with a synchronous `ref.getInstance()` identity check; strengthened the "rebuilds on change" test to also assert a second onReady + a fresh instance; added a `users`-prop stability test; added a StrictMode + rerender test to guard the ref-write-during-render path. * fix(react): define event types explicitly to unblock CI type-check (SD-2635) CI type-check failed with `Property 'onEditorUpdate' does not exist on type 'Config'` even though the JSDoc `Config` typedef in superdoc clearly declares it. The old approach derived SuperDocEditorUpdateEvent and SuperDocTransactionEvent via `Parameters<NonNullable<SuperDocConstructorConfig['onEditorUpdate']>>[0]`, which walked a chain: ConstructorParameters<typeof SuperDoc>[0] → @param {Config} in core/SuperDoc.js → @typedef Config in core/types/index.js → @Property onEditorUpdate with @typedef EditorUpdateEvent This chain resolves fine locally but breaks on CI — the exact failure point in JSDoc resolution depends on TS version, moduleResolution mode, and the `customConditions: ["source"]` in tsconfig.base.json (which routes imports to the raw .js source instead of the built .d.ts). Define the two event shapes inline instead, mirroring superdoc's JSDoc. No behavior change for consumers — same fields, same optionality. * fix(react): round-4 review feedback — lock modules contract, restore transaction: any (SD-2635) Addressing consensus findings from the round-3 review: - Revert `transaction: unknown` back to `any` to match superdoc's upstream typedef. `unknown` was a narrowing from `any` that would have broken existing consumer code like `event.transaction.docChanged`. - Re-export `EditorSurface` from the package barrel. It's referenced by two public event interfaces but wasn't exported, so consumers couldn't name the `surface` field's type. - Symmetrize per-field JSDoc on `SuperDocTransactionEvent` to match its sibling `SuperDocEditorUpdateEvent`. - Add a regression test asserting that passing a new `modules` object with identical content DOES rebuild the editor. This pins the contract that `modules` stays on reference identity (it can carry functions and live objects that structural compare misses) — a future "cleanup" that wraps `modules` in useStructuralMemo would silently re-introduce the SD-2635 blocker without this test. Also trim commentary added during rounds 1-3: the stabilization rationale was documented twice in SuperDocEditor.tsx (once at the destructure, once near the dep array), and the types.ts docstrings leaked maintainer build-tooling rationale into consumer IDE hovers. * refactor(react): swap JSON.stringify compare for lodash.isequal; drop TransactionEvent (SD-2635) - Swap the hand-rolled JSON.stringify-based structural compare for `lodash.isequal`. +10KB raw / +3.7KB gzipped cost, but removes the 5-bullet footgun list from JSDoc and gets proper handling of Dates, Maps, circular refs, and key ordering for free. For our use (stabilizing `user`/`users` plain-data records) the 3.7KB buys nothing practical, but it removes ~40 lines of hand-maintained code and a bag of edge cases. Trade maintenance cost for bundle cost. - Rename `useStructuralMemo` → `useMemoByValue`. Plainer, matches the useMemo family, says what it does without jargon. - Drop `SuperDocTransactionEvent` from the public API. It was exported but never wired up to a callback prop in `CallbackProps`, so nothing fires it. Its shape also leaked ProseMirror's `transaction` object — a deprecated surface per superdoc's own notes. Removing it now is cheaper than removing it after someone starts relying on it. - Replace the JSON-stringify-specific unit tests with tests that exercise what lodash.isequal actually gives us (key-order insensitivity, same-reference function equality). 27/27 tests pass. * refactor(react): drop lodash.isequal dep, inline JSON.stringify compare (SD-2635) Round 5 added lodash.isequal for `useMemoByValue` correctness at the cost of +10KB raw / +3.7KB gzipped. For the two props actually using the hook (`user` and `users` — small plain-data records) the extra correctness buys nothing practical: those records contain strings only, no Dates, no Maps, no circular refs, no functions. Revert to an inline JSON.stringify compare wrapped in a `try/catch` for circular refs. The hook is now ~15 lines, zero dependencies, and the unit test that required lodash (key-order insensitivity) is replaced by a circular-ref fallback test that matches the implementation. Bundle is back to 3.69 KB / 1.60 KB gzipped. * test(react): cover StrictMode + user prop value change (SD-2635) The existing StrictMode test only proved same-content rerender stays stable — the positive path (real value change under StrictMode still triggers destroy + fresh onReady) wasn't exercised. Coverage audit after rounds 2-6 flagged this as the one gap worth closing before ship. Mirrors the existing non-StrictMode rebuild test, wrapped in <StrictMode>.
1 parent 7142f4e commit 1f827f4

7 files changed

Lines changed: 385 additions & 167 deletions

File tree

packages/react/src/SuperDocEditor.test.tsx

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,213 @@ describe('SuperDocEditor', () => {
175175
});
176176
});
177177

178+
describe('prop stability (SD-2635)', () => {
179+
it('does not destroy/re-init when user prop is a new object literal with identical content', async () => {
180+
const ref = createRef<SuperDocRef>();
181+
const onReady = vi.fn();
182+
const onEditorDestroy = vi.fn();
183+
184+
const { rerender } = render(
185+
<SuperDocEditor
186+
ref={ref}
187+
user={{ name: 'Alex', email: 'alex@example.com' }}
188+
onReady={onReady}
189+
onEditorDestroy={onEditorDestroy}
190+
/>,
191+
);
192+
193+
await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
194+
const instanceBefore = ref.current?.getInstance();
195+
expect(instanceBefore).toBeTruthy();
196+
197+
// Re-render with a *new* object literal carrying the same content —
198+
// this is the idiomatic React pattern that used to trigger a full
199+
// destroy + re-init loop before SD-2635.
200+
rerender(
201+
<SuperDocEditor
202+
ref={ref}
203+
user={{ name: 'Alex', email: 'alex@example.com' }}
204+
onReady={onReady}
205+
onEditorDestroy={onEditorDestroy}
206+
/>,
207+
);
208+
209+
// Same underlying instance proves no destroy+rebuild happened.
210+
expect(ref.current?.getInstance()).toBe(instanceBefore);
211+
expect(onEditorDestroy).not.toHaveBeenCalled();
212+
});
213+
214+
it('does not destroy/re-init when users prop is a new array literal with identical content', async () => {
215+
const ref = createRef<SuperDocRef>();
216+
const onReady = vi.fn();
217+
const onEditorDestroy = vi.fn();
218+
219+
const { rerender } = render(
220+
<SuperDocEditor
221+
ref={ref}
222+
users={[{ name: 'Alex', email: 'alex@example.com' }]}
223+
onReady={onReady}
224+
onEditorDestroy={onEditorDestroy}
225+
/>,
226+
);
227+
228+
await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
229+
const instanceBefore = ref.current?.getInstance();
230+
231+
rerender(
232+
<SuperDocEditor
233+
ref={ref}
234+
users={[{ name: 'Alex', email: 'alex@example.com' }]}
235+
onReady={onReady}
236+
onEditorDestroy={onEditorDestroy}
237+
/>,
238+
);
239+
240+
expect(ref.current?.getInstance()).toBe(instanceBefore);
241+
expect(onEditorDestroy).not.toHaveBeenCalled();
242+
});
243+
244+
it('rebuilds and remounts a new instance when user prop value actually changes', async () => {
245+
const ref = createRef<SuperDocRef>();
246+
const onReady = vi.fn();
247+
const onEditorDestroy = vi.fn();
248+
249+
const { rerender } = render(
250+
<SuperDocEditor
251+
ref={ref}
252+
user={{ name: 'Alex', email: 'alex@example.com' }}
253+
onReady={onReady}
254+
onEditorDestroy={onEditorDestroy}
255+
/>,
256+
);
257+
258+
await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
259+
const instanceBefore = ref.current?.getInstance();
260+
261+
rerender(
262+
<SuperDocEditor
263+
ref={ref}
264+
user={{ name: 'Jamie', email: 'jamie@example.com' }}
265+
onReady={onReady}
266+
onEditorDestroy={onEditorDestroy}
267+
/>,
268+
);
269+
270+
// Old instance torn down, new instance ready.
271+
await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 });
272+
await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: 5000 });
273+
expect(ref.current?.getInstance()).not.toBe(instanceBefore);
274+
});
275+
276+
it('stays stable under StrictMode double-invocation on rerender', async () => {
277+
const ref = createRef<SuperDocRef>();
278+
const onReady = vi.fn();
279+
const onEditorDestroy = vi.fn();
280+
281+
const { rerender } = render(
282+
<StrictMode>
283+
<SuperDocEditor
284+
ref={ref}
285+
user={{ name: 'Alex', email: 'alex@example.com' }}
286+
onReady={onReady}
287+
onEditorDestroy={onEditorDestroy}
288+
/>
289+
</StrictMode>,
290+
);
291+
292+
await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
293+
const instanceBefore = ref.current?.getInstance();
294+
const destroysBefore = onEditorDestroy.mock.calls.length;
295+
296+
rerender(
297+
<StrictMode>
298+
<SuperDocEditor
299+
ref={ref}
300+
user={{ name: 'Alex', email: 'alex@example.com' }}
301+
onReady={onReady}
302+
onEditorDestroy={onEditorDestroy}
303+
/>
304+
</StrictMode>,
305+
);
306+
307+
expect(ref.current?.getInstance()).toBe(instanceBefore);
308+
expect(onEditorDestroy.mock.calls.length).toBe(destroysBefore);
309+
});
310+
311+
it('still rebuilds under StrictMode when user prop value actually changes', async () => {
312+
// The same-content StrictMode test above proves memoization survives
313+
// double-invocation. This test proves the positive path — a real
314+
// value change under StrictMode still tears down and remounts.
315+
const ref = createRef<SuperDocRef>();
316+
const onReady = vi.fn();
317+
const onEditorDestroy = vi.fn();
318+
319+
const { rerender } = render(
320+
<StrictMode>
321+
<SuperDocEditor
322+
ref={ref}
323+
user={{ name: 'Alex', email: 'alex@example.com' }}
324+
onReady={onReady}
325+
onEditorDestroy={onEditorDestroy}
326+
/>
327+
</StrictMode>,
328+
);
329+
330+
await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
331+
const instanceBefore = ref.current?.getInstance();
332+
333+
rerender(
334+
<StrictMode>
335+
<SuperDocEditor
336+
ref={ref}
337+
user={{ name: 'Jamie', email: 'jamie@example.com' }}
338+
onReady={onReady}
339+
onEditorDestroy={onEditorDestroy}
340+
/>
341+
</StrictMode>,
342+
);
343+
344+
await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 });
345+
await waitFor(() => expect(ref.current?.getInstance()).not.toBe(instanceBefore), { timeout: 5000 });
346+
});
347+
348+
it('rebuilds when a new modules object is passed, even if content looks equal', async () => {
349+
// `modules` is intentionally kept on reference identity in the dep
350+
// array because it can carry functions and live objects that a
351+
// structural compare would miss. This test pins that contract —
352+
// if a future refactor wraps `modules` in useStructuralMemo, this
353+
// test will fail and flag the regression.
354+
const ref = createRef<SuperDocRef>();
355+
const onReady = vi.fn();
356+
const onEditorDestroy = vi.fn();
357+
358+
const { rerender } = render(
359+
<SuperDocEditor
360+
ref={ref}
361+
modules={{ comments: { visible: true } }}
362+
onReady={onReady}
363+
onEditorDestroy={onEditorDestroy}
364+
/>,
365+
);
366+
367+
await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
368+
const instanceBefore = ref.current?.getInstance();
369+
370+
rerender(
371+
<SuperDocEditor
372+
ref={ref}
373+
modules={{ comments: { visible: true } }}
374+
onReady={onReady}
375+
onEditorDestroy={onEditorDestroy}
376+
/>,
377+
);
378+
379+
await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 });
380+
await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: 5000 });
381+
expect(ref.current?.getInstance()).not.toBe(instanceBefore);
382+
});
383+
});
384+
178385
describe('unique IDs', () => {
179386
it('should generate unique container IDs for multiple instances', () => {
180387
const { container: container1 } = render(<SuperDocEditor />);

packages/react/src/SuperDocEditor.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type CSSProperties,
88
type ForwardedRef,
99
} from 'react';
10-
import { useStableId } from './utils';
10+
import { useStableId, useMemoByValue } from './utils';
1111
import type {
1212
CallbackProps,
1313
DocumentMode,
@@ -50,8 +50,8 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef<Super
5050
onException,
5151
// Key props that trigger rebuild when changed
5252
document: documentProp,
53-
user,
54-
users,
53+
user: userProp,
54+
users: usersProp,
5555
modules,
5656
// All other props passed through
5757
...restProps
@@ -61,6 +61,13 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef<Super
6161
const documentMode = props.documentMode ?? 'editing';
6262
const role = props.role ?? 'editor';
6363

64+
// `user` and `users` are memoized by value so inline literals don't
65+
// trigger a rebuild. `modules` stays on reference identity — it can
66+
// carry functions and live objects (e.g. `collaboration.provider`)
67+
// that a consumer may intentionally swap. See SD-2635.
68+
const user = useMemoByValue(userProp);
69+
const users = useMemoByValue(usersProp);
70+
6471
const instanceRef = useRef<SuperDocInstance | null>(null);
6572
const toolbarContainerRef = useRef<HTMLDivElement | null>(null);
6673

@@ -223,8 +230,8 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef<Super
223230
destroyed = true;
224231
};
225232
// Only these props trigger a full rebuild. Other props (rulers, etc.) are
226-
// initial values - use getInstance() methods to change them at runtime.
227-
// Note: restProps is intentionally excluded to avoid rebuilds on every render.
233+
// initial values use getInstance() methods to change them at runtime.
234+
// restProps is intentionally excluded to avoid rebuilds on every render.
228235
// documentMode is handled separately via setDocumentMode() for efficiency.
229236
}, [documentProp, user, users, modules, role, hideToolbar, contained, containerId, toolbarId]);
230237

packages/react/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ export type {
2020

2121
// Callback event types
2222
Editor,
23+
EditorSurface,
2324
SuperDocReadyEvent,
2425
SuperDocEditorCreateEvent,
2526
SuperDocEditorUpdateEvent,
26-
SuperDocTransactionEvent,
2727
SuperDocContentErrorEvent,
2828
SuperDocExceptionEvent,
2929
} from './types';

packages/react/src/types.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,22 @@ export interface SuperDocEditorCreateEvent {
5050
editor: Editor;
5151
}
5252

53-
/** Event passed to onEditorUpdate callback */
54-
export type SuperDocEditorUpdateEvent = Parameters<NonNullable<SuperDocConstructorConfig['onEditorUpdate']>>[0];
53+
/** Surface where an editor event originated. */
54+
export type EditorSurface = 'body' | 'header' | 'footer';
5555

56-
/** Event passed to onTransaction callback */
57-
export type SuperDocTransactionEvent = Parameters<NonNullable<SuperDocConstructorConfig['onTransaction']>>[0];
56+
/** Event passed to onEditorUpdate callback. Mirrors superdoc's EditorUpdateEvent. */
57+
export interface SuperDocEditorUpdateEvent {
58+
/** The primary editor associated with the update. For header/footer edits, this is the main body editor. */
59+
editor: Editor;
60+
/** The editor instance that emitted the update. For body edits, this matches `editor`. */
61+
sourceEditor: Editor;
62+
/** The surface where the edit originated. */
63+
surface: EditorSurface;
64+
/** Relationship ID for header/footer edits. */
65+
headerId?: string | null;
66+
/** Header/footer variant (`default`, `first`, `even`, `odd`) when available. */
67+
sectionType?: string | null;
68+
}
5869

5970
/** Event passed to onContentError callback */
6071
export interface SuperDocContentErrorEvent {

packages/react/src/utils.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { renderHook } from '@testing-library/react';
3+
import { useMemoByValue } from './utils';
4+
5+
describe('useMemoByValue', () => {
6+
it('returns the same reference across renders when content is unchanged', () => {
7+
const initial = { name: 'Alex', email: 'alex@example.com' };
8+
const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), {
9+
initialProps: { value: initial },
10+
});
11+
12+
const first = result.current;
13+
expect(first).toBe(initial);
14+
15+
// Parent passes a fresh object literal with identical content
16+
rerender({ value: { name: 'Alex', email: 'alex@example.com' } });
17+
expect(result.current).toBe(first); // same reference — critical for effect deps
18+
19+
// And again, still stable
20+
rerender({ value: { name: 'Alex', email: 'alex@example.com' } });
21+
expect(result.current).toBe(first);
22+
});
23+
24+
it('returns a new reference when the content actually changes', () => {
25+
const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), {
26+
initialProps: { value: { name: 'Alex' } },
27+
});
28+
29+
const first = result.current;
30+
rerender({ value: { name: 'Jamie' } });
31+
expect(result.current).not.toBe(first);
32+
expect(result.current.name).toBe('Jamie');
33+
});
34+
35+
it('handles undefined and null stably', () => {
36+
const { result, rerender } = renderHook(({ value }) => useMemoByValue(value as unknown), {
37+
initialProps: { value: undefined },
38+
});
39+
40+
const first = result.current;
41+
rerender({ value: undefined });
42+
expect(result.current).toBe(first);
43+
44+
rerender({ value: null });
45+
expect(result.current).toBe(null);
46+
});
47+
48+
it('stabilizes arrays the same way as objects', () => {
49+
const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), {
50+
initialProps: { value: [{ id: 1 }, { id: 2 }] },
51+
});
52+
53+
const first = result.current;
54+
rerender({ value: [{ id: 1 }, { id: 2 }] });
55+
expect(result.current).toBe(first);
56+
57+
rerender({ value: [{ id: 1 }, { id: 3 }] });
58+
expect(result.current).not.toBe(first);
59+
});
60+
61+
it('adopts a new reference on circular input (JSON.stringify throws)', () => {
62+
const circularA: { self?: unknown; name: string } = { name: 'a' };
63+
circularA.self = circularA;
64+
65+
const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), {
66+
initialProps: { value: circularA },
67+
});
68+
69+
const circularB: { self?: unknown; name: string } = { name: 'a' };
70+
circularB.self = circularB;
71+
rerender({ value: circularB });
72+
// The compare throws; the hook falls back to adopting the new reference.
73+
expect(result.current).toBe(circularB);
74+
});
75+
});

0 commit comments

Comments
 (0)