Skip to content
5 changes: 5 additions & 0 deletions .changeset/fix-issue-16373-unocss-view-transitions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fix UnoCSS `@apply` and `--at-apply` styles breaking on ClientRouter soft navigations during `astro dev`. CSS is now cached after integration plugins have processed it, and Astro component styles are persisted across navigations.
14 changes: 13 additions & 1 deletion packages/astro/src/core/create-vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ export async function createVite(
// Validate that envPrefix doesn't conflict with secret env schema variables
validateEnvPrefixAgainstSchema(settings.config);

// Create the dev CSS plugin, which returns the main plugins and a separate
// CSS cache plugin that must be registered after integration plugins.
const devCss = astroDevCssPlugin({ routesList, command });

// Start with the Vite configuration that Astro core needs
const commonConfig: vite.InlineConfig = {
// Tell Vite not to combine config from vite.config.js with our provided inline config
Expand Down Expand Up @@ -187,7 +191,7 @@ export async function createVite(
vitePluginApp(),
command === 'dev' && vitePluginAstroServer({ settings, logger }),
command === 'dev' && vitePluginAstroServerClient(),
astroDevCssPlugin({ routesList, command }),
...devCss.plugins,
importMetaEnv({ envLoader }),
astroEnv({ settings, sync, envLoader }),
vitePluginAdapterConfig(settings),
Expand Down Expand Up @@ -323,6 +327,14 @@ export async function createVite(
}
result = vite.mergeConfig(result, commandConfig);

// Add the CSS cache plugin AFTER integration plugins have been merged.
// This ensures the CSS caching transform runs after integration transforms
// (e.g. UnoCSS's @apply directive processing) so we cache fully-processed CSS.
// Only push in dev mode — the cache plugin becomes a no-op during build.
if (command === 'dev') {
(result.plugins!).push(devCss.cssCachePlugin);
}

return result;
}

Expand Down
36 changes: 21 additions & 15 deletions packages/astro/src/transitions/swap-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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>();
const knownDevStyles = new Map<string, HTMLStyleElement>();

const scriptsAlreadyRan = new Set<string>();
export function detectScriptExecuted(script: HTMLScriptElement) {
Expand Down Expand Up @@ -64,9 +64,9 @@ 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);
// In DEV mode, keep updated dev styles for later reuse
const viteDevId = devStyleId(el);
viteDevId && knownDevStyles.set(viteDevId, el);
}
// If the element does not exist in the new document, remove the element from current the head.
el.remove();
Expand All @@ -75,9 +75,9 @@ 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
// In DEV mode, replace known dev styles with the versions we remembered
[...doc.head.children].forEach((child) => {
document.head.append(knownVueScopedStyles.get((child as any).dataset?.viteDevId) || child);
document.head.append(knownDevStyles.get((child as any).dataset?.viteDevId) || child);
});
} else {
document.head.append(...doc.head.children);
Expand Down Expand Up @@ -187,15 +187,19 @@ export const restoreFocus = ({ activeElement, start, end }: SavedFocus) => {
}
};

export const vueScopedStyleId = (el: HTMLStyleElement): string => {
export const devStyleId = (el: HTMLStyleElement): string => {
const viteDevId = el.dataset.viteDevId || '';

const url = new URL(viteDevId, location.href);
return url.searchParams.get('vue') !== null &&
// Match dev-mode styles that need auto-persistence across soft navigations.
// Vue scoped styles: ?vue&type=style&scoped
// Astro component styles: ?astro&type=style&...lang.<ext>
const isVueScoped =
url.searchParams.get('vue') !== null &&
url.searchParams.get('type') === 'style' &&
url.searchParams.has('scoped')
? viteDevId
: '';
url.searchParams.has('scoped');
const isAstroStyle = /\?astro&type=style&.*lang\.[a-z0-9]+$/.test(viteDevId);
return isVueScoped || isAstroStyle ? viteDevId : '';
};

// Check for a head element that should persist and returns it,
Expand All @@ -211,15 +215,17 @@ const persistedHeadElement = (el: HTMLElement, newDoc: Document): Element | null
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]`).
// textContent may later be transformed (especially Vue's `:deep()` → `[data-v-xxx]`
// or Astro component styles with integration transforms applied).
// 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.
// We apply the auto-persist logic to styles that look like Vue or Astro component styles,
// whose content is stable for a given component but may differ between the raw HTML
// (pre-integration-transform) and the HMR-applied version (post-transform).
if (import.meta.env.DEV && el instanceof HTMLStyleElement) {
const viteDevId = vueScopedStyleId(el);
const viteDevId = devStyleId(el);
if (viteDevId) {
return newDoc.head.querySelector(`style[data-vite-dev-id="${viteDevId}"]`);
}
Expand Down
73 changes: 50 additions & 23 deletions packages/astro/src/vite-plugin-css/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,21 @@ function* collectCSSWithOrder(
*
* @param routesList
*/
export function astroDevCssPlugin({ routesList, command }: AstroVitePluginOptions): Plugin[] {
interface AstroDevCssPluginResult {
plugins: Plugin[];
/**
* A separate plugin that caches CSS content during `transform`.
* This must be added to the Vite config AFTER integration plugins so that
* CSS transforms from integrations (e.g. UnoCSS's `@apply` directives)
* have already been applied before content is cached.
*/
cssCachePlugin: Plugin;
}

export function astroDevCssPlugin({
routesList,
command,
}: AstroVitePluginOptions): AstroDevCssPluginResult {
let server: vite.ViteDevServer | undefined;
// Cache CSS content by module ID to avoid re-reading
const cssContentCache = new Map<string, string>();
Expand All @@ -151,7 +165,39 @@ export function astroDevCssPlugin({ routesList, command }: AstroVitePluginOption
);
}

return [
const cssCachePlugin: Plugin = {
name: 'astro:dev-css-cache',
applyToEnvironment(env) {
return (
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr ||
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.client ||
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender
);
},
transform: {
filter: {
id: {
include: [CSS_LANGS_RE],
exclude: [rawRE, inlineRE],
},
},
handler(code, id) {
if (command === 'build') {
return;
}
// Cache CSS content after all plugins (including integration plugins like
// UnoCSS's transformerDirectives) have transformed it. This plugin is
// registered after integration plugins to ensure we cache fully-processed CSS.
const env = getCurrentEnvironment(this.environment as DevEnvironment);
const mod = env?.moduleGraph.getModuleById(id);
if (mod) {
cssContentCache.set(id, code);
}
},
},
};

const plugins: Plugin[] = [
{
name: MODULE_DEV_CSS,

Expand Down Expand Up @@ -252,27 +298,6 @@ export function astroDevCssPlugin({ routesList, command }: AstroVitePluginOption
}
},
},

transform: {
filter: {
id: {
include: [CSS_LANGS_RE],
exclude: [rawRE, inlineRE],
},
},
handler(code, id) {
if (command === 'build') {
return;
}

// Cache CSS content as we see it
const env = getCurrentEnvironment(this.environment as DevEnvironment);
const mod = env?.moduleGraph.getModuleById(id);
if (mod) {
cssContentCache.set(id, code);
}
},
},
},
{
name: MODULE_DEV_CSS_ALL,
Expand Down Expand Up @@ -309,4 +334,6 @@ export function astroDevCssPlugin({ routesList, command }: AstroVitePluginOption
},
},
];

return { plugins, cssCachePlugin };
}
Loading