Skip to content

SSR hydration mismatch: renderToString renders full HTML document while client hydrates at #root #62

@dallenpyrah

Description

@dallenpyrah

Summary

createSsrHandler passes the entire element returned by the render callback to ReactDOMServer.renderToString(). When the render function returns a full <html> document (which the API design encourages — injectScript even looks for </body>), the React tree on the server includes <html>, <head>, <body>, and <div id="root"> as React elements above the app content.

On the client, hydrateRoot(document.getElementById("root"), <EffectProvider><App/></EffectProvider>) hydrates only from #root inward.

This tree depth mismatch causes React's useId() to generate different identifiers on server vs client, which breaks any library that relies on useId() for attribute matching (e.g. Radix UI's aria-controls).

Reproduction

  1. Use createRequestHandler with a render function that returns a full <html> document (as shown in the docs/demo)
  2. Include any Radix UI primitive (AlertDialog, Dialog, etc.) in the app
  3. Load a server-rendered page in the browser
  4. Observe hydration mismatch warning on aria-controls attribute:
    + aria-controls="radix-_R_ap_"    (client)
    - aria-controls="radix-_R_ap6_"   (server)
    

Root cause

  • Server: renderToString(<html><head>...</head><body><div id="root"><EffectProvider><App/></EffectProvider></div>...</body></html>) — useId tree path includes html/head/body/div ancestors
  • Client: hydrateRoot(rootEl, <EffectProvider><App/></EffectProvider>) — useId tree path starts at EffectProvider

The different tree depths produce different useId() values for the same component.

Suggested fix

Split the SSR rendering so only the app content (<EffectProvider><App/>) goes through renderToString, with the HTML document shell assembled as a plain string template. This ensures the React tree boundary matches between server and client.

Options:

  1. Change createSsrHandler to accept separate renderShell (string template) + renderApp (React element) callbacks
  2. Or have createSsrHandler automatically extract and only renderToString the content inside #root, wrapping the document shell as a string

Affected files

  • src/render/ssr.tscreateSsrHandler / injectScript
  • src/server/index.tscreateRequestHandler

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions