Skip to content
5 changes: 5 additions & 0 deletions .changeset/cloudflare-dev-head-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes a dev rendering issue with the Cloudflare adapter where head metadata could be missing and dev CSS/scripts could be injected in the wrong place
5 changes: 5 additions & 0 deletions packages/astro/dev-only.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ declare module 'virtual:astro:dev-css-all' {
export const devCSSMap: Map<string, () => Promise<{ css: Set<ImportedDevStyles> }>>;
}

declare module 'virtual:astro:component-metadata' {
import type { SSRComponentMetadata } from './src/types/public/internal.js';
export const componentMetadataEntries: [string, SSRComponentMetadata][];
}

declare module 'virtual:astro:app' {
export const createApp: import('./src/core/app/types.js').CreateApp;
}
12 changes: 12 additions & 0 deletions packages/astro/src/core/app/dev/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
RouteData,
SSRElement,
} from '../../../types/public/index.js';
import type { SSRComponentMetadata } from '../../../types/public/internal.js';
import { type HeadElements, Pipeline, type TryRewriteResult } from '../../base-pipeline.js';
import { ASTRO_VERSION } from '../../constants.js';
import { createModuleScriptElement, createStylesheetElementSet } from '../../render/ssr-element.js';
Expand Down Expand Up @@ -58,6 +59,17 @@ export class NonRunnablePipeline extends Pipeline {
}

async headElements(routeData: RouteData): Promise<HeadElements> {
// NonRunnablePipeline cannot call getComponentMetadata() (requires a ModuleLoader) so we
// hydrate the manifest's componentMetadata from the virtual module exposed by vite-plugin-head.
// This ensures head placement (containsHead / headInTree) is correct for adapters that run
// requests outside of Vite's module runner, such as Cloudflare.
const { componentMetadataEntries } = (await import('virtual:astro:component-metadata')) as {
componentMetadataEntries: [string, SSRComponentMetadata][];
};
for (const [id, entry] of componentMetadataEntries) {
this.manifest.componentMetadata.set(id, entry);
}

const { assetsPrefix, base } = this.manifest;
const routeInfo = this.manifest.routes.find((route) => route.routeData === routeData);
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
Expand Down
67 changes: 65 additions & 2 deletions packages/astro/src/vite-plugin-head/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,35 @@ import { getAstroMetadata } from '../vite-plugin-astro/index.js';
import type { PluginMetadata } from '../vite-plugin-astro/types.js';
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js';

/**
* A dev-only virtual module that exposes accumulated component metadata (containsHead, propagation)
* as a serialized array that can be statically imported.
*
* This exists to serve pipelines that cannot do live module graph traversal at request time —
* specifically `NonRunnablePipeline`, used by adapters like Cloudflare that run requests through
* their own server runtime rather than Vite's runner. Those pipelines cannot call
* `getComponentMetadata()` (which requires a `ModuleLoader`), so they import this virtual module
* instead to get equivalent metadata.
*
* The `RunnablePipeline` does NOT use this module; it calls `getComponentMetadata()` directly,
* which traverses the live Vite module graph and produces more accurate per-request data.
*
* The virtual module is invalidated whenever metadata propagation runs (on transform, resolveId)
* and on file add/unlink, ensuring it stays fresh during HMR.
*/
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;

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

function buildImporterGraphFromEnvironment(seed: string) {
// Start from one changed/imported module and walk upward to collect ancestors.
const queue: string[] = [seed];
Expand Down Expand Up @@ -65,16 +91,51 @@ export default function configHeadVitePlugin(): vite.Plugin {
}
}
}

invalidateComponentMetadataModule();
}

return {
name: 'astro:head-metadata',
enforce: 'pre',
apply: 'serve',
configureServer(server) {
environment = server.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr];
configureServer(devServer) {
environment = devServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr];
devServer.watcher.on('add', invalidateComponentMetadataModule);
devServer.watcher.on('unlink', invalidateComponentMetadataModule);
devServer.watcher.on('change', invalidateComponentMetadataModule);
},
load(id) {
if (id !== RESOLVED_VIRTUAL_COMPONENT_METADATA) {
return;
}

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,
},
]);
}

return {
code: `export const componentMetadataEntries = ${JSON.stringify(componentMetadataEntries)};`,
};
},
resolveId(source, importer) {
if (source === VIRTUAL_COMPONENT_METADATA) {
return RESOLVED_VIRTUAL_COMPONENT_METADATA;
}

if (importer) {
// Do propagation any time a new module is imported. This is because
// A module with propagation might be loaded before one of its parent pages
Expand Down Expand Up @@ -108,6 +169,8 @@ export default function configHeadVitePlugin(): vite.Plugin {
// `// astro-head-inject` and `//! astro-head-inject` opt a module into bubbling.
propagateMetadata.call(this, id, 'propagation', 'in-tree');
}

invalidateComponentMetadataModule();
},
};
}
Expand Down
Loading