Skip to content

Commit b51f297

Browse files
authored
Preserve head metadata in Cloudflare dev rendering (#16161)
* Update dev head metadata for non-runnable pipeline * Refine non-runnable component metadata loading * Load component metadata in non-runnable dev * Remove unused export for virtual component metadata constant * Add docs for virtual component metadata module
1 parent 1d1448c commit b51f297

4 files changed

Lines changed: 87 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
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

packages/astro/dev-only.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ declare module 'virtual:astro:dev-css-all' {
7878
export const devCSSMap: Map<string, () => Promise<{ css: Set<ImportedDevStyles> }>>;
7979
}
8080

81+
declare module 'virtual:astro:component-metadata' {
82+
import type { SSRComponentMetadata } from './src/types/public/internal.js';
83+
export const componentMetadataEntries: [string, SSRComponentMetadata][];
84+
}
85+
8186
declare module 'virtual:astro:app' {
8287
export const createApp: import('./src/core/app/types.js').CreateApp;
8388
}

packages/astro/src/core/app/dev/pipeline.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
RouteData,
66
SSRElement,
77
} from '../../../types/public/index.js';
8+
import type { SSRComponentMetadata } from '../../../types/public/internal.js';
89
import { type HeadElements, Pipeline, type TryRewriteResult } from '../../base-pipeline.js';
910
import { ASTRO_VERSION } from '../../constants.js';
1011
import { createModuleScriptElement, createStylesheetElementSet } from '../../render/ssr-element.js';
@@ -58,6 +59,17 @@ export class NonRunnablePipeline extends Pipeline {
5859
}
5960

6061
async headElements(routeData: RouteData): Promise<HeadElements> {
62+
// NonRunnablePipeline cannot call getComponentMetadata() (requires a ModuleLoader) so we
63+
// hydrate the manifest's componentMetadata from the virtual module exposed by vite-plugin-head.
64+
// This ensures head placement (containsHead / headInTree) is correct for adapters that run
65+
// requests outside of Vite's module runner, such as Cloudflare.
66+
const { componentMetadataEntries } = (await import('virtual:astro:component-metadata')) as {
67+
componentMetadataEntries: [string, SSRComponentMetadata][];
68+
};
69+
for (const [id, entry] of componentMetadataEntries) {
70+
this.manifest.componentMetadata.set(id, entry);
71+
}
72+
6173
const { assetsPrefix, base } = this.manifest;
6274
const routeInfo = this.manifest.routes.find((route) => route.routeData === routeData);
6375
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.

packages/astro/src/vite-plugin-head/index.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,35 @@ import { getAstroMetadata } from '../vite-plugin-astro/index.js';
1313
import type { PluginMetadata } from '../vite-plugin-astro/types.js';
1414
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js';
1515

16+
/**
17+
* A dev-only virtual module that exposes accumulated component metadata (containsHead, propagation)
18+
* as a serialized array that can be statically imported.
19+
*
20+
* This exists to serve pipelines that cannot do live module graph traversal at request time —
21+
* specifically `NonRunnablePipeline`, used by adapters like Cloudflare that run requests through
22+
* their own server runtime rather than Vite's runner. Those pipelines cannot call
23+
* `getComponentMetadata()` (which requires a `ModuleLoader`), so they import this virtual module
24+
* instead to get equivalent metadata.
25+
*
26+
* The `RunnablePipeline` does NOT use this module; it calls `getComponentMetadata()` directly,
27+
* which traverses the live Vite module graph and produces more accurate per-request data.
28+
*
29+
* The virtual module is invalidated whenever metadata propagation runs (on transform, resolveId)
30+
* and on file add/unlink, ensuring it stays fresh during HMR.
31+
*/
32+
const VIRTUAL_COMPONENT_METADATA = 'virtual:astro:component-metadata';
33+
const RESOLVED_VIRTUAL_COMPONENT_METADATA = `\0${VIRTUAL_COMPONENT_METADATA}`;
34+
1635
export default function configHeadVitePlugin(): vite.Plugin {
1736
let environment: DevEnvironment;
1837

38+
function invalidateComponentMetadataModule() {
39+
const virtualMod = environment.moduleGraph.getModuleById(RESOLVED_VIRTUAL_COMPONENT_METADATA);
40+
if (virtualMod) {
41+
environment.moduleGraph.invalidateModule(virtualMod);
42+
}
43+
}
44+
1945
function buildImporterGraphFromEnvironment(seed: string) {
2046
// Start from one changed/imported module and walk upward to collect ancestors.
2147
const queue: string[] = [seed];
@@ -65,16 +91,51 @@ export default function configHeadVitePlugin(): vite.Plugin {
6591
}
6692
}
6793
}
94+
95+
invalidateComponentMetadataModule();
6896
}
6997

7098
return {
7199
name: 'astro:head-metadata',
72100
enforce: 'pre',
73101
apply: 'serve',
74-
configureServer(server) {
75-
environment = server.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr];
102+
configureServer(devServer) {
103+
environment = devServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr];
104+
devServer.watcher.on('add', invalidateComponentMetadataModule);
105+
devServer.watcher.on('unlink', invalidateComponentMetadataModule);
106+
devServer.watcher.on('change', invalidateComponentMetadataModule);
107+
},
108+
load(id) {
109+
if (id !== RESOLVED_VIRTUAL_COMPONENT_METADATA) {
110+
return;
111+
}
112+
113+
const componentMetadataEntries: [string, SSRComponentMetadata][] = [];
114+
for (const [moduleId, mod] of environment.moduleGraph.idToModuleMap) {
115+
const info = this.getModuleInfo(moduleId) ?? (mod.id ? this.getModuleInfo(mod.id) : null);
116+
if (!info) continue;
117+
118+
const astro = getAstroMetadata(info);
119+
if (!astro) continue;
120+
121+
componentMetadataEntries.push([
122+
moduleId,
123+
{
124+
containsHead: astro.containsHead,
125+
propagation: astro.propagation,
126+
},
127+
]);
128+
}
129+
130+
return {
131+
code: `export const componentMetadataEntries = ${JSON.stringify(componentMetadataEntries)};`,
132+
};
76133
},
77134
resolveId(source, importer) {
135+
if (source === VIRTUAL_COMPONENT_METADATA) {
136+
return RESOLVED_VIRTUAL_COMPONENT_METADATA;
137+
}
138+
78139
if (importer) {
79140
// Do propagation any time a new module is imported. This is because
80141
// A module with propagation might be loaded before one of its parent pages
@@ -108,6 +169,8 @@ export default function configHeadVitePlugin(): vite.Plugin {
108169
// `// astro-head-inject` and `//! astro-head-inject` opt a module into bubbling.
109170
propagateMetadata.call(this, id, 'propagation', 'in-tree');
110171
}
172+
173+
invalidateComponentMetadataModule();
111174
},
112175
};
113176
}

0 commit comments

Comments
 (0)