Skip to content

Implement renderToString#21481

Draft
NullVoxPopuli-ai-agent wants to merge 6 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:render-component-ssr
Draft

Implement renderToString#21481
NullVoxPopuli-ai-agent wants to merge 6 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:render-component-ssr

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

What / Why

An experiment giving @ember/renderer a full server-side-rendering story — an alternative approach to #21480. Where #21480 bypasses the renderer machinery and drives the low-level glimmer renderComponent directly, this PR builds SSR on renderComponent's own pipeline with renderToString as a thin wrapper, and completes the round trip with client-side rehydration.

Design principle: a server render is a real render, not a degraded one. FastBoot's SimpleDOM-based model forced isInteractive: false (no modifiers), no settling, and a pile of "the server is different" special cases. This PR deliberately does not carry those forward:

  • Modifiers run during SSR, against real elements — and their DOM effects (attributes etc.) are serialized into the output.
  • renderToString is async and settles before serializing: if a modifier (or anything else during render) updates tracked state, the re-render completes before the promise resolves.
  • No isInteractive / hasDOM options on renderToString — those are always true.
  • No SimpleDOM, no document option. The environment must provide real DOM building blocks globally: the browser already does; in Node register them (e.g. happy-dom's GlobalRegistrator). This keeps the implementation identical to the browser path (instanceof checks and all) and means nothing new ships in the published build — serialization is just innerHTML.

renderToString

import { GlobalRegistrator } from '@happy-dom/global-registrator';
import { renderToString } from '@ember/renderer';

GlobalRegistrator.register(); // once, in Node — the browser already has a document

let html = await renderToString(MyComponent, { args: { name: 'Zoey' } });
// => "<h1>Hello, Zoey!</h1>"

Renders into a detached element through the normal renderer machinery (error-loop protection, transactions, destroyables — all shared with renderComponent), awaits renderSettled(), serializes via innerHTML, and tears the renderer down (running destructors). env: { rehydratable: true } emits glimmer's rehydration markers.

There is no env.document option: the environment defines the document, same as in the browser. And rehydratable is renderToString-only, just as rehydrate is renderComponent-only — internally the tree builder is chosen per render call (not per renderer), so a prior normal render never locks an owner out of rehydrating later.

Client-side rehydration

// server
let html = await renderToString(MyComponent, { args, env: { rehydratable: true } });

// client — adopts the server-rendered DOM instead of rebuilding it
renderComponent(MyComponent, { into: element, args, env: { rehydrate: true } });

env: { rehydrate: true } selects glimmer's rehydrationBuilder and skips the innerHTML = '' clearing (the existing contents are the render target).

Tests

Node + happy-dom, against the built package (tests/node-vitest/render-to-string.test.js, runs via pnpm test:node:vitest):

  • rendering with no DOM globals, {{{tripleStache}}} raw HTML
  • modifiers run during SSR and their DOM effects appear in the output
  • settle-before-serialize: tracked state updated by a modifier is reflected in the serialized HTML
  • rehydration markers

Browser suite (render-to-string-test.ts, all passing in headless Chrome):

  • components, args, raw HTML, modifiers-run, modifier DOM effects, settle-before-serialize, rehydration markers, no-into rendering
  • two end-to-end rehydration round trips (flat and nested) asserting the server-rendered DOM nodes are adopted — same node identity — rather than replaced

Also verified: all 436 existing rehydration-related tests pass, the RenderComponent module passes, build:js, type-check, and lint are clean. package.json and rollup.config.mjs have zero diff vs main.

Open questions

  • Which shape do we prefer — this (renderer-pipeline renderToString + rehydration) or [FEATURE] Add renderToString for server-side rendering #21480's standalone low-level path?
  • Should settling integrate with test-waiter-style waiters (beyond renderSettled()) so data-loading patterns can hold the serialization open?
  • Is rehydrate the right knob on renderComponent, or should rehydration be a separate entry point?

Marking as draft to start the conversation. cc @NullVoxPopuli for review.

Makes the `renderComponent` pipeline itself SSR-capable and adds a
`renderToString` export to `@ember/renderer` on top of it.

```js
import { renderToString } from '@ember/renderer';

let html = renderToString(MyComponent, { args: { name: 'Zoey' } });
// => "<h1>Hello, Zoey!</h1>"
```

Three layers:

1. `BaseRenderer` (and therefore `renderComponent`) now works against an
   in-memory SimpleDOM document: when handed one, it uses
   `NodeDOMTreeConstruction` (so `{{{tripleStache}}}` raw HTML works without
   `insertAdjacentHTML`), and `renderComponent` no longer references the bare
   `Element` global, which throws a ReferenceError in Node. You can pass a
   SimpleDOM document + element via `env.document` / `into` and render
   server-side with the exact same code path as the browser.

2. `renderToString(component, { owner, args, env })`: a thin wrapper that
   creates a SimpleDOM document, renders into a detached element via the
   normal renderer machinery, serializes the children to a string, and tears
   the renderer down synchronously (one-shot, non-interactive by default).
   `env: { rehydratable: true }` emits glimmer's rehydration markers.

3. Client-side rehydration: `renderComponent(component, { into, env:
   { rehydrate: true } })` adopts server-rendered markup already present in
   `into` (produced by `renderToString` with `rehydratable: true`) instead of
   clearing it and building fresh DOM, completing the SSR round trip.

Also fixes a latent bug in `renderComponent`'s re-render positioning path,
which assumed any non-`Element` target was a `Cursor` and read `.element`
off of it (undefined for SimpleDOM elements).

`@simple-dom/serializer` + `@simple-dom/void-map` (previously test-only) are
inlined into the build to serialize the SimpleDOM tree.

Tested: new integration suite covering renderToString (args, raw HTML,
glimmer components, non-interactive modifiers, rehydration markers),
renderComponent into SimpleDOM, and two end-to-end rehydration round trips
asserting the server-rendered DOM nodes are adopted rather than replaced.
Verified in Node (no DOM globals): clean render, rehydratable render, and
direct renderComponent-into-SimpleDOM all produce correct HTML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

@NullVoxPopuli — tagging you for review (the formal reviewer-request API needs triage permission this account lacks). This is the second, alternative take on SSR for renderComponent — compare with #21480. Key differences: the renderComponent pipeline itself is SSR-capable (SimpleDOM-aware BaseRenderer, two Node incompatibilities fixed), renderToString is a thin wrapper over it, and the round trip is completed with env: { rehydrate: true } on renderComponent, covered by end-to-end rehydration tests asserting DOM-node adoption.

Rework `renderToString` so server rendering is the same kind of render as the
browser performs, rather than the degraded mode FastBoot forced:

- Always interactive: modifiers run against real elements. `isInteractive`
  and `hasDOM` are no longer options — a server render is a real render.
- Async: the returned promise resolves only after rendering has settled
  (via `renderSettled()`). Tracked state updated during render — e.g. by a
  modifier — is reflected in the serialized output.
- Requires a real DOM implementation: the global `document` in the browser,
  or one provided via `env.document` in Node — e.g. happy-dom's
  `new Window().document`. SimpleDOM is rejected with a pointer to
  `renderComponent` for those who want static SimpleDOM rendering.
- Serializes via `innerHTML`, so `@simple-dom/serializer` is no longer
  bundled into the published build (reverted to a test-only dependency).

Cross-realm DOM support: the tree-construction and innerHTML-clearing guards
now use capability detection (`createRawHTMLSection` presence, `'innerHTML'
in target`) instead of `instanceof Document`/`instanceof Element`, which
mis-classify documents from another realm — exactly what a happy-dom
`new Window()` in Node is.

Adds a Node-based vitest suite (tests/node-vitest/render-to-string.test.js)
running against the built ember-source package with happy-dom, covering:
rendering without DOM globals, `{{{tripleStache}}}`, modifiers running during
SSR with their DOM effects serialized, settle-before-serialize, and
rehydration markers. Browser suite updated: modifiers-run and
settle-before-serialize tests replace the old non-interactive test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Pushed a rework addressing the FastBoot-mistakes concern (10653a8):

  • happy-dom is now a first-class, tested path: tests/node-vitest/render-to-string.test.js runs against the built package in plain Node with happy-dom providing the DOM. The instanceof Document/instanceof Element guards are replaced with capability detection, since a cross-realm new Window().document is not an instance of the host realm's Document.
  • Modifiers always run during SSRisInteractive and hasDOM are no longer renderToString options; a server render is a real render. Modifier-driven DOM effects (e.g. attributes) are serialized into the output.
  • renderToString is now async and settles before serializing: it awaits renderSettled(), so tracked state updated during render (the test mutates state from a modifier) is reflected in the output before the promise resolves. There's a test asserting exactly the FastBoot regression this must not repeat.
  • Serialization is plain innerHTML, so @simple-dom/serializer is back to test-only — nothing new ships in the published build.

SimpleDOM is still supported at the renderComponent layer for static rendering, but renderToString rejects it with a pointer, since it can't deliver the always-interactive contract.

Per review direction: this feature does not support environments that lack
basic DOM building blocks (`Document`, `Element`), so the cross-realm
capability-detection workarounds and the SimpleDOM tree-construction branch
are removed:

- `BaseRenderer` no longer special-cases SimpleDOM documents; the
  constructor is back to exactly what upstream does.
- `renderComponent`'s `instanceof Element` guards are restored verbatim
  (plus the `rehydrate` condition).
- `renderToString` types `env.document` as a real `Document` and no longer
  rejects/branches on SimpleDOM. Note it still never touches the `Element`
  global itself, so passing `env.document` from a bare happy-dom
  `new Window()` works without registering globals.
- The renderComponent-into-SimpleDOM test is removed, and
  `@simple-dom/serializer` / `@simple-dom/void-map` come out of the root
  devDependencies — `package.json` and `rollup.config.mjs` now have zero
  diff vs main.

Re-verified: browser module 11/11, all 436 rehydration-related tests,
RenderComponent module 42/42 (+2 pre-existing skips), Node+happy-dom vitest
suite 7/7 against the rebuilt package, type-check and lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Simplified per direction (ee59b2a): SimpleDOM support is dropped from this feature — environments must provide real DOM building blocks (browser, or happy-dom in Node).

  • BaseRenderer no longer branches on document type; the constructor is byte-identical to main again.
  • The instanceof Element guards in renderComponent are restored verbatim (plus the rehydrate condition).
  • The capability-detection helpers, the SimpleDOM tree-construction branch, the renderComponent-into-SimpleDOM test, and the @simple-dom/serializer/void-map devDeps are all gone — package.json and rollup.config.mjs now have zero diff vs main.

One detail worth noting: renderToString itself never touches the Element/Document globals, so env.document from a bare happy-dom new Window() still works without registering globals — only renderComponent(into:) requires Element to exist. All suites re-verified: browser 11/11, rehydration 436/436, Node+happy-dom 7/7 against the rebuilt package.

…vironment

Two API refinements per review:

- `rehydratable` is no longer accepted by `renderComponent` (it leaked in via
  the shared `BaseRenderer.strict` options). Builder selection moved out of
  `strict` (which now takes an optional internal `builder`) and into each
  entry point: `renderToString` knows only `rehydratable` (server half),
  `renderComponent` knows only `rehydrate` (client half).

- `renderToString` no longer accepts `env.document`. The environment defines
  the document — the global `document`, same as the browser. In Node,
  register DOM building blocks globally first (e.g. happy-dom's
  `GlobalRegistrator.register()`). The node-vitest suite now runs with
  vitest's happy-dom environment (registered globals) instead of passing
  per-render documents.

Re-verified: browser module 11/11, rehydration-related 436/436, Node
happy-dom vitest 7/7 against the rebuilt package, type-check and lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Two more API tightenings (92af170):

  • rehydratable is no longer a renderComponent option — it was leaking in through the shared BaseRenderer.strict options. Builder selection is now internal (strict takes a private builder), and each entry point exposes exactly its half of the handshake: renderToStringrehydratable (emit markers), renderComponentrehydrate (adopt markup).
  • renderToString no longer accepts env.document — the environment defines the document, same as the browser. In Node, register DOM globals first (happy-dom GlobalRegistrator.register()); the node-vitest suite now runs under vitest's happy-dom environment to model exactly that.

All suites re-verified: browser 11/11, rehydration 436/436, Node happy-dom 7/7 against the rebuilt package.

`BaseRenderer.strict` no longer takes a builder (back to the upstream
shape, always `clientBuilder` as the renderer default). Instead,
`renderer.render()` accepts an optional per-call `builder`, threaded to the
root it creates: `renderComponent` passes `rehydrationBuilder` when
`env.rehydrate` is set, `renderToString` passes `serializeBuilder` when
`env.rehydratable` is set.

This fixes a real bug in the previous shape: `renderComponent` caches one
renderer per owner, so a builder baked into the renderer was locked in by
whichever render happened first — an owner that had already rendered
normally could never rehydrate. The builder is a property of an individual
render (the same renderer can rehydrate one root and client-build the
next), so it now travels with the render call. New regression test covers
exactly that sequence: normal render, then rehydration, same owner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Good catch on the builder (16054ba). It shouldn't take one — that was my layering mistake, and it was hiding a real bug.

The tree builder (client-build vs rehydrate vs serialize) is a property of an individual render, not of the renderer: the same renderer/environment can rehydrate one root and client-build the next. But I had threaded it through BaseRenderer.strict, making it a renderer property — and since renderComponent caches one renderer per owner, the builder got baked in by whichever render happened first. An owner that had already rendered normally could never rehydrate.

Now: strict is back to the upstream shape (no builder, clientBuilder default), and renderer.render() takes an optional per-call builder threaded to the root it creates. renderComponent maps env.rehydraterehydrationBuilder; renderToString maps env.rehydratableserializeBuilder. Neither public API accepts a builder.

New regression test covers the previously-broken sequence — normal render, then rehydration, same owner — asserting the server node is still adopted. All suites re-verified: browser 12/12, rehydration 437/437, Node happy-dom 7/7.

@NullVoxPopuli NullVoxPopuli changed the title [FEATURE] SSR support for renderComponent + renderToString renderToString Jul 2, 2026
CI's lint:format caught two files with stray blank lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent changed the title renderToString [FEATURE render-to-string] SSR support for renderComponent + renderToString Jul 2, 2026
@NullVoxPopuli NullVoxPopuli changed the title [FEATURE render-to-string] SSR support for renderComponent + renderToString Implement renderToString Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants