Skip to content

fix(astro): stop the head-metadata plugin invalidating its own virtual module - #18007

Merged
matthewp merged 4 commits into
withastro:mainfrom
L4XB:fix/17995-head-metadata-invalidation-loop
Sep 16, 2026
Merged

matthewp merged 4 commits into
withastro:mainfrom
L4XB:fix/17995-head-metadata-invalidation-loop

Conversation

@L4XB

@L4XB L4XB commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Changes

Fixes #17995.

astro:head-metadata ends its transform hook with an unconditional
invalidateComponentMetadataModule()
(packages/astro/src/vite-plugin-head/index.ts:197). That runs for every
module it transforms — including \0virtual:astro:component-metadata itself,
which the plugin also loads.

That closes a cycle:

  1. A request evaluates \0virtual:astro:component-metadata. It is invalidated,
    so Vite re-transforms it, and the transform hook fires for it.
  2. The hook invalidates it again. Its only importer in the ssr graph is the
    pre-bundled dev app entrypoint
    (node_modules/.vite/deps_ssr/astro_app_entrypoint_dev.js), because
    entrypoints/virtual/dev.ts:4 statically imports dev-nonrunnable.js, whose
    headElements() imports the virtual module
    (packages/astro/src/core/environment/dev-nonrunnable.ts:101). The
    invalidation walks up that chain to the SSR entry.
  3. On the next request the module runner sees the entry chain invalidated and
    re-evaluates all of it — which re-fetches the virtual module, and we are back
    at step 1.

The cycle never converges, so a dev server on a non-runnable environment
(@astrojs/cloudflare and other adapters that run requests outside Vite's
module runner) re-evaluates the whole server module graph on every request for
the lifetime of the process.

The fix is to skip the invalidation when the module being transformed is the
virtual module. It reports metadata that other modules own and contributes none
of its own, so its own transform can never change what it emits. Every other
caller is unchanged, so a component that really does change head metadata still
invalidates.

Testing

Measured with @astrojs/cloudflare on a two-page project with a layout that
holds <head>, counting invalidations of the metadata module, calls into the
astro:head-metadata transform hook, and fetchModule calls per request. Same
fixture, same script, only the built packages/astro differs.

Before (main @ c2e6b0d7c7), after touch src/pages/index.astro:

  /                   ttfb=0.022626s  metadata-invalidations=1  head-transforms=1  fetchModule=34
  /                   ttfb=0.022161s  metadata-invalidations=1  head-transforms=1  fetchModule=34
  /                   ttfb=0.022951s  metadata-invalidations=1  head-transforms=1  fetchModule=34
  /nonexistent-route  ttfb=0.021122s  metadata-invalidations=1  head-transforms=1  fetchModule=32
  /nonexistent-route  ttfb=0.025208s  metadata-invalidations=1  head-transforms=1  fetchModule=32
  /nonexistent-route  ttfb=0.019701s  metadata-invalidations=1  head-transforms=1  fetchModule=32

After, same sequence:

  /                   ttfb=0.026430s  metadata-invalidations=0  head-transforms=1  fetchModule=34
  /                   ttfb=0.014279s  metadata-invalidations=0  head-transforms=0  fetchModule=6
  /                   ttfb=0.012144s  metadata-invalidations=0  head-transforms=0  fetchModule=6
  /nonexistent-route  ttfb=0.010486s  metadata-invalidations=0  head-transforms=0  fetchModule=4
  /nonexistent-route  ttfb=0.017429s  metadata-invalidations=0  head-transforms=1  fetchModule=32
  /nonexistent-route  ttfb=0.009857s  metadata-invalidations=0  head-transforms=0  fetchModule=4

The graph is re-evaluated once after the edit and then settles, instead of once
per request forever. This project is small enough that the wall-clock difference
is only ~2x; the cost scales with the size of the server graph.

