Skip to content

fix: compare options with an order-insensitive key - #48

Open
sidgaikwad wants to merge 2 commits into
unlayer:mainfrom
sidgaikwad:fix/stable-remount-key
Open

fix: compare options with an order-insensitive key#48
sidgaikwad wants to merge 2 commits into
unlayer:mainfrom
sidgaikwad:fix/stable-remount-key

Conversation

@sidgaikwad

@sidgaikwad sidgaikwad commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #31.

Problem

remountKey was derived with JSON.stringify, which preserves key insertion order:

const remountKey = JSON.stringify(remountOptions);

So two deeply equal option objects can produce different keys, and the remount effect destroys and recreates the editor — discarding the canvas, undo/redo history and AI chat:

<ImageEditor image={url} options={{ projectId: 1234, offline: false }} />
<ImageEditor image={url} options={{ offline: false, projectId: 1234 }} />

Easy to hit in real apps, where options is usually assembled conditionally rather than written as one fixed literal.

Fix

New src/stableKey.ts sorts object keys at every level, so the key depends only on content. It otherwise mirrors JSON.stringify semantics — undefined/function/symbol values omitted from objects, null-filled in arrays, so existing behaviour is unchanged for every input that worked before.

Two deliberate divergences, both because this runs during render where a throw would be fatal:

  • cycles serialize as [Circular] (JSON.stringify throws)
  • bigints serialize as strings (JSON.stringify throws)

toJSON is honoured. That is not a behaviour change — JSON.stringify already honoured it, so
a Date serialised correctly before. Because this serialiser walks objects itself, the explicit
toJSON handling is what preserves the existing behaviour: without it a Date would collapse to
{} (no own enumerable keys) and two different dates would compare equal.

Verification

The new regression test is red without the fix and green with it:

× does not remount when the same options are written in a different key order
  AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
  • 9 new unit tests for stableKey, 1 new component regression test
  • 56 tests pass, coverage still 100% statements / branches / functions / lines
  • lint, typecheck, build all clean

Note

stableKey is not exported from the package entry point — it is internal.

`remountKey` was derived with `JSON.stringify`, which preserves key
insertion order. Two deeply equal options objects written with their keys
in a different order produced different keys, so the remount effect tore
the editor down and recreated it — discarding the canvas, undo/redo
history and AI chat.

This is easy to hit whenever `options` is assembled conditionally rather
than written as one fixed literal.

Add `stableKey`, which sorts object keys at every level so the result
depends only on content. It otherwise mirrors `JSON.stringify` semantics
(undefined/function/symbol omitted from objects, null-filled in arrays),
with two deliberate differences: cycles and bigints serialize instead of
throwing, since this runs during render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

@sidgaikwad is attempting to deploy a commit to the Unlayer Team on Vercel.

A member of the Team first needs to authorize it.

@lucasbesen

Copy link
Copy Markdown
Member

@sidgaikwad thank you! Two small things:

  1. A toJSON that returns this recurses until stack overflow, during render. Call toJSON() once and serialize its result without re-dispatching (that's what JSON.stringify does), and add a test for it.

  2. Wrap remountKey / updatableKey in useMemo keyed on options so consumers passing a stable object skip the serialization on re-render.

Nit: JSON.stringify already honours toJSON, so the Date line in the description isn't a behaviour change.

I'll approve the CI run once these land.

Review follow-ups on the order-insensitive key.

serialize() called itself on the result of toJSON, so a toJSON returning
`this` recursed until the stack overflowed — during render, and before the
cycle guard, which sits after the toJSON branch, could ever run.

Dispatch toJSON exactly once and serialize its result directly, which is
what JSON.stringify does. Properties inside that result still get their own
dispatch, also matching JSON.stringify. All four toJSON tests now assert
parity with JSON.stringify rather than hardcoding expectations alone.

Also memoise remountKey and updatableKey on the options identity, so a
consumer passing a stable object serialises once instead of on every
render. The `options` default is now a module-level constant: a fresh {}
per render would have defeated the memo for everyone who omits the prop.
@sidgaikwad

Copy link
Copy Markdown
Contributor Author

Both fixed, and you were right on the nit too. Pushed as dfc9a1e.

1. toJSON returning this

Confirmed the stack overflow — reverting the fix makes the new test fail with RangeError: Maximum call stack size exceeded. The cycle guard never helped because the toJSON branch sits above it.

Now dispatches once and serializes the result directly with honourToJSON: false, matching JSON.stringify. I checked the exact semantics rather than assuming, because there are two separate behaviours to match:

case JSON.stringify stableKey now
{ a: 1, toJSON: () => this } {"a":1} {"a":1}
toJSON returning an object that itself has toJSON {} — the immediate result is not re-dispatched {}
toJSON returning { nested: objWithToJSON } {"nested":"INNER"} — properties inside the result are dispatched same

That middle row is the subtle one: the result of toJSON is not re-dispatched, but its properties still are. All four toJSON tests now assert equality with JSON.stringify(...) directly rather than only hardcoded strings, so they stay honest if I misread the spec somewhere.

2. useMemo

Done, both keyed on [options] — everything either key reads comes from options, so it is the only real dep.

One thing that fell out of this: options = {} in the destructure handed the memo a fresh object identity on every render, so the memo would have been dead for everyone who omits the prop. Hoisted it to a module-level NO_OPTIONS constant.

Added three tests, each red against a different regression:

  • no useMemo → 2 fail
  • useMemo present but the {} default restored → 1 fails (the empty-default one specifically)

They spy on stableKey while still running the real implementation, so nothing else changes.

3. Your nit — you are right, and I have corrected the description.

JSON.stringify honours toJSON, so a Date serialized fine before and nothing improved there. The accurate framing is the opposite: since this serializer walks objects itself, the explicit toJSON handling is what preserves existing behaviour — without it a Date would collapse to {} (no own enumerable keys) and two different dates would compare equal.

62 tests, coverage still 100% across the board; lint, typecheck and build clean. Ready for the CI run whenever suits.

@brenopolanski

Copy link
Copy Markdown
Contributor

Hey @sidgaikwad & @lucasbesen

The memo is fine, not blocking. Just a note:

It only skips work when options is reference-stable, so it pays off for a consumer who memoises options themselves, which I assume is the target. For inline options={{ ... }}, which is the common case, the dep misses and it serializes anyway. The rest spread above the memos also runs every render either way. So the win is narrower than it looks, and the NO_OPTIONS path where it's guaranteed to hit is the one where serializing {} costs nothing.

The part I'd reconsider is the three tests that assert stableKey call counts. Those pin the memo itself rather than any behavior, so they'd go red on a refactor that changes nothing for users. The swapped-key remount test is the one that actually guards the bug, and that one's great.

Anyway, that's just my observation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deeply-equal options can still force a full remount (JSON.stringify key order)

3 participants