From d597993566cc0d82da71ee6ebd394ea48bfa8e26 Mon Sep 17 00:00:00 2001 From: L4XB Date: Tue, 15 Sep 2026 18:29:54 +0200 Subject: [PATCH 1/3] fix(astro): stop the head-metadata plugin invalidating its own virtual module The `astro:head-metadata` transform hook invalidated `virtual:astro:component-metadata` unconditionally, including when the module being transformed was that virtual module itself. The dev app entrypoint imports it, so each invalidation reached the SSR entry chain, the module runner re-evaluated it on the next request, re-fetched the virtual module, and the transform hook invalidated it again. Dev servers using a non-runnable environment re-evaluated the whole server module graph on every request for the lifetime of the process. Fixes #17995 --- .changeset/rotten-jars-check.md | 5 +++++ packages/astro/src/vite-plugin-head/index.ts | 10 ++++++++++ 2 files changed, 15 insertions(+) create mode 100644 .changeset/rotten-jars-check.md diff --git a/.changeset/rotten-jars-check.md b/.changeset/rotten-jars-check.md new file mode 100644 index 000000000000..4124bd6c4d29 --- /dev/null +++ b/.changeset/rotten-jars-check.md @@ -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. diff --git a/packages/astro/src/vite-plugin-head/index.ts b/packages/astro/src/vite-plugin-head/index.ts index a8b41467f584..a79b71df2a3e 100644 --- a/packages/astro/src/vite-plugin-head/index.ts +++ b/packages/astro/src/vite-plugin-head/index.ts @@ -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. From 23f890cdf9614e60d7dc0c27a3cc391c6caaf902 Mon Sep 17 00:00:00 2001 From: L4XB Date: Tue, 15 Sep 2026 18:30:02 +0200 Subject: [PATCH 2/3] test(astro): cover the head-metadata invalidation loop Adds a unit test asserting the plugin does not invalidate the metadata module when transforming it, and a Cloudflare dev-server test that counts the invalidations the transform hook issues once the graph is warm and checks that a layout head edit still reaches the served HTML. --- .../vite-plugin-head/head-metadata.test.ts | 71 +++++++++++++++++++ .../astro.config.ts | 62 ++++++++++++++++ .../head-metadata-invalidation/package.json | 12 ++++ .../src/layouts/Layout.astro | 14 ++++ .../src/pages/index.astro | 7 ++ .../head-metadata-invalidation/wrangler.jsonc | 5 ++ .../test/head-metadata-invalidation.test.ts | 52 ++++++++++++++ pnpm-lock.yaml | 13 ++++ 8 files changed, 236 insertions(+) create mode 100644 packages/astro/test/units/vite-plugin-head/head-metadata.test.ts create mode 100644 packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/astro.config.ts create mode 100644 packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/package.json create mode 100644 packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/layouts/Layout.astro create mode 100644 packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/pages/index.astro create mode 100644 packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/wrangler.jsonc create mode 100644 packages/integrations/cloudflare/test/head-metadata-invalidation.test.ts diff --git a/packages/astro/test/units/vite-plugin-head/head-metadata.test.ts b/packages/astro/test/units/vite-plugin-head/head-metadata.test.ts new file mode 100644 index 000000000000..4326953263b8 --- /dev/null +++ b/packages/astro/test/units/vite-plugin-head/head-metadata.test.ts @@ -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() }; +} + +/** + * 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', + ); + }); +}); diff --git a/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/astro.config.ts b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/astro.config.ts new file mode 100644 index 000000000000..85899c17739b --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/astro.config.ts @@ -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(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()], + }, +}); diff --git a/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/package.json b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/package.json new file mode 100644 index 000000000000..a04a472e1c8b --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/package.json @@ -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" + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/layouts/Layout.astro b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/layouts/Layout.astro new file mode 100644 index 000000000000..2944557bc700 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/layouts/Layout.astro @@ -0,0 +1,14 @@ +--- +const { title } = Astro.props; +--- + + + + + {title} + + + + + + diff --git a/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/pages/index.astro new file mode 100644 index 000000000000..bdd37284abc7 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/pages/index.astro @@ -0,0 +1,7 @@ +--- +import Layout from '../layouts/Layout.astro'; +--- + + +

