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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const dataUrl = editorRef.current?.editor?.getImage();
Two distinct channels:

- **`onLoadError`** — the editor loaded fine, but the _image_ couldn't be loaded into the canvas (CORS, dead URL, decode error).
- **`onError`** — the wrapper couldn't reach a working editor: the embed script failed to load, editor creation was rejected, or re-applying a changed `image` failed. After a CDN failure the wrapper automatically resets its loader state, so a later remount retries from scratch.
- **`onError`** — the wrapper couldn't reach a working editor: the embed script failed to load, editor creation was rejected, or re-applying a changed `image` failed. A later remount retries from scratch after a CDN failure. The wrapper also clears its own loader state, but only tears down a script tag it injected itself — an embed the host page loaded is left intact for its other consumers.

## Tools

Expand Down
11 changes: 7 additions & 4 deletions src/ImageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,13 @@ function ImageEditorInner(
}
})
.catch((error) => {
// If the versioned bundle never evaluated, the CDN loader has
// cached a rejected promise it will return forever — hard-reset it
// so the next mount attempt can retry from scratch. When the impl
// global exists, the failure was a mount error; keep the loader.
// If the versioned bundle never evaluated, drop the loader state
// we own so the next mount starts from a fresh embed.js. This is
// not what makes recovery possible — embed.js nulls its own cached
// promise on failure — so on a host-loaded page resetLoader leaves
// the tag and global in place and only our cache is cleared. When
// the impl global exists the failure was a mount error, not a
// bundle-load one; keep the loader.
if (!window.__ImageEditorImpl__) {
resetLoader(scriptUrl);
}
Expand Down
58 changes: 42 additions & 16 deletions src/loadScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ interface TrackedLoad {
// share a single <script> tag.
const loads = new Map<string, TrackedLoad>();

// The tags this module injected. Anything else matching the script URL was
// put there by the host page, which may have other consumers of the embed:
// a reset must not remove it, or delete the global it installed, from under
// them. A tag that has provably failed is still removed either way — see
// failWith.
const ownedTags = new WeakSet<HTMLScriptElement>();

const resolveUrl = (scriptUrl: string): string =>
new URL(scriptUrl, document.baseURI).href;

Expand Down Expand Up @@ -54,15 +61,18 @@ export const loadScript = (
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.
const failWith = (error: Error, removeTag: boolean) => {
// A tag that fired `error` (or timed out) never fires again — evict
// the cache and remove the dead tag so a retry injects a fresh one,
// even when the host injected it: a failed tag is no use to anyone.
// A reset is different — the tag there may be perfectly alive — so it
// removes only what we own.
if (timeout !== undefined) clearTimeout(timeout);
loads.delete(scriptUrl);
tag.remove();
if (removeTag) tag.remove();
reject(error);
};
abort = failWith;
abort = (error: Error) => failWith(error, ownedTags.has(tag));

tag.addEventListener(
'load',
Expand All @@ -83,7 +93,8 @@ export const loadScript = (
failWith(
new Error(
`Failed to load the image editor embed script: ${scriptUrl}`
)
),
true
);
},
{ once: true }
Expand All @@ -97,11 +108,13 @@ export const loadScript = (
failWith(
new Error(
`Timed out waiting for an existing embed script tag: ${scriptUrl}`
)
),
true
);
}, REUSED_TAG_TIMEOUT_MS);
} else {
tag.src = scriptUrl;
ownedTags.add(tag);
document.head.appendChild(tag);
}
});
Expand All @@ -111,18 +124,31 @@ export const loadScript = (
};

/**
* Hard-reset the embed loader. The CDN loader caches its versioned-bundle
* promise forever — including rejections — so after a bundle-load failure
* the only way to retry is to reload embed.js with fresh module state.
* Rejects any still-pending load for the URL (waiters fail fast through
* their normal error paths instead of hanging on a removed tag), removes
* the embed script tag, deletes window.ImageEditor, and evicts the cached
* load; the next loadScript call starts from scratch.
* Reset this module's loader state for a script URL. Rejects any
* still-pending load (waiters fail fast through their normal error paths
* instead of hanging on a removed tag) and evicts the cached load, so the
* next loadScript call starts from scratch.
*
* The script tag and window.ImageEditor are only torn down when we injected
* the tag ourselves. On a page that loaded embed.js itself the embed may be
* in active use by other consumers, and removing a working global out from
* under them is not ours to do.
*
* Recovery does not depend on any of this: embed.js nulls its own cached
* promise when a bundle load fails, so a later createEditor retries whether
* or not the tag was ours. This only clears the state we own.
*/
export const resetLoader = (scriptUrl: string = defaultScriptUrl): void => {
// Captured before abort(), which may remove the tag itself.
const tag = findScriptTag(scriptUrl);
const ownedTag = tag && ownedTags.has(tag) ? tag : null;

const tracked = loads.get(scriptUrl);
loads.delete(scriptUrl);
tracked?.abort(new Error(`The image editor loader was reset: ${scriptUrl}`));
findScriptTag(scriptUrl)?.remove();
delete window.ImageEditor;

if (ownedTag) {
ownedTag.remove();
delete window.ImageEditor;
}
};
39 changes: 39 additions & 0 deletions test/loadScript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,42 @@ it('resetLoader removes the global, the tag, and the cached promise', async () =
fire(scriptTags()[0], 'load');
await retry;
});

it('resetLoader leaves a host-injected tag and its global alone', async () => {
// The host page loaded embed.js itself and other consumers may be using
// the global — tearing it down here would break them.
const hostTag = document.createElement('script');
hostTag.src = 'https://cdn.unlayer.com/image-editor/embed.js';
document.head.appendChild(hostTag);

const promise = loadScript();
const embed = mockEmbed();
window.ImageEditor = embed;
fire(hostTag, 'load');
await promise;

resetLoader();

expect(window.ImageEditor).toBe(embed);
expect(scriptTags()).toEqual([hostTag]);
});

it('resetLoader rejects waiters on a reused host tag without removing it', async () => {
const hostTag = document.createElement('script');
hostTag.src = 'https://cdn.unlayer.com/image-editor/embed.js';
document.head.appendChild(hostTag);

const pending = loadScript();
const rejection = expect(pending).rejects.toThrow(/loader was reset/);

resetLoader();
await rejection;

// Our cache is cleared, but the host's still-loading tag is untouched.
expect(scriptTags()).toEqual([hostTag]);
});

it('resetLoader is a no-op when nothing was ever loaded', () => {
expect(() => resetLoader()).not.toThrow();
expect(scriptTags()).toHaveLength(0);
});