Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lovely-ways-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'preact-render-to-string': patch
---

Fix issues regarding streaming full HTML documents
24 changes: 23 additions & 1 deletion src/lib/chunked.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,41 @@ export async function renderToChunks(vnode, { context, onWrite, abortSignal }) {
// Synchronously render the shell
// @ts-ignore - using third internal RendererState argument
const shell = renderToString(vnode, context, renderer);
onWrite(shell);

// Wait for any suspended sub-trees if there are any
const len = renderer.suspended.length;
if (len > 0) {
// When rendering a full HTML document, the shell ends with </body></html>.
// Inserting the deferred <div hidden> wrapper after </html> is invalid HTML
// and causes browsers to reject the content. Instead, we inject the deferred
// content before the closing tags, then emit them last.
const docSuffixIndex = getDocumentClosingTagsIndex(shell);
const hasHtmlTag = shell.trimStart().startsWith('<html');
const initialWrite =
docSuffixIndex !== -1 ? shell.slice(0, docSuffixIndex) : shell;
const prefix = hasHtmlTag ? '<!DOCTYPE html>' : '';
onWrite(prefix + initialWrite);
onWrite('<div hidden>');
onWrite(createInitScript(len));
// We should keep checking all promises
await forkPromises(renderer);
onWrite('</div>');
if (docSuffixIndex !== -1) onWrite(shell.slice(docSuffixIndex));
} else {
onWrite(shell);
}
}

/**
* If the shell ends with </body></html> (full document rendering), return that
* suffix so it can be emitted *after* the deferred content, keeping the HTML valid.
* @param {string} html
* @returns {number}
*/
function getDocumentClosingTagsIndex(html) {
return html.lastIndexOf('</body>');
}

async function forkPromises(renderer) {
if (renderer.suspended.length > 0) {
const suspensions = [...renderer.suspended];
Expand Down
4 changes: 2 additions & 2 deletions src/lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
// else if (n.data == '/preact-island:' + i) e = n;
// if (s && e) break;
// }
// if (s && e) {
// if (s && e && s.parentNode !== document) {
// requestAnimationFrame(() => {
// var p = e.previousSibling;
// while (p != s) {
Expand All @@ -46,7 +46,7 @@
// }

// To modify the INIT_SCRIPT, uncomment the above code, modify it, and paste it into https://try.terser.org/.
const INIT_SCRIPT = `class e extends HTMLElement{connectedCallback(){var e=this;if(!e.isConnected)return;let t=this.getAttribute("data-target");if(t){for(var r,a,i=document.createNodeIterator(document,128);i.nextNode();){let e=i.referenceNode;if(e.data=="preact-island:"+t?r=e:e.data=="/preact-island:"+t&&(a=e),r&&a)break}r&&a&&requestAnimationFrame((()=>{for(var t=a.previousSibling;t!=r&&t&&t!=r;)a.parentNode.removeChild(t),t=a.previousSibling;for(i=r;e.firstChild;)r=e.firstChild,e.removeChild(r),i.after(r),i=r;e.parentNode.removeChild(e)}))}}}customElements.define("preact-island",e);`;
const INIT_SCRIPT = `class e extends HTMLElement{connectedCallback(){var e=this;if(!e.isConnected)return;let t=this.getAttribute("data-target");if(t){for(var r,a,i=document.createNodeIterator(document,128);i.nextNode();){let e=i.referenceNode;if(e.data=="preact-island:"+t?r=e:e.data=="/preact-island:"+t&&(a=e),r&&a)break}r&&a&&r.parentNode!==document&&requestAnimationFrame((()=>{for(var t=a.previousSibling;t!=r&&t&&t!=r;)a.parentNode.removeChild(t),t=a.previousSibling;for(i=r;e.firstChild;)r=e.firstChild,e.removeChild(r),i.after(r),i=r;e.parentNode.removeChild(e)}))}}}customElements.define("preact-island",e);`;

export function createInitScript() {
return `<script>(function(){${INIT_SCRIPT}}())</script>`;
Expand Down
92 changes: 90 additions & 2 deletions test/compat/render-chunked.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,94 @@ describe('renderToChunks', () => {
]);
});

it('should inject deferred content before </body></html> for full document rendering', async () => {
const { Suspender, suspended } = createSuspender();

const result = [];
const promise = renderToChunks(
<html>
<head>
<title>Test</title>
</head>
<body>
<Suspense fallback="loading...">
<Suspender />
</Suspense>
</body>
</html>,
{ onWrite: (s) => result.push(s) }
);
suspended.resolve();
await promise;

const fullHtml = result.join('');

// Deferred wrapper must appear before </body></html>, not after
const deferredPos = fullHtml.indexOf('<div hidden>');
const bodyClosePos = fullHtml.indexOf('</body>');
const htmlClosePos = fullHtml.indexOf('</html>');

expect(deferredPos).toBeGreaterThan(-1);
expect(deferredPos).toBeLessThan(bodyClosePos);
expect(bodyClosePos).toBeLessThan(htmlClosePos);

// The document must end with </html>
expect(fullHtml.endsWith('</html>')).toBe(true);
// No content after </html>
expect(result[result.length - 1]).toBe('</body></html>');
});

it('should prepend <!DOCTYPE html> when rendering a full document with suspended content', async () => {
const { Suspender, suspended } = createSuspender();

const result = [];
const promise = renderToChunks(
<html>
<head>
<title>Test</title>
</head>
<body>
<Suspense fallback="loading...">
<Suspender />
</Suspense>
</body>
</html>,
{ onWrite: (s) => result.push(s) }
);
suspended.resolve();
await promise;

// The first chunk must be prefixed with <!DOCTYPE html>
expect(result[0].startsWith('<!DOCTYPE html>')).toBe(true);

// The full output must start with the doctype
const fullHtml = result.join('');
expect(fullHtml.startsWith('<!DOCTYPE html>')).toBe(true);

// The doctype should appear exactly once
const doctypeCount = (fullHtml.match(/<!DOCTYPE html>/gi) || []).length;
expect(doctypeCount).toBe(1);
});

it('should not prepend <!DOCTYPE html> when rendering a non-document fragment with suspended content', async () => {
const { Suspender, suspended } = createSuspender();

const result = [];
const promise = renderToChunks(
<div>
<Suspense fallback="loading...">
<Suspender />
</Suspense>
</div>,
{ onWrite: (s) => result.push(s) }
);
suspended.resolve();
await promise;

const fullHtml = result.join('');
expect(fullHtml.includes('<!DOCTYPE html>')).toBe(false);
});

it('should support a component that suspends multiple times', async () => {
const { Suspender, suspended } = createSuspender();
const { Suspender: Suspender2, suspended: suspended2 } = createSuspender();
Expand All @@ -217,10 +305,10 @@ describe('renderToChunks', () => {
await promise;

expect(result).to.deep.equal([
'<div><!--preact-island:49-->loading part 1...<!--/preact-island:49--></div>',
'<div><!--preact-island:70-->loading part 1...<!--/preact-island:70--></div>',
'<div hidden>',
createInitScript(1),
createSubtree('49', '<p>it works</p><p>it works</p>'),
createSubtree('70', '<p>it works</p><p>it works</p>'),
'</div>'
]);
});
Expand Down