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(); + } +});