diff --git a/.changeset/clever-garlics-relate.md b/.changeset/clever-garlics-relate.md new file mode 100644 index 000000000000..2e213df09e1c --- /dev/null +++ b/.changeset/clever-garlics-relate.md @@ -0,0 +1,5 @@ +--- +'astro': minor +--- + +Render sibling components in parallel 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 fc1442422505..2433596bdcad 100644 --- a/packages/astro/src/runtime/server/render/astro/render-template.ts +++ b/packages/astro/src/runtime/server/render/astro/render-template.ts @@ -3,6 +3,7 @@ import type { RenderInstruction } from '../types'; import { HTMLBytes, markHTMLString } from '../../escape.js'; import { isPromise } from '../../util.js'; import { renderChild } from '../any.js'; +import { EagerAsyncIterableIterator } from '../util.js'; const renderTemplateResultSym = Symbol.for('astro.renderTemplateResult'); @@ -35,12 +36,23 @@ export class RenderTemplateResult { async *[Symbol.asyncIterator]() { const { htmlParts, expressions } = this; + let iterables: Array = []; + // all async iterators start running in non-buffered mode to avoid useless caching + for (let i = 0; i < htmlParts.length; i++) { + iterables.push(new EagerAsyncIterableIterator(renderChild(expressions[i]))); + } + // 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 + setTimeout(() => { + // buffer all iterators that haven't started yet + iterables.forEach((it) => !it.isStarted() && it.buffer()); + }, 0); for (let i = 0; i < htmlParts.length; i++) { const html = htmlParts[i]; - const expression = expressions[i]; + const iterable = iterables[i]; yield markHTMLString(html); - yield* renderChild(expression); + yield* iterable; } } } diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts index f4a02a75f36a..45ab4236ef8e 100644 --- a/packages/astro/src/runtime/server/render/page.ts +++ b/packages/astro/src/runtime/server/render/page.ts @@ -157,6 +157,15 @@ export async function renderPage( } } + // `chunk` might be a Response that contains a redirect, + // that was rendered eagerly and therefore bypassed the early check + // whether headers can still be modified. In that case, throw an error + if (chunk instanceof Response) { + throw new AstroError({ + ...AstroErrorData.ResponseSentError, + }); + } + const bytes = chunkToByteArray(result, chunk); controller.enqueue(bytes); i++; diff --git a/packages/astro/src/runtime/server/render/util.ts b/packages/astro/src/runtime/server/render/util.ts index 37008abbb769..5ddd97716bbc 100644 --- a/packages/astro/src/runtime/server/render/util.ts +++ b/packages/astro/src/runtime/server/render/util.ts @@ -138,3 +138,111 @@ export function renderElement( } return `<${name}${internalSpreadAttributes(props, shouldEscape)}>${children}`; } + +// 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/fixtures/parallel/package.json b/packages/astro/test/fixtures/parallel/package.json new file mode 100644 index 000000000000..299d904d1e94 --- /dev/null +++ b/packages/astro/test/fixtures/parallel/package.json @@ -0,0 +1,12 @@ +{ + "name": "@test/parallel-components", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "astro build", + "dev": "astro dev" + }, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/parallel/src/components/Delayed.astro b/packages/astro/test/fixtures/parallel/src/components/Delayed.astro new file mode 100644 index 000000000000..9f540bd70fae --- /dev/null +++ b/packages/astro/test/fixtures/parallel/src/components/Delayed.astro @@ -0,0 +1,11 @@ +--- +const { ms } = Astro.props +const start = new Date().valueOf(); +await new Promise(res => setTimeout(res, ms)); +const finished = new Date().valueOf(); +--- +
+

{ms}ms Delayed

+ { start } + { finished } +
diff --git a/packages/astro/test/fixtures/parallel/src/pages/index.astro b/packages/astro/test/fixtures/parallel/src/pages/index.astro new file mode 100644 index 000000000000..952e9efcebc6 --- /dev/null +++ b/packages/astro/test/fixtures/parallel/src/pages/index.astro @@ -0,0 +1,8 @@ +--- +import Delayed from '../components/Delayed.astro' +--- + + + + + diff --git a/packages/astro/test/fixtures/streaming/src/pages/slot.astro b/packages/astro/test/fixtures/streaming/src/pages/slot.astro index ea918cc6f8ca..5cf79fb61dc0 100644 --- a/packages/astro/test/fixtures/streaming/src/pages/slot.astro +++ b/packages/astro/test/fixtures/streaming/src/pages/slot.astro @@ -15,7 +15,7 @@ import Wait from '../components/Wait.astro';

Section content

Next section

- +

Section content

Paragraph 3

diff --git a/packages/astro/test/parallel.js b/packages/astro/test/parallel.js new file mode 100644 index 000000000000..2ad50f2d6ca2 --- /dev/null +++ b/packages/astro/test/parallel.js @@ -0,0 +1,36 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './test-utils.js'; + +describe('Component parallelization', () => { + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/parallel/', + }); + await fixture.build(); + }); + + it('renders fast', async () => { + let html = await fixture.readFile('/index.html'); + let $ = cheerio.load(html); + + const startTimes = Array.from($('.start')).map((element) => Number(element.children[0].data)); + const finishTimes = Array.from($('.finished')).map((element) => + Number(element.children[0].data) + ); + + let renderStartWithin = Math.max(...startTimes) - Math.min(...startTimes); + expect(renderStartWithin).to.be.lessThan( + 10, // in theory, this should be 0, so 10ms tolerance + "The components didn't start rendering in parallel" + ); + + const totalRenderTime = Math.max(...finishTimes) - Math.min(...startTimes); + expect(totalRenderTime).to.be.lessThan( + 60, // max component delay is 40ms + 'The total render time was significantly longer than the max component delay' + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 224a78ee6c9e..30368328c153 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2904,6 +2904,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/parallel: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/postcss: dependencies: '@astrojs/solid-js':