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
49 changes: 35 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,22 @@ The component works out of the box in React Server Components environments (e.g.

## Props

| Prop | Type | Description |
| -------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `image` | `string` (required) | Image URL or base64 data URL to edit. |
| `options` | `ImageEditorOptions` | Editor configuration: `projectId`, `user`, `features`, `theme`, `locale`, `translations`, `env`, `offline`, `licenseUrl`, `defaultPrompt`, `autoSubmitPrompt`, `aiAssistantOpenState`. |
| `editorId` | `string` | id for the container div. Cosmetic — the editor mounts by element reference. |
| `minHeight` | `number \| string` | Minimum height of the editor container. Defaults to `500`. |
| `style` | `CSSProperties` | Styles applied to the container div. Overrides the default `flex: 1`. |
| `wrapperStyle` | `CSSProperties` | Styles applied to the outer wrapper div, which owns `minHeight` and the flex layout. Set this to drop the editor into a non-flex layout. |
| `ariaLabel` | `string` | Accessible name for the editor region. Defaults to `'Image editor'`. |
| `onLoad` | `(editor) => void` | Called with the editor instance once it is mounted. |
| `onSave` | `({ dataUrl, blob }) => void` | Called when the user saves the edited image. |
| `onCancel` | `() => void` | Called when the user cancels editing. |
| `onLoadError` | `() => void` | Called when the image fails to load into the canvas (CORS, 404, decode error). |
| `onError` | `(error: Error) => void` | Wrapper-level failures: embed script load, editor creation, or image reset. Falls back to `console.error` when absent. |
| Prop | Type | Description |
| ----------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `image` | `string` (required) | Image URL or base64 data URL to edit. |
| `options` | `ImageEditorOptions` | Editor configuration: `projectId`, `user`, `features`, `theme`, `locale`, `translations`, `env`, `offline`, `licenseUrl`, `defaultPrompt`, `autoSubmitPrompt`, `aiAssistantOpenState`. |
| `editorId` | `string` | id for the container div. Cosmetic — the editor mounts by element reference. |
| `minHeight` | `number \| string` | Minimum height of the editor container. Defaults to `500`. |
| `style` | `CSSProperties` | Styles applied to the container div. Overrides the default `flex: 1`. |
| `wrapperStyle` | `CSSProperties` | Styles applied to the outer wrapper div, which owns `minHeight` and the flex layout. Set this to drop the editor into a non-flex layout. |
| `ariaLabel` | `string` | Accessible name for the editor region. Defaults to `'Image editor'`. |
| `loadingFallback` | `ReactNode` | Custom UI displayed while the embed script and editor initialize. |
| `errorFallback` | `ReactNode \| (error, retry) => ReactNode` | Custom UI displayed when initialization fails. A render function receives the error and retry callback. |
| `onLoad` | `(editor) => void` | Called with the editor instance once it is mounted. |
| `onSave` | `({ dataUrl, blob }) => void` | Called when the user saves the edited image. |
| `onCancel` | `() => void` | Called when the user cancels editing. |
| `onLoadError` | `() => void` | Called when the image fails to load into the canvas (CORS, 404, decode error). |
| `onError` | `(error: Error) => void` | Wrapper-level failures: embed script load, editor creation, or image reset. Falls back to `console.error` when absent. |

## Editor instance (ref)

Expand All @@ -88,6 +90,25 @@ const dataUrl = editorRef.current?.editor?.getImage();
| Any other `options` key | Full remount — the editor is destroyed and recreated with the new configuration. |
| `onSave` / `onCancel` / `onLoadError` / `onLoad` / `onError` | Always call the latest handler; changing them never remounts. |

## Loading and error UI

The editor shows a built-in loading message while its CDN script and instance
initialize, and a built-in retryable error message if initialization fails. Use
fallbacks to match your application's UI:

```tsx
<ImageEditor
image={url}
loadingFallback={<Spinner label="Loading editor" />}
errorFallback={(error, retry) => (
<section role="alert">
<p>Could not load the editor: {error.message}</p>
<button onClick={retry}>Try again</button>
</section>
)}
/>
```

## Error handling

Two distinct channels:
Expand Down
53 changes: 51 additions & 2 deletions src/ImageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ function ImageEditorInner(
} = props;

const [editor, setEditor] = useState<ImageEditorInstance | null>(null);
const [isInitializing, setIsInitializing] = useState(true);
const [initializationError, setInitializationError] = useState<Error | null>(
null
);
const [retryAttempt, setRetryAttempt] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);

