Skip to content

Commit

Permalink
fix(astro): Improve ssr performance (astro#11454) (#13195)
Browse files Browse the repository at this point in the history
* Add alternate rendering paths to avoid use of Promise

* Add run commands

* Remove promise from synchronous components

* Create makefile and update loadtest

* Rename functions, fix implementation of renderArray

* More performance updates

* Minor code cleanup

* incremental

* Add initial rendering tests

* WIP - bad tests

* Fix tests

* Make the tests good, even

* Add more tests

* Finish tests

* Add test to ensure rendering order

* Finalize pr

* Remove code not intended for PR

* Add changeset

* Revert change to minimal example

* Fix linting and formatting errors

* Address code review comments

* Fix mishandling of uncaught synchronous renders

* Update .changeset/shaggy-deers-destroy.md

---------

Co-authored-by: Matt Kane <[email protected]>
Co-authored-by: Emanuele Stoppa <[email protected]>
  • Loading branch information
3 people authored Feb 12, 2025
1 parent 150c001 commit 3b66955
Show file tree
Hide file tree
Showing 8 changed files with 561 additions and 93 deletions.
5 changes: 5 additions & 0 deletions .changeset/shaggy-deers-destroy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': minor
---

Improves SSR performance for synchronous components by avoiding the use of Promises. With this change, SSR rendering of on-demand pages can be up to 4x faster.
152 changes: 117 additions & 35 deletions packages/astro/src/runtime/server/render/any.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,52 +3,134 @@ import { isPromise } from '../util.js';
import { isAstroComponentInstance, isRenderTemplateResult } from './astro/index.js';
import { type RenderDestination, isRenderInstance } from './common.js';
import { SlotString } from './slot.js';
import { renderToBufferDestination } from './util.js';
import { createBufferedRenderer } from './util.js';

export async function renderChild(destination: RenderDestination, child: any) {
export function renderChild(destination: RenderDestination, child: any): void | Promise<void> {
if (isPromise(child)) {
child = await child;
return child.then((x) => renderChild(destination, x));
}

if (child instanceof SlotString) {
destination.write(child);
} else if (isHTMLString(child)) {
return;
}

if (isHTMLString(child)) {
destination.write(child);
} else if (Array.isArray(child)) {
// Render all children eagerly and in parallel
const childRenders = child.map((c) => {
return renderToBufferDestination((bufferDestination) => {
return renderChild(bufferDestination, c);
});
});
for (const childRender of childRenders) {
if (!childRender) continue;
await childRender.renderToFinalDestination(destination);
}
} else if (typeof child === 'function') {
return;
}

if (Array.isArray(child)) {
return renderArray(destination, child);
}

if (typeof child === 'function') {
// Special: If a child is a function, call it automatically.
// This lets you do {() => ...} without the extra boilerplate
// of wrapping it in a function and calling it.
await renderChild(destination, child());
} else if (typeof child === 'string') {
destination.write(markHTMLString(escapeHTML(child)));
} else if (!child && child !== 0) {
return renderChild(destination, child());
}

if (!child && child !== 0) {
// do nothing, safe to ignore falsey values.
} else if (isRenderInstance(child)) {
await child.render(destination);
} else if (isRenderTemplateResult(child)) {
await child.render(destination);
} else if (isAstroComponentInstance(child)) {
await child.render(destination);
} else if (ArrayBuffer.isView(child)) {
return;
}

if (typeof child === 'string') {
destination.write(markHTMLString(escapeHTML(child)));
return;
}

if (isRenderInstance(child)) {
return child.render(destination);
}

if (isRenderTemplateResult(child)) {
return child.render(destination);
}

if (isAstroComponentInstance(child)) {
return child.render(destination);
}

if (ArrayBuffer.isView(child)) {
destination.write(child);
} else if (
typeof child === 'object' &&
(Symbol.asyncIterator in child || Symbol.iterator in child)
) {
for await (const value of child) {
await renderChild(destination, value);
return;
}

if (typeof child === 'object' && (Symbol.asyncIterator in child || Symbol.iterator in child)) {
if (Symbol.asyncIterator in child) {
return renderAsyncIterable(destination, child);
}
} else {
destination.write(child);

return renderIterable(destination, child);
}

destination.write(child);
}

function renderArray(destination: RenderDestination, children: any[]): void | Promise<void> {
// 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<void> => {
for (;;) {
const { value: flusher, done } = iterator.next();

if (done) {
break;
}

const result = flusher.flush();

if (isPromise(result)) {
return result.then(iterate);
}
}
};

return iterate();
}

function renderIterable(
destination: RenderDestination,
children: Iterable<any>,
): void | Promise<void> {
// although arrays and iterables may be similar, an iterable
// may be unbounded, so rendering all children eagerly may not
// be possible.
const iterator = (children[Symbol.iterator] as () => Iterator<any>)();

const iterate = (): void | Promise<void> => {
for (;;) {
const { value, done } = iterator.next();

if (done) {
break;
}

const result = renderChild(destination, value);

if (isPromise(result)) {
return result.then(iterate);
}
}
};

return iterate();
}

async function renderAsyncIterable(
destination: RenderDestination,
children: AsyncIterable<any>,
): Promise<void> {
for await (const value of children) {
await renderChild(destination, value);
}
}
27 changes: 20 additions & 7 deletions packages/astro/src/runtime/server/render/astro/instance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ComponentSlots } from '../slot.js';
import type { AstroComponentFactory } from './factory.js';
import type { AstroComponentFactory, AstroFactoryReturnValue } from './factory.js';

import type { SSRResult } from '../../../../types/public/internal.js';
import { isPromise } from '../../util.js';
Expand Down Expand Up @@ -46,9 +46,13 @@ export class AstroComponentInstance {
}
}

