From 116724e3b641c84ed56b2a60d494b6cb2298bbb3 Mon Sep 17 00:00:00 2001 From: svelte-triage-bot Date: Tue, 8 Sep 2026 18:44:00 +0000 Subject: [PATCH] fix: reduce SSR render result garbage collection --- .changeset/calm-render-results.md | 5 + .../benchmarks/ssr/wrapper/wrapper_bench.js | 4 +- .../svelte/src/internal/server/renderer.js | 157 ++++++++++-------- .../src/internal/server/renderer.test.ts | 10 ++ 4 files changed, 108 insertions(+), 68 deletions(-) create mode 100644 .changeset/calm-render-results.md diff --git a/.changeset/calm-render-results.md b/.changeset/calm-render-results.md new file mode 100644 index 0000000000..f3073ec4b5 --- /dev/null +++ b/.changeset/calm-render-results.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: reduce SSR render result garbage collection diff --git a/benchmarking/benchmarks/ssr/wrapper/wrapper_bench.js b/benchmarking/benchmarks/ssr/wrapper/wrapper_bench.js index 9a8dda617d..50634ff4e6 100644 --- a/benchmarking/benchmarks/ssr/wrapper/wrapper_bench.js +++ b/benchmarking/benchmarks/ssr/wrapper/wrapper_bench.js @@ -25,12 +25,12 @@ export const wrapper_bench = { // Do 3 loops to warm up JIT for (let i = 0; i < 3; i++) { - render(App); + render(App).body; } return await fastest_test(10, () => { for (let i = 0; i < 100; i++) { - render(App); + render(App).body; } }); } diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 6b3c1b28dd..49061c0b0f 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -23,6 +23,87 @@ import { escape_html } from '../../escaping.js'; * @typedef {string | Renderer} RendererItem */ +class RenderResult { + /** @type {() => AccumulatedContent} */ + #render; + + /** @type {() => Promise} */ + #render_async; + + /** @type {AccumulatedContent | undefined} */ + #sync; + + /** @type {{ script: '' }} */ + #hashes = { script: '' }; + + /** @type {Promise | undefined} */ + #promise; + + /** + * @param {() => AccumulatedContent} render + * @param {() => Promise} render_async + */ + constructor(render, render_async) { + this.#render = render; + this.#render_async = render_async; + } + + #get() { + return (this.#sync ??= this.#render()); + } + + get html() { + return this.#get().body; + } + + get head() { + return this.#get().head; + } + + get body() { + return this.#get().body; + } + + get hashes() { + return this.#hashes; + } + + /** + * This is not type-safe, but honestly it's the best I can do right now, and it's a straightforward function. + * + * @template TResult1 + * @template [TResult2=never] + * @param {(value: SyncRenderOutput) => TResult1} onfulfilled + * @param {(reason: unknown) => TResult2} onrejected + */ + then(onfulfilled, onrejected) { + if (!async_mode_flag) { + const result = this.#get(); + const user_result = onfulfilled({ + head: result.head, + body: result.body, + html: result.body, + hashes: { script: [] } + }); + return Promise.resolve(user_result); + } + + this.#promise ??= this.#render_async().then((result) => { + Object.defineProperty(result, 'html', { + // eslint-disable-next-line getter-return + get: () => { + e.html_deprecated(); + } + }); + return result; + }); + return this.#promise.then( + (result) => onfulfilled(/** @type {SyncRenderOutput} */ (result)), + onrejected + ); + } +} + /** * Renderers are basically a tree of `string | Renderer`s, where each `Renderer` in the tree represents * work that may or may not have completed. A renderer can be {@link collect}ed to aggregate the @@ -534,73 +615,17 @@ export class Renderer { * @returns {RenderOutput} */ static render(component, options = {}) { - /** @type {AccumulatedContent | undefined} */ - let sync; - /** @type {Promise | undefined} */ - let async; - - const result = /** @type {RenderOutput} */ ({}); - // making these properties non-enumerable so that console.logging - // doesn't trigger a sync render - Object.defineProperties(result, { - html: { - get: () => { - return (sync ??= Renderer.#render(component, options)).body; - } - }, - head: { - get: () => { - return (sync ??= Renderer.#render(component, options)).head; - } - }, - body: { - get: () => { - return (sync ??= Renderer.#render(component, options)).body; - } - }, - hashes: { - value: { - script: '' - } - }, - then: { - value: - /** - * this is not type-safe, but honestly it's the best I can do right now, and it's a straightforward function. - * - * @template TResult1 - * @template [TResult2=never] - * @param { (value: SyncRenderOutput) => TResult1 } onfulfilled - * @param { (reason: unknown) => TResult2 } onrejected - */ - (onfulfilled, onrejected) => { - if (!async_mode_flag) { - const result = (sync ??= Renderer.#render(component, options)); - const user_result = onfulfilled({ - head: result.head, - body: result.body, - html: result.body, - hashes: { script: [] } - }); - return Promise.resolve(user_result); - } - async ??= init_render_context().then(() => + return /** @type {RenderOutput} */ ( + /** @type {unknown} */ ( + new RenderResult( + () => Renderer.#render(component, options), + () => + init_render_context().then(() => with_render_context(() => Renderer.#render_async(component, options)) - ); - return async.then((result) => { - Object.defineProperty(result, 'html', { - // eslint-disable-next-line getter-return - get: () => { - e.html_deprecated(); - } - }); - return onfulfilled(/** @type {SyncRenderOutput} */ (result)); - }, onrejected); - } - } - }); - - return result; + ) + ) + ) + ); } /** diff --git a/packages/svelte/src/internal/server/renderer.test.ts b/packages/svelte/src/internal/server/renderer.test.ts index 1adfdda64c..f35df5d571 100644 --- a/packages/svelte/src/internal/server/renderer.test.ts +++ b/packages/svelte/src/internal/server/renderer.test.ts @@ -4,6 +4,16 @@ import type { Component } from 'svelte'; import { disable_async_mode_flag, enable_async_mode_flag } from '../flags/index.js'; import { getAbortSignal } from './abort-signal.js'; +test('render result properties are inherited', () => { + const result = Renderer.render((() => {}) as unknown as Component); + + expect(Object.hasOwn(result, 'head')).toBe(false); + expect(Object.hasOwn(result, 'body')).toBe(false); + expect(Object.hasOwn(result, 'html')).toBe(false); + expect(Object.hasOwn(result, 'hashes')).toBe(false); + expect(Object.hasOwn(result, 'then')).toBe(false); +}); + test('collects synchronous body content by default', () => { const component = (renderer: Renderer) => { renderer.push('a');