diff --git a/.changeset/fast-wolves-render.md b/.changeset/fast-wolves-render.md new file mode 100644 index 000000000000..28ba531e297e --- /dev/null +++ b/.changeset/fast-wolves-render.md @@ -0,0 +1,7 @@ +--- +'astro': patch +--- + +Improves `.astro` component SSR rendering performance by up to 2x. + +This includes several optimizations to the way that Astro generates and renders components on the server. These are mostly micro-optimizations, but they add up to a significant improvement in performance. Most pages will benefit, but pages with many components will see the biggest improvement, as will pages with lots of strings (e.g. text-heavy pages with lots of HTML elements). diff --git a/benchmark/bench/codspeed-setup.js b/benchmark/bench/codspeed-setup.js index 23c6bbdaa7de..043ab45c3051 100644 --- a/benchmark/bench/codspeed-setup.js +++ b/benchmark/bench/codspeed-setup.js @@ -4,21 +4,23 @@ import { astroBin, makeProject } from './_util.js'; /** * Setup script for codspeed benchmarks. - * This builds the render-bench project which is required for the rendering benchmarks. + * This builds the benchmark projects which are required for the rendering benchmarks. * This is separated out so it can run in a separate CI job since it takes a long time. */ async function setup() { - console.log('Setting up render-bench project...'); - const render = await makeProject('render-bench'); - const root = fileURLToPath(render); + for (const name of ['render-bench', 'rendering-perf']) { + console.log(`Setting up ${name} project...`); + const projectDir = await makeProject(name); + const root = fileURLToPath(projectDir); - console.log(`Building project at ${root}...`); - await exec(astroBin, ['build'], { - nodeOptions: { - cwd: root, - stdio: 'inherit', - }, - }); + console.log(`Building project at ${root}...`); + await exec(astroBin, ['build'], { + nodeOptions: { + cwd: root, + stdio: 'inherit', + }, + }); + } } setup().catch((error) => { diff --git a/benchmark/bench/rendering-perf.bench.js b/benchmark/bench/rendering-perf.bench.js new file mode 100644 index 000000000000..db71f7691570 --- /dev/null +++ b/benchmark/bench/rendering-perf.bench.js @@ -0,0 +1,90 @@ +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { beforeAll, bench, describe } from 'vitest'; + +/** + * Rendering performance benchmarks targeting specific hot paths from RENDERING_PERF_PLAN.md. + * + * Each page isolates a different performance pattern: + * - many-components: #1 markHTMLString, #2 isHTMLString, #6 validateComponentProps + * - many-expressions: #2 isHTMLString, #5 renderChild dispatch, #10 escapeHTML + * - many-head-elements: #3 head dedup O(N²) + * - many-slots: #9 eager slot prerendering + * - large-array: #8 BufferedRenderer per array child + * - static-heavy: #1 markHTMLString baseline, #11/#12 future comparison + * + * Requires: pnpm run build:bench + */ + +const projectRoot = new URL('../projects/rendering-perf/', import.meta.url); + +let streamingApp; +let nonStreamingApp; + +beforeAll(async () => { + const entry = new URL('./dist/server/entry.mjs', projectRoot); + + if (!existsSync(fileURLToPath(entry))) { + throw new Error( + 'rendering-perf project not built. Please run `pnpm run build:bench` before running the benchmarks.', + ); + } + + const { createApp } = await import(entry); + streamingApp = createApp(true); + nonStreamingApp = createApp(false); +}, 900000); + +// Non-streaming (prerender path) — this is the primary target for most optimizations +// since it's the path where all the overhead is synchronous and measurable. +describe('Rendering perf (non-streaming)', () => { + bench('many-components (markHTMLString, isHTMLString, validateProps)', async () => { + const request = new Request(new URL('http://example.com/many-components')); + await nonStreamingApp.render(request); + }); + + bench('many-expressions (renderChild dispatch, escapeHTML)', async () => { + const request = new Request(new URL('http://example.com/many-expressions')); + await nonStreamingApp.render(request); + }); + + bench('many-head-elements (head dedup)', async () => { + const request = new Request(new URL('http://example.com/many-head-elements')); + await nonStreamingApp.render(request); + }); + + bench('many-slots (eager slot prerendering)', async () => { + const request = new Request(new URL('http://example.com/many-slots')); + await nonStreamingApp.render(request); + }); + + bench('large-array (BufferedRenderer per child)', async () => { + const request = new Request(new URL('http://example.com/large-array')); + await nonStreamingApp.render(request); + }); + + bench('static-heavy (markHTMLString baseline)', async () => { + const request = new Request(new URL('http://example.com/static-heavy')); + await nonStreamingApp.render(request); + }); +}); + +// Streaming path — included for comparison. Optimizations to the sync path +// (#1, #2, #5, #6) should show up here too, but BufferedRenderer (#8) and +// slot prerendering (#9) may behave differently. +describe('Rendering perf (streaming)', () => { + bench('many-components [streaming]', async () => { + const request = new Request(new URL('http://example.com/many-components')); + await streamingApp.render(request); + }); + + bench('many-expressions [streaming]', async () => { + const request = new Request(new URL('http://example.com/many-expressions')); + await streamingApp.render(request); + }); + + bench('large-array [streaming]', async () => { + const request = new Request(new URL('http://example.com/large-array')); + await streamingApp.render(request); + }); +}); diff --git a/benchmark/make-project/rendering-perf.js b/benchmark/make-project/rendering-perf.js new file mode 100644 index 000000000000..453972ca5dc2 --- /dev/null +++ b/benchmark/make-project/rendering-perf.js @@ -0,0 +1,285 @@ +import fs from 'node:fs/promises'; +import { loremIpsumHtml } from './_util.js'; + +/** + * Generates a benchmark project targeting specific rendering hot paths + * identified in RENDERING_PERF_PLAN.md. Each page isolates a different + * performance-sensitive pattern so we can measure the impact of optimizations. + */ + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +const components = { + // A leaf component with ~4 static HTML parts and a few expressions. + // Used by many-components to stress markHTMLString + isHTMLString (#1, #2). + 'components/Card.astro': `\ +--- +const { title, href, index } = Astro.props; +--- +
+
+

{title}

+
+
+

Card number {index}, rendering static content around expressions.

+
+ +
+`, + + // A wrapper component that renders children via a default slot. + // Used by many-slots to stress eager slot prerendering (#9). + 'components/Section.astro': `\ +--- +const { heading } = Astro.props; +--- +
+

{heading}

+ +
+`, + + // A component with 3 named slots — only default is typically used. + // Stresses eager slot prerendering (#9). + 'components/Layout.astro': `\ +--- +const { title } = Astro.props; +--- + + + {title} + + + +
+
+ + + +`, + + // A component that contributes a +`, + + // A component that contributes a unique +`, +}; + +// --------------------------------------------------------------------------- +// Pages +// --------------------------------------------------------------------------- + +const pages = { + // #1, #2, #6: 200 Astro component instances, each with ~4 HTML parts. + // Stresses markHTMLString allocs, isHTMLString checks, validateComponentProps. + 'pages/many-components.astro': `\ +--- +import Card from '../components/Card.astro'; +const items = Array.from({ length: 200 }, (_, i) => ({ + title: \`Card \${i}\`, + href: \`/card/\${i}\`, + index: i, +})); +--- + + Many Components + +

200 Component Instances

+ {items.map((item) => ( + + ))} + + +`, + + // #2, #5, #10: Thousands of text expressions ({value}). + // Stresses renderChild dispatch ordering, isHTMLString, escapeHTML. + 'pages/many-expressions.astro': `\ +--- +const items = Array.from({ length: 2000 }, (_, i) => ({ + name: \`Item \${i}\`, + value: i * 17, + label: i % 2 === 0 ? "even" : "odd", +})); +const title = "Expression Heavy Page"; +const subtitle = "Testing renderChild dispatch"; +--- + + {title} + +

{title}

+

{subtitle}

+ + + + + + {items.map((item) => ( + + + + + + ))} + +
NameValueLabel
{item.name}{item.value}{item.label}
+ + +`, + + // #3: 60 components each contributing styles to . + // Stresses head deduplication O(N^2) with JSON.stringify. + 'pages/many-head-elements.astro': `\ +--- +import StyledWidget from '../components/StyledWidget.astro'; +import UniqueStyled from '../components/UniqueStyled.astro'; +// 30 instances of same component (dedup should collapse these) +const duplicated = Array.from({ length: 30 }, (_, i) => i); +// 30 instances with unique styles (dedup must compare all) +const unique = Array.from({ length: 30 }, (_, i) => i); +--- + + Many Head Elements + +

Head Deduplication Stress Test

+ {duplicated.map((i) => ( + + ))} + {unique.map((i) => ( + + ))} + + +`, + + // #9: Components with multiple named slots, only default used. + // Stresses eager slot prerendering of unused slots. + 'pages/many-slots.astro': `\ +--- +import Layout from '../components/Layout.astro'; +import Section from '../components/Section.astro'; +const sections = Array.from({ length: 100 }, (_, i) => ({ + heading: \`Section \${i}\`, + content: \`Content for section \${i} with some text to render.\`, +})); +--- + +

100 Sections with Slots

+ {sections.map((s) => ( +
+

{s.content}

+
+ ))} +
+`, + + // #8: Large array .map() with component children. + // Stresses BufferedRenderer-per-array-child allocation. + 'pages/large-array.astro': `\ +--- +import Card from '../components/Card.astro'; +const items = Array.from({ length: 5000 }, (_, i) => ({ + title: \`Item \${i}\`, + href: \`/item/\${i}\`, + index: i, +})); +--- + + Large Array + +

5000 Array Items with Components

+
+ {items.map((item) => ( + + ))} +
+ + +`, + + // #1, #11, #12: Mostly static HTML with very few expressions. + // Baseline for measuring overhead of the rendering machinery on static content. + 'pages/static-heavy.astro': `\ +--- +const title = "Static Heavy Page"; +--- + + {title} + +

{title}

+ ${Array.from({ length: 200 }) + .map( + (_, i) => `
+

Section ${i}

+

${loremIpsumHtml}

+

${loremIpsumHtml}

+
`, + ) + .join('\n ')} + + +`, +}; + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +export const renderPages = Object.keys(pages) + .filter((f) => f.startsWith('pages/')) + .map((f) => f.replace('pages/', '')); + +/** + * @param {URL} projectDir + */ +export async function run(projectDir) { + await fs.rm(projectDir, { recursive: true, force: true }); + await fs.mkdir(new URL('./src/pages', projectDir), { recursive: true }); + await fs.mkdir(new URL('./src/components', projectDir), { recursive: true }); + + const allFiles = { ...components, ...pages }; + + await Promise.all( + Object.entries(allFiles).map(([name, content]) => { + return fs.writeFile(new URL(`./src/${name}`, projectDir), content, 'utf-8'); + }), + ); + + await fs.writeFile( + new URL('./astro.config.js', projectDir), + `\ +import { defineConfig } from 'astro/config'; +import adapter from '@benchmark/adapter'; + +export default defineConfig({ + output: 'server', + adapter: adapter(), +});`, + 'utf-8', + ); +} diff --git a/packages/astro/src/runtime/server/escape.ts b/packages/astro/src/runtime/server/escape.ts index 1dbfd3725a88..1bd90785dbf8 100644 --- a/packages/astro/src/runtime/server/escape.ts +++ b/packages/astro/src/runtime/server/escape.ts @@ -48,7 +48,7 @@ export const markHTMLString = (value: any) => { }; export function isHTMLString(value: any): value is HTMLString { - return Object.prototype.toString.call(value) === '[object HTMLString]'; + return value instanceof HTMLString; } function markHTMLBytes(bytes: Uint8Array) { diff --git a/packages/astro/src/runtime/server/render/any.ts b/packages/astro/src/runtime/server/render/any.ts index b56ca7cdac87..4f17e28cd01b 100644 --- a/packages/astro/src/runtime/server/render/any.ts +++ b/packages/astro/src/runtime/server/render/any.ts @@ -6,6 +6,13 @@ import { SlotString } from './slot.js'; import { createBufferedRenderer } from './util.js'; export function renderChild(destination: RenderDestination, child: any): void | Promise { + // Strings are the most common child type (text expressions like {title}, {name}) + // so check them first for the fastest dispatch in the common case. + if (typeof child === 'string') { + destination.write(markHTMLString(escapeHTML(child))); + return; + } + if (isPromise(child)) { return child.then((x) => renderChild(destination, x)); } @@ -20,6 +27,11 @@ export function renderChild(destination: RenderDestination, child: any): void | return; } + if (!child && child !== 0) { + // do nothing, safe to ignore falsey values. + return; + } + if (Array.isArray(child)) { return renderArray(destination, child); } @@ -31,16 +43,6 @@ export function renderChild(destination: RenderDestination, child: any): void | return renderChild(destination, child()); } - if (!child && child !== 0) { - // do nothing, safe to ignore falsey values. - return; - } - - if (typeof child === 'string') { - destination.write(markHTMLString(escapeHTML(child))); - return; - } - if (isRenderInstance(child)) { return child.render(destination); } @@ -70,32 +72,43 @@ export function renderChild(destination: RenderDestination, child: any): void | } function renderArray(destination: RenderDestination, children: any[]): void | Promise { - // Render all children eagerly and in parallel - const flushers = children.map((c) => { - return createBufferedRenderer(destination, (bufferDestination) => { - return renderChild(bufferDestination, c); - }); - }); - - const iterator = flushers[Symbol.iterator](); - - const iterate = (): void | Promise => { - for (;;) { - const { value: flusher, done } = iterator.next(); - - if (done) { - break; + // Fast path: render children one at a time directly to the destination. + // If all children are sync, no buffering is needed at all. + // If a child returns a Promise, fall back to buffered rendering for + // the remaining children to preserve output ordering. + for (let i = 0; i < children.length; i++) { + const result = renderChild(destination, children[i]); + + if (isPromise(result)) { + // This child is async. Buffer remaining children in parallel + // to preserve ordering, then flush them sequentially. + if (i + 1 >= children.length) { + // No remaining children, just wait for this one + return result; } - const result = flusher.flush(); - - if (isPromise(result)) { - return result.then(iterate); + const remaining = children.length - i - 1; + const flushers = new Array(remaining); + for (let j = 0; j < remaining; j++) { + flushers[j] = createBufferedRenderer(destination, (bufferDestination) => { + return renderChild(bufferDestination, children[i + 1 + j]); + }); } - } - }; - return iterate(); + return result.then(() => { + let k = 0; + const iterate = (): void | Promise => { + while (k < flushers.length) { + const flushResult = flushers[k++].flush(); + if (isPromise(flushResult)) { + return flushResult.then(iterate); + } + } + }; + return iterate(); + }); + } + } } function renderIterable( diff --git a/packages/astro/src/runtime/server/render/astro/render-template.ts b/packages/astro/src/runtime/server/render/astro/render-template.ts index 330368a6ab1b..48d3be8fa9e3 100644 --- a/packages/astro/src/runtime/server/render/astro/render-template.ts +++ b/packages/astro/src/runtime/server/render/astro/render-template.ts @@ -33,44 +33,71 @@ export class RenderTemplateResult { } render(destination: RenderDestination): void | Promise { - // Render all expressions eagerly and in parallel - const flushers = this.expressions.map((exp) => { - return createBufferedRenderer(destination, (bufferDestination) => { - // Skip render if falsy, except the number 0 - if (exp || exp === 0) { - return renderChild(bufferDestination, exp); - } - }); - }); + // Fast path: render HTML parts and expressions directly to the + // destination without buffering. If all expressions are sync, + // this avoids all BufferedRenderer allocations. When an async + // expression is encountered, fall back to buffered rendering + // for the remaining expressions to preserve output ordering. + // + // Template structure: html[0] exp[0] html[1] exp[1] ... html[N] + // (htmlParts.length === expressions.length + 1) + const { htmlParts, expressions } = this; - let i = 0; + for (let i = 0; i < htmlParts.length; i++) { + const html = htmlParts[i]; + if (html) { + destination.write(markHTMLString(html)); + } - const iterate = (): void | Promise => { - while (i < this.htmlParts.length) { - const html = this.htmlParts[i]; - const flusher = flushers[i]; + // expressions[i] doesn't exist for the last htmlPart + if (i >= expressions.length) break; - // increment here due to potential return in - // Promise scenario - i++; + const exp = expressions[i]; + // Skip render if falsy, except the number 0 + if (!(exp || exp === 0)) continue; - if (html) { - // only write non-empty strings + const result = renderChild(destination, exp); - destination.write(markHTMLString(html)); + if (isPromise(result)) { + // This expression is async. Buffer remaining expressions + // in parallel to preserve ordering, then flush sequentially. + const startIdx = i + 1; + const remaining = expressions.length - startIdx; + const flushers = new Array(remaining); + for (let j = 0; j < remaining; j++) { + const rExp = expressions[startIdx + j]; + flushers[j] = createBufferedRenderer(destination, (bufferDestination) => { + if (rExp || rExp === 0) { + return renderChild(bufferDestination, rExp); + } + }); } - if (flusher) { - const result = flusher.flush(); + return result.then(() => { + let k = 0; + const iterate = (): void | Promise => { + while (k < flushers.length) { + // Write the HTML part that precedes this expression + const rHtml = htmlParts[startIdx + k]; + if (rHtml) { + destination.write(markHTMLString(rHtml)); + } - if (isPromise(result)) { - return result.then(iterate); - } - } + const flushResult = flushers[k++].flush(); + if (isPromise(flushResult)) { + return flushResult.then(iterate); + } + } + // Write the final trailing HTML part + const lastHtml = htmlParts[htmlParts.length - 1]; + if (lastHtml) { + destination.write(markHTMLString(lastHtml)); + } + }; + return iterate(); + }); } - }; - - return iterate(); + } } } diff --git a/packages/astro/src/runtime/server/render/head.ts b/packages/astro/src/runtime/server/render/head.ts index 48ce553dea98..333cce4dfe9e 100644 --- a/packages/astro/src/runtime/server/render/head.ts +++ b/packages/astro/src/runtime/server/render/head.ts @@ -5,14 +5,30 @@ import type { MaybeRenderHeadInstruction, RenderHeadInstruction } from './instru import { createRenderInstruction } from './instruction.js'; import { renderElement } from './util.js'; -// Filter out duplicate elements in our set -const uniqueElements = (item: any, index: number, all: any[]) => { - const props = JSON.stringify(item.props); - const children = item.children; - return ( - index === all.findIndex((i) => JSON.stringify(i.props) === props && i.children === children) - ); -}; +// Deterministic stringification of props that is key-order independent, +// so elements with the same props in different insertion order are still deduped. +function stablePropsKey(props: Record): string { + const keys = Object.keys(props).sort(); + let result = '{'; + for (let i = 0; i < keys.length; i++) { + if (i > 0) result += ','; + result += JSON.stringify(keys[i]) + ':' + JSON.stringify(props[keys[i]]); + } + result += '}'; + return result; +} + +// Filter out duplicate elements using a Set for O(N) instead of O(N²) +function deduplicateElements(elements: any[]): any[] { + if (elements.length <= 1) return elements; + const seen = new Set(); + return elements.filter((item) => { + const key = stablePropsKey(item.props) + item.children; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} export function renderAllHeadContent(result: SSRResult) { result._metadata.hasRenderedHead = true; @@ -30,27 +46,21 @@ export function renderAllHeadContent(result: SSRResult) { false, ); } - const styles = Array.from(result.styles) - .filter(uniqueElements) - .map((style) => - style.props.rel === 'stylesheet' - ? renderElement('link', style) - : renderElement('style', style), - ); + const styles = deduplicateElements(Array.from(result.styles)).map((style) => + style.props.rel === 'stylesheet' ? renderElement('link', style) : renderElement('style', style), + ); // Clear result.styles so that any new styles added will be inlined. result.styles.clear(); - const scripts = Array.from(result.scripts) - .filter(uniqueElements) - .map((script) => { - if (result.userAssetsBase) { - script.props.src = - (result.base === '/' ? '' : result.base) + result.userAssetsBase + script.props.src; - } - return renderElement('script', script, false); - }); - const links = Array.from(result.links) - .filter(uniqueElements) - .map((link) => renderElement('link', link, false)); + const scripts = deduplicateElements(Array.from(result.scripts)).map((script) => { + if (result.userAssetsBase) { + script.props.src = + (result.base === '/' ? '' : result.base) + result.userAssetsBase + script.props.src; + } + return renderElement('script', script, false); + }); + const links = deduplicateElements(Array.from(result.links)).map((link) => + renderElement('link', link, false), + ); // Order styles -> links -> scripts similar to src/content/runtime.ts // The order is usually fine as the ordering between these groups are mutually exclusive,