async init(result: SSRResult) {
if (this.returnValue !== undefined) return this.returnValue;
init(result: SSRResult) {
if (this.returnValue !== undefined) {
return this.returnValue;
}

this.returnValue = this.factory(result, this.props, this.slotValues);

// Save the resolved value after promise is resolved for optimization
if (isPromise(this.returnValue)) {
this.returnValue
Expand All @@ -62,12 +66,21 @@ export class AstroComponentInstance {
return this.returnValue;
}

async render(destination: RenderDestination) {
const returnValue = await this.init(this.result);
render(destination: RenderDestination): void | Promise<void> {
const returnValue = this.init(this.result);

if (isPromise(returnValue)) {
return returnValue.then((x) => this.renderImpl(destination, x));
}

return this.renderImpl(destination, returnValue);
}

private renderImpl(destination: RenderDestination, returnValue: AstroFactoryReturnValue) {
if (isHeadAndContent(returnValue)) {
await returnValue.content.render(destination);
return returnValue.content.render(destination);
} else {
await renderChild(destination, returnValue);
return renderChild(destination, returnValue);
}
}
}
Expand Down
41 changes: 30 additions & 11 deletions packages/astro/src/runtime/server/render/astro/render-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { markHTMLString } from '../../escape.js';
import { isPromise } from '../../util.js';
import { renderChild } from '../any.js';
import type { RenderDestination } from '../common.js';
import { renderToBufferDestination } from '../util.js';
import { createBufferedRenderer } from '../util.js';

const renderTemplateResultSym = Symbol.for('astro.renderTemplateResult');

Expand Down Expand Up @@ -32,26 +32,45 @@ export class RenderTemplateResult {
});
}

async render(destination: RenderDestination) {
render(destination: RenderDestination): void | Promise<void> {
// Render all expressions eagerly and in parallel
const expRenders = this.expressions.map((exp) => {
return renderToBufferDestination((bufferDestination) => {
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);
}
});
});

for (let i = 0; i < this.htmlParts.length; i++) {
const html = this.htmlParts[i];
const expRender = expRenders[i];
let i = 0;

destination.write(markHTMLString(html));
if (expRender) {
await expRender.renderToFinalDestination(destination);
const iterate = (): void | Promise<void> => {
while (i < this.htmlParts.length) {
const html = this.htmlParts[i];
const flusher = flushers[i];

// increment here due to potential return in
// Promise scenario
i++;

if (html) {
// only write non-empty strings

destination.write(markHTMLString(html));
}

if (flusher) {
const result = flusher.flush();

if (isPromise(result)) {
return result.then(iterate);
}
}
}
}
};

return iterate();
}
}

