diff --git a/.changeset/cloudflare-dev-head-metadata.md b/.changeset/cloudflare-dev-head-metadata.md new file mode 100644 index 000000000000..61749b335dd6 --- /dev/null +++ b/.changeset/cloudflare-dev-head-metadata.md @@ -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 diff --git a/packages/astro/dev-only.d.ts b/packages/astro/dev-only.d.ts index 96f3a94d7924..a4c1e7ea9e71 100644 --- a/packages/astro/dev-only.d.ts +++ b/packages/astro/dev-only.d.ts @@ -78,6 +78,11 @@ declare module 'virtual:astro:dev-css-all' { export const devCSSMap: Map Promise<{ css: Set }>>; } +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; } diff --git a/packages/astro/src/core/app/dev/pipeline.ts b/packages/astro/src/core/app/dev/pipeline.ts index 846740729c3b..f8f996eab2f6 100644 --- a/packages/astro/src/core/app/dev/pipeline.ts +++ b/packages/astro/src/core/app/dev/pipeline.ts @@ -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'; @@ -58,6 +59,17 @@ export class NonRunnablePipeline extends Pipeline { } async headElements(routeData: RouteData): Promise { + // 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. diff --git a/packages/astro/src/vite-plugin-head/index.ts b/packages/astro/src/vite-plugin-head/index.ts index 914b8c5b5c30..6d2fe1f366f8 100644 --- a/packages/astro/src/vite-plugin-head/index.ts +++ b/packages/astro/src/vite-plugin-head/index.ts @@ -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]; @@ -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 @@ -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(); }, }; }