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

Simplify HMR for circular imports and CSS #9706

Merged
merged 3 commits into from
Jan 17, 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/curvy-seas-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@astrojs/mdx": patch
---

Removes redundant HMR handling code
5 changes: 5 additions & 0 deletions .changeset/tidy-planets-whisper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"astro": patch
---

Simplifies HMR handling, improves circular dependency invalidation, and fixes Astro styles invalidation
1 change: 1 addition & 0 deletions packages/astro/e2e/fixtures/tailwindcss/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default defineConfig({
integrations: [
tailwind({
configFile: fileURLToPath(new URL('./tailwind.config.js', import.meta.url)),
applyBaseStyles: false
}),
],
devToolbar: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Astro + TailwindCSS</title>
</head>
<body class="bg-dawn text-midnight">
<slot/>
</body>
</html>

<style is:global>
@tailwind base;

@tailwind utilities;
</style>
17 changes: 5 additions & 12 deletions packages/astro/e2e/fixtures/tailwindcss/src/pages/index.astro
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
---
// Component Imports
import Layout from '../components/Layout.astro';
import Button from '../components/Button.astro';
import Complex from '../components/Complex.astro';
---

<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Astro + TailwindCSS</title>
</head>

<body class="bg-dawn text-midnight">
<Button>I’m a Tailwind Button!</Button>
<Complex />
</body>
</html>
<Layout>
<Button>I’m a Tailwind Button!</Button>
<Complex />
</Layout>
2 changes: 1 addition & 1 deletion packages/astro/src/core/logger/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { isLogLevelEnabled, type Logger as AstroLogger } from './core.js';

const PKG_PREFIX = fileURLToPath(new URL('../../../', import.meta.url));
const E2E_PREFIX = fileURLToPath(new URL('../../../e2e', import.meta.url));
export function isAstroSrcFile(id: string | null) {
function isAstroSrcFile(id: string | null) {
return id?.startsWith(PKG_PREFIX) && !id.startsWith(E2E_PREFIX);
}

Expand Down
5 changes: 0 additions & 5 deletions packages/astro/src/vite-plugin-astro/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,6 @@ export async function cachedFullCompilation({
}
}

// Prefer live reload to HMR in `.astro` files
if (!compileProps.viteConfig.isProduction) {
SUFFIX += `\nif (import.meta.hot) { import.meta.hot.decline() }`;
}

return {
...transformResult,
code: esbuildResult.code + SUFFIX,
Expand Down
95 changes: 35 additions & 60 deletions packages/astro/src/vite-plugin-astro/hmr.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import type { HmrContext, ModuleNode } from 'vite';
import path from 'node:path';
import { appendForwardSlash } from '@astrojs/internal-helpers/path';
import type { HmrContext } from 'vite';
import type { AstroConfig } from '../@types/astro.js';
import type { cachedCompilation } from '../core/compile/index.js';
import { invalidateCompilation, isCached, type CompileResult } from '../core/compile/index.js';
import type { Logger } from '../core/logger/core.js';
import { isAstroSrcFile } from '../core/logger/vite.js';
import { isAstroScript } from './query.js';

export interface HandleHotUpdateOptions {
config: AstroConfig;
logger: Logger;
astroFileToCssAstroDeps: Map<string, Set<string>>;

compile: () => ReturnType<typeof cachedCompilation>;
source: string;
}

export async function handleHotUpdate(
ctx: HmrContext,
{ config, logger, compile, source }: HandleHotUpdateOptions
{ config, logger, astroFileToCssAstroDeps, compile, source }: HandleHotUpdateOptions
) {
let isStyleOnlyChange = false;
if (ctx.file.endsWith('.astro') && isCached(config, ctx.file)) {
Expand All @@ -34,75 +35,45 @@ export async function handleHotUpdate(
invalidateCompilation(config, ctx.file);
}

// Skip monorepo files to avoid console spam
if (isAstroSrcFile(ctx.file)) {
return;
}

// go through each of these modules importers and invalidate any .astro compilation
// that needs to be rerun.
const filtered = new Set<ModuleNode>(ctx.modules);
const files = new Set<string>();
for (const mod of ctx.modules) {
// Skip monorepo files to avoid console spam
if (isAstroSrcFile(mod.id ?? mod.file)) {
filtered.delete(mod);
continue;
}

if (mod.file && isCached(config, mod.file)) {
filtered.add(mod);
files.add(mod.file);
}

for (const imp of mod.importers) {
if (imp.file && isCached(config, imp.file)) {
filtered.add(imp);
files.add(imp.file);
}
}
}

// Invalidate happens as a separate step because a single .astro file
// produces multiple CSS modules and we want to return all of those.
for (const file of files) {
if (isStyleOnlyChange && file === ctx.file) continue;
invalidateCompilation(config, file);
// If `ctx.file` is depended by an .astro file, e.g. via `this.addWatchFile`,
// Vite doesn't trigger updating that .astro file by default. See:
// https://github.com/vitejs/vite/issues/3216
// For now, we trigger the change manually here.
if (file.endsWith('.astro')) {
ctx.server.moduleGraph.onFileChange(file);
}
}

// Bugfix: sometimes style URLs get normalized and end with `lang.css=`
// These will cause full reloads, so filter them out here
const mods = [...filtered].filter((m) => !m.url.endsWith('='));

// If only styles are changed, remove the component file from the update list
if (isStyleOnlyChange) {
logger.debug('watch', 'style-only change');
// Only return the Astro styles that have changed!
return mods.filter((mod) => mod.id?.includes('astro&type=style'));
return ctx.modules.filter((mod) => mod.id?.includes('astro&type=style'));
}

// Add hoisted scripts so these get invalidated
for (const mod of mods) {
for (const imp of mod.importedModules) {
if (imp.id && isAstroScript(imp.id)) {
mods.push(imp);
// 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.ws.send({ type: 'full-reload', path: '*' });
}
}

return mods;
}

function isStyleOnlyChanged(oldResult: CompileResult, newResult: CompileResult) {
return (
normalizeCode(oldResult.code) === normalizeCode(newResult.code) &&
// If style tags are added/removed, we need to regenerate the main Astro file
// so that its CSS imports are also added/removed
oldResult.css.length === newResult.css.length &&
!isArrayEqual(oldResult.css, newResult.css)
);
}
Expand All @@ -129,3 +100,7 @@ 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;
}
41 changes: 40 additions & 1 deletion 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 {
cachedCompilation,
getCachedCompileResult,
type CompileProps,
invalidateCompilation,
} from '../core/compile/index.js';
import { isRelativePath } from '../core/path.js';
import { normalizeFilename } from '../vite-plugin-utils/index.js';
Expand All @@ -27,7 +28,13 @@ interface AstroPluginOptions {
export default function astro({ settings, logger }: AstroPluginOptions): vite.Plugin[] {
const { config } = settings;
let resolvedConfig: vite.ResolvedConfig;
let server: vite.ViteDevServer;
let server: vite.ViteDevServer | undefined;
// 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>>();

// Variables for determining if an id starts with /src...
const srcRootWeb = config.srcDir.pathname.slice(config.root.pathname.length - 1);
Expand All @@ -42,6 +49,9 @@ export default function astro({ settings, logger }: AstroPluginOptions): vite.Pl
configureServer(_server) {
server = _server;
},
buildStart() {
astroFileToCssAstroDeps = new Map();
},
async load(id, opts) {
const parsedId = parseAstroRequest(id);
const query = parsedId.query;
Expand Down Expand Up @@ -157,11 +167,39 @@ export default function astro({ settings, logger }: AstroPluginOptions): vite.Pl
source,
};

// We invalidate and then compile again as we know Vite will only call this `transform`
// when its cache is invalidated.
// TODO: Do the compilation directly and remove our cache so we rely on Vite only.
invalidateCompilation(config, compileProps.filename);
const transformResult = await cachedFullCompilation({ compileProps, logger });

// 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(compileProps.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,
Expand Down Expand Up @@ -200,6 +238,7 @@ export default function astro({ settings, logger }: AstroPluginOptions): vite.Pl
return handleHotUpdate(context, {
config,
logger,
astroFileToCssAstroDeps,
compile,
source,
});
Expand Down
5 changes: 0 additions & 5 deletions packages/astro/src/vite-plugin-astro/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,3 @@ export function parseAstroRequest(id: string): ParsedRequestResult {
query,
};
}

export function isAstroScript(id: string): boolean {
const parsed = parseAstroRequest(id);
return parsed.query.type === 'script';
}
5 changes: 0 additions & 5 deletions packages/astro/test/units/vite-plugin-astro/compile.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,6 @@ const name = 'world
expect(result).to.be.undefined;
});

it('injects hmr code', async () => {
const result = await compile(`<h1>Hello World</h1>`, '/src/components/index.astro');
expect(result.code).to.include('import.meta.hot');
});

it('has file and url exports for markdwon compat', async () => {
const result = await compile(`<h1>Hello World</h1>`, '/src/components/index.astro');
await init;
Expand Down
16 changes: 2 additions & 14 deletions packages/integrations/mdx/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,8 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
name: '@astrojs/mdx',
hooks: {
'astro:config:setup': async (params) => {
const {
updateConfig,
config,
addPageExtension,
addContentEntryType,
command,
addRenderer,
} = params as SetupHookParams;
const { updateConfig, config, addPageExtension, addContentEntryType, addRenderer } =
params as SetupHookParams;

addRenderer(astroJSXRenderer);
addPageExtension('.mdx');
Expand Down Expand Up @@ -209,12 +203,6 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
code += `\nContent[Symbol.for('astro.needsHeadRendering')] = !Boolean(frontmatter.layout);`;
code += `\nContent.moduleId = ${JSON.stringify(id)};`;

if (command === 'dev') {
// TODO: decline HMR updates until we have a stable approach
code += `\nif (import.meta.hot) {
import.meta.hot.decline();
}`;
}
return { code, map: null };
},
},
Expand Down