-
Notifications
You must be signed in to change notification settings - Fork 0
Description
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
- Use
createRequestHandlerwith arenderfunction that returns a full<html>document (as shown in the docs/demo) - Include any Radix UI primitive (AlertDialog, Dialog, etc.) in the app
- Load a server-rendered page in the browser
- Observe hydration mismatch warning on
aria-controlsattribute:+ 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:
- Change
createSsrHandlerto accept separaterenderShell(string template) +renderApp(React element) callbacks - Or have
createSsrHandlerautomatically extract and onlyrenderToStringthe content inside#root, wrapping the document shell as a string
Affected files
src/render/ssr.ts—createSsrHandler/injectScriptsrc/server/index.ts—createRequestHandler