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
This commit is contained in:
parent
05695ab202
commit
e186ecc5e2
10 changed files with 210 additions and 3 deletions
5
.changeset/clever-garlics-relate.md
Normal file
5
.changeset/clever-garlics-relate.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': minor
|
||||
---
|
||||
|
||||
Render sibling components in parallel
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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++;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
12
packages/astro/test/fixtures/parallel/package.json
vendored
Normal file
12
packages/astro/test/fixtures/parallel/package.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "@test/parallel-components",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "astro build",
|
||||
"dev": "astro dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
11
packages/astro/test/fixtures/parallel/src/components/Delayed.astro
vendored
Normal file
11
packages/astro/test/fixtures/parallel/src/components/Delayed.astro
vendored
Normal file
|
@ -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>
|
8
packages/astro/test/fixtures/parallel/src/pages/index.astro
vendored
Normal file
8
packages/astro/test/fixtures/parallel/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
import Delayed from '../components/Delayed.astro'
|
||||
---
|
||||
|
||||
<Delayed ms={30} />
|
||||
<Delayed ms={20} />
|
||||
<Delayed ms={40} />
|
||||
<Delayed ms={10} />
|
|
@ -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>
|
||||
|
|
36
packages/astro/test/parallel.js
Normal file
36
packages/astro/test/parallel.js
Normal file
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
@ -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':
|
||||
|
|
Loading…
Add table
Reference in a new issue