Two tests, both failing on main and passing here:

  • packages/astro/test/units/vite-plugin-head/head-metadata.test.ts — asserts
    the plugin does not invalidate the metadata module when transforming it, and
    that a component carrying head content still does.

    # main
    ✖ does not invalidate the metadata module when transforming it
      AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
      + actual - expected
      + [ '\x00virtual:astro:component-metadata' ]
      - []
    
    # this branch
    ✔ does not invalidate the metadata module when transforming it (0.789ms)
    ✔ invalidates the metadata module when a component carrying head content is transformed (0.245791ms)
    
  • packages/integrations/cloudflare/test/head-metadata-invalidation.test.ts —
    boots a dev server and counts the invalidations the transform hook issues once
    the graph is warm. It also edits the layout's <head> and asserts the new
    markup is served, so a fix that stopped the loop by dropping a real update
    would fail here.

    # main
    ✖ stops invalidating the metadata module once the graph is warm
      AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
      16 !== 13
    ✔ serves updated head content after a layout edit (279.741709ms)
    
    # this branch
    ✔ stops invalidating the metadata module once the graph is warm (36.55975ms)
    ✔ serves updated head content after a layout edit (215.588459ms)
    

Also run on this branch:

  • packages/astro unit suite — 3524 tests, 0 failures
  • packages/astro integration suite — 0 failures (five files stalled under load
    on my machine — endpoint-response, endpoint-routing, endpoint-runtime,
    env-secret, hmr-markdown, none of which touch head metadata — and were
    re-run on their own: also 0 failures)
  • packages/integrations/cloudflare full suite — 390 tests, 0 failures
  • pnpm lint:ai — clean (the one noConsole warning in
    packages/language-tools is present on main too)
  • tsc -b in packages/astro — clean

One thing this does not change, noted while measuring: the watcher listeners
registered in configureServer
(packages/astro/src/vite-plugin-head/index.ts:123-125) invalidate on any
add/unlink/change under the project root with no path filter. That is the
head-transforms=1 fetchModule=32 row in the "after" output above — an
unrelated write costs one full re-evaluation. It is bounded and self-correcting,
unlike the loop, so I left it alone rather than widening the diff. Happy to
follow up if you'd like it filtered.

Docs

No user-facing behaviour changes, so no docs needed.

File and line references are against main @ c2e6b0d7c7.

…l 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 withastro#17995
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.
@changeset-bot

changeset-bot Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0713352

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added pkg: integration Related to any renderer integration (scope) pkg: astro Related to the core `astro` package (scope) labels Sep 15, 2026
@codspeed

codspeed Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 21 untouched benchmarks


Comparing L4XB:fix/17995-head-metadata-invalidation-loop (0713352) with main (30ef3cb)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (24946f7) during the generation of this report, so 30ef3cb was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@L4XB

L4XB commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

The red Test (Smoke) job is not from this branch — it is the @lunariajs/core install gate, and it is red on main too.

Here:

ERR_PNPM_NO_MATURE_MATCHING_VERSION
  @lunariajs/core@0.2.0 was published at 2026-09-14T02:13:40.802Z,
  within the minimumReleaseAge cutoff (2026-09-12T16:52:03.194Z)

On main at this PR's merge base (c2e6b0d7c, run 34975763362) the same job fails at the same install step on the same package, with the other half of the same plumbing problem:

ERR_PNPM_FETCH_404
  GET https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@722c34c: Not Found - 404

The three most recent completed ci.yml runs on main are all failures.

This branch's pnpm-lock.yaml diff contains no @lunariajs lines at all — the lockfile change here is only the new packages/integrations/cloudflare/test/fixtures/head-metadata-invalidation fixture. Nothing to re-run on my side; it should clear once the version question is settled upstream.

Everything else on this PR is green.