Home

+
diff --git a/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/wrangler.jsonc b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/wrangler.jsonc new file mode 100644 index 000000000000..031d411bc0e8 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "head-metadata-invalidation", + "compatibility_date": "2026-01-28", + "main": "@astrojs/cloudflare/entrypoints/server" +} diff --git a/packages/integrations/cloudflare/test/head-metadata-invalidation.test.ts b/packages/integrations/cloudflare/test/head-metadata-invalidation.test.ts new file mode 100644 index 000000000000..ab6660e953a3 --- /dev/null +++ b/packages/integrations/cloudflare/test/head-metadata-invalidation.test.ts @@ -0,0 +1,52 @@ +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('serves updated head content after a layout edit', async () => { + await fixture.editFile('./src/layouts/Layout.astro', (content) => + content.replace('content="original"', 'content="edited"'), + ); + + const html = await (await fixture.fetch('/')).text(); + assert.match(html, / Date: Wed, 16 Sep 2026 11:01:29 -0400 Subject: [PATCH 3/3] test(astro): verify head metadata refreshes after HMR --- .../src/components/Icon.astro | 14 ++++++++++ .../src/components/ThemeIcons.astro | 8 ++++++ .../src/layouts/Layout.astro | 6 +---- .../test/head-metadata-invalidation.test.ts | 27 ++++++++++++++++--- 4 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/components/Icon.astro create mode 100644 packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/components/ThemeIcons.astro diff --git a/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/components/Icon.astro b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/components/Icon.astro new file mode 100644 index 000000000000..0b62eb1fdc23 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/components/Icon.astro @@ -0,0 +1,14 @@ +--- +const { name } = Astro.props; +--- + + + {name === 'sun' ? : } + + + diff --git a/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/components/ThemeIcons.astro b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/components/ThemeIcons.astro new file mode 100644 index 000000000000..994731765f14 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/components/ThemeIcons.astro @@ -0,0 +1,8 @@ +--- +import Icon from './Icon.astro'; +--- + + diff --git a/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/layouts/Layout.astro b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/layouts/Layout.astro index 2944557bc700..c8416bc683cb 100644 --- a/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/layouts/Layout.astro +++ b/packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation/src/layouts/Layout.astro @@ -3,11 +3,7 @@ const { title } = Astro.props; --- - - - {title} - - + diff --git a/packages/integrations/cloudflare/test/head-metadata-invalidation.test.ts b/packages/integrations/cloudflare/test/head-metadata-invalidation.test.ts index ab6660e953a3..baf5e91e4178 100644 --- a/packages/integrations/cloudflare/test/head-metadata-invalidation.test.ts +++ b/packages/integrations/cloudflare/test/head-metadata-invalidation.test.ts @@ -41,12 +41,33 @@ describe('Head metadata invalidation in dev', () => { assert.equal(await transformInvalidations(), baseline); }); - it('serves updated head content after a layout edit', async () => { + it('refreshes propagated head metadata after a layout adds a head', async () => { await fixture.editFile('./src/layouts/Layout.astro', (content) => - content.replace('content="original"', 'content="edited"'), + content + .replace( + 'const { title } = Astro.props;', + "import ThemeIcons from '../components/ThemeIcons.astro';\nconst { title } = Astro.props;", + ) + .replace( + '\t', + `\t + + {title} + + `, + ), ); const html = await (await fixture.fetch('/')).text(); - assert.match(html, /'); + const templateClose = html.indexOf(''); + assert.ok(templateOpen !== -1 && templateClose > templateOpen); + + assert.match(html, /