From c000eea2928fb959b1e237d74c2532d9bd2ce13a Mon Sep 17 00:00:00 2001 From: arpittkhandelwal Date: Mon, 21 Sep 2026 14:57:12 +0530 Subject: [PATCH 1/2] feat: add image editor fallback UI --- README.md | 49 +++++++++++++++++++++++++++++------------ src/ImageEditor.tsx | 53 +++++++++++++++++++++++++++++++++++++++++++-- src/types.ts | 16 +++++++++++++- test/index.test.tsx | 43 +++++++++++++++++++++++++++++++++++- 4 files changed, 143 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d0f8bfa..7d24ed8 100644 --- a/README.md +++ b/README.md @@ -48,20 +48,22 @@ 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'`. | +| `loadingFallback` | `ReactNode` | Custom UI displayed while the embed script and editor initialize. | +| `errorFallback` | `ReactNode \| (error, retry) => ReactNode` | Custom UI displayed when initialization fails. A render function receives the error and retry callback. | +| `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) @@ -88,6 +90,25 @@ const dataUrl = editorRef.current?.editor?.getImage(); | Any other `options` key | Full remount — the editor is destroyed and recreated with the new configuration. | | `onSave` / `onCancel` / `onLoadError` / `onLoad` / `onError` | Always call the latest handler; changing them never remounts. | +## Loading and error UI + +The editor shows a built-in loading message while its CDN script and instance +initialize, and a built-in retryable error message if initialization fails. Use +fallbacks to match your application's UI: + +```tsx +} + errorFallback={(error, retry) => ( +
+

Could not load the editor: {error.message}

+ +
+ )} +/> +``` + ## Error handling Two distinct channels: diff --git a/src/ImageEditor.tsx b/src/ImageEditor.tsx index acae488..0b80ea0 100644 --- a/src/ImageEditor.tsx +++ b/src/ImageEditor.tsx @@ -24,6 +24,11 @@ function ImageEditorInner( } = props; const [editor, setEditor] = useState(null); + const [isInitializing, setIsInitializing] = useState(true); + const [initializationError, setInitializationError] = useState( + null + ); + const [retryAttempt, setRetryAttempt] = useState(0); const containerRef = useRef(null); const generatedId = useId(); @@ -71,6 +76,12 @@ function ImageEditorInner( } }; + const retry = () => { + setInitializationError(null); + setIsInitializing(true); + setRetryAttempt((attempt) => attempt + 1); + }; + // theme/locale/translations apply via updateOptions; everything else in // options requires a remount. const { theme, locale, translations, ...remountOptions } = options; @@ -128,6 +139,7 @@ function ImageEditorInner( mountOptions.translations, ]); setEditor(created); + setIsInitializing(false); // The editor mounted successfully, so a throw from the consumer's // callback must not reach the terminal .catch below, where it would // surface as a wrapper failure and could hard-reset the loader. @@ -148,13 +160,20 @@ function ImageEditorInner( if (!window.__ImageEditorImpl__) { resetLoader(scriptUrl); } - fail(error); + if (!cancelled) { + const err = error instanceof Error ? error : new Error(String(error)); + setInitializationError(err); + setIsInitializing(false); + fail(err); + } }); return () => { cancelled = true; editorRef.current = null; setEditor(null); + setIsInitializing(true); + setInitializationError(null); chainRef.current = chainRef.current .then(() => { instance?.destroy(); @@ -162,7 +181,7 @@ function ImageEditorInner( }) .catch(fail); }; - }, [scriptUrl, remountKey]); + }, [scriptUrl, remountKey, retryAttempt]); // Image changes apply through the chain as serialized resets: the // underlying reset is deeply async, so un-serialized resets could finish @@ -202,12 +221,30 @@ function ImageEditorInner( // eslint-disable-next-line react-hooks/exhaustive-deps }, [editor, updatableKey]); + const fallback = initializationError + ? typeof props.errorFallback === 'function' + ? props.errorFallback(initializationError, retry) + : (props.errorFallback ?? ( +
+

Unable to load the image editor.

