Skip to content

fix: use useId for auto-generated editor id to fix SSR hydration - #495

Open
jawadakram20 wants to merge 1 commit into
unlayer:masterfrom
jawadakram20:fix/ssr-hydration-editor-id
Open

fix: use useId for auto-generated editor id to fix SSR hydration#495
jawadakram20 wants to merge 1 commit into
unlayer:masterfrom
jawadakram20:fix/ssr-hydration-editor-id

Conversation

@jawadakram20

Copy link
Copy Markdown

Fix SSR hydration mismatch for the auto-generated editor id

The problem

When <EmailEditor /> is rendered without an explicit editorId, the id is generated from a module-level counter:

const editorId = useMemo(
  () => props.editorId || `editor-${++win.__unlayer_lastEditorId}`,
  [props.editorId]
);

Under SSR this diverges between server and client:

  • On a warm server the module counter persists across requests, so successive renders emit editor-2, editor-3, ….
  • Every fresh browser starts at 0, so the client computes editor-1.

React does not patch attribute mismatches during hydration, so the DOM keeps the server's id while the mount effect calls unlayer.createEditor({ id: 'editor-1' }) against an element that no longer matches — the editor mounts on a missing container and renders blank, with no error.

This bites the Next.js App Router specifically, which this package already targets via the 'use client' banner added in 2.0.0.

The fix

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, so they keep the legacy counter fallback. The implementation is selected once at module load, so the same hook runs on every render (rules of hooks are respected). Passing an explicit editorId is unaffected.

Testing

Adds test/ssr.test.tsx, which renders on the server with renderToString, then hydrates with hydrateRoot, and asserts the id is stable with no hydration-mismatch warning. The test fails on the old counter (server/client id divergence + React hydration warning) and passes with useId.

  • npm test — 10 passing (8 existing + 2 new)
  • npm run typecheck, npm run build, npm run lint — clean
  • npm run test:coverage — above thresholds (the legacy fallback is v8 ignore-d since it is exercised only by the React 16/17 test:legacy smoke suite, which runs without coverage)

Notes

  • No public API change. editorId still wins when provided.
  • useId output is stripped of : so the generated id stays a valid CSS selector for unlayer.createEditor.

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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant