diff --git a/CHANGELOG.md b/CHANGELOG.md index 5595b941..811f0f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2.1.2 (2026-08-11) + +### Fixed + +- **SSR/hydration:** auto-generated editor ids now use React's `useId` (React 18+) so the id is identical on the server render and during client hydration. Previously the id came from a module-level counter that diverges between server and client, causing a hydration mismatch and — because the editor then mounted against a stale server id — a silently blank editor under SSR (e.g. the Next.js App Router). React 16.8/17 keep the legacy counter fallback. Pass an explicit `editorId` to opt out. ([EmailEditor.tsx](src/EmailEditor.tsx)) + ## 2.1.1 (2026-08-11) ### Fixed diff --git a/package-lock.json b/package-lock.json index 7784c4c2..d8d706e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-email-editor", - "version": "2.1.1", + "version": "2.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "react-email-editor", - "version": "2.1.1", + "version": "2.1.2", "license": "MIT", "dependencies": { "@unlayer/types": "^1.448.0" diff --git a/package.json b/package.json index 6076a96e..9e3a4779 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-email-editor", - "version": "2.1.1", + "version": "2.1.2", "description": "Unlayer's Email Editor Component for React.js", "type": "commonjs", "main": "./dist/index.js", diff --git a/src/EmailEditor.tsx b/src/EmailEditor.tsx index c3e563d3..0ca70c23 100644 --- a/src/EmailEditor.tsx +++ b/src/EmailEditor.tsx @@ -16,6 +16,25 @@ const win = typeof window === 'undefined' ? { __unlayer_lastEditorId: 0 } : window; win.__unlayer_lastEditorId = win.__unlayer_lastEditorId || 0; +// Legacy fallback for React 16.8/17, which have no useId. Not hydration-safe, +// but those versions predate the modern SSR story. Exercised only by the React +// 16/17 smoke suite (npm run test:legacy), which runs without coverage. +/* v8 ignore start */ +const useCounterEditorId = (): string => + useMemo(() => `editor-${++win.__unlayer_lastEditorId}`, []); +/* v8 ignore stop */ + +// React 18+ exposes useId, which returns an identifier that is identical on the +// server render and during client hydration — the correct fix for the id +// mismatch that otherwise leaves the editor mounting against a stale server id +// (blank editor) under SSR/Next.js. The implementation is picked once at module +// load (stable for the app's lifetime), so the same hook runs on every render. +const useGeneratedEditorId: () => string = + typeof React.useId === 'function' + ? // Strip ':' so the id is a valid CSS selector for unlayer.createEditor. + () => `editor-${React.useId().replace(/:/g, '')}` + : useCounterEditorId; + function EmailEditorInner< TDisplayMode extends DisplayMode | undefined = 'email', >( @@ -30,10 +49,10 @@ function EmailEditorInner< const [hasLoadedEmbedScript, setHasLoadedEmbedScript] = useState(false); - const editorId = useMemo( - () => props.editorId || `editor-${++win.__unlayer_lastEditorId}`, - [props.editorId] - ); + // Always call the hook (rules of hooks); the generated id is only used when + // no explicit editorId prop is provided. + const generatedId = useGeneratedEditorId(); + const editorId = props.editorId || generatedId; const options = { ...(props.options || {}), diff --git a/test/index.test.tsx b/test/index.test.tsx index 3e6fc1dd..a050e2cd 100644 --- a/test/index.test.tsx +++ b/test/index.test.tsx @@ -72,6 +72,13 @@ it('registers on* props as editor event listeners and onReady on editor:ready', 'editor:ready', expect.any(Function) ); + + // Fire editor:ready and confirm onReady is invoked with the editor instance. + const readyCall = mockEditor.addEventListener.mock.calls.find( + ([type]) => type === 'editor:ready' + ); + readyCall?.[1](); + expect(onReady).toHaveBeenCalledWith(mockEditor); }); it('destroys the editor on unmount', () => { diff --git a/test/ssr.test.tsx b/test/ssr.test.tsx new file mode 100644 index 00000000..6de4507a --- /dev/null +++ b/test/ssr.test.tsx @@ -0,0 +1,68 @@ +import React, { act } from 'react'; +import { renderToString } from 'react-dom/server'; +import { hydrateRoot } from 'react-dom/client'; +import EmailEditor from '../src'; + +// Resolve the embed script synchronously and stub the editor instance so the +// mount effects run without hitting the network. +vi.mock('../src/loadScript', () => ({ + loadScript: (callback: Function) => callback(), +})); + +// Raw react-dom (not @testing-library) is used here to control the SSR -> +// hydrate boundary, so opt in to act() support explicitly. +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +beforeEach(() => { + (globalThis as any).unlayer = { + createEditor: vi.fn(() => ({ + addEventListener: vi.fn(), + destroy: vi.fn(), + })), + }; +}); + +const editorIdIn = (root: ParentNode) => + root.querySelector('[id^="editor-"]')?.id; + +const parseId = (html: string) => { + const el = document.createElement('div'); + el.innerHTML = html; + return editorIdIn(el); +}; + +it('derives the auto id from tree position, so repeat renders agree', () => { + // The old module-level counter produced a different id on every render + // (editor-1, editor-2, ...). Two independent renders of the same tree must + // now produce the same id, which is what makes server and client agree. + const first = renderToString(); + const second = renderToString(); + + expect(parseId(first)).toBeTruthy(); + expect(parseId(first)).toBe(parseId(second)); +}); + +it('hydrates a server-rendered editor without an id mismatch', () => { + const serverHtml = renderToString(); + const container = document.createElement('div'); + document.body.appendChild(container); + container.innerHTML = serverHtml; + + const serverId = editorIdIn(container); + expect(serverId).toBeTruthy(); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + act(() => { + hydrateRoot(container, ); + }); + + // Same id after hydration, and React logged no hydration-mismatch warning. + expect(editorIdIn(container)).toBe(serverId); + const hydrationWarning = errorSpy.mock.calls.some((args) => + args.some((a) => typeof a === 'string' && /hydrat|did not match/i.test(a)) + ); + expect(hydrationWarning).toBe(false); + + errorSpy.mockRestore(); + document.body.removeChild(container); +});