diff options
author | 2023-05-18 15:54:23 +0200 | |
---|---|---|
committer | 2023-05-18 09:54:23 -0400 | |
commit | e186ecc5e292de8c6a2c441a2d588512c0813068 (patch) | |
tree | cfa7d439eee2a04dc55fac09f9f361a49e3381f2 | |
parent | 05695ab202264ea5c6c3bbaf0a6711e8af2cc072 (diff) | |
download | astro-e186ecc5e292de8c6a2c441a2d588512c0813068.tar.gz astro-e186ecc5e292de8c6a2c441a2d588512c0813068.tar.zst astro-e186ecc5e292de8c6a2c441a2d588512c0813068.zip |
Parallelize rendering of sibling components to avoid async waterfalls (#7071)
* Parallelize rendering of sibling components to avoid async waterfalls
* Catch and rethrow errors when eagerly rendering children
* Catch `Response` in rendering stage and throw error
* Add changeset
* Fix test error message
* Improve unit tests
* Start async generators in non-buffered mode, and only start buffering once a component doesn't resolve immediatly
* Add more documentation
-rw-r--r-- | .changeset/clever-garlics-relate.md | 5 | ||||
-rw-r--r-- | packages/astro/src/runtime/server/render/astro/render-template.ts | 16 | ||||
-rw-r--r-- | packages/astro/src/runtime/server/render/page.ts | 9 | ||||
-rw-r--r-- | packages/astro/src/runtime/server/render/util.ts | 108 | ||||
-rw-r--r-- | packages/astro/test/fixtures/parallel/package.json | 12 | ||||
-rw-r--r-- | packages/astro/test/fixtures/parallel/src/components/Delayed.astro | 11 | ||||
-rw-r--r-- | packages/astro/test/fixtures/parallel/src/pages/index.astro | 8 | ||||
-rw-r--r-- | packages/astro/test/fixtures/streaming/src/pages/slot.astro | 2 | ||||
-rw-r--r-- | packages/astro/test/parallel.js | 36 | ||||
-rw-r--r-- | pnpm-lock.yaml | 6 |
10 files changed, 210 insertions, 3 deletions
diff --git a/.changeset/clever-garlics-relate.md b/.changeset/clever-garlics-relate.md new file mode 100644 index 000000000..2e213df09 --- /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 fc1442422..2433596bd 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<EagerAsyncIterableIterator> = []; + // 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 f4a02a75f..45ab4236e 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 37008abbb..5ddd97716 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}</${name}>`; } + +// 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; + } +} diff --git a/packages/astro/test/fixtures/parallel/package.json b/packages/astro/test/fixtures/parallel/package.json new file mode 100644 index 000000000..299d904d1 --- /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 000000000..9f540bd70 --- /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(); +--- +<section> + <h1>{ms}ms Delayed</h1> + <span class="start">{ start }</span> + <span class="finished">{ finished }</span> +</section> 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 000000000..952e9efce --- /dev/null +++ b/packages/astro/test/fixtures/parallel/src/pages/index.astro @@ -0,0 +1,8 @@ +--- +import Delayed from '../components/Delayed.astro' +--- + +<Delayed ms={30} /> +<Delayed ms={20} /> +<Delayed ms={40} /> +<Delayed ms={10} /> diff --git a/packages/astro/test/fixtures/streaming/src/pages/slot.astro b/packages/astro/test/fixtures/streaming/src/pages/slot.astro index ea918cc6f..5cf79fb61 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'; <p>Section content</p> </Wait> <h2>Next section</h2> - <Wait ms={50}> + <Wait ms={60}> <p>Section content</p> </Wait> <p>Paragraph 3</p> diff --git a/packages/astro/test/parallel.js b/packages/astro/test/parallel.js new file mode 100644 index 000000000..2ad50f2d6 --- /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 224a78ee6..30368328c 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': |