Fix Astro components parent-child render order (#8187)

This commit is contained in:
Bjorn Lu 2023-08-23 20:29:34 +08:00 committed by GitHub
parent 1e8942c438
commit 273335cb01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 123 additions and 27 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Fix Astro components parent-child render order

View file

@ -2,6 +2,7 @@ import { escapeHTML, isHTMLString, markHTMLString } from '../escape.js';
import { isAstroComponentInstance, isRenderTemplateResult } from './astro/index.js';
import { isRenderInstance, type RenderDestination } from './common.js';
import { SlotString } from './slot.js';
import { renderToBufferDestination } from './util.js';
export async function renderChild(destination: RenderDestination, child: any) {
child = await child;
@ -10,8 +11,14 @@ export async function renderChild(destination: RenderDestination, child: any) {
} else if (isHTMLString(child)) {
destination.write(child);
} else if (Array.isArray(child)) {
for (const c of child) {
await renderChild(destination, c);
// Render all children eagerly and in parallel
const childRenders = child.map((c) => {
return renderToBufferDestination((bufferDestination) => {
return renderChild(bufferDestination, c);
});
});
for (const childRender of childRenders) {
await childRender.renderToFinalDestination(destination);
}
} else if (typeof child === 'function') {
// Special: If a child is a function, call it automatically.

View file

@ -2,6 +2,7 @@ import { markHTMLString } from '../../escape.js';
import { isPromise } from '../../util.js';
import { renderChild } from '../any.js';
import type { RenderDestination } from '../common.js';
import { renderToBufferDestination } from '../util.js';
const renderTemplateResultSym = Symbol.for('astro.renderTemplateResult');
@ -32,14 +33,23 @@ export class RenderTemplateResult {
}
async render(destination: RenderDestination) {
// Render all expressions eagerly and in parallel
const expRenders = this.expressions.map((exp) => {
return renderToBufferDestination((bufferDestination) => {
// Skip render if falsy, except the number 0
if (exp || exp === 0) {
return renderChild(bufferDestination, exp);
}
});
});
for (let i = 0; i < this.htmlParts.length; i++) {
const html = this.htmlParts[i];
const exp = this.expressions[i];
const expRender = expRenders[i];
destination.write(markHTMLString(html));
// Skip render if falsy, except the number 0
if (exp || exp === 0) {
await renderChild(destination, exp);
if (expRender) {
await expRender.renderToFinalDestination(destination);
}
}
}

View file

@ -37,9 +37,11 @@ export interface RenderDestination {
}
export interface RenderInstance {
render(destination: RenderDestination): Promise<void> | void;
render: RenderFunction;
}
export type RenderFunction = (destination: RenderDestination) => Promise<void> | void;
export const Fragment = Symbol.for('astro:fragment');
export const Renderer = Symbol.for('astro:renderer');

View file

@ -23,7 +23,6 @@ import {
Renderer,
chunkToString,
type RenderDestination,
type RenderDestinationChunk,
type RenderInstance,
} from './common.js';
import { componentIsHTMLElement, renderHTMLElement } from './dom.js';
@ -421,27 +420,12 @@ function renderAstroComponent(
slots: any = {}
): RenderInstance {
const instance = createAstroComponentInstance(result, displayName, Component, props, slots);
// Eagerly render the component so they are rendered in parallel.
// Render to buffer for now until our returned render function is called.
const bufferChunks: RenderDestinationChunk[] = [];
const bufferDestination: RenderDestination = {
write: (chunk) => bufferChunks.push(chunk),
};
// Don't await for the render to finish to not block streaming
const renderPromise = instance.render(bufferDestination);
return {
async render(destination) {
// Write the buffered chunks to the real destination
for (const chunk of bufferChunks) {
destination.write(chunk);
}
// Free memory
bufferChunks.length = 0;
// Re-assign the real destination so `instance.render` will continue and write to the new destination
bufferDestination.write = (chunk) => destination.write(chunk);
await renderPromise;
// NOTE: This render call can't be pre-invoked outside of this function as it'll also initialize the slots
// recursively, which causes each Astro components in the tree to be called bottom-up, and is incorrect.
// The slots are initialized eagerly for head propagation.
await instance.render(destination);
},
};
}

View file

@ -1,4 +1,5 @@
import type { SSRElement } from '../../../@types/astro';
import type { RenderDestination, RenderDestinationChunk, RenderFunction } from './common.js';
import { HTMLString, markHTMLString } from '../escape.js';
import { serializeListValue } from '../util.js';
@ -145,3 +146,56 @@ export function renderElement(
}
return `<${name}${internalSpreadAttributes(props, shouldEscape)}>${children}</${name}>`;
}
/**
* Executes the `bufferRenderFunction` to prerender it into a buffer destination, and return a promise
* with an object containing the `renderToFinalDestination` function to flush the buffer to the final
* destination.
*
* @example
* ```ts
* // Render components in parallel ahead of time
* const finalRenders = [ComponentA, ComponentB].map((comp) => {
* return renderToBufferDestination(async (bufferDestination) => {
* await renderComponentToDestination(bufferDestination);
* });
* });
* // Render array of components serially
* for (const finalRender of finalRenders) {
* await finalRender.renderToFinalDestination(finalDestination);
* }
* ```
*/
export function renderToBufferDestination(bufferRenderFunction: RenderFunction): {
renderToFinalDestination: RenderFunction;
} {
// Keep chunks in memory
const bufferChunks: RenderDestinationChunk[] = [];
const bufferDestination: RenderDestination = {
write: (chunk) => bufferChunks.push(chunk),
};
// Don't await for the render to finish to not block streaming
const renderPromise = bufferRenderFunction(bufferDestination);
// Return a closure that writes the buffered chunk
return {
async renderToFinalDestination(destination) {
// Write the buffered chunks to the real destination
for (const chunk of bufferChunks) {
destination.write(chunk);
}
// NOTE: We don't empty `bufferChunks` after it's written as benchmarks show
// that it causes poorer performance, likely due to forced memory re-allocation,
// instead of letting the garbage collector handle it automatically.
// (Unsure how this affects on limited memory machines)
// Re-assign the real destination so `instance.render` will continue and write to the new destination
bufferDestination.write = (chunk) => destination.write(chunk);
// Wait for render to finish entirely
await renderPromise;
},
};
}

View file

@ -102,6 +102,12 @@ describe('Astro basics', () => {
// will have already erred by now, but add test anyway
expect(await fixture.readFile('/special-“characters” -in-file/index.html')).to.be.ok;
});
it('renders the components top-down', async () => {
const html = await fixture.readFile('/order/index.html');
const $ = cheerio.load(html);
expect($('#rendered-order').text()).to.eq('Rendered order: A, B')
})
});
it('Supports void elements whose name is a string (#2062)', async () => {

View file

@ -0,0 +1,7 @@
---
globalThis.__ASTRO_TEST_ORDER__ ??= []
globalThis.__ASTRO_TEST_ORDER__.push('A')
---
<p>A</p>
<slot />

View file

@ -0,0 +1,7 @@
---
globalThis.__ASTRO_TEST_ORDER__ ??= []
globalThis.__ASTRO_TEST_ORDER__.push('B')
---
<p>B</p>
<slot />

View file

@ -0,0 +1 @@
<p id="rendered-order">Rendered order: {() => (globalThis.__ASTRO_TEST_ORDER__ ?? []).join(', ')}</p>

View file

@ -0,0 +1,13 @@
---
import OrderA from "../components/OrderA.astro";
import OrderB from "../components/OrderB.astro";
import OrderLast from "../components/OrderLast.astro";
globalThis.__ASTRO_TEST_ORDER__ = [];
---
<OrderA>
<OrderB>
<OrderLast />
</OrderB>
</OrderA>