const generatedId = useId();
Expand Down Expand Up @@ -71,6 +76,12 @@ function ImageEditorInner(
}
};

const retry = () => {
setInitializationError(null);
setIsInitializing(true);
setRetryAttempt((attempt) => attempt + 1);
};

// theme/locale/translations apply via updateOptions; everything else in
// options requires a remount.
const { theme, locale, translations, ...remountOptions } = options;
Expand Down Expand Up @@ -128,6 +139,7 @@ function ImageEditorInner(
mountOptions.translations,
]);
setEditor(created);
setIsInitializing(false);
// The editor mounted successfully, so a throw from the consumer's
// callback must not reach the terminal .catch below, where it would
// surface as a wrapper failure and could hard-reset the loader.
Expand All @@ -148,21 +160,28 @@ function ImageEditorInner(
if (!window.__ImageEditorImpl__) {
resetLoader(scriptUrl);
}
fail(error);
if (!cancelled) {
const err = error instanceof Error ? error : new Error(String(error));
setInitializationError(err);
setIsInitializing(false);
fail(err);
}
});

return () => {
cancelled = true;
editorRef.current = null;
setEditor(null);
setIsInitializing(true);
setInitializationError(null);
chainRef.current = chainRef.current
.then(() => {
instance?.destroy();
instance = null;
})
.catch(fail);
};
}, [scriptUrl, remountKey]);
}, [scriptUrl, remountKey, retryAttempt]);

// Image changes apply through the chain as serialized resets: the
// underlying reset is deeply async, so un-serialized resets could finish
Expand Down Expand Up @@ -202,12 +221,30 @@ function ImageEditorInner(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, updatableKey]);

const fallback = initializationError
? typeof props.errorFallback === 'function'
? props.errorFallback(initializationError, retry)
: (props.errorFallback ?? (
<div role="alert">
<p>Unable to load the image editor.</p>
<button type="button" onClick={retry}>
Try again
</button>
</div>
))
: isInitializing
? (props.loadingFallback ?? (
<div role="status">Loading image editor…</div>
))
: null;

return (
<div
style={{
flex: 1,
display: 'flex',
minHeight: minHeight,
position: 'relative',
...wrapperStyle,
}}
>
Expand All @@ -222,6 +259,18 @@ function ImageEditorInner(
// flex first: a default the consumer's style can override.
style={{ flex: 1, ...style }}
/>
{fallback && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'grid',
placeItems: 'center',
}}
>
{fallback}
</div>
)}
</div>
);
}
Expand Down
37 changes: 18 additions & 19 deletions src/loadScript.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
const defaultScriptUrl = 'https://cdn.unlayer.com/image-editor/embed.js';

// When reusing a host-injected tag we cannot know whether it already fired
// `error` (a dead tag never re-fires), so the wait is bounded instead of
// letting the promise hang forever.
const REUSED_TAG_TIMEOUT_MS = 30_000;
// A script request can stall indefinitely (for example, when a network
// middlebox drops the CDN request) without dispatching `load` or `error`.
// Bound every wait so consumers can render an error state and retry.
const SCRIPT_LOAD_TIMEOUT_MS = 30_000;

