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

Fixes the dev server re-evaluating the whole server module graph on every request. The `astro:head-metadata` plugin invalidated its component metadata virtual module from its own `transform` hook, so each evaluation of that module scheduled the next one. Adapters that run requests outside Vite's module runner, such as `@astrojs/cloudflare`, paid for a full re-evaluation of the server graph on every request for the lifetime of the process.
10 changes: 10 additions & 0 deletions packages/astro/src/vite-plugin-head/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,16 @@ export default function configHeadVitePlugin(): vite.Plugin {
}
},
transform(_source, id) {
if (id === RESOLVED_VIRTUAL_COMPONENT_METADATA) {
// The virtual module only reports metadata that other modules own, so
// transforming it cannot change what it emits. Invalidating it here
// would make every evaluation schedule the next one: the invalidation
// reaches the dev app entrypoint that imports it, the runner
// re-evaluates that chain on the next request, re-fetches this module,
// and arrives back here. https://github.com/withastro/astro/issues/17995
return;
}

let info = this.getModuleInfo(id);
if (info && getAstroMetadata(info)?.containsHead) {
// Keep bubbling `containsHead` when this module was already marked earlier.
Expand Down
71 changes: 71 additions & 0 deletions packages/astro/test/units/vite-plugin-head/head-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import configHeadVitePlugin from '../../../dist/vite-plugin-head/index.js';

const METADATA_ID = '\0virtual:astro:component-metadata';
const PAGE_ID = '/src/pages/index.astro';

function createMockModule(id: string) {
return { id, importers: new Set<any>() };
}

/**
* Wires the plugin the way a dev server does and records every
* `invalidateModule` call the plugin makes against the metadata module.
*/
function setupPlugin(moduleIds: string[]) {
const plugin = configHeadVitePlugin() as any;
const invalidated: string[] = [];
const modules = new Map(moduleIds.map((id) => [id, createMockModule(id)]));

const environment = {
name: 'ssr',
moduleGraph: {
idToModuleMap: modules,
getModuleById: (id: string) => modules.get(id),
invalidateModule: (mod: any) => invalidated.push(mod.id),
},
};

plugin.configureServer({
environments: { ssr: environment },
watcher: { on() {} },
});

return { plugin, invalidated };
}

describe('astro:head-metadata', () => {
// Regression test for https://github.com/withastro/astro/issues/17995
//
// The metadata virtual module is imported by the dev app entrypoint, so
// invalidating it invalidates that whole import chain. When the plugin
// invalidated it from its own `transform` hook, every evaluation of the
// module scheduled the next one and the module runner re-evaluated the
// server graph on every request for the rest of the session.
it('does not invalidate the metadata module when transforming it', () => {
const { plugin, invalidated } = setupPlugin([METADATA_ID]);
const context = { getModuleInfo: () => null };

plugin.transform.call(context, 'export const componentMetadataEntries = [];', METADATA_ID);

assert.deepEqual(invalidated, []);
});

it('invalidates the metadata module when a component carrying head content is transformed', () => {
const { plugin, invalidated } = setupPlugin([METADATA_ID, PAGE_ID]);
const context = {
getModuleInfo: (id: string) =>
id === PAGE_ID
? { id, meta: { astro: { containsHead: true, propagation: 'none' } } }
: null,
};

plugin.transform.call(context, '// compiled page', PAGE_ID);

assert.ok(
invalidated.includes(METADATA_ID),
'a component that carries head content must still invalidate the metadata module',
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import cloudflare from '@astrojs/cloudflare';
import { defineConfig } from 'astro/config';

const RESOLVED_VIRTUAL_COMPONENT_METADATA = '\0virtual:astro:component-metadata';

/**
* Counts how often `astro:head-metadata` invalidates its virtual module from
* its own `transform` hook, and serves the running total from
* `/__transform-invalidations`. `DevServer` exposes no route to the module
* graph, so the count has to be collected inside the Vite config the dev
* server runs with. Invalidations from the plugin's file watcher listeners are
* deliberately excluded: unrelated writes under the project root fire those at
* arbitrary times.
*/
function countTransformInvalidations() {
let count = 0;
let transforming = false;
return {
name: 'test:count-transform-invalidations',
enforce: 'post' as const,
apply: 'serve' as const,
configureServer(server: any) {
for (const environment of Object.values<any>(server.environments)) {
const moduleGraph = environment.moduleGraph;
if (typeof moduleGraph?.invalidateModule !== 'function') continue;
const invalidateModule = moduleGraph.invalidateModule.bind(moduleGraph);
moduleGraph.invalidateModule = (mod: any, ...rest: unknown[]) => {
if (transforming && mod?.id === RESOLVED_VIRTUAL_COMPONENT_METADATA) count++;
return invalidateModule(mod, ...rest);
};
}

const headPlugin = server.config.plugins.find(
(plugin: any) => plugin.name === 'astro:head-metadata',
);
const transform = headPlugin?.transform;
if (typeof transform === 'function') {
headPlugin.transform = function (this: unknown, ...args: unknown[]) {
transforming = true;
try {
return transform.apply(this, args);
} finally {
transforming = false;
}
};
}

server.middlewares.use((req: any, res: any, next: () => void) => {
if (req.url !== '/__transform-invalidations') return next();
res.end(String(count));
});
},
};
}

export default defineConfig({
output: 'server',
adapter: cloudflare(),
vite: {
plugins: [countTransformInvalidations()],
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "@test/astro-cloudflare-head-metadata-invalidation",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:*",
"astro": "workspace:*"
},
"devDependencies": {
"wrangler": "^4.83.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
const { name } = Astro.props;
---

<svg class={`icon icon-${name}`} viewBox="0 0 24 24">
{name === 'sun' ? <circle cx="12" cy="12" r="5" /> : <path d="M21 12.79A9 9 0 1 1 11.21 3" />}
</svg>

<style>
.icon {
width: 1em;
height: 1em;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
import Icon from './Icon.astro';
---

<template id="theme-icons">
<Icon name="sun" />
<Icon name="moon" />
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
const { title } = Astro.props;
---

<html lang="en">
<!-- head-placeholder -->
<body>
<slot />
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
import Layout from '../layouts/Layout.astro';
---

<Layout title="Home">
<h1>Home</h1>
</Layout>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "head-metadata-invalidation",
"compatibility_date": "2026-01-28",
"main": "@astrojs/cloudflare/entrypoints/server"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { type DevServer, type Fixture, loadFixture } from './test-utils.ts';

// Regression test for https://github.com/withastro/astro/issues/17995
//
// `virtual:astro:component-metadata` is imported by the dev app entrypoint, so
// invalidating it invalidates that entire import chain and the module runner
// re-evaluates the server graph on the next request. The plugin invalidated the
// module from its own `transform` hook, so evaluating it scheduled the next
// evaluation and every request from then on paid for a full re-evaluation.
describe('Head metadata invalidation in dev', () => {
let fixture: Fixture;
let devServer: DevServer;

async function transformInvalidations() {
const res = await fixture.fetch('/__transform-invalidations');
return Number(await res.text());
}

before(async () => {
fixture = await loadFixture({
root: './fixtures/head-metadata-invalidation/',
});
devServer = await fixture.startDevServer();
// Warm the graph: the first requests legitimately invalidate while the
// page and its layout are compiled for the first time.
await fixture.fetch('/');
await fixture.fetch('/');
});

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

it('stops invalidating the metadata module once the graph is warm', async () => {
const baseline = await transformInvalidations();
await fixture.fetch('/');
await fixture.fetch('/');
await fixture.fetch('/');
assert.equal(await transformInvalidations(), baseline);
});

it('refreshes propagated head metadata after a layout adds a head', async () => {
await fixture.editFile('./src/layouts/Layout.astro', (content) =>
content
.replace(
'const { title } = Astro.props;',
"import ThemeIcons from '../components/ThemeIcons.astro';\nconst { title } = Astro.props;",
)
.replace(
'\t<!-- head-placeholder -->',
`\t<head>
<meta charset="utf-8" />
<title>{title}</title>
<ThemeIcons />
</head>`,
),
);

const html = await (await fixture.fetch('/')).text();
const templateOpen = html.indexOf('<template id="theme-icons">');
const templateClose = html.indexOf('</template>');
assert.ok(templateOpen !== -1 && templateClose > templateOpen);

assert.match(html, /<style data-vite-dev-id=/);
assert.doesNotMatch(
html.slice(templateOpen, templateClose),
/<style data-vite-dev-id=/,
'fresh containsHead metadata must keep injected styles outside an inert template',
);
});
});
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

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

Loading