From 0ed6375ad37f9d6511fab8e8aa4dd2c81cb9f9cd Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 18:11:36 -0300 Subject: [PATCH 1/7] fix(react): stabilize user/users/modules props to prevent re-init flicker (SD-2635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/react/src/SuperDocEditor.test.tsx | 60 +++++++++++++++++ packages/react/src/SuperDocEditor.tsx | 17 +++-- packages/react/src/utils.test.ts | 75 ++++++++++++++++++++++ packages/react/src/utils.ts | 42 ++++++++++++ 4 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 packages/react/src/utils.test.ts diff --git a/packages/react/src/SuperDocEditor.test.tsx b/packages/react/src/SuperDocEditor.test.tsx index 6c469273ea..4c107a4969 100644 --- a/packages/react/src/SuperDocEditor.test.tsx +++ b/packages/react/src/SuperDocEditor.test.tsx @@ -175,6 +175,66 @@ describe('SuperDocEditor', () => { }); }); + describe('prop stability (SD-2635)', () => { + it('does not destroy/re-init when user prop is passed as a new object literal with identical content', async () => { + const onReady = vi.fn(); + const onEditorDestroy = vi.fn(); + + const { rerender } = render( + , + ); + + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 }); + const readyCallsAfterMount = onReady.mock.calls.length; + + // 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( + , + ); + + // Give any spurious effects time to run + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(onEditorDestroy).not.toHaveBeenCalled(); + expect(onReady.mock.calls.length).toBe(readyCallsAfterMount); + }); + + it('rebuilds when user prop value actually changes', async () => { + const onReady = vi.fn(); + const onEditorDestroy = vi.fn(); + + const { rerender } = render( + , + ); + + await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 }); + + rerender( + , + ); + + await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 }); + }); + }); + describe('unique IDs', () => { it('should generate unique container IDs for multiple instances', () => { const { container: container1 } = render(); diff --git a/packages/react/src/SuperDocEditor.tsx b/packages/react/src/SuperDocEditor.tsx index 33662a5555..58b69c9cb4 100644 --- a/packages/react/src/SuperDocEditor.tsx +++ b/packages/react/src/SuperDocEditor.tsx @@ -7,7 +7,7 @@ import { type CSSProperties, type ForwardedRef, } from 'react'; -import { useStableId } from './utils'; +import { useStableId, useStableValue } from './utils'; import type { CallbackProps, DocumentMode, @@ -50,9 +50,9 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef(null); const toolbarContainerRef = useRef(null); @@ -226,6 +233,8 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef { + it('returns the same reference across renders when content is unchanged', () => { + const initial = { name: 'Alex', email: 'alex@example.com' }; + const { result, rerender } = renderHook(({ value }) => useStableValue(value), { + initialProps: { value: initial }, + }); + + const first = result.current; + expect(first).toBe(initial); + + // Parent passes a fresh object literal with identical content + rerender({ value: { name: 'Alex', email: 'alex@example.com' } }); + expect(result.current).toBe(first); // same reference — critical for effect deps + + // And again, still stable + rerender({ value: { name: 'Alex', email: 'alex@example.com' } }); + expect(result.current).toBe(first); + }); + + it('returns a new reference when the content actually changes', () => { + const { result, rerender } = renderHook(({ value }) => useStableValue(value), { + initialProps: { value: { name: 'Alex' } }, + }); + + const first = result.current; + rerender({ value: { name: 'Jamie' } }); + expect(result.current).not.toBe(first); + expect(result.current.name).toBe('Jamie'); + }); + + it('handles undefined and null stably', () => { + const { result, rerender } = renderHook(({ value }) => useStableValue(value as unknown), { + initialProps: { value: undefined }, + }); + + const first = result.current; + rerender({ value: undefined }); + expect(result.current).toBe(first); + + rerender({ value: null }); + expect(result.current).toBe(null); + }); + + it('stabilizes arrays the same way as objects', () => { + const { result, rerender } = renderHook(({ value }) => useStableValue(value), { + initialProps: { value: [{ id: 1 }, { id: 2 }] }, + }); + + const first = result.current; + rerender({ value: [{ id: 1 }, { id: 2 }] }); + expect(result.current).toBe(first); + + rerender({ value: [{ id: 1 }, { id: 3 }] }); + expect(result.current).not.toBe(first); + }); + + it('falls back gracefully on circular references (treats as changed)', () => { + const circularA: { self?: unknown; name: string } = { name: 'a' }; + circularA.self = circularA; + + const { result, rerender } = renderHook(({ value }) => useStableValue(value), { + initialProps: { value: circularA }, + }); + + const circularB: { self?: unknown; name: string } = { name: 'a' }; + circularB.self = circularB; + rerender({ value: circularB }); + // Can't compare circular refs structurally — the new reference is adopted. + expect(result.current).toBe(circularB); + }); +}); diff --git a/packages/react/src/utils.ts b/packages/react/src/utils.ts index 6162d2b8f1..a5faf7258d 100644 --- a/packages/react/src/utils.ts +++ b/packages/react/src/utils.ts @@ -25,3 +25,45 @@ function useIdPolyfill(): string { */ export const useStableId: () => string = typeof (React as any).useId === 'function' ? (React as any).useId : useIdPolyfill; + +/** + * Returns a reference-stable version of `value` that only changes identity + * when the structural content changes (compared via JSON.stringify). + * + * Use for object/array props that feed into `useEffect` / `useMemo` + * dependency arrays where the consumer is likely to pass inline object + * literals. Without this, every parent re-render produces a fresh + * reference and causes the effect to re-run even when the content is + * identical. + * + * Limitations: + * - Values containing functions, Dates, Maps, Sets, or circular references + * are compared imperfectly (functions are dropped by JSON.stringify; + * circular refs fall through to "different"). For config-shaped props + * (plain data) this is sufficient. + * - The structural compare only runs when the incoming reference differs + * from the previous one, so steady-state cost is a single pointer check. + */ +export function useStableValue(value: T): T { + const lastRawRef = React.useRef(value); + const stableRef = React.useRef(value); + + if (lastRawRef.current !== value) { + if (!structurallyEqual(stableRef.current, value)) { + stableRef.current = value; + } + lastRawRef.current = value; + } + + return stableRef.current; +} + +function structurallyEqual(a: T, b: T): boolean { + if (a === b) return true; + if (a == null || b == null) return a === b; + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} From 5a37fc4d05b01350db224689b2f349faf113df90 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 21 Apr 2026 06:44:26 -0300 Subject: [PATCH 2/7] fix(react): drop modules from structural memo; harden tests (SD-2635 review round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/react/src/SuperDocEditor.test.tsx | 86 ++++++++++++++++++++-- packages/react/src/SuperDocEditor.tsx | 23 ++++-- packages/react/src/utils.test.ts | 14 ++-- packages/react/src/utils.ts | 41 +++++++---- 4 files changed, 130 insertions(+), 34 deletions(-) diff --git a/packages/react/src/SuperDocEditor.test.tsx b/packages/react/src/SuperDocEditor.test.tsx index 4c107a4969..7c1bf2b192 100644 --- a/packages/react/src/SuperDocEditor.test.tsx +++ b/packages/react/src/SuperDocEditor.test.tsx @@ -176,12 +176,14 @@ describe('SuperDocEditor', () => { }); describe('prop stability (SD-2635)', () => { - it('does not destroy/re-init when user prop is passed as a new object literal with identical content', async () => { + 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 readyCallsAfterMount = onReady.mock.calls.length; + 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( , ); - // Give any spurious effects time to run - await new Promise((resolve) => setTimeout(resolve, 100)); + // 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(); - expect(onReady.mock.calls.length).toBe(readyCallsAfterMount); }); - it('rebuilds when user prop value actually changes', async () => { + 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('stays stable under StrictMode double-invocation on rerender', 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(); + const destroysBefore = onEditorDestroy.mock.calls.length; + + rerender( + + + , + ); + + expect(ref.current?.getInstance()).toBe(instanceBefore); + expect(onEditorDestroy.mock.calls.length).toBe(destroysBefore); }); }); diff --git a/packages/react/src/SuperDocEditor.tsx b/packages/react/src/SuperDocEditor.tsx index 58b69c9cb4..4dcd2b6f3d 100644 --- a/packages/react/src/SuperDocEditor.tsx +++ b/packages/react/src/SuperDocEditor.tsx @@ -7,7 +7,7 @@ import { type CSSProperties, type ForwardedRef, } from 'react'; -import { useStableId, useStableValue } from './utils'; +import { useStableId, useStructuralMemo } from './utils'; import type { CallbackProps, DocumentMode, @@ -52,7 +52,7 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef(null); const toolbarContainerRef = useRef(null); @@ -233,8 +238,10 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef { +describe('useStructuralMemo', () => { it('returns the same reference across renders when content is unchanged', () => { const initial = { name: 'Alex', email: 'alex@example.com' }; - const { result, rerender } = renderHook(({ value }) => useStableValue(value), { + const { result, rerender } = renderHook(({ value }) => useStructuralMemo(value), { initialProps: { value: initial }, }); @@ -22,7 +22,7 @@ describe('useStableValue', () => { }); it('returns a new reference when the content actually changes', () => { - const { result, rerender } = renderHook(({ value }) => useStableValue(value), { + const { result, rerender } = renderHook(({ value }) => useStructuralMemo(value), { initialProps: { value: { name: 'Alex' } }, }); @@ -33,7 +33,7 @@ describe('useStableValue', () => { }); it('handles undefined and null stably', () => { - const { result, rerender } = renderHook(({ value }) => useStableValue(value as unknown), { + const { result, rerender } = renderHook(({ value }) => useStructuralMemo(value as unknown), { initialProps: { value: undefined }, }); @@ -46,7 +46,7 @@ describe('useStableValue', () => { }); it('stabilizes arrays the same way as objects', () => { - const { result, rerender } = renderHook(({ value }) => useStableValue(value), { + const { result, rerender } = renderHook(({ value }) => useStructuralMemo(value), { initialProps: { value: [{ id: 1 }, { id: 2 }] }, }); @@ -62,7 +62,7 @@ describe('useStableValue', () => { const circularA: { self?: unknown; name: string } = { name: 'a' }; circularA.self = circularA; - const { result, rerender } = renderHook(({ value }) => useStableValue(value), { + const { result, rerender } = renderHook(({ value }) => useStructuralMemo(value), { initialProps: { value: circularA }, }); diff --git a/packages/react/src/utils.ts b/packages/react/src/utils.ts index a5faf7258d..793afe7c96 100644 --- a/packages/react/src/utils.ts +++ b/packages/react/src/utils.ts @@ -28,23 +28,38 @@ export const useStableId: () => string = /** * Returns a reference-stable version of `value` that only changes identity - * when the structural content changes (compared via JSON.stringify). + * when the structural content changes (compared via `JSON.stringify`). * * Use for object/array props that feed into `useEffect` / `useMemo` - * dependency arrays where the consumer is likely to pass inline object - * literals. Without this, every parent re-render produces a fresh - * reference and causes the effect to re-run even when the content is - * identical. + * dependency arrays when the consumer is likely to pass inline literals. + * Without this, every parent re-render produces a fresh reference and + * causes the effect to re-run even when the content is identical. * - * Limitations: - * - Values containing functions, Dates, Maps, Sets, or circular references - * are compared imperfectly (functions are dropped by JSON.stringify; - * circular refs fall through to "different"). For config-shaped props - * (plain data) this is sufficient. - * - The structural compare only runs when the incoming reference differs - * from the previous one, so steady-state cost is a single pointer check. + * **Intended only for plain-data values.** `JSON.stringify` has well-known + * limitations that make this hook unsuitable for values containing: + * + * - **Functions** — silently dropped during serialization, so a change + * to a callback-valued property is treated as "equal" and ignored. + * - **Class instances / live objects** (e.g. Yjs Doc, DOM nodes, Maps, + * Sets, Dates) — serialize to `{}` or to identical strings across + * distinct instances, so swaps are missed. + * - **`undefined` property values** — dropped (`{a: undefined}` → `"{}"`), + * so `{a: undefined, b: 1}` and `{b: 1}` compare equal. + * - **`NaN` / `Infinity` / `-Infinity`** — serialize to `null`, collapsing + * distinct numeric values. + * - **Circular references** — throw; the hook falls back to adopting the + * new reference (treated as "different"). + * - **Key insertion order** — `JSON.stringify({a:1, b:2}) !== + * JSON.stringify({b:2, a:1})`. Content-equal objects assembled via + * spreads or conditional keys can still be classified as different + * (false negative — triggers a rebuild that wasn't needed, no + * correctness impact). + * + * The structural compare only runs when the incoming reference differs + * from the previous one, so the steady-state cost is a single pointer + * check. */ -export function useStableValue(value: T): T { +export function useStructuralMemo(value: T): T { const lastRawRef = React.useRef(value); const stableRef = React.useRef(value); From c38a8324e8d3636a052626cdd486ae33385562f6 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 21 Apr 2026 06:55:04 -0300 Subject: [PATCH 3/7] fix(react): define event types explicitly to unblock CI type-check (SD-2635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>[0]`, which walked a chain: ConstructorParameters[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. --- packages/react/src/types.ts | 40 +++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 1e598e6fea..5baa9bff36 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -50,11 +50,43 @@ export interface SuperDocEditorCreateEvent { editor: Editor; } -/** Event passed to onEditorUpdate callback */ -export type SuperDocEditorUpdateEvent = Parameters>[0]; +/** Surface where an editor event originated. Mirrors superdoc's EditorSurface. */ +export type EditorSurface = 'body' | 'header' | 'footer'; -/** Event passed to onTransaction callback */ -export type SuperDocTransactionEvent = Parameters>[0]; +/** + * Event passed to onEditorUpdate callback. + * Mirrors superdoc's EditorUpdateEvent typedef — defined here explicitly so + * this package's types don't depend on JSDoc chain resolution across + * workspace packages (which is fragile across TypeScript versions). + */ +export interface SuperDocEditorUpdateEvent { + /** The primary editor associated with the update. For header/footer edits, this is the main body editor. */ + editor: Editor; + /** The editor instance that emitted the update. For body edits, this matches `editor`. */ + sourceEditor: Editor; + /** The surface where the edit originated. */ + surface: EditorSurface; + /** Relationship ID for header/footer edits. */ + headerId?: string | null; + /** Header/footer variant (`default`, `first`, `even`, `odd`) when available. */ + sectionType?: string | null; +} + +/** + * Event passed to onTransaction callback. + * Mirrors superdoc's EditorTransactionEvent typedef — see note on SuperDocEditorUpdateEvent. + */ +export interface SuperDocTransactionEvent { + editor: Editor; + sourceEditor: Editor; + /** The ProseMirror transaction or transaction-like payload emitted by the source editor. */ + transaction: unknown; + /** Time spent applying the transaction, in milliseconds. */ + duration?: number; + surface: EditorSurface; + headerId?: string | null; + sectionType?: string | null; +} /** Event passed to onContentError callback */ export interface SuperDocContentErrorEvent { From 0cb7d3e60cf11a9c2c0e8cbf78d7aa45703efe30 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 21 Apr 2026 07:14:39 -0300 Subject: [PATCH 4/7] =?UTF-8?q?fix(react):=20round-4=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20lock=20modules=20contract,=20restore=20transacti?= =?UTF-8?q?on:=20any=20(SD-2635)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/react/src/SuperDocEditor.test.tsx | 36 ++++++++++++++++++++++ packages/react/src/SuperDocEditor.tsx | 21 ++++--------- packages/react/src/index.ts | 1 + packages/react/src/types.ts | 27 ++++++++-------- 4 files changed, 57 insertions(+), 28 deletions(-) diff --git a/packages/react/src/SuperDocEditor.test.tsx b/packages/react/src/SuperDocEditor.test.tsx index 7c1bf2b192..59443d240c 100644 --- a/packages/react/src/SuperDocEditor.test.tsx +++ b/packages/react/src/SuperDocEditor.test.tsx @@ -307,6 +307,42 @@ describe('SuperDocEditor', () => { expect(ref.current?.getInstance()).toBe(instanceBefore); expect(onEditorDestroy.mock.calls.length).toBe(destroysBefore); }); + + 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: 5000 }); + const instanceBefore = ref.current?.getInstance(); + + rerender( + , + ); + + await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 }); + await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: 5000 }); + expect(ref.current?.getInstance()).not.toBe(instanceBefore); + }); }); describe('unique IDs', () => { diff --git a/packages/react/src/SuperDocEditor.tsx b/packages/react/src/SuperDocEditor.tsx index 4dcd2b6f3d..21738721f6 100644 --- a/packages/react/src/SuperDocEditor.tsx +++ b/packages/react/src/SuperDocEditor.tsx @@ -61,15 +61,10 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef Date: Tue, 21 Apr 2026 07:36:07 -0300 Subject: [PATCH 5/7] refactor(react): swap JSON.stringify compare for lodash.isequal; drop TransactionEvent (SD-2635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- packages/react/package.json | 2 + packages/react/src/SuperDocEditor.tsx | 12 +- packages/react/src/index.ts | 1 - packages/react/src/types.ts | 22 --- packages/react/src/utils.test.ts | 42 +++-- packages/react/src/utils.ts | 44 +----- pnpm-lock.yaml | 217 +++++++------------------- 7 files changed, 101 insertions(+), 239 deletions(-) diff --git a/packages/react/package.json b/packages/react/package.json index 5acbc31285..bbb76e6059 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -35,6 +35,7 @@ ], "license": "AGPL-3.0", "dependencies": { + "lodash.isequal": "^4.5.0", "superdoc": ">=1.0.0" }, "peerDependencies": { @@ -43,6 +44,7 @@ }, "devDependencies": { "@testing-library/react": "catalog:", + "@types/lodash.isequal": "^4.5.8", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/packages/react/src/SuperDocEditor.tsx b/packages/react/src/SuperDocEditor.tsx index 21738721f6..b215bdacc0 100644 --- a/packages/react/src/SuperDocEditor.tsx +++ b/packages/react/src/SuperDocEditor.tsx @@ -7,7 +7,7 @@ import { type CSSProperties, type ForwardedRef, } from 'react'; -import { useStableId, useStructuralMemo } from './utils'; +import { useStableId, useMemoByValue } from './utils'; import type { CallbackProps, DocumentMode, @@ -62,11 +62,11 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef(null); const toolbarContainerRef = useRef(null); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index eba3573a70..dafad66ad4 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -24,7 +24,6 @@ export type { SuperDocReadyEvent, SuperDocEditorCreateEvent, SuperDocEditorUpdateEvent, - SuperDocTransactionEvent, SuperDocContentErrorEvent, SuperDocExceptionEvent, } from './types'; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 9933276ec8..dfb96a27ce 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -67,28 +67,6 @@ export interface SuperDocEditorUpdateEvent { sectionType?: string | null; } -/** Event passed to onTransaction callback. Mirrors superdoc's EditorTransactionEvent. */ -export interface SuperDocTransactionEvent { - /** The primary editor associated with the transaction. For header/footer edits, this is the main body editor. */ - 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. - * Typed as `any` to match superdoc's upstream typedef and avoid narrowing existing consumers. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - transaction: any; - /** Time spent applying the transaction, in milliseconds. */ - duration?: number; - /** The surface where the transaction originated. */ - surface: EditorSurface; - /** Relationship ID for header/footer edits. */ - headerId?: string | null; - /** Header/footer variant (`default`, `first`, `even`, `odd`) when available. */ - sectionType?: string | null; -} - /** Event passed to onContentError callback */ export interface SuperDocContentErrorEvent { error: Error; diff --git a/packages/react/src/utils.test.ts b/packages/react/src/utils.test.ts index b8b30af9b9..188daa6592 100644 --- a/packages/react/src/utils.test.ts +++ b/packages/react/src/utils.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from 'vitest'; import { renderHook } from '@testing-library/react'; -import { useStructuralMemo } from './utils'; +import { useMemoByValue } from './utils'; -describe('useStructuralMemo', () => { +describe('useMemoByValue', () => { it('returns the same reference across renders when content is unchanged', () => { const initial = { name: 'Alex', email: 'alex@example.com' }; - const { result, rerender } = renderHook(({ value }) => useStructuralMemo(value), { + const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), { initialProps: { value: initial }, }); @@ -22,7 +22,7 @@ describe('useStructuralMemo', () => { }); it('returns a new reference when the content actually changes', () => { - const { result, rerender } = renderHook(({ value }) => useStructuralMemo(value), { + const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), { initialProps: { value: { name: 'Alex' } }, }); @@ -33,7 +33,7 @@ describe('useStructuralMemo', () => { }); it('handles undefined and null stably', () => { - const { result, rerender } = renderHook(({ value }) => useStructuralMemo(value as unknown), { + const { result, rerender } = renderHook(({ value }) => useMemoByValue(value as unknown), { initialProps: { value: undefined }, }); @@ -46,7 +46,7 @@ describe('useStructuralMemo', () => { }); it('stabilizes arrays the same way as objects', () => { - const { result, rerender } = renderHook(({ value }) => useStructuralMemo(value), { + const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), { initialProps: { value: [{ id: 1 }, { id: 2 }] }, }); @@ -58,18 +58,28 @@ describe('useStructuralMemo', () => { expect(result.current).not.toBe(first); }); - it('falls back gracefully on circular references (treats as changed)', () => { - const circularA: { self?: unknown; name: string } = { name: 'a' }; - circularA.self = circularA; + it('handles key order changes as equal (deep compare is order-insensitive)', () => { + const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), { + initialProps: { value: { a: 1, b: 2 } }, + }); - const { result, rerender } = renderHook(({ value }) => useStructuralMemo(value), { - initialProps: { value: circularA }, + const first = result.current; + rerender({ value: { b: 2, a: 1 } }); + expect(result.current).toBe(first); + }); + + it('treats values with different function identities as equal', () => { + // lodash.isequal compares functions by reference. Same-reference functions + // are equal; different-reference functions are not. We rely on the parent + // ref-check to short-circuit same-reference cases, so function equality + // only matters when the whole value object is freshly allocated. + const fn = () => 1; + const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), { + initialProps: { value: { cb: fn, n: 1 } }, }); + const first = result.current; - const circularB: { self?: unknown; name: string } = { name: 'a' }; - circularB.self = circularB; - rerender({ value: circularB }); - // Can't compare circular refs structurally — the new reference is adopted. - expect(result.current).toBe(circularB); + rerender({ value: { cb: fn, n: 1 } }); + expect(result.current).toBe(first); }); }); diff --git a/packages/react/src/utils.ts b/packages/react/src/utils.ts index 793afe7c96..6aa589c5cc 100644 --- a/packages/react/src/utils.ts +++ b/packages/react/src/utils.ts @@ -1,6 +1,7 @@ /** @module utils */ import * as React from 'react'; +import isEqual from 'lodash.isequal'; /** * Polyfill for React.useId() for React versions < 18. @@ -27,44 +28,23 @@ export const useStableId: () => string = typeof (React as any).useId === 'function' ? (React as any).useId : useIdPolyfill; /** - * Returns a reference-stable version of `value` that only changes identity - * when the structural content changes (compared via `JSON.stringify`). + * Returns a reference-stable version of `value` — identity only changes + * when the content changes (deep equality via `lodash.isequal`). * * Use for object/array props that feed into `useEffect` / `useMemo` * dependency arrays when the consumer is likely to pass inline literals. * Without this, every parent re-render produces a fresh reference and * causes the effect to re-run even when the content is identical. * - * **Intended only for plain-data values.** `JSON.stringify` has well-known - * limitations that make this hook unsuitable for values containing: - * - * - **Functions** — silently dropped during serialization, so a change - * to a callback-valued property is treated as "equal" and ignored. - * - **Class instances / live objects** (e.g. Yjs Doc, DOM nodes, Maps, - * Sets, Dates) — serialize to `{}` or to identical strings across - * distinct instances, so swaps are missed. - * - **`undefined` property values** — dropped (`{a: undefined}` → `"{}"`), - * so `{a: undefined, b: 1}` and `{b: 1}` compare equal. - * - **`NaN` / `Infinity` / `-Infinity`** — serialize to `null`, collapsing - * distinct numeric values. - * - **Circular references** — throw; the hook falls back to adopting the - * new reference (treated as "different"). - * - **Key insertion order** — `JSON.stringify({a:1, b:2}) !== - * JSON.stringify({b:2, a:1})`. Content-equal objects assembled via - * spreads or conditional keys can still be classified as different - * (false negative — triggers a rebuild that wasn't needed, no - * correctness impact). - * - * The structural compare only runs when the incoming reference differs - * from the previous one, so the steady-state cost is a single pointer - * check. + * The deep compare only runs when the incoming reference differs from + * the previous one, so the steady-state cost is a single pointer check. */ -export function useStructuralMemo(value: T): T { +export function useMemoByValue(value: T): T { const lastRawRef = React.useRef(value); const stableRef = React.useRef(value); if (lastRawRef.current !== value) { - if (!structurallyEqual(stableRef.current, value)) { + if (!isEqual(stableRef.current, value)) { stableRef.current = value; } lastRawRef.current = value; @@ -72,13 +52,3 @@ export function useStructuralMemo(value: T): T { return stableRef.current; } - -function structurallyEqual(a: T, b: T): boolean { - if (a === b) return true; - if (a == null || b == null) return a === b; - try { - return JSON.stringify(a) === JSON.stringify(b); - } catch { - return false; - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46e0c792ab..a5099411ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -549,7 +549,7 @@ importers: version: 14.0.3 mintlify: specifier: 4.2.446 - version: 4.2.446(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 4.2.446(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) remark-mdx: specifier: ^3.1.1 version: 3.1.1 @@ -2672,6 +2672,9 @@ importers: packages/react: dependencies: + lodash.isequal: + specifier: ^4.5.0 + version: 4.5.0 superdoc: specifier: workspace:* version: link:../superdoc @@ -2679,6 +2682,9 @@ importers: '@testing-library/react': specifier: 'catalog:' version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/lodash.isequal': + specifier: ^4.5.8 + version: 4.5.8 '@types/node': specifier: 'catalog:' version: 22.19.2 @@ -10463,6 +10469,12 @@ packages: '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/lodash.isequal@4.5.8': + resolution: {integrity: sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==} + + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/mdast@3.0.15': resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} @@ -16610,6 +16622,10 @@ packages: lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isinteger@4.0.4: resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} @@ -26333,16 +26349,6 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 - '@inquirer/checkbox@4.3.2(@types/node@22.19.2)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/checkbox@4.3.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26368,13 +26374,6 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 - '@inquirer/confirm@5.1.21(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/confirm@5.1.21(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26389,19 +26388,6 @@ snapshots: optionalDependencies: '@types/node': 18.19.130 - '@inquirer/core@10.3.2(@types/node@22.19.2)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) - cli-width: 4.1.0 - mute-stream: 2.0.0 - signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/core@10.3.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26468,14 +26454,6 @@ snapshots: chalk: 4.1.2 external-editor: 3.1.0 - '@inquirer/editor@4.2.23(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/external-editor': 1.0.3(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/editor@4.2.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26499,14 +26477,6 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 - '@inquirer/expand@4.0.23(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/expand@4.0.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26515,13 +26485,6 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/external-editor@1.0.3(@types/node@22.19.2)': - dependencies: - chardet: 2.1.1 - iconv-lite: 0.7.2 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/external-editor@1.0.3(@types/node@25.6.0)': dependencies: chardet: 2.1.1 @@ -26546,13 +26509,6 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 - '@inquirer/input@4.3.1(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/input@4.3.1(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26567,13 +26523,6 @@ snapshots: optionalDependencies: '@types/node': 18.19.130 - '@inquirer/number@3.0.23(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/number@3.0.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26588,14 +26537,6 @@ snapshots: ansi-escapes: 4.3.2 chalk: 4.1.2 - '@inquirer/password@4.0.23(@types/node@22.19.2)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/password@4.0.23(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26616,21 +26557,6 @@ snapshots: '@inquirer/rawlist': 1.2.16 '@inquirer/select': 1.3.3 - '@inquirer/prompts@7.10.1(@types/node@22.19.2)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.2) - '@inquirer/confirm': 5.1.21(@types/node@22.19.2) - '@inquirer/editor': 4.2.23(@types/node@22.19.2) - '@inquirer/expand': 4.0.23(@types/node@22.19.2) - '@inquirer/input': 4.3.1(@types/node@22.19.2) - '@inquirer/number': 3.0.23(@types/node@22.19.2) - '@inquirer/password': 4.0.23(@types/node@22.19.2) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.2) - '@inquirer/search': 3.2.2(@types/node@22.19.2) - '@inquirer/select': 4.4.2(@types/node@22.19.2) - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/prompts@7.10.1(@types/node@25.6.0)': dependencies: '@inquirer/checkbox': 4.3.2(@types/node@25.6.0) @@ -26646,20 +26572,20 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/prompts@7.9.0(@types/node@22.19.2)': + '@inquirer/prompts@7.9.0(@types/node@25.6.0)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.2) - '@inquirer/confirm': 5.1.21(@types/node@22.19.2) - '@inquirer/editor': 4.2.23(@types/node@22.19.2) - '@inquirer/expand': 4.0.23(@types/node@22.19.2) - '@inquirer/input': 4.3.1(@types/node@22.19.2) - '@inquirer/number': 3.0.23(@types/node@22.19.2) - '@inquirer/password': 4.0.23(@types/node@22.19.2) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.2) - '@inquirer/search': 3.2.2(@types/node@22.19.2) - '@inquirer/select': 4.4.2(@types/node@22.19.2) + '@inquirer/checkbox': 4.3.2(@types/node@25.6.0) + '@inquirer/confirm': 5.1.21(@types/node@25.6.0) + '@inquirer/editor': 4.2.23(@types/node@25.6.0) + '@inquirer/expand': 4.0.23(@types/node@25.6.0) + '@inquirer/input': 4.3.1(@types/node@25.6.0) + '@inquirer/number': 3.0.23(@types/node@25.6.0) + '@inquirer/password': 4.0.23(@types/node@25.6.0) + '@inquirer/rawlist': 4.1.11(@types/node@25.6.0) + '@inquirer/search': 3.2.2(@types/node@25.6.0) + '@inquirer/select': 4.4.2(@types/node@25.6.0) optionalDependencies: - '@types/node': 22.19.2 + '@types/node': 25.6.0 '@inquirer/rawlist@1.2.16': dependencies: @@ -26667,14 +26593,6 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 - '@inquirer/rawlist@4.1.11(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/rawlist@4.1.11(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26683,15 +26601,6 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/search@3.2.2(@types/node@22.19.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/search@3.2.2(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26709,16 +26618,6 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 - '@inquirer/select@4.4.2(@types/node@22.19.2)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.2) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/select@4.4.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26742,10 +26641,6 @@ snapshots: dependencies: mute-stream: 1.0.0 - '@inquirer/type@3.0.10(@types/node@22.19.2)': - optionalDependencies: - '@types/node': 22.19.2 - '@inquirer/type@3.0.10(@types/node@25.6.0)': optionalDependencies: '@types/node': 25.6.0 @@ -27310,11 +27205,11 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} - '@mintlify/cli@4.0.1049(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': + '@mintlify/cli@4.0.1049(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': dependencies: - '@inquirer/prompts': 7.9.0(@types/node@22.19.2) + '@inquirer/prompts': 7.9.0(@types/node@25.6.0) '@mintlify/common': 1.0.813(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - '@mintlify/link-rot': 3.0.983(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + '@mintlify/link-rot': 3.0.983(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/models': 0.0.286 '@mintlify/prebuild': 1.0.954(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/previewing': 4.0.1012(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) @@ -27327,7 +27222,7 @@ snapshots: front-matter: 4.0.2 fs-extra: 11.2.0 ink: 6.3.0(@types/react@19.2.14)(react@19.2.3) - inquirer: 12.3.0(@types/node@22.19.2) + inquirer: 12.3.0(@types/node@25.6.0) js-yaml: 4.1.0 mdast-util-mdx-jsx: 3.2.0 react: 19.2.3 @@ -27353,7 +27248,7 @@ snapshots: - utf-8-validate - yaml - '@mintlify/common@1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3)': + '@mintlify/common@1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@asyncapi/parser': 3.4.0 '@mintlify/mdx': 3.0.4(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -27393,7 +27288,7 @@ snapshots: remark-parse: 11.0.0 remark-rehype: 11.1.1 remark-stringify: 11.0.0 - tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)) + tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)) unified: 11.0.5 unist-builder: 4.0.0 unist-util-map: 4.0.0 @@ -27477,12 +27372,12 @@ snapshots: - typescript - yaml - '@mintlify/link-rot@3.0.983(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': + '@mintlify/link-rot@3.0.983(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': dependencies: '@mintlify/common': 1.0.813(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/prebuild': 1.0.954(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/previewing': 4.0.1012(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - '@mintlify/scraping': 4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3) + '@mintlify/scraping': 4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3) '@mintlify/validation': 0.1.640(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(typescript@5.9.3) fs-extra: 11.1.0 unist-util-visit: 4.1.2 @@ -27626,9 +27521,9 @@ snapshots: - utf-8-validate - yaml - '@mintlify/scraping@4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3)': + '@mintlify/scraping@4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@mintlify/common': 1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3) + '@mintlify/common': 1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3) '@mintlify/openapi-parser': 0.0.8 fs-extra: 11.1.1 hast-util-to-mdast: 10.1.0 @@ -32226,6 +32121,12 @@ snapshots: '@types/katex@0.16.8': {} + '@types/lodash.isequal@4.5.8': + dependencies: + '@types/lodash': 4.17.24 + + '@types/lodash@4.17.24': {} + '@types/mdast@3.0.15': dependencies: '@types/unist': 2.0.11 @@ -38850,12 +38751,12 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - inquirer@12.3.0(@types/node@22.19.2): + inquirer@12.3.0(@types/node@25.6.0): dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.2) - '@inquirer/prompts': 7.10.1(@types/node@22.19.2) - '@inquirer/type': 3.0.10(@types/node@22.19.2) - '@types/node': 22.19.2 + '@inquirer/core': 10.3.2(@types/node@25.6.0) + '@inquirer/prompts': 7.10.1(@types/node@25.6.0) + '@inquirer/type': 3.0.10(@types/node@25.6.0) + '@types/node': 25.6.0 ansi-escapes: 4.3.2 mute-stream: 2.0.0 run-async: 3.0.0 @@ -39932,6 +39833,8 @@ snapshots: lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + lodash.isinteger@4.0.4: {} lodash.isnumber@3.0.3: {} @@ -41227,9 +41130,9 @@ snapshots: dependencies: minipass: 7.1.3 - mintlify@4.2.446(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + mintlify@4.2.446(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: - '@mintlify/cli': 4.0.1049(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + '@mintlify/cli': 4.0.1049(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/node' @@ -43188,13 +43091,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.8 - postcss-load-config@4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)): + postcss-load-config@4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)): dependencies: lilconfig: 3.1.3 yaml: 2.8.3 optionalDependencies: postcss: 8.5.10 - ts-node: 10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3) + ts-node: 10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3) postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3): dependencies: @@ -46397,7 +46300,7 @@ snapshots: - tsx - yaml - tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)): + tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -46416,7 +46319,7 @@ snapshots: postcss: 8.5.10 postcss-import: 15.1.0(postcss@8.5.10) postcss-js: 4.1.0(postcss@8.5.10) - postcss-load-config: 4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)) + postcss-load-config: 4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)) postcss-nested: 6.2.0(postcss@8.5.10) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -46786,14 +46689,14 @@ snapshots: '@swc/core': 1.15.21 optional: true - ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3): + ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 22.19.2 + '@types/node': 25.6.0 acorn: 8.16.0 acorn-walk: 8.3.5 arg: 4.1.3 From 43ead758ebd1d04063e280816d0bfb98941c5c07 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 21 Apr 2026 07:44:12 -0300 Subject: [PATCH 6/7] refactor(react): drop lodash.isequal dep, inline JSON.stringify compare (SD-2635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/react/package.json | 2 -- packages/react/src/utils.test.ts | 28 +++++++++------------------- packages/react/src/utils.ts | 30 +++++++++++++++++++++--------- pnpm-lock.yaml | 24 ------------------------ 4 files changed, 30 insertions(+), 54 deletions(-) diff --git a/packages/react/package.json b/packages/react/package.json index bbb76e6059..5acbc31285 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -35,7 +35,6 @@ ], "license": "AGPL-3.0", "dependencies": { - "lodash.isequal": "^4.5.0", "superdoc": ">=1.0.0" }, "peerDependencies": { @@ -44,7 +43,6 @@ }, "devDependencies": { "@testing-library/react": "catalog:", - "@types/lodash.isequal": "^4.5.8", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/packages/react/src/utils.test.ts b/packages/react/src/utils.test.ts index 188daa6592..6e651fb7d3 100644 --- a/packages/react/src/utils.test.ts +++ b/packages/react/src/utils.test.ts @@ -58,28 +58,18 @@ describe('useMemoByValue', () => { expect(result.current).not.toBe(first); }); - it('handles key order changes as equal (deep compare is order-insensitive)', () => { - const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), { - initialProps: { value: { a: 1, b: 2 } }, - }); - - const first = result.current; - rerender({ value: { b: 2, a: 1 } }); - expect(result.current).toBe(first); - }); + it('adopts a new reference on circular input (JSON.stringify throws)', () => { + const circularA: { self?: unknown; name: string } = { name: 'a' }; + circularA.self = circularA; - it('treats values with different function identities as equal', () => { - // lodash.isequal compares functions by reference. Same-reference functions - // are equal; different-reference functions are not. We rely on the parent - // ref-check to short-circuit same-reference cases, so function equality - // only matters when the whole value object is freshly allocated. - const fn = () => 1; const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), { - initialProps: { value: { cb: fn, n: 1 } }, + initialProps: { value: circularA }, }); - const first = result.current; - rerender({ value: { cb: fn, n: 1 } }); - expect(result.current).toBe(first); + const circularB: { self?: unknown; name: string } = { name: 'a' }; + circularB.self = circularB; + rerender({ value: circularB }); + // The compare throws; the hook falls back to adopting the new reference. + expect(result.current).toBe(circularB); }); }); diff --git a/packages/react/src/utils.ts b/packages/react/src/utils.ts index 6aa589c5cc..782e8fe7af 100644 --- a/packages/react/src/utils.ts +++ b/packages/react/src/utils.ts @@ -1,7 +1,6 @@ /** @module utils */ import * as React from 'react'; -import isEqual from 'lodash.isequal'; /** * Polyfill for React.useId() for React versions < 18. @@ -29,22 +28,25 @@ export const useStableId: () => string = /** * Returns a reference-stable version of `value` — identity only changes - * when the content changes (deep equality via `lodash.isequal`). + * when the serialized content changes. * - * Use for object/array props that feed into `useEffect` / `useMemo` - * dependency arrays when the consumer is likely to pass inline literals. - * Without this, every parent re-render produces a fresh reference and - * causes the effect to re-run even when the content is identical. + * Use for plain-data object/array props that feed into `useEffect` / + * `useMemo` dependency arrays when the consumer is likely to pass inline + * literals. Without this, every parent re-render produces a fresh + * reference and causes the effect to re-run even when the content is + * identical. * - * The deep compare only runs when the incoming reference differs from - * the previous one, so the steady-state cost is a single pointer check. + * Not suitable for values containing functions, class instances (Yjs + * Doc, Maps, Sets, Dates), or circular references — JSON.stringify + * drops or collapses those. The compare only runs when the incoming + * reference differs, so the steady-state cost is a single pointer check. */ export function useMemoByValue(value: T): T { const lastRawRef = React.useRef(value); const stableRef = React.useRef(value); if (lastRawRef.current !== value) { - if (!isEqual(stableRef.current, value)) { + if (!shallowJsonEqual(stableRef.current, value)) { stableRef.current = value; } lastRawRef.current = value; @@ -52,3 +54,13 @@ export function useMemoByValue(value: T): T { return stableRef.current; } + +function shallowJsonEqual(a: T, b: T): boolean { + if (a === b) return true; + if (a == null || b == null) return a === b; + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5099411ee..69c691e95d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2672,9 +2672,6 @@ importers: packages/react: dependencies: - lodash.isequal: - specifier: ^4.5.0 - version: 4.5.0 superdoc: specifier: workspace:* version: link:../superdoc @@ -2682,9 +2679,6 @@ importers: '@testing-library/react': specifier: 'catalog:' version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@types/lodash.isequal': - specifier: ^4.5.8 - version: 4.5.8 '@types/node': specifier: 'catalog:' version: 22.19.2 @@ -10469,12 +10463,6 @@ packages: '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} - '@types/lodash.isequal@4.5.8': - resolution: {integrity: sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==} - - '@types/lodash@4.17.24': - resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} - '@types/mdast@3.0.15': resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} @@ -16622,10 +16610,6 @@ packages: lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} - lodash.isequal@4.5.0: - resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} - deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. - lodash.isinteger@4.0.4: resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} @@ -32121,12 +32105,6 @@ snapshots: '@types/katex@0.16.8': {} - '@types/lodash.isequal@4.5.8': - dependencies: - '@types/lodash': 4.17.24 - - '@types/lodash@4.17.24': {} - '@types/mdast@3.0.15': dependencies: '@types/unist': 2.0.11 @@ -39833,8 +39811,6 @@ snapshots: lodash.isboolean@3.0.3: {} - lodash.isequal@4.5.0: {} - lodash.isinteger@4.0.4: {} lodash.isnumber@3.0.3: {} From 4da3d7b4b050298e7f27246d3de7e78c8daa9db6 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 21 Apr 2026 07:46:33 -0300 Subject: [PATCH 7/7] test(react): cover StrictMode + user prop value change (SD-2635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . --- packages/react/src/SuperDocEditor.test.tsx | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/react/src/SuperDocEditor.test.tsx b/packages/react/src/SuperDocEditor.test.tsx index 59443d240c..c93b3af722 100644 --- a/packages/react/src/SuperDocEditor.test.tsx +++ b/packages/react/src/SuperDocEditor.test.tsx @@ -308,6 +308,43 @@ describe('SuperDocEditor', () => { expect(onEditorDestroy.mock.calls.length).toBe(destroysBefore); }); + 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(onReady).toHaveBeenCalled(), { timeout: 5000 }); + const instanceBefore = ref.current?.getInstance(); + + rerender( + + + , + ); + + await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 }); + await waitFor(() => expect(ref.current?.getInstance()).not.toBe(instanceBefore), { timeout: 5000 }); + }); + 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