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/dirty-bees-carry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes head metadata propagation in dev for adapters that load modules in the `prerender` Vite environment, such as `@astrojs/cloudflare`. The `astro:head-metadata` plugin previously only tracked the `ssr` environment, so `maybeRenderHead()` could fire inside an unrelated component's `<template>` element, trapping subsequent hoisted `<style>` blocks.
62 changes: 41 additions & 21 deletions packages/astro/src/vite-plugin-head/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,24 @@ const VIRTUAL_COMPONENT_METADATA = 'virtual:astro:component-metadata';
const RESOLVED_VIRTUAL_COMPONENT_METADATA = `\0${VIRTUAL_COMPONENT_METADATA}`;

export default function configHeadVitePlugin(): vite.Plugin {
let environment: DevEnvironment;
// Adapters like `@astrojs/cloudflare` load page modules in the `prerender`
// environment; default dev uses `ssr`. Track both so propagation covers either.
let environments: DevEnvironment[] = [];

function findModule(id: string) {
for (const env of environments) {
const mod = env.moduleGraph.getModuleById(id);
if (mod) return mod;
}
return undefined;
}

function invalidateComponentMetadataModule() {
const virtualMod = environment.moduleGraph.getModuleById(RESOLVED_VIRTUAL_COMPONENT_METADATA);
if (virtualMod) {
environment.moduleGraph.invalidateModule(virtualMod);
for (const env of environments) {
const virtualMod = env.moduleGraph.getModuleById(RESOLVED_VIRTUAL_COMPONENT_METADATA);
if (virtualMod) {
env.moduleGraph.invalidateModule(virtualMod);
}
}
}

Expand All @@ -50,7 +62,7 @@ export default function configHeadVitePlugin(): vite.Plugin {
const current = queue.pop()!;
if (collected.has(current)) continue;
collected.add(current);
const mod = environment.moduleGraph.getModuleById(current);
const mod = findModule(current);
for (const importer of mod?.importers ?? []) {
if (importer.id) {
queue.push(importer.id);
Expand All @@ -60,7 +72,7 @@ export default function configHeadVitePlugin(): vite.Plugin {

// Convert Vite's module graph shape into our plain importer adjacency map.
return buildImporterGraphFromModuleInfo(collected, (id) => {
const mod = environment.moduleGraph.getModuleById(id);
const mod = findModule(id);
if (!mod) return null;
return {
importers: Array.from(mod.importers)
Expand Down Expand Up @@ -100,7 +112,10 @@ export default function configHeadVitePlugin(): vite.Plugin {
enforce: 'pre',
apply: 'serve',
configureServer(devServer) {
environment = devServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr];
environments = [
devServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr],
devServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.prerender],
].filter((e): e is DevEnvironment => !!e);
devServer.watcher.on('add', invalidateComponentMetadataModule);
devServer.watcher.on('unlink', invalidateComponentMetadataModule);
devServer.watcher.on('change', invalidateComponentMetadataModule);
Expand All @@ -110,21 +125,26 @@ export default function configHeadVitePlugin(): vite.Plugin {
return;
}

const seen = new Set<string>();
const componentMetadataEntries: [string, SSRComponentMetadata][] = [];
for (const [moduleId, mod] of environment.moduleGraph.idToModuleMap) {
const info = this.getModuleInfo(moduleId) ?? (mod.id ? this.getModuleInfo(mod.id) : null);
if (!info) continue;

const astro = getAstroMetadata(info);
if (!astro) continue;

componentMetadataEntries.push([
moduleId,
{
containsHead: astro.containsHead,
propagation: astro.propagation,
},
]);
for (const env of environments) {
for (const [moduleId, mod] of env.moduleGraph.idToModuleMap) {
if (seen.has(moduleId)) continue;
const info = this.getModuleInfo(moduleId) ?? (mod.id ? this.getModuleInfo(mod.id) : null);
if (!info) continue;

const astro = getAstroMetadata(info);
if (!astro) continue;

seen.add(moduleId);
componentMetadataEntries.push([
moduleId,
{
containsHead: astro.containsHead,
propagation: astro.propagation,
},
]);
}
}

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { defineConfig } from 'astro/config';
import testAdapter from '../../test-adapter.js';

const devPrerenderMiddlewareSymbol = Symbol.for('astro.devPrerenderMiddleware');

/**
* Mimics what `@astrojs/cloudflare` does in dev: register a dedicated
* `prerender` Vite environment and flip the core dev-prerender middleware
* switch. This is the adapter-level setup that exercises the bug fixed by
* tracking the `prerender` environment in the `astro:head-metadata` plugin.
*/
function prerenderEnvIntegration() {
return {
name: 'test:prerender-env',
hooks: {
'astro:config:setup': ({ updateConfig }) => {
updateConfig({
vite: {
plugins: [
{
name: 'test:prerender-env',
config() {
return {
environments: {
prerender: { dev: {} },
},
};
},
configureServer(server) {
server[devPrerenderMiddlewareSymbol] = true;
},
},
],
},
});
},
},
};
}

export default defineConfig({
output: 'server',
adapter: testAdapter(),
integrations: [prerenderEnvIntegration()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/head-propagation-prerender-env",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
// Mirrors Starlight's `<Icon>`: each instance ships its own scoped `<style>`.
// In dev these are hoisted as `<style data-vite-dev-id>` blocks emitted at the
// current SSR render position. The bug traps them between a `<template>`
// element's opening tag and its first child.
const { name } = Astro.props;
---
<svg class={`icon icon-${name}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
{name === 'sun' && <circle cx="12" cy="12" r="5"></circle>}
{name === 'moon' && <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>}
{name === 'laptop' && <rect x="2" y="4" width="20" height="14" rx="2"></rect>}
</svg>

<style>
.icon {
width: 1em;
height: 1em;
display: inline-block;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
import ThemeIcons from './ThemeIcons.astro';
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>{title}</title>
<ThemeIcons />
</head>
<body>
<slot />
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
// Mirrors Starlight's `ThemeProvider.astro`: a `<template id="theme-icons">`
// lives inside `<head>` and holds several child components that each own
// scoped CSS. When head propagation is missing the hoisted style blocks
// get emitted between the template's opening tag and its first child,
// trapping the styles inside the inert `<template>`.
import Icon from './Icon.astro';
---
<template id="theme-icons">
<Icon name="sun" />
<Icon name="moon" />
<Icon name="laptop" />
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
export const prerender = true;

import Layout from '../components/Layout.astro';
---
<Layout title="Head propagation through prerender env">
<p>Hello</p>
</Layout>
57 changes: 57 additions & 0 deletions packages/astro/test/head-propagation-prerender-env.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { loadFixture } from './test-utils.js';

// Regression test for https://github.com/withastro/astro/issues/16291
//
// Adapters like `@astrojs/cloudflare` load prerendered page modules in a
// dedicated `prerender` Vite environment. The `astro:head-metadata` dev
// plugin used to track only the `ssr` environment, so `containsHead` /
// `propagation` were never computed for those modules and `maybeRenderHead()`
// could fire inside an unrelated component's `<template>` element, trapping
// the hoisted `<style data-vite-dev-id>` blocks that Vite injects in dev.
// Because `<template>` content is inert per the HTML spec, any style block
// trapped inside is non-functional and the page renders unstyled.
//
// The fixture mirrors the Starlight `<ThemeProvider>` shape from the bug
// report (a `<template id="theme-icons">` inside `<head>` holding Icon
// components with their own scoped CSS) and the integration mirrors what
// `@astrojs/cloudflare` does in dev (registers a `prerender` Vite
// environment and flips on the core dev-prerender middleware).
describe('Head propagation across ssr and prerender envs in dev', () => {
let fixture;
let devServer;

before(async () => {
fixture = await loadFixture({
root: './fixtures/head-propagation-prerender-env/',
});
devServer = await fixture.startDevServer();
});

after(async () => {
await devServer?.stop();
});

it('does not trap hoisted dev styles inside a <template> on prerendered routes', async () => {
const res = await fixture.fetch('/');
const html = await res.text();

const templateOpen = html.indexOf('<template');
const templateClose = html.indexOf('</template>');
assert.ok(
templateOpen !== -1 && templateClose !== -1 && templateOpen < templateClose,
'Expected the fixture to render a <template> element',
);

// The regression is an inline `<style data-vite-dev-id>` block emitted
// between the template's opening tag and its first child. Mirrors the
// one-liner from the bug report so the failure mode is easy to
// recognize in the output.
const styleInsideTemplate = html.indexOf('<style data-vite-dev-id', templateOpen);
assert.ok(
styleInsideTemplate === -1 || styleInsideTemplate > templateClose,
'Hoisted <style data-vite-dev-id> block must not be nested inside <template>',
);
});
});
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading