diff --git a/README.md b/README.md index d0f8bfa..3a44819 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ The component works out of the box in React Server Components environments (e.g. | `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'`. | +| `placeholder` | `ReactNode` | Rendered centred over the container until the editor mounts — a spinner or skeleton for the embed's cold-cache load. Stays visible if the mount fails. | | `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. | @@ -88,6 +89,16 @@ 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 state + +The embed script and its versioned bundle are fetched at mount. On a cold cache that is a visible gap, so pass a `placeholder` to fill it: + +```jsx +} /> +``` + +It is rendered centred over the container and removed once the editor is live. It is **not** removed when the mount fails, so a failed load never leaves a blank box — pair it with `onError` to swap in a failure message. + ## Error handling Two distinct channels: diff --git a/src/ImageEditor.tsx b/src/ImageEditor.tsx index acae488..22c3ae6 100644 --- a/src/ImageEditor.tsx +++ b/src/ImageEditor.tsx @@ -21,6 +21,7 @@ function ImageEditorInner( style = {}, wrapperStyle = {}, ariaLabel = 'Image editor', + placeholder, } = props; const [editor, setEditor] = useState(null); @@ -202,12 +203,20 @@ function ImageEditorInner( // eslint-disable-next-line react-hooks/exhaustive-deps }, [editor, updatableKey]); + // Rendered as an overlay sibling rather than a child of the mount + // container: the embed owns that container's DOM, and React must not + // reconcile children in and out from under it. + const showPlaceholder = placeholder !== undefined && editor === null; + return (
@@ -222,6 +231,19 @@ function ImageEditorInner( // flex first: a default the consumer's style can override. style={{ flex: 1, ...style }} /> + {showPlaceholder && ( +
+ {placeholder} +
+ )}
); } diff --git a/src/types.ts b/src/types.ts index fb1b831..a036598 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, @@ -113,6 +113,13 @@ export interface ImageEditorProps { * and no name of its own. Defaults to 'Image editor'. */ ariaLabel?: string; + /** + * Rendered centred over the container until the editor is mounted — a + * spinner or skeleton for the embed's cold-cache load. Stays visible if + * the mount fails, so a blank box is never the end state; pair it with + * `onError` to swap in a failure message. + */ + placeholder?: ReactNode; /** * Override the embed script URL (e.g. to pin an environment). One embed * per page: the first loader to run installs window.ImageEditor and wins diff --git a/test/index.test.tsx b/test/index.test.tsx index 909c577..006b2cb 100644 --- a/test/index.test.tsx +++ b/test/index.test.tsx @@ -400,6 +400,73 @@ it('gives the editor region an accessible name, overridable via ariaLabel', asyn expect(container.getAttribute('aria-label')).toBe('Avatar cropper'); }); +it('shows the placeholder until the editor mounts, then removes it', async () => { + const created = defer(); + createEditor.mockReturnValueOnce(created.promise); + + render(Loading…

} />); + + // Visible for the whole embed load + createEditor round trip. + expect(document.body.textContent).toContain('Loading…'); + + await act(async () => { + created.resolve(mockInstance as unknown as ImageEditorInstance); + }); + + expect(document.body.textContent).not.toContain('Loading…'); +}); + +it('renders no placeholder element when the prop is absent', async () => { + const { container } = render(); + await flush(); + + const wrapper = container.firstElementChild as HTMLElement; + // Just the mount container — no overlay, and no positioning added. + expect(wrapper.children).toHaveLength(1); + expect(wrapper.style.position).toBe(''); +}); + +it('keeps the placeholder visible when the mount fails', async () => { + createEditor.mockRejectedValueOnce(new Error('bundle 404')); + + render( + Loading…

} + onError={vi.fn()} + /> + ); + await flush(); + + // A failed mount must not leave a blank box behind. + expect(document.body.textContent).toContain('Loading…'); +}); + +it('brings the placeholder back while a remount is in flight', async () => { + const { rerender } = render( + Loading…

} /> + ); + await flush(); + expect(document.body.textContent).not.toContain('Loading…'); + + const second = defer(); + createEditor.mockReturnValueOnce(second.promise); + rerender( + Loading…

} + options={{ projectId: 1 }} + /> + ); + + expect(document.body.textContent).toContain('Loading…'); + + await act(async () => { + second.resolve(makeInstance() as unknown as ImageEditorInstance); + }); + expect(document.body.textContent).not.toContain('Loading…'); +}); + it('passes onCancel and onLoadError through to the editor', async () => { const onCancel = vi.fn(); const onLoadError = vi.fn();