diff --git a/README.md b/README.md index d0f8bfa..4417bcc 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/ImageEditor.tsx b/src/ImageEditor.tsx index acae488..8bd9f2a 100644 --- a/src/ImageEditor.tsx +++ b/src/ImageEditor.tsx @@ -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; diff --git a/src/loadScript.ts b/src/loadScript.ts index 8cceb77..5871520 100644 --- a/src/loadScript.ts +++ b/src/loadScript.ts @@ -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; @@ -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 => { // 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(); } @@ -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); diff --git a/src/types.ts b/src/types.ts index fb1b831..5f73057 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; /** diff --git a/test/index.test.tsx b/test/index.test.tsx index 909c577..585b5da 100644 --- a/test/index.test.tsx +++ b/test/index.test.tsx @@ -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(); + await flush(); + + expect(loadScript).toHaveBeenCalledWith(undefined, 5_000); +}); + +it('leaves loadScript to its own default when the timeout is omitted', async () => { + render(); + await flush(); + + expect(loadScript).toHaveBeenCalledWith(undefined, undefined); +}); + +it('does not remount when only the timeout changes', async () => { + const { rerender } = render( + + ); + await flush(); + + rerender(); + await flush(); + + expect(mockInstance.destroy).not.toHaveBeenCalled(); + expect(createEditor).toHaveBeenCalledTimes(1); }); it('reports a loadScript failure via onError and recovers on remount', async () => { @@ -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 () => { diff --git a/test/loadScript.test.ts b/test/loadScript.test.ts index 90002b4..9595e50 100644 --- a/test/loadScript.test.ts +++ b/test/loadScript.test.ts @@ -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(); + } +});