Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve Astro style HMR for imported styles #10166

Merged
merged 4 commits into from
Feb 22, 2024
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/little-trains-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"astro": patch
---

Improves Astro style tag HMR when updating imported styles
11 changes: 11 additions & 0 deletions packages/astro/e2e/fixtures/hmr/src/pages/css-external.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<html>
<head>
<title>Test</title>
</head>
<body>
<h1 class="css-external">This is blue</h1>
<style>
@import "../styles/css-external.css";
</style>
</body>
</html>
3 changes: 3 additions & 0 deletions packages/astro/e2e/fixtures/hmr/src/styles/css-external.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.css-external {
color: blue;
}
29 changes: 27 additions & 2 deletions packages/astro/e2e/hmr.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,18 @@ const test = testFactory({

let devServer;

function throwPageShouldNotReload() {
throw new Error('Page should not reload in HMR');
}

test.beforeAll(async ({ astro }) => {
devServer = await astro.startDevServer();
});

test.afterEach(({ page }) => {
page.off('load', throwPageShouldNotReload);
});

test.afterAll(async () => {
await devServer.stop();
});
Expand All @@ -33,15 +41,32 @@ test.describe('Scripts with dependencies', () => {
});
});

test.describe('Styles with dependencies', () => {
test('refresh with HMR', async ({ page, astro }) => {
test.describe('Styles', () => {
test('dependencies cause refresh with HMR', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/css-dep'));

page.once('load', throwPageShouldNotReload);

const h = page.locator('h1');
await expect(h).toHaveCSS('color', 'rgb(0, 0, 255)');

await astro.editFile('./src/styles/vars.scss', (original) => original.replace('blue', 'red'));

await expect(h).toHaveCSS('color', 'rgb(255, 0, 0)');
});

test('external CSS refresh with HMR', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/css-external'));

page.once('load', throwPageShouldNotReload);

const h = page.locator('h1');
await expect(h).toHaveCSS('color', 'rgb(0, 0, 255)');

await astro.editFile('./src/styles/css-external.css', (original) =>
original.replace('blue', 'red')
);

await expect(h).toHaveCSS('color', 'rgb(255, 0, 0)');
});
});
24 changes: 18 additions & 6 deletions packages/astro/src/core/compile/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,16 @@ export interface CompileProps {
source: string;
}

export interface CompileResult extends TransformResult {
cssDeps: Set<string>;
source: string;
export interface CompileCssResult {
code: string;
/**
* The dependencies of the transformed CSS (Normalized paths)
*/
dependencies?: string[];
}

export interface CompileResult extends Omit<TransformResult, 'css'> {
css: CompileCssResult[];
}

export async function compile({
Expand All @@ -32,7 +39,10 @@ export async function compile({
filename,
source,
}: CompileProps): Promise<CompileResult> {
const cssDeps = new Set<string>();
// Because `@astrojs/compiler` can't return the dependencies for each style transformed,
// we need to use an external array to track the dependencies whenever preprocessing is called,
// and we'll rebuild the final `css` result after transformation.
const cssDeps: CompileCssResult['dependencies'][] = [];
const cssTransformErrors: AstroError[] = [];
let transformResult: TransformResult;

Expand Down Expand Up @@ -82,8 +92,10 @@ export async function compile({

return {
...transformResult,
cssDeps,
source,
css: transformResult.css.map((code, i) => ({
code,
dependencies: cssDeps[i],
})),
};
}

Expand Down
16 changes: 10 additions & 6 deletions packages/astro/src/core/compile/style.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { TransformOptions } from '@astrojs/compiler';
import fs from 'node:fs';
import { preprocessCSS, type ResolvedConfig } from 'vite';
import { normalizePath, preprocessCSS, type ResolvedConfig } from 'vite';
import { AstroErrorData, CSSError, positionAt } from '../errors/index.js';
import type { CompileCssResult } from './compile.js';

export function createStylePreprocessor({
filename,
Expand All @@ -11,18 +12,21 @@ export function createStylePreprocessor({
}: {
filename: string;
viteConfig: ResolvedConfig;
cssDeps: Set<string>;
cssDeps: CompileCssResult['dependencies'][];
cssTransformErrors: Error[];
}): TransformOptions['preprocessStyle'] {
let processedStylesCount = 0;

return async (content, attrs) => {
const index = processedStylesCount++;
const lang = `.${attrs?.lang || 'css'}`.toLowerCase();
const id = `${filename}?astro&type=style&lang${lang}`;
const id = `${filename}?astro&type=style&index=${index}&lang${lang}`;
try {
const result = await preprocessCSS(content, id, viteConfig);

result.deps?.forEach((dep) => {
cssDeps.add(dep);
});
if (result.deps) {
cssDeps[index] = [...result.deps].map((dep) => normalizePath(dep));
bluwy marked this conversation as resolved.
Show resolved Hide resolved
}

let map: string | undefined;
if (result.map) {
Expand Down
67 changes: 23 additions & 44 deletions packages/astro/src/vite-plugin-astro/hmr.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,45 @@
import path from 'node:path';
import { appendForwardSlash } from '@astrojs/internal-helpers/path';
import type { HmrContext } from 'vite';
import type { Logger } from '../core/logger/core.js';
import type { CompileAstroResult } from './compile.js';
import type { CompileMetadata } from './types.js';
import { frontmatterRE } from './utils.js';
import type { CompileMetadata } from './types.js';

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

export async function handleHotUpdate(
ctx: HmrContext,
{ logger, compile, astroFileToCssAstroDeps, astroFileToCompileMetadata }: HandleHotUpdateOptions
{ logger, astroFileToCompileMetadata }: HandleHotUpdateOptions
) {
const oldCode = astroFileToCompileMetadata.get(ctx.file)?.originalCode;
const newCode = await ctx.read();
// HANDLING 1: Invalidate compile metadata if CSS dependency updated
//
// If any `ctx.file` is part of a CSS dependency of any Astro file, invalidate its `astroFileToCompileMetadata`
// so the next transform of the Astro file or Astro script/style virtual module will re-generate it
for (const [astroFile, compileData] of astroFileToCompileMetadata) {
const isUpdatedFileCssDep = compileData.css.some((css) => css.dependencies?.includes(ctx.file));
if (isUpdatedFileCssDep) {
astroFileToCompileMetadata.delete(astroFile);
}
}

// HANDLING 2: Only invalidate Astro style virtual module if only style tags changed
//
// If only the style code has changed, e.g. editing the `color`, then we can directly invalidate
// the Astro CSS virtual modules only. The main Astro module's JS result will be the same and doesn't
// need to be invalidated.
if (oldCode && isStyleOnlyChanged(oldCode, newCode)) {
const oldCode = astroFileToCompileMetadata.get(ctx.file)?.originalCode;
if (oldCode == null) return;
const newCode = await ctx.read();

if (isStyleOnlyChanged(oldCode, newCode)) {
logger.debug('watch', 'style-only change');
// Re-compile the main Astro component (even though we know its JS result will be the same)
// so that `astroFileToCompileMetadata` gets a fresh set of compile metadata to be used
// by the virtual modules later in the `load()` hook.
await compile(newCode, ctx.file);
// Invalidate its `astroFileToCompileMetadata` so that the next transform of Astro style virtual module
// will re-generate it
astroFileToCompileMetadata.delete(ctx.file);
// Only return the Astro styles that have changed!
return ctx.modules.filter((mod) => mod.id?.includes('astro&type=style'));
}

// Edge case handling usually caused by Tailwind creating circular dependencies
//
// TODO: we can also workaround this with better CSS dependency management for Astro files,
// so that changes within style tags are scoped to itself. But it'll take a bit of work.
// https://github.com/withastro/astro/issues/9370#issuecomment-1850160421
for (const [astroFile, cssAstroDeps] of astroFileToCssAstroDeps) {
// If the `astroFile` has a CSS dependency on `ctx.file`, there's a good chance this causes a
// circular dependency, which Vite doesn't issue a full page reload. Workaround it by forcing a
// full page reload ourselves. (Vite bug)
// https://github.com/vitejs/vite/pull/15585
if (cssAstroDeps.has(ctx.file)) {
// Mimic the HMR log as if this file is updated
logger.info('watch', getShortName(ctx.file, ctx.server.config.root));
// Invalidate the modules of `astroFile` explicitly as Vite may incorrectly soft-invalidate
// the parent if the parent actually imported `ctx.file`, but `this.addWatchFile` was also called
// on `ctx.file`. Vite should do a hard-invalidation instead. (Vite bug)
const parentModules = ctx.server.moduleGraph.getModulesByFile(astroFile);
if (parentModules) {
for (const mod of parentModules) {
ctx.server.moduleGraph.invalidateModule(mod);
}
}
ctx.server.hot.send({ type: 'full-reload', path: '*' });
}
}
}

// Disable eslint as we're not sure how to improve this regex yet
Expand Down Expand Up @@ -112,7 +95,3 @@ function isArrayEqual(a: any[], b: any[]) {
}
return true;
}

function getShortName(file: string, root: string): string {
return file.startsWith(appendForwardSlash(root)) ? path.posix.relative(root, file) : file;
}
71 changes: 22 additions & 49 deletions packages/astro/src/vite-plugin-astro/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { normalizeFilename } from '../vite-plugin-utils/index.js';
import { compileAstro, type CompileAstroResult } from './compile.js';
import { handleHotUpdate } from './hmr.js';
import { parseAstroRequest } from './query.js';
import { loadId } from './utils.js';
export { getAstroMetadata } from './metadata.js';
export type { AstroPluginMetadata };

Expand All @@ -24,14 +25,10 @@ export default function astro({ settings, logger }: AstroPluginOptions): vite.Pl
const { config } = settings;
let server: vite.ViteDevServer | undefined;
let compile: (code: string, filename: string) => Promise<CompileAstroResult>;
// Tailwind styles could register Astro files as dependencies of other Astro files,
// causing circular imports which trips Vite's HMR. This set is passed to `handleHotUpdate`
// to force a page reload when these dependency files are updated
// NOTE: We need to initialize a map here and in `buildStart` because our unit tests don't
// call `buildStart` (test bug)
let astroFileToCssAstroDeps = new Map<string, Set<string>>();
// Each Astro file has its own compile metadata so that its scripts and styles virtual module
// can retrieve their code from here.
// NOTE: We need to initialize a map here and in `buildStart` because our unit tests don't
// call `buildStart` (test bug)
let astroFileToCompileMetadata = new Map<string, CompileMetadata>();

// Variables for determining if an id starts with /src...
Expand Down Expand Up @@ -65,7 +62,6 @@ export default function astro({ settings, logger }: AstroPluginOptions): vite.Pl
});
},
buildStart() {
astroFileToCssAstroDeps = new Map();
astroFileToCompileMetadata = new Map();

// Share the `astroFileToCompileMetadata` across the same Astro config as Astro performs
Expand All @@ -86,10 +82,19 @@ export default function astro({ settings, logger }: AstroPluginOptions): vite.Pl

// Astro scripts and styles virtual module code comes from the main Astro compilation
// through the metadata from `astroFileToCompileMetadata`. It should always exist as Astro
// modules are compiled first, then its virtual modules. If the virtual modules are somehow
// compiled first, throw an error and we should investigate it.
// modules are compiled first, then its virtual modules.
const filename = normalizePath(normalizeFilename(parsedId.filename, config.root));
const compileMetadata = astroFileToCompileMetadata.get(filename);
let compileMetadata = astroFileToCompileMetadata.get(filename);
// If `compileMetadata` doesn't exist in dev, that means the virtual module may have been invalidated.
// We try to re-compile the main Astro module (`filename`) first before retrieving the metadata again.
if (!compileMetadata && server) {
const code = await loadId(server.pluginContainer, filename);
// `compile` should re-set `filename` in `astroFileToCompileMetadata`
if (code != null) await compile(code, filename);
compileMetadata = astroFileToCompileMetadata.get(filename);
bluwy marked this conversation as resolved.
Show resolved Hide resolved
}
// If the metadata still doesn't exist, that means the virtual modules are somehow compiled first,
// throw an error and we should investigate it.
if (!compileMetadata) {
throw new Error(
`No cached compile metadata found for "${id}". The main Astro module "${filename}" should have ` +
Expand All @@ -103,12 +108,15 @@ export default function astro({ settings, logger }: AstroPluginOptions): vite.Pl
throw new Error(`Requests for Astro CSS must include an index.`);
}

const code = compileMetadata.css[query.index];
if (!code) {
const result = compileMetadata.css[query.index];
if (!result) {
throw new Error(`No Astro CSS at index ${query.index}`);
}

return { code };
// Register dependencies from preprocessing this style
result.dependencies?.forEach((dep) => this.addWatchFile(dep));

return { code: result.code };
}
case 'script': {
if (typeof query.index === 'undefined') {
Expand Down Expand Up @@ -174,34 +182,6 @@ export default function astro({ settings, logger }: AstroPluginOptions): vite.Pl
const filename = normalizePath(parsedId.filename);
const transformResult = await compile(source, filename);

// Register dependencies of this module
const astroDeps = new Set<string>();
for (const dep of transformResult.cssDeps) {
if (dep.endsWith('.astro')) {
astroDeps.add(dep);
}
this.addWatchFile(dep);
}
astroFileToCssAstroDeps.set(id, astroDeps);

// When a dependency from the styles are updated, the dep and Astro module will get invalidated.
// However, the Astro style virtual module is not invalidated because we didn't register that the virtual
// module has that dependency. We currently can't do that either because of a Vite bug.
// https://github.com/vitejs/vite/pull/15608
// Here we manually invalidate the virtual modules ourselves when we're compiling the Astro module.
// When that bug is resolved, we can add the dependencies to the virtual module directly and remove this.
if (server) {
const mods = server.moduleGraph.getModulesByFile(filename);
if (mods) {
const seen = new Set(mods);
for (const mod of mods) {
if (mod.url.includes('?astro')) {
server.moduleGraph.invalidateModule(mod, seen);
}
}
}
}

const astroMetadata: AstroPluginMetadata['astro'] = {
clientOnlyComponents: transformResult.clientOnlyComponents,
hydratedComponents: transformResult.hydratedComponents,
Expand All @@ -225,14 +205,7 @@ export default function astro({ settings, logger }: AstroPluginOptions): vite.Pl
};
},
async handleHotUpdate(ctx) {
if (!ctx.file.endsWith('.astro')) return;

return handleHotUpdate(ctx, {
logger,
compile,
astroFileToCssAstroDeps,
astroFileToCompileMetadata,
});
return handleHotUpdate(ctx, { logger, astroFileToCompileMetadata });
},
};

Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/vite-plugin-astro/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { HoistedScript, TransformResult } from '@astrojs/compiler';
import type { PropagationHint } from '../@types/astro.js';
import type { CompileCssResult } from '../core/compile/compile.js';

export interface PageOptions {
prerender?: boolean;
Expand All @@ -20,7 +21,7 @@ export interface CompileMetadata {
/** Used for HMR to compare code changes */
originalCode: string;
/** For Astro CSS virtual module */
css: string[];
css: CompileCssResult[];
/** For Astro hoisted scripts virtual module */
scripts: HoistedScript[];
}
Loading
Loading