Skip to content
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
d6ac9cb
perf(runtime): reduce server-side render allocations
Princesseuh Aug 29, 2026
a582d91
perf(runtime): return the component instance directly from renderAstr…
Princesseuh Aug 29, 2026
1f00ef4
perf(runtime): expand sync component and slot subtrees in the streami…
Princesseuh Aug 29, 2026
e9a12e7
perf(runtime): escape HTML with a scan loop instead of a regex callback
Princesseuh Aug 29, 2026
caba2ac
perf(runtime): avoid intermediate arrays when spreading attributes an…
Princesseuh Aug 29, 2026
b967c4b
perf(runtime): cache attribute-name classification in addAttribute
Princesseuh Aug 29, 2026
16c740b
perf(runtime): avoid the entries array in spreadAttributes
Princesseuh Aug 29, 2026
c80520a
perf(runtime): give the streaming engine stable helper identities
Princesseuh Aug 30, 2026
2af0b37
perf(runtime): share the Astro.slots accessor across component renders
Princesseuh Aug 30, 2026
d6dd1bb
perf(runtime): reuse stateless render instructions
Princesseuh Aug 30, 2026
66fe4cd
perf(runtime): skip the slot values copy for slotless components
Princesseuh Aug 30, 2026
ac98cce
perf(runtime): match Astro component factories first in renderComponent
Princesseuh Aug 30, 2026
754af93
perf(runtime): write batched static output as primitive strings
Princesseuh Aug 30, 2026
6384af0
perf(runtime): dispatch render instructions before render instances
Princesseuh Aug 30, 2026
f6011b1
perf(runtime): wrap async template expressions only when rendered
Princesseuh Aug 30, 2026
b4c0c30
perf(runtime): merge all-string stream buffers with a single encode
Princesseuh Aug 30, 2026
b5d2c84
fix(runtime): correct types for shared slots accessor and lazy expres…
Princesseuh Aug 30, 2026
7dcb475
perf(runtime): spread JSX element attributes without a rest copy
Princesseuh Aug 30, 2026
7f61a85
perf(runtime): drop the in-operator probe from isRenderInstance
Princesseuh Aug 30, 2026
2c98f41
perf(runtime): extract directives without materializing prop entries
Princesseuh Aug 30, 2026
6d0679b
perf(runtime): strip static slots only from renderer output
Princesseuh Aug 30, 2026
d758231
chore: add changeset for render improvements
Princesseuh Aug 30, 2026
c84b821
chore(runtime): fix prefer-for-of and prefer-includes lint errors
Princesseuh Aug 30, 2026
a1ffad0
fix(runtime): match buffered-path semantics when expanding sync subtrees
Princesseuh Sep 2, 2026
f4bc28a
fix(runtime): preserve optimized rendering semantics
Princesseuh Sep 4, 2026
60a1a0d
refactor(runtime): type shared slot state
Princesseuh Sep 4, 2026
30c5de8
refactor(runtime): prune redundant comments
Princesseuh Sep 4, 2026
8ef51e6
refactor(runtime): clarify streaming state names
Princesseuh Sep 4, 2026
081366d
docs(runtime): restore rendering comments
Princesseuh Sep 4, 2026
af61a47
perf(runtime): remove HTML escape fallback
Princesseuh Sep 5, 2026
1c1a6e0
refactor(runtime): narrow render type assertions
Princesseuh Sep 7, 2026
4e3b153
fix(runtime): coerce renderer attributes before escaping
Princesseuh Sep 7, 2026
e518435
refactor(runtime): clarify rendering constants
Princesseuh Sep 7, 2026
dc81a5a
chore: move to ssrresult
Princesseuh Sep 8, 2026
92395ce
refactor(runtime): refine internal rendering state
Princesseuh Sep 9, 2026
8514d2b
Update fresh-otters-run.md
Princesseuh Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-otters-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Improves server-side rendering performance
Comment thread
Princesseuh marked this conversation as resolved.
Outdated
39 changes: 25 additions & 14 deletions packages/astro/src/core/fetch/fetch-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ import { getRouteCache } from '../render/route-cache.js';
import { getRouteTable, matchAllRoutes, matchRoute } from '../routing/route-table.js';
import { getServerIslands } from '../server-islands/mappings.js';

const slotValuesSymbol = Symbol('astro.slotValues');

type AstroSlotValues = {
[slotValuesSymbol]: Record<string, any> | null;
};

