From 61c03a2b451c648a6096e690de8cc610f1442d45 Mon Sep 17 00:00:00 2001 From: sidgaikwad Date: Fri, 4 Sep 2026 12:13:00 +0530 Subject: [PATCH 1/2] perf: prefetch the bundle when the host page already loaded embed.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The load listener prefetches the versioned bundle so the first createEditor does not pay a second round trip, but the early return for an already-installed window.ImageEditor bypassed it entirely. So on exactly the pages the tag-reuse logic exists to support — where the host injected embed.js itself — the prefetch never happened and the first mount was a full round trip slower than the injected path. The embed loader caches its own promise, so the duplicate call is a no-op when the host already triggered it. Also make the reused-tag timeout a parameter (defaulting to the exported REUSED_TAG_TIMEOUT_MS) rather than a hardcoded constant, so the bound is overridable and directly testable. --- src/loadScript.ts | 12 +++++++++--- test/loadScript.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/loadScript.ts b/src/loadScript.ts index 8cceb77..58bc9fe 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) { + // Prefetch the versioned bundle, exactly as the load listener below + // does. Without this a page that injected embed.js itself pays a full + // extra round trip on the first createEditor. The embed loader caches + // its own promise, so a duplicate call is a no-op. + window.ImageEditor.load().catch(() => {}); 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/test/loadScript.test.ts b/test/loadScript.test.ts index 90002b4..3093e74 100644 --- a/test/loadScript.test.ts +++ b/test/loadScript.test.ts @@ -192,3 +192,40 @@ it('resetLoader removes the global, the tag, and the cached promise', async () = fire(scriptTags()[0], 'load'); await retry; }); + +it('prefetches the bundle when the host page already installed the global', async () => { + // Without this the first createEditor on a host-injected page pays a full + // extra round trip that the injected path avoids. + const embed = mockEmbed(); + window.ImageEditor = embed; + + await loadScript(); + + expect(embed.load).toHaveBeenCalledTimes(1); + expect(scriptTags()).toHaveLength(0); +}); + +it('swallows a prefetch failure on the already-installed path', async () => { + const embed = mockEmbed(); + vi.mocked(embed.load).mockRejectedValueOnce(new Error('bundle 404')); + window.ImageEditor = embed; + + await expect(loadScript()).resolves.toBeUndefined(); +}); + +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(); + } +}); From beaff1b30e34e2ffca1c21c05fda743ef78887f7 Mon Sep 17 00:00:00 2001 From: sidgaikwad Date: Sat, 12 Sep 2026 22:00:33 +0530 Subject: [PATCH 2/2] fix: drop the prefetch, expose the reused-tag timeout Removes the load() prefetch on the already-installed path. It did not eliminate a round trip: createEditor is awaited immediately after loadScript resolves and starts the bundle request itself, so the prefetch bought a microtask. Upstream already prefetches on the path where it does help, inside the load listener. It also risked overriding version selection. load() with no arguments resolves "latest" and the embed caches that promise, so a version the embed's own createEditor pins could be silently ignored on a page that installed embed.js itself. A test now pins the no-prefetch behaviour so this cannot be reintroduced by accident. Keeps the reused-tag timeout work and finishes it: the parameter was internal-only, so package consumers still could not configure it. Adds a reusedTagTimeoutMs prop, read from latestPropsRef at call time so changing it never remounts the editor, and documents it in the props table. --- README.md | 29 +++++++++++++++-------------- src/ImageEditor.tsx | 4 +++- src/loadScript.ts | 10 +++++----- src/types.ts | 7 +++++++ test/index.test.tsx | 37 +++++++++++++++++++++++++++++++++++-- test/loadScript.test.ts | 18 ++++++------------ 6 files changed, 71 insertions(+), 34 deletions(-) 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 58bc9fe..5871520 100644 --- a/src/loadScript.ts +++ b/src/loadScript.ts @@ -39,11 +39,11 @@ export const loadScript = ( // embed.js evaluates, so its presence means the script already ran // (whether we injected it or the host page did). if (window.ImageEditor) { - // Prefetch the versioned bundle, exactly as the load listener below - // does. Without this a page that injected embed.js itself pays a full - // extra round trip on the first createEditor. The embed loader caches - // its own promise, so a duplicate call is a no-op. - window.ImageEditor.load().catch(() => {}); + // 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(); } 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 3093e74..9595e50 100644 --- a/test/loadScript.test.ts +++ b/test/loadScript.test.ts @@ -193,26 +193,20 @@ it('resetLoader removes the global, the tag, and the cached promise', async () = await retry; }); -it('prefetches the bundle when the host page already installed the global', async () => { - // Without this the first createEditor on a host-injected page pays a full - // extra round trip that the injected path avoids. +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).toHaveBeenCalledTimes(1); + expect(embed.load).not.toHaveBeenCalled(); expect(scriptTags()).toHaveLength(0); }); -it('swallows a prefetch failure on the already-installed path', async () => { - const embed = mockEmbed(); - vi.mocked(embed.load).mockRejectedValueOnce(new Error('bundle 404')); - window.ImageEditor = embed; - - await expect(loadScript()).resolves.toBeUndefined(); -}); - it('accepts a custom reused-tag timeout', async () => { vi.useFakeTimers(); try {