diff --git a/CHANGELOG.md b/CHANGELOG.md index cf186d27..7c662fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### 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.0.0 (2026-07-06) Modernization release. The component API is unchanged — most apps on React 16.8+ with a current toolchain can upgrade without any code changes. 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/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); +});