Implement renderToString#21481
Conversation
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 — tagging you for review (the formal reviewer-request API needs triage permission this account lacks). This is the second, alternative take on SSR for |
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>
|
Pushed a rework addressing the FastBoot-mistakes concern (10653a8):
SimpleDOM is still supported at the |
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>
|
Simplified per direction (ee59b2a): SimpleDOM support is dropped from this feature — environments must provide real DOM building blocks (browser, or happy-dom in Node).
One detail worth noting: |
…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>
|
Two more API tightenings (92af170):
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>
|
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 Now: 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. |
renderComponent + renderToStringrenderToString
CI's lint:format caught two files with stray blank lines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
renderToStringrenderComponent + renderToString
renderComponent + renderToStringrenderToString
What / Why
An experiment giving
@ember/renderera full server-side-rendering story — an alternative approach to #21480. Where #21480 bypasses the renderer machinery and drives the low-level glimmerrenderComponentdirectly, this PR builds SSR onrenderComponent's own pipeline withrenderToStringas 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:renderToStringis async and settles before serializing: if a modifier (or anything else during render) updates tracked state, the re-render completes before the promise resolves.isInteractive/hasDOMoptions onrenderToString— those are always true.documentoption. The environment must provide real DOM building blocks globally: the browser already does; in Node register them (e.g. happy-dom'sGlobalRegistrator). This keeps the implementation identical to the browser path (instanceofchecks and all) and means nothing new ships in the published build — serialization is justinnerHTML.renderToStringRenders into a detached element through the normal renderer machinery (error-loop protection, transactions, destroyables — all shared with
renderComponent), awaitsrenderSettled(), serializes viainnerHTML, and tears the renderer down (running destructors).env: { rehydratable: true }emits glimmer's rehydration markers.There is no
env.documentoption: the environment defines the document, same as in the browser. AndrehydratableisrenderToString-only, just asrehydrateisrenderComponent-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
env: { rehydrate: true }selects glimmer'srehydrationBuilderand skips theinnerHTML = ''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 viapnpm test:node:vitest):{{{tripleStache}}}raw HTMLBrowser suite (
render-to-string-test.ts, all passing in headless Chrome):intorenderingAlso verified: all 436 existing rehydration-related tests pass, the
RenderComponentmodule passes,build:js, type-check, and lint are clean.package.jsonandrollup.config.mjshave zero diff vsmain.Open questions
renderToString+ rehydration) or [FEATURE] AddrenderToStringfor server-side rendering #21480's standalone low-level path?renderSettled()) so data-loading patterns can hold the serialization open?rehydratethe right knob onrenderComponent, or should rehydration be a separate entry point?Marking as draft to start the conversation. cc @NullVoxPopuli for review.