type AstroComponentPartial = Omit<AstroGlobal, 'self' | 'slots'> & Partial<AstroSlotValues>;

/**
* Per-render facade inputs passed by `BaseApp.render`'s fast path to the
* internal `FetchState` constructor and stored as plain state fields.
Expand Down Expand Up @@ -539,21 +547,11 @@ export class FetchState implements AstroFetchState {
}
this.#astroPagePartial ??= this.createAstroPagePartial(result, apiContext);
astroPagePartial = this.#astroPagePartial;
const astroComponentPartial = { props, self: null };
const Astro: Omit<AstroGlobal, 'self' | 'slots'> = Object.assign(
Object.create(astroPagePartial),
astroComponentPartial,
);

let _slots: AstroGlobal['slots'];
Object.defineProperty(Astro, 'slots', {
get: () => {
if (!_slots) {
_slots = new Slots(result, slotValues, this.logger) as unknown as AstroGlobal['slots'];
}
return _slots;
},
const Astro: AstroComponentPartial = Object.assign(Object.create(astroPagePartial), {
props,
self: null,
});
Astro[slotValuesSymbol] = slotValues;
Comment thread
Princesseuh marked this conversation as resolved.
Outdated

return Astro as AstroGlobal;
}
Expand Down Expand Up @@ -588,6 +586,19 @@ export class FetchState implements AstroFetchState {
routePattern: this.routeData!.route,
isPrerendered: this.routeData!.prerender,
cookies,
get slots(): Slots {
Comment thread
Princesseuh marked this conversation as resolved.
const slotsByAstro = (result._metadata.slotsByAstro ??= new WeakMap());
let slots = slotsByAstro.get(this);
if (slots === undefined) {
slots = new Slots(
result,
(this as Partial<AstroSlotValues>)[slotValuesSymbol] ?? null,
logger,
);
slotsByAstro.set(this, slots);
}
return slots;
},
get clientAddress() {
return state.getClientAddress();
},
Expand Down
41 changes: 34 additions & 7 deletions packages/astro/src/runtime/server/escape.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,35 @@
import { escape } from 'html-escaper';

@Princesseuh Princesseuh Sep 7, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know, I know, this is scary, however the code here is incredibly hot (🔥 🔥), and as such it needs a very fast HTML escape solution. This code is between 2 to 3 times as fast depending on the workload and is really similar (albeit slightly tuned to Astro's use case) to rising stars libraries like https://github.com/SukkaW/fast-escape-html

In a further PR, I'd like to perhaps remove that dep completely and have our own escape.ts somewhere that we re-use.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not worried about "being scary", but maybe this change should be part of its own PR:

  • if we break something, we can revert only this change
  • this PR doesn't have a test suite to cover the dep we're removing (we assume it's tested)

import { streamAsyncIterator } from './util.js';

// Leverage the battle-tested `html-escaper` npm package.
export const escapeHTML = escape;
const ESCAPABLE = /[&<>'"]/g;

function entityFor(code: number): string {
switch (code) {
case 38:
return '&amp;';
case 60:
return '&lt;';
case 62:
return '&gt;';
case 39:
return '&#39;';
default:
return '&quot;';
}
}

export function escapeHTML(value: string): string {
ESCAPABLE.lastIndex = 0;
if (!ESCAPABLE.test(value)) return value;
let output = '';
let last = 0;
do {
const index = ESCAPABLE.lastIndex - 1;
if (last !== index) output += value.slice(last, index);
output += entityFor(value.charCodeAt(index));
last = index + 1;
} while (ESCAPABLE.test(value));
return last === value.length ? output : output + value.slice(last);
}

/**
* Serializes a value to a JSON string that is safe to embed inside a `<script>` tag.
Expand Down Expand Up @@ -36,9 +63,9 @@ const htmlStringSymbol = Symbol.for('astro:html-string');
* A "blessed" extension of String that tells Astro that the string
* has already been escaped. This helps prevent double-escaping of HTML.
*/
export class HTMLString extends String {
[htmlStringSymbol] = true;
}
export class HTMLString extends String {}

Object.defineProperty(HTMLString.prototype, htmlStringSymbol, { value: true });

type BlessedType = string | HTMLBytes;

Expand All @@ -64,7 +91,7 @@ export const markHTMLString = (value: any) => {
};

export function isHTMLString(value: any): value is HTMLString {
return !!value?.[htmlStringSymbol];
return typeof value === 'object' && value !== null && value[htmlStringSymbol] === true;
}

