Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
27 changes: 23 additions & 4 deletions src/EmailEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
>(
Expand All @@ -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 || {}),
Expand Down
68 changes: 68 additions & 0 deletions test/ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('[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(<EmailEditor />);
const second = renderToString(<EmailEditor />);

expect(parseId(first)).toBeTruthy();
expect(parseId(first)).toBe(parseId(second));
});

it('hydrates a server-rendered editor without an id mismatch', () => {
const serverHtml = renderToString(<EmailEditor />);
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, <EmailEditor />);
});

// 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);
});