summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Johannes Spohr <johannes.spohr@futurice.com> 2023-05-18 15:54:23 +0200
committerGravatar GitHub <noreply@github.com> 2023-05-18 09:54:23 -0400
commite186ecc5e292de8c6a2c441a2d588512c0813068 (patch)
treecfa7d439eee2a04dc55fac09f9f361a49e3381f2
parent05695ab202264ea5c6c3bbaf0a6711e8af2cc072 (diff)
downloadastro-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.md5
-rw-r--r--packages/astro/src/runtime/server/render/astro/render-template.ts16
-rw-r--r--packages/astro/src/runtime/server/render/page.ts9
-rw-r--r--packages/astro/src/runtime/server/render/util.ts108
-rw-r--r--packages/astro/test/fixtures/parallel/package.json12
-rw-r--r--packages/astro/test/fixtures/parallel/src/components/Delayed.astro11
-rw-r--r--packages/astro/test/fixtures/parallel/src/pages/index.astro8
-rw-r--r--packages/astro/test/fixtures/streaming/src/pages/slot.astro2
-rw-r--r--packages/astro/test/parallel.js36
-rw-r--r--pnpm-lock.yaml6
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':