function markHTMLBytes(bytes: Uint8Array) {
Expand Down
9 changes: 5 additions & 4 deletions packages/astro/src/runtime/server/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface ExtractedProps {
propsWithoutTransitionAttributes: Props;
}

const transitionDirectivesToCopyOnIsland = Object.freeze([
const transitionDirectivesToCopyOnIsland = new Set([
'data-astro-transition-scope',
'data-astro-transition-persist',
'data-astro-transition-persist-props',
Expand All @@ -42,7 +42,8 @@ export function extractDirectives(
props: {},
propsWithoutTransitionAttributes: {},
};
for (const [key, value] of Object.entries(inputProps)) {
for (const key of Object.keys(inputProps)) {
const value = inputProps[key];
if (key.startsWith('server:')) {
if (key === 'server:root') {
extracted.isPage = true;
Expand Down Expand Up @@ -101,7 +102,7 @@ export function extractDirectives(
}
} else {
extracted.props[key] = value;
if (!transitionDirectivesToCopyOnIsland.includes(key)) {
if (!transitionDirectivesToCopyOnIsland.has(key)) {
extracted.propsWithoutTransitionAttributes[key] = value;
}
}
Expand Down Expand Up @@ -148,7 +149,7 @@ export async function generateHydrateScript(
// Attach renderer-provided attributes
if (attrs) {
for (const [key, value] of Object.entries(attrs)) {
island.props[key] = escapeHTML(value);
island.props[key] = escapeHTML(String(value));
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/runtime/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ export function spreadAttributes(
values.class = scopedClassName;
}
}
for (const [key, value] of Object.entries(values)) {
output += addAttribute(value, key, true, _name);
for (const key of Object.keys(values)) {
output += addAttribute(values[key], key, true, _name);
}
return markHTMLString(output);
}
Expand Down
24 changes: 11 additions & 13 deletions packages/astro/src/runtime/server/render/any.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { escapeHTML, isHTMLString, markHTMLString } from '../escape.js';
import { isPromise } from '../util.js';
import { isAstroComponentInstance, isRenderTemplateResult } from './astro/index.js';
import { isRenderInstance, type RenderDestination } from './common.js';
import { isRenderInstruction } from './instruction.js';
import { SlotString } from './slot.js';
import { createBufferedRenderer } from './util.js';

Expand All @@ -13,10 +13,20 @@ export function renderChild(destination: RenderDestination, child: any): void |
return;
}

// Must precede `isRenderInstance`: a `renderer-hydration-script` instruction has a `render` property.
if (isRenderInstruction(child)) {
destination.write(child);
return;
}

if (isPromise(child)) {
return child.then((x) => renderChild(destination, x));
}

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

if (child instanceof SlotString) {
destination.write(child);
return;
Expand All @@ -43,18 +53,6 @@ export function renderChild(destination: RenderDestination, child: any): void |
return renderChild(destination, child());
}

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);
return;
Expand Down
17 changes: 13 additions & 4 deletions packages/astro/src/runtime/server/render/astro/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { isHeadAndContent } from './head-and-content.js';
type ComponentProps = Record<string | number, any>;

const astroComponentInstanceSym = Symbol.for('astro.componentInstance');
const CLIENT_DIRECTIVE_PREFIX = 'client:';

export class AstroComponentInstance {
[astroComponentInstanceSym] = true;
Expand All @@ -28,8 +29,11 @@ export class AstroComponentInstance {
this.result = result;
this.props = props;
this.factory = factory;
this.slotValues = {};
this.slotValues = slots;
for (const name in slots) {
if (this.slotValues === slots) {
this.slotValues = {};
}
// prerender the slots eagerly to make collection entries propagate styles and scripts
let didRender = false;
let value = slots[name](result);
Expand Down Expand Up @@ -74,6 +78,9 @@ export class AstroComponentInstance {
return this.returnValue;
}

// 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.
render(destination: RenderDestination): void | Promise<void> {
const returnValue = this.init(this.result);

Expand All @@ -100,9 +107,11 @@ function validateComponentProps(
displayName: string,
) {
if (props != null) {
const directives = [...clientDirectives.keys()].map((directive) => `client:${directive}`);
for (const prop of Object.keys(props)) {
if (directives.includes(prop)) {
for (const prop in props) {
if (
prop.startsWith(CLIENT_DIRECTIVE_PREFIX) &&
clientDirectives.has(prop.slice(CLIENT_DIRECTIVE_PREFIX.length))
) {
console.warn(
`You are attempting to render <${displayName} ${prop} />, but ${displayName} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.`,
);
Expand Down
58 changes: 40 additions & 18 deletions packages/astro/src/runtime/server/render/astro/render-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,49 @@ import { createBufferedRenderer } from '../util.js';

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

const markedHtmlParts = new WeakMap<TemplateStringsArray, string[]>();

function markHtmlParts(htmlParts: TemplateStringsArray): string[] {
let marked = markedHtmlParts.get(htmlParts);
if (marked === undefined) {
marked = new Array(htmlParts.length);
for (let i = 0; i < htmlParts.length; i++) {
marked[i] = htmlParts[i] ? markHTMLString(htmlParts[i]) : htmlParts[i];
}
markedHtmlParts.set(htmlParts, marked);
}
return marked;
}

// The return value when rendering a component.
// This is the result of calling render(), should this be named to RenderResult or...?
export class RenderTemplateResult {
public [renderTemplateResultSym] = true;
readonly htmlParts: TemplateStringsArray;
public expressions: any[];
private error: Error | undefined;
private wrapped: Set<number> | undefined;
constructor(htmlParts: TemplateStringsArray, expressions: unknown[]) {
this.htmlParts = htmlParts;
this.error = undefined;
this.expressions = expressions.map((expression) => {
// Wrap Promise expressions so we can catch errors
// There can only be 1 error that we rethrow from an Astro component,
// so this keeps track of whether or not we have already done so.
if (isPromise(expression)) {
return Promise.resolve(expression).catch((err) => {
if (!this.error) {
this.error = err;
throw err;
}
});
this.expressions = expressions;
}

catchExpressionError(index: number, expression: unknown): Promise<unknown> {
// Wrap Promise expressions so we can catch errors
// There can only be 1 error that we rethrow from an Astro component,
// so this keeps track of whether or not we have already done so.
// Re-wrapping hits the `this.error` guard and resolves, swallowing the rejection.
if (this.wrapped?.has(index)) return expression as Promise<unknown>;
const wrapped = Promise.resolve(expression).catch((err) => {
if (!this.error) {
this.error = err;
throw err;
}
return expression;
});
(this.wrapped ??= new Set()).add(index);
this.expressions[index] = wrapped;
return wrapped;
}

render(destination: RenderDestination): void | Promise<void> {
Expand All @@ -41,20 +60,22 @@ export class RenderTemplateResult {
//
// Template structure: html[0] exp[0] html[1] exp[1] ... html[N]
// (htmlParts.length === expressions.length + 1)
const { htmlParts, expressions } = this;
const { expressions } = this;
const htmlParts = markHtmlParts(this.htmlParts);

for (let i = 0; i < htmlParts.length; i++) {
const html = htmlParts[i];
if (html) {
destination.write(markHTMLString(html));
destination.write(html);
}

// expressions[i] doesn't exist for the last htmlPart
if (i >= expressions.length) break;

const exp = expressions[i];
let exp = expressions[i];
// Skip render if falsy, except the number 0
if (!(exp || exp === 0)) continue;
if (isPromise(exp)) exp = this.catchExpressionError(i, exp);

const result = renderChild(destination, exp);

Expand All @@ -65,7 +86,8 @@ export class RenderTemplateResult {
const remaining = expressions.length - startIdx;
const flushers = new Array(remaining);
for (let j = 0; j < remaining; j++) {
const rExp = expressions[startIdx + j];
let rExp = expressions[startIdx + j];
if (isPromise(rExp)) rExp = this.catchExpressionError(startIdx + j, rExp);
flushers[j] = createBufferedRenderer(destination, (bufferDestination) => {
if (rExp || rExp === 0) {
return renderChild(bufferDestination, rExp);
Expand All @@ -80,7 +102,7 @@ export class RenderTemplateResult {
// Write the HTML part that precedes this expression
const rHtml = htmlParts[startIdx + k];
if (rHtml) {
destination.write(markHTMLString(rHtml));
destination.write(rHtml);
}

const flushResult = flushers[k++].flush();
Expand All @@ -91,7 +113,7 @@ export class RenderTemplateResult {
// Write the final trailing HTML part
const lastHtml = htmlParts[htmlParts.length - 1];
if (lastHtml) {
destination.write(markHTMLString(lastHtml));
destination.write(lastHtml);
}
};
return iterate();
Expand Down
Loading
Loading