diff --git a/.changeset/lemon-snakes-invite.md b/.changeset/lemon-snakes-invite.md new file mode 100644 index 000000000..49bb98510 --- /dev/null +++ b/.changeset/lemon-snakes-invite.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Refactor Astro rendering to write results directly. This improves the rendering performance for all Astro files. diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index d86cce348..46bae1128 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -7,16 +7,12 @@ import type { SSRLoadedRenderer, SSRResult, } from '../../@types/astro'; -import { isHTMLString } from '../../runtime/server/escape.js'; -import { - renderSlotToString, - stringifyChunk, - type ComponentSlots, -} from '../../runtime/server/index.js'; +import { renderSlotToString, type ComponentSlots } from '../../runtime/server/index.js'; import { renderJSX } from '../../runtime/server/jsx.js'; import { AstroCookies } from '../cookies/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { warn, type LogOptions } from '../logger/core.js'; +import { chunkToString } from '../../runtime/server/render/index.js'; const clientAddressSymbol = Symbol.for('astro.clientAddress'); const responseSentSymbol = Symbol.for('astro.responseSent'); @@ -112,7 +108,7 @@ class Slots { const expression = getFunctionExpression(component); if (expression) { const slot = async () => - isHTMLString(await expression) ? expression : expression(...args); + typeof expression === 'function' ? expression(...args) : expression; return await renderSlotToString(result, slot).then((res) => { return res != null ? String(res) : res; }); @@ -126,7 +122,7 @@ class Slots { } const content = await renderSlotToString(result, this.#slots[name]); - const outHTML = stringifyChunk(result, content); + const outHTML = chunkToString(result, content); return outHTML; } diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 1a03a507b..aca260d00 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -17,9 +17,7 @@ export { Fragment, maybeRenderHead, renderTemplate as render, - renderAstroTemplateResult as renderAstroComponent, renderComponent, - renderComponentToIterable, Renderer as Renderer, renderHead, renderHTMLElement, @@ -30,7 +28,6 @@ export { renderTemplate, renderToString, renderUniqueStylesheet, - stringifyChunk, voidElementNames, } from './render/index.js'; export type { diff --git a/packages/astro/src/runtime/server/jsx.ts b/packages/astro/src/runtime/server/jsx.ts index 48f879b10..d2cb87a61 100644 --- a/packages/astro/src/runtime/server/jsx.ts +++ b/packages/astro/src/runtime/server/jsx.ts @@ -5,13 +5,11 @@ import { HTMLString, escapeHTML, markHTMLString, - renderComponentToIterable, renderToString, spreadAttributes, voidElementNames, } from './index.js'; -import { HTMLParts } from './render/common.js'; -import type { ComponentIterable } from './render/component'; +import { renderComponentToString } from './render/component.js'; 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); props[Skip.symbol] = skip; - let output: ComponentIterable; + let output: string; if (vnode.type === ClientOnlyPlaceholder && vnode.props['client:only']) { - output = await renderComponentToIterable( + output = await renderComponentToString( result, vnode.props['client:display-name'] ?? '', null, @@ -187,7 +185,7 @@ Did you forget to import the component or is it possible there is a typo?`); slots ); } else { - output = await renderComponentToIterable( + output = await renderComponentToString( result, typeof vnode.type === 'function' ? vnode.type.name : vnode.type, vnode.type, @@ -195,15 +193,7 @@ Did you forget to import the component or is it possible there is a typo?`); slots ); } - if (typeof output !== 'string' && Symbol.asyncIterator in output) { - let parts = new HTMLParts(); - for await (const chunk of output) { - parts.append(chunk, result); - } - return markHTMLString(parts.toString()); - } else { - return markHTMLString(output); - } + return markHTMLString(output); } } // numbers, plain objects, etc diff --git a/packages/astro/src/runtime/server/render/any.ts b/packages/astro/src/runtime/server/render/any.ts index 4ee947ee6..7c181fecb 100644 --- a/packages/astro/src/runtime/server/render/any.ts +++ b/packages/astro/src/runtime/server/render/any.ts @@ -1,47 +1,43 @@ import { escapeHTML, isHTMLString, markHTMLString } from '../escape.js'; -import { - isAstroComponentInstance, - isRenderTemplateResult, - renderAstroTemplateResult, -} from './astro/index.js'; +import { isAstroComponentInstance, isRenderTemplateResult } from './astro/index.js'; +import { isRenderInstance, type RenderDestination } from './common.js'; import { SlotString } from './slot.js'; -import { bufferIterators } from './util.js'; -export async function* renderChild(child: any): AsyncIterable { +export async function renderChild(destination: RenderDestination, child: any) { child = await child; if (child instanceof SlotString) { - if (child.instructions) { - yield* child.instructions; - } - yield child; + destination.write(child); } else if (isHTMLString(child)) { - yield child; + destination.write(child); } else if (Array.isArray(child)) { - const bufferedIterators = bufferIterators(child.map((c) => renderChild(c))); - for (const value of bufferedIterators) { - yield markHTMLString(await value); + for (const c of child) { + await renderChild(destination, c); } } else 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. - yield* renderChild(child()); + await renderChild(destination, child()); } else if (typeof child === 'string') { - yield markHTMLString(escapeHTML(child)); + destination.write(markHTMLString(escapeHTML(child))); } else if (!child && child !== 0) { // do nothing, safe to ignore falsey values. + } else if (isRenderInstance(child)) { + await child.render(destination); } else if (isRenderTemplateResult(child)) { - yield* renderAstroTemplateResult(child); + await child.render(destination); } else if (isAstroComponentInstance(child)) { - yield* child.render(); + await child.render(destination); } else if (ArrayBuffer.isView(child)) { - yield child; + destination.write(child); } else if ( typeof child === 'object' && (Symbol.asyncIterator in child || Symbol.iterator in child) ) { - yield* child; + for await (const value of child) { + await renderChild(destination, value); + } } else { - yield child; + destination.write(child); } } diff --git a/packages/astro/src/runtime/server/render/astro/index.ts b/packages/astro/src/runtime/server/render/astro/index.ts index f7d9923ee..d9283b9f9 100644 --- a/packages/astro/src/runtime/server/render/astro/index.ts +++ b/packages/astro/src/runtime/server/render/astro/index.ts @@ -3,9 +3,5 @@ export { isAstroComponentFactory } from './factory.js'; export { createHeadAndContent, isHeadAndContent } from './head-and-content.js'; export type { AstroComponentInstance } from './instance'; export { createAstroComponentInstance, isAstroComponentInstance } from './instance.js'; -export { - isRenderTemplateResult, - renderAstroTemplateResult, - renderTemplate, -} from './render-template.js'; +export { isRenderTemplateResult, renderTemplate } from './render-template.js'; export { renderToReadableStream, renderToString } from './render.js'; diff --git a/packages/astro/src/runtime/server/render/astro/instance.ts b/packages/astro/src/runtime/server/render/astro/instance.ts index 527d4a8c6..e4df186c6 100644 --- a/packages/astro/src/runtime/server/render/astro/instance.ts +++ b/packages/astro/src/runtime/server/render/astro/instance.ts @@ -6,6 +6,7 @@ import { isPromise } from '../../util.js'; import { renderChild } from '../any.js'; import { isAPropagatingComponent } from './factory.js'; import { isHeadAndContent } from './head-and-content.js'; +import type { RenderDestination } from '../common.js'; type ComponentProps = Record; @@ -40,7 +41,7 @@ export class AstroComponentInstance { return this.returnValue; } - async *render() { + async render(destination: RenderDestination) { if (this.returnValue === undefined) { await this.init(this.result); } @@ -50,9 +51,9 @@ export class AstroComponentInstance { value = await value; } if (isHeadAndContent(value)) { - yield* value.content; + await value.content.render(destination); } 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, displayName: string, factory: AstroComponentFactory, @@ -80,9 +81,16 @@ export function createAstroComponentInstance( ) { validateComponentProps(props, displayName); const instance = new AstroComponentInstance(result, props, slots, factory); + if (isAPropagatingComponent(result, factory) && !result._metadata.propagators.has(factory)) { 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; } 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 b0dbabdc1..1d5af33fc 100644 --- a/packages/astro/src/runtime/server/render/astro/render-template.ts +++ b/packages/astro/src/runtime/server/render/astro/render-template.ts @@ -1,9 +1,7 @@ -import type { RenderInstruction } from '../types'; - -import { HTMLBytes, markHTMLString } from '../../escape.js'; +import { markHTMLString } from '../../escape.js'; import { isPromise } from '../../util.js'; import { renderChild } from '../any.js'; -import { bufferIterators } from '../util.js'; +import type { RenderDestination } from '../common.js'; const renderTemplateResultSym = Symbol.for('astro.renderTemplateResult'); @@ -33,17 +31,15 @@ export class RenderTemplateResult { }); } - async *[Symbol.asyncIterator]() { - const { htmlParts, expressions } = this; + async render(destination: RenderDestination) { + 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))); - for (let i = 0; i < htmlParts.length; i++) { - const html = htmlParts[i]; - const iterable = iterables[i]; - - yield markHTMLString(html); - if (iterable) { - yield* iterable; + destination.write(markHTMLString(html)); + // Skip render if falsy, except the number 0 + if (exp || exp === 0) { + await renderChild(destination, exp); } } } @@ -54,27 +50,6 @@ export function isRenderTemplateResult(obj: unknown): obj is RenderTemplateResul return typeof obj === 'object' && !!(obj as any)[renderTemplateResultSym]; } -export async function* renderAstroTemplateResult( - component: RenderTemplateResult -): AsyncIterable { - 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[]) { return new RenderTemplateResult(htmlParts, expressions); } diff --git a/packages/astro/src/runtime/server/render/astro/render.ts b/packages/astro/src/runtime/server/render/astro/render.ts index 81b4375be..89dc28b75 100644 --- a/packages/astro/src/runtime/server/render/astro/render.ts +++ b/packages/astro/src/runtime/server/render/astro/render.ts @@ -3,7 +3,7 @@ import { AstroError, AstroErrorData } from '../../../../core/errors/index.js'; import { chunkToByteArray, chunkToString, encoder, type RenderDestination } from '../common.js'; import type { AstroComponentFactory } from './factory.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 export async function renderToString( @@ -46,9 +46,7 @@ export async function renderToString( }, }; - for await (const chunk of renderAstroTemplateResult(templateResult)) { - destination.write(chunk); - } + await templateResult.render(destination); return str; } @@ -73,10 +71,6 @@ export async function renderToReadableStream( // If the Astro component returns a Response on init, return that response if (templateResult instanceof Response) return templateResult; - if (isPage) { - await bufferHeadContent(result); - } - let renderedFirstPageChunk = false; return new ReadableStream({ @@ -108,9 +102,7 @@ export async function renderToReadableStream( (async () => { try { - for await (const chunk of renderAstroTemplateResult(templateResult)) { - destination.write(chunk); - } + await templateResult.render(destination); controller.close(); } catch (e) { // 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, }); } - 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; } - -// 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); - } - } -} diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts index 206f138cc..48d8143df 100644 --- a/packages/astro/src/runtime/server/render/common.ts +++ b/packages/astro/src/runtime/server/render/common.ts @@ -1,7 +1,7 @@ import type { SSRResult } from '../../../@types/astro'; import type { RenderInstruction } from './types.js'; -import { HTMLBytes, markHTMLString } from '../escape.js'; +import { HTMLBytes, HTMLString, markHTMLString } from '../escape.js'; import { determineIfNeedsHydrationScript, determinesIfNeedsDirectiveScript, @@ -11,12 +11,32 @@ import { import { renderAllHeadContent } from './head.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 { /** * 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; } 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. // 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. -export function stringifyChunk( +function stringifyChunk( result: SSRResult, - chunk: string | SlotString | RenderInstruction + chunk: string | HTMLString | SlotString | RenderInstruction ): string { if (typeof (chunk as any).type === 'string') { const instruction = chunk as RenderInstruction; @@ -89,27 +109,7 @@ export function stringifyChunk( } } -export class HTMLParts { - 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) { +export function chunkToString(result: SSRResult, chunk: Exclude) { if (ArrayBuffer.isView(chunk)) { return decoder.decode(chunk); } else { @@ -119,7 +119,7 @@ export function chunkToString(result: SSRResult, chunk: string | HTMLBytes | Ren export function chunkToByteArray( result: SSRResult, - chunk: string | HTMLBytes | RenderInstruction + chunk: Exclude ): Uint8Array { if (ArrayBuffer.isView(chunk)) { return chunk as Uint8Array; @@ -129,3 +129,7 @@ export function chunkToByteArray( 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'; +} diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts index 4eacafe80..de36b0ac9 100644 --- a/packages/astro/src/runtime/server/render/component.ts +++ b/packages/astro/src/runtime/server/render/component.ts @@ -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 { AstroError, AstroErrorData } from '../../../core/errors/index.js'; @@ -10,16 +15,23 @@ import { isPromise } from '../util.js'; import { createAstroComponentInstance, isAstroComponentFactory, - isAstroComponentInstance, - renderAstroTemplateResult, renderTemplate, - type AstroComponentInstance, + type AstroComponentFactory, } 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 { renderSlotToString, renderSlots, type ComponentSlots } from './slot.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']]); function guessRenderers(componentUrl?: string): string[] { @@ -67,7 +79,7 @@ async function renderFrameworkComponent( Component: unknown, _props: Record, slots: any = {} -): Promise { +): Promise { if (!Component && !_props['client:only']) { 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?` @@ -134,9 +146,17 @@ async function renderFrameworkComponent( } if (!renderer && typeof HTMLElement === 'function' && componentIsHTMLElement(Component)) { - const output = renderHTMLElement(result, Component as typeof HTMLElement, _props, slots); - - return output; + const output = await renderHTMLElement( + result, + Component as typeof HTMLElement, + _props, + slots + ); + return { + render(destination) { + destination.write(output); + }, + }; } } else { // 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 🙄 const Tag = sanitizeElementName(Component); const childSlots = Object.values(children).join(''); - const iterable = renderAstroTemplateResult( - await renderTemplate`<${Tag}${internalSpreadAttributes(props)}${markHTMLString( - childSlots === '' && voidElementNames.test(Tag) ? `/>` : `>${childSlots}` - )}` - ); + + const renderTemplateResult = renderTemplate`<${Tag}${internalSpreadAttributes( + props + )}${markHTMLString( + childSlots === '' && voidElementNames.test(Tag) ? `/>` : `>${childSlots}` + )}`; + html = ''; - for await (const chunk of iterable) { - html += chunk; - } + const destination: RenderDestination = { + write(chunk) { + if (chunk instanceof Response) return; + html += chunkToString(result, chunk); + }, + }; + await renderTemplateResult.render(destination); } if (!hydration) { - return (async function* () { - if (slotInstructions) { - yield* slotInstructions; - } - - if (isPage || renderer?.name === 'astro:jsx') { - yield html; - } else if (html && html.length > 0) { - yield markHTMLString( - removeStaticAstroSlot(html, renderer?.ssr?.supportsAstroStaticSlot ?? false) - ); - } else { - yield ''; - } - })(); + return { + render(destination) { + // If no hydration is needed, start rendering the html and return + if (slotInstructions) { + for (const instruction of slotInstructions) { + destination.write(instruction); + } + } + if (isPage || renderer?.name === 'astro:jsx') { + destination.write(html); + } else if (html && html.length > 0) { + destination.write( + markHTMLString( + removeStaticAstroSlot(html, renderer?.ssr?.supportsAstroStaticSlot ?? false) + ) + ); + } + }, + }; } // 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'] = ''; } - async function* renderAll() { - if (slotInstructions) { - yield* slotInstructions; - } - yield { type: 'directive', hydration, result }; - yield markHTMLString(renderElement('astro-island', island, false)); - } - - return renderAll(); + return { + render(destination) { + // Render the html + if (slotInstructions) { + for (const instruction of slotInstructions) { + destination.write(instruction); + } + } + destination.write({ type: 'directive', hydration }); + destination.write(markHTMLString(renderElement('astro-island', island, false))); + }, + }; } function sanitizeElementName(tag: string) { @@ -349,12 +382,17 @@ function sanitizeElementName(tag: string) { return tag.trim().split(unsafe)[0].trim(); } -async function renderFragmentComponent(result: SSRResult, slots: ComponentSlots = {}) { +async function renderFragmentComponent( + result: SSRResult, + slots: ComponentSlots = {} +): Promise { const children = await renderSlotToString(result, slots?.default); - if (children == null) { - return children; - } - return markHTMLString(children); + return { + render(destination) { + if (children == null) return; + destination.write(children); + }, + }; } async function renderHTMLComponent( @@ -362,54 +400,136 @@ async function renderHTMLComponent( Component: unknown, _props: Record, slots: any = {} -) { +): Promise { const { slotInstructions, children } = await renderSlots(result, slots); const html = (Component as any)({ slots: children }); 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, + slots: any = {} +): Promise { + 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, displayName: string, Component: unknown, props: Record, slots: any = {} -): Promise | ComponentIterable | AstroComponentInstance { +): Promise { if (isPromise(Component)) { - return Promise.resolve(Component).then((Unwrapped) => { - return renderComponent(result, displayName, Unwrapped, props, slots) as any; - }); + Component = await Component; } if (isFragmentComponent(Component)) { - return renderFragmentComponent(result, slots); + return await renderFragmentComponent(result, slots); } // .html components if (isHTMLComponent(Component)) { - return renderHTMLComponent(result, Component, props, slots); + return await renderHTMLComponent(result, Component, props, slots); } 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, displayName: string, Component: unknown, props: Record, - slots: any = {} -): Promise | ComponentIterable { - const renderResult = renderComponent(result, displayName, Component, props, slots); - if (isAstroComponentInstance(renderResult)) { - return renderResult.render(); + slots: any = {}, + isPage = false, + route?: RouteData +): Promise { + 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 (!/' : '\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]; } diff --git a/packages/astro/src/runtime/server/render/dom.ts b/packages/astro/src/runtime/server/render/dom.ts index 803f29995..1d0ea192f 100644 --- a/packages/astro/src/runtime/server/render/dom.ts +++ b/packages/astro/src/runtime/server/render/dom.ts @@ -13,7 +13,7 @@ export async function renderHTMLElement( constructor: typeof HTMLElement, props: any, slots: any -) { +): Promise { const name = getHTMLElementName(constructor); let attrHTML = ''; diff --git a/packages/astro/src/runtime/server/render/index.ts b/packages/astro/src/runtime/server/render/index.ts index d34bdd6c7..8a5376797 100644 --- a/packages/astro/src/runtime/server/render/index.ts +++ b/packages/astro/src/runtime/server/render/index.ts @@ -1,12 +1,7 @@ export type { AstroComponentFactory, AstroComponentInstance } from './astro/index'; -export { - createHeadAndContent, - renderAstroTemplateResult, - renderTemplate, - renderToString, -} from './astro/index.js'; -export { Fragment, Renderer, stringifyChunk } from './common.js'; -export { renderComponent, renderComponentToIterable } from './component.js'; +export { createHeadAndContent, renderTemplate, renderToString } from './astro/index.js'; +export { Fragment, Renderer, chunkToString, chunkToByteArray } from './common.js'; +export { renderComponent, renderComponentToString } from './component.js'; export { renderHTMLElement } from './dom.js'; export { maybeRenderHead, renderHead } from './head.js'; export { renderPage } from './page.js'; diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts index 025b0b9e3..cabbe8dae 100644 --- a/packages/astro/src/runtime/server/render/page.ts +++ b/packages/astro/src/runtime/server/render/page.ts @@ -1,50 +1,11 @@ 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 { AstroError } from '../../../core/errors/index.js'; -import { isHTMLString } from '../escape.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 { HTMLParts, 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 -): Promise { - const parts = new HTMLParts(); - let i = 0; - for await (const chunk of iterable) { - if (isHTMLString(chunk)) { - if (i === 0) { - i++; - if (!/' : '\n'}`, result); - if (onDocTypeInjection) { - await onDocTypeInjection(parts); - } - } - } - } - parts.append(chunk, result); - } - return parts.toArrayBuffer(); -} +import { encoder } from './common.js'; export async function renderPage( result: SSRResult, @@ -52,49 +13,25 @@ export async function renderPage( props: any, children: any, streaming: boolean, - route?: RouteData | undefined + route?: RouteData ): Promise { if (!isAstroComponentFactory(componentFactory)) { result._metadata.headInTree = result.componentMetadata.get((componentFactory as any).moduleId)?.containsHead ?? false; + const pageProps: Record = { ...(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( - result, - componentFactory.name, - componentFactory, - pageProps, - null - ); - if (isAstroComponentInstance(renderResult)) { - output = renderResult.render(); - } else { - output = renderResult; - } - } catch (e) { - if (AstroError.is(e) && !e.loc) { - e.setLocation({ - file: route?.component, - }); - } + const str = await renderComponentToString( + result, + componentFactory.name, + componentFactory, + pageProps, + null, + true, + route + ); - throw e; - } - - // Accumulate the HTML string and append the head if necessary. - const bytes = await iterableToHTMLBytes(result, output, async (parts) => { - parts.append(head, result); - }); + const bytes = encoder.encode(str); return new Response(bytes, { headers: new Headers([ @@ -103,6 +40,7 @@ export async function renderPage( ]), }); } + // Mark if this page component contains a within its tree. If it does // We avoid implicit head injection entirely. result._metadata.headInTree = diff --git a/packages/astro/src/runtime/server/render/slot.ts b/packages/astro/src/runtime/server/render/slot.ts index 152230ba9..daae87a80 100644 --- a/packages/astro/src/runtime/server/render/slot.ts +++ b/packages/astro/src/runtime/server/render/slot.ts @@ -4,6 +4,7 @@ import type { RenderInstruction } from './types.js'; import { HTMLString, markHTMLString } from '../escape.js'; import { renderChild } from './any.js'; +import { chunkToString, type RenderDestination, type RenderInstance } from './common.js'; type RenderTemplateResult = ReturnType; export type ComponentSlots = Record; @@ -27,19 +28,19 @@ export function isSlotString(str: string): str is any { return !!(str as any)[slotString]; } -export async function* renderSlot( +export function renderSlot( result: SSRResult, slotted: ComponentSlotValue | RenderTemplateResult, fallback?: ComponentSlotValue | RenderTemplateResult -): AsyncGenerator { - if (slotted) { - let iterator = renderChild(typeof slotted === 'function' ? slotted(result) : slotted); - yield* iterator; - } - - if (fallback && !slotted) { - yield* renderSlot(result, fallback); +): RenderInstance { + if (!slotted && fallback) { + return renderSlot(result, fallback); } + return { + async render(destination) { + await renderChild(destination, typeof slotted === 'function' ? slotted(result) : slotted); + }, + }; } export async function renderSlotToString( @@ -49,17 +50,21 @@ export async function renderSlotToString( ): Promise { let content = ''; let instructions: null | RenderInstruction[] = null; - let iterator = renderSlot(result, slotted, fallback); - for await (const chunk of iterator) { - if (typeof chunk.type === 'string') { - if (instructions === null) { - instructions = []; + const temporaryDestination: RenderDestination = { + write(chunk) { + if (chunk instanceof Response) return; + if (typeof chunk === 'object' && 'type' in chunk && typeof chunk.type === 'string') { + 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)); } diff --git a/packages/astro/src/runtime/server/render/util.ts b/packages/astro/src/runtime/server/render/util.ts index f422f66d5..e007fe6f1 100644 --- a/packages/astro/src/runtime/server/render/util.ts +++ b/packages/astro/src/runtime/server/render/util.ts @@ -145,145 +145,3 @@ export function renderElement( } return `<${name}${internalSpreadAttributes(props, shouldEscape)}>${children}`; } - -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(iterators: AsyncIterable[]): AsyncIterable[] { - // 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; - #queue = new Queue>(); - #error: any = undefined; - #next: Promise> | undefined; - /** - * Whether the proxy is running in buffering or pass-through mode - */ - #isBuffering = false; - #gen: AsyncIterator | undefined = undefined; - #isStarted = false; - - constructor(iterable: AsyncIterable) { - 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 | 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 { - item: T; - next?: QueueItem; -} - -/** - * Basis Queue implementation with a linked list - */ -class Queue { - head: QueueItem | undefined = undefined; - tail: QueueItem | 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; - } -} diff --git a/packages/astro/test/streaming.test.js b/packages/astro/test/streaming.test.js index c7b835de1..e3627d7ba 100644 --- a/packages/astro/test/streaming.test.js +++ b/packages/astro/test/streaming.test.js @@ -48,7 +48,7 @@ describe('Streaming', () => { let chunk = decoder.decode(bytes); chunks.push(chunk); } - expect(chunks.length).to.equal(3); + expect(chunks.length).to.equal(2); }); });