+ +
+ )) + : isInitializing + ? (props.loadingFallback ?? ( +
Loading image editor…
+ )) + : null; + return (
@@ -222,6 +259,18 @@ function ImageEditorInner( // flex first: a default the consumer's style can override. style={{ flex: 1, ...style }} /> + {fallback && ( +
+ {fallback} +
+ )}
); } diff --git a/src/types.ts b/src/types.ts index fb1b831..048d379 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { CSSProperties } from 'react'; +import { CSSProperties, ReactNode } from 'react'; import type { Features, @@ -92,6 +92,13 @@ export type ImageEditorOptions = Omit< 'container' | 'image' | 'onSave' | 'onCancel' | 'onLoadError' >; +/** A custom UI displayed while the embed script and editor initialize. */ +export type ImageEditorLoadingFallback = ReactNode; + +/** A custom UI displayed when the embed script or editor initialization fails. */ +export type ImageEditorErrorFallback = + ReactNode | ((error: Error, retry: () => void) => ReactNode); + export interface ImageEditorProps { /** Image URL or data URL to edit. Changes apply via a serialized reset. */ image: string; @@ -119,6 +126,13 @@ export interface ImageEditorProps { * globally, so do not mix different scriptUrls across components. */ scriptUrl?: string; + /** Custom UI displayed while the embed script and editor initialize. */ + loadingFallback?: ImageEditorLoadingFallback; + /** + * Custom UI displayed when initialization fails. A function receives the + * error and a retry callback; a React node is also accepted for static UI. + */ + errorFallback?: ImageEditorErrorFallback; /** 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..d9fdb18 100644 --- a/test/index.test.tsx +++ b/test/index.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { act, render } from '@testing-library/react'; +import { act, fireEvent, render } from '@testing-library/react'; import ImageEditor, { ImageEditorInstance, @@ -81,6 +81,47 @@ it('renders the editor container', async () => { await flush(); }); +it('shows a custom loading fallback until the editor is ready', async () => { + const deferred = defer(); + vi.mocked(loadScript).mockImplementationOnce(() => deferred.promise); + + render( + Preparing editor} + /> + ); + + expect(document.querySelector('[data-testid="loading"]')).toBeTruthy(); + deferred.resolve(); + await flush(); + + expect(document.querySelector('[data-testid="loading"]')).toBeNull(); + expect(createEditor).toHaveBeenCalledTimes(1); +}); + +it('renders an error fallback with a working retry callback', async () => { + const error = new Error('cdn down'); + vi.mocked(loadScript).mockRejectedValueOnce(error); + const renderError = vi.fn((_error: Error, retry: () => void) => ( + + )); + + render(); + await flush(); + + expect(renderError).toHaveBeenCalledWith(error, expect.any(Function)); + expect(createEditor).not.toHaveBeenCalled(); + + fireEvent.click(document.querySelector('button')!); + await flush(); + + expect(createEditor).toHaveBeenCalledTimes(1); + expect(document.querySelector('button')).toBeNull(); +}); + it('creates the editor with the container element, image, and options', async () => { render( Date: Mon, 21 Sep 2026 15:04:05 +0530 Subject: [PATCH 2/2] fix: time out stalled embed script loads --- src/loadScript.ts | 37 ++++++++++++++++++------------------- test/loadScript.test.ts | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/loadScript.ts b/src/loadScript.ts index 8cceb77..def0ce0 100644 --- a/src/loadScript.ts +++ b/src/loadScript.ts @@ -1,9 +1,9 @@ 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; +// A script request can stall indefinitely (for example, when a network +// middlebox drops the CDN request) without dispatching `load` or `error`. +// Bound every wait so consumers can render an error state and retry. +const SCRIPT_LOAD_TIMEOUT_MS = 30_000; interface TrackedLoad { promise: Promise; @@ -52,22 +52,32 @@ export const loadScript = ( // injecting a duplicate. const existing = findScriptTag(scriptUrl); const tag = existing ?? document.createElement('script'); - let timeout: ReturnType | 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. - if (timeout !== undefined) clearTimeout(timeout); + clearTimeout(timeout); loads.delete(scriptUrl); tag.remove(); reject(error); }; abort = failWith; + // An existing tag may have errored before listeners were attached, and a + // newly injected tag may never settle at all. In either case, make the + // failure observable instead of leaving the editor permanently blank. + const timeout = setTimeout(() => { + failWith( + new Error( + `Timed out loading the image editor embed script: ${scriptUrl}` + ) + ); + }, SCRIPT_LOAD_TIMEOUT_MS); + tag.addEventListener( 'load', () => { - if (timeout !== undefined) clearTimeout(timeout); + clearTimeout(timeout); // Prefetch the versioned bundle so the first createEditor doesn't // pay a second network hop. A prefetch failure is swallowed here — // the same failure surfaces through createEditor's rejection. @@ -89,18 +99,7 @@ export const loadScript = ( { once: true } ); - if (existing) { - // The tag may have errored before we attached listeners (a loaded - // embed would have been caught by the window.ImageEditor check - // above) — bound the wait so the promise can't hang forever. - timeout = setTimeout(() => { - failWith( - new Error( - `Timed out waiting for an existing embed script tag: ${scriptUrl}` - ) - ); - }, REUSED_TAG_TIMEOUT_MS); - } else { + if (!existing) { tag.src = scriptUrl; document.head.appendChild(tag); } diff --git a/test/loadScript.test.ts b/test/loadScript.test.ts index 90002b4..795e5ef 100644 --- a/test/loadScript.test.ts +++ b/test/loadScript.test.ts @@ -131,6 +131,22 @@ it('times out on a reused tag that never fires (already-errored host tag)', asyn } }); +it('times out and cleans up when a newly injected script never settles', async () => { + vi.useFakeTimers(); + try { + const promise = loadScript(); + expect(scriptTags()).toHaveLength(1); + + const rejection = expect(promise).rejects.toThrow(/Timed out/); + vi.advanceTimersByTime(30_000); + await rejection; + + expect(scriptTags()).toHaveLength(0); + } finally { + vi.useRealTimers(); + } +}); + it('resetLoader rejects a still-pending load so waiters fail fast', async () => { const pending = loadScript(); expect(scriptTags()).toHaveLength(1);