From a9408ce91f923459f239ce4bf8076b78eaca5d1a Mon Sep 17 00:00:00 2001 From: jawad Akram Date: Fri, 24 Jul 2026 14:25:21 +0500 Subject: [PATCH] fix: use useId for auto-generated editor id to fix SSR hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-generated editor id came from a module-level counter (`editor-${++win.__unlayer_lastEditorId}`). On a warm SSR server the counter keeps climbing across requests while every fresh client starts at 0, so the server and client render different ids. React does not patch the attribute mismatch on hydration, and the mount effect then calls `unlayer.createEditor` against the stale server id — a div that no longer matches — leaving a silently blank editor under SSR (e.g. the Next.js App Router, which this package already targets via the `'use client'` banner). Use React 18+'s `useId`, whose value is identical on the server render and during client hydration. React 16.8/17 (still in the peer range) have no useId and keep the legacy counter fallback. Passing an explicit `editorId` is unaffected. Adds test/ssr.test.tsx, which reproduces the hydration mismatch (fails on the old counter, passes with useId). --- CHANGELOG.md | 6 ++++ src/EmailEditor.tsx | 27 +++++++++++++++--- test/ssr.test.tsx | 68 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 test/ssr.test.tsx 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); +});