diff --git a/.changeset/fix-queued-rendering-html-pages.md b/.changeset/fix-queued-rendering-html-pages.md new file mode 100644 index 000000000000..8db14e449fe5 --- /dev/null +++ b/.changeset/fix-queued-rendering-html-pages.md @@ -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. diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts index f7ccd333f81b..4ee7f51ad579 100644 --- a/packages/astro/src/runtime/server/render/page.ts +++ b/packages/astro/src/runtime/server/render/page.ts @@ -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'; @@ -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( diff --git a/packages/astro/test/units/render/queue-rendering.test.js b/packages/astro/test/units/render/queue-rendering.test.js index 4a6ac17d62f1..724812396a7f 100644 --- a/packages/astro/test/units/render/queue-rendering.test.js +++ b/packages/astro/test/units/render/queue-rendering.test.js @@ -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 @@ -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 '\n \n'; + }; + 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 '), + `Expected unescaped '; + }; + // 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('