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..e542e2ffec0 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -23,6 +23,8 @@ 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 { 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'; @@ -88,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 @@ -480,6 +487,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 +547,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 && into instanceof Element && !env?.rehydrate) { into.innerHTML = ''; } @@ -558,7 +575,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); @@ -580,6 +604,148 @@ export function renderComponent( const RENDER_CACHE = new WeakMap(); const RENDERER_CACHE = new WeakMap(); +/** + * Render a component to an HTML string. + * + * This is the server-side-rendering (SSR) counterpart to + * {@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 = await renderToString(MyComponent, { args: { name: 'Zoey' } }); + * // => "

Hello, Zoey!

" + * ``` + * + * 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 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 { GlobalRegistrator } from '@happy-dom/global-registrator'; + * import { renderToString } from '@ember/renderer'; + * + * GlobalRegistrator.register(); + * + * let html = await renderToString(MyComponent, { args: { name: 'Zoey' } }); + * ``` + * + * 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 + * @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. These may be reactive; rendering settles before serialization. + * @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 + */ +export async 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. + * + * If your args are reactive, rendering settles before serialization. + */ + args?: Record; + /** + * Optionally configure the rendering environment. + */ + env?: { + /** + * 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; + }; + } = {} +): Promise { + 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. There is no + // `document` option: the environment defines the document, same as in the + // browser. + assert( + '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 + ); + + // 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`, 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 DOM. + let renderer = BaseRenderer.strict(owner, document, { + isInteractive: true, + hasDOM: true, + }); + + try { + 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 + // 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 so component destructors + // run and nothing stays registered with the run loop. + _backburner.run(() => renderer.destroy()); + } +} + export class BaseRenderer { static strict( owner: object, @@ -652,11 +818,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/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..8a928a6c1dc --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/render-to-string-test.ts @@ -0,0 +1,256 @@ +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 { tracked } from '@glimmer/tracking'; +import { run } from '@ember/runloop'; +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 { + async ['@test it renders a template-only component to a string']() { + let Hello = template('

Hello, world!

'); + + this.assert.strictEqual( + await renderToString(Hello, { owner: this.owner }), + '

Hello, world!

' + ); + } + + async ['@test it renders args into the string']() { + let Greeting = template('

Hello, {{@name}}!

'); + + this.assert.strictEqual( + await renderToString(Greeting, { owner: this.owner, args: { name: 'Zoey' } }), + '

Hello, Zoey!

' + ); + } + + async ['@test it renders `{{{tripleStache}}}` raw HTML']() { + let Component = template('
{{{@html}}}
'); + + this.assert.strictEqual( + await renderToString(Component, { owner: this.owner, args: { html: 'bold' } }), + '
bold
' + ); + } + + async ['@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( + await renderToString(Component, { owner: this.owner, args: { message: 'hi' } }), + 'HI' + ); + } + + async ['@test modifiers run during server rendering']() { + // Server rendering is a real, interactive render — unlike FastBoot, + // modifiers are not skipped. + let ran = false; + let installed = defineSimpleModifier(() => (ran = true)); + let Component = template('', { + scope: () => ({ installed }), + }); + + let html = await renderToString(Component, { owner: this.owner }); + + this.assert.strictEqual(html, ''); + this.assert.true(ran, 'the modifier installed during SSR'); + } + + 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 = await 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'); + } + + async ['@test renderComponent can rehydrate server-rendered markup, reusing the DOM']() { + let Hello = template('

Hello, {{@name}}!

'); + + // Server: render to a rehydratable string. + let html = await 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()); + } + + 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('
', { + scope: () => ({ Inner }), + }); + + let html = await 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()); + } + + 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( + await renderToString(Component, { owner: this.owner }), + 'ok' + ); + } + } +); diff --git a/packages/@ember/renderer/index.ts b/packages/@ember/renderer/index.ts index f9084bb1483..803d4390858 100644 --- a/packages/@ember/renderer/index.ts +++ b/packages/@ember/renderer/index.ts @@ -80,3 +80,53 @@ 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. + * + * This is the server-side-rendering (SSR) counterpart to `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 = await renderToString(MyComponent, { args: { name: 'Zoey' } }); + * // => "

Hello, Zoey!

" + * ``` + * + * 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 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 { GlobalRegistrator } from '@happy-dom/global-registrator'; + * import { renderToString } from '@ember/renderer'; + * + * GlobalRegistrator.register(); + * + * let html = await renderToString(MyComponent, { args: { name: 'Zoey' } }); + * ``` + * + * 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 + * @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. These may be reactive; rendering settles before serialization. + * @param {Object} [options.env] Optional renderer configuration (`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 5860bfd674c..823e0d8ed13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3237,8 +3237,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 @@ -5598,6 +5601,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==} @@ -6425,6 +6431,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==} @@ -7561,6 +7571,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'} @@ -8399,6 +8413,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'} @@ -11856,6 +11874,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'} @@ -14951,7 +14973,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: @@ -14988,18 +15010,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 @@ -16241,6 +16265,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 @@ -17598,6 +17626,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + env-paths@2.2.1: {} errlop@2.2.0: {} @@ -18771,6 +18801,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: @@ -22402,7 +22445,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)) @@ -22426,6 +22469,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 @@ -22600,6 +22644,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/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', 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..3d503d5b6a0 --- /dev/null +++ b/tests/node-vitest/render-to-string.test.js @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom +/** + * 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 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 { 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); +} + +it('renders a component to a string', async () => { + let Hello = template('

Hello, {{@name}}!

'); + + let html = await renderToString(Hello, { args: { name: 'Zoey' } }); + + expect(html).toBe('

Hello, Zoey!

'); +}); + +it('renders {{{tripleStache}}} raw HTML', async () => { + let Component = template('
{{{@html}}}
'); + + let html = await renderToString(Component, { args: { html: 'bold' } }); + + 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); + + 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); + + expect(html).toBe('
loaded
'); +}); + +it('emits rehydration markers when asked', async () => { + let Hello = template('

Hello!

'); + + let html = await renderToString(Hello, { env: { rehydratable: true } }); + + expect(html).toContain(''); + expect(html).toContain('

Hello!

'); +});