Expand Down
23 changes: 15 additions & 8 deletions packages/astro/src/runtime/server/render/astro/render.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AstroError, AstroErrorData } from '../../../../core/errors/index.js';
import type { RouteData, SSRResult } from '../../../../types/public/internal.js';
import { isPromise } from '../../util.js';
import { type RenderDestination, chunkToByteArray, chunkToString, encoder } from '../common.js';
import { promiseWithResolvers } from '../util.js';
import type { AstroComponentFactory } from './factory.js';
Expand Down Expand Up @@ -317,16 +318,13 @@ export async function renderToAsyncIterable(
},
};

const renderPromise = templateResult.render(destination);
renderPromise
.then(() => {
// Once rendering is complete, calling resolve() allows the iterator to finish running.
renderingComplete = true;
next?.resolve();
})
const renderResult = toPromise(() => templateResult.render(destination));

renderResult
.catch((err) => {
// If an error occurs, save it in the scope so that we throw it when next() is called.
error = err;
})
.finally(() => {
renderingComplete = true;
next?.resolve();
});
Expand All @@ -339,3 +337,12 @@ export async function renderToAsyncIterable(
},
};
}

function toPromise<T>(fn: () => T | Promise<T>): Promise<T> {
try {
const result = fn();
return isPromise(result) ? result : Promise.resolve(result);
} catch (err) {
return Promise.reject(err);
}
}
19 changes: 11 additions & 8 deletions packages/astro/src/runtime/server/render/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,44 +446,47 @@ function renderAstroComponent(
}

const instance = createAstroComponentInstance(result, displayName, Component, props, slots);

return {
async render(destination) {
render(destination: RenderDestination): Promise<void> | void {
// NOTE: This render call can't be pre-invoked outside of this function as it'll also initialize the slots
// recursively, which causes each Astro components in the tree to be called bottom-up, and is incorrect.
// The slots are initialized eagerly for head propagation.
await instance.render(destination);
return instance.render(destination);
},
};
}

export async function renderComponent(
export function renderComponent(
result: SSRResult,
displayName: string,
Component: unknown,
props: Record<string | number, any>,
slots: ComponentSlots = {},
): Promise<RenderInstance> {
): RenderInstance | Promise<RenderInstance> {
if (isPromise(Component)) {
Component = await Component.catch(handleCancellation);
return Component.catch(handleCancellation).then((x) => {
return renderComponent(result, displayName, x, props, slots);
});
}

if (isFragmentComponent(Component)) {
return await renderFragmentComponent(result, slots).catch(handleCancellation);
return renderFragmentComponent(result, slots).catch(handleCancellation);
}

// Ensure directives (`class:list`) are processed
props = normalizeProps(props);

// .html components
if (isHTMLComponent(Component)) {
return await renderHTMLComponent(result, Component, props, slots).catch(handleCancellation);
return renderHTMLComponent(result, Component, props, slots).catch(handleCancellation);
}

if (isAstroComponentFactory(Component)) {
return renderAstroComponent(result, displayName, Component, props, slots);
}

return await renderFrameworkComponent(result, displayName, Component, props, slots).catch(
return renderFrameworkComponent(result, displayName, Component, props, slots).catch(
handleCancellation,
);

Expand Down
Loading

0 comments on commit 3b66955

Please sign in to comment.