From 71337304965425820e81cc27d54b95d23033c017 Mon Sep 17 00:00:00 2001
From: thelazylama <67310144+thelazylamaGit@users.noreply.github.com>
Date: Mon, 10 Aug 2026 18:16:37 +1000
Subject: [PATCH] fix(transitions): preserve CSS HMR across ClientRouter
navigations (#17612)
* Preserve Vite style HMR across ClientRouter navigations
* add changeset
* lint fix
* Address Reviewer Notes
* cover updated Vite style contents with test
* update comment pr reference
* Improve Comments
---------
Co-authored-by: Martin Trapp <94928215+martrapp@users.noreply.github.com>
---
.changeset/bright-styles-return.md | 5 ++
.../src/components/SvelteMessage.svelte | 7 ++
.../src/components/SvelteStyleParent.svelte | 5 ++
.../src/components/client-router-hmr.css | 3 +
.../src/pages/island-svelte-one.astro | 4 ++
.../src/pages/island-svelte-two.astro | 1 +
.../view-transitions/src/pages/one.astro | 1 +
packages/astro/e2e/view-transitions.test.ts | 65 +++++++++++++++++++
.../astro/src/transitions/swap-functions.ts | 65 +++++++++++++++----
9 files changed, 142 insertions(+), 14 deletions(-)
create mode 100644 .changeset/bright-styles-return.md
create mode 100644 packages/astro/e2e/fixtures/view-transitions/src/components/SvelteMessage.svelte
create mode 100644 packages/astro/e2e/fixtures/view-transitions/src/components/SvelteStyleParent.svelte
create mode 100644 packages/astro/e2e/fixtures/view-transitions/src/components/client-router-hmr.css
diff --git a/.changeset/bright-styles-return.md b/.changeset/bright-styles-return.md
new file mode 100644
index 000000000000..58349ca743a7
--- /dev/null
+++ b/.changeset/bright-styles-return.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Fixes CSS hot module replacement after navigating between pages with `ClientRouter`
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/components/SvelteMessage.svelte b/packages/astro/e2e/fixtures/view-transitions/src/components/SvelteMessage.svelte
new file mode 100644
index 000000000000..1b8315433244
--- /dev/null
+++ b/packages/astro/e2e/fixtures/view-transitions/src/components/SvelteMessage.svelte
@@ -0,0 +1,7 @@
+
Nested component
+
+
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/components/SvelteStyleParent.svelte b/packages/astro/e2e/fixtures/view-transitions/src/components/SvelteStyleParent.svelte
new file mode 100644
index 000000000000..17d195b143db
--- /dev/null
+++ b/packages/astro/e2e/fixtures/view-transitions/src/components/SvelteStyleParent.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/components/client-router-hmr.css b/packages/astro/e2e/fixtures/view-transitions/src/components/client-router-hmr.css
new file mode 100644
index 000000000000..7fadba97a150
--- /dev/null
+++ b/packages/astro/e2e/fixtures/view-transitions/src/components/client-router-hmr.css
@@ -0,0 +1,3 @@
+#island-one {
+ color: blue;
+}
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/island-svelte-one.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/island-svelte-one.astro
index c60dfa9c9d10..b71cfb18dd3c 100644
--- a/packages/astro/e2e/fixtures/view-transitions/src/pages/island-svelte-one.astro
+++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/island-svelte-one.astro
@@ -1,11 +1,15 @@
---
import Counter from '../components/SvelteCounter.svelte';
+import StyleParent from '../components/SvelteStyleParent.svelte';
import Layout from '../components/Layout.astro';
+import '../components/client-router-hmr.css';
export const prerender = false;
---
Page 1
go to 2
+ leave route
+
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/island-svelte-two.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/island-svelte-two.astro
index 63222bacbc39..8c0499375cc8 100644
--- a/packages/astro/e2e/fixtures/view-transitions/src/pages/island-svelte-two.astro
+++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/island-svelte-two.astro
@@ -1,6 +1,7 @@
---
import Counter from '../components/SvelteCounter.svelte';
import Layout from '../components/Layout.astro';
+import '../components/client-router-hmr.css';
export const prerender = false;
---
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/one.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/one.astro
index eee063b6d416..05c3c8b7fdd6 100644
--- a/packages/astro/e2e/fixtures/view-transitions/src/pages/one.astro
+++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/one.astro
@@ -17,6 +17,7 @@ import Layout from '../components/Layout.astro';
go to undefined page
go to inline module
go to Vue scoped styles
+ go to Svelte styles
go to 2
diff --git a/packages/astro/e2e/view-transitions.test.ts b/packages/astro/e2e/view-transitions.test.ts
index de048b3c733d..f498d49fc008 100644
--- a/packages/astro/e2e/view-transitions.test.ts
+++ b/packages/astro/e2e/view-transitions.test.ts
@@ -634,6 +634,71 @@ test.describe('View Transitions', () => {
await expect(cnt).toHaveText('B1');
});
+ test('Vite styles keep HMR after returning to a route', async ({ page, astro }) => {
+ const expectLoads = collectLoads(page);
+ await page.goto(astro.resolveUrl('/one'));
+ await page.click('#click-svelte-styles');
+
+ const nestedMessage = page.locator('.nested-message');
+ const pageTitle = page.locator('#island-one');
+ await expect(nestedMessage).toHaveCSS('background-color', 'rgb(128, 0, 0)');
+ await expect(pageTitle).toHaveCSS('color', 'rgb(0, 0, 255)');
+
+ const nestedSvelteStyles = page.locator('style[data-vite-dev-id*="SvelteMessage.svelte"]');
+ const cssStyles = page.locator('style[data-vite-dev-id*="client-router-hmr.css"]');
+ await expect(nestedSvelteStyles).toHaveCount(1);
+ await expect(cssStyles).toHaveCount(1);
+ const nestedSvelteStyle = nestedSvelteStyles.first();
+ const cssStyle = cssStyles.first();
+ await nestedSvelteStyle.evaluate((element) => (element.dataset.hmrStyle = 'nested-svelte'));
+ await cssStyle.evaluate((element) => (element.dataset.hmrStyle = 'css'));
+
+ await page.click('#click-away');
+ await expect(page.locator('#one')).toHaveText('Page 1');
+ await page.goBack();
+ await expect(pageTitle).toBeVisible();
+
+ await expect(page.locator('style[data-hmr-style="nested-svelte"]')).toHaveCount(1);
+ await expect(page.locator('style[data-hmr-style="css"]')).toHaveCount(1);
+
+ await astro.editFile('./src/components/SvelteMessage.svelte', (contents) =>
+ contents.replace('background-color: maroon', 'background-color: navy'),
+ );
+ await expect(nestedMessage).toHaveCSS('background-color', 'rgb(0, 0, 128)');
+
+ await astro.editFile('./src/components/client-router-hmr.css', (contents) =>
+ contents.replace('color: blue', 'color: red'),
+ );
+ await expect(pageTitle).toHaveCSS('color', 'rgb(255, 0, 0)');
+ await expectLoads(1);
+ });
+
+ test('Vite style nodes receive updated contents during head swaps', async ({ page, astro }) => {
+ const expectLoads = collectLoads(page);
+ await page.goto(astro.resolveUrl('/island-svelte-one'));
+
+ const cssStyle = page.locator('style[data-vite-dev-id*="client-router-hmr.css"]');
+ await expect(cssStyle).toHaveCount(1);
+ await cssStyle.evaluate((element) => (element.dataset.hmrStyle = 'css'));
+ await page.evaluate(() => {
+ document.addEventListener(
+ 'astro:before-swap',
+ (event) => {
+ const incomingStyle = event.newDocument.querySelector(
+ 'style[data-vite-dev-id*="client-router-hmr.css"]',
+ );
+ if (incomingStyle) incomingStyle.textContent += '#island-two { color: red; }';
+ },
+ { once: true },
+ );
+ });
+
+ await page.click('#click-two');
+ await expect(page.locator('#island-two')).toHaveCSS('color', 'rgb(255, 0, 0)');
+ await expect(page.locator('style[data-hmr-style="css"]')).toHaveCount(1);
+ await expectLoads(1);
+ });
+
test('Vue Islands can persist using transition:persist', async ({ page, astro }) => {
// Go to page 1
await page.goto(astro.resolveUrl('/island-vue-one'));
diff --git a/packages/astro/src/transitions/swap-functions.ts b/packages/astro/src/transitions/swap-functions.ts
index b07a4b71d848..2f883afb3057 100644
--- a/packages/astro/src/transitions/swap-functions.ts
+++ b/packages/astro/src/transitions/swap-functions.ts
@@ -10,7 +10,37 @@ const PERSIST_ATTR = 'data-astro-transition-persist';
const NON_OVERRIDABLE_ASTRO_ATTRS = ['data-astro-transition', 'data-astro-transition-fallback'];
-const knownVueScopedStyles = new Map();
+// Vite's CSS HMR runtime keeps references to the style nodes it injects, so preserve
+// those nodes across ClientRouter head swaps. https://github.com/withastro/astro/pull/17612
+const viteStyleState = import.meta.env.DEV
+ ? (() => {
+ const styles = new Map();
+ let observer: MutationObserver | undefined;
+ return {
+ styles,
+ observe() {
+ if (observer) return;
+ observer = new MutationObserver((records) => {
+ for (const record of records) {
+ for (const node of record.addedNodes) {
+ if (!(node instanceof HTMLStyleElement)) continue;
+ const viteDevId = node.dataset.viteDevId;
+ if (!viteDevId) continue;
+ const knownStyle = styles.get(viteDevId);
+ if (node === knownStyle) continue;
+
+ // ClientRouter appends the fetched style before Vite injects the node
+ // registered for HMR. Replace it and track Vite's node instead.
+ knownStyle?.remove();
+ styles.set(viteDevId, node);
+ }
+ }
+ });
+ observer.observe(document.head, { childList: true });
+ },
+ };
+ })()
+ : undefined;
const scriptsAlreadyRan = new Set();
export function detectScriptExecuted(script: HTMLScriptElement) {
@@ -75,9 +105,8 @@ export function swapHeadElements(doc: Document) {
newEl.remove();
} else {
if (import.meta.env.DEV && el instanceof HTMLStyleElement) {
- // In DEV mode, keep updated Vue scoped styles for later reuse
- const viteDevId = vueScopedStyleId(el);
- viteDevId && knownVueScopedStyles.set(viteDevId, el);
+ const viteDevId = el.dataset.viteDevId;
+ viteDevId && viteStyleState?.styles.set(viteDevId, el);
}
// If the element does not exist in the new document, remove the element from current the head.
el.remove();
@@ -90,9 +119,23 @@ export function swapHeadElements(doc: Document) {
// Everything left in the new head is new, append it all.
if (import.meta.env.DEV) {
- // In DEV mode, replace known Vue scoped styles with the versions we remembered
relevantNodes(doc.head).forEach((child) => {
- document.head.append(knownVueScopedStyles.get((child as any).dataset?.viteDevId) || child);
+ const viteDevId = child instanceof HTMLStyleElement && child.dataset.viteDevId;
+ const knownStyle = viteDevId && viteStyleState?.styles.get(viteDevId);
+ if (knownStyle) {
+ // Generated styles such as UnoCSS can keep the same Vite ID while their CSS changes
+ // between routes, so copy the incoming CSS into the style element Vite uses for HMR.
+ // https://github.com/withastro/astro/pull/16242
+ // Vue scoped styles are excluded because their content may be transformed in the browser.
+ if (!vueScopedStyleId(knownStyle)) knownStyle.textContent = child.textContent;
+ document.head.append(knownStyle);
+ } else {
+ if (viteDevId) {
+ viteStyleState?.styles.set(viteDevId, child);
+ viteStyleState?.observe();
+ }
+ document.head.append(child);
+ }
});
} else {
document.head.append(...relevantNodes(doc.head));
@@ -252,14 +295,8 @@ const persistedHeadElement = (el: HTMLElement, newDoc: Document): Element | null
const href = el.getAttribute('href');
return newDoc.head.querySelector(`link[rel=stylesheet][href="${href}"]`);
}
- // In dev mode, Vite injects