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

Fixes stale scoped styles during HMR when both markup and `<style>` are changed in a single save
5 changes: 5 additions & 0 deletions packages/astro/src/vite-plugin-astro/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ export async function handleHotUpdate(
return query.container || (query.astro && query.type === 'style' && !query.inline);
});
}

// When non-style content changed (markup, frontmatter, scripts), Vite's default HMR
// invalidates the affected modules in its module graph. Clear the compile metadata so
// the load hook recompiles from disk on the next request instead of serving stale CSS.
astroFileToCompileMetadata.delete(ctx.file);
}

// Disable eslint as we're not sure how to improve this regex yet
Expand Down
112 changes: 111 additions & 1 deletion packages/astro/test/units/vite-plugin-astro/hmr.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,116 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { isStyleOnlyChanged } from '../../../dist/vite-plugin-astro/hmr.js';
import { handleHotUpdate, isStyleOnlyChanged } from '../../../dist/vite-plugin-astro/hmr.js';
import { defaultLogger } from '../test-utils.ts';

function createHmrTestContext({
file,
newCode,
modules = [],
}: {
file: string;
newCode: string;
modules?: { id: string }[];
}) {
const ctx = {
file,
read: () => Promise.resolve(newCode),
modules,
};

return ctx;
}

describe('handleHotUpdate', () => {
it('deletes compile metadata when markup and style both change', async () => {
const file = '/src/components/Target.astro';
const oldCode = '<div>old</div>\n<style>.t { --gen: 0; }</style>';
const newCode = '<div>new</div>\n<style>.t { --gen: 1; }</style>';

const astroFileToCompileMetadata = new Map();
astroFileToCompileMetadata.set(file, {
originalCode: oldCode,
css: [{ code: '.t { --gen: 0; }' }],
scripts: [],
});

const ctx = createHmrTestContext({ file, newCode });

const result = await handleHotUpdate(ctx as any, {
logger: defaultLogger,
compile: async () => ({}) as any,
astroFileToCompileMetadata,
});

// Returns undefined so Vite uses its default full-module invalidation
assert.equal(result, undefined);
// The stale metadata entry must be deleted so the load hook recompiles from disk
assert.equal(astroFileToCompileMetadata.has(file), false);
});

it('keeps metadata and eagerly recompiles when only style changes', async () => {
const file = '/src/components/Target.astro';
const oldCode = '<div>same</div>\n<style>.t { --gen: 0; }</style>';
const newCode = '<div>same</div>\n<style>.t { --gen: 1; }</style>';

const astroFileToCompileMetadata = new Map();
astroFileToCompileMetadata.set(file, {
originalCode: oldCode,
css: [{ code: '.t { --gen: 0; }' }],
scripts: [],
});

const styleModule = { id: file + '?astro&type=style&index=0&lang.css' };
const ctx = createHmrTestContext({ file, newCode, modules: [styleModule] });
const compile = async (code: string, filename: string) => {
astroFileToCompileMetadata.set(filename, {
originalCode: code,
css: [{ code: '.t { --gen: 1; }' }],
scripts: [],
});
return {};
};

const result = await handleHotUpdate(ctx as any, {
logger: defaultLogger,
compile: compile as any,
astroFileToCompileMetadata,
});

// Returns the style modules for targeted CSS HMR
assert.ok(Array.isArray(result));
assert.equal(result.length, 1);
assert.equal(result[0], styleModule);
assert.deepEqual(astroFileToCompileMetadata.get(file), {
originalCode: newCode,
css: [{ code: '.t { --gen: 1; }' }],
scripts: [],
});
});

it('deletes compile metadata when only frontmatter changes', async () => {
const file = '/src/components/Target.astro';
const oldCode = '---\nconst x = 1;\n---\n<div>same</div>\n<style>.t { color: red; }</style>';
const newCode = '---\nconst x = 2;\n---\n<div>same</div>\n<style>.t { color: red; }</style>';

const astroFileToCompileMetadata = new Map();
astroFileToCompileMetadata.set(file, {
originalCode: oldCode,
css: [{ code: '.t { color: red; }' }],
scripts: [],
});

const ctx = createHmrTestContext({ file, newCode });

await handleHotUpdate(ctx as any, {
logger: defaultLogger,
compile: async () => ({}) as any,
astroFileToCompileMetadata,
});

assert.equal(astroFileToCompileMetadata.has(file), false);
});
});

describe('isStyleOnlyChanged', () => {
it('should return false if nothing change', () => {
Expand Down
Loading