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

Overhaul HMR handling for .astro files #3932

Merged
merged 8 commits into from
Jul 22, 2022
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/popular-taxis-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Overhaul HMR handling for more stable live reload behavior
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"test": "turbo run test --output-logs=new-only --concurrency=1",
"test:match": "cd packages/astro && pnpm run test:match",
"test:templates": "turbo run test --filter=create-astro --concurrency=1",
"test:smoke": "turbo run build --filter=\"@example/*\" --filter=\"astro.build\" --filter=\"docs\" --output-logs=new-only",
"test:smoke": "turbo run build --filter=\"@example/*\" --filter=\"astro.build\" --filter=\"docs\" --output-logs=new-only --concurrency=1",
"test:vite-ci": "turbo run test --output-logs=new-only --no-deps --scope=astro --concurrency=1",
"test:e2e": "cd packages/astro && pnpm playwright install && pnpm run test:e2e",
"test:e2e:match": "cd packages/astro && pnpm playwright install && pnpm run test:e2e:match",
Expand Down
11 changes: 0 additions & 11 deletions packages/astro/src/core/render/dev/hmr.ts

This file was deleted.

14 changes: 11 additions & 3 deletions packages/astro/src/core/render/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,19 @@ export async function render(

let styles = new Set<SSRElement>();
[...stylesMap].forEach(([url, content]) => {
// The URL is only used by HMR for Svelte components
// See src/runtime/client/hmr.ts for more details
// Vite handles HMR for styles injected as scripts
scripts.add({
props: {
type: 'module',
src: url,
'data-astro-injected': true,
natemoo-re marked this conversation as resolved.
Show resolved Hide resolved
},
children: '',
});
// But we still want to inject the styles to avoid FOUC
styles.add({
props: {
'data-astro-injected': svelteStylesRE.test(url) ? url : true,
'data-astro-injected': url,
},
children: content,
});
Expand Down
110 changes: 3 additions & 107 deletions packages/astro/src/runtime/client/hmr.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,8 @@
/// <reference types="vite/client" />
if (import.meta.hot) {
import.meta.hot.accept((mod) => mod);

const parser = new DOMParser();

const KNOWN_MANUAL_HMR_EXTENSIONS = new Set(['.astro', '.md', '.mdx']);
function needsManualHMR(path: string) {
for (const ext of KNOWN_MANUAL_HMR_EXTENSIONS.values()) {
if (path.endsWith(ext)) return true;
}
return false;
}

async function updatePage() {
const { default: diff } = await import('micromorph');
const html = await fetch(`${window.location}`).then((res) => res.text());
const doc = parser.parseFromString(html, 'text/html');
for (const style of sheetsMap.values()) {
doc.head.appendChild(style);
}
// Match incoming islands to current state
for (const root of doc.querySelectorAll('astro-island')) {
const uid = root.getAttribute('uid');
const current = document.querySelector(`astro-island[uid="${uid}"]`);
if (current) {
current.setAttribute('data-persist', '');
root.replaceWith(current);
}
}
// both Vite and Astro's HMR scripts include `type="text/css"` on injected
// <style> blocks. These style blocks would not have been rendered in Astro's
// build and need to be persisted when diffing HTML changes.
for (const style of document.querySelectorAll("style[type='text/css']")) {
style.setAttribute('data-persist', '');
doc.head.appendChild(style.cloneNode(true));
}
return diff(document, doc).then(() => {
// clean up data-persist attributes added before diffing
for (const root of document.querySelectorAll('astro-island[data-persist]')) {
root.removeAttribute('data-persist');
}
for (const style of document.querySelectorAll("style[type='text/css'][data-persist]")) {
style.removeAttribute('data-persist');
}
});
}
async function updateAll(files: any[]) {
let hasManualUpdate = false;
let styles = [];
for (const file of files) {
if (needsManualHMR(file.acceptedPath)) {
hasManualUpdate = true;
continue;
}
if (file.acceptedPath.includes('svelte&type=style')) {
import.meta.hot.on('vite:beforeUpdate', async (payload) => {
for (const file of payload.updates) {
if (file.acceptedPath.includes('svelte&type=style') || file.acceptedPath.includes('astro&type=style')) {
// This will only be called after the svelte component has hydrated in the browser.
// At this point Vite is tracking component style updates, we need to remove
// styles injected by Astro for the component in favor of Vite's internal HMR.
Expand All @@ -70,59 +19,6 @@ if (import.meta.hot) {
link.replaceWith(link.cloneNode(true));
}
}
if (file.acceptedPath.includes('astro&type=style')) {
styles.push(
fetch(file.acceptedPath)
.then((res) => res.text())
.then((res) => [file.acceptedPath, res])
);
}
}
if (styles.length > 0) {
for (const [id, content] of await Promise.all(styles)) {
updateStyle(id, content);
}
}
if (hasManualUpdate) {
return await updatePage();
}
}
import.meta.hot.on('vite:beforeUpdate', async (event) => {
await updateAll(event.updates);
});
}

const sheetsMap = new Map();

function updateStyle(id: string, content: string): void {
let style = sheetsMap.get(id);
if (style && !(style instanceof HTMLStyleElement)) {
removeStyle(id);
style = undefined;
}

if (!style) {
style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.innerHTML = content;
document.head.appendChild(style);
} else {
style.innerHTML = content;
}
sheetsMap.set(id, style);
}

function removeStyle(id: string): void {
const style = sheetsMap.get(id);
if (style) {
if (style instanceof CSSStyleSheet) {
// @ts-expect-error: using experimental API
document.adoptedStyleSheets = document.adoptedStyleSheets.filter(
(s: CSSStyleSheet) => s !== style
);
} else {
document.head.removeChild(style);
}
sheetsMap.delete(id);
}
}
29 changes: 0 additions & 29 deletions packages/astro/src/vite-plugin-astro-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,31 +350,6 @@ async function handleRequest(
}
}

/**
* Vite HMR sends requests for new CSS and those get returned as JS, but we want it to be CSS
* since they are inside of a link tag for Astro.
*/
const forceTextCSSForStylesMiddleware: vite.Connect.NextHandleFunction = function (req, res, next) {
if (req.url) {
// We are just using this to parse the URL to get the search params object
// so the second arg here doesn't matter
const url = new URL(req.url, 'https://astro.build');
// lang.css is a search param that exists on Astro, Svelte, and Vue components.
// We only want to override for astro files.
if (url.searchParams.has('astro') && url.searchParams.has('lang.css')) {
// Override setHeader so we can set the correct content-type for this request.
const setHeader = res.setHeader;
res.setHeader = function (key, value) {
if (key.toLowerCase() === 'content-type') {
return setHeader.call(this, key, 'text/css');
}
return setHeader.apply(this, [key, value]);
};
}
}
next();
};

export default function createPlugin({ config, logging }: AstroPluginOptions): vite.Plugin {
return {
name: 'astro:server',
Expand All @@ -396,10 +371,6 @@ export default function createPlugin({ config, logging }: AstroPluginOptions): v
removeViteHttpMiddleware(viteServer.middlewares);

// Push this middleware to the front of the stack so that it can intercept responses.
viteServer.middlewares.stack.unshift({
route: '',
handle: forceTextCSSForStylesMiddleware,
});
if (config.base !== '/') {
viteServer.middlewares.stack.unshift({
route: '',
Expand Down
17 changes: 14 additions & 3 deletions packages/astro/src/vite-plugin-astro/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { PluginContext as RollupPluginContext, ResolvedId } from 'rollup';
import type { HmrContext, ModuleNode, ViteDevServer } from 'vite';
import type { AstroConfig } from '../@types/astro';
import type { LogOptions } from '../core/logger/core.js';
import { fileURLToPath } from 'node:url';
import { info } from '../core/logger/core.js';
import * as msg from '../core/messages.js';
import { invalidateCompilation, isCached } from './compile.js';
Expand Down Expand Up @@ -49,21 +50,31 @@ export async function trackCSSDependencies(
}
}

const PKG_PREFIX = new URL('../../', import.meta.url)
const isPkgFile = (id: string|null) => {
return id?.startsWith(fileURLToPath(PKG_PREFIX)) || id?.startsWith(PKG_PREFIX.pathname)
}

export async function handleHotUpdate(ctx: HmrContext, config: AstroConfig, logging: LogOptions) {
// Invalidate the compilation cache so it recompiles
invalidateCompilation(config, ctx.file);

// Skip monorepo files to avoid console spam
if (isPkgFile(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) {
// This is always the HMR script, we skip it to avoid spamming
// the browser console with HMR updates about this file
if (mod.id?.endsWith('.astro?html-proxy&index=0.js')) {
// Skip monorepo files to avoid console spam
if (isPkgFile(mod.id ?? mod.file)) {
filtered.delete(mod);
continue;
}

if (mod.file && isCached(config, mod.file)) {
filtered.add(mod);
files.add(mod.file);
Expand Down
9 changes: 5 additions & 4 deletions packages/astro/src/vite-plugin-astro/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,16 +268,17 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
SUFFIX += `import "${id}?astro&type=script&index=${i}&lang.ts";`;
i++;
}

SUFFIX += `\nif (import.meta.hot) {
import.meta.hot.accept(mod => mod);
}`;
}
// Add handling to inject scripts into each page JS bundle, if needed.
if (isPage) {
SUFFIX += `\nimport "${PAGE_SSR_SCRIPT_ID}";`;
}

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

const astroMetadata: AstroPluginMetadata['astro'] = {
clientOnlyComponents: transformResult.clientOnlyComponents,
hydratedComponents: transformResult.hydratedComponents,
Expand Down
11 changes: 0 additions & 11 deletions packages/astro/test/fixtures/hmr-css/src/pages/index.astro

This file was deleted.

34 changes: 0 additions & 34 deletions packages/astro/test/hmr-css.test.js

This file was deleted.

3 changes: 2 additions & 1 deletion packages/astro/test/postcss.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import * as cheerio from 'cheerio';
import eol from 'eol';
import { loadFixture } from './test-utils.js';

describe('PostCSS', () => {
describe('PostCSS', function () {
const PREFIXED_CSS = `{-webkit-appearance:none;appearance:none`;

let fixture;
let bundledCSS;
before(async () => {
this.timeout(45000); // test needs a little more time in CI
fixture = await loadFixture({
root: './fixtures/postcss',
});
Expand Down