From 1aa80d3a14e1da2fe7cd4bc647a4e327f2655a39 Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Tue, 30 Jun 2026 10:36:11 -0400
Subject: [PATCH 1/6] [FEATURE] SSR support for renderComponent +
renderToString
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' } });
// => "
Hello, Zoey! "
```
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)
---
package.json | 2 +
packages/@ember/-internals/glimmer/index.ts | 1 +
.../-internals/glimmer/lib/base-renderer.ts | 185 ++++++++++++++++-
.../@ember/-internals/glimmer/lib/renderer.ts | 1 +
.../components/render-to-string-test.ts | 195 ++++++++++++++++++
packages/@ember/renderer/index.ts | 34 +++
pnpm-lock.yaml | 6 +
rollup.config.mjs | 14 +-
tests/docs/expected.cjs | 1 +
9 files changed, 424 insertions(+), 15 deletions(-)
create mode 100644 packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
diff --git a/package.json b/package.json
index 86c1d3d37db..73404c8b1e8 100644
--- a/package.json
+++ b/package.json
@@ -95,6 +95,8 @@
"@octokit/rest": "^22.0.0",
"@rollup/plugin-babel": "^6.0.4",
"@simple-dom/document": "^1.4.0",
+ "@simple-dom/serializer": "^1.4.0",
+ "@simple-dom/void-map": "^1.4.0",
"@swc-node/register": "^1.6.8",
"@swc/core": "^1.3.100",
"@tsconfig/ember": "3.0.8",
diff --git a/packages/@ember/-internals/glimmer/index.ts b/packages/@ember/-internals/glimmer/index.ts
index abb5506b78f..72138f9f5be 100644
--- a/packages/@ember/-internals/glimmer/index.ts
+++ b/packages/@ember/-internals/glimmer/index.ts
@@ -516,6 +516,7 @@ export {
_resetRenderers,
renderSettled,
renderComponent,
+ renderToString,
type View,
} from './lib/renderer';
export {
diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts
index 955511c4389..9daf2833c28 100644
--- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts
+++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts
@@ -23,9 +23,16 @@ import type {
import { artifacts } from '@glimmer/program/lib/helpers';
import { RuntimeOpImpl } from '@glimmer/program/lib/opcode';
import { clientBuilder } from '@glimmer/runtime/lib/vm/element-builder';
+import { rehydrationBuilder } from '@glimmer/runtime/lib/vm/rehydrate-builder';
+import { serializeBuilder } from '@glimmer/node/lib/serialize-builder';
+import NodeDOMTreeConstruction from '@glimmer/node/lib/node-dom-helper';
+import { DOMChangesImpl } from '@glimmer/runtime/lib/dom/helper';
import { inTransaction, runtimeOptions } from '@glimmer/runtime/lib/environment';
import { renderComponent as glimmerRenderComponent } from '@glimmer/runtime/lib/render';
import { CURRENT_TAG, validateTag, valueForTag } from '@glimmer/validator/lib/validators';
+import createHTMLDocument from '@simple-dom/document';
+import Serializer from '@simple-dom/serializer';
+import voidMap from '@simple-dom/void-map';
import type { SimpleDocument, SimpleElement } from '@simple-dom/interface';
import { hasDOM } from '../../browser-environment';
import { EmberEnvironmentDelegate } from './environment';
@@ -434,6 +441,15 @@ function intoTarget(into: IntoTarget): Cursor {
}
}
+/**
+ * `Element` only exists in a real DOM environment; SSR targets (SimpleDOM
+ * elements) are never instances of it. Referencing the bare `Element` global
+ * from Node.js would throw a ReferenceError, so guard on its existence.
+ */
+function isDOMElement(target: IntoTarget): target is Element {
+ return typeof Element !== 'undefined' && target instanceof Element;
+}
+
/**
* Render a component into a DOM element.
*
@@ -480,6 +496,13 @@ export function renderComponent(
* When false, modifiers will not run.
*/
isInteractive?: boolean;
+ /**
+ * When true, the render adopts (rehydrates) server-rendered markup
+ * already present in `into` — produced by `renderToString` with
+ * `env: { rehydratable: true }` — instead of clearing it and building
+ * fresh DOM.
+ */
+ rehydrate?: boolean;
/**
* All other options are forwarded to the underlying renderer.
* (its API is currently private and out of scope for this RFC,
@@ -533,8 +556,11 @@ export function renderComponent(
* We can only replace the inner HTML the first time.
* Because destruction is async, it won't be safe to
* do this again, and we'll have to rely on the above destroy.
+ *
+ * When rehydrating, the existing contents *are* the server-rendered markup
+ * that the render below will adopt, so they must not be cleared.
*/
- if (!existing && into instanceof Element) {
+ if (!existing && isDOMElement(into) && !env?.rehydrate) {
into.innerHTML = '';
}
@@ -552,8 +578,12 @@ export function renderComponent(
*/
let renderTarget: IntoTarget = into;
if (existing?.glimmerResult) {
+ // A cursor carries its parent on `.element`; both DOM and SimpleDOM
+ // elements *are* the parent. (Mirrors `intoTarget` above — an
+ // `instanceof Element` check would throw in Node and would mis-handle
+ // SimpleDOM elements.)
let parentElement =
- into instanceof Element ? (into as unknown as SimpleElement) : (into as Cursor).element;
+ 'element' in into ? into.element : (into as unknown as SimpleElement);
let firstNode = existing.glimmerResult.firstNode();
renderTarget = { element: parentElement, nextSibling: firstNode };
}
@@ -580,18 +610,151 @@ export function renderComponent(
const RENDER_CACHE = new WeakMap();
const RENDERER_CACHE = new WeakMap();
+/**
+ * Render a component to an HTML string, without needing a live DOM.
+ *
+ * This is the server-side-rendering (SSR) counterpart to
+ * {@link renderComponent}: the same rendering pipeline, pointed at an
+ * in-memory [SimpleDOM](https://github.com/ember-fastboot/simple-dom) document
+ * instead of a live one, with the result serialized to a string. That makes it
+ * usable from Node.js (or any environment without a global `document`), which
+ * is exactly what a server needs in order to produce HTML for the initial page
+ * load.
+ *
+ * ```js
+ * import { renderToString } from '@ember/renderer';
+ *
+ * let html = renderToString(MyComponent, { args: { name: 'Zoey' } });
+ * // => "Hello, Zoey! "
+ * ```
+ *
+ * Rendering is a synchronous, one-shot operation: the component tree is
+ * rendered, serialized, and torn down (running any component destructors)
+ * before this function returns, so no reactivity or re-rendering occurs.
+ * Rendering is non-interactive by default (modifiers do not run), matching the
+ * constraints of a server environment. Pass `env: { rehydratable: true }` to
+ * include glimmer's rehydration markers in the output so a subsequent
+ * client-side render can re-use (rehydrate) the server-rendered markup rather
+ * than throwing it away.
+ *
+ * @method renderToString
+ * @static
+ * @for @ember/renderer
+ * @param {Object} component The component to render.
+ * @param {Object} [options]
+ * @param {Object} [options.owner] Optionally specify the owner to use. This will be used for injections, and overall cleanup.
+ * @param {Object} [options.args] Optionally pass args in to the component.
+ * @param {Object} [options.env] Optional renderer configuration. `isInteractive` (default `false`) controls whether modifiers run; `rehydratable` (default `false`) controls whether rehydration markers are emitted.
+ * @returns {String} the serialized HTML for the rendered component
+ * @public
+ */
+export function renderToString(
+ /**
+ * The component definition to render. Any component that has had its manager
+ * registered is valid, same as {@link renderComponent}.
+ */
+ component: object,
+ {
+ owner = {},
+ args,
+ env,
+ }: {
+ /**
+ * Optional owner. Defaults to `{}`, can be any object, but will need to
+ * implement the [Owner](https://api.emberjs.com/ember/release/classes/Owner)
+ * API for components within this render tree to access services.
+ */
+ owner?: object;
+ /**
+ * These args get passed to the rendered component.
+ */
+ args?: Record;
+ /**
+ * Optionally configure the rendering environment.
+ */
+ env?: {
+ /**
+ * When true, modifiers will run. Defaults to `false`, since server
+ * rendering is typically non-interactive.
+ */
+ isInteractive?: boolean;
+ /**
+ * When true, the emitted HTML includes glimmer's rehydration markers so
+ * the output can be re-used (rehydrated) by a subsequent client-side
+ * render. Defaults to `false`, which produces clean HTML with no
+ * framework-specific comments.
+ */
+ rehydratable?: boolean;
+ /**
+ * All other options are forwarded to the underlying renderer (private API).
+ */
+ [rendererOption: string]: unknown;
+ };
+ } = {}
+): string {
+ let document = createHTMLDocument();
+ // Render into a detached wrapper element and serialize its *children*, so
+ // the returned string is only the component's own markup.
+ let element = document.createElement('div');
+
+ // Build a dedicated renderer rather than going through the per-owner
+ // renderer cache used by `renderComponent`: SSR output must target the
+ // in-memory document created above, even if this owner has previously
+ // rendered into a live DOM.
+ let renderer = BaseRenderer.strict(owner, document, {
+ ...env,
+ isInteractive: env?.isInteractive ?? false,
+ hasDOM: false,
+ rehydratable: Boolean(env?.rehydratable),
+ });
+
+ try {
+ renderer.render(component, { into: element, args });
+
+ return new Serializer(voidMap).serializeChildren(element);
+ } finally {
+ // One-shot render: tear the whole renderer down synchronously so component
+ // destructors run and nothing stays registered with the run loop.
+ _backburner.run(() => renderer.destroy());
+ }
+}
+
+/**
+ * A real `Document` supports DOM APIs (like `insertAdjacentHTML`, used by
+ * `{{{tripleStache}}}`) that an in-memory SimpleDOM document does not, so the
+ * renderer picks its tree-construction strategy based on which one it is
+ * handed. Anything that isn't a browser `Document` is treated as SimpleDOM.
+ */
+function isRealDocument(document: SimpleDocument | Document): document is Document {
+ return typeof Document !== 'undefined' && document instanceof Document;
+}
+
export class BaseRenderer {
static strict(
owner: object,
document: SimpleDocument | Document,
- options: { isInteractive: boolean; hasDOM?: boolean }
+ options: {
+ isInteractive: boolean;
+ hasDOM?: boolean;
+ rehydratable?: boolean;
+ rehydrate?: boolean;
+ }
) {
+ let { rehydratable, rehydrate, ...envOptions } = options;
+
+ // `serializeBuilder` emits glimmer's rehydration markers alongside the
+ // markup (the server half of rehydration); `rehydrationBuilder` consumes
+ // those markers to adopt existing server-rendered DOM instead of building
+ // fresh nodes (the client half); `clientBuilder` builds clean markup from
+ // scratch.
+ let builder = rehydrate ? rehydrationBuilder : rehydratable ? serializeBuilder : clientBuilder;
+
return new BaseRenderer(
owner,
- { hasDOM: hasDOM, ...options },
+ { hasDOM: hasDOM, ...envOptions },
document as SimpleDocument,
new ResolverImpl(),
- clientBuilder
+ builder
);
}
@@ -614,7 +777,17 @@ export class BaseRenderer {
* an app needs (which will actually change and become less over time)
*/
let env = new EmberEnvironmentDelegate(owner as InternalOwner, envOptions.isInteractive);
- let options = runtimeOptions({ document }, env, sharedArtifacts, resolver);
+ // A SimpleDOM document (e.g. during SSR) needs SimpleDOM-aware tree
+ // construction: the default `DOMTreeConstruction` relies on real-DOM APIs
+ // (`insertAdjacentHTML`, SVG namespace handling) that SimpleDOM does not
+ // implement. `NodeDOMTreeConstruction` appends raw HTML sections instead.
+ let environmentOptions = isRealDocument(document)
+ ? { document }
+ : {
+ appendOperations: new NodeDOMTreeConstruction(document),
+ updateOperations: new DOMChangesImpl(document),
+ };
+ let options = runtimeOptions(environmentOptions, env, sharedArtifacts, resolver);
let context = new EvaluationContextImpl(
sharedArtifacts,
(heap) => new RuntimeOpImpl(heap),
diff --git a/packages/@ember/-internals/glimmer/lib/renderer.ts b/packages/@ember/-internals/glimmer/lib/renderer.ts
index e533c4042ba..2ffb24d6206 100644
--- a/packages/@ember/-internals/glimmer/lib/renderer.ts
+++ b/packages/@ember/-internals/glimmer/lib/renderer.ts
@@ -53,6 +53,7 @@ export {
ComponentRootState,
errorLoopTransaction,
renderComponent,
+ renderToString,
renderSettled,
_resetRenderers,
} from './base-renderer';
diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts b/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
new file mode 100644
index 00000000000..b4fc117569c
--- /dev/null
+++ b/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
@@ -0,0 +1,195 @@
+import { AbstractTestCase, buildOwner, moduleFor, runDestroy } from 'internal-test-helpers';
+
+import { template } from '@ember/template-compiler';
+import { precompileTemplate } from '@ember/template-compilation';
+import { setComponentTemplate } from '@glimmer/manager';
+import templateOnly from '@ember/component/template-only';
+import { on } from '@glimmer/runtime';
+import { run } from '@ember/runloop';
+import createHTMLDocument from '@simple-dom/document';
+import Serializer from '@simple-dom/serializer';
+import voidMap from '@simple-dom/void-map';
+import GlimmerishComponent from '../../utils/glimmerish-component';
+import { renderComponent, renderToString } from '../../../lib/renderer';
+import type Owner from '@ember/owner';
+
+class RenderToStringTestCase extends AbstractTestCase {
+ owner: Owner;
+
+ constructor(assert: QUnit['assert']) {
+ super(assert);
+ this.owner = buildOwner({});
+ }
+
+ teardown() {
+ runDestroy(this.owner);
+ }
+}
+
+moduleFor(
+ 'Server-side rendering - renderToString',
+ class extends RenderToStringTestCase {
+ ['@test it renders a template-only component to a string']() {
+ let Hello = template('Hello, world! ');
+
+ this.assert.strictEqual(
+ renderToString(Hello, { owner: this.owner }),
+ 'Hello, world! '
+ );
+ }
+
+ ['@test it renders args into the string']() {
+ let Greeting = template('Hello, {{@name}}!
');
+
+ this.assert.strictEqual(
+ renderToString(Greeting, { owner: this.owner, args: { name: 'Zoey' } }),
+ 'Hello, Zoey!
'
+ );
+ }
+
+ ['@test it renders `{{{tripleStache}}}` raw HTML without a live DOM']() {
+ let Component = template('{{{@html}}}
');
+
+ this.assert.strictEqual(
+ renderToString(Component, { owner: this.owner, args: { html: 'bold ' } }),
+ 'bold
'
+ );
+ }
+
+ ['@test it renders a glimmer component with state']() {
+ class State extends GlimmerishComponent {
+ get shout() {
+ return String((this.args as { message: unknown }).message).toUpperCase();
+ }
+ }
+ let Component = setComponentTemplate(
+ precompileTemplate('{{this.shout}} '),
+ State
+ );
+
+ this.assert.strictEqual(
+ renderToString(Component, { owner: this.owner, args: { message: 'hi' } }),
+ 'HI '
+ );
+ }
+
+ ['@test by default it is non-interactive, so modifiers do not run']() {
+ let ran = false;
+ let noop = () => (ran = true);
+ let Component = template('ok ', {
+ scope: () => ({ on, noop }),
+ });
+
+ let html = renderToString(Component, { owner: this.owner });
+
+ this.assert.strictEqual(html, 'ok ');
+ this.assert.false(ran, 'the modifier did not install during non-interactive SSR');
+ }
+
+ ['@test it can emit rehydration markers']() {
+ let Hello = template('Hello! ');
+
+ let html = renderToString(Hello, { owner: this.owner, env: { rehydratable: true } });
+
+ this.assert.ok(
+ html.includes(''),
+ `rehydratable output contains open-block markers: ${html}`
+ );
+ this.assert.ok(html.includes('Hello! '), 'the rendered content is still present');
+ }
+
+ ['@test renderComponent can rehydrate server-rendered markup, reusing the DOM']() {
+ let Hello = template('Hello, {{@name}}! ');
+
+ // Server: render to a rehydratable string.
+ let html = renderToString(Hello, {
+ owner: this.owner,
+ args: { name: 'Zoey' },
+ env: { rehydratable: true },
+ });
+ this.assert.ok(html.includes(''), `server output is rehydratable: ${html}`);
+
+ // Ship it to the "browser": the server markup becomes the initial DOM.
+ let element = document.querySelector('#qunit-fixture') as HTMLElement;
+ element.innerHTML = html;
+ let serverNode = element.querySelector('h1');
+ this.assert.ok(serverNode, 'server-rendered element is in the DOM before rehydration');
+
+ // Client: rehydrate on top of the server markup.
+ let result = renderComponent(Hello, {
+ owner: this.owner,
+ into: element,
+ args: { name: 'Zoey' },
+ env: { rehydrate: true },
+ });
+
+ this.assert.strictEqual(element.textContent, 'Hello, Zoey!');
+ this.assert.strictEqual(
+ element.querySelector('h1'),
+ serverNode,
+ 'the server-rendered element was adopted, not replaced'
+ );
+
+ run(() => result.destroy());
+ }
+
+ ['@test rehydration adopts nested component markup']() {
+ let Inner = template('inner ');
+ let Outer = template('
', {
+ scope: () => ({ Inner }),
+ });
+
+ let html = renderToString(Outer, { owner: this.owner, env: { rehydratable: true } });
+
+ let element = document.querySelector('#qunit-fixture') as HTMLElement;
+ element.innerHTML = html;
+ let serverInner = element.querySelector('span');
+
+ let result = renderComponent(Outer, {
+ owner: this.owner,
+ into: element,
+ env: { rehydrate: true },
+ });
+
+ this.assert.strictEqual(element.textContent, 'inner');
+ this.assert.strictEqual(
+ element.querySelector('span'),
+ serverInner,
+ 'the nested component element was adopted, not replaced'
+ );
+
+ run(() => result.destroy());
+ }
+
+ ['@test it does not require a real DOM element to render into']() {
+ // `renderToString` builds its own in-memory document, so it works even
+ // when nothing is passed for `into` (unlike `renderComponent`).
+ let Component = setComponentTemplate(precompileTemplate('ok '), templateOnly());
+
+ this.assert.strictEqual(renderToString(Component, { owner: this.owner }), 'ok ');
+ }
+
+ ['@test renderComponent itself can render into a SimpleDOM element']() {
+ // The underlying capability: handing `renderComponent` a SimpleDOM
+ // document + element uses SimpleDOM-aware tree construction, so the
+ // whole `renderComponent` pipeline works server-side.
+ let document = createHTMLDocument();
+ let element = document.createElement('div');
+ let Hello = template('{{@greeting}} {{{@html}}} ');
+
+ let result = renderComponent(Hello, {
+ owner: this.owner,
+ into: element,
+ args: { greeting: 'hi', html: 'there ' },
+ env: { document, hasDOM: false, isInteractive: false },
+ });
+
+ this.assert.strictEqual(
+ new Serializer(voidMap).serializeChildren(element),
+ 'hi there '
+ );
+
+ run(() => result.destroy());
+ }
+ }
+);
diff --git a/packages/@ember/renderer/index.ts b/packages/@ember/renderer/index.ts
index f9084bb1483..785ccb73a9e 100644
--- a/packages/@ember/renderer/index.ts
+++ b/packages/@ember/renderer/index.ts
@@ -80,3 +80,37 @@ export { renderSettled } from '@ember/-internals/glimmer/lib/base-renderer';
* @public
*/
export { renderComponent } from '@ember/-internals/glimmer/lib/base-renderer';
+
+/**
+ * Render a component to an HTML string, without needing a live DOM.
+ *
+ * This is the server-side-rendering (SSR) counterpart to `renderComponent`:
+ * the same rendering pipeline, pointed at an in-memory
+ * [SimpleDOM](https://github.com/ember-fastboot/simple-dom) document instead
+ * of a live one, with the result serialized to a string — so it works in
+ * Node.js (or any environment without a global `document`).
+ *
+ * ```js
+ * import { renderToString } from '@ember/renderer';
+ *
+ * let html = renderToString(MyComponent, { args: { name: 'Zoey' } });
+ * // => "Hello, Zoey! "
+ * ```
+ *
+ * Rendering is a synchronous, one-shot operation with no reactivity, and is
+ * non-interactive by default (modifiers do not run). Pass
+ * `env: { rehydratable: true }` to include glimmer's rehydration markers in
+ * the output so a subsequent client render can rehydrate the markup.
+ *
+ * @method renderToString
+ * @static
+ * @for @ember/renderer
+ * @param {Object} component The component to render.
+ * @param {Object} [options]
+ * @param {Object} [options.owner] Optionally specify the owner to use. This will be used for injections, and overall cleanup.
+ * @param {Object} [options.args] Optionally pass args in to the component.
+ * @param {Object} [options.env] Optional renderer configuration (`isInteractive`, `rehydratable`).
+ * @returns {String} the serialized HTML for the rendered component
+ * @public
+ */
+export { renderToString } from '@ember/-internals/glimmer/lib/base-renderer';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5860bfd674c..80c1bd84c85 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -115,6 +115,12 @@ importers:
'@simple-dom/document':
specifier: ^1.4.0
version: 1.4.0
+ '@simple-dom/serializer':
+ specifier: ^1.4.0
+ version: 1.4.0
+ '@simple-dom/void-map':
+ specifier: ^1.4.0
+ version: 1.4.0
'@swc-node/register':
specifier: ^1.6.8
version: 1.11.1(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@5.9.3)
diff --git a/rollup.config.mjs b/rollup.config.mjs
index 37333ab65f9..afab1a41f9a 100644
--- a/rollup.config.mjs
+++ b/rollup.config.mjs
@@ -15,14 +15,7 @@ const packageCache = PackageCache.shared('ember-source', projectRoot);
const buildDebugMacroPlugin = require('./broccoli/build-debug-macro-plugin.cjs');
const canaryFeatures = require('./broccoli/canary-features.cjs');
-const testDependencies = [
- 'qunit',
- 'vite',
- 'js-reporters',
- '@simple-dom/serializer',
- '@simple-dom/void-map',
- 'expect-type',
-];
+const testDependencies = ['qunit', 'vite', 'js-reporters', 'expect-type'];
let configs = [
esmConfig(),
@@ -299,7 +292,10 @@ export function hiddenDependencies() {
).path,
rsvp: resolve(findFromProject('rsvp').root, 'dist/es6/rsvp.es.js'),
'@handlebars/parser': resolve(packageCache.appRoot, 'packages/@handlebars/parser/lib/index.js'),
- ...walkGlimmerDeps(['@glimmer/compiler']),
+ // `@simple-dom/serializer` (and its void-map) power `renderToString` from
+ // `@ember/renderer`, which serializes a server-rendered SimpleDOM tree to a
+ // string. They are inlined rather than exposed to consumers.
+ ...walkGlimmerDeps(['@glimmer/compiler', '@simple-dom/serializer', '@simple-dom/void-map']),
'decorator-transforms/runtime': resolve(
findFromProject('decorator-transforms').root,
'dist/runtime.js'
diff --git a/tests/docs/expected.cjs b/tests/docs/expected.cjs
index db1994ff30a..49080ec537d 100644
--- a/tests/docs/expected.cjs
+++ b/tests/docs/expected.cjs
@@ -414,6 +414,7 @@ module.exports = {
'removeObserver',
'renderComponent',
'renderSettled',
+ 'renderToString',
'reopen',
'reopenClass',
'replace',
From 10653a88a04ab54978b1a9b822a330739ea07637 Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Wed, 1 Jul 2026 23:13:43 -0400
Subject: [PATCH 2/6] renderToString: real renders, not FastBoot-style degraded
ones
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.../-internals/glimmer/lib/base-renderer.ts | 156 ++++++++++++------
.../components/render-to-string-test.ts | 95 ++++++++---
packages/@ember/renderer/index.ts | 41 +++--
pnpm-lock.yaml | 62 ++++++-
rollup.config.mjs | 14 +-
tests/node-vitest/package.json | 1 +
tests/node-vitest/render-to-string.test.js | 106 ++++++++++++
7 files changed, 370 insertions(+), 105 deletions(-)
create mode 100644 tests/node-vitest/render-to-string.test.js
diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts
index 9daf2833c28..361301487d2 100644
--- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts
+++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts
@@ -30,9 +30,6 @@ import { DOMChangesImpl } from '@glimmer/runtime/lib/dom/helper';
import { inTransaction, runtimeOptions } from '@glimmer/runtime/lib/environment';
import { renderComponent as glimmerRenderComponent } from '@glimmer/runtime/lib/render';
import { CURRENT_TAG, validateTag, valueForTag } from '@glimmer/validator/lib/validators';
-import createHTMLDocument from '@simple-dom/document';
-import Serializer from '@simple-dom/serializer';
-import voidMap from '@simple-dom/void-map';
import type { SimpleDocument, SimpleElement } from '@simple-dom/interface';
import { hasDOM } from '../../browser-environment';
import { EmberEnvironmentDelegate } from './environment';
@@ -442,12 +439,14 @@ function intoTarget(into: IntoTarget): Cursor {
}
/**
- * `Element` only exists in a real DOM environment; SSR targets (SimpleDOM
- * elements) are never instances of it. Referencing the bare `Element` global
- * from Node.js would throw a ReferenceError, so guard on its existence.
+ * Whether the render target's existing contents can be cleared via
+ * `innerHTML`. Detected by capability rather than `instanceof Element`: the
+ * `Element` global doesn't exist in Node, and a cross-realm element (e.g.
+ * happy-dom's) isn't an instance of the host realm's `Element` anyway.
+ * Cursors and SimpleDOM elements don't have `innerHTML` and are skipped.
*/
-function isDOMElement(target: IntoTarget): target is Element {
- return typeof Element !== 'undefined' && target instanceof Element;
+function supportsInnerHTML(target: IntoTarget): target is IntoTarget & { innerHTML: string } {
+ return 'innerHTML' in target;
}
/**
@@ -560,7 +559,7 @@ export function renderComponent(
* When rehydrating, the existing contents *are* the server-rendered markup
* that the render below will adopt, so they must not be cleared.
*/
- if (!existing && isDOMElement(into) && !env?.rehydrate) {
+ if (!existing && supportsInnerHTML(into) && !env?.rehydrate) {
into.innerHTML = '';
}
@@ -611,30 +610,45 @@ const RENDER_CACHE = new WeakMap();
const RENDERER_CACHE = new WeakMap();
/**
- * Render a component to an HTML string, without needing a live DOM.
+ * Render a component to an HTML string.
*
* This is the server-side-rendering (SSR) counterpart to
- * {@link renderComponent}: the same rendering pipeline, pointed at an
- * in-memory [SimpleDOM](https://github.com/ember-fastboot/simple-dom) document
- * instead of a live one, with the result serialized to a string. That makes it
- * usable from Node.js (or any environment without a global `document`), which
- * is exactly what a server needs in order to produce HTML for the initial page
- * load.
+ * {@link renderComponent}: the same rendering pipeline, rendered into a
+ * detached element and serialized to a string once rendering has settled.
*
* ```js
* import { renderToString } from '@ember/renderer';
*
- * let html = renderToString(MyComponent, { args: { name: 'Zoey' } });
+ * let html = await renderToString(MyComponent, { args: { name: 'Zoey' } });
* // => "Hello, Zoey! "
* ```
*
- * Rendering is a synchronous, one-shot operation: the component tree is
- * rendered, serialized, and torn down (running any component destructors)
- * before this function returns, so no reactivity or re-rendering occurs.
- * Rendering is non-interactive by default (modifiers do not run), matching the
- * constraints of a server environment. Pass `env: { rehydratable: true }` to
- * include glimmer's rehydration markers in the output so a subsequent
- * client-side render can re-use (rehydrate) the server-rendered markup rather
+ * Server rendering is a *real* render, not a degraded one:
+ *
+ * - Rendering is always interactive: modifiers run against real elements,
+ * exactly as they would in the browser.
+ * - The returned promise resolves once rendering has settled: if a modifier
+ * (or anything else during render) updates tracked state, the resulting
+ * re-render completes before the output is serialized.
+ * - The component tree is torn down (running destructors) before the promise
+ * resolves.
+ *
+ * This requires a DOM implementation. In the browser the global `document` is
+ * used automatically; in Node.js provide one via `env.document` — for example
+ * [happy-dom](https://github.com/capricorn86/happy-dom):
+ *
+ * ```js
+ * import { Window } from 'happy-dom';
+ * import { renderToString } from '@ember/renderer';
+ *
+ * let html = await renderToString(MyComponent, {
+ * env: { document: new Window().document },
+ * });
+ * ```
+ *
+ * Pass `env: { rehydratable: true }` to include glimmer's rehydration markers
+ * in the output so a subsequent client-side render (`renderComponent` with
+ * `env: { rehydrate: true }`) can re-use the server-rendered markup rather
* than throwing it away.
*
* @method renderToString
@@ -643,12 +657,12 @@ const RENDERER_CACHE = new WeakMap();
* @param {Object} component The component to render.
* @param {Object} [options]
* @param {Object} [options.owner] Optionally specify the owner to use. This will be used for injections, and overall cleanup.
- * @param {Object} [options.args] Optionally pass args in to the component.
- * @param {Object} [options.env] Optional renderer configuration. `isInteractive` (default `false`) controls whether modifiers run; `rehydratable` (default `false`) controls whether rehydration markers are emitted.
- * @returns {String} the serialized HTML for the rendered component
+ * @param {Object} [options.args] Optionally pass args in to the component. These may be reactive; rendering settles before serialization.
+ * @param {Object} [options.env] Optional renderer configuration. `document` provides the DOM implementation to render with (defaults to the global `document`); `rehydratable` (default `false`) controls whether rehydration markers are emitted.
+ * @returns {Promise} the serialized HTML for the rendered component
* @public
*/
-export function renderToString(
+export async function renderToString(
/**
* The component definition to render. Any component that has had its manager
* registered is valid, same as {@link renderComponent}.
@@ -667,6 +681,8 @@ export function renderToString(
owner?: object;
/**
* These args get passed to the rendered component.
+ *
+ * If your args are reactive, rendering settles before serialization.
*/
args?: Record;
/**
@@ -674,10 +690,11 @@ export function renderToString(
*/
env?: {
/**
- * When true, modifiers will run. Defaults to `false`, since server
- * rendering is typically non-interactive.
+ * The DOM implementation to render with. Defaults to the global
+ * `document`. In environments without one (Node.js), pass a
+ * DOM-compatible document such as happy-dom's `new Window().document`.
*/
- isInteractive?: boolean;
+ document?: SimpleDocument | Document;
/**
* When true, the emitted HTML includes glimmer's rehydration markers so
* the output can be re-used (rehydrated) by a subsequent client-side
@@ -691,42 +708,73 @@ export function renderToString(
[rendererOption: string]: unknown;
};
} = {}
-): string {
- let document = createHTMLDocument();
+): Promise {
+ let document = env?.document ?? globalThis.document;
+
+ assert(
+ 'renderToString requires a document. In environments without a global `document` (like Node.js), provide one via `env.document` — for example a happy-dom `new Window().document`.',
+ document !== undefined && document !== null
+ );
+
+ // Server rendering is a *real* render — modifiers run against real elements
+ // and the output is serialized via `innerHTML` — so it needs a full DOM
+ // implementation (browser, happy-dom, jsdom), not SimpleDOM. To render into
+ // SimpleDOM (no modifiers, manual serialization), use `renderComponent` with
+ // `env: { document }` instead.
+ assert(
+ 'renderToString requires a full DOM-compatible document (the browser document, or happy-dom in Node.js), not a SimpleDOM document. To render into SimpleDOM, use `renderComponent` with `env: { document }` and serialize the result yourself.',
+ !isSimpleDocument(document)
+ );
+
// Render into a detached wrapper element and serialize its *children*, so
// the returned string is only the component's own markup.
- let element = document.createElement('div');
+ let element = (document as Document).createElement('div');
// Build a dedicated renderer rather than going through the per-owner
// renderer cache used by `renderComponent`: SSR output must target the
- // in-memory document created above, even if this owner has previously
- // rendered into a live DOM.
+ // document chosen above, even if this owner has previously rendered into
+ // the live DOM.
+ //
+ // `isInteractive` and `hasDOM` are deliberately not options here: a server
+ // render is a real, interactive render against a real (in-memory) DOM.
let renderer = BaseRenderer.strict(owner, document, {
...env,
- isInteractive: env?.isInteractive ?? false,
- hasDOM: false,
+ isInteractive: true,
+ hasDOM: true,
rehydratable: Boolean(env?.rehydratable),
});
try {
renderer.render(component, { into: element, args });
- return new Serializer(voidMap).serializeChildren(element);
+ // The initial render is synchronous, but rendering isn't *done* until
+ // every follow-on invalidation has settled: modifiers run during SSR and
+ // may update tracked state that the template consumed. Wait for that
+ // reactivity to flush before serializing.
+ await renderSettled();
+
+ return element.innerHTML;
} finally {
- // One-shot render: tear the whole renderer down synchronously so component
- // destructors run and nothing stays registered with the run loop.
+ // One-shot render: tear the whole renderer down so component destructors
+ // run and nothing stays registered with the run loop.
_backburner.run(() => renderer.destroy());
}
}
/**
- * A real `Document` supports DOM APIs (like `insertAdjacentHTML`, used by
- * `{{{tripleStache}}}`) that an in-memory SimpleDOM document does not, so the
- * renderer picks its tree-construction strategy based on which one it is
- * handed. Anything that isn't a browser `Document` is treated as SimpleDOM.
+ * SimpleDOM documents expose `createRawHTMLSection`, the primitive that
+ * `NodeDOMTreeConstruction` uses to append raw HTML (`{{{tripleStache}}}`).
+ * Real DOM implementations — browser, happy-dom, jsdom — don't have it; they
+ * support `insertAdjacentHTML` instead, so they take the default tree
+ * construction path.
+ *
+ * This is detected by capability rather than `instanceof Document` on purpose:
+ * a cross-realm document (e.g. a happy-dom `new Window().document` in Node) is
+ * not an instance of the host realm's `Document` — and in Node there may be no
+ * `Document` global at all.
*/
-function isRealDocument(document: SimpleDocument | Document): document is Document {
- return typeof Document !== 'undefined' && document instanceof Document;
+function isSimpleDocument(document: SimpleDocument | Document): document is SimpleDocument {
+ return typeof (document as SimpleDocument).createRawHTMLSection === 'function';
}
export class BaseRenderer {
@@ -777,16 +825,18 @@ export class BaseRenderer {
* an app needs (which will actually change and become less over time)
*/
let env = new EmberEnvironmentDelegate(owner as InternalOwner, envOptions.isInteractive);
- // A SimpleDOM document (e.g. during SSR) needs SimpleDOM-aware tree
- // construction: the default `DOMTreeConstruction` relies on real-DOM APIs
+ // A SimpleDOM document needs SimpleDOM-aware tree construction: the
+ // default `DOMTreeConstruction` relies on real-DOM APIs
// (`insertAdjacentHTML`, SVG namespace handling) that SimpleDOM does not
// implement. `NodeDOMTreeConstruction` appends raw HTML sections instead.
- let environmentOptions = isRealDocument(document)
- ? { document }
- : {
+ // Real DOM implementations (browser, happy-dom, jsdom) take the default
+ // path.
+ let environmentOptions = isSimpleDocument(document)
+ ? {
appendOperations: new NodeDOMTreeConstruction(document),
updateOperations: new DOMChangesImpl(document),
- };
+ }
+ : { document };
let options = runtimeOptions(environmentOptions, env, sharedArtifacts, resolver);
let context = new EvaluationContextImpl(
sharedArtifacts,
diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts b/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
index b4fc117569c..6c67b6d032e 100644
--- a/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
+++ b/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
@@ -1,10 +1,16 @@
-import { AbstractTestCase, buildOwner, moduleFor, runDestroy } from 'internal-test-helpers';
+import {
+ AbstractTestCase,
+ buildOwner,
+ defineSimpleModifier,
+ moduleFor,
+ runDestroy,
+} from 'internal-test-helpers';
import { template } from '@ember/template-compiler';
import { precompileTemplate } from '@ember/template-compilation';
import { setComponentTemplate } from '@glimmer/manager';
import templateOnly from '@ember/component/template-only';
-import { on } from '@glimmer/runtime';
+import { tracked } from '@glimmer/tracking';
import { run } from '@ember/runloop';
import createHTMLDocument from '@simple-dom/document';
import Serializer from '@simple-dom/serializer';
@@ -29,34 +35,34 @@ class RenderToStringTestCase extends AbstractTestCase {
moduleFor(
'Server-side rendering - renderToString',
class extends RenderToStringTestCase {
- ['@test it renders a template-only component to a string']() {
+ async ['@test it renders a template-only component to a string']() {
let Hello = template('Hello, world! ');
this.assert.strictEqual(
- renderToString(Hello, { owner: this.owner }),
+ await renderToString(Hello, { owner: this.owner }),
'Hello, world! '
);
}
- ['@test it renders args into the string']() {
+ async ['@test it renders args into the string']() {
let Greeting = template('Hello, {{@name}}!
');
this.assert.strictEqual(
- renderToString(Greeting, { owner: this.owner, args: { name: 'Zoey' } }),
+ await renderToString(Greeting, { owner: this.owner, args: { name: 'Zoey' } }),
'Hello, Zoey!
'
);
}
- ['@test it renders `{{{tripleStache}}}` raw HTML without a live DOM']() {
+ async ['@test it renders `{{{tripleStache}}}` raw HTML']() {
let Component = template('{{{@html}}}
');
this.assert.strictEqual(
- renderToString(Component, { owner: this.owner, args: { html: 'bold ' } }),
+ await renderToString(Component, { owner: this.owner, args: { html: 'bold ' } }),
'bold
'
);
}
- ['@test it renders a glimmer component with state']() {
+ async ['@test it renders a glimmer component with state']() {
class State extends GlimmerishComponent {
get shout() {
return String((this.args as { message: unknown }).message).toUpperCase();
@@ -68,28 +74,62 @@ moduleFor(
);
this.assert.strictEqual(
- renderToString(Component, { owner: this.owner, args: { message: 'hi' } }),
+ await renderToString(Component, { owner: this.owner, args: { message: 'hi' } }),
'HI '
);
}
- ['@test by default it is non-interactive, so modifiers do not run']() {
+ async ['@test modifiers run during server rendering']() {
+ // Server rendering is a real, interactive render — unlike FastBoot,
+ // modifiers are not skipped.
let ran = false;
- let noop = () => (ran = true);
- let Component = template('ok ', {
- scope: () => ({ on, noop }),
+ let installed = defineSimpleModifier(() => (ran = true));
+ let Component = template('ok ', {
+ scope: () => ({ installed }),
});
- let html = renderToString(Component, { owner: this.owner });
+ let html = await renderToString(Component, { owner: this.owner });
this.assert.strictEqual(html, 'ok ');
- this.assert.false(ran, 'the modifier did not install during non-interactive SSR');
+ this.assert.true(ran, 'the modifier installed during SSR');
}
- ['@test it can emit rehydration markers']() {
+ async ['@test modifier-driven DOM effects appear in the output']() {
+ let autofocus = defineSimpleModifier((element: Element) =>
+ element.setAttribute('data-focused', 'true')
+ );
+ let Component = template(' ', {
+ scope: () => ({ autofocus }),
+ });
+
+ this.assert.strictEqual(
+ await renderToString(Component, { owner: this.owner }),
+ ' '
+ );
+ }
+
+ async ['@test rendering settles before serializing: tracked state updated by a modifier is reflected in the output']() {
+ // The FastBoot regression this API must not repeat: state changes made
+ // during render (here, by a modifier) must be rendered before the
+ // output is declared done.
+ class State {
+ @tracked message = 'loading';
+ }
+ let state = new State();
+ let load = defineSimpleModifier(() => (state.message = 'loaded'));
+ let Component = template('{{state.message}}
', {
+ scope: () => ({ load, state }),
+ });
+
+ let html = await renderToString(Component, { owner: this.owner });
+
+ this.assert.strictEqual(html, 'loaded
');
+ }
+
+ async ['@test it can emit rehydration markers']() {
let Hello = template('Hello! ');
- let html = renderToString(Hello, { owner: this.owner, env: { rehydratable: true } });
+ let html = await renderToString(Hello, { owner: this.owner, env: { rehydratable: true } });
this.assert.ok(
html.includes(''),
@@ -98,11 +138,11 @@ moduleFor(
this.assert.ok(html.includes('Hello! '), 'the rendered content is still present');
}
- ['@test renderComponent can rehydrate server-rendered markup, reusing the DOM']() {
+ async ['@test renderComponent can rehydrate server-rendered markup, reusing the DOM']() {
let Hello = template('Hello, {{@name}}! ');
// Server: render to a rehydratable string.
- let html = renderToString(Hello, {
+ let html = await renderToString(Hello, {
owner: this.owner,
args: { name: 'Zoey' },
env: { rehydratable: true },
@@ -133,13 +173,13 @@ moduleFor(
run(() => result.destroy());
}
- ['@test rehydration adopts nested component markup']() {
+ async ['@test rehydration adopts nested component markup']() {
let Inner = template('inner ');
let Outer = template('
', {
scope: () => ({ Inner }),
});
- let html = renderToString(Outer, { owner: this.owner, env: { rehydratable: true } });
+ let html = await renderToString(Outer, { owner: this.owner, env: { rehydratable: true } });
let element = document.querySelector('#qunit-fixture') as HTMLElement;
element.innerHTML = html;
@@ -161,12 +201,15 @@ moduleFor(
run(() => result.destroy());
}
- ['@test it does not require a real DOM element to render into']() {
- // `renderToString` builds its own in-memory document, so it works even
- // when nothing is passed for `into` (unlike `renderComponent`).
+ async ['@test it does not require an `into` target']() {
+ // `renderToString` renders into its own detached element, so unlike
+ // `renderComponent` there is no `into` option.
let Component = setComponentTemplate(precompileTemplate('ok '), templateOnly());
- this.assert.strictEqual(renderToString(Component, { owner: this.owner }), 'ok ');
+ this.assert.strictEqual(
+ await renderToString(Component, { owner: this.owner }),
+ 'ok '
+ );
}
['@test renderComponent itself can render into a SimpleDOM element']() {
diff --git a/packages/@ember/renderer/index.ts b/packages/@ember/renderer/index.ts
index 785ccb73a9e..a0bf472035d 100644
--- a/packages/@ember/renderer/index.ts
+++ b/packages/@ember/renderer/index.ts
@@ -82,25 +82,40 @@ export { renderSettled } from '@ember/-internals/glimmer/lib/base-renderer';
export { renderComponent } from '@ember/-internals/glimmer/lib/base-renderer';
/**
- * Render a component to an HTML string, without needing a live DOM.
+ * Render a component to an HTML string.
*
* This is the server-side-rendering (SSR) counterpart to `renderComponent`:
- * the same rendering pipeline, pointed at an in-memory
- * [SimpleDOM](https://github.com/ember-fastboot/simple-dom) document instead
- * of a live one, with the result serialized to a string — so it works in
- * Node.js (or any environment without a global `document`).
+ * the same rendering pipeline, rendered into a detached element and serialized
+ * to a string once rendering has settled.
*
* ```js
* import { renderToString } from '@ember/renderer';
*
- * let html = renderToString(MyComponent, { args: { name: 'Zoey' } });
+ * let html = await renderToString(MyComponent, { args: { name: 'Zoey' } });
* // => "Hello, Zoey! "
* ```
*
- * Rendering is a synchronous, one-shot operation with no reactivity, and is
- * non-interactive by default (modifiers do not run). Pass
- * `env: { rehydratable: true }` to include glimmer's rehydration markers in
- * the output so a subsequent client render can rehydrate the markup.
+ * Server rendering is a *real* render, not a degraded one: modifiers run
+ * against real elements, and the returned promise resolves only after
+ * rendering has settled — any tracked-state updates made during render (e.g.
+ * by a modifier) are reflected in the output.
+ *
+ * This requires a DOM implementation. In the browser the global `document` is
+ * used automatically; in Node.js provide one via `env.document` — for example
+ * [happy-dom](https://github.com/capricorn86/happy-dom):
+ *
+ * ```js
+ * import { Window } from 'happy-dom';
+ * import { renderToString } from '@ember/renderer';
+ *
+ * let html = await renderToString(MyComponent, {
+ * env: { document: new Window().document },
+ * });
+ * ```
+ *
+ * Pass `env: { rehydratable: true }` to include glimmer's rehydration markers
+ * in the output so a subsequent client render (`renderComponent` with
+ * `env: { rehydrate: true }`) can rehydrate the markup.
*
* @method renderToString
* @static
@@ -108,9 +123,9 @@ export { renderComponent } from '@ember/-internals/glimmer/lib/base-renderer';
* @param {Object} component The component to render.
* @param {Object} [options]
* @param {Object} [options.owner] Optionally specify the owner to use. This will be used for injections, and overall cleanup.
- * @param {Object} [options.args] Optionally pass args in to the component.
- * @param {Object} [options.env] Optional renderer configuration (`isInteractive`, `rehydratable`).
- * @returns {String} the serialized HTML for the rendered component
+ * @param {Object} [options.args] Optionally pass args in to the component. These may be reactive; rendering settles before serialization.
+ * @param {Object} [options.env] Optional renderer configuration (`document`, `rehydratable`).
+ * @returns {Promise} the serialized HTML for the rendered component
* @public
*/
export { renderToString } from '@ember/-internals/glimmer/lib/base-renderer';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 80c1bd84c85..4a420ed9e22 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -168,7 +168,7 @@ importers:
version: 4.0.0
ember-cli-dependency-checker:
specifier: ^3.3.1
- version: 3.3.3(ember-cli@7.0.1(@babel/core@7.29.7)(@types/node@22.19.19)(ejs@3.1.10)(handlebars@4.7.9)(underscore@1.13.8))
+ version: 3.3.3(ember-cli@7.0.1(@babel/core@7.29.7)(@types/node@22.19.19))
ember-cli-yuidoc:
specifier: ^0.9.1
version: 0.9.1
@@ -2840,7 +2840,7 @@ importers:
version: 3.0.0
ember-cli-dependency-checker:
specifier: ^3.3.3
- version: 3.3.3(ember-cli@7.0.1(@babel/core@7.29.7)(@types/node@22.19.19)(ejs@3.1.10)(handlebars@4.7.9)(underscore@1.13.8))
+ version: 3.3.3(ember-cli@7.0.1(@babel/core@7.29.7)(@types/node@22.19.19))
ember-cli-deprecation-workflow:
specifier: ^3.4.0
version: 3.4.0(ember-source@)
@@ -3243,8 +3243,11 @@ importers:
version: 8.0.16(@types/node@22.19.19)(esbuild@0.27.7)(terser@5.48.0)(yaml@2.9.0)
vitest:
specifier: ^4.1.5
- version: 4.1.8(@types/node@22.19.19)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.27.7)(terser@5.48.0)(yaml@2.9.0))
+ version: 4.1.8(@types/node@22.19.19)(happy-dom@20.10.6)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.27.7)(terser@5.48.0)(yaml@2.9.0))
devDependencies:
+ happy-dom:
+ specifier: ^20.10.6
+ version: 20.10.6
rollup:
specifier: ^4.2.0
version: 4.61.0
@@ -5604,6 +5607,9 @@ packages:
'@types/symlink-or-copy@1.2.2':
resolution: {integrity: sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==}
+ '@types/whatwg-mimetype@3.0.2':
+ resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
+
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -6431,6 +6437,10 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+ buffer-image-size@0.6.4:
+ resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
+ engines: {node: '>=4.0'}
+
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
@@ -7567,6 +7577,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
+ entities@7.0.1:
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
+
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -8405,6 +8419,10 @@ packages:
engines: {node: '>=0.4.7'}
hasBin: true
+ happy-dom@20.10.6:
+ resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==}
+ engines: {node: '>=20.0.0'}
+
hard-rejection@2.1.0:
resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==}
engines: {node: '>=6'}
@@ -11862,6 +11880,10 @@ packages:
engines: {node: '>=18'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
+ whatwg-mimetype@3.0.0:
+ resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
+ engines: {node: '>=12'}
+
whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
@@ -14957,7 +14979,7 @@ snapshots:
'@types/fs-extra@9.0.13':
dependencies:
- '@types/node': 20.19.41
+ '@types/node': 22.19.19
'@types/glob@9.0.0':
dependencies:
@@ -14994,18 +15016,20 @@ snapshots:
'@types/rimraf@3.0.2':
dependencies:
'@types/glob': 9.0.0
- '@types/node': 20.19.41
+ '@types/node': 22.19.19
'@types/rsvp@4.0.9': {}
'@types/ssri@7.1.5':
dependencies:
- '@types/node': 20.19.41
+ '@types/node': 22.19.19
'@types/supports-color@8.1.3': {}
'@types/symlink-or-copy@1.2.2': {}
+ '@types/whatwg-mimetype@3.0.2': {}
+
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.19.19
@@ -16247,6 +16271,10 @@ snapshots:
buffer-from@1.1.2: {}
+ buffer-image-size@0.6.4:
+ dependencies:
+ '@types/node': 22.19.19
+
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
@@ -17139,7 +17167,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- ember-cli-dependency-checker@3.3.3(ember-cli@7.0.1(@babel/core@7.29.7)(@types/node@22.19.19)(ejs@3.1.10)(handlebars@4.7.9)(underscore@1.13.8)):
+ ember-cli-dependency-checker@3.3.3(ember-cli@7.0.1(@babel/core@7.29.7)(@types/node@22.19.19)):
dependencies:
chalk: 2.4.2
ember-cli: 7.0.1(@babel/core@7.29.7)(@types/node@22.19.19)(ejs@3.1.10)(handlebars@4.7.9)(underscore@1.13.8)
@@ -17604,6 +17632,8 @@ snapshots:
entities@6.0.1: {}
+ entities@7.0.1: {}
+
env-paths@2.2.1: {}
errlop@2.2.0: {}
@@ -18777,6 +18807,19 @@ snapshots:
optionalDependencies:
uglify-js: 3.19.3
+ happy-dom@20.10.6:
+ dependencies:
+ '@types/node': 22.19.19
+ '@types/whatwg-mimetype': 3.0.2
+ '@types/ws': 8.18.1
+ buffer-image-size: 0.6.4
+ entities: 7.0.1
+ whatwg-mimetype: 3.0.0
+ ws: 8.21.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
hard-rejection@2.1.0: {}
has-ansi@1.0.3:
@@ -22408,7 +22451,7 @@ snapshots:
terser: 5.48.0
yaml: 2.9.0
- vitest@4.1.8(@types/node@22.19.19)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.27.7)(terser@5.48.0)(yaml@2.9.0)):
+ vitest@4.1.8(@types/node@22.19.19)(happy-dom@20.10.6)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.27.7)(terser@5.48.0)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.8
'@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@22.19.19)(esbuild@0.27.7)(terser@5.48.0)(yaml@2.9.0))
@@ -22432,6 +22475,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.19
+ happy-dom: 20.10.6
jsdom: 26.1.0
transitivePeerDependencies:
- msw
@@ -22606,6 +22650,8 @@ snapshots:
dependencies:
iconv-lite: 0.6.3
+ whatwg-mimetype@3.0.0: {}
+
whatwg-mimetype@4.0.0: {}
whatwg-url@14.2.0:
diff --git a/rollup.config.mjs b/rollup.config.mjs
index afab1a41f9a..37333ab65f9 100644
--- a/rollup.config.mjs
+++ b/rollup.config.mjs
@@ -15,7 +15,14 @@ const packageCache = PackageCache.shared('ember-source', projectRoot);
const buildDebugMacroPlugin = require('./broccoli/build-debug-macro-plugin.cjs');
const canaryFeatures = require('./broccoli/canary-features.cjs');
-const testDependencies = ['qunit', 'vite', 'js-reporters', 'expect-type'];
+const testDependencies = [
+ 'qunit',
+ 'vite',
+ 'js-reporters',
+ '@simple-dom/serializer',
+ '@simple-dom/void-map',
+ 'expect-type',
+];
let configs = [
esmConfig(),
@@ -292,10 +299,7 @@ export function hiddenDependencies() {
).path,
rsvp: resolve(findFromProject('rsvp').root, 'dist/es6/rsvp.es.js'),
'@handlebars/parser': resolve(packageCache.appRoot, 'packages/@handlebars/parser/lib/index.js'),
- // `@simple-dom/serializer` (and its void-map) power `renderToString` from
- // `@ember/renderer`, which serializes a server-rendered SimpleDOM tree to a
- // string. They are inlined rather than exposed to consumers.
- ...walkGlimmerDeps(['@glimmer/compiler', '@simple-dom/serializer', '@simple-dom/void-map']),
+ ...walkGlimmerDeps(['@glimmer/compiler']),
'decorator-transforms/runtime': resolve(
findFromProject('decorator-transforms').root,
'dist/runtime.js'
diff --git a/tests/node-vitest/package.json b/tests/node-vitest/package.json
index 4ba160d258f..ae8fc8522f8 100644
--- a/tests/node-vitest/package.json
+++ b/tests/node-vitest/package.json
@@ -15,6 +15,7 @@
"vitest": "^4.1.5"
},
"devDependencies": {
+ "happy-dom": "^20.10.6",
"rollup": "^4.61.0"
}
}
diff --git a/tests/node-vitest/render-to-string.test.js b/tests/node-vitest/render-to-string.test.js
new file mode 100644
index 00000000000..b4e3b009d50
--- /dev/null
+++ b/tests/node-vitest/render-to-string.test.js
@@ -0,0 +1,106 @@
+/**
+ * SSR smoke tests for `renderToString` from `@ember/renderer`, run in plain
+ * Node.js (no DOM globals) against the *built* ember-source package, with
+ * happy-dom providing the DOM implementation.
+ *
+ * These prove the server-rendering contract:
+ * - rendering works without a global `document` (the document is provided)
+ * - modifiers run during SSR, against real (happy-dom) elements
+ * - rendering settles before serialization: tracked-state updates made
+ * during render are reflected in the output
+ */
+import { it, expect } from 'vitest';
+import { Window } from 'happy-dom';
+
+import { renderToString } from 'ember-source/@ember/renderer/index.js';
+import { template } from 'ember-source/@ember/template-compiler/runtime.js';
+import { trackedObject } from 'ember-source/@ember/reactive/collections.js';
+import { setModifierManager, capabilities } from 'ember-source/@ember/modifier/index.js';
+
+// A minimal function-based modifier (mirrors internal-test-helpers'
+// FunctionalModifierManager) so these tests don't need ember-modifier.
+const MODIFIER_MANAGER = {
+ capabilities: capabilities('3.22'),
+ createModifier(fn, args) {
+ return { fn, args };
+ },
+ installModifier(state, element) {
+ state.fn(element, state.args.positional, state.args.named);
+ },
+ updateModifier() {},
+ destroyModifier() {},
+ getDebugName(fn) {
+ return fn.name || '(anonymous function)';
+ },
+};
+
+function modifier(fn) {
+ return setModifierManager(() => MODIFIER_MANAGER, fn);
+}
+
+function freshDocument() {
+ return new Window().document;
+}
+
+it('renders a component to a string with happy-dom', async () => {
+ let Hello = template('Hello, {{@name}}! ');
+
+ let html = await renderToString(Hello, {
+ args: { name: 'Zoey' },
+ env: { document: freshDocument() },
+ });
+
+ expect(html).toBe('Hello, Zoey! ');
+});
+
+it('renders {{{tripleStache}}} raw HTML', async () => {
+ let Component = template('{{{@html}}}
');
+
+ let html = await renderToString(Component, {
+ args: { html: 'bold ' },
+ env: { document: freshDocument() },
+ });
+
+ expect(html).toBe('bold
');
+});
+
+it('runs modifiers during SSR, and their DOM effects appear in the output', async () => {
+ let ran = false;
+ let autofocus = modifier((element) => {
+ ran = true;
+ element.setAttribute('data-focused', 'true');
+ });
+ let Component = template(' ', {
+ scope: () => ({ autofocus }),
+ });
+
+ let html = await renderToString(Component, { env: { document: freshDocument() } });
+
+ expect(ran).toBe(true);
+ expect(html).toBe(' ');
+});
+
+it('settles before serializing: tracked state updated by a modifier is in the output', async () => {
+ let state = trackedObject({ message: 'loading' });
+ let load = modifier(() => {
+ state.message = 'loaded';
+ });
+ let Component = template('{{state.message}}
', {
+ scope: () => ({ load, state }),
+ });
+
+ let html = await renderToString(Component, { env: { document: freshDocument() } });
+
+ expect(html).toBe('loaded
');
+});
+
+it('emits rehydration markers when asked', async () => {
+ let Hello = template('Hello! ');
+
+ let html = await renderToString(Hello, {
+ env: { document: freshDocument(), rehydratable: true },
+ });
+
+ expect(html).toContain('');
+ expect(html).toContain('Hello! ');
+});
From ee59b2acd84bc68532cebd0647224e00506be738 Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Wed, 1 Jul 2026 23:26:20 -0400
Subject: [PATCH 3/6] Drop SimpleDOM support; environments must provide DOM
building blocks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
package.json | 2 -
.../-internals/glimmer/lib/base-renderer.ts | 69 +++----------------
.../components/render-to-string-test.ts | 25 -------
pnpm-lock.yaml | 12 +---
4 files changed, 12 insertions(+), 96 deletions(-)
diff --git a/package.json b/package.json
index 73404c8b1e8..86c1d3d37db 100644
--- a/package.json
+++ b/package.json
@@ -95,8 +95,6 @@
"@octokit/rest": "^22.0.0",
"@rollup/plugin-babel": "^6.0.4",
"@simple-dom/document": "^1.4.0",
- "@simple-dom/serializer": "^1.4.0",
- "@simple-dom/void-map": "^1.4.0",
"@swc-node/register": "^1.6.8",
"@swc/core": "^1.3.100",
"@tsconfig/ember": "3.0.8",
diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts
index 361301487d2..7022ab4d07c 100644
--- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts
+++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts
@@ -25,8 +25,6 @@ import { RuntimeOpImpl } from '@glimmer/program/lib/opcode';
import { clientBuilder } from '@glimmer/runtime/lib/vm/element-builder';
import { rehydrationBuilder } from '@glimmer/runtime/lib/vm/rehydrate-builder';
import { serializeBuilder } from '@glimmer/node/lib/serialize-builder';
-import NodeDOMTreeConstruction from '@glimmer/node/lib/node-dom-helper';
-import { DOMChangesImpl } from '@glimmer/runtime/lib/dom/helper';
import { inTransaction, runtimeOptions } from '@glimmer/runtime/lib/environment';
import { renderComponent as glimmerRenderComponent } from '@glimmer/runtime/lib/render';
import { CURRENT_TAG, validateTag, valueForTag } from '@glimmer/validator/lib/validators';
@@ -438,16 +436,6 @@ function intoTarget(into: IntoTarget): Cursor {
}
}
-/**
- * Whether the render target's existing contents can be cleared via
- * `innerHTML`. Detected by capability rather than `instanceof Element`: the
- * `Element` global doesn't exist in Node, and a cross-realm element (e.g.
- * happy-dom's) isn't an instance of the host realm's `Element` anyway.
- * Cursors and SimpleDOM elements don't have `innerHTML` and are skipped.
- */
-function supportsInnerHTML(target: IntoTarget): target is IntoTarget & { innerHTML: string } {
- return 'innerHTML' in target;
-}
/**
* Render a component into a DOM element.
@@ -559,7 +547,7 @@ export function renderComponent(
* When rehydrating, the existing contents *are* the server-rendered markup
* that the render below will adopt, so they must not be cleared.
*/
- if (!existing && supportsInnerHTML(into) && !env?.rehydrate) {
+ if (!existing && into instanceof Element && !env?.rehydrate) {
into.innerHTML = '';
}
@@ -577,12 +565,8 @@ export function renderComponent(
*/
let renderTarget: IntoTarget = into;
if (existing?.glimmerResult) {
- // A cursor carries its parent on `.element`; both DOM and SimpleDOM
- // elements *are* the parent. (Mirrors `intoTarget` above — an
- // `instanceof Element` check would throw in Node and would mis-handle
- // SimpleDOM elements.)
let parentElement =
- 'element' in into ? into.element : (into as unknown as SimpleElement);
+ into instanceof Element ? (into as unknown as SimpleElement) : (into as Cursor).element;
let firstNode = existing.glimmerResult.firstNode();
renderTarget = { element: parentElement, nextSibling: firstNode };
}
@@ -694,7 +678,7 @@ export async function renderToString(
* `document`. In environments without one (Node.js), pass a
* DOM-compatible document such as happy-dom's `new Window().document`.
*/
- document?: SimpleDocument | Document;
+ document?: Document;
/**
* When true, the emitted HTML includes glimmer's rehydration markers so
* the output can be re-used (rehydrated) by a subsequent client-side
@@ -711,24 +695,17 @@ export async function renderToString(
): Promise {
let document = env?.document ?? globalThis.document;
- assert(
- 'renderToString requires a document. In environments without a global `document` (like Node.js), provide one via `env.document` — for example a happy-dom `new Window().document`.',
- document !== undefined && document !== null
- );
-
// Server rendering is a *real* render — modifiers run against real elements
- // and the output is serialized via `innerHTML` — so it needs a full DOM
- // implementation (browser, happy-dom, jsdom), not SimpleDOM. To render into
- // SimpleDOM (no modifiers, manual serialization), use `renderComponent` with
- // `env: { document }` instead.
+ // and the output is serialized via `innerHTML` — so a full DOM
+ // implementation (browser, happy-dom, jsdom) is required.
assert(
- 'renderToString requires a full DOM-compatible document (the browser document, or happy-dom in Node.js), not a SimpleDOM document. To render into SimpleDOM, use `renderComponent` with `env: { document }` and serialize the result yourself.',
- !isSimpleDocument(document)
+ 'renderToString requires a document. In environments without a global `document` (like Node.js), provide DOM building blocks globally (e.g. via happy-dom) or pass a document via `env.document` — for example a happy-dom `new Window().document`.',
+ document !== undefined && document !== null
);
// Render into a detached wrapper element and serialize its *children*, so
// the returned string is only the component's own markup.
- let element = (document as Document).createElement('div');
+ let element = document.createElement('div');
// Build a dedicated renderer rather than going through the per-owner
// renderer cache used by `renderComponent`: SSR output must target the
@@ -761,22 +738,6 @@ export async function renderToString(
}
}
-/**
- * SimpleDOM documents expose `createRawHTMLSection`, the primitive that
- * `NodeDOMTreeConstruction` uses to append raw HTML (`{{{tripleStache}}}`).
- * Real DOM implementations — browser, happy-dom, jsdom — don't have it; they
- * support `insertAdjacentHTML` instead, so they take the default tree
- * construction path.
- *
- * This is detected by capability rather than `instanceof Document` on purpose:
- * a cross-realm document (e.g. a happy-dom `new Window().document` in Node) is
- * not an instance of the host realm's `Document` — and in Node there may be no
- * `Document` global at all.
- */
-function isSimpleDocument(document: SimpleDocument | Document): document is SimpleDocument {
- return typeof (document as SimpleDocument).createRawHTMLSection === 'function';
-}
-
export class BaseRenderer {
static strict(
owner: object,
@@ -825,19 +786,7 @@ export class BaseRenderer {
* an app needs (which will actually change and become less over time)
*/
let env = new EmberEnvironmentDelegate(owner as InternalOwner, envOptions.isInteractive);
- // A SimpleDOM document needs SimpleDOM-aware tree construction: the
- // default `DOMTreeConstruction` relies on real-DOM APIs
- // (`insertAdjacentHTML`, SVG namespace handling) that SimpleDOM does not
- // implement. `NodeDOMTreeConstruction` appends raw HTML sections instead.
- // Real DOM implementations (browser, happy-dom, jsdom) take the default
- // path.
- let environmentOptions = isSimpleDocument(document)
- ? {
- appendOperations: new NodeDOMTreeConstruction(document),
- updateOperations: new DOMChangesImpl(document),
- }
- : { document };
- let options = runtimeOptions(environmentOptions, env, sharedArtifacts, resolver);
+ let options = runtimeOptions({ document }, env, sharedArtifacts, resolver);
let context = new EvaluationContextImpl(
sharedArtifacts,
(heap) => new RuntimeOpImpl(heap),
diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts b/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
index 6c67b6d032e..62fddb8f003 100644
--- a/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
+++ b/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
@@ -12,9 +12,6 @@ import { setComponentTemplate } from '@glimmer/manager';
import templateOnly from '@ember/component/template-only';
import { tracked } from '@glimmer/tracking';
import { run } from '@ember/runloop';
-import createHTMLDocument from '@simple-dom/document';
-import Serializer from '@simple-dom/serializer';
-import voidMap from '@simple-dom/void-map';
import GlimmerishComponent from '../../utils/glimmerish-component';
import { renderComponent, renderToString } from '../../../lib/renderer';
import type Owner from '@ember/owner';
@@ -212,27 +209,5 @@ moduleFor(
);
}
- ['@test renderComponent itself can render into a SimpleDOM element']() {
- // The underlying capability: handing `renderComponent` a SimpleDOM
- // document + element uses SimpleDOM-aware tree construction, so the
- // whole `renderComponent` pipeline works server-side.
- let document = createHTMLDocument();
- let element = document.createElement('div');
- let Hello = template('{{@greeting}} {{{@html}}} ');
-
- let result = renderComponent(Hello, {
- owner: this.owner,
- into: element,
- args: { greeting: 'hi', html: 'there ' },
- env: { document, hasDOM: false, isInteractive: false },
- });
-
- this.assert.strictEqual(
- new Serializer(voidMap).serializeChildren(element),
- 'hi there '
- );
-
- run(() => result.destroy());
- }
}
);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4a420ed9e22..823e0d8ed13 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -115,12 +115,6 @@ importers:
'@simple-dom/document':
specifier: ^1.4.0
version: 1.4.0
- '@simple-dom/serializer':
- specifier: ^1.4.0
- version: 1.4.0
- '@simple-dom/void-map':
- specifier: ^1.4.0
- version: 1.4.0
'@swc-node/register':
specifier: ^1.6.8
version: 1.11.1(@swc/core@1.15.40)(@swc/types@0.1.26)(typescript@5.9.3)
@@ -168,7 +162,7 @@ importers:
version: 4.0.0
ember-cli-dependency-checker:
specifier: ^3.3.1
- version: 3.3.3(ember-cli@7.0.1(@babel/core@7.29.7)(@types/node@22.19.19))
+ version: 3.3.3(ember-cli@7.0.1(@babel/core@7.29.7)(@types/node@22.19.19)(ejs@3.1.10)(handlebars@4.7.9)(underscore@1.13.8))
ember-cli-yuidoc:
specifier: ^0.9.1
version: 0.9.1
@@ -2840,7 +2834,7 @@ importers:
version: 3.0.0
ember-cli-dependency-checker:
specifier: ^3.3.3
- version: 3.3.3(ember-cli@7.0.1(@babel/core@7.29.7)(@types/node@22.19.19))
+ version: 3.3.3(ember-cli@7.0.1(@babel/core@7.29.7)(@types/node@22.19.19)(ejs@3.1.10)(handlebars@4.7.9)(underscore@1.13.8))
ember-cli-deprecation-workflow:
specifier: ^3.4.0
version: 3.4.0(ember-source@)
@@ -17167,7 +17161,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- ember-cli-dependency-checker@3.3.3(ember-cli@7.0.1(@babel/core@7.29.7)(@types/node@22.19.19)):
+ ember-cli-dependency-checker@3.3.3(ember-cli@7.0.1(@babel/core@7.29.7)(@types/node@22.19.19)(ejs@3.1.10)(handlebars@4.7.9)(underscore@1.13.8)):
dependencies:
chalk: 2.4.2
ember-cli: 7.0.1(@babel/core@7.29.7)(@types/node@22.19.19)(ejs@3.1.10)(handlebars@4.7.9)(underscore@1.13.8)
From 92af170031a5da1aedc924273fbaa1cfb1d0d484 Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Wed, 1 Jul 2026 23:37:38 -0400
Subject: [PATCH 4/6] Tighten API surfaces: builder is internal, document comes
from the environment
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.../-internals/glimmer/lib/base-renderer.ts | 68 ++++++++-----------
packages/@ember/renderer/index.ts | 15 ++--
tests/node-vitest/render-to-string.test.js | 35 ++++------
3 files changed, 47 insertions(+), 71 deletions(-)
diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts
index 7022ab4d07c..3087d5ef7a8 100644
--- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts
+++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts
@@ -524,6 +524,10 @@ export function renderComponent(
...env,
isInteractive: env?.isInteractive ?? true,
hasDOM: env && 'hasDOM' in env ? Boolean(env?.['hasDOM']) : true,
+ // `rehydrationBuilder` adopts server-rendered markup already in the
+ // target (emitted by `renderToString` with `rehydratable: true`)
+ // instead of building fresh DOM.
+ builder: env?.rehydrate ? rehydrationBuilder : clientBuilder,
});
RENDERER_CACHE.set(owner, renderer);
}
@@ -617,17 +621,18 @@ const RENDERER_CACHE = new WeakMap();
* - The component tree is torn down (running destructors) before the promise
* resolves.
*
- * This requires a DOM implementation. In the browser the global `document` is
- * used automatically; in Node.js provide one via `env.document` — for example
+ * This renders with the global `document`, same as the browser does — there is
+ * no `document` option. In environments without one (Node.js), register DOM
+ * building blocks globally first — for example with
* [happy-dom](https://github.com/capricorn86/happy-dom):
*
* ```js
- * import { Window } from 'happy-dom';
+ * import { GlobalRegistrator } from '@happy-dom/global-registrator';
* import { renderToString } from '@ember/renderer';
*
- * let html = await renderToString(MyComponent, {
- * env: { document: new Window().document },
- * });
+ * GlobalRegistrator.register();
+ *
+ * let html = await renderToString(MyComponent, { args: { name: 'Zoey' } });
* ```
*
* Pass `env: { rehydratable: true }` to include glimmer's rehydration markers
@@ -642,7 +647,7 @@ const RENDERER_CACHE = new WeakMap();
* @param {Object} [options]
* @param {Object} [options.owner] Optionally specify the owner to use. This will be used for injections, and overall cleanup.
* @param {Object} [options.args] Optionally pass args in to the component. These may be reactive; rendering settles before serialization.
- * @param {Object} [options.env] Optional renderer configuration. `document` provides the DOM implementation to render with (defaults to the global `document`); `rehydratable` (default `false`) controls whether rehydration markers are emitted.
+ * @param {Object} [options.env] Optional renderer configuration. `rehydratable` (default `false`) controls whether rehydration markers are emitted.
* @returns {Promise} the serialized HTML for the rendered component
* @public
*/
@@ -673,12 +678,6 @@ export async function renderToString(
* Optionally configure the rendering environment.
*/
env?: {
- /**
- * The DOM implementation to render with. Defaults to the global
- * `document`. In environments without one (Node.js), pass a
- * DOM-compatible document such as happy-dom's `new Window().document`.
- */
- document?: Document;
/**
* When true, the emitted HTML includes glimmer's rehydration markers so
* the output can be re-used (rehydrated) by a subsequent client-side
@@ -686,20 +685,18 @@ export async function renderToString(
* framework-specific comments.
*/
rehydratable?: boolean;
- /**
- * All other options are forwarded to the underlying renderer (private API).
- */
- [rendererOption: string]: unknown;
};
} = {}
): Promise {
- let document = env?.document ?? globalThis.document;
+ let { document } = globalThis;
// Server rendering is a *real* render — modifiers run against real elements
// and the output is serialized via `innerHTML` — so a full DOM
- // implementation (browser, happy-dom, jsdom) is required.
+ // implementation (browser, happy-dom, jsdom) is required. There is no
+ // `document` option: the environment defines the document, same as in the
+ // browser.
assert(
- 'renderToString requires a document. In environments without a global `document` (like Node.js), provide DOM building blocks globally (e.g. via happy-dom) or pass a document via `env.document` — for example a happy-dom `new Window().document`.',
+ 'renderToString requires a global `document`. In environments without one (like Node.js), register DOM building blocks globally first — for example with happy-dom via `GlobalRegistrator.register()`.',
document !== undefined && document !== null
);
@@ -708,17 +705,18 @@ export async function renderToString(
let element = document.createElement('div');
// Build a dedicated renderer rather than going through the per-owner
- // renderer cache used by `renderComponent`: SSR output must target the
- // document chosen above, even if this owner has previously rendered into
- // the live DOM.
+ // renderer cache used by `renderComponent`, so a one-shot server render
+ // never interferes with an owner's live renderer.
//
// `isInteractive` and `hasDOM` are deliberately not options here: a server
- // render is a real, interactive render against a real (in-memory) DOM.
+ // render is a real, interactive render against a real DOM.
let renderer = BaseRenderer.strict(owner, document, {
- ...env,
isInteractive: true,
hasDOM: true,
- rehydratable: Boolean(env?.rehydratable),
+ // `serializeBuilder` emits glimmer's rehydration markers alongside the
+ // markup so a later client render can adopt it; `clientBuilder` emits
+ // clean markup.
+ builder: env?.rehydratable ? serializeBuilder : clientBuilder,
});
try {
@@ -742,28 +740,16 @@ export class BaseRenderer {
static strict(
owner: object,
document: SimpleDocument | Document,
- options: {
- isInteractive: boolean;
- hasDOM?: boolean;
- rehydratable?: boolean;
- rehydrate?: boolean;
- }
+ options: { isInteractive: boolean; hasDOM?: boolean; builder?: IBuilder }
) {
- let { rehydratable, rehydrate, ...envOptions } = options;
-
- // `serializeBuilder` emits glimmer's rehydration markers alongside the
- // markup (the server half of rehydration); `rehydrationBuilder` consumes
- // those markers to adopt existing server-rendered DOM instead of building
- // fresh nodes (the client half); `clientBuilder` builds clean markup from
- // scratch.
- let builder = rehydrate ? rehydrationBuilder : rehydratable ? serializeBuilder : clientBuilder;
+ let { builder, ...envOptions } = options;
return new BaseRenderer(
owner,
{ hasDOM: hasDOM, ...envOptions },
document as SimpleDocument,
new ResolverImpl(),
- builder
+ builder ?? clientBuilder
);
}
diff --git a/packages/@ember/renderer/index.ts b/packages/@ember/renderer/index.ts
index a0bf472035d..803d4390858 100644
--- a/packages/@ember/renderer/index.ts
+++ b/packages/@ember/renderer/index.ts
@@ -100,17 +100,18 @@ export { renderComponent } from '@ember/-internals/glimmer/lib/base-renderer';
* rendering has settled — any tracked-state updates made during render (e.g.
* by a modifier) are reflected in the output.
*
- * This requires a DOM implementation. In the browser the global `document` is
- * used automatically; in Node.js provide one via `env.document` — for example
+ * This renders with the global `document`, same as the browser does — there is
+ * no `document` option. In environments without one (Node.js), register DOM
+ * building blocks globally first — for example with
* [happy-dom](https://github.com/capricorn86/happy-dom):
*
* ```js
- * import { Window } from 'happy-dom';
+ * import { GlobalRegistrator } from '@happy-dom/global-registrator';
* import { renderToString } from '@ember/renderer';
*
- * let html = await renderToString(MyComponent, {
- * env: { document: new Window().document },
- * });
+ * GlobalRegistrator.register();
+ *
+ * let html = await renderToString(MyComponent, { args: { name: 'Zoey' } });
* ```
*
* Pass `env: { rehydratable: true }` to include glimmer's rehydration markers
@@ -124,7 +125,7 @@ export { renderComponent } from '@ember/-internals/glimmer/lib/base-renderer';
* @param {Object} [options]
* @param {Object} [options.owner] Optionally specify the owner to use. This will be used for injections, and overall cleanup.
* @param {Object} [options.args] Optionally pass args in to the component. These may be reactive; rendering settles before serialization.
- * @param {Object} [options.env] Optional renderer configuration (`document`, `rehydratable`).
+ * @param {Object} [options.env] Optional renderer configuration (`rehydratable`).
* @returns {Promise} the serialized HTML for the rendered component
* @public
*/
diff --git a/tests/node-vitest/render-to-string.test.js b/tests/node-vitest/render-to-string.test.js
index b4e3b009d50..3d503d5b6a0 100644
--- a/tests/node-vitest/render-to-string.test.js
+++ b/tests/node-vitest/render-to-string.test.js
@@ -1,16 +1,17 @@
+// @vitest-environment happy-dom
/**
- * SSR smoke tests for `renderToString` from `@ember/renderer`, run in plain
- * Node.js (no DOM globals) against the *built* ember-source package, with
- * happy-dom providing the DOM implementation.
+ * SSR smoke tests for `renderToString` from `@ember/renderer`, run in Node.js
+ * against the *built* ember-source package, with happy-dom registered as the
+ * global DOM implementation (the `@vitest-environment happy-dom` pragma above
+ * — equivalent to happy-dom's `GlobalRegistrator.register()` on a server).
*
* These prove the server-rendering contract:
- * - rendering works without a global `document` (the document is provided)
+ * - rendering works in Node once DOM building blocks are registered
* - modifiers run during SSR, against real (happy-dom) elements
* - rendering settles before serialization: tracked-state updates made
* during render are reflected in the output
*/
import { it, expect } from 'vitest';
-import { Window } from 'happy-dom';
import { renderToString } from 'ember-source/@ember/renderer/index.js';
import { template } from 'ember-source/@ember/template-compiler/runtime.js';
@@ -38,17 +39,10 @@ function modifier(fn) {
return setModifierManager(() => MODIFIER_MANAGER, fn);
}
-function freshDocument() {
- return new Window().document;
-}
-
-it('renders a component to a string with happy-dom', async () => {
+it('renders a component to a string', async () => {
let Hello = template('Hello, {{@name}}! ');
- let html = await renderToString(Hello, {
- args: { name: 'Zoey' },
- env: { document: freshDocument() },
- });
+ let html = await renderToString(Hello, { args: { name: 'Zoey' } });
expect(html).toBe('Hello, Zoey! ');
});
@@ -56,10 +50,7 @@ it('renders a component to a string with happy-dom', async () => {
it('renders {{{tripleStache}}} raw HTML', async () => {
let Component = template('{{{@html}}}
');
- let html = await renderToString(Component, {
- args: { html: 'bold ' },
- env: { document: freshDocument() },
- });
+ let html = await renderToString(Component, { args: { html: 'bold ' } });
expect(html).toBe('bold
');
});
@@ -74,7 +65,7 @@ it('runs modifiers during SSR, and their DOM effects appear in the output', asyn
scope: () => ({ autofocus }),
});
- let html = await renderToString(Component, { env: { document: freshDocument() } });
+ let html = await renderToString(Component);
expect(ran).toBe(true);
expect(html).toBe(' ');
@@ -89,7 +80,7 @@ it('settles before serializing: tracked state updated by a modifier is in the ou
scope: () => ({ load, state }),
});
- let html = await renderToString(Component, { env: { document: freshDocument() } });
+ let html = await renderToString(Component);
expect(html).toBe('loaded
');
});
@@ -97,9 +88,7 @@ it('settles before serializing: tracked state updated by a modifier is in the ou
it('emits rehydration markers when asked', async () => {
let Hello = template('Hello! ');
- let html = await renderToString(Hello, {
- env: { document: freshDocument(), rehydratable: true },
- });
+ let html = await renderToString(Hello, { env: { rehydratable: true } });
expect(html).toContain('');
expect(html).toContain('Hello! ');
From 16054ba539fde4878cf690f4afacdf774b1ab10e Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Wed, 1 Jul 2026 23:46:09 -0400
Subject: [PATCH 5/6] The tree builder belongs to the render call, not the
renderer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`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)
---
.../-internals/glimmer/lib/base-renderer.ts | 46 +++++++++++--------
.../components/render-to-string-test.ts | 44 ++++++++++++++++++
2 files changed, 72 insertions(+), 18 deletions(-)
diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts
index 3087d5ef7a8..4b0015bca95 100644
--- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts
+++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts
@@ -90,12 +90,17 @@ export class ComponentRootState implements RendererRoot {
constructor(
state: RendererState,
definition: object,
- options: { into: Cursor; args?: Record }
+ options: { into: Cursor; args?: Record; builder?: IBuilder }
) {
this.#render = errorLoopTransaction(() => {
+ // The tree builder is a property of an individual render (this root),
+ // not of the renderer: the same renderer can rehydrate one root and
+ // build another from scratch.
+ let builder = options.builder ?? state.builder;
+
let iterator = glimmerRenderComponent(
state.context,
- state.builder(state.env, options.into),
+ builder(state.env, options.into),
state.owner,
definition,
options?.args
@@ -524,10 +529,6 @@ export function renderComponent(
...env,
isInteractive: env?.isInteractive ?? true,
hasDOM: env && 'hasDOM' in env ? Boolean(env?.['hasDOM']) : true,
- // `rehydrationBuilder` adopts server-rendered markup already in the
- // target (emitted by `renderToString` with `rehydratable: true`)
- // instead of building fresh DOM.
- builder: env?.rehydrate ? rehydrationBuilder : clientBuilder,
});
RENDERER_CACHE.set(owner, renderer);
}
@@ -575,7 +576,14 @@ export function renderComponent(
renderTarget = { element: parentElement, nextSibling: firstNode };
}
- let innerResult = renderer.render(component, { into: renderTarget, args }).result;
+ let innerResult = renderer.render(component, {
+ into: renderTarget,
+ args,
+ // A rehydrating render adopts server-rendered markup already in the
+ // target (emitted by `renderToString` with `rehydratable: true`) instead
+ // of building fresh DOM.
+ builder: env?.rehydrate ? rehydrationBuilder : undefined,
+ }).result;
if (innerResult) {
associateDestroyableChild(owner, innerResult);
@@ -713,14 +721,17 @@ export async function renderToString(
let renderer = BaseRenderer.strict(owner, document, {
isInteractive: true,
hasDOM: true,
- // `serializeBuilder` emits glimmer's rehydration markers alongside the
- // markup so a later client render can adopt it; `clientBuilder` emits
- // clean markup.
- builder: env?.rehydratable ? serializeBuilder : clientBuilder,
});
try {
- renderer.render(component, { into: element, args });
+ renderer.render(component, {
+ into: element,
+ args,
+ // `serializeBuilder` emits glimmer's rehydration markers alongside the
+ // markup so a later client render can adopt it; the default builder
+ // emits clean markup.
+ builder: env?.rehydratable ? serializeBuilder : undefined,
+ });
// The initial render is synchronous, but rendering isn't *done* until
// every follow-on invalidation has settled: modifiers run during SSR and
@@ -740,16 +751,14 @@ export class BaseRenderer {
static strict(
owner: object,
document: SimpleDocument | Document,
- options: { isInteractive: boolean; hasDOM?: boolean; builder?: IBuilder }
+ options: { isInteractive: boolean; hasDOM?: boolean }
) {
- let { builder, ...envOptions } = options;
-
return new BaseRenderer(
owner,
- { hasDOM: hasDOM, ...envOptions },
+ { hasDOM: hasDOM, ...options },
document as SimpleDocument,
new ResolverImpl(),
- builder ?? clientBuilder
+ clientBuilder
);
}
@@ -810,11 +819,12 @@ export class BaseRenderer {
render(
component: object,
- options: { into: IntoTarget; args?: Record }
+ options: { into: IntoTarget; args?: Record; builder?: IBuilder }
): RendererRoot {
const root = new ComponentRootState(this.state, component, {
args: options.args,
into: intoTarget(options.into),
+ builder: options.builder,
});
return this.state.renderRoot(root, this);
}
diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts b/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
index 62fddb8f003..b3b952d6476 100644
--- a/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
+++ b/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
@@ -170,6 +170,50 @@ moduleFor(
run(() => result.destroy());
}
+ async ['@test rehydration works even when the owner has already rendered normally']() {
+ // The tree builder is per render call, not per (cached-per-owner)
+ // renderer: a prior normal render must not lock the owner out of
+ // rehydrating later.
+ let Plain = template('plain
');
+ let Hello = template('Hello, {{@name}}! ');
+
+ let fixture = document.querySelector('#qunit-fixture') as HTMLElement;
+ let plainTarget = fixture.appendChild(document.createElement('div'));
+
+ // First: a normal client render, which caches this owner's renderer.
+ let first = renderComponent(Plain, { owner: this.owner, into: plainTarget });
+ this.assert.strictEqual(plainTarget.textContent, 'plain');
+
+ // Then: server output rehydrated with the same owner.
+ let html = await renderToString(Hello, {
+ owner: this.owner,
+ args: { name: 'Zoey' },
+ env: { rehydratable: true },
+ });
+ let target = fixture.appendChild(document.createElement('div'));
+ target.innerHTML = html;
+ let serverNode = target.querySelector('h1');
+
+ let result = renderComponent(Hello, {
+ owner: this.owner,
+ into: target,
+ args: { name: 'Zoey' },
+ env: { rehydrate: true },
+ });
+
+ this.assert.strictEqual(target.textContent, 'Hello, Zoey!');
+ this.assert.strictEqual(
+ target.querySelector('h1'),
+ serverNode,
+ 'the server-rendered element was adopted despite a prior normal render with this owner'
+ );
+
+ run(() => {
+ result.destroy();
+ first.destroy();
+ });
+ }
+
async ['@test rehydration adopts nested component markup']() {
let Inner = template('inner ');
let Outer = template('
', {
From 26d22da5896f942ad8b335137780a7dce6ffcef9 Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Thu, 2 Jul 2026 00:12:36 -0400
Subject: [PATCH 6/6] Run prettier (pnpm lint:fix)
CI's lint:format caught two files with stray blank lines.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/@ember/-internals/glimmer/lib/base-renderer.ts | 1 -
.../tests/integration/components/render-to-string-test.ts | 1 -
2 files changed, 2 deletions(-)
diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts
index 4b0015bca95..e542e2ffec0 100644
--- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts
+++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts
@@ -441,7 +441,6 @@ function intoTarget(into: IntoTarget): Cursor {
}
}
-
/**
* Render a component into a DOM element.
*
diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts b/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
index b3b952d6476..8a928a6c1dc 100644
--- a/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
+++ b/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts
@@ -252,6 +252,5 @@ moduleFor(
'ok '
);
}
-
}
);