fix: reduce SSR render result garbage collection (#18798)

Fixes #18797.

### Problem

SSR `render()` is lazy, but the wrapper benchmark discarded its result
and therefore measured almost no rendering work. When `.body` was
consumed, each render result's own accessor properties caused
substantial garbage-collection overhead.

### Fix

- Consume `render(App).body` in both the warmup and measured SSR
benchmark loops.
- Move lazy render-result properties onto a shared `RenderResult`
prototype.
- Memoize synchronous output and the asynchronous render promise while
preserving lazy sync/async API behavior.
- Add a regression test ensuring exposed render-result properties are
inherited.
- Add a patch changeset for `svelte`.

The benchmark now deliberately selects the synchronous lazy-render path
by reading `.body`, so the process-wide async flag imported by the
reactivity benchmarks does not determine this benchmark's render mode.

### Performance on this host

| Case | Time | GC time |
| --- | ---: | ---: |
| main, output discarded | 0.20 ms | 0.00 ms |
| main, `.body` consumed before fix | 163.04 ms | 62.68 ms |
| `svelte@5.38.10` | 110.05 ms | 1.39 ms |
| fixed, `.body` consumed | 104.79 ms | 2.43 ms |

### Tests

- `pnpm test packages/svelte/src/internal/server/renderer.test.ts` — 53
passed
- `pnpm test server-side-rendering` — 234 passed, 2 skipped
- `pnpm test` — 7,771 passed, 55 skipped
- `pnpm lint`
- `pnpm check`
- `pnpm format`
- `git diff --check`

<!--
svelte-triage-bot:feedback-baseline:116724e3b641c84ed56b2a60d494b6cb2298bbb3
-->

Co-authored-by: svelte-triage-bot <team@svelte.com>
pull/18759/merge
svelte-triage-bot[bot] 2 weeks ago committed by GitHub
parent 6be176df2f
commit a8a9b02e38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: reduce SSR render result garbage collection

@ -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;
}
});
}

@ -23,6 +23,87 @@ import { escape_html } from '../../escaping.js';
* @typedef {string | Renderer} RendererItem
*/
class RenderResult {
/** @type {() => AccumulatedContent} */
#render;
/** @type {() => Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }>} */
#render_async;
/** @type {AccumulatedContent | undefined} */
#sync;
/** @type {{ script: '' }} */
#hashes = { script: '' };
/** @type {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }> | undefined} */
#promise;
/**
* @param {() => AccumulatedContent} render
* @param {() => Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }>} 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<AccumulatedContent & { hashes: { script: Sha256Source[] } }> | 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;
)
)
)
);
}
/**

@ -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');

Loading…
Cancel
Save