Refactor Astro rendering to write results directly (#7782)

This commit is contained in:
Bjorn Lu 2023-07-25 23:44:25 +08:00 committed by GitHub
parent ec40c8ccbe
commit 0f677c009d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 322 additions and 461 deletions

View file

@ -0,0 +1,5 @@
---
"astro": patch
---
Refactor Astro rendering to write results directly. This improves the rendering performance for all Astro files.

View file

@ -7,16 +7,12 @@ import type {
SSRLoadedRenderer, SSRLoadedRenderer,
SSRResult, SSRResult,
} from '../../@types/astro'; } from '../../@types/astro';
import { isHTMLString } from '../../runtime/server/escape.js'; import { renderSlotToString, type ComponentSlots } from '../../runtime/server/index.js';
import {
renderSlotToString,
stringifyChunk,
type ComponentSlots,
} from '../../runtime/server/index.js';
import { renderJSX } from '../../runtime/server/jsx.js'; import { renderJSX } from '../../runtime/server/jsx.js';
import { AstroCookies } from '../cookies/index.js'; import { AstroCookies } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js';
import { warn, type LogOptions } from '../logger/core.js'; import { warn, type LogOptions } from '../logger/core.js';
import { chunkToString } from '../../runtime/server/render/index.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress'); const clientAddressSymbol = Symbol.for('astro.clientAddress');
const responseSentSymbol = Symbol.for('astro.responseSent'); const responseSentSymbol = Symbol.for('astro.responseSent');
@ -112,7 +108,7 @@ class Slots {
const expression = getFunctionExpression(component); const expression = getFunctionExpression(component);
if (expression) { if (expression) {
const slot = async () => const slot = async () =>
isHTMLString(await expression) ? expression : expression(...args); typeof expression === 'function' ? expression(...args) : expression;
return await renderSlotToString(result, slot).then((res) => { return await renderSlotToString(result, slot).then((res) => {
return res != null ? String(res) : res; return res != null ? String(res) : res;
}); });
@ -126,7 +122,7 @@ class Slots {
} }
const content = await renderSlotToString(result, this.#slots[name]); const content = await renderSlotToString(result, this.#slots[name]);
const outHTML = stringifyChunk(result, content); const outHTML = chunkToString(result, content);
return outHTML; return outHTML;
} }

View file

@ -17,9 +17,7 @@ export {
Fragment, Fragment,
maybeRenderHead, maybeRenderHead,
renderTemplate as render, renderTemplate as render,
renderAstroTemplateResult as renderAstroComponent,
renderComponent, renderComponent,
renderComponentToIterable,
Renderer as Renderer, Renderer as Renderer,
renderHead, renderHead,
renderHTMLElement, renderHTMLElement,
@ -30,7 +28,6 @@ export {
renderTemplate, renderTemplate,
renderToString, renderToString,
renderUniqueStylesheet, renderUniqueStylesheet,
stringifyChunk,
voidElementNames, voidElementNames,
} from './render/index.js'; } from './render/index.js';
export type { export type {

View file

@ -5,13 +5,11 @@ import {
HTMLString, HTMLString,
escapeHTML, escapeHTML,
markHTMLString, markHTMLString,
renderComponentToIterable,
renderToString, renderToString,
spreadAttributes, spreadAttributes,
voidElementNames, voidElementNames,
} from './index.js'; } from './index.js';
import { HTMLParts } from './render/common.js'; import { renderComponentToString } from './render/component.js';
import type { ComponentIterable } from './render/component';
const ClientOnlyPlaceholder = 'astro-client-only'; const ClientOnlyPlaceholder = 'astro-client-only';
@ -177,9 +175,9 @@ Did you forget to import the component or is it possible there is a typo?`);
await Promise.all(slotPromises); await Promise.all(slotPromises);
props[Skip.symbol] = skip; props[Skip.symbol] = skip;
let output: ComponentIterable; let output: string;
if (vnode.type === ClientOnlyPlaceholder && vnode.props['client:only']) { if (vnode.type === ClientOnlyPlaceholder && vnode.props['client:only']) {
output = await renderComponentToIterable( output = await renderComponentToString(
result, result,
vnode.props['client:display-name'] ?? '', vnode.props['client:display-name'] ?? '',
null, null,
@ -187,7 +185,7 @@ Did you forget to import the component or is it possible there is a typo?`);
slots slots
); );
} else { } else {
output = await renderComponentToIterable( output = await renderComponentToString(
result, result,
typeof vnode.type === 'function' ? vnode.type.name : vnode.type, typeof vnode.type === 'function' ? vnode.type.name : vnode.type,
vnode.type, vnode.type,
@ -195,15 +193,7 @@ Did you forget to import the component or is it possible there is a typo?`);
slots slots
); );
} }
if (typeof output !== 'string' && Symbol.asyncIterator in output) { return markHTMLString(output);
let parts = new HTMLParts();
for await (const chunk of output) {
parts.append(chunk, result);
}
return markHTMLString(parts.toString());
} else {
return markHTMLString(output);
}
} }
} }
// numbers, plain objects, etc // numbers, plain objects, etc

View file

@ -1,47 +1,43 @@
import { escapeHTML, isHTMLString, markHTMLString } from '../escape.js'; import { escapeHTML, isHTMLString, markHTMLString } from '../escape.js';
import { import { isAstroComponentInstance, isRenderTemplateResult } from './astro/index.js';
isAstroComponentInstance, import { isRenderInstance, type RenderDestination } from './common.js';
isRenderTemplateResult,
renderAstroTemplateResult,
} from './astro/index.js';
import { SlotString } from './slot.js'; import { SlotString } from './slot.js';
import { bufferIterators } from './util.js';
export async function* renderChild(child: any): AsyncIterable<any> { export async function renderChild(destination: RenderDestination, child: any) {
child = await child; child = await child;
if (child instanceof SlotString) { if (child instanceof SlotString) {
if (child.instructions) { destination.write(child);
yield* child.instructions;
}
yield child;
} else if (isHTMLString(child)) { } else if (isHTMLString(child)) {
yield child; destination.write(child);
} else if (Array.isArray(child)) { } else if (Array.isArray(child)) {
const bufferedIterators = bufferIterators(child.map((c) => renderChild(c))); for (const c of child) {
for (const value of bufferedIterators) { await renderChild(destination, c);
yield markHTMLString(await value);
} }
} else if (typeof child === 'function') { } else if (typeof child === 'function') {
// Special: If a child is a function, call it automatically. // Special: If a child is a function, call it automatically.
// This lets you do {() => ...} without the extra boilerplate // This lets you do {() => ...} without the extra boilerplate
// of wrapping it in a function and calling it. // of wrapping it in a function and calling it.
yield* renderChild(child()); await renderChild(destination, child());
} else if (typeof child === 'string') { } else if (typeof child === 'string') {
yield markHTMLString(escapeHTML(child)); destination.write(markHTMLString(escapeHTML(child)));
} else if (!child && child !== 0) { } else if (!child && child !== 0) {
// do nothing, safe to ignore falsey values. // do nothing, safe to ignore falsey values.
} else if (isRenderInstance(child)) {
await child.render(destination);
} else if (isRenderTemplateResult(child)) { } else if (isRenderTemplateResult(child)) {
yield* renderAstroTemplateResult(child); await child.render(destination);
} else if (isAstroComponentInstance(child)) { } else if (isAstroComponentInstance(child)) {
yield* child.render(); await child.render(destination);
} else if (ArrayBuffer.isView(child)) { } else if (ArrayBuffer.isView(child)) {
yield child; destination.write(child);
} else if ( } else if (
typeof child === 'object' && typeof child === 'object' &&
(Symbol.asyncIterator in child || Symbol.iterator in child) (Symbol.asyncIterator in child || Symbol.iterator in child)
) { ) {
yield* child; for await (const value of child) {
await renderChild(destination, value);
}
} else { } else {
yield child; destination.write(child);
} }
} }

View file

@ -3,9 +3,5 @@ export { isAstroComponentFactory } from './factory.js';
export { createHeadAndContent, isHeadAndContent } from './head-and-content.js'; export { createHeadAndContent, isHeadAndContent } from './head-and-content.js';
export type { AstroComponentInstance } from './instance'; export type { AstroComponentInstance } from './instance';
export { createAstroComponentInstance, isAstroComponentInstance } from './instance.js'; export { createAstroComponentInstance, isAstroComponentInstance } from './instance.js';
export { export { isRenderTemplateResult, renderTemplate } from './render-template.js';
isRenderTemplateResult,
renderAstroTemplateResult,
renderTemplate,
} from './render-template.js';
export { renderToReadableStream, renderToString } from './render.js'; export { renderToReadableStream, renderToString } from './render.js';

View file

@ -6,6 +6,7 @@ import { isPromise } from '../../util.js';
import { renderChild } from '../any.js'; import { renderChild } from '../any.js';
import { isAPropagatingComponent } from './factory.js'; import { isAPropagatingComponent } from './factory.js';
import { isHeadAndContent } from './head-and-content.js'; import { isHeadAndContent } from './head-and-content.js';
import type { RenderDestination } from '../common.js';
type ComponentProps = Record<string | number, any>; type ComponentProps = Record<string | number, any>;
@ -40,7 +41,7 @@ export class AstroComponentInstance {
return this.returnValue; return this.returnValue;
} }
async *render() { async render(destination: RenderDestination) {
if (this.returnValue === undefined) { if (this.returnValue === undefined) {
await this.init(this.result); await this.init(this.result);
} }
@ -50,9 +51,9 @@ export class AstroComponentInstance {
value = await value; value = await value;
} }
if (isHeadAndContent(value)) { if (isHeadAndContent(value)) {
yield* value.content; await value.content.render(destination);
} else { } else {
yield* renderChild(value); await renderChild(destination, value);
} }
} }
} }
@ -71,7 +72,7 @@ function validateComponentProps(props: any, displayName: string) {
} }
} }
export function createAstroComponentInstance( export async function createAstroComponentInstance(
result: SSRResult, result: SSRResult,
displayName: string, displayName: string,
factory: AstroComponentFactory, factory: AstroComponentFactory,
@ -80,9 +81,16 @@ export function createAstroComponentInstance(
) { ) {
validateComponentProps(props, displayName); validateComponentProps(props, displayName);
const instance = new AstroComponentInstance(result, props, slots, factory); const instance = new AstroComponentInstance(result, props, slots, factory);
if (isAPropagatingComponent(result, factory) && !result._metadata.propagators.has(factory)) { if (isAPropagatingComponent(result, factory) && !result._metadata.propagators.has(factory)) {
result._metadata.propagators.set(factory, instance); result._metadata.propagators.set(factory, instance);
// Call component instances that might have head content to be propagated up.
const returnValue = await instance.init(result);
if (isHeadAndContent(returnValue)) {
result._metadata.extraHead.push(returnValue.head);
}
} }
return instance; return instance;
} }

View file

@ -1,9 +1,7 @@
import type { RenderInstruction } from '../types'; import { markHTMLString } from '../../escape.js';
import { HTMLBytes, markHTMLString } from '../../escape.js';
import { isPromise } from '../../util.js'; import { isPromise } from '../../util.js';
import { renderChild } from '../any.js'; import { renderChild } from '../any.js';
import { bufferIterators } from '../util.js'; import type { RenderDestination } from '../common.js';
const renderTemplateResultSym = Symbol.for('astro.renderTemplateResult'); const renderTemplateResultSym = Symbol.for('astro.renderTemplateResult');
@ -33,17 +31,15 @@ export class RenderTemplateResult {
}); });
} }
async *[Symbol.asyncIterator]() { async render(destination: RenderDestination) {
const { htmlParts, expressions } = this; for (let i = 0; i < this.htmlParts.length; i++) {
const html = this.htmlParts[i];
const exp = this.expressions[i];
let iterables = bufferIterators(expressions.map((e) => renderChild(e))); destination.write(markHTMLString(html));
for (let i = 0; i < htmlParts.length; i++) { // Skip render if falsy, except the number 0
const html = htmlParts[i]; if (exp || exp === 0) {
const iterable = iterables[i]; await renderChild(destination, exp);
yield markHTMLString(html);
if (iterable) {
yield* iterable;
} }
} }
} }
@ -54,27 +50,6 @@ export function isRenderTemplateResult(obj: unknown): obj is RenderTemplateResul
return typeof obj === 'object' && !!(obj as any)[renderTemplateResultSym]; return typeof obj === 'object' && !!(obj as any)[renderTemplateResultSym];
} }
export async function* renderAstroTemplateResult(
component: RenderTemplateResult
): AsyncIterable<string | HTMLBytes | RenderInstruction> {
for await (const value of component) {
if (value || value === 0) {
for await (const chunk of renderChild(value)) {
switch (chunk.type) {
case 'directive': {
yield chunk;
break;
}
default: {
yield markHTMLString(chunk);
break;
}
}
}
}
}
}
export function renderTemplate(htmlParts: TemplateStringsArray, ...expressions: any[]) { export function renderTemplate(htmlParts: TemplateStringsArray, ...expressions: any[]) {
return new RenderTemplateResult(htmlParts, expressions); return new RenderTemplateResult(htmlParts, expressions);
} }

View file

@ -3,7 +3,7 @@ import { AstroError, AstroErrorData } from '../../../../core/errors/index.js';
import { chunkToByteArray, chunkToString, encoder, type RenderDestination } from '../common.js'; import { chunkToByteArray, chunkToString, encoder, type RenderDestination } from '../common.js';
import type { AstroComponentFactory } from './factory.js'; import type { AstroComponentFactory } from './factory.js';
import { isHeadAndContent } from './head-and-content.js'; import { isHeadAndContent } from './head-and-content.js';
import { isRenderTemplateResult, renderAstroTemplateResult } from './render-template.js'; import { isRenderTemplateResult } from './render-template.js';
// Calls a component and renders it into a string of HTML // Calls a component and renders it into a string of HTML
export async function renderToString( export async function renderToString(
@ -46,9 +46,7 @@ export async function renderToString(
}, },
}; };
for await (const chunk of renderAstroTemplateResult(templateResult)) { await templateResult.render(destination);
destination.write(chunk);
}
return str; return str;
} }
@ -73,10 +71,6 @@ export async function renderToReadableStream(
// If the Astro component returns a Response on init, return that response // If the Astro component returns a Response on init, return that response
if (templateResult instanceof Response) return templateResult; if (templateResult instanceof Response) return templateResult;
if (isPage) {
await bufferHeadContent(result);
}
let renderedFirstPageChunk = false; let renderedFirstPageChunk = false;
return new ReadableStream({ return new ReadableStream({
@ -108,9 +102,7 @@ export async function renderToReadableStream(
(async () => { (async () => {
try { try {
for await (const chunk of renderAstroTemplateResult(templateResult)) { await templateResult.render(destination);
destination.write(chunk);
}
controller.close(); controller.close();
} catch (e) { } catch (e) {
// We don't have a lot of information downstream, and upstream we can't catch the error properly // We don't have a lot of information downstream, and upstream we can't catch the error properly
@ -120,7 +112,9 @@ export async function renderToReadableStream(
file: route?.component, file: route?.component,
}); });
} }
controller.error(e);
// Queue error on next microtask to flush the remaining chunks written synchronously
setTimeout(() => controller.error(e), 0);
} }
})(); })();
}, },
@ -150,19 +144,3 @@ async function callComponentAsTemplateResultOrResponse(
return isHeadAndContent(factoryResult) ? factoryResult.content : factoryResult; return isHeadAndContent(factoryResult) ? factoryResult.content : factoryResult;
} }
// Recursively calls component instances that might have head content
// to be propagated up.
async function bufferHeadContent(result: SSRResult) {
const iterator = result._metadata.propagators.values();
while (true) {
const { value, done } = iterator.next();
if (done) {
break;
}
const returnValue = await value.init(result);
if (isHeadAndContent(returnValue)) {
result._metadata.extraHead.push(returnValue.head);
}
}
}

View file

@ -1,7 +1,7 @@
import type { SSRResult } from '../../../@types/astro'; import type { SSRResult } from '../../../@types/astro';
import type { RenderInstruction } from './types.js'; import type { RenderInstruction } from './types.js';
import { HTMLBytes, markHTMLString } from '../escape.js'; import { HTMLBytes, HTMLString, markHTMLString } from '../escape.js';
import { import {
determineIfNeedsHydrationScript, determineIfNeedsHydrationScript,
determinesIfNeedsDirectiveScript, determinesIfNeedsDirectiveScript,
@ -11,12 +11,32 @@ import {
import { renderAllHeadContent } from './head.js'; import { renderAllHeadContent } from './head.js';
import { isSlotString, type SlotString } from './slot.js'; import { isSlotString, type SlotString } from './slot.js';
/**
* Possible chunk types to be written to the destination, and it'll
* handle stringifying them at the end.
*
* NOTE: Try to reduce adding new types here. If possible, serialize
* the custom types to a string in `renderChild` in `any.ts`.
*/
export type RenderDestinationChunk =
| string
| HTMLBytes
| HTMLString
| SlotString
| ArrayBufferView
| RenderInstruction
| Response;
export interface RenderDestination { export interface RenderDestination {
/** /**
* Any rendering logic should call this to construct the HTML output. * Any rendering logic should call this to construct the HTML output.
* See the `chunk` parameter for possible writable values * See the `chunk` parameter for possible writable values.
*/ */
write(chunk: string | HTMLBytes | RenderInstruction | Response): void; write(chunk: RenderDestinationChunk): void;
}
export interface RenderInstance {
render(destination: RenderDestination): Promise<void> | void;
} }
export const Fragment = Symbol.for('astro:fragment'); export const Fragment = Symbol.for('astro:fragment');
@ -28,9 +48,9 @@ export const decoder = new TextDecoder();
// Rendering produces either marked strings of HTML or instructions for hydration. // Rendering produces either marked strings of HTML or instructions for hydration.
// These directive instructions bubble all the way up to renderPage so that we // These directive instructions bubble all the way up to renderPage so that we
// can ensure they are added only once, and as soon as possible. // can ensure they are added only once, and as soon as possible.
export function stringifyChunk( function stringifyChunk(
result: SSRResult, result: SSRResult,
chunk: string | SlotString | RenderInstruction chunk: string | HTMLString | SlotString | RenderInstruction
): string { ): string {
if (typeof (chunk as any).type === 'string') { if (typeof (chunk as any).type === 'string') {
const instruction = chunk as RenderInstruction; const instruction = chunk as RenderInstruction;
@ -89,27 +109,7 @@ export function stringifyChunk(
} }
} }
export class HTMLParts { export function chunkToString(result: SSRResult, chunk: Exclude<RenderDestinationChunk, Response>) {
public parts: string;
constructor() {
this.parts = '';
}
append(part: string | HTMLBytes | RenderInstruction, result: SSRResult) {
if (ArrayBuffer.isView(part)) {
this.parts += decoder.decode(part);
} else {
this.parts += stringifyChunk(result, part);
}
}
toString() {
return this.parts;
}
toArrayBuffer() {
return encoder.encode(this.parts);
}
}
export function chunkToString(result: SSRResult, chunk: string | HTMLBytes | RenderInstruction) {
if (ArrayBuffer.isView(chunk)) { if (ArrayBuffer.isView(chunk)) {
return decoder.decode(chunk); return decoder.decode(chunk);
} else { } else {
@ -119,7 +119,7 @@ export function chunkToString(result: SSRResult, chunk: string | HTMLBytes | Ren
export function chunkToByteArray( export function chunkToByteArray(
result: SSRResult, result: SSRResult,
chunk: string | HTMLBytes | RenderInstruction chunk: Exclude<RenderDestinationChunk, Response>
): Uint8Array { ): Uint8Array {
if (ArrayBuffer.isView(chunk)) { if (ArrayBuffer.isView(chunk)) {
return chunk as Uint8Array; return chunk as Uint8Array;
@ -129,3 +129,7 @@ export function chunkToByteArray(
return encoder.encode(stringified.toString()); return encoder.encode(stringified.toString());
} }
} }
export function isRenderInstance(obj: unknown): obj is RenderInstance {
return !!obj && typeof obj === 'object' && 'render' in obj && typeof obj.render === 'function';
}

View file

@ -1,4 +1,9 @@
import type { AstroComponentMetadata, SSRLoadedRenderer, SSRResult } from '../../../@types/astro'; import type {
AstroComponentMetadata,
RouteData,
SSRLoadedRenderer,
SSRResult,
} from '../../../@types/astro';
import type { RenderInstruction } from './types.js'; import type { RenderInstruction } from './types.js';
import { AstroError, AstroErrorData } from '../../../core/errors/index.js'; import { AstroError, AstroErrorData } from '../../../core/errors/index.js';
@ -10,16 +15,23 @@ import { isPromise } from '../util.js';
import { import {
createAstroComponentInstance, createAstroComponentInstance,
isAstroComponentFactory, isAstroComponentFactory,
isAstroComponentInstance,
renderAstroTemplateResult,
renderTemplate, renderTemplate,
type AstroComponentInstance, type AstroComponentFactory,
} from './astro/index.js'; } from './astro/index.js';
import { Fragment, Renderer, stringifyChunk } from './common.js'; import {
Fragment,
Renderer,
type RenderDestination,
chunkToString,
type RenderInstance,
type RenderDestinationChunk,
} from './common.js';
import { componentIsHTMLElement, renderHTMLElement } from './dom.js'; import { componentIsHTMLElement, renderHTMLElement } from './dom.js';
import { renderSlotToString, renderSlots, type ComponentSlots } from './slot.js'; import { renderSlotToString, renderSlots, type ComponentSlots } from './slot.js';
import { formatList, internalSpreadAttributes, renderElement, voidElementNames } from './util.js'; import { formatList, internalSpreadAttributes, renderElement, voidElementNames } from './util.js';
import { maybeRenderHead } from './head.js';
const needsHeadRenderingSymbol = Symbol.for('astro.needsHeadRendering');
const rendererAliases = new Map([['solid', 'solid-js']]); const rendererAliases = new Map([['solid', 'solid-js']]);
function guessRenderers(componentUrl?: string): string[] { function guessRenderers(componentUrl?: string): string[] {
@ -67,7 +79,7 @@ async function renderFrameworkComponent(
Component: unknown, Component: unknown,
_props: Record<string | number, any>, _props: Record<string | number, any>,
slots: any = {} slots: any = {}
): Promise<ComponentIterable> { ): Promise<RenderInstance> {
if (!Component && !_props['client:only']) { if (!Component && !_props['client:only']) {
throw new Error( throw new Error(
`Unable to render ${displayName} because it is ${Component}!\nDid you forget to import the component or is it possible there is a typo?` `Unable to render ${displayName} because it is ${Component}!\nDid you forget to import the component or is it possible there is a typo?`
@ -134,9 +146,17 @@ async function renderFrameworkComponent(
} }
if (!renderer && typeof HTMLElement === 'function' && componentIsHTMLElement(Component)) { if (!renderer && typeof HTMLElement === 'function' && componentIsHTMLElement(Component)) {
const output = renderHTMLElement(result, Component as typeof HTMLElement, _props, slots); const output = await renderHTMLElement(
result,
return output; Component as typeof HTMLElement,
_props,
slots
);
return {
render(destination) {
destination.write(output);
},
};
} }
} else { } else {
// Attempt: use explicitly passed renderer name // Attempt: use explicitly passed renderer name
@ -253,33 +273,43 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
// Sanitize tag name because some people might try to inject attributes 🙄 // Sanitize tag name because some people might try to inject attributes 🙄
const Tag = sanitizeElementName(Component); const Tag = sanitizeElementName(Component);
const childSlots = Object.values(children).join(''); const childSlots = Object.values(children).join('');
const iterable = renderAstroTemplateResult(
await renderTemplate`<${Tag}${internalSpreadAttributes(props)}${markHTMLString( const renderTemplateResult = renderTemplate`<${Tag}${internalSpreadAttributes(
childSlots === '' && voidElementNames.test(Tag) ? `/>` : `>${childSlots}</${Tag}>` props
)}` )}${markHTMLString(
); childSlots === '' && voidElementNames.test(Tag) ? `/>` : `>${childSlots}</${Tag}>`
)}`;
html = ''; html = '';
for await (const chunk of iterable) { const destination: RenderDestination = {
html += chunk; write(chunk) {
} if (chunk instanceof Response) return;
html += chunkToString(result, chunk);
},
};
await renderTemplateResult.render(destination);
} }
if (!hydration) { if (!hydration) {
return (async function* () { return {
if (slotInstructions) { render(destination) {
yield* slotInstructions; // If no hydration is needed, start rendering the html and return
} if (slotInstructions) {
for (const instruction of slotInstructions) {
if (isPage || renderer?.name === 'astro:jsx') { destination.write(instruction);
yield html; }
} else if (html && html.length > 0) { }
yield markHTMLString( if (isPage || renderer?.name === 'astro:jsx') {
removeStaticAstroSlot(html, renderer?.ssr?.supportsAstroStaticSlot ?? false) destination.write(html);
); } else if (html && html.length > 0) {
} else { destination.write(
yield ''; markHTMLString(
} removeStaticAstroSlot(html, renderer?.ssr?.supportsAstroStaticSlot ?? false)
})(); )
);
}
},
};
} }
// Include componentExport name, componentUrl, and props in hash to dedupe identical islands // Include componentExport name, componentUrl, and props in hash to dedupe identical islands
@ -332,15 +362,18 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
island.props['await-children'] = ''; island.props['await-children'] = '';
} }
async function* renderAll() { return {
if (slotInstructions) { render(destination) {
yield* slotInstructions; // Render the html
} if (slotInstructions) {
yield { type: 'directive', hydration, result }; for (const instruction of slotInstructions) {
yield markHTMLString(renderElement('astro-island', island, false)); destination.write(instruction);
} }
}
return renderAll(); destination.write({ type: 'directive', hydration });
destination.write(markHTMLString(renderElement('astro-island', island, false)));
},
};
} }
function sanitizeElementName(tag: string) { function sanitizeElementName(tag: string) {
@ -349,12 +382,17 @@ function sanitizeElementName(tag: string) {
return tag.trim().split(unsafe)[0].trim(); return tag.trim().split(unsafe)[0].trim();
} }
async function renderFragmentComponent(result: SSRResult, slots: ComponentSlots = {}) { async function renderFragmentComponent(
result: SSRResult,
slots: ComponentSlots = {}
): Promise<RenderInstance> {
const children = await renderSlotToString(result, slots?.default); const children = await renderSlotToString(result, slots?.default);
if (children == null) { return {
return children; render(destination) {
} if (children == null) return;
return markHTMLString(children); destination.write(children);
},
};
} }
async function renderHTMLComponent( async function renderHTMLComponent(
@ -362,54 +400,136 @@ async function renderHTMLComponent(
Component: unknown, Component: unknown,
_props: Record<string | number, any>, _props: Record<string | number, any>,
slots: any = {} slots: any = {}
) { ): Promise<RenderInstance> {
const { slotInstructions, children } = await renderSlots(result, slots); const { slotInstructions, children } = await renderSlots(result, slots);
const html = (Component as any)({ slots: children }); const html = (Component as any)({ slots: children });
const hydrationHtml = slotInstructions const hydrationHtml = slotInstructions
? slotInstructions.map((instr) => stringifyChunk(result, instr)).join('') ? slotInstructions.map((instr) => chunkToString(result, instr)).join('')
: ''; : '';
return markHTMLString(hydrationHtml + html); return {
render(destination) {
destination.write(markHTMLString(hydrationHtml + html));
},
};
} }
export function renderComponent( async function renderAstroComponent(
result: SSRResult,
displayName: string,
Component: AstroComponentFactory,
props: Record<string | number, any>,
slots: any = {}
): Promise<RenderInstance> {
const instance = await createAstroComponentInstance(result, displayName, Component, props, slots);
// Eagerly render the component so they are rendered in parallel
const chunks: RenderDestinationChunk[] = [];
const temporaryDestination: RenderDestination = {
write: (chunk) => chunks.push(chunk),
};
await instance.render(temporaryDestination);
return {
render(destination) {
// The real render function will simply pass on the results from the temporary destination
for (const chunk of chunks) {
destination.write(chunk);
}
},
};
}
export async function renderComponent(
result: SSRResult, result: SSRResult,
displayName: string, displayName: string,
Component: unknown, Component: unknown,
props: Record<string | number, any>, props: Record<string | number, any>,
slots: any = {} slots: any = {}
): Promise<ComponentIterable> | ComponentIterable | AstroComponentInstance { ): Promise<RenderInstance> {
if (isPromise(Component)) { if (isPromise(Component)) {
return Promise.resolve(Component).then((Unwrapped) => { Component = await Component;
return renderComponent(result, displayName, Unwrapped, props, slots) as any;
});
} }
if (isFragmentComponent(Component)) { if (isFragmentComponent(Component)) {
return renderFragmentComponent(result, slots); return await renderFragmentComponent(result, slots);
} }
// .html components // .html components
if (isHTMLComponent(Component)) { if (isHTMLComponent(Component)) {
return renderHTMLComponent(result, Component, props, slots); return await renderHTMLComponent(result, Component, props, slots);
} }
if (isAstroComponentFactory(Component)) { if (isAstroComponentFactory(Component)) {
return createAstroComponentInstance(result, displayName, Component, props, slots); return await renderAstroComponent(result, displayName, Component, props, slots);
} }
return renderFrameworkComponent(result, displayName, Component, props, slots); return await renderFrameworkComponent(result, displayName, Component, props, slots);
} }
export function renderComponentToIterable( export async function renderComponentToString(
result: SSRResult, result: SSRResult,
displayName: string, displayName: string,
Component: unknown, Component: unknown,
props: Record<string | number, any>, props: Record<string | number, any>,
slots: any = {} slots: any = {},
): Promise<ComponentIterable> | ComponentIterable { isPage = false,
const renderResult = renderComponent(result, displayName, Component, props, slots); route?: RouteData
if (isAstroComponentInstance(renderResult)) { ): Promise<string> {
return renderResult.render(); let str = '';
let renderedFirstPageChunk = false;
// Handle head injection if required. Note that this needs to run early so
// we can ensure getting a value for `head`.
let head = '';
if (nonAstroPageNeedsHeadInjection(Component)) {
for (const headChunk of maybeRenderHead()) {
head += chunkToString(result, headChunk);
}
} }
return renderResult;
try {
const destination: RenderDestination = {
write(chunk) {
// Automatic doctype and head insertion for pages
if (isPage && !renderedFirstPageChunk) {
renderedFirstPageChunk = true;
if (!/<!doctype html/i.test(String(chunk))) {
const doctype = result.compressHTML ? '<!DOCTYPE html>' : '<!DOCTYPE html>\n';
str += doctype + head;
}
}
// `renderToString` doesn't work with emitting responses, so ignore here
if (chunk instanceof Response) return;
str += chunkToString(result, chunk);
},
};
const renderInstance = await renderComponent(result, displayName, Component, props, slots);
await renderInstance.render(destination);
} catch (e) {
// We don't have a lot of information downstream, and upstream we can't catch the error properly
// So let's add the location here
if (AstroError.is(e) && !e.loc) {
e.setLocation({
file: route?.component,
});
}
throw e;
}
return str;
}
export type NonAstroPageComponent = {
name: string;
[needsHeadRenderingSymbol]: boolean;
};
function nonAstroPageNeedsHeadInjection(
pageComponent: any
): pageComponent is NonAstroPageComponent {
return !!pageComponent?.[needsHeadRenderingSymbol];
} }

View file

@ -13,7 +13,7 @@ export async function renderHTMLElement(
constructor: typeof HTMLElement, constructor: typeof HTMLElement,
props: any, props: any,
slots: any slots: any
) { ): Promise<string> {
const name = getHTMLElementName(constructor); const name = getHTMLElementName(constructor);
let attrHTML = ''; let attrHTML = '';

View file

@ -1,12 +1,7 @@
export type { AstroComponentFactory, AstroComponentInstance } from './astro/index'; export type { AstroComponentFactory, AstroComponentInstance } from './astro/index';
export { export { createHeadAndContent, renderTemplate, renderToString } from './astro/index.js';
createHeadAndContent, export { Fragment, Renderer, chunkToString, chunkToByteArray } from './common.js';
renderAstroTemplateResult, export { renderComponent, renderComponentToString } from './component.js';
renderTemplate,
renderToString,
} from './astro/index.js';
export { Fragment, Renderer, stringifyChunk } from './common.js';
export { renderComponent, renderComponentToIterable } from './component.js';
export { renderHTMLElement } from './dom.js'; export { renderHTMLElement } from './dom.js';
export { maybeRenderHead, renderHead } from './head.js'; export { maybeRenderHead, renderHead } from './head.js';
export { renderPage } from './page.js'; export { renderPage } from './page.js';

View file

@ -1,50 +1,11 @@
import type { RouteData, SSRResult } from '../../../@types/astro'; import type { RouteData, SSRResult } from '../../../@types/astro';
import type { ComponentIterable } from './component'; import { renderComponentToString, type NonAstroPageComponent } from './component.js';
import type { AstroComponentFactory } from './index'; import type { AstroComponentFactory } from './index';
import { AstroError } from '../../../core/errors/index.js';
import { isHTMLString } from '../escape.js';
import { createResponse } from '../response.js'; import { createResponse } from '../response.js';
import { isAstroComponentFactory, isAstroComponentInstance } from './astro/index.js'; import { isAstroComponentFactory } from './astro/index.js';
import { renderToReadableStream, renderToString } from './astro/render.js'; import { renderToReadableStream, renderToString } from './astro/render.js';
import { HTMLParts, encoder } from './common.js'; import { encoder } from './common.js';
import { renderComponent } from './component.js';
import { maybeRenderHead } from './head.js';
const needsHeadRenderingSymbol = Symbol.for('astro.needsHeadRendering');
type NonAstroPageComponent = {
name: string;
[needsHeadRenderingSymbol]: boolean;
};
function nonAstroPageNeedsHeadInjection(pageComponent: NonAstroPageComponent): boolean {
return needsHeadRenderingSymbol in pageComponent && !!pageComponent[needsHeadRenderingSymbol];
}
async function iterableToHTMLBytes(
result: SSRResult,
iterable: ComponentIterable,
onDocTypeInjection?: (parts: HTMLParts) => Promise<void>
): Promise<Uint8Array> {
const parts = new HTMLParts();
let i = 0;
for await (const chunk of iterable) {
if (isHTMLString(chunk)) {
if (i === 0) {
i++;
if (!/<!doctype html/i.test(String(chunk))) {
parts.append(`${result.compressHTML ? '<!DOCTYPE html>' : '<!DOCTYPE html>\n'}`, result);
if (onDocTypeInjection) {
await onDocTypeInjection(parts);
}
}
}
}
parts.append(chunk, result);
}
return parts.toArrayBuffer();
}
export async function renderPage( export async function renderPage(
result: SSRResult, result: SSRResult,
@ -52,49 +13,25 @@ export async function renderPage(
props: any, props: any,
children: any, children: any,
streaming: boolean, streaming: boolean,
route?: RouteData | undefined route?: RouteData
): Promise<Response> { ): Promise<Response> {
if (!isAstroComponentFactory(componentFactory)) { if (!isAstroComponentFactory(componentFactory)) {
result._metadata.headInTree = result._metadata.headInTree =
result.componentMetadata.get((componentFactory as any).moduleId)?.containsHead ?? false; result.componentMetadata.get((componentFactory as any).moduleId)?.containsHead ?? false;
const pageProps: Record<string, any> = { ...(props ?? {}), 'server:root': true }; const pageProps: Record<string, any> = { ...(props ?? {}), 'server:root': true };
let output: ComponentIterable;
let head = '';
try {
if (nonAstroPageNeedsHeadInjection(componentFactory)) {
const parts = new HTMLParts();
for await (const chunk of maybeRenderHead()) {
parts.append(chunk, result);
}
head = parts.toString();
}
const renderResult = await renderComponent( const str = await renderComponentToString(
result, result,
componentFactory.name, componentFactory.name,
componentFactory, componentFactory,
pageProps, pageProps,
null null,
); true,
if (isAstroComponentInstance(renderResult)) { route
output = renderResult.render(); );
} else {
output = renderResult;
}
} catch (e) {
if (AstroError.is(e) && !e.loc) {
e.setLocation({
file: route?.component,
});
}
throw e; const bytes = encoder.encode(str);
}
// Accumulate the HTML string and append the head if necessary.
const bytes = await iterableToHTMLBytes(result, output, async (parts) => {
parts.append(head, result);
});
return new Response(bytes, { return new Response(bytes, {
headers: new Headers([ headers: new Headers([
@ -103,6 +40,7 @@ export async function renderPage(
]), ]),
}); });
} }
// Mark if this page component contains a <head> within its tree. If it does // Mark if this page component contains a <head> within its tree. If it does
// We avoid implicit head injection entirely. // We avoid implicit head injection entirely.
result._metadata.headInTree = result._metadata.headInTree =

View file

@ -4,6 +4,7 @@ import type { RenderInstruction } from './types.js';
import { HTMLString, markHTMLString } from '../escape.js'; import { HTMLString, markHTMLString } from '../escape.js';
import { renderChild } from './any.js'; import { renderChild } from './any.js';
import { chunkToString, type RenderDestination, type RenderInstance } from './common.js';
type RenderTemplateResult = ReturnType<typeof renderTemplate>; type RenderTemplateResult = ReturnType<typeof renderTemplate>;
export type ComponentSlots = Record<string, ComponentSlotValue>; export type ComponentSlots = Record<string, ComponentSlotValue>;
@ -27,19 +28,19 @@ export function isSlotString(str: string): str is any {
return !!(str as any)[slotString]; return !!(str as any)[slotString];
} }
export async function* renderSlot( export function renderSlot(
result: SSRResult, result: SSRResult,
slotted: ComponentSlotValue | RenderTemplateResult, slotted: ComponentSlotValue | RenderTemplateResult,
fallback?: ComponentSlotValue | RenderTemplateResult fallback?: ComponentSlotValue | RenderTemplateResult
): AsyncGenerator<any, void, undefined> { ): RenderInstance {
if (slotted) { if (!slotted && fallback) {
let iterator = renderChild(typeof slotted === 'function' ? slotted(result) : slotted); return renderSlot(result, fallback);
yield* iterator;
}
if (fallback && !slotted) {
yield* renderSlot(result, fallback);
} }
return {
async render(destination) {
await renderChild(destination, typeof slotted === 'function' ? slotted(result) : slotted);
},
};
} }
export async function renderSlotToString( export async function renderSlotToString(
@ -49,17 +50,21 @@ export async function renderSlotToString(
): Promise<string> { ): Promise<string> {
let content = ''; let content = '';
let instructions: null | RenderInstruction[] = null; let instructions: null | RenderInstruction[] = null;
let iterator = renderSlot(result, slotted, fallback); const temporaryDestination: RenderDestination = {
for await (const chunk of iterator) { write(chunk) {
if (typeof chunk.type === 'string') { if (chunk instanceof Response) return;
if (instructions === null) { if (typeof chunk === 'object' && 'type' in chunk && typeof chunk.type === 'string') {
instructions = []; if (instructions === null) {
instructions = [];
}
instructions.push(chunk);
} else {
content += chunkToString(result, chunk);
} }
instructions.push(chunk); },
} else { };
content += chunk; const renderInstance = renderSlot(result, slotted, fallback);
} await renderInstance.render(temporaryDestination);
}
return markHTMLString(new SlotString(content, instructions)); return markHTMLString(new SlotString(content, instructions));
} }

View file

@ -145,145 +145,3 @@ export function renderElement(
} }
return `<${name}${internalSpreadAttributes(props, shouldEscape)}>${children}</${name}>`; return `<${name}${internalSpreadAttributes(props, shouldEscape)}>${children}</${name}>`;
} }
const iteratorQueue: EagerAsyncIterableIterator[][] = [];
/**
* Takes an array of iterators and adds them to a list of iterators to start buffering
* as soon as the execution flow is suspended for the first time. We expect a lot
* of calls to this function before the first suspension, so to reduce the number
* of calls to setTimeout we batch the buffering calls.
* @param iterators
*/
function queueIteratorBuffers(iterators: EagerAsyncIterableIterator[]) {
if (iteratorQueue.length === 0) {
setTimeout(() => {
// buffer all iterators that haven't started yet
iteratorQueue.forEach((its) => its.forEach((it) => !it.isStarted() && it.buffer()));
iteratorQueue.length = 0; // fastest way to empty an array
});
}
iteratorQueue.push(iterators);
}
/**
* This will take an array of async iterables and start buffering them eagerly.
* To avoid useless buffering, it will only start buffering the next tick, so the
* first sync iterables won't be buffered.
*/
export function bufferIterators<T>(iterators: AsyncIterable<T>[]): AsyncIterable<T>[] {
// all async iterators start running in non-buffered mode to avoid useless caching
const eagerIterators = iterators.map((it) => new EagerAsyncIterableIterator(it));
// once the execution of the next for loop is suspended due to an async component,
// this timeout triggers and we start buffering the other iterators
queueIteratorBuffers(eagerIterators);
return eagerIterators;
}
// This wrapper around an AsyncIterable can eagerly consume its values, so that
// its values are ready to yield out ASAP. This is used for list-like usage of
// Astro components, so that we don't have to wait on earlier components to run
// to even start running those down in the list.
export class EagerAsyncIterableIterator {
#iterable: AsyncIterable<any>;
#queue = new Queue<IteratorResult<any, any>>();
#error: any = undefined;
#next: Promise<IteratorResult<any, any>> | undefined;
/**
* Whether the proxy is running in buffering or pass-through mode
*/
#isBuffering = false;
#gen: AsyncIterator<any> | undefined = undefined;
#isStarted = false;
constructor(iterable: AsyncIterable<any>) {
this.#iterable = iterable;
}
/**
* Starts to eagerly fetch the inner iterator and cache the results.
* Note: This might not be called after next() has been called once, e.g. the iterator is started
*/
async buffer() {
if (this.#gen) {
// If this called as part of rendering, please open a bug report.
// Any call to buffer() should verify that the iterator isn't running
throw new Error('Cannot not switch from non-buffer to buffer mode');
}
this.#isBuffering = true;
this.#isStarted = true;
this.#gen = this.#iterable[Symbol.asyncIterator]();
let value: IteratorResult<any, any> | undefined = undefined;
do {
this.#next = this.#gen.next();
try {
value = await this.#next;
this.#queue.push(value);
} catch (e) {
this.#error = e;
}
} while (value && !value.done);
}
async next() {
if (this.#error) {
throw this.#error;
}
// for non-buffered mode, just pass through the next result
if (!this.#isBuffering) {
if (!this.#gen) {
this.#isStarted = true;
this.#gen = this.#iterable[Symbol.asyncIterator]();
}
return await this.#gen.next();
}
if (!this.#queue.isEmpty()) {
return this.#queue.shift()!;
}
await this.#next;
// the previous statement will either put an element in the queue or throw,
// so we can safely assume we have something now
return this.#queue.shift()!;
}
isStarted() {
return this.#isStarted;
}
[Symbol.asyncIterator]() {
return this;
}
}
interface QueueItem<T> {
item: T;
next?: QueueItem<T>;
}
/**
* Basis Queue implementation with a linked list
*/
class Queue<T> {
head: QueueItem<T> | undefined = undefined;
tail: QueueItem<T> | undefined = undefined;
push(item: T) {
if (this.head === undefined) {
this.head = { item };
this.tail = this.head;
} else {
this.tail!.next = { item };
this.tail = this.tail!.next;
}
}
isEmpty() {
return this.head === undefined;
}
shift(): T | undefined {
const val = this.head?.item;
this.head = this.head?.next;
return val;
}
}

View file

@ -48,7 +48,7 @@ describe('Streaming', () => {
let chunk = decoder.decode(bytes); let chunk = decoder.decode(bytes);
chunks.push(chunk); chunks.push(chunk);
} }
expect(chunks.length).to.equal(3); expect(chunks.length).to.equal(2);
}); });
}); });