Skip to content

Commit 3b66955

Browse files
MatthewLymerascorbicematipico
authoredFeb 12, 2025··
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 <m@mk.gg> Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
1 parent 150c001 commit 3b66955

File tree

8 files changed

+561
-93
lines changed

8 files changed

+561
-93
lines changed
 

‎.changeset/shaggy-deers-destroy.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': minor
3+
---
4+
5+
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.

‎packages/astro/src/runtime/server/render/any.ts

+117-35
Original file line numberDiff line numberDiff line change
@@ -3,52 +3,134 @@ import { isPromise } from '../util.js';
33
import { isAstroComponentInstance, isRenderTemplateResult } from './astro/index.js';
44
import { type RenderDestination, isRenderInstance } from './common.js';
55
import { SlotString } from './slot.js';
6-
import { renderToBufferDestination } from './util.js';
6+
import { createBufferedRenderer } from './util.js';
77

8-
export async function renderChild(destination: RenderDestination, child: any) {
8+
export function renderChild(destination: RenderDestination, child: any): void | Promise<void> {
99
if (isPromise(child)) {
10-
child = await child;
10+
return child.then((x) => renderChild(destination, x));
1111
}
12+
1213
if (child instanceof SlotString) {
1314
destination.write(child);
14-
} else if (isHTMLString(child)) {
15+
return;
16+
}
17+
18+
if (isHTMLString(child)) {
1519
destination.write(child);
16-
} else if (Array.isArray(child)) {
17-
// Render all children eagerly and in parallel
18-
const childRenders = child.map((c) => {
19-
return renderToBufferDestination((bufferDestination) => {
20-
return renderChild(bufferDestination, c);
21-
});
22-
});
23-
for (const childRender of childRenders) {
24-
if (!childRender) continue;
25-
await childRender.renderToFinalDestination(destination);
26-
}
27-
} else if (typeof child === 'function') {
20+
return;
21+
}
22+
23+
if (Array.isArray(child)) {
24+
return renderArray(destination, child);
25+
}
26+
27+
if (typeof child === 'function') {
2828
// Special: If a child is a function, call it automatically.
2929
// This lets you do {() => ...} without the extra boilerplate
3030
// of wrapping it in a function and calling it.
31-
await renderChild(destination, child());
32-
} else if (typeof child === 'string') {
33-
destination.write(markHTMLString(escapeHTML(child)));
34-
} else if (!child && child !== 0) {
31+
return renderChild(destination, child());
32+
}
33+
34+
if (!child && child !== 0) {
3535
// do nothing, safe to ignore falsey values.
36-
} else if (isRenderInstance(child)) {
37-
await child.render(destination);
38-
} else if (isRenderTemplateResult(child)) {
39-
await child.render(destination);
40-
} else if (isAstroComponentInstance(child)) {
41-
await child.render(destination);
42-
} else if (ArrayBuffer.isView(child)) {
36+
return;
37+
}
38+
39+
if (typeof child === 'string') {
40+
destination.write(markHTMLString(escapeHTML(child)));
41+
return;
42+
}
43+
44+
if (isRenderInstance(child)) {
45+
return child.render(destination);
46+
}
47+
48+
if (isRenderTemplateResult(child)) {
49+
return child.render(destination);
50+
}
51+
52+
if (isAstroComponentInstance(child)) {
53+
return child.render(destination);
54+
}
55+
56+
if (ArrayBuffer.isView(child)) {
4357
destination.write(child);
44-
} else if (
45-
typeof child === 'object' &&
46-
(Symbol.asyncIterator in child || Symbol.iterator in child)
47-
) {
48-
for await (const value of child) {
49-
await renderChild(destination, value);
58+
return;
59+
}
60+
61+
if (typeof child === 'object' && (Symbol.asyncIterator in child || Symbol.iterator in child)) {
62+
if (Symbol.asyncIterator in child) {
63+
return renderAsyncIterable(destination, child);
5064
}
51-
} else {
52-
destination.write(child);
65+
66+
return renderIterable(destination, child);
67+
}
68+
69+
destination.write(child);
70+
}
71+
72+
function renderArray(destination: RenderDestination, children: any[]): void | Promise<void> {
73+
// Render all children eagerly and in parallel
74+
const flushers = children.map((c) => {
75+
return createBufferedRenderer(destination, (bufferDestination) => {
76+
return renderChild(bufferDestination, c);
77+
});
78+
});
79+
80+
const iterator = flushers[Symbol.iterator]();
81+
82+
const iterate = (): void | Promise<void> => {
83+
for (;;) {
84+
const { value: flusher, done } = iterator.next();
85+
86+
if (done) {
87+
break;
88+
}
89+
90+
const result = flusher.flush();
91+
92+
if (isPromise(result)) {
93+
return result.then(iterate);
94+
}
95+
}
96+
};
97+
98+
return iterate();
99+
}
100+
101+
function renderIterable(
102+
destination: RenderDestination,
103+
children: Iterable<any>,
104+
): void | Promise<void> {
105+
// although arrays and iterables may be similar, an iterable
106+
// may be unbounded, so rendering all children eagerly may not
107+
// be possible.
108+
const iterator = (children[Symbol.iterator] as () => Iterator<any>)();
109+
110+
const iterate = (): void | Promise<void> => {
111+
for (;;) {
112+
const { value, done } = iterator.next();
113+
114+
if (done) {
115+
break;
116+
}
117+
118+
const result = renderChild(destination, value);
119+
120+
if (isPromise(result)) {
121+
return result.then(iterate);
122+
}
123+
}
124+
};
125+
126+
return iterate();
127+
}
128+
129+
async function renderAsyncIterable(
130+
destination: RenderDestination,
131+
children: AsyncIterable<any>,
132+
): Promise<void> {
133+
for await (const value of children) {
134+
await renderChild(destination, value);
53135
}
54136
}

‎packages/astro/src/runtime/server/render/astro/instance.ts

+20-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ComponentSlots } from '../slot.js';
2-
import type { AstroComponentFactory } from './factory.js';
2+
import type { AstroComponentFactory, AstroFactoryReturnValue } from './factory.js';
33

44
import type { SSRResult } from '../../../../types/public/internal.js';
55
import { isPromise } from '../../util.js';
@@ -46,9 +46,13 @@ export class AstroComponentInstance {
4646
}
4747
}
4848