@matthewp
matthewp merged commit 2245837 into withastro:main Sep 16, 2026
30 checks passed
@astrobot-houston astrobot-houston mentioned this pull request Sep 16, 2026
dadezzz pushed a commit to dadezzz/university_notes that referenced this pull request Sep 27, 2026
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [astro](https://astro.build) ([source](https://github.com/withastro/astro/tree/HEAD/packages/astro)) | [`7.3.3` → `7.3.4`](https://renovatebot.com/diffs/npm/astro/7.3.3/7.3.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/astro/7.3.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/astro/7.3.3/7.3.4?slim=true) |

---

### Release Notes

<details>
<summary>withastro/astro (astro)</summary>

### [`v7.3.4`](https://github.com/withastro/astro/blob/HEAD/packages/astro/CHANGELOG.md#734)

[Compare Source](https://github.com/withastro/astro/compare/astro@7.3.3...astro@7.3.4)

##### Patch Changes

- [#&#8203;18063](withastro/astro#18063) [`40896ac`](withastro/astro@40896ac) Thanks [@&#8203;adamchal](https://github.com/adamchal)! - Fixes incremental builds repeatedly rendering unchanged pages when modules or compiled CSS reference bundled assets.

- [#&#8203;18053](withastro/astro#18053) [`cf5d72f`](withastro/astro@cf5d72f) Thanks [@&#8203;Princesseuh](https://github.com/Princesseuh)! - Improves the `astro check` error shown for TypeScript 7. The command now explains that TypeScript 7 is not currently supported and provides instructions for experimentally type-checking Astro files with TypeScript 7.1 and `@astrojs/ts-content-mapper`.

- [#&#8203;18086](withastro/astro#18086) [`795a7e4`](withastro/astro@795a7e4) Thanks [@&#8203;ump45nose](https://github.com/ump45nose)! - Fix double-escaped ampersands in Markdown image `alt` and `title` attributes. The `__ASTRO_IMAGE_` round-trip now decodes the numeric (`&#x26;`) and named (`&amp;`) character references the Markdown processors emit, so an `&` in an alt or title is escaped exactly once in the final HTML instead of twice.

- [#&#8203;18074](withastro/astro#18074) [`0429805`](withastro/astro@0429805) Thanks [@&#8203;SurefireStudios](https://github.com/SurefireStudios)! - Fix three error names that did not match their documented reference. `MissingLocale`, `MissingIndexForInternationalization` and `NoManifestAvailable` reported names ending in `Error` in the dev overlay, while their error reference pages are published under the unsuffixed names, so the name shown to users could not be found in the docs.

- [#&#8203;18007](withastro/astro#18007) [`2245837`](withastro/astro@2245837) Thanks [@&#8203;L4XB](https://github.com/L4XB)! - 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.

- [#&#8203;18096](withastro/astro#18096) [`43657c4`](withastro/astro@43657c4) Thanks [@&#8203;matthewp](https://github.com/matthewp)! - Fixes domain-based i18n routing to respect `security.allowedDomains` when selecting a locale from request host headers

- [#&#8203;18043](withastro/astro#18043) [`8a53a8b`](withastro/astro@8a53a8b) Thanks [@&#8203;astro-factory](https://github.com/apps/astro-factory)! - Fixes `image.responsiveStyles` emitting invalid `object-position` CSS values for same-axis keyword pairs (`top bottom`, `left right`, etc.)

- [#&#8203;18029](withastro/astro#18029) [`c08252d`](withastro/astro@c08252d) Thanks [@&#8203;matthewp](https://github.com/matthewp)! - Runs `astro dev` and `astro preview` in the foreground when an AI agent is detected on Windows, allowing the agent to manage the process lifetime. Pass `--background` explicitly to request an Astro-managed background process. Agent-inferred backgrounding remains enabled on other platforms.

- Updated dependencies \[[`3fd16ee`](withastro/astro@3fd16ee), [`8358d59`](withastro/astro@8358d59)]:
  - [@&#8203;astrojs/markdown-satteri](https://github.com/astrojs/markdown-satteri)@0.4.2

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMTUuMiIsInVwZGF0ZWRJblZlciI6IjQ0LjExNS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg: astro Related to the core `astro` package (scope) pkg: integration Related to any renderer integration (scope)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dev server re-evaluates the whole server module graph on every request after the first source edit (head-metadata plugin invalidates on every transform)

2 participants