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/fix-hmr-slots-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

fix(hmr): eagerly recompile on style-only change to prevent stale slots render
18 changes: 14 additions & 4 deletions packages/astro/src/vite-plugin-astro/hmr.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import type { HmrContext } from 'vite';
import type { Logger } from '../core/logger/core.js';
import type { CompileAstroResult } from './compile.js';
import { parseAstroRequest } from './query.js';
import type { CompileMetadata } from './types.js';
import { frontmatterRE } from './utils.js';

interface HandleHotUpdateOptions {
logger: Logger;
compile: (code: string, filename: string) => Promise<CompileAstroResult>;
astroFileToCompileMetadata: Map<string, CompileMetadata>;
}

export async function handleHotUpdate(
ctx: HmrContext,
{ logger, astroFileToCompileMetadata }: HandleHotUpdateOptions,
{ logger, compile, astroFileToCompileMetadata }: HandleHotUpdateOptions,
) {
// HANDLING 1: Invalidate compile metadata if CSS dependency updated
//
Expand All @@ -35,9 +37,17 @@ export async function handleHotUpdate(

if (isStyleOnlyChanged(oldCode, newCode)) {
logger.debug('watch', 'style-only change');
// Invalidate its `astroFileToCompileMetadata` so that the next transform of Astro style virtual module
// will re-generate it
astroFileToCompileMetadata.delete(ctx.file);
// Eagerly re-compile to update the metadata with the new CSS. This ensures
// the compile metadata stays consistent so that subsequent SSR requests
// (e.g. page refresh) can load the style virtual modules without needing
// to re-compile from disk, avoiding potential stale state.
try {
await compile(newCode, ctx.file);
} catch {
// If re-compilation fails, fall back to deleting the metadata so the
// load hook will re-compile lazily on the next request.
astroFileToCompileMetadata.delete(ctx.file);
}
return ctx.modules.filter((mod) => {
if (!mod.id) {
return false;
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/vite-plugin-astro/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ export default function astro({ settings, logger }: AstroPluginOptions): vite.Pl
},
},
async handleHotUpdate(ctx) {
return handleHotUpdate(ctx, { logger, astroFileToCompileMetadata });
return handleHotUpdate(ctx, { logger, compile, astroFileToCompileMetadata });
},
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from 'astro/config';

export default defineConfig({});
8 changes: 8 additions & 0 deletions packages/astro/test/fixtures/hmr-slots-render/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/hmr-slots-render",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
type Props = {
items: unknown[];
};

const { items, ...attrs } = Astro.props;
---

<ul {...attrs}>
{
items.map((item, index, list) => (
<li set:html={Astro.slots.render("default", [item, index, list])} />
))
}
</ul>

<style>
ul {
margin: 0;
font-size: 0.5rem;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
const { name } = Astro.props;
---
<div class="item-wrapper">{name}</div>

<style>
.item-wrapper {
padding: 0.25rem;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
import Each from '../components/Each.astro';
import Item from '../components/Item.astro';
const items = ['one', 'two', 'three'];
---
<html>
<head>
<title>HMR Slots Render Test</title>
</head>
<body>
<div id="result">
<Each items={items}>
{(item) => <Item name={item} />}
</Each>
</div>
</body>
</html>
71 changes: 71 additions & 0 deletions packages/astro/test/hmr-slots-render.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import * as cheerio from 'cheerio';
import { isWindows, loadFixture } from './test-utils.js';

describe('HMR: slots.render with callback args after style change', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
/** @type {import('./test-utils').DevServer} */
let devServer;

before(async () => {
fixture = await loadFixture({ root: './fixtures/hmr-slots-render/' });
devServer = await fixture.startDevServer();
});

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

function verifyRendering($, label) {
const items = $('#result .item-wrapper');
assert.ok(
items.length >= 3,
`[${label}] Expected 3 item-wrappers, got ${items.length}. HTML:\n${$('#result').html()?.substring(0, 500)}`,
);
assert.equal($(items[0]).text(), 'one');
assert.equal($(items[1]).text(), 'two');
assert.equal($(items[2]).text(), 'three');

// Verify no escaped HTML source code visible (the bug symptom from #15925)
const resultText = $('#result').text();
assert.ok(
!resultText.includes('data-astro-cid'),
`[${label}] Found escaped data-astro-cid in output: ${resultText.substring(0, 300)}`,
);
}

it(
'should render after style change in the slot-render component',
{ skip: isWindows },
async () => {
// Initial fetch - verify correct rendering
let res = await fixture.fetch('/');
assert.equal(res.status, 200);
verifyRendering(cheerio.load(await res.text()), 'initial');

// Style-only edit (triggers HMR style-only path)
await fixture.editFile('/src/components/Each.astro', (c) =>
c.replace('font-size: 0.5rem;', 'font-size: 1rem;'),
);
await new Promise((r) => setTimeout(r, 500));

// Page refresh after HMR - must still render correctly
res = await fixture.fetch('/');
assert.equal(res.status, 200);
verifyRendering(cheerio.load(await res.text()), 'after style change');

// Second style edit + refresh
await fixture.editFile('/src/components/Each.astro', (c) =>
c.replace('font-size: 1rem;', 'font-size: 2rem;'),
);
await new Promise((r) => setTimeout(r, 500));

res = await fixture.fetch('/');
assert.equal(res.status, 200);
verifyRendering(cheerio.load(await res.text()), 'after 2nd style change');
},
);
});
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading