Skip to content

Commit d0b53e6

Browse files
committed
Ensure we return a full document
1 parent b283245 commit d0b53e6

File tree

2 files changed

+59
-3
lines changed

2 files changed

+59
-3
lines changed

src/lib/chunked.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,38 @@ export async function renderToChunks(vnode, { context, onWrite, abortSignal }) {
2323
// Synchronously render the shell
2424
// @ts-ignore - using third internal RendererState argument
2525
const shell = renderToString(vnode, context, renderer);
26-
onWrite(shell);
2726

2827
// Wait for any suspended sub-trees if there are any
2928
const len = renderer.suspended.length;
3029
if (len > 0) {
30+
// When rendering a full HTML document, the shell ends with </body></html>.
31+
// Inserting the deferred <div hidden> wrapper after </html> is invalid HTML
32+
// and causes browsers to reject the content. Instead, we inject the deferred
33+
// content before the closing tags, then emit them last.
34+
const docSuffix = getDocumentClosingTags(shell);
35+
onWrite(docSuffix ? shell.slice(0, -docSuffix.length) : shell);
3136
onWrite('<div hidden>');
3237
onWrite(createInitScript(len));
3338
// We should keep checking all promises
3439
await forkPromises(renderer);
3540
onWrite('</div>');
41+
if (docSuffix) onWrite(docSuffix);
42+
} else {
43+
onWrite(shell);
3644
}
3745
}
3846

47+
/**
48+
* If the shell ends with </body></html> (full document rendering), return that
49+
* suffix so it can be emitted *after* the deferred content, keeping the HTML valid.
50+
* @param {string} html
51+
* @returns {string | null}
52+
*/
53+
function getDocumentClosingTags(html) {
54+
const match = html.match(/(<\/body>)?\s*<\/html>\s*/i);
55+
return match ? match[0] : null;
56+
}
57+
3958
async function forkPromises(renderer) {
4059
if (renderer.suspended.length > 0) {
4160
const suspensions = [...renderer.suspended];

test/compat/render-chunked.test.jsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,43 @@ describe('renderToChunks', () => {
191191
]);
192192
});
193193

194+
it('should inject deferred content before </body></html> for full document rendering', async () => {
195+
const { Suspender, suspended } = createSuspender();
196+
197+
const result = [];
198+
const promise = renderToChunks(
199+
<html>
200+
<head>
201+
<title>Test</title>
202+
</head>
203+
<body>
204+
<Suspense fallback="loading...">
205+
<Suspender />
206+
</Suspense>
207+
</body>
208+
</html>,
209+
{ onWrite: (s) => result.push(s) }
210+
);
211+
suspended.resolve();
212+
await promise;
213+
214+
const fullHtml = result.join('');
215+
216+
// Deferred wrapper must appear before </body></html>, not after
217+
const deferredPos = fullHtml.indexOf('<div hidden>');
218+
const bodyClosePos = fullHtml.indexOf('</body>');
219+
const htmlClosePos = fullHtml.indexOf('</html>');
220+
221+
expect(deferredPos).toBeGreaterThan(-1);
222+
expect(deferredPos).toBeLessThan(bodyClosePos);
223+
expect(bodyClosePos).toBeLessThan(htmlClosePos);
224+
225+
// The document must end with </html>
226+
expect(fullHtml.endsWith('</html>')).toBe(true);
227+
// No content after </html>
228+
expect(result[result.length - 1]).toBe('</body></html>');
229+
});
230+
194231
it('should support a component that suspends multiple times', async () => {
195232
const { Suspender, suspended } = createSuspender();
196233
const { Suspender: Suspender2, suspended: suspended2 } = createSuspender();
@@ -217,10 +254,10 @@ describe('renderToChunks', () => {
217254
await promise;
218255

219256
expect(result).to.deep.equal([
220-
'<div><!--preact-island:49-->loading part 1...<!--/preact-island:49--></div>',
257+
'<div><!--preact-island:57-->loading part 1...<!--/preact-island:57--></div>',
221258
'<div hidden>',
222259
createInitScript(1),
223-
createSubtree('49', '<p>it works</p><p>it works</p>'),
260+
createSubtree('57', '<p>it works</p><p>it works</p>'),
224261
'</div>'
225262
]);
226263
});

0 commit comments

Comments
 (0)