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('', { + scope: () => ({ on, noop }), + }); + + let html = renderToString(Component, { owner: this.owner }); + + this.assert.strictEqual(html, ''); + 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('', { - scope: () => ({ on, noop }), + let installed = defineSimpleModifier(() => (ran = true)); + let Component = template('', { + scope: () => ({ installed }), }); - let html = renderToString(Component, { owner: this.owner }); + let html = await renderToString(Component, { owner: this.owner }); this.assert.strictEqual(html, ''); - 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' ); } - } );