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/fix-queued-rendering-html-pages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes `experimental.queuedRendering` incorrectly escaping the HTML output of `.html` page files, causing the page content to render as plain text instead of HTML in the browser.
9 changes: 8 additions & 1 deletion packages/astro/src/runtime/server/render/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { RouteData, SSRResult } from '../../../types/public/internal.js';
import { renderToAsyncIterable, renderToReadableStream, renderToString } from './astro/render.js';
import { encoder } from './common.js';
import { type NonAstroPageComponent, renderComponentToString } from './component.js';
import { markHTMLString } from '../escape.js';
import { renderCspContent } from './csp.js';
import type { AstroComponentFactory } from './index.js';
import { isDeno, isNode } from './util.js';
Expand Down Expand Up @@ -32,7 +33,13 @@ export async function renderPage(
// then process it through the queue system

// Call the component function to get the vnode tree
const vnode = await (componentFactory as any)(pageProps);
let vnode = await (componentFactory as any)(pageProps);

// .html pages return plain strings that are already valid HTML.
// Mark them as safe HTML so the queue builder doesn't escape the content.
if ((componentFactory as any)['astro:html'] && typeof vnode === 'string') {
vnode = markHTMLString(vnode);
}

// Build a render queue from the vnode tree
const queue = await buildRenderQueue(
Expand Down
82 changes: 82 additions & 0 deletions packages/astro/test/units/render/queue-rendering.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, it } from 'node:test';
import { buildRenderQueue } from '../../../dist/runtime/server/render/queue/builder.js';
import { renderQueue } from '../../../dist/runtime/server/render/queue/renderer.js';
import { NodePool } from '../../../dist/runtime/server/render/queue/pool.js';
import { renderPage } from '../../../dist/runtime/server/render/page.js';

/**
* Tests for the queue-based rendering engine
Expand Down Expand Up @@ -261,3 +262,84 @@ describe('Queue-based rendering engine', () => {
});
});
});

/**
* Regression tests for issue #16053:
* queuedRendering breaks .html pages by escaping their raw HTML string output.
*/
describe('renderPage() with queuedRendering and .html pages', () => {
function createMockResultWithQueue() {
const pool = new NodePool(1000);
return {
_metadata: {
hasHydrationScript: false,
rendererSpecificHydrationScripts: new Set(),
hasRenderedHead: false,
renderedScripts: new Set(),
hasDirectives: new Set(),
hasRenderedServerIslandRuntime: false,
headInTree: false,
extraHead: [],
extraStyleHashes: [],
extraScriptHashes: [],
propagators: new Set(),
},
styles: new Set(),
scripts: new Set(),
links: new Set(),
componentMetadata: new Map(),
cancelled: false,
compressHTML: false,
partial: false,
response: { status: 200, statusText: 'OK', headers: new Headers() },
shouldInjectCspMetaTags: false,
_experimentalQueuedRendering: {
enabled: true,
pool,
},
};
}

it('does not escape HTML tags when rendering a .html page component', async () => {
// Simulate the component factory generated by vite-plugin-html for a .html file.
// These return a plain string and have `astro:html = true`.
const htmlPageFactory = function render(_props) {
return '<body>\n <script src="https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js"></script>\n</body>';
};
htmlPageFactory['astro:html'] = true;
htmlPageFactory.moduleId = 'src/pages/admin/index.html';

const result = createMockResultWithQueue();

const response = await renderPage(result, htmlPageFactory, {}, null, false);
const html = await response.text();

// The raw <script> tag must appear verbatim — not HTML-escaped
assert.ok(
html.includes('<script src="https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js"></script>'),
`Expected unescaped <script> tag in output, got:\n${html}`,
);
assert.ok(
!html.includes('&lt;script'),
`Expected no HTML-escaped tags in output, got:\n${html}`,
);
});

it('still escapes HTML in non-.html page components with queuedRendering', async () => {
// A regular (non-.html) component factory should NOT have astro:html = true,
// so raw string output from it should be treated as text and escaped.
const regularFactory = function render(_props) {
return '<script>alert("xss")</script>';
};
// No astro:html flag set — this is the default for non-.html components
regularFactory.moduleId = 'src/pages/regular.astro';

const result = createMockResultWithQueue();

const response = await renderPage(result, regularFactory, {}, null, false);
const html = await response.text();

assert.ok(!html.includes('<script>alert'), `Expected escaped output, got:\n${html}`);
assert.ok(html.includes('&lt;script&gt;'), `Expected HTML-escaped output, got:\n${html}`);
});
});
Loading