Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions src/ImageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React, {
} from 'react';

import { loadScript, resetLoader } from './loadScript';
import { stableKey } from './stableKey';
import { ImageEditorInstance, ImageEditorProps, ImageEditorRef } from './types';

function ImageEditorInner(
Expand Down Expand Up @@ -74,8 +75,18 @@ function ImageEditorInner(
// theme/locale/translations apply via updateOptions; everything else in
// options requires a remount.
const { theme, locale, translations, ...remountOptions } = options;
const remountKey = JSON.stringify(remountOptions);
const updatableKey = JSON.stringify([theme, locale, translations]);
// stableKey, not JSON.stringify: the latter is key-order sensitive, so a
// deeply equal options object written with its keys in a different order
// would remount the editor and discard the user's unsaved work.
//
// Deliberately recomputed every render, NOT memoised on the options
// identity. Callers are free to mutate one long-lived options object in
// place, and a memo keyed on that object would never see it change,
// leaving projectId/theme/feature settings silently stale. Serializing a
// small options object is far cheaper than the remount a missed change
// costs, and it is what the released JSON.stringify did.
const remountKey = stableKey(remountOptions);
const updatableKey = stableKey([theme, locale, translations]);

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -122,7 +133,7 @@ function ImageEditorInner(
instance = created;
editorRef.current = created;
appliedImageRef.current = mountImage;
appliedUpdatableRef.current = JSON.stringify([
appliedUpdatableRef.current = stableKey([
mountOptions.theme,
mountOptions.locale,
mountOptions.translations,
Expand Down
87 changes: 87 additions & 0 deletions src/stableKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Deterministic serialization used to detect option changes across renders.
*
* `JSON.stringify` preserves key insertion order, so two deeply equal option
* objects can serialize differently and trigger a needless remount. This
* sorts object keys at every level, so the result depends only on content.
*
* Semantics otherwise mirror `JSON.stringify` (undefined/function/symbol
* values are omitted from objects and become `null` in arrays), with two
* deliberate differences: cycles serialize as `[Circular]` and bigints
* serialize as strings, because `JSON.stringify` throws on both and this
* runs during render.
*/
const serialize = (
value: unknown,
seen: Set<object>,
// The property name this value sits under, forwarded to toJSON exactly as
// JSON.stringify does: '' at the top level, the property name inside an
// object, the index as a string inside an array.
key: string,
honourToJSON = true
): string | undefined => {
if (typeof value === 'bigint') return `"${value}"`;

// Covers primitives, plus the omitted-value cases (undefined, function,
// symbol) where JSON.stringify itself returns undefined.
if (value === null || typeof value !== 'object') return JSON.stringify(value);

// Read the property once. A toJSON defined as a getter would otherwise be
// invoked twice — once to type-check it, once to call it — so a getter with
// side effects, or one returning a fresh function each read, behaved
// differently here than under JSON.stringify.
const toJSON = honourToJSON
? (value as { toJSON?: (key: string) => unknown }).toJSON
: undefined;
if (typeof toJSON === 'function') {
// .call, because caching the method above loses the `this` that
// method-call syntax bound for free. JSON.stringify invokes toJSON with
// `this` set to the value, and implementations rely on it.
//
// Dispatch exactly once and serialize the result directly, as
// JSON.stringify does. Re-dispatching would let a toJSON that returns
// `this` recurse until the stack overflows — during render, before the
// cycle guard below is ever reached. Properties *inside* the result
// still get their own dispatch, which is also what JSON.stringify does.
return serialize(toJSON.call(value, key), seen, key, false);
}

if (seen.has(value)) return '"[Circular]"';
seen.add(value);

let result: string;
if (Array.isArray(value)) {
// An index loop, not .map(): .map() skips holes, so a sparse array
// joined to fewer entries than its length — new Array(1) serialized as
// '[]' and collided with a genuinely empty array. Reading value[index]
// yields undefined for a hole, which serializes to null, matching
// JSON.stringify.
const items: string[] = [];
for (let index = 0; index < value.length; index++) {
items.push(serialize(value[index], seen, String(index)) ?? 'null');
}
result = `[${items.join(',')}]`;
} else {
const entries: string[] = [];
for (const name of Object.keys(value).sort()) {
const serialized = serialize(
(value as Record<string, unknown>)[name],
seen,
name
);
if (serialized !== undefined) {
entries.push(`${JSON.stringify(name)}:${serialized}`);
}
}
result = `{${entries.join(',')}}`;
}

seen.delete(value);
return result;
};

/**
* A key that is equal for deeply equal values, regardless of key order.
*/
export const stableKey = (value: unknown): string =>
serialize(value, new Set(), '') ?? 'undefined';
75 changes: 75 additions & 0 deletions test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,21 @@ import ImageEditor, {
MountOptions,
} from '../src';
import { loadScript, resetLoader } from '../src/loadScript';
import { stableKey } from '../src/stableKey';

// Resolve the embed script immediately instead of hitting the network.
vi.mock('../src/loadScript', () => ({
loadScript: vi.fn(() => Promise.resolve()),
resetLoader: vi.fn(),
}));

// Spy only — the real implementation still runs, so behaviour is unchanged.
// Lets a test assert that the option keys are actually memoised.
vi.mock('../src/stableKey', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/stableKey')>();
return { stableKey: vi.fn(actual.stableKey) };
});

interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
Expand Down Expand Up @@ -61,6 +69,7 @@ beforeEach(() => {
vi.mocked(loadScript).mockImplementation(() => Promise.resolve());
vi.mocked(resetLoader).mockClear();

vi.mocked(stableKey).mockClear();
mockInstance = makeInstance();
createEditor = vi.fn(async () => mockInstance);
window.ImageEditor = {
Expand Down Expand Up @@ -188,6 +197,72 @@ it('does not remount when the tools config is deep-equal but not reference-equal
expect(createEditor).toHaveBeenCalledTimes(1);
});

it('does not remount when the same options are written in a different key order', async () => {
const { rerender } = render(
<ImageEditor image="img-a" options={{ projectId: 1234, offline: false }} />
);
await flush();

// Same configuration, keys in the other order — the common case when
// options are assembled conditionally rather than as one fixed literal.
rerender(
<ImageEditor image="img-a" options={{ offline: false, projectId: 1234 }} />
);
await flush();

expect(mockInstance.destroy).not.toHaveBeenCalled();
expect(createEditor).toHaveBeenCalledTimes(1);
});

it('re-serializes when a fresh options object arrives', async () => {
const { rerender } = render(
<ImageEditor image="img-a" options={{ projectId: 1234 }} />
);
await flush();
const afterMount = vi.mocked(stableKey).mock.calls.length;

rerender(<ImageEditor image="img-a" options={{ projectId: 1234 }} />);

expect(vi.mocked(stableKey).mock.calls.length).toBeGreaterThan(afterMount);
// ...and still resolves to the same key, so no remount.
expect(createEditor).toHaveBeenCalledTimes(1);
});

it('detects an in-place mutation of a long-lived options object', async () => {
// A consumer holding one config object and mutating it is supported by the
// released version, which re-serialised on every render. Memoising the key
// on the options identity would silently miss this and leave the editor
// configured with the old projectId.
const options: ImageEditorOptions = { projectId: 1 };
const { rerender } = render(<ImageEditor image="img-a" options={options} />);
await flush();

expect(createEditor).toHaveBeenCalledTimes(1);

options.projectId = 2;
rerender(<ImageEditor image="img-a" options={options} />);
await flush();

expect(mockInstance.destroy).toHaveBeenCalledTimes(1);
expect(createEditor).toHaveBeenCalledTimes(2);
expect(mountOptionsOf(1).projectId).toBe(2);
});

it('detects an in-place theme mutation without remounting', async () => {
const options: ImageEditorOptions = { theme: 'light' };
const { rerender } = render(<ImageEditor image="img-a" options={options} />);
await flush();

options.theme = 'dark';
rerender(<ImageEditor image="img-a" options={options} />);
await flush();

expect(mockInstance.updateOptions).toHaveBeenCalledWith(
expect.objectContaining({ theme: 'dark' })
);
expect(createEditor).toHaveBeenCalledTimes(1);
});

it('exposes the editor instance through the ref and calls onLoad', async () => {
const ref = React.createRef<ImageEditorRef>();
const onLoad = vi.fn();
Expand Down
Loading
Loading