Skip to content

Commit 7c65c04

Browse files
fix: stream Fragment sync siblings before async children resolve (#16239)
1 parent 1da523d commit 7c65c04

5 files changed

Lines changed: 129 additions & 7 deletions

File tree

.changeset/rhrc-kpon-ngct.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes sync content inside `<Fragment>` not streaming to the browser until all async sibling expressions have resolved.

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { componentIsHTMLElement, renderHTMLElement } from './dom.js';
2626
import { maybeRenderHead } from './head.js';
2727
import { createRenderInstruction } from './instruction.js';
2828
import { containsServerDirective, ServerIslandComponent } from './server-islands.js';
29-
import { type ComponentSlots, renderSlots, renderSlotToString } from './slot.js';
29+
import { type ComponentSlots, renderSlot, renderSlots, renderSlotToString } from './slot.js';
3030
import { formatList, internalSpreadAttributes, renderElement, voidElementNames } from './util.js';
3131

3232
const needsHeadRenderingSymbol = Symbol.for('astro.needsHeadRendering');
@@ -413,15 +413,15 @@ function sanitizeElementName(tag: string) {
413413
return tag.trim().split(unsafe)[0].trim();
414414
}
415415

416-
async function renderFragmentComponent(
416+
function renderFragmentComponent(
417417
result: SSRResult,
418418
slots: ComponentSlots = {},
419-
): Promise<RenderInstance> {
420-
const children = await renderSlotToString(result, slots?.default);
419+
): RenderInstance {
420+
const slot = slots?.default;
421421
return {
422422
render(destination) {
423-
if (children == null) return;
424-
destination.write(children);
423+
if (slot == null) return;
424+
return renderSlot(result, slot).render(destination);
425425
},
426426
};
427427
}
@@ -483,7 +483,7 @@ export function renderComponent(
483483
}
484484

485485
if (isFragmentComponent(Component)) {
486-
return renderFragmentComponent(result, slots).catch(handleCancellation);
486+
return renderFragmentComponent(result, slots);
487487
}
488488

489489
// Ensure directives (`class:list`) are processed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
import { wait } from '../wait';
3+
4+
export const prerender = false;
5+
6+
// This promise resolves after a delay — the sync sibling should stream before it
7+
const promise = wait(50).then(() => 'resolved');
8+
---
9+
<html>
10+
<head><title>Fragment Streaming</title></head>
11+
<body>
12+
<!--
13+
Issue #13283: sync sibling inside Fragment should stream immediately,
14+
before the async child resolves. With the old code it was blocked.
15+
-->
16+
<Fragment>
17+
<p id="sync-in-fragment">I should appear before the promise resolves</p>
18+
{promise.then(() => <p id="async-in-fragment">I appear after the promise resolves</p>)}
19+
</Fragment>
20+
21+
<!--
22+
Control: same content outside Fragment (bare template expressions).
23+
This always worked correctly — used to verify the fix matches this behavior.
24+
-->
25+
<p id="sync-bare">Bare sync sibling (always worked)</p>
26+
{promise.then(() => <p id="async-bare">Bare async sibling</p>)}
27+
</body>
28+
</html>

packages/astro/test/streaming.test.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,41 @@ describe('Streaming', () => {
9090
});
9191
});
9292

93+
describe('Fragment streaming (issue #13283)', () => {
94+
/** @type {import('./test-utils').Fixture} */
95+
let fixture;
96+
const decoder = new TextDecoder();
97+
98+
before(async () => {
99+
fixture = await loadFixture({
100+
root: './fixtures/streaming/',
101+
adapter: testAdapter(),
102+
output: 'server',
103+
});
104+
await fixture.build();
105+
});
106+
107+
it('sync sibling inside Fragment streams before async child resolves', async () => {
108+
const app = await fixture.loadTestAdapterApp();
109+
const request = new Request('http://example.com/fragment-streaming');
110+
const response = await app.render(request);
111+
112+
const chunks = [];
113+
for await (const bytes of streamAsyncIterator(response.body)) {
114+
chunks.push(decoder.decode(bytes));
115+
}
116+
117+
const syncChunkIndex = chunks.findIndex((c) => c.includes('sync-in-fragment'));
118+
const asyncChunkIndex = chunks.findIndex((c) => c.includes('async-in-fragment'));
119+
assert.ok(syncChunkIndex !== -1, 'sync-in-fragment present in output');
120+
assert.ok(asyncChunkIndex !== -1, 'async-in-fragment present in output');
121+
assert.ok(
122+
syncChunkIndex < asyncChunkIndex,
123+
`sync content (chunk ${syncChunkIndex}) should stream before async content (chunk ${asyncChunkIndex})`,
124+
);
125+
});
126+
});
127+
93128
describe('Streaming disabled', () => {
94129
/** @type {import('./test-utils').Fixture} */
95130
let fixture;

packages/astro/test/units/render/html-primitives.test.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '../../../dist/runtime/server/render/util.js';
1414
import {
1515
createComponent,
16+
Fragment,
1617
render as renderTemplate,
1718
renderComponent,
1819
renderSlot,
@@ -320,6 +321,59 @@ describe('Allows using the Fragment element', async () => {
320321
const $ = cheerio.load(await response.text());
321322
assert.equal($('#one').length, 1);
322323
});
324+
325+
it('streams sync siblings before async children resolve (issue #13283)', async () => {
326+
// A deferred promise simulates a slow async child inside the Fragment.
327+
let resolveAsync;
328+
const asyncChild = new Promise((resolve) => {
329+
resolveAsync = resolve;
330+
});
331+
332+
const DEFAULT_RESULT = { clientDirectives: new Map() };
333+
334+
// Build a Fragment whose default slot contains a sync <p> followed by an async <p>.
335+
const renderInstance = renderComponent(
336+
DEFAULT_RESULT,
337+
'Fragment',
338+
Fragment,
339+
{},
340+
{
341+
default: (_result) =>
342+
renderTemplate`<p id="sync">sync</p>${asyncChild.then(
343+
() => renderTemplate`<p id="async">async</p>`,
344+
)}`,
345+
},
346+
);
347+
348+
// Collect chunks as they are written so we can inspect ordering.
349+
const chunks = [];
350+
const destination = {
351+
write(chunk) {
352+
chunks.push(String(chunk));
353+
},
354+
};
355+
356+
// Start rendering — do NOT await yet so we can inspect mid-flight state.
357+
const instance = await Promise.resolve(renderInstance);
358+
const renderPromise = instance.render(destination);
359+
360+
// Yield to the microtask queue so the sync portion can flush.
361+
await Promise.resolve();
362+
363+
// The sync <p> must have been written before the async promise resolved.
364+
const syncFlushed = chunks.join('').includes('sync');
365+
assert.ok(syncFlushed, 'sync sibling should stream before async child resolves');
366+
367+
// Now resolve the async child and finish rendering.
368+
resolveAsync();
369+
await renderPromise;
370+
371+
const html = chunks.join('');
372+
assert.ok(html.includes('sync'), 'sync content present in final output');
373+
assert.ok(html.includes('async'), 'async content present in final output');
374+
// Sync must appear before async in the output.
375+
assert.ok(html.indexOf('sync') < html.indexOf('async'), 'sync appears before async in output');
376+
});
323377
});
324378

325379
describe('renders the components top-down', async () => {

0 commit comments

Comments
 (0)