interface TrackedLoad {
promise: Promise<void>;
Expand Down Expand Up @@ -52,22 +52,32 @@ export const loadScript = (
// injecting a duplicate.
const existing = findScriptTag(scriptUrl);
const tag = existing ?? document.createElement('script');
let timeout: ReturnType<typeof setTimeout> | undefined;

const failWith = (error: Error) => {
// A tag that fired `error` never fires again — evict the cache and
// remove the dead tag so a retry injects a fresh one.
if (timeout !== undefined) clearTimeout(timeout);
clearTimeout(timeout);
loads.delete(scriptUrl);
tag.remove();
reject(error);
};
abort = failWith;

// An existing tag may have errored before listeners were attached, and a
// newly injected tag may never settle at all. In either case, make the
// failure observable instead of leaving the editor permanently blank.
const timeout = setTimeout(() => {
failWith(
new Error(
`Timed out loading the image editor embed script: ${scriptUrl}`
)
);
}, SCRIPT_LOAD_TIMEOUT_MS);

tag.addEventListener(
'load',
() => {
if (timeout !== undefined) clearTimeout(timeout);
clearTimeout(timeout);
// Prefetch the versioned bundle so the first createEditor doesn't
// pay a second network hop. A prefetch failure is swallowed here —
// the same failure surfaces through createEditor's rejection.
Expand All @@ -89,18 +99,7 @@ export const loadScript = (
{ once: true }
);

if (existing) {
// The tag may have errored before we attached listeners (a loaded
// embed would have been caught by the window.ImageEditor check
// above) — bound the wait so the promise can't hang forever.
timeout = setTimeout(() => {
failWith(
new Error(
`Timed out waiting for an existing embed script tag: ${scriptUrl}`
)
);
}, REUSED_TAG_TIMEOUT_MS);
} else {
if (!existing) {
tag.src = scriptUrl;
document.head.appendChild(tag);
}
Expand Down
16 changes: 15 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CSSProperties } from 'react';
import { CSSProperties, ReactNode } from 'react';

import type {
Features,
Expand Down Expand Up @@ -92,6 +92,13 @@ export type ImageEditorOptions = Omit<
'container' | 'image' | 'onSave' | 'onCancel' | 'onLoadError'
>;

/** A custom UI displayed while the embed script and editor initialize. */
export type ImageEditorLoadingFallback = ReactNode;

/** A custom UI displayed when the embed script or editor initialization fails. */
export type ImageEditorErrorFallback =
ReactNode | ((error: Error, retry: () => void) => ReactNode);

export interface ImageEditorProps {
/** Image URL or data URL to edit. Changes apply via a serialized reset. */
image: string;
Expand Down Expand Up @@ -119,6 +126,13 @@ export interface ImageEditorProps {
* globally, so do not mix different scriptUrls across components.
*/
scriptUrl?: string;
/** Custom UI displayed while the embed script and editor initialize. */
loadingFallback?: ImageEditorLoadingFallback;
/**
* Custom UI displayed when initialization fails. A function receives the
* error and a retry callback; a React node is also accepted for static UI.
*/
errorFallback?: ImageEditorErrorFallback;
/** Called with the editor instance once it is mounted. */
onLoad?(editor: ImageEditorInstance): void;
/**
Expand Down
43 changes: 42 additions & 1 deletion test/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { act, render } from '@testing-library/react';
import { act, fireEvent, render } from '@testing-library/react';

import ImageEditor, {
ImageEditorInstance,
Expand Down Expand Up @@ -81,6 +81,47 @@ it('renders the editor container', async () => {
await flush();
});

it('shows a custom loading fallback until the editor is ready', async () => {
const deferred = defer<void>();
vi.mocked(loadScript).mockImplementationOnce(() => deferred.promise);

render(
<ImageEditor
image="img-a"
loadingFallback={<span data-testid="loading">Preparing editor</span>}
/>
);

expect(document.querySelector('[data-testid="loading"]')).toBeTruthy();
deferred.resolve();
await flush();

expect(document.querySelector('[data-testid="loading"]')).toBeNull();
expect(createEditor).toHaveBeenCalledTimes(1);
});

it('renders an error fallback with a working retry callback', async () => {
const error = new Error('cdn down');
vi.mocked(loadScript).mockRejectedValueOnce(error);
const renderError = vi.fn((_error: Error, retry: () => void) => (
<button type="button" onClick={retry}>
Retry editor
</button>
));

render(<ImageEditor image="img-a" errorFallback={renderError} />);
await flush();

expect(renderError).toHaveBeenCalledWith(error, expect.any(Function));
expect(createEditor).not.toHaveBeenCalled();

fireEvent.click(document.querySelector('button')!);
await flush();

expect(createEditor).toHaveBeenCalledTimes(1);
expect(document.querySelector('button')).toBeNull();
});

it('creates the editor with the container element, image, and options', async () => {
render(
<ImageEditor
Expand Down
16 changes: 16 additions & 0 deletions test/loadScript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,22 @@ it('times out on a reused tag that never fires (already-errored host tag)', asyn
}
});

it('times out and cleans up when a newly injected script never settles', async () => {
vi.useFakeTimers();
try {
const promise = loadScript();
expect(scriptTags()).toHaveLength(1);

const rejection = expect(promise).rejects.toThrow(/Timed out/);
vi.advanceTimersByTime(30_000);
await rejection;

expect(scriptTags()).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});

it('resetLoader rejects a still-pending load so waiters fail fast', async () => {
const pending = loadScript();
expect(scriptTags()).toHaveLength(1);
Expand Down