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

Fixes CSS hot module replacement after navigating between pages with `ClientRouter`
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<p class="nested-message">Nested component</p>

<style>
.nested-message {
background-color: maroon;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script lang="ts">
import Message from './SvelteMessage.svelte';
</script>

<Message />
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#island-one {
color: blue;
}
Original file line number Diff line number Diff line change
@@ -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;

---
<Layout>
<p id="island-one">Page 1</p>
<a id="click-two" href="/island-svelte-two">go to 2</a>
<a id="click-away" href="/one">leave route</a>
<Counter prefix="A" client:load transition:persist transition:name="counter"/>
<StyleParent client:load />
</Layout>
Original file line number Diff line number Diff line change
@@ -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;
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import Layout from '../components/Layout.astro';
<a id="click-404" href="/undefined-page">go to undefined page</a>
<a id="click-inline-module" href="/inline-module">go to inline module</a>
<a id="click-vue-scoped-styles" href="/island-vue-one">go to Vue scoped styles</a>
<a id="click-svelte-styles" href="/island-svelte-one">go to Svelte styles</a>
<custom-a id="custom-click-two">
<template shadowrootmode="open">
<a href="/two">go to 2</a>
Expand Down
65 changes: 65 additions & 0 deletions packages/astro/e2e/view-transitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLStyleElement>(
'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'));
Expand Down
65 changes: 51 additions & 14 deletions packages/astro/src/transitions/swap-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, HTMLStyleElement>();
// 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<string, HTMLStyleElement>();
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<string>();
export function detectScriptExecuted(script: HTMLScriptElement) {
Expand Down Expand Up @@ -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();
Expand All @@ -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));
Expand Down Expand Up @@ -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 <style data-vite-dev-id="..."> elements whose
// textContent may later be transformed (especially Vue's `:deep()` → `[data-v-xxx]`).
// Match these by their stable dev ID so the already-transformed style is preserved
// across ClientRouter soft navigations instead of being replaced by the raw version.
// There are other ids that can't be preserved and need a refresh, like Uno's /__uno.css,
// which keeps the same id, but with different contents.
// To avoid enumerating all exceptions, we only apply the auto-persist logic to elements
// that look like Vue's dev styles.
// Vue scoped CSS may be transformed in the browser, so preserve its current contents
// across ClientRouter navigations. https://github.com/withastro/astro/pull/16379
if (import.meta.env.DEV && el instanceof HTMLStyleElement) {
const viteDevId = vueScopedStyleId(el);
if (viteDevId) {
Expand Down
Loading