49-
async init(result: SSRResult) {
50-
if (this.returnValue !== undefined) return this.returnValue;
49+
init(result: SSRResult) {
50+
if (this.returnValue !== undefined) {
51+
return this.returnValue;
52+
}
53+
5154
this.returnValue = this.factory(result, this.props, this.slotValues);
55+
5256
// Save the resolved value after promise is resolved for optimization
5357
if (isPromise(this.returnValue)) {
5458
this.returnValue
@@ -62,12 +66,21 @@ export class AstroComponentInstance {
6266
return this.returnValue;
6367
}
6468

65-
async render(destination: RenderDestination) {
66-
const returnValue = await this.init(this.result);
69+
render(destination: RenderDestination): void | Promise<void> {
70+
const returnValue = this.init(this.result);
71+
72+
if (isPromise(returnValue)) {
73+
return returnValue.then((x) => this.renderImpl(destination, x));
74+
}
75+
76+
return this.renderImpl(destination, returnValue);
77+
}
78+
79+
private renderImpl(destination: RenderDestination, returnValue: AstroFactoryReturnValue) {
6780
if (isHeadAndContent(returnValue)) {
68-
await returnValue.content.render(destination);
81+
return returnValue.content.render(destination);
6982
} else {
70-
await renderChild(destination, returnValue);
83+
return renderChild(destination, returnValue);
7184
}
7285
}
7386
}

‎packages/astro/src/runtime/server/render/astro/render-template.ts

+30-11
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { markHTMLString } from '../../escape.js';
22
import { isPromise } from '../../util.js';
33
import { renderChild } from '../any.js';
44
import type { RenderDestination } from '../common.js';
5-
import { renderToBufferDestination } from '../util.js';
5+
import { createBufferedRenderer } from '../util.js';
66

77
const renderTemplateResultSym = Symbol.for('astro.renderTemplateResult');
88

@@ -32,26 +32,45 @@ export class RenderTemplateResult {
3232
});
3333
}
3434

35-
async render(destination: RenderDestination) {
35+
render(destination: RenderDestination): void | Promise<void> {
3636
// Render all expressions eagerly and in parallel
37-
const expRenders = this.expressions.map((exp) => {
38-
return renderToBufferDestination((bufferDestination) => {
37+
const flushers = this.expressions.map((exp) => {
38+
return createBufferedRenderer(destination, (bufferDestination) => {
3939
// Skip render if falsy, except the number 0
4040
if (exp || exp === 0) {
4141
return renderChild(bufferDestination, exp);
4242
}
4343
});
4444
});
4545

46-
for (let i = 0; i < this.htmlParts.length; i++) {
47-
const html = this.htmlParts[i];
48-
const expRender = expRenders[i];
46+
let i = 0;
4947

50-
destination.write(markHTMLString(html));
51-
if (expRender) {
52-
await expRender.renderToFinalDestination(destination);
48+
const iterate = (): void | Promise<void> => {
49+
while (i < this.htmlParts.length) {
50+
const html = this.htmlParts[i];
51+
const flusher = flushers[i];
52+
53+
// increment here due to potential return in
54+
// Promise scenario
55+
i++;
56+
57+
if (html) {
58+
// only write non-empty strings
59+
60+
destination.write(markHTMLString(html));
61+
}
62+
63+
if (flusher) {
64+
const result = flusher.flush();
65+
66+
if (isPromise(result)) {
67+
return result.then(iterate);
68+
}
69+
}
5370
}
54-
}
71+
};
72+
73+
return iterate();
5574
}
5675
}
5776

‎packages/astro/src/runtime/server/render/astro/render.ts

+15-8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AstroError, AstroErrorData } from '../../../../core/errors/index.js';
22
import type { RouteData, SSRResult } from '../../../../types/public/internal.js';
3+
import { isPromise } from '../../util.js';
34
import { type RenderDestination, chunkToByteArray, chunkToString, encoder } from '../common.js';
45
import { promiseWithResolvers } from '../util.js';
56
import type { AstroComponentFactory } from './factory.js';
@@ -317,16 +318,13 @@ export async function renderToAsyncIterable(
317318
},
318319
};
319320

320-
const renderPromise = templateResult.render(destination);
321-
renderPromise
322-
.then(() => {
323-
// Once rendering is complete, calling resolve() allows the iterator to finish running.
324-
renderingComplete = true;
325-
next?.resolve();
326-
})
321+
const renderResult = toPromise(() => templateResult.render(destination));
322+
323+
renderResult
327324
.catch((err) => {
328-
// If an error occurs, save it in the scope so that we throw it when next() is called.
329325
error = err;
326+
})
327+
.finally(() => {
330328
renderingComplete = true;
331329
next?.resolve();
332330
});
@@ -339,3 +337,12 @@ export async function renderToAsyncIterable(
339337
},
340338
};
341339
}
340+
341+
function toPromise<T>(fn: () => T | Promise<T>): Promise<T> {
342+
try {
343+
const result = fn();
344+
return isPromise(result) ? result : Promise.resolve(result);
345+
} catch (err) {
346+
return Promise.reject(err);
347+
}
348+
}

‎packages/astro/src/runtime/server/render/component.ts

+11-8
Original file line numberDiff line numberDiff line change
@@ -446,44 +446,47 @@ function renderAstroComponent(
446446
}
447447

448448
const instance = createAstroComponentInstance(result, displayName, Component, props, slots);
449+
449450
return {
450-
async render(destination) {
451+
render(destination: RenderDestination): Promise<void> | void {
451452
// NOTE: This render call can't be pre-invoked outside of this function as it'll also initialize the slots
452453
// recursively, which causes each Astro components in the tree to be called bottom-up, and is incorrect.
453454
// The slots are initialized eagerly for head propagation.
454-
await instance.render(destination);
455+
return instance.render(destination);
455456
},
456457
};
457458
}
458459

459-
export async function renderComponent(
460+
export function renderComponent(
460461
result: SSRResult,
461462
displayName: string,
462463
Component: unknown,
463464
props: Record<string | number, any>,
464465
slots: ComponentSlots = {},
465-
): Promise<RenderInstance> {
466+
): RenderInstance | Promise<RenderInstance> {
466467
if (isPromise(Component)) {
467-
Component = await Component.catch(handleCancellation);
468+
return Component.catch(handleCancellation).then((x) => {
469+
return renderComponent(result, displayName, x, props, slots);
470+
});
468471
}
469472

470473
if (isFragmentComponent(Component)) {
471-
return await renderFragmentComponent(result, slots).catch(handleCancellation);
474+
return renderFragmentComponent(result, slots).catch(handleCancellation);
472475
}
473476

474477
// Ensure directives (`class:list`) are processed
475478
props = normalizeProps(props);
476479

477480
// .html components
478481
if (isHTMLComponent(Component)) {
479-
return await renderHTMLComponent(result, Component, props, slots).catch(handleCancellation);
482+
return renderHTMLComponent(result, Component, props, slots).catch(handleCancellation);
480483
}
481484

482485
if (isAstroComponentFactory(Component)) {
483486
return renderAstroComponent(result, displayName, Component, props, slots);
484487
}
485488

486-
return await renderFrameworkComponent(result, displayName, Component, props, slots).catch(
489+
return renderFrameworkComponent(result, displayName, Component, props, slots).catch(
487490
handleCancellation,
488491
);
489492

‎packages/astro/src/runtime/server/render/util.ts

+51-24
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { RenderDestination, RenderDestinationChunk, RenderFunction } from '
33
import { clsx } from 'clsx';
44
import type { SSRElement } from '../../../types/public/internal.js';
55
import { HTMLString, markHTMLString } from '../escape.js';
6+
import { isPromise } from '../util.js';
67

78
export const voidElementNames =
89
/^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i;
@@ -152,72 +153,98 @@ export function renderElement(
152153
const noop = () => {};
153154

154155
/**
155-
* Renders into a buffer until `renderToFinalDestination` is called (which
156+
* Renders into a buffer until `flush` is called (which
156157
* flushes the buffer)
157158
*/
158-
class BufferedRenderer implements RenderDestination {
159+
class BufferedRenderer implements RenderDestination, RendererFlusher {
159160
private chunks: RenderDestinationChunk[] = [];
160161
private renderPromise: Promise<void> | void;
161-
private destination?: RenderDestination;
162+
private destination: RenderDestination;
162163

163-
public constructor(bufferRenderFunction: RenderFunction) {
164-
this.renderPromise = bufferRenderFunction(this);
165-
// Catch here in case it throws before `renderToFinalDestination` is called,
166-
// to prevent an unhandled rejection.
167-
Promise.resolve(this.renderPromise).catch(noop);
164+
/**
165+
* Determines whether buffer has been flushed
166+
* to the final destination.
167+
*/
168+
private flushed = false;
169+
170+
public constructor(destination: RenderDestination, renderFunction: RenderFunction) {
171+
this.destination = destination;
172+
this.renderPromise = renderFunction(this);
173+
174+
if (isPromise(this.renderPromise)) {
175+
// Catch here in case it throws before `flush` is called,
176+
// to prevent an unhandled rejection.
177+
Promise.resolve(this.renderPromise).catch(noop);
178+
}
168179
}
169180

170181
public write(chunk: RenderDestinationChunk): void {
171-
if (this.destination) {
182+
// Before the buffer has been flushed, we want to
183+
// append to the buffer, afterwards we'll write
184+
// to the underlying destination if subsequent
185+
// writes arrive.
186+
187+
if (this.flushed) {
172188
this.destination.write(chunk);
173189
} else {
174190
this.chunks.push(chunk);
175191
}
176192
}
177193

178-
public async renderToFinalDestination(destination: RenderDestination) {
194+
public flush(): void | Promise<void> {
195+
if (this.flushed) {
196+
throw new Error('The render buffer has already been flushed.');
197+
}
198+
199+
this.flushed = true;
200+
179201
// Write the buffered chunks to the real destination
180202
for (const chunk of this.chunks) {
181-
destination.write(chunk);
203+
this.destination.write(chunk);
182204
}
183205

184206
// NOTE: We don't empty `this.chunks` after it's written as benchmarks show
185207
// that it causes poorer performance, likely due to forced memory re-allocation,
186208
// instead of letting the garbage collector handle it automatically.
187209
// (Unsure how this affects on limited memory machines)
188210

189-
// Re-assign the real destination so `instance.render` will continue and write to the new destination
190-
this.destination = destination;
191-
192-
// Wait for render to finish entirely
193-
await this.renderPromise;
211+
return this.renderPromise;
194212
}
195213
}
196214

197215
/**
198216
* Executes the `bufferRenderFunction` to prerender it into a buffer destination, and return a promise
199-
* with an object containing the `renderToFinalDestination` function to flush the buffer to the final
217+
* with an object containing the `flush` function to flush the buffer to the final
200218
* destination.
201219
*
202220
* @example
203221
* ```ts
204222
* // Render components in parallel ahead of time
205223
* const finalRenders = [ComponentA, ComponentB].map((comp) => {
206-
* return renderToBufferDestination(async (bufferDestination) => {
224+
* return createBufferedRenderer(finalDestination, async (bufferDestination) => {
207225
* await renderComponentToDestination(bufferDestination);
208226
* });
209227
* });
210228
* // Render array of components serially
211229
* for (const finalRender of finalRenders) {
212-
* await finalRender.renderToFinalDestination(finalDestination);
230+
* await finalRender.flush();
213231
* }
214232
* ```
215233
*/
216-
export function renderToBufferDestination(bufferRenderFunction: RenderFunction): {
217-
renderToFinalDestination: RenderFunction;
218-
} {
219-
const renderer = new BufferedRenderer(bufferRenderFunction);
220-
return renderer;
234+
export function createBufferedRenderer(
235+
destination: RenderDestination,
236+
renderFunction: RenderFunction,
237+
): RendererFlusher {
238+
return new BufferedRenderer(destination, renderFunction);
239+
}
240+
241+
export interface RendererFlusher {
242+
/**
243+
* Flushes the current renderer to the underlying renderer.
244+
*
245+
* See example of `createBufferedRenderer` for usage.
246+
*/
247+
flush(): void | Promise<void>;
221248
}
222249

223250
export const isNode =
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import * as assert from 'node:assert/strict';
2+
import { beforeEach, describe, it } from 'node:test';
3+
import { isPromise } from 'node:util/types';
4+
import * as cheerio from 'cheerio';
5+
import {
6+
HTMLString,
7+
createComponent,
8+
renderComponent,
9+
renderTemplate,
10+
} from '../../../dist/runtime/server/index.js';
11+
12+
describe('rendering', () => {
13+
const evaluated = [];
14+
15+
const Scalar = createComponent((_result, props) => {
16+
evaluated.push(props.id);
17+
return renderTemplate`<scalar id="${props.id}"></scalar>`;
18+
});
19+
20+
beforeEach(() => {
21+
evaluated.length = 0;
22+
});
23+
24+
it('components are evaluated and rendered depth-first', async () => {
25+
const Root = createComponent((result, props) => {
26+
evaluated.push(props.id);
27+
return renderTemplate`<root id="${props.id}">
28+
${renderComponent(result, '', Scalar, { id: `${props.id}/scalar_1` })}
29+
${renderComponent(result, '', Nested, { id: `${props.id}/nested` })}
30+
${renderComponent(result, '', Scalar, { id: `${props.id}/scalar_2` })}
31+
</root>`;
32+
});
33+
34+
const Nested = createComponent((result, props) => {
35+
evaluated.push(props.id);
36+
return renderTemplate`<nested id="${props.id}">
37+
${renderComponent(result, '', Scalar, { id: `${props.id}/scalar` })}
38+
</nested>`;
39+
});
40+
41+
const result = await renderToString(Root({}, { id: 'root' }, {}));
42+
const rendered = getRenderedIds(result);
43+
44+
assert.deepEqual(evaluated, [
45+
'root',
46+
'root/scalar_1',
47+
'root/nested',
48+
'root/nested/scalar',
49+
'root/scalar_2',
50+
]);
51+
52+
assert.deepEqual(rendered, [
53+
'root',
54+
'root/scalar_1',
55+
'root/nested',
56+
'root/nested/scalar',
57+
'root/scalar_2',
58+
]);
59+
});
60+
61+
it('synchronous component trees are rendered without promises', () => {
62+
const Root = createComponent((result, props) => {
63+
evaluated.push(props.id);
64+
return renderTemplate`<root id="${props.id}">
65+
${() => renderComponent(result, '', Scalar, { id: `${props.id}/scalar_1` })}
66+
${function* () {
67+
yield renderComponent(result, '', Scalar, { id: `${props.id}/scalar_2` });
68+
}}
69+
${[renderComponent(result, '', Scalar, { id: `${props.id}/scalar_3` })]}
70+
${renderComponent(result, '', Scalar, { id: `${props.id}/scalar_4` })}
71+
</root>`;
72+
});
73+
74+
const result = renderToString(Root({}, { id: 'root' }, {}));
75+
assert.ok(!isPromise(result));
76+
77+
const rendered = getRenderedIds(result);
78+
79+
assert.deepEqual(evaluated, [
80+
'root',
81+
'root/scalar_1',
82+
'root/scalar_2',
83+
'root/scalar_3',
84+
'root/scalar_4',
85+
]);
86+
87+
assert.deepEqual(rendered, [
88+
'root',
89+
'root/scalar_1',
90+
'root/scalar_2',
91+
'root/scalar_3',
92+
'root/scalar_4',
93+
]);
94+
});
95+
96+
it('async component children are deferred', async () => {
97+
const Root = createComponent((result, props) => {
98+
evaluated.push(props.id);
99+
return renderTemplate`<root id="${props.id}">
100+
${renderComponent(result, '', AsyncNested, { id: `${props.id}/asyncnested` })}
101+
${renderComponent(result, '', Scalar, { id: `${props.id}/scalar` })}
102+
</root>`;
103+
});
104+
105+
const AsyncNested = createComponent(async (result, props) => {
106+
evaluated.push(props.id);
107+
await new Promise((resolve) => setTimeout(resolve, 0));
108+
return renderTemplate`<asyncnested id="${props.id}">
109+
${renderComponent(result, '', Scalar, { id: `${props.id}/scalar` })}
110+
</asyncnested>`;
111+
});
112+
113+
const result = await renderToString(Root({}, { id: 'root' }, {}));
114+
115+
const rendered = getRenderedIds(result);
116+
117+
assert.deepEqual(evaluated, [
118+
'root',
119+
'root/asyncnested',
120+
'root/scalar',
121+
'root/asyncnested/scalar',
122+
]);
123+
124+
assert.deepEqual(rendered, [
125+
'root',
126+
'root/asyncnested',
127+
'root/asyncnested/scalar',
128+
'root/scalar',
129+
]);
130+
});
131+
132+
it('adjacent async components are evaluated eagerly', async () => {
133+
const resetEvent = new ManualResetEvent();
134+
135+
const Root = createComponent((result, props) => {
136+
evaluated.push(props.id);
137+
return renderTemplate`<root id="${props.id}">
138+
${renderComponent(result, '', AsyncNested, { id: `${props.id}/asyncnested_1` })}
139+
${renderComponent(result, '', AsyncNested, { id: `${props.id}/asyncnested_2` })}
140+
</root>`;
141+
});
142+
143+
const AsyncNested = createComponent(async (result, props) => {
144+
evaluated.push(props.id);
145+
await resetEvent.wait();
146+
return renderTemplate`<asyncnested id="${props.id}">
147+
${renderComponent(result, '', Scalar, { id: `${props.id}/scalar` })}
148+
</asyncnested>`;
149+
});
150+
151+
const awaitableResult = renderToString(Root({}, { id: 'root' }, {}));
152+
153+
assert.deepEqual(evaluated, ['root', 'root/asyncnested_1', 'root/asyncnested_2']);
154+
155+
resetEvent.release();
156+
157+
// relinquish control after release
158+
await new Promise((resolve) => setTimeout(resolve, 0));
159+
160+
assert.deepEqual(evaluated, [
161+
'root',
162+
'root/asyncnested_1',
163+
'root/asyncnested_2',
164+
'root/asyncnested_1/scalar',
165+
'root/asyncnested_2/scalar',
166+
]);
167+
168+
const result = await awaitableResult;
169+
const rendered = getRenderedIds(result);
170+
171+
assert.deepEqual(rendered, [
172+
'root',
173+
'root/asyncnested_1',
174+
'root/asyncnested_1/scalar',
175+
'root/asyncnested_2',
176+
'root/asyncnested_2/scalar',
177+
]);
178+
});
179+
180+
it('skip rendering blank html fragments', async () => {
181+
const Root = createComponent(() => {
182+
const message = 'hello world';
183+
return renderTemplate`${message}`;
184+
});
185+
186+
const renderInstance = await renderComponent({}, '', Root, {});
187+
188+
const chunks = [];
189+
const destination = {
190+
write: (chunk) => {
191+
chunks.push(chunk);
192+
},
193+
};
194+
195+
await renderInstance.render(destination);
196+
197+
assert.deepEqual(chunks, [new HTMLString('hello world')]);
198+
});
199+
200+
it('all primitives are rendered in order', async () => {
201+
const Root = createComponent((result, props) => {
202+
evaluated.push(props.id);
203+
return renderTemplate`<root id="${props.id}">
204+
${renderComponent(result, '', Scalar, { id: `${props.id}/first` })}
205+
${() => renderComponent(result, '', Scalar, { id: `${props.id}/func` })}
206+
${new Promise((resolve) => {
207+
setTimeout(() => {
208+
resolve(renderComponent(result, '', Scalar, { id: `${props.id}/promise` }));
209+
}, 0);
210+
})}
211+
${[
212+
() => renderComponent(result, '', Scalar, { id: `${props.id}/array_func` }),
213+
renderComponent(result, '', Scalar, { id: `${props.id}/array_scalar` }),
214+
]}
215+
${async function* () {
216+
yield await new Promise((resolve) => {
217+
setTimeout(() => {
218+
resolve(renderComponent(result, '', Scalar, { id: `${props.id}/async_generator` }));
219+
}, 0);
220+
});
221+
}}
222+
${function* () {
223+
yield renderComponent(result, '', Scalar, { id: `${props.id}/generator` });
224+
}}
225+
${renderComponent(result, '', Scalar, { id: `${props.id}/last` })}
226+
</root>`;
227+
});
228+
229+
const result = await renderToString(Root({}, { id: 'root' }, {}));
230+
231+
const rendered = getRenderedIds(result);
232+
233+
assert.deepEqual(rendered, [
234+
'root',
235+
'root/first',
236+
'root/func',
237+
'root/promise',
238+
'root/array_func',
239+
'root/array_scalar',
240+
'root/async_generator',
241+
'root/generator',
242+
'root/last',
243+
]);
244+
});
245+
});
246+
247+
function renderToString(item) {
248+
if (isPromise(item)) {
249+
return item.then(renderToString);
250+
}
251+
252+
let result = '';
253+
254+
const destination = {
255+
write: (chunk) => {
256+
result += chunk.toString();
257+
},
258+
};
259+
260+
const renderResult = item.render(destination);
261+
262+
if (isPromise(renderResult)) {
263+
return renderResult.then(() => result);
264+
}
265+
266+
return result;
267+
}
268+
269+
function getRenderedIds(html) {
270+
return cheerio
271+
.load(
272+
html,
273+
null,
274+
false,
275+
)('*')
276+
.map((_, node) => node.attribs['id'])
277+
.toArray();
278+
}
279+
280+
class ManualResetEvent {
281+
#resolve;
282+
#promise;
283+
#done = false;
284+
285+
release() {
286+
if (this.#done) {
287+
return;
288+
}
289+
290+
this.#done = true;
291+
292+
if (this.#resolve) {
293+
this.#resolve();
294+
}
295+
}
296+
297+
wait() {
298+
// Promise constructor callbacks are called immediately
299+
// so retrieving the value of "resolve" should
300+
// be safe to do.
301+
302+
if (!this.#promise) {
303+
this.#promise = this.#done
304+
? Promise.resolve()
305+
: new Promise((resolve) => {
306+
this.#resolve = resolve;
307+
});
308+
}
309+
310+
return this.#promise;
311+
}
312+
}

0 commit comments

Comments
 (0)
Please sign in to comment.