From 3b669555d7ab9da5427e7b7037699d4f905d3536 Mon Sep 17 00:00:00 2001 From: Matthew Lymer Date: Wed, 12 Feb 2025 09:10:41 -0500 Subject: [PATCH] fix(astro): Improve ssr performance (astro#11454) (#13195) * Add alternate rendering paths to avoid use of Promise * Add run commands * Remove promise from synchronous components * Create makefile and update loadtest * Rename functions, fix implementation of renderArray * More performance updates * Minor code cleanup * incremental * Add initial rendering tests * WIP - bad tests * Fix tests * Make the tests good, even * Add more tests * Finish tests * Add test to ensure rendering order * Finalize pr * Remove code not intended for PR * Add changeset * Revert change to minimal example * Fix linting and formatting errors * Address code review comments * Fix mishandling of uncaught synchronous renders * Update .changeset/shaggy-deers-destroy.md --------- Co-authored-by: Matt Kane Co-authored-by: Emanuele Stoppa --- .changeset/shaggy-deers-destroy.md | 5 + .../astro/src/runtime/server/render/any.ts | 152 +++++++-- .../runtime/server/render/astro/instance.ts | 27 +- .../server/render/astro/render-template.ts | 41 ++- .../src/runtime/server/render/astro/render.ts | 23 +- .../src/runtime/server/render/component.ts | 19 +- .../astro/src/runtime/server/render/util.ts | 75 +++-- .../astro/test/units/render/rendering.test.js | 312 ++++++++++++++++++ 8 files changed, 561 insertions(+), 93 deletions(-) create mode 100644 .changeset/shaggy-deers-destroy.md create mode 100644 packages/astro/test/units/render/rendering.test.js diff --git a/.changeset/shaggy-deers-destroy.md b/.changeset/shaggy-deers-destroy.md new file mode 100644 index 000000000000..00f6c581d287 --- /dev/null +++ b/.changeset/shaggy-deers-destroy.md @@ -0,0 +1,5 @@ +--- +'astro': minor +--- + +Improves SSR performance for synchronous components by avoiding the use of Promises. With this change, SSR rendering of on-demand pages can be up to 4x faster. diff --git a/packages/astro/src/runtime/server/render/any.ts b/packages/astro/src/runtime/server/render/any.ts index e7e9f0b56e9e..33a68f93e6a8 100644 --- a/packages/astro/src/runtime/server/render/any.ts +++ b/packages/astro/src/runtime/server/render/any.ts @@ -3,52 +3,134 @@ import { isPromise } from '../util.js'; import { isAstroComponentInstance, isRenderTemplateResult } from './astro/index.js'; import { type RenderDestination, isRenderInstance } from './common.js'; import { SlotString } from './slot.js'; -import { renderToBufferDestination } from './util.js'; +import { createBufferedRenderer } from './util.js'; -export async function renderChild(destination: RenderDestination, child: any) { +export function renderChild(destination: RenderDestination, child: any): void | Promise { if (isPromise(child)) { - child = await child; + return child.then((x) => renderChild(destination, x)); } + if (child instanceof SlotString) { destination.write(child); - } else if (isHTMLString(child)) { + return; + } + + if (isHTMLString(child)) { destination.write(child); - } else if (Array.isArray(child)) { - // Render all children eagerly and in parallel - const childRenders = child.map((c) => { - return renderToBufferDestination((bufferDestination) => { - return renderChild(bufferDestination, c); - }); - }); - for (const childRender of childRenders) { - if (!childRender) continue; - await childRender.renderToFinalDestination(destination); - } - } else if (typeof child === 'function') { + return; + } + + if (Array.isArray(child)) { + return renderArray(destination, child); + } + + if (typeof child === 'function') { // Special: If a child is a function, call it automatically. // This lets you do {() => ...} without the extra boilerplate // of wrapping it in a function and calling it. - await renderChild(destination, child()); - } else if (typeof child === 'string') { - destination.write(markHTMLString(escapeHTML(child))); - } else if (!child && child !== 0) { + return renderChild(destination, child()); + } + + if (!child && child !== 0) { // do nothing, safe to ignore falsey values. - } else if (isRenderInstance(child)) { - await child.render(destination); - } else if (isRenderTemplateResult(child)) { - await child.render(destination); - } else if (isAstroComponentInstance(child)) { - await child.render(destination); - } else if (ArrayBuffer.isView(child)) { + return; + } + + if (typeof child === 'string') { + destination.write(markHTMLString(escapeHTML(child))); + return; + } + + if (isRenderInstance(child)) { + return child.render(destination); + } + + if (isRenderTemplateResult(child)) { + return child.render(destination); + } + + if (isAstroComponentInstance(child)) { + return child.render(destination); + } + + if (ArrayBuffer.isView(child)) { destination.write(child); - } else if ( - typeof child === 'object' && - (Symbol.asyncIterator in child || Symbol.iterator in child) - ) { - for await (const value of child) { - await renderChild(destination, value); + return; + } + + if (typeof child === 'object' && (Symbol.asyncIterator in child || Symbol.iterator in child)) { + if (Symbol.asyncIterator in child) { + return renderAsyncIterable(destination, child); } - } else { - destination.write(child); + + return renderIterable(destination, child); + } + + destination.write(child); +} + +function renderArray(destination: RenderDestination, children: any[]): void | Promise { + // Render all children eagerly and in parallel + const flushers = children.map((c) => { + return createBufferedRenderer(destination, (bufferDestination) => { + return renderChild(bufferDestination, c); + }); + }); + + const iterator = flushers[Symbol.iterator](); + + const iterate = (): void | Promise => { + for (;;) { + const { value: flusher, done } = iterator.next(); + + if (done) { + break; + } + + const result = flusher.flush(); + + if (isPromise(result)) { + return result.then(iterate); + } + } + }; + + return iterate(); +} + +function renderIterable( + destination: RenderDestination, + children: Iterable, +): void | Promise { + // although arrays and iterables may be similar, an iterable + // may be unbounded, so rendering all children eagerly may not + // be possible. + const iterator = (children[Symbol.iterator] as () => Iterator)(); + + const iterate = (): void | Promise => { + for (;;) { + const { value, done } = iterator.next(); + + if (done) { + break; + } + + const result = renderChild(destination, value); + + if (isPromise(result)) { + return result.then(iterate); + } + } + }; + + return iterate(); +} + +async function renderAsyncIterable( + destination: RenderDestination, + children: AsyncIterable, +): Promise { + for await (const value of children) { + await renderChild(destination, value); } } diff --git a/packages/astro/src/runtime/server/render/astro/instance.ts b/packages/astro/src/runtime/server/render/astro/instance.ts index aae13ca9e2b5..4c603366fbd3 100644 --- a/packages/astro/src/runtime/server/render/astro/instance.ts +++ b/packages/astro/src/runtime/server/render/astro/instance.ts @@ -1,5 +1,5 @@ import type { ComponentSlots } from '../slot.js'; -import type { AstroComponentFactory } from './factory.js'; +import type { AstroComponentFactory, AstroFactoryReturnValue } from './factory.js'; import type { SSRResult } from '../../../../types/public/internal.js'; import { isPromise } from '../../util.js'; @@ -46,9 +46,13 @@ export class AstroComponentInstance { } } - async init(result: SSRResult) { - if (this.returnValue !== undefined) return this.returnValue; + init(result: SSRResult) { + if (this.returnValue !== undefined) { + return this.returnValue; + } + this.returnValue = this.factory(result, this.props, this.slotValues); + // Save the resolved value after promise is resolved for optimization if (isPromise(this.returnValue)) { this.returnValue @@ -62,12 +66,21 @@ export class AstroComponentInstance { return this.returnValue; } - async render(destination: RenderDestination) { - const returnValue = await this.init(this.result); + render(destination: RenderDestination): void | Promise { + const returnValue = this.init(this.result); + + if (isPromise(returnValue)) { + return returnValue.then((x) => this.renderImpl(destination, x)); + } + + return this.renderImpl(destination, returnValue); + } + + private renderImpl(destination: RenderDestination, returnValue: AstroFactoryReturnValue) { if (isHeadAndContent(returnValue)) { - await returnValue.content.render(destination); + return returnValue.content.render(destination); } else { - await renderChild(destination, returnValue); + return renderChild(destination, returnValue); } } } 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 90d57fe01806..330368a6ab1b 100644 --- a/packages/astro/src/runtime/server/render/astro/render-template.ts +++ b/packages/astro/src/runtime/server/render/astro/render-template.ts @@ -2,7 +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'; +import { createBufferedRenderer } from '../util.js'; const renderTemplateResultSym = Symbol.for('astro.renderTemplateResult'); @@ -32,10 +32,10 @@ export class RenderTemplateResult { }); } - async render(destination: RenderDestination) { + render(destination: RenderDestination): void | Promise { // Render all expressions eagerly and in parallel - const expRenders = this.expressions.map((exp) => { - return renderToBufferDestination((bufferDestination) => { + const flushers = this.expressions.map((exp) => { + return createBufferedRenderer(destination, (bufferDestination) => { // Skip render if falsy, except the number 0 if (exp || exp === 0) { return renderChild(bufferDestination, exp); @@ -43,15 +43,34 @@ export class RenderTemplateResult { }); }); - for (let i = 0; i < this.htmlParts.length; i++) { - const html = this.htmlParts[i]; - const expRender = expRenders[i]; + let i = 0; - destination.write(markHTMLString(html)); - if (expRender) { - await expRender.renderToFinalDestination(destination); + const iterate = (): void | Promise => { + while (i < this.htmlParts.length) { + const html = this.htmlParts[i]; + const flusher = flushers[i]; + + // increment here due to potential return in + // Promise scenario + i++; + + if (html) { + // only write non-empty strings + + destination.write(markHTMLString(html)); + } + + if (flusher) { + const result = flusher.flush(); + + if (isPromise(result)) { + return result.then(iterate); + } + } } - } + }; + + return iterate(); } } diff --git a/packages/astro/src/runtime/server/render/astro/render.ts b/packages/astro/src/runtime/server/render/astro/render.ts index adc335495da1..745d707ac7f6 100644 --- a/packages/astro/src/runtime/server/render/astro/render.ts +++ b/packages/astro/src/runtime/server/render/astro/render.ts @@ -1,5 +1,6 @@ import { AstroError, AstroErrorData } from '../../../../core/errors/index.js'; import type { RouteData, SSRResult } from '../../../../types/public/internal.js'; +import { isPromise } from '../../util.js'; import { type RenderDestination, chunkToByteArray, chunkToString, encoder } from '../common.js'; import { promiseWithResolvers } from '../util.js'; import type { AstroComponentFactory } from './factory.js'; @@ -317,16 +318,13 @@ export async function renderToAsyncIterable( }, }; - const renderPromise = templateResult.render(destination); - renderPromise - .then(() => { - // Once rendering is complete, calling resolve() allows the iterator to finish running. - renderingComplete = true; - next?.resolve(); - }) + const renderResult = toPromise(() => templateResult.render(destination)); + + renderResult .catch((err) => { - // If an error occurs, save it in the scope so that we throw it when next() is called. error = err; + }) + .finally(() => { renderingComplete = true; next?.resolve(); }); @@ -339,3 +337,12 @@ export async function renderToAsyncIterable( }, }; } + +function toPromise(fn: () => T | Promise): Promise { + try { + const result = fn(); + return isPromise(result) ? result : Promise.resolve(result); + } catch (err) { + return Promise.reject(err); + } +} diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts index b3979f831790..f00234a7b756 100644 --- a/packages/astro/src/runtime/server/render/component.ts +++ b/packages/astro/src/runtime/server/render/component.ts @@ -446,29 +446,32 @@ function renderAstroComponent( } const instance = createAstroComponentInstance(result, displayName, Component, props, slots); + return { - async render(destination) { + render(destination: RenderDestination): Promise | void { // 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); + return instance.render(destination); }, }; } -export async function renderComponent( +export function renderComponent( result: SSRResult, displayName: string, Component: unknown, props: Record, slots: ComponentSlots = {}, -): Promise { +): RenderInstance | Promise { if (isPromise(Component)) { - Component = await Component.catch(handleCancellation); + return Component.catch(handleCancellation).then((x) => { + return renderComponent(result, displayName, x, props, slots); + }); } if (isFragmentComponent(Component)) { - return await renderFragmentComponent(result, slots).catch(handleCancellation); + return renderFragmentComponent(result, slots).catch(handleCancellation); } // Ensure directives (`class:list`) are processed @@ -476,14 +479,14 @@ export async function renderComponent( // .html components if (isHTMLComponent(Component)) { - return await renderHTMLComponent(result, Component, props, slots).catch(handleCancellation); + return renderHTMLComponent(result, Component, props, slots).catch(handleCancellation); } if (isAstroComponentFactory(Component)) { return renderAstroComponent(result, displayName, Component, props, slots); } - return await renderFrameworkComponent(result, displayName, Component, props, slots).catch( + return renderFrameworkComponent(result, displayName, Component, props, slots).catch( handleCancellation, ); diff --git a/packages/astro/src/runtime/server/render/util.ts b/packages/astro/src/runtime/server/render/util.ts index d693ad070f3a..1d1b1e17bfa2 100644 --- a/packages/astro/src/runtime/server/render/util.ts +++ b/packages/astro/src/runtime/server/render/util.ts @@ -3,6 +3,7 @@ import type { RenderDestination, RenderDestinationChunk, RenderFunction } from ' import { clsx } from 'clsx'; import type { SSRElement } from '../../../types/public/internal.js'; import { HTMLString, markHTMLString } from '../escape.js'; +import { isPromise } from '../util.js'; export const voidElementNames = /^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i; @@ -152,33 +153,54 @@ export function renderElement( const noop = () => {}; /** - * Renders into a buffer until `renderToFinalDestination` is called (which + * Renders into a buffer until `flush` is called (which * flushes the buffer) */ -class BufferedRenderer implements RenderDestination { +class BufferedRenderer implements RenderDestination, RendererFlusher { private chunks: RenderDestinationChunk[] = []; private renderPromise: Promise | void; - private destination?: RenderDestination; + private destination: RenderDestination; - public constructor(bufferRenderFunction: RenderFunction) { - this.renderPromise = bufferRenderFunction(this); - // Catch here in case it throws before `renderToFinalDestination` is called, - // to prevent an unhandled rejection. - Promise.resolve(this.renderPromise).catch(noop); + /** + * Determines whether buffer has been flushed + * to the final destination. + */ + private flushed = false; + + public constructor(destination: RenderDestination, renderFunction: RenderFunction) { + this.destination = destination; + this.renderPromise = renderFunction(this); + + if (isPromise(this.renderPromise)) { + // Catch here in case it throws before `flush` is called, + // to prevent an unhandled rejection. + Promise.resolve(this.renderPromise).catch(noop); + } } public write(chunk: RenderDestinationChunk): void { - if (this.destination) { + // Before the buffer has been flushed, we want to + // append to the buffer, afterwards we'll write + // to the underlying destination if subsequent + // writes arrive. + + if (this.flushed) { this.destination.write(chunk); } else { this.chunks.push(chunk); } } - public async renderToFinalDestination(destination: RenderDestination) { + public flush(): void | Promise { + if (this.flushed) { + throw new Error('The render buffer has already been flushed.'); + } + + this.flushed = true; + // Write the buffered chunks to the real destination for (const chunk of this.chunks) { - destination.write(chunk); + this.destination.write(chunk); } // NOTE: We don't empty `this.chunks` after it's written as benchmarks show @@ -186,38 +208,43 @@ class BufferedRenderer implements RenderDestination { // 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 - this.destination = destination; - - // Wait for render to finish entirely - await this.renderPromise; + return this.renderPromise; } } /** * 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 + * with an object containing the `flush` 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) => { + * return createBufferedRenderer(finalDestination, async (bufferDestination) => { * await renderComponentToDestination(bufferDestination); * }); * }); * // Render array of components serially * for (const finalRender of finalRenders) { - * await finalRender.renderToFinalDestination(finalDestination); + * await finalRender.flush(); * } * ``` */ -export function renderToBufferDestination(bufferRenderFunction: RenderFunction): { - renderToFinalDestination: RenderFunction; -} { - const renderer = new BufferedRenderer(bufferRenderFunction); - return renderer; +export function createBufferedRenderer( + destination: RenderDestination, + renderFunction: RenderFunction, +): RendererFlusher { + return new BufferedRenderer(destination, renderFunction); +} + +export interface RendererFlusher { + /** + * Flushes the current renderer to the underlying renderer. + * + * See example of `createBufferedRenderer` for usage. + */ + flush(): void | Promise; } export const isNode = diff --git a/packages/astro/test/units/render/rendering.test.js b/packages/astro/test/units/render/rendering.test.js new file mode 100644 index 000000000000..3c9fa89ec448 --- /dev/null +++ b/packages/astro/test/units/render/rendering.test.js @@ -0,0 +1,312 @@ +import * as assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import { isPromise } from 'node:util/types'; +import * as cheerio from 'cheerio'; +import { + HTMLString, + createComponent, + renderComponent, + renderTemplate, +} from '../../../dist/runtime/server/index.js'; + +describe('rendering', () => { + const evaluated = []; + + const Scalar = createComponent((_result, props) => { + evaluated.push(props.id); + return renderTemplate``; + }); + + beforeEach(() => { + evaluated.length = 0; + }); + + it('components are evaluated and rendered depth-first', async () => { + const Root = createComponent((result, props) => { + evaluated.push(props.id); + return renderTemplate` + ${renderComponent(result, '', Scalar, { id: `${props.id}/scalar_1` })} + ${renderComponent(result, '', Nested, { id: `${props.id}/nested` })} + ${renderComponent(result, '', Scalar, { id: `${props.id}/scalar_2` })} + `; + }); + + const Nested = createComponent((result, props) => { + evaluated.push(props.id); + return renderTemplate` + ${renderComponent(result, '', Scalar, { id: `${props.id}/scalar` })} + `; + }); + + const result = await renderToString(Root({}, { id: 'root' }, {})); + const rendered = getRenderedIds(result); + + assert.deepEqual(evaluated, [ + 'root', + 'root/scalar_1', + 'root/nested', + 'root/nested/scalar', + 'root/scalar_2', + ]); + + assert.deepEqual(rendered, [ + 'root', + 'root/scalar_1', + 'root/nested', + 'root/nested/scalar', + 'root/scalar_2', + ]); + }); + + it('synchronous component trees are rendered without promises', () => { + const Root = createComponent((result, props) => { + evaluated.push(props.id); + return renderTemplate` + ${() => renderComponent(result, '', Scalar, { id: `${props.id}/scalar_1` })} + ${function* () { + yield renderComponent(result, '', Scalar, { id: `${props.id}/scalar_2` }); + }} + ${[renderComponent(result, '', Scalar, { id: `${props.id}/scalar_3` })]} + ${renderComponent(result, '', Scalar, { id: `${props.id}/scalar_4` })} + `; + }); + + const result = renderToString(Root({}, { id: 'root' }, {})); + assert.ok(!isPromise(result)); + + const rendered = getRenderedIds(result); + + assert.deepEqual(evaluated, [ + 'root', + 'root/scalar_1', + 'root/scalar_2', + 'root/scalar_3', + 'root/scalar_4', + ]); + + assert.deepEqual(rendered, [ + 'root', + 'root/scalar_1', + 'root/scalar_2', + 'root/scalar_3', + 'root/scalar_4', + ]); + }); + + it('async component children are deferred', async () => { + const Root = createComponent((result, props) => { + evaluated.push(props.id); + return renderTemplate` + ${renderComponent(result, '', AsyncNested, { id: `${props.id}/asyncnested` })} + ${renderComponent(result, '', Scalar, { id: `${props.id}/scalar` })} + `; + }); + + const AsyncNested = createComponent(async (result, props) => { + evaluated.push(props.id); + await new Promise((resolve) => setTimeout(resolve, 0)); + return renderTemplate` + ${renderComponent(result, '', Scalar, { id: `${props.id}/scalar` })} + `; + }); + + const result = await renderToString(Root({}, { id: 'root' }, {})); + + const rendered = getRenderedIds(result); + + assert.deepEqual(evaluated, [ + 'root', + 'root/asyncnested', + 'root/scalar', + 'root/asyncnested/scalar', + ]); + + assert.deepEqual(rendered, [ + 'root', + 'root/asyncnested', + 'root/asyncnested/scalar', + 'root/scalar', + ]); + }); + + it('adjacent async components are evaluated eagerly', async () => { + const resetEvent = new ManualResetEvent(); + + const Root = createComponent((result, props) => { + evaluated.push(props.id); + return renderTemplate` + ${renderComponent(result, '', AsyncNested, { id: `${props.id}/asyncnested_1` })} + ${renderComponent(result, '', AsyncNested, { id: `${props.id}/asyncnested_2` })} + `; + }); + + const AsyncNested = createComponent(async (result, props) => { + evaluated.push(props.id); + await resetEvent.wait(); + return renderTemplate` + ${renderComponent(result, '', Scalar, { id: `${props.id}/scalar` })} + `; + }); + + const awaitableResult = renderToString(Root({}, { id: 'root' }, {})); + + assert.deepEqual(evaluated, ['root', 'root/asyncnested_1', 'root/asyncnested_2']); + + resetEvent.release(); + + // relinquish control after release + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual(evaluated, [ + 'root', + 'root/asyncnested_1', + 'root/asyncnested_2', + 'root/asyncnested_1/scalar', + 'root/asyncnested_2/scalar', + ]); + + const result = await awaitableResult; + const rendered = getRenderedIds(result); + + assert.deepEqual(rendered, [ + 'root', + 'root/asyncnested_1', + 'root/asyncnested_1/scalar', + 'root/asyncnested_2', + 'root/asyncnested_2/scalar', + ]); + }); + + it('skip rendering blank html fragments', async () => { + const Root = createComponent(() => { + const message = 'hello world'; + return renderTemplate`${message}`; + }); + + const renderInstance = await renderComponent({}, '', Root, {}); + + const chunks = []; + const destination = { + write: (chunk) => { + chunks.push(chunk); + }, + }; + + await renderInstance.render(destination); + + assert.deepEqual(chunks, [new HTMLString('hello world')]); + }); + + it('all primitives are rendered in order', async () => { + const Root = createComponent((result, props) => { + evaluated.push(props.id); + return renderTemplate` + ${renderComponent(result, '', Scalar, { id: `${props.id}/first` })} + ${() => renderComponent(result, '', Scalar, { id: `${props.id}/func` })} + ${new Promise((resolve) => { + setTimeout(() => { + resolve(renderComponent(result, '', Scalar, { id: `${props.id}/promise` })); + }, 0); + })} + ${[ + () => renderComponent(result, '', Scalar, { id: `${props.id}/array_func` }), + renderComponent(result, '', Scalar, { id: `${props.id}/array_scalar` }), + ]} + ${async function* () { + yield await new Promise((resolve) => { + setTimeout(() => { + resolve(renderComponent(result, '', Scalar, { id: `${props.id}/async_generator` })); + }, 0); + }); + }} + ${function* () { + yield renderComponent(result, '', Scalar, { id: `${props.id}/generator` }); + }} + ${renderComponent(result, '', Scalar, { id: `${props.id}/last` })} + `; + }); + + const result = await renderToString(Root({}, { id: 'root' }, {})); + + const rendered = getRenderedIds(result); + + assert.deepEqual(rendered, [ + 'root', + 'root/first', + 'root/func', + 'root/promise', + 'root/array_func', + 'root/array_scalar', + 'root/async_generator', + 'root/generator', + 'root/last', + ]); + }); +}); + +function renderToString(item) { + if (isPromise(item)) { + return item.then(renderToString); + } + + let result = ''; + + const destination = { + write: (chunk) => { + result += chunk.toString(); + }, + }; + + const renderResult = item.render(destination); + + if (isPromise(renderResult)) { + return renderResult.then(() => result); + } + + return result; +} + +function getRenderedIds(html) { + return cheerio + .load( + html, + null, + false, + )('*') + .map((_, node) => node.attribs['id']) + .toArray(); +} + +class ManualResetEvent { + #resolve; + #promise; + #done = false; + + release() { + if (this.#done) { + return; + } + + this.#done = true; + + if (this.#resolve) { + this.#resolve(); + } + } + + wait() { + // Promise constructor callbacks are called immediately + // so retrieving the value of "resolve" should + // be safe to do. + + if (!this.#promise) { + this.#promise = this.#done + ? Promise.resolve() + : new Promise((resolve) => { + this.#resolve = resolve; + }); + } + + return this.#promise; + } +}