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
29 changes: 15 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,21 @@ 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'`. |
| `reusedTagTimeoutMs` | `number` | How long to wait (ms) for an embed script tag the host page already placed on the page. Only applies to a reused tag. Defaults to `30000`. |
| `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 Down
4 changes: 3 additions & 1 deletion src/ImageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ function ImageEditorInner(
chainRef.current = chainRef.current
.then(async () => {
if (cancelled) return;
await loadScript(scriptUrl);
// Read at call time from latestPropsRef, so changing the timeout
// never tears the editor down and remounts it.
await loadScript(scriptUrl, latestPropsRef.current.reusedTagTimeoutMs);
if (cancelled) return;

const embed = window.ImageEditor;
Expand Down
12 changes: 9 additions & 3 deletions src/loadScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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;
export const REUSED_TAG_TIMEOUT_MS = 30_000;

interface TrackedLoad {
promise: Promise<void>;
Expand Down Expand Up @@ -32,12 +32,18 @@ const findScriptTag = (scriptUrl: string): HTMLScriptElement | null => {
* host-injected tag, if it doesn't become ready within a bounded wait).
*/
export const loadScript = (
scriptUrl: string = defaultScriptUrl
scriptUrl: string = defaultScriptUrl,
reusedTagTimeoutMs: number = REUSED_TAG_TIMEOUT_MS
): Promise<void> => {
// The embed loader assigns window.ImageEditor synchronously while
// embed.js evaluates, so its presence means the script already ran
// (whether we injected it or the host page did).
if (window.ImageEditor) {
// Deliberately no load() prefetch here. createEditor is awaited
// immediately after this resolves and starts the bundle request itself,
// so a prefetch would buy a microtask, not a round trip — and calling
// load() with no arguments resolves "latest" and caches that promise,
// which would defeat any version the embed's own createEditor pins.
return Promise.resolve();
}

Expand Down Expand Up @@ -99,7 +105,7 @@ export const loadScript = (
`Timed out waiting for an existing embed script tag: ${scriptUrl}`
)
);
}, REUSED_TAG_TIMEOUT_MS);
}, reusedTagTimeoutMs);
} else {
tag.src = scriptUrl;
document.head.appendChild(tag);
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ export interface ImageEditorProps {
* globally, so do not mix different scriptUrls across components.
*/
scriptUrl?: string;
/**
* How long to wait, in ms, for an embed script tag the host page already
* placed on the page to become ready. Only applies when such a tag is
* reused — a tag this component injects resolves or errors on its own.
* Defaults to 30000. Changing it never remounts the editor.
*/
reusedTagTimeoutMs?: number;
/** Called with the editor instance once it is mounted. */
onLoad?(editor: ImageEditorInstance): void;
/**
Expand Down
37 changes: 35 additions & 2 deletions test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,37 @@ it('forwards a custom scriptUrl to loadScript', async () => {
);
await flush();

expect(loadScript).toHaveBeenCalledWith('https://example.com/embed.js');
expect(loadScript).toHaveBeenCalledWith(
'https://example.com/embed.js',
undefined
);
});

it('forwards reusedTagTimeoutMs to loadScript', async () => {
render(<ImageEditor image="img-a" reusedTagTimeoutMs={5_000} />);
await flush();

expect(loadScript).toHaveBeenCalledWith(undefined, 5_000);
});

it('leaves loadScript to its own default when the timeout is omitted', async () => {
render(<ImageEditor image="img-a" />);
await flush();

expect(loadScript).toHaveBeenCalledWith(undefined, undefined);
});

it('does not remount when only the timeout changes', async () => {
const { rerender } = render(
<ImageEditor image="img-a" reusedTagTimeoutMs={5_000} />
);
await flush();

rerender(<ImageEditor image="img-a" reusedTagTimeoutMs={9_000} />);
await flush();

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

it('reports a loadScript failure via onError and recovers on remount', async () => {
Expand Down Expand Up @@ -704,7 +734,10 @@ it('remounts when scriptUrl changes', async () => {

expect(mockInstance.destroy).toHaveBeenCalledTimes(1);
expect(createEditor).toHaveBeenCalledTimes(2);
expect(loadScript).toHaveBeenLastCalledWith('https://b.example.com/embed.js');
expect(loadScript).toHaveBeenLastCalledWith(
'https://b.example.com/embed.js',
undefined
);
});

it('reverts theme to default when it is removed from options', async () => {
Expand Down
31 changes: 31 additions & 0 deletions test/loadScript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,34 @@ it('resetLoader removes the global, the tag, and the cached promise', async () =
fire(scriptTags()[0], 'load');
await retry;
});

it('does not prefetch the bundle when the host page installed the global', async () => {
// load() with no arguments resolves "latest" and the embed caches that
// promise, so prefetching here would override a version the embed's own
// createEditor pins. createEditor is awaited straight after this resolves
// and starts the request anyway.
const embed = mockEmbed();
window.ImageEditor = embed;

await loadScript();

expect(embed.load).not.toHaveBeenCalled();
expect(scriptTags()).toHaveLength(0);
});

it('accepts a custom reused-tag timeout', async () => {
vi.useFakeTimers();
try {
const hostTag = document.createElement('script');
hostTag.src = 'https://cdn.unlayer.com/image-editor/embed.js';
document.head.appendChild(hostTag);

const rejection = expect(
loadScript('https://cdn.unlayer.com/image-editor/embed.js', 5_000)
).rejects.toThrow(/Timed out/);
vi.advanceTimersByTime(5_000);
await rejection;
} finally {
vi.useRealTimers();
}
});
Loading