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
7 changes: 7 additions & 0 deletions .changeset/fix-custom-element-slot-attribute.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'astro': patch
---

Fixes custom elements in MDX having their children's `slot` attribute stripped by the JSX runtime

When custom elements (tags with hyphens like `<my-element>`) are used in MDX files, the `slot` HTML attribute on their children is now correctly preserved. Previously, the shared JSX runtime would treat `slot` as an Astro slot assignment and remove it from the output, breaking Shadow DOM named slot distribution for web components.
9 changes: 9 additions & 0 deletions .changeset/fix-stale-css-hmr-16780.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'astro': patch
---

Fixes stale inline CSS in server-rendered HTML after CSS file edits during dev

When editing a CSS file (`.css`, `.scss`, etc.) during development, the inline `<style>` tags in server-rendered HTML would retain old CSS content instead of updating. This caused a brief flash of old CSS (FOUC) on fresh page loads before Vite's client-side HMR corrected the styles.

The fix ensures that Astro's per-route dev CSS virtual modules are invalidated in both the SSR module graph and the module runner's evaluation cache when a style file changes, so the next page render picks up the fresh CSS.
7 changes: 6 additions & 1 deletion packages/astro/src/runtime/server/jsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ Did you forget to import the component or is it possible there is a typo?`);
const _slots: Record<string, any> = {
default: [],
};
// For custom HTML elements (string type with hyphen), the `slot` attribute on children
// is a standard HTML attribute for web component Shadow DOM slot distribution, not an
// Astro slot assignment. Skip slot extraction to preserve it in the output.
const isCustomElement =
typeof vnode.type === 'string' && (vnode.type as string).includes('-');
function extractSlots(child: any): any {
if (Array.isArray(child)) {
return child.map((c) => extractSlots(c));
Expand All @@ -131,7 +136,7 @@ Did you forget to import the component or is it possible there is a typo?`);
_slots.default.push(child);
return;
}
if ('slot' in child.props) {
if ('slot' in child.props && !isCustomElement) {
_slots[child.props.slot] = [...(_slots[child.props.slot] ?? []), child];
delete child.props.slot;
return;
Expand Down
32 changes: 26 additions & 6 deletions packages/astro/src/vite-plugin-hmr-reload/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import { isRunnableDevEnvironment, type EnvironmentModuleNode, type Plugin } from 'vite';
import { VIRTUAL_PAGE_RESOLVED_MODULE_ID } from '../vite-plugin-pages/const.js';
import { RESOLVED_MODULE_DEV_CSS_PREFIX } from '../vite-plugin-css/const.js';
import { getDevCssModuleNameFromPageVirtualModuleName } from '../vite-plugin-css/util.js';
import { isAstroServerEnvironment } from '../environments.js';

const STYLE_EXT_REGEX = /\.(?:css|scss|sass|less|styl|pcss)$/i;
const RAW_QUERY_REGEX = /(?:\?|&)raw(?:&|$)/;

function hasStyleExtension(id: string): boolean {
// Style module IDs may include Vite query params such as ?used or ?direct.
return STYLE_EXT_REGEX.test(id.split('?')[0]);
}

function isStyleModule(mod: EnvironmentModuleNode): boolean {
if (mod.file && STYLE_EXT_REGEX.test(mod.file)) return true;
// CSS imported with ?raw is a JS string export, so SSR importers need to be invalidated
// instead of relying on Vite's client-side CSS HMR handling.
if (mod.id && RAW_QUERY_REGEX.test(mod.id) && hasStyleExtension(mod.id)) return false;
if (mod.file && hasStyleExtension(mod.file)) return true;
// CSS modules and other style files may have query params in their id (e.g. ?used, ?direct)
if (mod.id) {
const idPath = mod.id.split('?')[0];
if (STYLE_EXT_REGEX.test(idPath)) return true;
}
return false;
return mod.id ? hasStyleExtension(mod.id) : false;
}

/**
Expand Down Expand Up @@ -97,6 +103,20 @@ export default function hmrReload(): Plugin {
// Vite's built-in style update mechanism, which works for all pages
// (with or without framework components).
if (hasSkippedStyleModules) {
// Invalidate all per-route dev CSS virtual modules so the next SSR request
// re-collects CSS with updated content. Without this, the inline <style>
// tags injected for anti-FOUC would serve stale CSS after HMR updates.
for (const [id, mod] of this.environment.moduleGraph.idToModuleMap) {
if (id.startsWith(RESOLVED_MODULE_DEV_CSS_PREFIX)) {
this.environment.moduleGraph.invalidateModule(mod, undefined, timestamp, true);
if (isRunnableDevEnvironment(this.environment)) {
const runnerMod = this.environment.runner.evaluatedModules.getModuleById(id);
if (runnerMod) {
this.environment.runner.evaluatedModules.invalidateModule(runnerMod);
}
}
}
}
return [];
}

Expand Down
70 changes: 70 additions & 0 deletions packages/astro/test/css-dev-css-hmr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { setTimeout as delay } from 'node:timers/promises';

import * as cheerio from 'cheerio';
import { loadFixture, type DevServer, type Fixture } from './test-utils.ts';

describe('CSS - dev CSS HMR', () => {
let fixture: Fixture;
let devServer: DevServer;

before(
async () => {
fixture = await loadFixture({
root: './fixtures/css-dev-css-hmr/',
});

devServer = await fixture.startDevServer();
},
{ timeout: 30000 },
);

after(async () => {
await devServer.stop();
});

async function getInlineStyles(pathname: string) {
const response = await fixture.fetch(pathname);
assert.equal(response.status, 200);

const html = await response.text();
const $ = cheerio.load(html);

return $('style').text();
}

async function waitForInlineStyles(pathname: string, pattern: RegExp) {
let styles = '';

for (let i = 0; i < 20; i++) {
styles = await getInlineStyles(pathname);

if (pattern.test(styles)) {
return styles;
}

await delay(50);
}

assert.match(styles, pattern);
return styles;
}

it('updates server-rendered inline CSS after a style-only change', {
timeout: 30000,
}, async () => {
const beforeStyles = await getInlineStyles('/posts/test');

assert.match(beforeStyles, /rgb\(255,\s*0,\s*0\)/);
assert.doesNotMatch(beforeStyles, /rgb\(0,\s*0,\s*255\)/);

await fixture.editFile('src/styles/global.css', (contents) =>
contents.replace('rgb(255, 0, 0)', 'rgb(0, 0, 255)'),
);

const afterStyles = await waitForInlineStyles('/posts/test', /rgb\(0,\s*0,\s*255\)/);

assert.doesNotMatch(afterStyles, /rgb\(255,\s*0,\s*0\)/);
});
});
3 changes: 3 additions & 0 deletions packages/astro/test/fixtures/css-dev-css-hmr/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from 'astro/config';

export default defineConfig({});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
import '../../styles/global.css';

export function getStaticPaths() {
return [{ params: { slug: 'test' } }];
}

const { slug } = Astro.params;
---

<html>
<head></head>
<body>
<p class="hmr-target">Post: {slug}</p>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.hmr-target {
color: rgb(255, 0, 0);
}
Loading
Loading